ArtiGate is a dependency mirror for one-way data-diode networks. It mirrors
Go modules, Python (PyPI) wheels and opt-in sdists, Java (Maven) artifacts,
NPM packages, Rust crates, Terraform/OpenTofu providers and modules, Helm
charts, NuGet packages, APT (.deb), RPM (.rpm), and Alpine (.apk)
repositories, Conda channels, Ruby gems, PHP Composer packages, VS Code
extensions (from Open VSX), Ansible Galaxy collections, R packages (CRAN),
Snap packages (with their store assertions, ready for snap ack +
snap install), raw git repositories, container images (Docker/OCI,
linux/amd64), AI models
from Hugging Face (GGUF for Ollama, plus full safetensors repositories), and
OSV vulnerability-advisory databases from the internet into an air-gapped
network, and serves them there in each ecosystem's native format — so the
air-gapped side can not only build against mirrored dependencies but also
audit them.
One binary, two modes:
low— runs on the internet side. From its web dashboard you give it a spec (ago.modor module list, a Python requirements list, Maven coordinates, apackage.jsonor NPM package list, a crate/provider/chart/NuGet list, an APT source, a.repo, an Alpine repositories file, a conda channel and package list, a gem/Composer/extension/collection/CRAN/snap package list, a git clone URL, a list of container images, a list of Hugging Face model references, or a list of OSV ecosystem names); it fetches the artifacts from upstream and writes signed, numbered bundle files.high— runs air-gapped. It imports the bundles (in order, verifying every signature and hash) and serves them as a GOPROXY, a PyPI index, a Maven 2 repository, an NPM registry (includingnpm audit, answered from the mirrored OSV data), a cargo sparse registry, a Terraform/OpenTofu provider+module registry, Helm repositories, a NuGet v3 feed, APT/RPM/Alpine repositories, conda channels, a RubyGems compact-index source, a Composer repository, a VS Code extension gallery, an Ansible Galaxy v3 API, a CRAN mirror, a Snap download mirror (.snap+.assertpairs for snapd's offline install flow), read-only git repositories (dumb HTTP), a read-only OCI container registry, an Ollama-compatible model registry with a Hugging Face Hub download API, and an OSV advisory feed for offline vulnerability scanners.
spec ──▶ [ low ] ──▶ signed bundles ──▶ ((diode)) ──▶ [ high ] ──▶ clients
fetch + sign carry across verify + serve
Each ecosystem is an independently numbered stream, so a stalled or missing bundle in one never blocks the others. The high side never trusts transferred metadata: it verifies every byte against the signed manifest and regenerates all repository indexes from the artifacts actually present.
Full documentation lives at https://define42.github.io/ArtiGate/.
Brings up a low + high stack wired together over the HTTP diode transport (the
low side uploads each bundle to the high side's /diode ingest endpoint), with
the signing keys generated automatically. The stack refuses to start until an
operator login and a random diode token are configured:
cp .env.example .env
go run ./cmd/artigate hashpw --user admin # paste into ARTIGATE_LOW_AUTH
openssl rand -hex 32 # paste the output into ARTIGATE_DIODE_TOKEN
make run # foreground (make run-detach to background)
make stop # stop, keep state make reset wipe stateThen open the low-side dashboard at http://localhost:8080/, pick an ecosystem,
enter a spec (or upload a go.mod), and click Collect & export. Watch it
appear on the high-side dashboard at http://localhost:8081/, then point a client
at the high side (see below). Both published ports are loopback-only by default;
terminate TLS in a reverse proxy before deliberately exposing either one.
go build -o artigate ./cmd/artigate # or: make buildCI publishes a container image on every push to main:
ghcr.io/define42/artigate (tags: latest, the commit SHA, and a
semver tag). The image bundles the fetch toolchains the low side shells out to
(go/git, pip, mvn + JDK, npm, gpgv, xz); a high-only deployment
needs none of them except gnupg when signing served APT/RPM repos.
./artigate keygen --private low.ed25519 --public high.ed25519.pubKeep the private key on the low side only; install the public key on the high side.
./artigate low \
--listen :8080 \
--root /var/lib/artigate-low \
--export-dir /var/spool/diode-out \
--private-key /etc/artigate/low.ed25519 \
--upstream-goproxy https://proxy.golang.org,direct \
--goprivate github.com/your-org/*Everything is driven from the dashboard at http://<low-host>:8080/ — one page
per ecosystem. Each collect fetches from upstream and writes a signed bundle to
the export directory (three files per bundle: .tar.gz, .manifest.json,
.manifest.json.sig).
Fetching uses the host's normal tools and credentials (go/git, pip, mvn,
npm, gpgv). Private Go modules can authenticate with a one-time login on the
collect (the Go page's Private module host login fields / the auth field) or
a standing host=user:password entry in ARTIGATE_GO_AUTH — ArtiGate
injects it into go/git for that collect and adds the host to GOPRIVATE;
alternatively, configure the service user's Git/SSH before starting.
--gotoolchain (default auto) lets go download a newer toolchain when a
module requires one. The configuration
reference lists every flag
and environment variable.
-
Go — list modules to fetch (
module@version, or a baremodule/module@latestfor the newest), or upload a project'sgo.mod(and optionalgo.sum) to mirror exactly what it builds. The full dependency graph is always fetched. -
Python — a requirements list (paste or upload
requirements.txt). Wheels by default, enforced with--only-binary=:all:for every collect so package build hooks never run beside the signing key. Packages that publish no wheel can be opted into source distributions via the request'ssdistslist — those are fetched straight from the index's JSON API (never through pip, so still no build hooks) and verified against the API-declared SHA-256; clients build them locally exactly as they would against PyPI. An optional cross-target downloads wheels for the high-side interpreter/platform rather than the low-side host. PEP 740 provenance documents published by the index (attested trusted-publisher uploads) mirror automatically and serve through/integrity/…plus the simple JSONprovenancekey. -
Java — Maven coordinates (
groupId:artifactId:version, one per line) or an uploadedpom.xml. Only the pom's dependency information is used — build sections, profiles, and repository overrides are rejected, so an uploaded pom can never execute code through Maven. Release versions only; SNAPSHOTs and version ranges are rejected. Each resolved file's detached.ascPGP signature is mirrored from Maven Central (best-effort;--maven-signaturesoverrides the source), so Gradle dependency verification and manualgpg --verifywork against the mirror. -
NPM — package specs (one per line:
lodash@4.17.21, a barelodashfor the newest version, a range likereact@^18.2, scoped@types/node), or an uploadedpackage.json(with an optionalpackage-lock.jsonpinning the exact resolved graph). The full dependency graph is resolved withnpm(--package-lock-only, scripts never run) and every resolved registry tarball is downloaded and verified against the lockfile's integrity hash. Dependencies that resolve outside the registry (git/file URLs) are skipped and reported.--npm-registrypoints resolution at a different registry. Registry signatures, provenance attestations, and the registry signing keys are mirrored with the packages, sonpm audit signaturesverifies against the mirror. -
APT — a deb822 source stanza (paste or upload a
.sourcesfile).Suites:may list several suites of the archive (they share one mirror and its pool); an optionalSigned-Bykeyring verifies each suite's upstream release withgpgv; several stanzas mirror several repositories. The mirror is named after the repository URI. Example:Types: deb URIs: http://archive.ubuntu.com/ubuntu Suites: noble noble-updates noble-security Components: main universe Architectures: amd64 Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg -
RPM — a yum/dnf
.repostanza (paste or upload).baseurlmust be concrete (no$releasever/$basearch) and names the mirror. Mirrors the repository's metadata plus its.rpms — by default only the x86_64 and noarch packages (noarch rides along because hardware-arch packages depend on it); list architectures explicitly in the collect request to override. Example:[code] baseurl=https://packages.microsoft.com/yumrepos/vscode gpgcheck=1 gpgkey=https://packages.microsoft.com/keys/microsoft.asc -
Containers — image references, one per line:
alpine:3.20,ghcr.io/org/app:v1,registry.access.redhat.com/ubi9/ubi@sha256:…. Only linux/amd64 is fetched (a multi-platform image is resolved to its amd64 manifest). Public images from any OCI registry (Docker Hub, GitHub, Red Hat, quay.io, …) work anonymously; private registries take a login from the page's Private registry login fields (used for that pull only, never stored) or fromARTIGATE_CONTAINER_AUTHon the low side (comma-separatedhost=user:password, also what scheduled pulls use). Each upstream registry keeps its own namespace on the high side, sodocker.io/...andghcr.io/...content never mixes. Layers are content-addressed, so a base layer shared by several images is bundled and stored once.Signatures and attestations cross the diode too. Each collect also mirrors whatever is attached to the image upstream — cosign/sigstore signatures, in-toto/SLSA provenance, SBOMs — discovered via the OCI 1.1 referrers API (and its tag fallback), cosign's
sha256-<digest>.sig/.att/.sbomtag scheme, and buildkit's attestation entries in the image index. A multi-platform tag serves its original index document on the high side, so the digest a client resolves is exactly upstream's and signatures over it verify unchanged;cosign verify, Kyverno, and Ratify work against the mirror with the same keys/identities they use online. (Artifacts that fail to fetch are skipped with a warning — the image still mirrors, and verification simply fails closed for it.)The tag position also takes a version constraint, resolved against the upstream tag list at collect time to the newest matching version:
golang:1.26.x # newest 1.26 patch release (e.g. 1.26.3) golang:<2.0.0 # newest version below 2.0.0 golang:>=1.24, <2.0 # a range ([hashicorp/go-version] syntax)Only plain numeric tags (
1.26.3,v2.0,17) are considered, so a variant tag like1.26.3-alpinenever outranks the plain image — pin variants explicitly. The bundle records the resolved concrete tag, and a scheduled collect re-resolves on every run, sogolang:1.26.xkeeps tracking new patch releases through the diode automatically. -
AI Models — two kinds of Hugging Face references, one per line each. GGUF models, container-style:
hf.co/unsloth/gpt-oss-20b-GGUF:Q4_0 bartowski/Llama-3.2-1B-Instruct-GGUF:Q8_0 # hf.co/ prefix optional unsloth/gpt-oss-20b-GGUF # no tag = default quantizationThe repository names the Hugging Face model; the tag selects a variant/quantization, resolved by Hugging Face itself (the same Ollama-compatible API behind
ollama run hf.co/…), so it works for any GGUF model repository that Ollama accepts. The manifest, model file, chat template, params, and license are fetched with their SHA-256s verified and stored content-addressed — a license or model blob shared between variants is bundled and stored once.Full repositories, for safetensors releases that publish no GGUF (
openai/gpt-oss-20b, say) — consumed on the high side by vLLM, transformers, andhf downloadthrough the Hub API:openai/gpt-oss-20b # branch main, pinned to its commit openai/gpt-oss-20b@main # same, explicit org/model@<commit-hash> # pin an exact revisionEvery file is mirrored at the pinned commit (large LFS files verified against their upstream SHA-256s) into the same content-addressed store. A "Skip repository paths" field excludes subtrees you don't want to carry across the diode — e.g.
original, metalskips gpt-oss's two extra full copies of the weights and roughly third-sizes the bundle.For both kinds: gated or private models need
ARTIGATE_HF_TOKEN(a Hugging Face access token) set on the low side;--hf-endpointpoints the collector at a private mirror instead ofhttps://huggingface.co.
- Crates — Rust crate specs, one per line (
serde@1.0.203, or a bareserdefor the newest release). The transitive dependency graph (normal and build dependencies; never dev-dependencies, optional ones only when asked) is resolved against the sparse index —https://index.crates.ioby default,--crates-indexoverrides — and every.cratearchive is verified against the index checksum. The verbatim index line of each release travels inside the signed manifest; the high side serves a sparse registry regenerated from those verified records. - Terraform — provider addresses (
hashicorp/aws@5.50.0, or bare for the newest release;platformsselects the target zips,linux_amd64by default) and/or registry modules (terraform-aws-modules/vpc/aws@5.8.1). Provider zips are verified against the registry-declared checksum and mirrored together with the upstreamSHA256SUMS, its GPG signature, and the registry-served signing keys, so terraform's own verification chain works unchanged against the mirror. Modules are fetched from their upstream source (https archives, or git sources via thegittool) and repacked as deterministic archives.--terraform-registry(or the request'sregistryfield) points at another registry — e.g.https://registry.opentofu.orgto mirror OpenTofu. - Helm — a chart repository URL plus charts, one per line (
nginx@21.1.0, or bare for the newest version). Chart archives are verified against the repository index digest when the index declares one. Each upstream repo is served as its own mirror under/helm/<mirror>, itsindex.yamlregenerated from every chart's own embeddedChart.yaml. A chart's signed.provprovenance file is mirrored when the upstream publishes one, and the chart is then advertised under its original<name>-<version>.tgzname sohelm pull --verifypasses with the author's key. - NuGet — package specs, one per line (
Newtonsoft.Json@13.0.3, or a bareSerilogfor the newest stable release). Dependencies from each package's nuspec are resolved the way NuGet restore does (lowest applicable version) against the v3 source —https://api.nuget.org/v3/index.jsonby default,--nuget-sourceoverrides. The high side serves a v3 feed (service index, flat container, registration, search), all metadata regenerated from each package's own embedded.nuspec. - Alpine — a mirror base URL plus branches/repositories/architectures
(defaults:
main,x86_64), or a pasted/etc/apk/repositoriesfile. Every listed.apkis verified against the APKINDEX-declared size and control checksum; the verbatim index stanzas travel inside the signed manifest and the high side regeneratesAPKINDEX.tar.gzfrom them, gated on the packages present. With--apk-rsa-keythe high side signs the regenerated index with its own RSA key (clients install the matching public key once, served at/apk/keys/<name>); unsigned indexes needapk --allow-untrusted. The upstream index carries no whole-file hash, so a scheduled re-collect re-downloads packages on the low side and dedups at export — the bundle still carries only new content. - Conda — a channel (a name like
conda-forgeunderhttps://conda.anaconda.org, or a full channel URL,--conda-channel-baseoverrides the alias base) plus package specs (numpy,scipy==1.13.1,pandas>=2.0,<3) and platform subdirs (noarchis always searched). Dependencies are resolved greedily against the channel's repodata; each package file is verified against its repodata-declared SHA-256, and the verbatim repodata entries travel inside the signed manifest. The high side regenerates per-subdirrepodata.jsonfrom the entries whose packages are present, soconda/mamba/micromambainstall from<high>/conda/<mirror>. Big channels are genuinely large — mirroring conda-forge's platform subdirs needs a generous RAM budget on the low side. - RubyGems — gem specs, one per line (
rake@13.2.1, or a barerailsfor the newest release;--rubygems-urloverrides the upstream). The runtime dependency closure is resolved from the compact index and every.gemis verified against its index-declared SHA-256; the verbatim/infolines travel inside the signed manifest. The high side regenerates a compact index (/versions,/info/<gem>,/names) gated on the gems present, so Bundler works withsource "<high>/rubygems". - Composer — PHP package specs, one per line (
monolog/monolog, orpsr/container:2.0.2to pin;--composer-repooverrides the upstream). The require closure is resolved from the Composer v2 (p2) metadata over stable releases; each release's expanded version object travels inside the signed manifest with its dist/source sections stripped. The high side re-renders the p2 API from those verified objects — dist URLs point back at its own verified zips — socomposer installworks against<high>/composerwith packagist.org disabled. - VS Code extensions — extension ids, one per line (
golang.Go, orredhat.vscode-yaml@1.14.0to pin), fetched from Open VSX (--vsx-registryoverrides). Extension dependencies and packs are mirrored with them. The high side regenerates gallery metadata from each.vsix's own embeddedpackage.jsonand answers the VS Code gallery query API at<high>/vsx/gallery— point VSCodium'sextensionsGallery.serviceUrl(orVSCODE_GALLERY_SERVICE_URL) at it, or download.vsixfiles directly. - Ansible — Galaxy collection specs, one per line (
ansible.posix, orcommunity.general@8.5.0to pin;--galaxy-serveroverrides the upstream). Dependencies from each collection's metadata are resolved and mirrored; artifacts are verified against the API-declared SHA-256. The high side regenerates a Galaxy v3 API from each artifact's own embeddedMANIFEST.json, soansible-galaxy collection install ns.name -s <high>/galaxy/works. - CRAN — R package specs, one per line (
jsonlite, ordata.table@1.15.4for a superseded release, fetched from the mirror's Archive;--cran-mirroroverrides the upstream). The runtime dependency closure (Depends/Imports/LinkingTo, minus base packages) is mirrored as source packages verified against the index MD5. The high side regeneratessrc/contrib/PACKAGES(.gz)from each tarball's ownDESCRIPTION, soinstall.packages("pkg", repos = "<high>/cran")works. - Snap — snap specs, one per line (
hello, orhello@edge/blender@4.1/stableto pick a channel; one architecture per collect, defaultamd64;--snap-storeoverrides the upstream). Each snap's current revision in that channel is fetched from the Snap Store API, verified against the store-declared SHA3-384, and mirrored together with its signed store assertions (.assert) and, unless opted out, the base snap it runs on. The high side recomputes every archive's SHA3-384 and refuses revisions whose assertions don't vouch for the exact bytes, then serves the<name>_<rev>.snap+<name>_<rev>.assertpairs (plus a JSON revision index at/snap/info/<name>) — on the air-gapped machine,snap ack <name>_<rev>.assert && snap install <name>_<rev>.snapinstalls with snapd's own signature verification, no--dangerousneeded. - Git — a clone URL (plus an optional mirror name and ref list). The low
side speaks the smart HTTP protocol as a pure-Go client — no git binary
beside the signing key — fetches every selected branch and tag as one
self-contained packfile, and fully verifies it (trailer hash, every object,
every delta) before signing. The high side re-verifies the pack, rebuilds
the
.idxitself, and serves the repository over git's dumb HTTP protocol, sogit clone <high>/git/<mirror>.gitworks with stock git. Each re-collect refreshes the mirror to the current upstream refs.
For APT, RPM, and Alpine, a "Newest version only" checkbox (on by default) mirrors just the latest version of each package; untick it to mirror every version.
Private git, APT, RPM, Alpine, and Conda upstreams authenticate with HTTP Basic:
a one-shot login on the collect (each page's Private … login fields / the
auth field, never stored) or standing host=user:password entries in
ARTIGATE_UPSTREAM_AUTH on the low side — the latter is what scheduled
collects use. URLs embedding user:pass@ are rejected (they previously
"worked" via Go's automatic Basic auth while leaking the secret into the
signed manifest that crosses the diode — move such logins into the auth field
or the environment variable). Private Go module hosts use their own
standing variable, ARTIGATE_GO_AUTH (same format), or the auth-field
login, injected into the go/git subprocesses for that collect (see the Go
bullet above). It is separate from ARTIGATE_UPSTREAM_AUTH because a
standing Go credential also marks its host private (GOPRIVATE et al.) —
a git/APT login on a shared host like github.com must not push public
module fetches off the proxy and checksum database.
- OSV — vulnerability-advisory databases from osv.dev:
OSV ecosystem names, one per line, exactly as osv.dev spells them (
npm,PyPI,Go,crates.io,Maven,NuGet,Alpine:v3.22,Debian:12, …). Each name's currentall.zipadvisory database is fetched and re-exported as a snapshot; the high side serves the verified zips in the upstream bucket's layout under/osv/…(plus single advisories by id, streamed straight out of the zip) for offline scanners such as osv-scanner. Advisory data is the one deliberately mutable mirrored subtree: each import replaces the previous snapshot at the same path, and an unchanged database dedups to a no-op, so a daily schedule keeps the air-gapped side's advisory picture current at near-zero diode cost. Mirroring thenpmdatabase additionally regenerates an advisory index that makesnpm auditwork against the mirror (see the NPM client note below). - Uploads — arbitrary files, no ecosystem behind them: pick a folder name
and one or more files (
POST /admin/uploads/collect, multipart form data). The high side serves them at/uploads/<folder>/<name>, lists them on its dashboard under Uploads, and — uniquely among the streams — lets the operator delete a file there again (an emptied folder disappears with its last file). Re-uploading a name replaces the file; uploads always ship in full (the forwarded-content index is not consulted), so a file deleted on the high side comes back by simply uploading it again. Uploads cannot be scheduled — there is no upstream to re-pull.
Each ecosystem page can turn its inputs into a recurring pull: set an interval
(hours or days) and click Add schedule — e.g. re-pull a go.mod or a
requirements list every day. Schedules run in the background (due schedules are
checked every --watch-interval, default one minute) and can be paused, run
immediately, or deleted from the same page.
A collect only bundles — and where possible only downloads — content it has not
already sent. The low side records every forwarded file (bundle path plus
content hash), per stream, in a small SQLite index (<root>/exported.db):
- Nothing new — when a collect resolves to a file set that is entirely already-forwarded, no bundle is written and no bundle number is consumed; the dashboard (and a schedule's status) simply reports "no new content".
- Partly new — the bundle's archive carries only the new files. The rest are listed in the manifest as prior references, which the high side verifies against its accumulated repository instead of receiving again. A daily schedule over a slowly-changing mirror therefore sends only the churn.
- Download skip — collectors whose upstream declares each file's SHA-256
before the bytes are fetched (APT
Packagesindexes, RPMprimary.xml, container image digests, Hugging Face LFS files) check the index first and skip the download entirely. pip-, mvn-, npm-, and go-driven fetches still download as before (their upstreams declare no usable pre-download SHA-256; the Go module cache already avoids re-downloads on its own), and their unchanged files are still deduplicated from the bundle after hashing.
Every collect request accepts "force": true to bypass the index and produce a
full, self-contained bundle — the disaster-recovery path when a high side is
rebuilt from scratch or bundles were pruned before it caught up. Note that a
delta bundle imports only on a high side that holds the stream's earlier
bundles; importing out of order fails with a "prior file … not in the
repository" error naming the missing content.
The index is independent of the re-export archive: re-transmitting a bundle never consults or updates it, and if the index is ever unavailable a collect simply downloads and exports as normal rather than wrongly skipping.
Every ecosystem page has an Estimate size button next to Collect &
export (the API equivalent is appending ?dry_run=1 to any
POST /admin/<stream>/collect). A dry run answers "N artifacts, ~X GB, Y
new" before you commit gigabytes to a rate-limited one-way link: it resolves
the request, checks every file against the export dedup index, and plans the
bundle split exactly like a real collect — then stops at the export threshold.
Nothing is written, no bundle number is consumed, nothing is recorded as
forwarded, and nothing is handed to the diode transport. The result reports
the resolved totals, the new files and bytes that would actually cross the
diode, an upper bound on the archived size, and how many sequenced bundles
the content would ship as.
Collectors whose upstream declares each file's SHA-256 and size (APT, RPM,
container images, Hugging Face models and LFS files — the gigabyte-scale
streams) estimate from metadata alone, so their dry runs download nothing but
indexes. Tool-driven ecosystems (go, pip, mvn, npm, …) must still
fetch into their local caches to learn sizes; the dry run then only spares
the diode, not the low side's own bandwidth. Combined with "force": true
the estimate covers the full self-contained bundle a forced collect would
produce.
The Status page shows each stream's next bundle number and the exported
bundles (with sizes, and whether each is still staged in the export directory
or already sent). If the high side reports a bundle missing, use its
re-transmit form to regenerate that bundle number or range from the archive
(<root>/bundles), which keeps a copy of every bundle ever exported.
Carry each bundle's three files across the diode into the high side's landing
directory. The high side imports each stream strictly in order. An
out-of-order bundle (e.g. go-bundle-000043 before 000042) is quarantined, not
rejected, and imported automatically once the gap is filled; duplicates and old
replays are ignored. Future gaps are capped at 10,000 sequences; unsupported,
excessively-future, or cryptographically invalid bundles move to
<root>/rejected and do not block the other streams.
Whichever transport carries the bundles, the low side also emits a periodic
heartbeat with every stream's newest committed sequence number, signed
with its key under a dedicated signature context (never confusable with a
manifest signature). A diode gives the high side no other way to learn what
it should have: /admin/missing can only report gaps behind bundles that
did arrive, so a bundle lost in its entirety — or a low side that stopped
exporting — would otherwise be invisible. ARTIGATE_DIODE_HEARTBEAT sets the
interval (default 30s, off disables); delivery matches the transport —
one more file in the export dir for a folder carrier
(artigate.heartbeat), a PUT to the HTTP diode endpoint, a datagram on the
built-in UDP diode — and the high side verifies and records it identically in
all three cases. The dashboard then shows the low side's index per stream, an
Awaiting column for bundles that left the low side but have not arrived
(in transit, or lost and needing a re-export), and the heartbeat's freshness;
/metrics exposes the same as artigate_high_low_last_sequence,
artigate_high_bundles_awaiting_from_low, and
artigate_high_diode_heartbeat_{timestamp,age}_seconds (alert on awaiting
with a for: clause long enough to ride out a large bundle's transfer).
For diodes (or diode proxies) that speak HTTP instead of moving files, both sides also support an HTTP transport, configured entirely by environment variables — the folder flow stays the default:
| Variable | Side | Meaning |
|---|---|---|
ARTIGATE_DIODE_URL |
low | endpoint bundles are uploaded to after every export and re-export (PUT <url>/<file>, archive first) |
ARTIGATE_DIODE_INGEST |
high | on accepts bundle uploads at PUT/POST /diode/<file> into the landing directory (default off) |
ARTIGATE_DIODE_TOKEN |
both | shared bearer token, at least 32 bytes and required whenever HTTP diode transport is enabled |
ARTIGATE_DIODE_HEARTBEAT |
low | stream-index heartbeat interval for whichever transport is active (folder, HTTP, or UDP), default 30s (off disables) |
# low side — upload each bundle to the diode proxy (or directly to the high side)
export ARTIGATE_DIODE_URL=https://artigate-high.local/diode
export ARTIGATE_DIODE_TOKEN=…
# high side — accept uploads into the landing directory
export ARTIGATE_DIODE_INGEST=on
export ARTIGATE_DIODE_TOKEN=…After a successful upload the low side clears the bundle from the export directory (it shows as sent on the Status page), exactly like a folder diode moving the files out; the archive copy is kept for re-transmits, which also go out over HTTP. A failed upload never loses a bundle — the collect still succeeds, the dashboard (and a schedule's status) reports the upload error, and the bundle stays staged for a re-transmit from the Status page. A complete bundle received over HTTP is imported immediately rather than on the next scan tick. Completion notifications use one coalescing worker rather than creating a goroutine per upload.
The transport carries no trust: uploads land in the landing directory exactly
as diode-carried files would, and the importer still verifies the Ed25519
signature, per-stream sequencing, and every file hash. The token only protects
the high side's disk from unauthenticated uploads — leave the ingest off (the
default) unless you use the HTTP transport. Anything that can PUT a file
works as a sender, e.g.:
curl -fT go-bundle-000042.tar.gz -H "Authorization: Bearer $TOKEN" \
https://artigate-high.local/diode/go-bundle-000042.tar.gzIngress is bounded before verification: archives are limited to 64 GiB, manifests to 16 MiB, signatures to 4 KiB, and pending/quarantined/rejected files to 128 GiB in aggregate. The archive bound is no ceiling on what a collect can carry: a collect whose new content would overflow it — a full safetensors repository easily does — is split automatically into consecutive sequenced bundles, each within the limit. The content ships in part bundles first and the ecosystem metadata arrives in the final bundle, which references the parts' files, so the model appears on the high side exactly once, complete. With the built-in UDP pitcher enabled, the split budget also respects the wire's block-count bound for the configured FEC geometry, so every bundle produced is guaranteed transmittable as configured.
ArtiGate can also drive a hardware diode directly — a one-way fiber
between a spare NIC on each side, no diode proxy software at all. The low
side's pitcher transmits every bundle as rate-limited, Reed-Solomon-coded
IPv6 link-local multicast (multicast because a one-way link can never resolve
the receiver's MAC address); the high side's catcher reassembles the
datagrams into the landing directory and imports immediately. Naming the
interface is what enables each side — ArtiGate configures the NIC itself
(MTU 9000, deep TX/RX queues, IPv6 addr-gen-mode eui64 link-local, link up):
| Variable | Side | Meaning |
|---|---|---|
ARTIGATE_PITCHER_INTERFACE |
low | dedicated diode TX NIC (e.g. eth1); enables the pitcher |
ARTIGATE_PITCHER_RATE_MBIT |
low | max wire rate, default 800 — a one-way link has no congestion control, so stay below what the catcher absorbs |
ARTIGATE_PITCHER_FEC_DATA / _FEC_PARITY |
low | Reed-Solomon geometry, default 32+8: any 8 of every 40 datagrams may be lost harmlessly |
ARTIGATE_PITCHER_TXQUEUELEN |
low | TX NIC queue length, default 10000 — raise if the driver drops on bursts |
ARTIGATE_CATCHER_INTERFACE |
high | dedicated diode RX NIC; enables the catcher |
ARTIGATE_CATCHER_RCVBUF_MB |
high | receive buffer (MiB), default 64, set via SO_RCVBUFFORCE |
ARTIGATE_{PITCHER,CATCHER}_{MTU,GROUP,PORT,NETSETUP} |
both | MTU 9000, group ff02::4147, port 4147; NETSETUP=off when the host pre-configures the NIC |
The stream-index heartbeat (see above) rides this transport too: the pitcher broadcasts it as a signed datagram and the catcher verifies it off the wire.
Loss beyond the parity budget expires the transfer on the catcher (nothing
partial ever lands) and is recovered the usual way: the gap shows on the high
side's /admin/missing, and a low-side re-export re-transmits it from the
archive. The retry is cheap even for huge bundles, because recovery is
per-block: the catcher keeps an expired transfer's completed FEC blocks beside
the landing directory (for 24 hours), and the re-sent file — same name, same
content hash — resumes from them, so each attempt only has to deliver the
blocks every earlier attempt lost. A multi-gigabyte model bundle therefore
converges on a lossy link instead of demanding one perfect pass. In Docker
both sides need network_mode: host, cap_add: [NET_ADMIN], and a root user
— see examples/docker-compose-diode-low.yml,
examples/docker-compose-diode-high.yml, and the
data-diode documentation
for tuning guidance.
./artigate high \
--listen :8080 \
--root /var/lib/artigate-high \
--landing /var/spool/diode-in \
--public-key /etc/artigate/high.ed25519.pub \
--import-interval 10s \
# --apt-gpg-key <keyid> --rpm-gpg-key <keyid> (optional: sign the served repos)
# --apk-rsa-key /etc/artigate/apk.pem --apk-key-name artigate.rsa.pub (optional: sign Alpine indexes)It imports on a timer, and the dashboard at http://<high-host>:8080/ shows
import status (per stream, flagging any missing bundles) and a browsable tree of
everything mirrored. The high side never trusts transferred index/latest/
metadata files as truth — it regenerates them from the artifacts actually present,
and serves only complete versions.
# Go
go env -w GOPROXY=https://artigate-high.local/go,off
# GOSUMDB stays on (the default): the mirror also serves the checksum
# database's signed records and Merkle proofs under /go/sumdb/, captured when
# each module was mirrored, so `go` keeps end-to-end sumdb verification
# offline. Only for modules mirrored by bundles from before sumdb capture
# existed: re-collect them once on the low side, or `go env -w GOSUMDB=off`.# Python — /etc/pip.conf
[global]
index-url = https://artigate-high.local/simple/<!-- Maven — ~/.m2/settings.xml -->
<settings><mirrors><mirror>
<id>artigate</id><mirrorOf>*</mirrorOf>
<url>https://artigate-high.local/maven/</url>
</mirror></mirrors></settings># NPM — ~/.npmrc (or /etc/npmrc)
registry=https://artigate-high.local/npm/
fund=false
# npm audit works once the OSV "npm" database is mirrored (see below);
# until then add audit=false, because the advisory endpoint answers 404.# APT — /etc/apt/sources.list.d/artigate.sources (use ArtiGate's key, not the vendor's)
Types: deb
URIs: https://artigate-high.local/apt/<mirror>
Suites: stable
Components: main
Architectures: amd64
Signed-By: /usr/share/keyrings/artigate-apt.gpg
# RPM — /etc/yum.repos.d/artigate.repo
[artigate]
baseurl=https://artigate-high.local/rpm/<mirror>
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-artigate# Rust — ~/.cargo/config.toml
[source.crates-io]
replace-with = "artigate"
[source.artigate]
registry = "sparse+https://artigate-high.local/crates/index/"# Terraform / OpenTofu — ~/.terraformrc (network_mirror needs HTTPS), or use
# the host directly in source addresses: artigate-high.local/hashicorp/aws
provider_installation {
network_mirror {
url = "https://artigate-high.local/terraform/v1/providers/"
}
}# Helm — each mirrored upstream repo is served under its mirror name
helm repo add artigate https://artigate-high.local/helm/<mirror>
helm install my-release artigate/<chart> --version <version><!-- NuGet — nuget.config (next to the solution) -->
<configuration><packageSources>
<clear />
<add key="artigate" value="https://artigate-high.local/nuget/v3/index.json" protocolVersion="3" />
</packageSources></configuration># Alpine — /etc/apk/repositories; with --apk-rsa-key, install the mirror's key once
wget -O /etc/apk/keys/artigate.rsa.pub https://artigate-high.local/apk/keys/artigate.rsa.pub
echo https://artigate-high.local/apk/<mirror>/v3.22/main >> /etc/apk/repositories
apk update # add --allow-untrusted instead when the index is served unsigned# Containers — the pull name embeds the upstream registry
docker pull artigate-high.local/docker.io/library/alpine:3.20
docker pull artigate-high.local/ghcr.io/org/app:v1
# Signatures/attestations mirrored with the image verify offline: cosign's
# tag scheme and the OCI referrers API both answer on the high side
cosign verify --key cosign.pub artigate-high.local/ghcr.io/org/app:v1
cosign verify-attestation --type slsaprovenance --key cosign.pub \
artigate-high.local/ghcr.io/org/app:v1# OSV advisories — the upstream bucket's layout, for offline scanners
curl -fsSL https://artigate-high.local/osv/ecosystems.txt
curl -fL -o npm-all.zip https://artigate-high.local/osv/npm/all.zip
curl -fsSL https://artigate-high.local/osv/npm/GHSA-xxxx-xxxx-xxxx.json
# osv-scanner: place each all.zip at <cache>/osv-scanner/<ecosystem>/all.zip
# and run with --offline. npm audit needs no setup at all — with the "npm"
# database mirrored, the registry above answers it.# AI models — Ollama pulls straight from the mirror (add --insecure for plain HTTP)
ollama pull artigate-high.local/unsloth/gpt-oss-20b-GGUF:Q4_0
ollama run artigate-high.local/unsloth/gpt-oss-20b-GGUF:Q4_0
# ...or download the raw GGUF for vLLM / llama.cpp
curl -fL -o gpt-oss-20b-GGUF-Q4_0.gguf \
https://artigate-high.local/hf/unsloth/gpt-oss-20b-GGUF/Q4_0.gguf
HF_HUB_OFFLINE=1 vllm serve ./gpt-oss-20b-GGUF-Q4_0.gguf
# Full repositories (safetensors) — every huggingface_hub client, via HF_ENDPOINT
export HF_ENDPOINT=https://artigate-high.local
vllm serve openai/gpt-oss-20b
hf download openai/gpt-oss-20bDocker/podman require HTTPS for remote registries — enable TLS on the high side,
or, for a plain-HTTP mirror, trust it explicitly (then systemctl restart docker).
The high-side "Set me up" guide renders this block ready to copy, with the
actual host and port filled in — for APT it even offers a per-suite release
picker with component checkboxes:
// /etc/docker/daemon.json
{
"insecure-registries": [
"artigate-high.local:8081"
]
}On the high side, use only ArtiGate as the source — don't add
--extra-index-url, mavenCentral(), or other upstreams, which reopens
dependency-confusion risk. If a repo is published unsigned, relax the client's
signature check (repo_gpgcheck=0, [trusted=yes], etc.).
Both servers serve plain HTTP by default. Enable HTTPS entirely through environment
variables (no flags) — the same set applies to low and high.
ARTIGATE_TLS_MODE selects one of:
unencrypted(default) — plain HTTP.acme— obtain and renew certificates automatically via ACME (certmagic).own-certificate— use a certificate and key you provide.auto-generate-certificate— a self-signed certificate made at startup (handy for testing; clients must trust it or skip verification).
| Variable | Modes | Meaning |
|---|---|---|
ARTIGATE_TLS_MODE |
all | unencrypted / acme / own-certificate / auto-generate-certificate |
ARTIGATE_TLS_DOMAINS |
acme, auto-generate | comma-separated domains/IPs (ACME cert names; self-signed SANs) |
ARTIGATE_TLS_CERT, ARTIGATE_TLS_KEY |
own-certificate | PEM certificate and private-key paths |
ARTIGATE_ACME_EMAIL |
acme | account email |
ARTIGATE_ACME_DIRECTORY |
acme | ACME server directory URL (defaults to Let's Encrypt) |
ARTIGATE_ACME_CA_ROOT |
acme | PEM root CA to trust, for a private ACME server |
ARTIGATE_ACME_STORAGE |
acme | certificate cache directory (default <root>/acme) |
Example against a private ACME server (e.g. step-ca):
export ARTIGATE_TLS_MODE=acme
export ARTIGATE_TLS_DOMAINS=mirror.internal
export ARTIGATE_ACME_EMAIL=ops@internal
export ARTIGATE_ACME_DIRECTORY=https://ca.internal/acme/acme/directory
export ARTIGATE_ACME_CA_ROOT=/etc/artigate/ca-root.pemACME uses the TLS-ALPN-01 challenge on the server's own listen port, so that port must be reachable by the ACME server as the configured domain.
The low-side dashboard can require a login. It is off by default and enabled
through a single environment variable, ARTIGATE_LOW_AUTH, holding one or more
credentials. Passwords are stored as argon2id hashes, never in plaintext —
generate one with the hashpw subcommand (it reads the password from stdin so it
never appears in your shell history):
./artigate hashpw --user alice
# prompts on stdin, then prints: alice:$argon2id$v=19$m=65536,t=3,p=1$...$...Put one or more username:hash credentials in the variable, separated by ; or
newlines (not commas — the argon2 parameters inside a hash contain commas):
export ARTIGATE_LOW_AUTH='alice:$argon2id$v=19$...;bob:$argon2id$v=19$...'When set, the dashboard presents a sign-in page and, after a successful login,
carries the session in an encrypted, signed cookie (gorilla/securecookie); a
Log out button in the header clears it. Sessions last 12 hours and survive a
restart (the cookie keys are persisted to <root>/session.key). The /healthz
and /readyz probes and the /metrics scrape endpoint stay open so container
health checks and monitoring keep working. The high side is never
authenticated — it serves only already-verified public mirror content.
When ARTIGATE_LOW_AUTH is unset the low-side dashboard is unauthenticated —
including the mutating /admin/* endpoints — so bind it to localhost or a trusted
network, or set credentials.
The session cookie's Secure flag defaults to whether ArtiGate itself terminates
TLS. If ArtiGate serves plain HTTP behind a TLS-terminating reverse proxy, set
ARTIGATE_LOW_COOKIE_SECURE=true so the cookie is still marked Secure (values:
auto (default), true, false).
The shipped Compose stack requires this value. Put it in the gitignored
.envfile as a single-quoted value so the$characters remain literal; see.env.example. Direct binary/systemd deployments may still leave auth unset only when strict network controls protect the low-side control plane.
ArtiGate is built to run unattended behind a diode, so both sides expose telemetry an ops team can scrape and alert on — a stalled stream or a failing nightly schedule should page someone, not wait for a human to notice a dashboard.
Both sides serve Prometheus text-exposition metrics at GET /metrics on the
same listener as the dashboard. Like /healthz, the endpoint stays open when
ARTIGATE_LOW_AUTH is set (a scraper cannot log in), and it exposes only the
same non-secret status the dashboard already shows — firewall the scrape port or
front it with an authenticating proxy if you need it restricted. No extra
configuration is required.
The low side reports, per stream, the next bundle sequence, retained and still-outbound bundle counts and their on-diode bytes, scheduled-collect run counters and each stream's last successful collect, the queued/running/finished job counts, and free/total disk on the root and export directories:
artigate_low_next_sequence{stream="python"} 42
artigate_low_bundle_bytes{stream="python"} 1830482
artigate_low_schedule_runs_total{stream="python",status="error"} 1
artigate_low_last_successful_collect_timestamp_seconds{stream="python"} 1720000000
artigate_disk_free_bytes{dir="export"} 5.36870912e+10
The high side reports, per stream, the last-imported and highest-seen
sequence, import lag (highest_seen − last_imported), whether the stream is
blocked on a missing bundle and for how long (gap age), quarantine
depth, last successful import, cumulative imported/rejected counts, the shared
unverified-transport quota and its usage, and disk on the root and landing
directories:
artigate_high_import_lag{stream="go"} 3
artigate_high_stream_blocked{stream="go"} 1
artigate_high_gap_age_seconds{stream="go"} 907
artigate_high_bundles_rejected_total{stream="go"} 0
artigate_high_unverified_transport_bytes 734003200
Counters reset on process restart, as is standard for Prometheus; the derived gauges (sequences, lag, quota, disk) are computed live from on-disk state on every scrape, so they never drift from reality.
GET /healthz is pure liveness — it answers ok as long as the process
serves, and is what container health checks and load balancers should keep
using. GET /readyz is the readiness probe next to it: it runs real
go/no-go checks against the same live state the dashboard shows and answers
200 ok when the side can do its job, or 503 with one
[-] check: reason line per failing check when it cannot (append ?verbose
to list every check on success too). Like /healthz and /metrics, it stays
open when auth is enabled.
The low side is not ready when the schedule store cannot be read
(watch-store), the export spool directory is missing (export-spool), or a
bundle's last diode transfer — UDP pitch or HTTP upload — failed and its files
still sit in the outbound spool awaiting a re-transmit (diode-transfer; a
successful re-export clears it).
The high side is not ready when import status cannot be computed
(import-status), a stream is blocked waiting for a missing bundle
(stream-gaps), complete bundles sit ready to import with no import pass
completing inside the grace window — three --import-intervals, at least a
minute (import-backlog), import passes stopped completing or the last pass
failed (import-pipeline), or the shared unverified-transport quota is
exhausted so the diode cannot land new bundles (transport-quota).
$ curl -s http://high:8080/readyz
[+] import-status ok
[-] stream-gaps: stream go waiting for missing bundle 42 for 15m7s
[+] import-backlog ok
[+] import-pipeline ok (last pass 4s ago)
[+] transport-quota ok (700.0 MiB of 128.0 GiB used)
not readyPoint alerting at /readyz (or scrape both: a 503 names exactly what to fix),
and keep orchestrator liveness probes on /healthz so a blocked stream — which
the high side deliberately survives while continuing to serve everything
already verified — never causes a restart loop.
Set ARTIGATE_WEBHOOK_URL (on either or both sides) to have ArtiGate POST a
small JSON document when something goes wrong, so an alert reaches a channel
without polling. ARTIGATE_WEBHOOK_TOKEN (optional) is sent as a
Authorization: Bearer … header.
| Event | Side | Fires when |
|---|---|---|
schedule_failed |
low | a scheduled collect run fails (upstream error, panic, cancel) |
bundle_rejected |
high | a bundle is rejected on import or sorting (bad signature/hash, unsupported, too far ahead) |
gap_detected |
high | a stream becomes blocked because a later bundle arrived before the next expected one |
Delivery is best-effort and fire-and-forget: a slow or unreachable receiver
never blocks an import or a scheduler tick, and failures are logged rather than
retried — the /metrics counters remain the durable record. gap_detected is
edge-triggered (one notification per gap; the gap then ages via
artigate_high_gap_age_seconds until it fills).
- The low-side dashboard is a privileged control plane — it holds the signing
key, so anyone who can reach it can have arbitrary content signed and sent across
the diode. It requires a session login (
ARTIGATE_LOW_AUTH, see above); when it is not set the low side refuses to start on a non-loopback listen address. Bind--listento loopback, setARTIGATE_LOW_AUTH, or — only behind a trusted TLS-authenticating reverse proxy — setARTIGATE_LOW_ALLOW_UNAUTHENTICATED=trueto acknowledge that layer. The high-side dashboard serves only already-verified public mirror content and is unauthenticated, so bind it to localhost or a trusted network; its state-changing admin endpoints (POST /admin/uploads/delete,/admin/import) are additionally restricted to loopback callers unlessARTIGATE_HIGH_ALLOW_REMOTE_ADMIN=on(set this when a published-port or reverse-proxy hop makes local admin appear non-loopback, and keep the listener itself restricted at the host). - Go: the checksum database (
sum.golang.orgby default) is mirrored per module: each collect also captures the signed lookup records and Merkle tiles the low-side toolchain verified, and the high side answers the GOPROXYsumdb/…passthrough — so clients keepGOSUMDBenabled and re-verify every module against the database's own key, fully offline. Modules mirrored by bundles from before sumdb capture existed have no records yet; one re-collect on the low side (a watch's next run does it) backfills them, and until then those clients needGOSUMDB=off. Private modules follow the low side'sGONOSUMDB/GOPRIVATEand are never looked up; with--gosumdb offnothing is captured and clients useGOSUMDB=offas before. - Python: wheels by default — every pip run is forced to
--only-binary=:all:, and a requirement with no compatible wheel fails the collect (pin a wheel-bearing version, or exclude it). Packages that publish no wheel can be opted into source distributions via the collect'ssdistslist: those are fetched from the index's JSON API (never through pip, so no build hooks run on the low side), verified against the API-declared SHA-256, and built by clients at install time. - Java/Maven: release versions only; SNAPSHOT and dynamic/range versions are rejected.
- NPM: registry tarballs only — dependencies resolved to git or file URLs
are skipped (and reported). Resolution needs npm 7 or newer on the low side
(lockfile v2+). The high side regenerates all packument metadata from each
tarball's own embedded
package.json; each collect also snapshots the upstreamdist-tags, and the served packument carries every mirrored tag whose target version is present, regeneratinglatestfrom the versions actually served when the upstream tag is absent or unmirrored (installs by tag, e.g.pkg@beta, resolve too).npm auditworks once the OSVnpmdatabase is mirrored (npm 7+, the bulk-advisory protocol); without it the advisory endpoint answers 404, so setaudit=false— yarn classic's olderauditsprotocol is not served either way. - APT/RPM: mirror the newest version of each package by default; untick
"Newest version only" to mirror every version. RPM collects default to
x86_64 + noarch packages. RPM
.zck-only indexes aren't supported (use.gz/.xz/.zst). Each collect re-syncs against upstream, but the export dedup index keeps it from re-downloading or re-sending what already crossed. - Crates: the resolver follows normal and build dependencies (never dev-dependencies; optional ones only with "include optional"), picking the highest version satisfying each requirement like cargo does — but it does no feature unification, so an unusual feature-gated dependency may need to be listed explicitly. Yanked releases are skipped unless pinned exactly.
- Terraform: provider mirroring covers the platforms listed at collect time
(
linux_amd64by default; re-collect with more platforms to extend a version). Module sources must be https archives orgit::httpsURLs (the usual registry forms); other go-getter schemes are skipped.terraform login/ publishing APIs are not served. - Helm: OCI-hosted charts are out of scope (mirror them as container
images); classic
index.yamlrepositories only. Chart provenance (.prov) files are not mirrored — integrity comes from the regenerated index digests. - NuGet: the flat container publishes no digests, so low-side downloads are TLS-trusted and validated against the embedded nuspec; everything after that is hash-locked into the signed bundle. Dependency resolution picks the lowest applicable version per range (NuGet restore behavior) across all target frameworks.
- Alpine: the APKINDEX carries no whole-file hash, so scheduled re-collects re-download packages on the low side (export dedup still keeps re-sends off the diode). Packages are verified against the index's size and Q1 control checksum at collect time.
- Signing the served repos is optional (
--apt-gpg-key/--rpm-gpg-keyfor APT/RPM,--apk-rsa-keyfor Alpine); otherwise those repositories are published unsigned. - Upstream verification material passes through wherever the ecosystem
publishes any, so clients keep verifying instead of relaxing checks:
Go sumdb notes and Terraform's GPG chain (long-standing), container cosign
signatures/attestations/SBOMs with the referrers API, npm registry
signatures + provenance attestations + the
/-/npm/v1/keysendpoint (npm audit signaturesworks against the mirror), Maven.ascPGP signatures (fetched from Central — override with--maven-signatures), Helm.provprovenance files (helm pull --verify), Snap Store assertion chains (snap ackverifies them against snapd's built-in root of trust; the high side additionally refuses any archive whose recomputed SHA3-384 the assertions don't vouch for), and PyPI PEP 740 provenance via/integrity/…and the simple JSONprovenancekey. All of it is best-effort at collect time (unsigned upstream content mirrors bare) and captured at collect: re-signing upstream later needs a re-collect. Ecosystems whose upstreams publish nothing verifiable per artifact (crates.io, RubyGems, Packagist, CRAN, Hugging Face, OSV) have nothing to pass through — clients there verify the checksums the mirror regenerates from verified bytes; NuGet signatures are embedded in the.nupkgfiles and already cross unmodified. Conda content-trust metadata, Ansible Galaxy collection signatures, and Open VSX signatures are not mirrored yet. - Containers: linux/amd64 only, and registries on non-standard ports can't
be mirrored (the port can't appear in the high-side pull name). Pulls are
anonymous by default; private registries take a per-pull login (the
authfield / the Containers page form, used once and never stored) or standing per-registry credentials inARTIGATE_CONTAINER_AUTH(host=user:password, comma-separated) — scheduled pulls use only the latter.--container-registry host=baseURLon the low side redirects a registry's API to a private mirror/proxy. The high-side registry is read-only (no push). Attached artifacts (cosign signatures, attestations, SBOMs) mirror automatically and are served back through cosign's tag scheme andGET /v2/<name>/referrers/<digest>; a multi-platform tag resolves to its preserved upstream index digest, so policy engines verify the mirror's content byte for byte. Only artifacts attached to the pulled image (or its index) at collect time cross — re-signing upstream later needs a re-collect to propagate. - OSV: databases are TLS-trusted at collect time (the OSV bucket publishes no digests for its zips) and hash-locked into the signed bundle from there. Advisory contents are served verbatim from the verified zip; the npm audit index maps GitHub-Advisory severities onto npm's words and renders OSV version events as npm ranges — a record it cannot render exactly is reported as affecting all versions rather than silently narrowed, and withdrawn advisories are dropped from audit results (the raw records stay downloadable). Audit responses carry no CVSS block (OSV publishes only vectors, not scores). Unlike every other stream, a re-collected database replaces the previous snapshot — advisory feeds are updates, not immutable artifacts.
- AI Models: GGUF references use Hugging Face's Ollama-compatible endpoint
(the repos
ollama run hf.co/…accepts; sharded/split GGUFs are not supported upstream); tags are quantization names resolved at collect time, and digest pins are not supported. Ollama requires HTTPS — enable TLS on the high side or pass--insecuretoollama pull. The raw GGUF is also served at/hf/<org>/<model>/<variant>.gguffor llama.cpp and vLLM's GGUF loader (with vLLM setHF_HUB_OFFLINE=1; without--tokenizerit converts the tokenizer from the GGUF, which slows the first start). Full-repository snapshots serve the download subset of the Hub API (/api/models/…and…/resolve/…) — enough forHF_ENDPOINT-pointed vLLM, transformers, andhf download, but not search or the write APIs. Snapshots are pinned to a commit; re-collecting a branch adds the new commit and moves the branch name to it (old snapshots stay pullable by commit hash). - Low-side collects for different ecosystems run concurrently; the high side never
runs
go/pip/mvnand does no upstream fetching.
Beyond the offline unit suite (go test ./...), an opt-in end-to-end suite
builds the real binary, starts a low+high pair wired over the HTTP diode
transport, collects every stream from its real upstream (PyPI,
proxy.golang.org, Maven Central, npmjs, cli.github.com, Docker Hub,
huggingface.co, osv.dev), and validates each with its real client tool — pip,
go, mvn+java, npm+node (including a real npm audit against the
mirrored OSV data), apt-get+dpkg-deb, dnf+rpm, docker,
huggingface_hub's CLI, and curl:
make e2e # == go test -tags e2e -v -count=1 -timeout 25m ./e2eIt needs network access and the client toolchains on PATH; a missing tool
skips its test locally (CI sets ARTIGATE_E2E_REQUIRE_ALL=1 to fail
instead). CI runs it on every PR via .github/workflows/e2e.yml. See
e2e/doc.go for all knobs (ARTIGATE_E2E_BIN, ARTIGATE_E2E_WORKDIR,
ARTIGATE_E2E_KEEP, ARTIGATE_E2E_HF_GGUF).
The full manual — architecture, per-ecosystem guides, configuration and HTTP
API references, deployment and troubleshooting — is at
https://define42.github.io/ArtiGate/ (source under page/, built with
MkDocs Material). ArtiGate is released under the Apache 2.0 license.