Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

Holger guards your artifacts at rest

OpenSSF Best Practices

Holger is a secure artifact gateway in pure Rust. It pulls packages from the internet (PyPI, npm, Maven, crates.io, OCI, Go, APK), verifies, scans and approves each one, and serves only what passed. An on-prem downstream takes the approved set as a signed bundle, over the network or on a USB stick, and serves the site's builds with no other network.

Install: four products

Holger is four products, one crate each:

product install from a clone crates binary what it is
holger cargo install holger cargo install --locked --path server/cli 349 58.0 MB the server: the twelve formats, the intake chain (scanners, approvals, audit), the offline bundle and intel verbs
holger-downstream cargo install holger-downstream cargo install --locked --path holger-downstream 328 48.7 MB the airgapped downstream: takes packages from ONE upstream Holger (a carried bundle or a sync), verifies them, serves the twelve formats, runs no scanner and dials nothing else
holger-ui cargo install holger-ui cargo install --locked --path ../holger-ui/crates/holger-ui 98 6.8 MB the web console: the console bundle and a same-origin pass-through to the holger it points at
holger-cli cargo install holger-cli cargo install --locked --path holger-cli 240 22.2 MB the command-line tool: every console action as a command (it dials only --at), the airgap bundle, the offline verifier and the courier

The extras are features of the same crates: --features all-formats (NuGet, gems, conda, Composer), online (the pull-through clients), federation (Artifactory, Nexus, AWS and Azure through Skidbladnir), and full (all three).

A clone needs no registered key: git clone ssh://anonymous@git.gunnar.rs:2222/nordisk/holger.git (and nordisk/holger-ui beside it for the console). The default of every product dials nothing: docs/v2/02-install.md.

Three products by hand

This guide installs the server, the console and the downstream from their crates, connects the downstream to the server online, serves a pip package through the downstream, and then carries the same package over an airgap as a signed bundle. Every command is copy-pasteable on one Linux host. You need cargo, rootless podman, openssl, curl, python3 and pip. The integration test cargo xtask products runs exactly these blocks, in this order.

0. The places and the names. Everything lives under $G; the two Holgers and the console listen on loopback.

export G="${G:-$HOME/holger-guide}"
export HQ="${HQ:-127.0.0.1:8080}" HQ_GRPC="${HQ_GRPC:-127.0.0.1:50051}"
export DS="${DS:-127.0.0.1:8090}" DS_GRPC="${DS_GRPC:-127.0.0.1:50061}"
export UI="${UI:-127.0.0.1:3210}" AIR="${AIR:-127.0.0.1:8095}" AIR_GRPC="${AIR_GRPC:-127.0.0.1:50065}"
mkdir -p "$G"/{pki,hq,ds,air,stick,dl}

1. The server

Install the server and the command-line tool:

cargo install holger holger-cli

Every scan runs in a container, never in the server. Build the scan worker's image once (a static program in an image FROM scratch; no registry is reached):

rustup target add x86_64-unknown-linux-musl
cargo install --target x86_64-unknown-linux-musl --root "$G/worker" holger-scan-worker
mkdir -p "$G/worker/ctx" && cp "$G/worker/bin/holger-scan-worker" "$G/worker/ctx/"
printf 'nonroot:x:65532:65532:nonroot:/scratch:/sbin/nologin\n' > "$G/worker/ctx/passwd"
printf 'nonroot:x:65532:\n' > "$G/worker/ctx/group"
printf '%s\n' 'FROM scratch' 'COPY --chmod=0555 holger-scan-worker /holger-scan-worker' \
  'COPY passwd /etc/passwd' 'COPY group /etc/group' 'USER 65532:65532' \
  'ENV HOME=/scratch TMPDIR=/scratch' 'ENTRYPOINT ["/holger-scan-worker"]' 'CMD ["/in"]' > "$G/worker/ctx/Containerfile"
podman build --pull=never --network=none -t localhost/holger-scan-worker:3.1.0 "$G/worker/ctx"

The keys: the server's evidence signer (a P-256 leaf under its own root), and the two people of a bundle (an approver and a distinct release signer, Ed25519). The tokens: an importer, a security approver, an admin, an auditor, and the downstream's own.

cd "$G/pki"
umask 077  # every key and token here is the user's alone: Holger refuses one others can read (holger-key-1)
openssl ecparam -name prime256v1 -genkey -noout -out scan-root.key
openssl req -x509 -new -key scan-root.key -subj "/CN=hq scan root" -days 30 \
  -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,digitalSignature" -outform der -out scan-root.der
openssl x509 -inform der -in scan-root.der -out scan-root.pem
openssl ecparam -name prime256v1 -genkey -noout -out scan.key
openssl pkcs8 -topk8 -nocrypt -in scan.key -outform der -out scan.pk8
openssl req -new -key scan.key -subj "/CN=hq scanners" -out scan.csr
printf 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\n' > leaf.ext
openssl x509 -req -in scan.csr -CA scan-root.pem -CAkey scan-root.key -CAcreateserial -days 30 -extfile leaf.ext -outform der -out scan.der
for who in reviewer releaser; do
  openssl genpkey -algorithm ed25519 -out $who.key
  openssl req -x509 -key $who.key -subj "/CN=$who" -days 30 -out $who.crt
  openssl x509 -in $who.crt -outform der -out $who.der
  openssl pkcs8 -topk8 -nocrypt -in $who.key -outform der -out $who.pk8
done
for who in ida bob adm aud lab; do openssl rand -hex 24 > $who.token; done
fp() { openssl x509 -in "$1" -outform der | sha256sum | cut -d' ' -f1; }
policy() { cat <<EOF
(schema_version: "holger.bundle-trust-policy/v1", trust_mode: Some(PinnedSigners), trusted_roots: [],
 approved_signers: [(fingerprint: "$(fp reviewer.crt)", roles: ["security-reviewer"])],
 required_approval_roles: ["security-reviewer"], minimum_distinct_approvers: 1,
 trusted_release_signers: ["$(fp releaser.crt)"], release_signer_must_be_distinct: true,
 require_expiry: true, on_expired_metadata: Some(ReadOnly)$1)
EOF
}
policy "" > trust-policy.ron
policy ', allowed_products: ["holger.intel/v1"]' > intel-trust.ron
umask 022
cd "$G"

The server's RON: the intake chain's four repositories and a bundles repository (what the downstream pulls), the five tokens and their duties, the evidence signer, the offline advisory database, and the sync key that signs what it sends downstream.

repo() { printf '(ron_name: "%s", ron_repo_type: "raw", ron_upstreams: [], ron_in: None, ron_out: Some((ron_storage_endpoint: "disk", ron_exposed_endpoint: "door")), ron_data_dir: Some("%s/%s")),\n' "$1" "$2" "$1"; }
cat > hq.ron <<EOF
(
    exposed_endpoints: [ (ron_name: "door", ron_url: "$HQ_GRPC", ron_http_url: Some("$HQ")) ],
    storage_endpoints: [ (ron_name: "disk", ron_storage_type: "znippy", ron_path: "$G/hq/store") ],
    repositories: [ $(for r in incoming quarantine approved production bundles; do repo $r "$G/hq"; done) ],
    audit: (dir: "$G/hq/audit", enabled: true),
    auth: Some((
        methods: [
            StaticToken(subject: "ida", token: Some("$(cat pki/ida.token)")),
            StaticToken(subject: "bob", token: Some("$(cat pki/bob.token)")),
            StaticToken(subject: "adm", token: Some("$(cat pki/adm.token)")),
            StaticToken(subject: "aud", token: Some("$(cat pki/aud.token)")),
            StaticToken(subject: "lab", token: Some("$(cat pki/lab.token)")),
        ],
        roles: { "ida": Writer, "adm": Admin, "aud": Reader, "lab": Reader },
        duties: { "ida": [Importer], "bob": [SecurityApprover], "adm": [RepositoryAdmin], "aud": [Auditor] },
    )),
    attestation: Some((trust_roots: ["$G/pki/scan-root.der"], signer_key: Some("$G/pki/scan.pk8"), signer_cert: Some("$G/pki/scan.der"), signer_alg: Some("ecdsa-p256"))),
    intel: Some((db_dir: "$G/hq/intel-db", offline: true, trust_policy: Some("$G/pki/intel-trust.ron"))),
    sync: Some((name: "hq")),
    sources: [ (name: "team", kind: upload) ],
    steps: (approvals: 1, unknown_license: Pass, scan: [inspection, vulnerability, license, sbom]),
    rules: (require_signed: false, require_provenance: false),
)
EOF

The vulnerability scan reads a local advisory database, never the network. Fill it from the public feeds (download them on a connected machine; the server itself dials nothing), signed as an intel bundle by the same two people. The first bundle of a kind sets the anti-rollback floor, and only an admin's explicit grant lets it (allow-first-import, used once, audited). Then start the server:

mkdir -p feeds
curl -fsSLo feeds/osv-PyPI.zip https://osv-vulnerabilities.storage.googleapis.com/PyPI/all.zip
curl -fsSLo feeds/kev.json https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json
holger intel build --feeds-dir feeds --policy pki/intel-trust.ron --release-cert pki/releaser.der --release-key pki/releaser.pk8 \
  --approve "security-reviewer=pki/reviewer.der,pki/reviewer.pk8" --out stick/intel.holger.znippy
holger bundle allow-first-import --config hq.ron --from-bundle stick/intel.holger.znippy --policy pki/intel-trust.ron
holger intel import stick/intel.holger.znippy --config hq.ron
holger start --config hq.ron > hq.log 2>&1 &
until curl -fsS "http://$HQ/healthz" > /dev/null 2>&1; do sleep 0.2; done

2. The console

cargo install holger-ui
HOLGER_UI_BIND="$UI" HOLGER_UI_HOLGER="$HQ_GRPC" HOLGER_UI_DOOR="$HQ" holger-ui > ui.log 2>&1 &
until curl -fsS "http://$UI/health" > /dev/null 2>&1; do sleep 0.2; done

Open http://127.0.0.1:3210/login and paste the admin's token (cat $G/pki/adm.token). The console keeps it as its session and every call goes to the server with it. The same sign-in, by hand:

curl -fsS -c ui.cookies -H 'content-type: application/json' -d "{\"token\":\"$(cat pki/adm.token)\"}" "http://$UI/-/console/token"
curl -fsS -H "Cookie: __Host-holger_session=$(cat pki/adm.token)" "http://$UI/-/sync/board"

3. The downstream, online

A downstream is one-way by default: its packages come in only through its inbox, and it refuses any way out. Online, it fetches from its one upstream: install it with fetch, and its RON says boundary: Fetch. Write its RON (the same intake chain, an inbox the bundles land in, the server's evidence roots it trusts, and the server as its one upstream), and print its sync key:

cargo install holger-downstream --features fetch
cat > ds.ron <<EOF
(
    exposed_endpoints: [ (ron_name: "door", ron_url: "$DS_GRPC", ron_http_url: Some("$DS")) ],
    storage_endpoints: [ (ron_name: "disk", ron_storage_type: "znippy", ron_path: "$G/ds/store") ],
    repositories: [ $(for r in incoming quarantine approved production; do repo $r "$G/ds"; done)
        (ron_name: "inbox", ron_repo_type: "raw", ron_upstreams: [], ron_in: None, ron_out: None, ron_data_dir: Some("$G/ds/bundle.inbox")) ],
    audit: (dir: "$G/ds/audit", enabled: true),
    boundary: Fetch,
    attestation: Some((trust_roots: ["$G/pki/scan-root.der"], enrolled_signers: ["$G/pki/scan.der"])),
    sync: Some((name: "lab", upstreams: [ (name: "hq", key: "$(curl -fsS -H "Authorization: Bearer $(cat pki/lab.token)" "http://$HQ/-/sync/key" | python3 -c 'import json,sys; print(json.load(sys.stdin)["key"])')", invite: Some("$G/lab.invite.json"), token_file: Some("$G/pki/lab.token")) ])),
    mirror: Some((ron_state_dir: "$G/ds/mirror", ron_sources: [ (ron_name: "hq", ron_kind: Holger, ron_endpoint: "http://$HQ_GRPC",
        ron_credential: Anonymous, ron_every_secs: 2, ron_retry_secs: 2,
        ron_repositories: [ (ron_from: "bundles", ron_to: "inbox", ron_unproven: TrustSize) ]) ])),
    steps: (trust_policy: Some("$G/pki/trust-policy.ron")),
)
EOF
holger-downstream sync key --config ds.ron

Enrol it on the server and invite it: in the console, Sync board, Enrol a downstream (name lab, the key it printed, via front), then Invite on its row, which hands out the signed invite (lab.invite.json). The same with the command-line tool. The invite is signed with the server's sync key, for this downstream's key, and names the server's sync door: the downstream's RON names the invite instead of a url:, and its start verifies it once and keeps it. The downstream's admin also grants the first bundle of the server's pypi product the right to set its anti-rollback floor (without the grant, the first bundle is refused: nothing was ever accepted here):

LAB_KEY=$(holger-downstream sync key --config ds.ron | awk '{print $NF}')
curl -fsS -X POST -H "Authorization: Bearer $(cat pki/adm.token)" "http://$HQ/-/sync/downstreams/enrol?name=lab&key=$LAB_KEY&via=front"
holger-cli --at "$HQ_GRPC" --token-file pki/adm.token downstream invite --name lab --out lab.invite.json
holger-downstream bundle allow-first-import --config ds.ron --vendor hq --product pypi --sequence 1
holger-downstream start --config ds.ron > ds.log 2>&1 &
until curl -fsS "http://$DS/healthz" > /dev/null 2>&1; do sleep 0.2; done
grep "invited this downstream directly" ds.log

4. A pip package through the downstream

Upload a wheel to the server as the importer, walk it through the intake chain (scanned, policy-checked, approved by somebody who did not import it, sealed, available), export it as a signed bundle and publish the bundle where the downstream pulls from:

pip download --no-deps --only-binary=:all: -d dl idna==3.10
WHEEL=$(basename dl/idna-*.whl)
curl -fsS -X PUT -H "Authorization: Bearer $(cat pki/ida.token)" --data-binary @"dl/$WHEEL" "http://$HQ/incoming/$WHEEL"
cli() { who=$1; shift; holger-cli --at "$HQ_GRPC" --token-file "pki/$who.token" "$@"; }
cli ida intake advance --name "$WHEEL" --reason "scan"
cli ida intake advance --name "$WHEEL" --reason "policy"
cli bob intake advance --name "$WHEEL" --reason "approved"
for s in sealed promoted available; do cli adm intake advance --name "$WHEEL" --reason "$s"; done
cli aud intake state --name "$WHEEL"
cli adm bundle export --out bundle-1 --bundle-id idna-1 --sequence 1 --supplier hq --product pypi --release-version 1
holger-cli bundle approve --candidate bundle-1 --role security-reviewer --cert pki/reviewer.crt --key pki/reviewer.key
holger-cli bundle finalize --candidate bundle-1 --policy pki/trust-policy.ron --release-cert pki/releaser.crt --release-key pki/releaser.key --output stick/idna-1.holger.znippy
curl -fsS -X PUT -H "Authorization: Bearer $(cat pki/adm.token)" --data-binary @stick/idna-1.holger.znippy "http://$HQ/bundles/idna-1.holger.znippy"

The downstream pulls the bundle, verifies it (the release signature, the approval, every payload hash, the server's signed scan evidence) and lands the wheel. pip installs it from the downstream:

until curl -fsS "http://$DS/production/simple/idna/" > /dev/null 2>&1; do sleep 1; done
pip install --no-cache-dir --index-url "http://$DS/production/simple/" idna==3.10

The server's Sync board names the downstream and what it applied: curl -fsS -H "Authorization: Bearer $(cat pki/aud.token)" "http://$HQ/-/sync/board".

5. The airgap: a bundle on a stick

A downstream with no network takes the same signed bundle from a stick. Verify it with the command-line tool (offline), then import it into an airgapped downstream: the plain one-way holger-downstream with network: Offline (a dial is refused before a socket opens), its admin granting this first bundle. Install the one-way build beside the online one (it compiles in no way out at all):

holger-cli verify stick/idna-1.holger.znippy --policy pki/trust-policy.ron
cargo install --root "$G/air" holger-downstream
sed -e "s|$G/ds|$G/air|g" -e "s|$DS_GRPC|$AIR_GRPC|; s|$DS\"|$AIR\"|" -e '/^    boundary:/d; /^    sync:/d; /^    mirror:/,/ron_unproven/d' ds.ron | sed '1a\    network: Offline,' > air.ron
"$G/air/bin/holger-downstream" bundle allow-first-import --config air.ron --from-bundle stick/idna-1.holger.znippy --policy pki/trust-policy.ron
"$G/air/bin/holger-downstream" bundle import --config air.ron --bundle stick/idna-1.holger.znippy --policy pki/trust-policy.ron
"$G/air/bin/holger-downstream" start --config air.ron > air.log 2>&1 &
until curl -fsS "http://$AIR/healthz" > /dev/null 2>&1; do sleep 0.2; done
pip install --no-cache-dir --index-url "http://$AIR/production/simple/" --target air-site idna==3.10

Stop the four programs: kill %1 %2 %3 %4.

Speed, measured

Every number here is generated from the newest holperf run on real packages (holperf/results/, cargo xtask bench-readme); none is written by hand.

Measured by holperf on real packages: oden (32 threads, 499 GiB RAM), 2026-11-01, holger ad6d66db; load1 26.6 before the run, 26.6 to 56.1 during it. Every set was fetched by its real client from its real registry (holperf/sets/, cargo xtask bench-readme fetch).

set what packages files MB
pip wheels from pypi.org (pip download) 70 70 127
npm tarballs from registry.npmjs.org (npm pack) 40 40 35.9
maven jars and poms from Maven Central 30 60 42.3
helm charts from their projects' repositories (helm pull) 17 17 2.40
apt Debian trixie .debs (apt-get download) 65 65 75.3
dnf Fedora 42 .rpms (dnf5 download) 57 57 127
oci container images (podman save, OCI layout) 8 8 413

Each row first shows what it costs to get there: open the store and answer the first index request after it (cold page cache; time and CPU), and the peak RSS of the bench process over that format's run (the server and its HTTP clients run in it, with every set's store open; the clients read each reply through one buffer and keep no body, so the memory is the server's, with the server's allocator settings). Every set must stay within 512 MiB, half the lean box's 1 GB (cargo xtask bench-readme check). Then the warm numbers: the index in process and over the HTTP door, and downloads over the HTTP door. plain is the format's own sealed store; chain is the estate box's intake chain (every package landed, scanned, approved, sealed, AVAILABLE) serving the same set.

set served by open + first index (ms) its CPU (ms) peak RSS (MiB) index p50 / p99 in process (µs) index over HTTP, c=32 (req/s) small downloads, c=32 (req/s, MB/s) largest file, c=8 (MB/s) load1
pip plain 0.325 + 1.21 1.12 144 2.02 / 2.67 41 853 4 638 / 8 420 9 214 38.9
npm plain 1.25 + 1.95 1.17 139 171 / 46 511 78 347 8 939 / 8 038 8 219 33.7
maven plain 2.12 + 0.036 0.341 144 0.210 / 0.221 60 305 4 527 / 6 396 4 706 45.2
helm plain 2.46 + 39.4 21.2 139 0.241 / 0.251 64 044 31 809 / 4 578 4 942 43.1
apt plain 0.369 + 79.0 77.4 149 1.99 / 2.08 60 158 7 455 / 8 664 8 155 37.5
dnf plain 0.368 + 103 98.3 157 2.77 / 2.92 56 535 4 275 / 9 578 6 752 37.1
oci plain 0.009 + 0.007 0.018 180 3.90 / 4.69 38 860 591 / 5 444 3 680 47.7
pip chain 0.021 + 28.1 5.21 191 1.30 / 1.61 36 440 2 513 / 3 949 7 515 56.1
npm chain 0.024 + 25.6 5.75 177 2.65 / 5.65 58 757 9 135 / 8 217 8 666 48.8
maven chain 0.017 + 24.1 4.44 175 2.43 / 3.03 65 918 5 673 / 8 005 7 835 44.9
helm chain 0.026 + 26.6 5.69 166 2.52 / 3.15 71 105 32 485 / 4 677 2 763 42.9
apt chain 0.029 + 46.8 10.7 177 4.50 / 4.94 54 846 6 081 / 7 045 7 671 43.1
dnf chain 0.017 + 30.1 6.76 191 5.24 / 6.26 53 758 4 166 / 9 379 9 263 36.5

The chain held 304 AVAILABLE files: every set above but OCI, landed through the intake chain with the sandbox off (the scan worker's container is not what this measures). The intake policy refused 5, which the chain rows do not serve: distlib-0.3.9-py2.py3-none-any.whl, numpy-2.1.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl, pip-24.2-py3-none-any.whl, setuptools-75.1.0-py3-none-any.whl, virtualenv-20.26.6-py3-none-any.whl.

Documentation

docs/v2/README.md: what Holger is, install, the RON, the intake chain, pairing, the Build Thing, the supply chain, the estate, security and the reference. The earlier pages are in docs/v1/.

On gunnar.rs, pages below the top folder are shown to signed-in users only for now. Guests read the same file raw: https://www.gunnar.rs/api/repos/nordisk/holger/raw?path=docs/v2/README.md

Release provenance (SLSA Build L2; why not L3 is said there): docs/v2/07-build-thing.md, the bundles in attestations/ and the release key in slsa/.

Development: run git config core.hooksPath .githooks once per clone; the hooks keep this README (generated from .nornir/README.md) and the AI-context packs in sync.

Contributing, security and changes

License

AGPL-3.0-only, © 2026 Vetra Automation AB and Ignalina ApS (Rickard Ernst Björn Lundin) (jointly owned in equal shares, 50/50). Commercial terms are available from both co-owners jointly; write to gunnar@gunnar.rs. Versions published to crates.io on or before 2026-10-02 keep the license they were published with (mostly MIT). See LICENSE, NOTICE, COMMERCIAL-LICENSING.md, TRADEMARKS.md and THIRD_PARTY_LICENSES.md.

Files

Newest commits