High side¶
The high side is ArtiGate's air-gapped, read-only mirror. It watches a landing directory for signed bundles delivered across the diode, imports them strictly in sequence order per stream, verifies every artifact, regenerates all repository metadata from the artifacts themselves, and serves clients. It never invokes go, pip, mvn, or npm, and it never reaches upstream for anything.
For the end-to-end model (streams, bundle format, signing, verification, trust) see Architecture. For the raw HTTP surface see HTTP API reference.
Running it¶
The high side is the high subcommand of the single artigate binary:
artigate high \
--public-key /etc/artigate/high.ed25519.pub \
--root /var/lib/artigate-high \
--landing /var/spool/diode-in \
--listen :8080
--public-key is the only hard-required flag; startup aborts with --public-key is required if it is empty. The key is the base64 Ed25519 public key produced by artigate keygen on the low side — it must correspond to the private key the low side signs bundles with, or every import fails signature verification.
Flags¶
| Flag | Default | Meaning |
|---|---|---|
--listen |
:8080 |
HTTP listen address |
--root |
/var/lib/artigate-high |
high-side repository root |
--landing |
/var/spool/diode-in |
directory where diode-delivered bundles arrive |
--quarantine |
"" → <root>/quarantine |
directory for out-of-order (future) bundles |
--public-key |
"" (required) |
base64 Ed25519 public key path |
--import-interval |
10s |
bundle import scan interval; 0 disables the background importer |
--apt-gpg-key |
"" |
GPG key id used to sign regenerated APT repositories (InRelease); unset serves them unsigned |
--rpm-gpg-key |
"" |
GPG key id used to sign regenerated RPM repositories (repomd.xml.asc); unset serves them unsigned |
--apk-rsa-key |
"" |
PEM RSA private key path used to sign regenerated Alpine APKINDEX files; unset serves them unsigned |
--apk-key-name |
artigate.rsa.pub |
filename Alpine clients install the APK signing public key under (/etc/apk/keys/<name>) |
--import-interval and --quarantine are the two that most often surprise operators: an empty --quarantine resolves to <root>/quarantine (the resolved value is printed in the startup log), and --import-interval 0 disables the background import goroutine entirely — imports then happen only when you POST /admin/import.
Note
TLS is configured entirely through environment variables (ARTIGATE_TLS_MODE and friends), shared with the low side — there is no TLS flag. The HTTP diode ingest (ARTIGATE_DIODE_INGEST=on) and the UDP diode catcher (ARTIGATE_CATCHER_INTERFACE) are also environment-configured. For the full command-line and environment surface, see the Configuration reference.
On startup NewHighServer makes --root absolute and creates --root, --landing, --quarantine, and <root>/cache/download (all mode 0755). Import progress is persisted to <root>/import-state.json.
The landing directory and the import loop¶
Bundles arrive in --landing as three files sharing a bundle ID — <id>.tar.gz, <id>.manifest.json, and <id>.manifest.json.sig — where the ID is <stream>-bundle-<seq>, e.g. go-bundle-000042 or python-bundle-000007. How they arrive is up to the deployment: a folder-moving diode, an upload to the HTTP ingest endpoint (PUT/POST /diode/<file>, which triggers an immediate import when a bundle completes), or the UDP catcher reassembling a fiber transmission. ArtiGate only reads what lands.
When --import-interval > 0 a background goroutine ticks on that interval and calls the same import routine that POST /admin/import calls. Each pass, per stream:
- Sort stray bundles. Every complete bundle in
--landingis classified against that stream's next-expected sequence: a supported future bundle no more than 10,000 positions ahead moves to quarantine; an already-imported bundle moves to<landing>/duplicates; unsupported or excessively-future IDs move to<root>/rejected. - Drain each stream in order. Starting at
last-imported + 1, the importer looks in landing and quarantine, imports it, advances, and repeats until the next sequence is missing. A gap or failure in one stream never prevents the other supported streams from draining. A bundle that fails signature, manifest, or archive verification moves to<root>/rejectedwith a bounded reason file; operational failures remain in place for retry.
Imports are strictly consecutive within a stream. The manifest carries a previous_sequence that must equal the high side's current last-imported sequence for that stream, so bundles cannot be skipped forward. A permanently missing bundle blocks its own stream forever but never the others.
Tip
Quarantine is self-healing. Because the drain loop searches quarantine as well as landing, a future bundle that arrived early sits in quarantine until its predecessor imports, then gets picked up automatically on the next pass — no operator action required.
Quarantine of out-of-order bundles¶
| Situation | Where the bundle goes |
|---|---|
seq == next-expected |
stays in landing, imported this pass |
seq > next-expected, gap ≤ 10,000 |
moved to --quarantine (default <root>/quarantine) |
| unsupported stream, larger future gap, or invalid signed content | moved to <root>/rejected with <id>.reason.txt |
seq <= last-imported (replay/duplicate) |
moved to <landing>/duplicates |
| successfully imported | its three files move to <landing>/imported |
Warning
Import moves files into <landing>/imported, <landing>/duplicates, quarantine, and <root>/rejected rather than deleting them, and the low side keeps its own <root>/bundles archive. imported, duplicates, and rejected files are reaped after 7 days; quarantine is retained until its gap fills (or an operator clears it), and the low-side bundles archive has no automatic retention — account for disk growth there.
Every imported bundle is verified before it counts: new ed25519ph: signatures stream the bounded manifest through SHA-512 before Ed25519 verification (legacy raw signatures remain readable), the manifest's sequence chain and completeness are validated, and the archive is extracted into a staging area where each file's size and streaming SHA-256 must match the manifest. Installation is write-once — a repo path that already exists must hash identically, or the import fails with an immutable-file conflict. See Architecture for the full verification chain.
Delta bundles and prior files¶
A delta bundle's manifest lists files marked prior — content the low side already shipped in an earlier bundle on the same stream, deliberately left out of the archive. The importer verifies each prior file against the accumulated repository: it must exist at the manifest path with the manifest size (installs are immutable and were hash-verified when they first arrived, so existence + size is sufficient and keeps large delta imports cheap). If a prior file is absent — a fresh high side, or one whose earlier bundles never arrived — the import fails with:
bundle references prior file <path> (sha256 <hash>) that is not in the repository:
import this stream's earlier bundles first, or run a forced (full) re-collect on the low side
Both remedies are low-side actions: re-export the stream's earlier bundles from the archive, or run the collect again with "force": true to produce a full, self-contained bundle.
The dashboard¶
The high side serves a self-contained dashboard at / (and /ui). It embeds its own HTML and JavaScript — no external assets, no CDN — so it works fully air-gapped.
The dashboard shows:
- Import status per stream — last-imported, next-expected, and highest-seen sequence for each supported stream, plus missing-bundle flags. Missing ranges are derived from sorted observed sequences, so their cost is proportional to files present rather than the numeric gap size. Quarantined sequences are listed.
- A browsable tree — expand each ecosystem one level at a time (modules, versions, files; projects and wheels for Python; packages and versions for npm). APT mirrors expand by suite and then component, mirroring the repository structure. The inventory is memoized for 3 seconds, so freshly imported content appears within that window.
- Detail — per-item metadata: for a Go module@version, the module, version, publish time, zip size and SHA-256, the proxy path, and the full
go.mod; for a Python wheel, filename, version, size, download path, and SHA-256; for containers, the pull reference and image layer history; for AI models, the quantization/format read from the model config, the copyableollama pullreference, and the raw-GGUF download path (or a repository snapshot's pinned commit, file count, and sizes). - Direct downloads — every file-backed leaf's detail panel carries download buttons for the artifact's files (the module zip, the wheel, each Maven jar/pom, each per-architecture
.deb/.rpm, the npm tarball, the raw GGUF, an uploaded file), so an operator can pull a single artifact from the browser without configuring a client. Container images stay pull-only — a multi-blob image has no single file to download. - The "Set me up" guide — client configuration for the repository-style ecosystems (APT, RPM, containers, AI models), including whether each repo is signed. A repo reports
signed: trueexactly when the high side republishes it with its own GPG signature (i.e. when--apt-gpg-key/--rpm-gpg-keyis set), so the guide can tell clients whether to verify signatures. The APT guide is pinned to the tree's component nodes and offers a release picker: choose among the mirror's suites and tick the components to include, and the rendered deb822 stanza follows, exact to what is actually mirrored per suite. The AI Models guide builds itsollama pull, raw-GGUF, andHF_ENDPOINT/vLLM blocks from what is actually mirrored, and the containers guide renders thedaemon.jsoninsecure-registriesblock with the real host and port when serving plain HTTP.
The dashboard is backed by read-only JSON endpoints under /ui/api/ (overview, tree, detail, repos); see HTTP API reference.
The serving model¶
HighServer is the sole HTTP handler. It runs a fixed, ordered dispatch chain — admin/health, diode ingest, then each ecosystem, then the UI — where the first handler that claims the request wins and anything unclaimed returns 404 not found. Every ecosystem handler is read-only: only GET and HEAD are allowed (405 otherwise; the container registry returns its own UNSUPPORTED error).
Top-level URL prefixes¶
| Prefix | Serves |
|---|---|
/go |
Go module proxy (GOPROXY protocol) |
/simple |
Python (PyPI) simple index (wheel downloads live under /packages/) |
/maven |
Maven / Java artifacts |
/npm |
npm registry API and tarballs |
/apt |
APT (Debian/Ubuntu) repositories |
/rpm |
RPM (RHEL/Fedora) repositories |
/v2 |
Container images (OCI / Docker Registry v2 API) — also answers Ollama pulls for mirrored GGUF models |
/hf, /api/models, /<org>/<repo>/resolve/… |
AI models: raw GGUF downloads and the Hugging Face Hub download API |
/crates |
Rust crates (cargo sparse registry: /crates/index/… + /crates/dl/…) |
/.well-known/terraform.json, /terraform |
Terraform / OpenTofu provider and module registry protocols plus the artifact files |
/helm |
Helm chart repositories (/helm/<mirror>/index.yaml + chart archives) |
/nuget |
NuGet v3 feed (service index at /nuget/v3/index.json, flat container, registration, search) |
/apk |
Alpine repositories (/apk/<mirror>/<branch>/<repo>/<arch>/…, plus /apk/keys/<name> when index signing is configured) |
/diode |
Bundle ingest (PUT/POST, only when ARTIGATE_DIODE_INGEST=on) |
/ui |
Dashboard (also served at /) |
/healthz |
Liveness check → ok\n |
/readyz |
Readiness check → 200 ok, or 503 naming the failing checks (blocked streams, undrained backlog, stalled/failing import passes, exhausted transport quota) |
/metrics |
Prometheus telemetry |
/admin/* |
POST /admin/import, GET /admin/status, GET /admin/missing |
Note
Two prefixes are easy to get wrong. Python's index prefix is /simple (not /python), with downloads under /packages/; containers use /v2 (the Docker Registry v2 API), where a bare /v2/ returns the API-version header and /v2/_catalog lists repositories. The /v2/ space is shared: a request for a mirrored Hugging Face model (/v2/<org>/<model>/…) is answered by the model registry first, and everything else falls through to the container registry — which is what lets ollama pull <host>/<org>/<model>:<tag> and docker pull <host>/docker.io/library/alpine:3.20 coexist on one port.
GET /admin/status and GET /admin/missing are aliases — both return the identical import-status JSON. Both are read-only: stray landing bundles are sorted into quarantine/duplicates only by import passes (the background loop, a diode-ingest kick, or POST /admin/import), never by a status read.
The trust model¶
The high side treats the verified artifacts themselves as the only source of truth and regenerates all repository metadata locally. It never serves a transferred index, Release/Packages file, RPM repomd, npm packument, or Go "latest" record:
- APT repositories are rebuilt per suite from the
.debstanzas actually present; the regeneratedInReleaseis signed only if--apt-gpg-keyis set, otherwise served unsigned. - RPM repodata is rebuilt from the installed packages;
repomd.xml.ascis signed only if--rpm-gpg-keyis set. - npm metadata is regenerated from each tarball's own embedded
package.json. - Go listings are computed by scanning the on-disk
@vdirectory and filtering to complete versions — a version is served only once its.completemarker plus.info,.mod, and.zipare all present, so half-installed versions are never visible. - Crates sparse-index files are regenerated from the manifest-carried index lines whose
cksumequals the verified artifact's SHA-256, listing only releases whose.crateis present. - Terraform provider metadata is rebuilt after cross-checking every zip against the mirrored
SHA256SUMS— the document terraform verifies the GPG signature of — and lists only platforms whose zip is present. - Helm
index.yamlis regenerated per mirror from each chart's own embeddedChart.yaml, with digests recomputed from the artifacts. - NuGet feed metadata (versions, registration, search) is regenerated from each package's own embedded
.nuspec. - Alpine
APKINDEX.tar.gzis regenerated per branch/repo/arch from the accumulated stanzas of the.apkfiles present; signed only if--apk-rsa-keyis set. - Conda
repodata.jsonis regenerated per subdir from the manifest-carried repodata entries whose package files are present. - RubyGems compact-index files (
/versions,/info/<gem>,/names) are regenerated from the verbatim info lines whose.gemis present. - Composer
packages.jsonand p2 metadata are re-rendered from the verified (dist/source-stripped) version objects, with dist URLs pointing back at the mirror and shasums recomputed. - VS Code gallery metadata is regenerated from each
.vsix's own embeddedpackage.json. - Ansible Galaxy v3 API documents are regenerated from each artifact's own embedded
MANIFEST.json, with SHA-256 and size recomputed from the file. - CRAN
PACKAGES(.gz)is regenerated from each tarball's own embeddedDESCRIPTION, listing only the newest present release per package. - Git pack indexes (
.idx),info/refs, andHEADare rebuilt after re-verifying the pack object by object; refs whose objects are missing are dropped rather than served dangling.
The high side therefore serves only complete versions of fully verified content. Optional repo signing (--apt-gpg-key, --rpm-gpg-key, --apk-rsa-key) lets you re-sign the regenerated APT/RPM/Alpine metadata with a key clients on the trusted network already trust; leaving them unset serves those repositories unsigned.
Always unauthenticated¶
Warning
The high side is never authenticated. There is no auth flag or environment variable for it — every route (the dashboard, /admin/import, /admin/status, /admin/missing, /healthz, /readyz, /metrics, and all ecosystem proxies) is open. The one exception is the optional diode ingest endpoint: enabling it requires an ARTIGATE_DIODE_TOKEN bearer token of at least 32 bytes. That token protects the disk from unauthenticated uploads, nothing more. Access control is expected to come from network placement: bind it to the trusted/isolated network, or front it with a reverse proxy. TLS (via the shared ARTIGATE_TLS_* variables) provides transport encryption but not authentication. See Security & trust.
Pointing clients here¶
Once the high side is serving, configure clients against its base URL. Each ecosystem page has the exact client-side setup and its "Set me up" details:
| Ecosystem | Client setup |
|---|---|
| Go modules | GOPROXY=<base>/go,off, GOSUMDB stays on (served from /go/sumdb/) — see Go modules |
| Python (PyPI) | index URL <base>/simple/ — see Python (PyPI) |
| Java (Maven) | mirror <base>/maven/ — see Java (Maven) |
| NPM | registry <base>/npm/ — see NPM |
| APT | deb822 stanza against <base>/apt/<mirror> — see APT (Debian/Ubuntu) |
| RPM | baseurl=<base>/rpm/<mirror> — see RPM (RHEL/Fedora) |
| Containers | docker pull <host>/<registry>/<repository>:<tag> — see Container images (OCI) |
| AI models | ollama pull <host>/<org>/<model>:<tag>, raw GGUF under /hf/, or HF_ENDPOINT=<base> — see AI models (Hugging Face) |
| Rust crates | ~/.cargo/config.toml source replacement with registry = "sparse+<base>/crates/index/" — see Rust crates |
| Terraform / OpenTofu | ~/.terraformrc network_mirror (<base>/terraform/v1/providers/, HTTPS required) or host-prefixed source addresses — see Terraform / OpenTofu |
| Helm | helm repo add artigate <base>/helm/<mirror> — see Helm charts |
| NuGet | nuget.config source <base>/nuget/v3/index.json with <clear /> — see NuGet |
| Alpine (apk) | /etc/apk/repositories line <base>/apk/<mirror>/<branch>/<repo>, key from /apk/keys/<name> — see Alpine (apk) |
| Conda | conda/mamba/micromamba with --override-channels -c <base>/conda/<mirror> — see Conda channels |
| RubyGems | Gemfile source "<base>/rubygems" — see RubyGems |
| PHP Composer | composer.json repository <base>/composer with "packagist.org": false — see PHP Composer |
| VS Code extensions | VSCODE_GALLERY_SERVICE_URL=<base>/vsx/gallery (or product.json extensionsGallery.serviceUrl) — see VS Code extensions |
| Ansible Galaxy | ansible-galaxy collection install ns.name -s <base>/galaxy/ — see Ansible Galaxy |
| R (CRAN) | install.packages("pkg", repos = "<base>/cran") — see R packages (CRAN) |
| Git | git clone <base>/git/<mirror>.git — see Git repositories |
| OSV advisories | curl <base>/osv/<ecosystem>/all.zip for osv-scanner --offline; npm audit answers from the npm registry — see OSV advisories |
| Uploads | curl <base>/uploads/<folder>/<name> — see Uploads |
For the overview of all twenty-two streams see Ecosystems. For production hardening and layout see Deployment.