Installing USCP in an air-gapped environment
For networks with no internet egress. USCP runs fully offline: the license is a signed bundle you import via approved transfer, the trusted JWKS is mounted locally, and the runtime never phones home. It is the same static binary and the same feature set as the online build — air-gap is a licensing mode, not a stripped SKU.
Read README.md for the general model. The two things that differ air-gapped: you build/transfer artifacts outside the enclave, and you use LICENSING_MODE=airgap with a locally-generated (or vendor-supplied) bundle.
Overview of the offline flow
┌─ Connected build host ─┐ approved ┌─ Air-gapped host ─┐
│ build static binary │ transfer │ verify signature │
│ generate/obtain license│ ──────────────────▶ │ import license │
│ cosign-sign + SBOM │ (USB / one-way / DMZ) │ run controlplane │
└────────────────────────┘ └────────────────────┘
You never run git clone or go build inside the enclave. You transfer a bundle of already-built artifacts.
Bundle contents
Produced from one release tag on a connected build host (deploy/airgap/README.md):
| File | Purpose |
|---|---|
controlplane | the static binary (CGO_ENABLED=0, no external deps) |
controlplane.bundle | cosign signature bundle — verify before install |
sbom.cdx.json / cbom.cdx.json | software/crypto bill of materials (offline-verifiable) |
entitlement.lic | signed entitlement bundle (compact JWS) — operator-imported |
jwks.json | trusted licensing JWKS (verifies the entitlement signature) |
migrations/ | shipped for review (also embedded in the binary) |
deploy/airgap/install.sh | verify-then-install script |
Part 1 — On the connected build host
Step 1 — Build the static binary
git clone https://github.com/cfssay/doublelogic_apex uscp && cd uscp/codes
make build-bin # CGO_ENABLED=0 static build → ./controlplane
# or explicitly:
CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o controlplane ./cmd/controlplane
Step 2 — Generate a license bundle
If DoubleLogic supplied you a signed entitlement.lic + jwks.json, skip to Step 3. Otherwise generate a self-signed bundle with the built-in tool:
# bound to a tenant, valid 365 days by default
go run -tags devtools ./cmd/devlicgen -out ./airgap-out -tenant <tenant-slug>
# → writes airgap-out/dev-entitlement.lic and airgap-out/dev-jwks.json
devlicgen flags: -out (output dir, default deploy), -tenant (the sub/tenant id, default dev-tenant), -installation (bind to an installation id / aud; empty = unbound), -bind (lock the license to a single host fingerprint — see below; empty = portable), -days (validity, default 365). A convenience target exists too:
make dev-license # runs devlicgen -out deploy -tenant dev-tenant
Single-deployment host binding
A license can be cryptographically locked to one machine so a valid, signed bundle cannot be copied to a second host (§36 "un-duplicatable, single-deployment"). The binding is a signed bind claim carrying the target host's fingerprint — a salted, non-reversible hash of stable machine identity (/etc/machine-id, primary MAC, hostname). Verification refuses the bundle on any host whose derived fingerprint differs, so lifting the .lic onto another box fails closed with host_binding_mismatch.
Because the fingerprint is derived on the target host, air-gap binding is a short two-pass flow:
# 1) INSIDE the enclave, on the target host — read its fingerprint:
./controlplane fingerprint # → e.g. 4f3c…a19 (also: licensectl fingerprint)
# 2) Carry that value out to the connected build host and mint a bound license:
go run -tags devtools ./cmd/devlicgen -out ./airgap-out -tenant <tenant-slug> -bind 4f3c…a19
# 3) Transfer the bundle back in and (recommended) pre-flight it on the target host:
licensectl verify-bundle --bundle entitlement.lic --jwks jwks.json # defaults to THIS host's fingerprint
licensectl doctor --bundle entitlement.lic --jwks jwks.json # includes a host-binding check
For containers/Kubernetes (where /etc/machine-id may be ephemeral) or a deliberately node-locked HA pair, set a stable USCP_HOST_FINGERPRINT on the target and bind to that same value; protect it like a secret. Leaving it unset on bare-metal/VM installs gives the strongest (pure-hardware) lock. An unbound license (-bind omitted) stays portable — use it only for dev/testing.
Anti-rollback: air-gap installs also keep a tamper-evident, host-bound monotonic time high-water mark next to the boot evidence, so turning the system clock back cannot resurrect an expired bundle.
Rename
dev-entitlement.lic→entitlement.licanddev-jwks.json→jwks.jsonfor the canonical air-gap layout, or keep the names and pass the paths explicitly at runtime.
Step 3 — (Recommended) sign the binary
cosign sign-blob --bundle controlplane.bundle controlplane
Step 4 — Stage the transfer bundle
mkdir uscp-airgap
cp controlplane controlplane.bundle entitlement.lic jwks.json uscp-airgap/
cp deploy/airgap/install.sh deploy/verify-deployment.sh uscp-airgap/
# optionally: sbom.cdx.json cbom.cdx.json, migrations/
tar czf uscp-airgap.tgz uscp-airgap
sha256sum uscp-airgap.tgz # record this; verify it after transfer
Move uscp-airgap.tgz into the enclave via your approved transfer path (one-way diode, vetted USB, DMZ scan).
Part 2 — Inside the air-gapped enclave
Step 5 — Prepare the host and database
- A VM/host meeting the minimum sizing
(2 vCPU / 4 GB / 20 GB).
- PostgreSQL 16 reachable on the internal network, with a database and a non-superuser
role (superusers bypass row-level security — see post-install.md → Database hardening).
- An internal OIDC provider (Keycloak, ADFS, PingFederate). A ready-to-import Keycloak realm
is at deploy/keycloak/realm-export.json. Its issuer is an internal URL; the redirect URI is still https://<internal-name>/auth/callback.
Step 6 — Verify signatures and install
Unpack and run the verify-then-install script (deploy/airgap/install.sh):
sha256sum -c <(echo "<recorded-hash> uscp-airgap.tgz") # confirm integrity
tar xzf uscp-airgap.tgz && cd uscp-airgap
DATABASE_URL='postgres://uscp_app:PASSWORD@db.internal:5432/uscp?sslmode=require' \
./install.sh
The script:
- Verifies the cosign signature (`cosign verify-blob --bundle controlplane.bundle
controlplane) — aborts if it fails. (If cosign` isn't present it warns; verify out-of-band.)
- Exports
LICENSING_MODE=airgap,USCP_AIRGAP_BUNDLE=./entitlement.lic,
USCP_LICENSE_JWKS=./jwks.json.
- Runs
controlplane migrate(applies all migrations). - Runs
controlplane serve.
Manual equivalent (full control)
cosign verify-blob --bundle controlplane.bundle controlplane # supply-chain gate
export DATABASE_URL='postgres://uscp_app:PASSWORD@db.internal:5432/uscp?sslmode=require'
export LICENSING_MODE=airgap
export USCP_AIRGAP_BUNDLE=./entitlement.lic
export USCP_LICENSE_JWKS=./jwks.json
export USCP_OIDC_ISSUER='https://keycloak.internal/realms/uscp'
export USCP_OIDC_CLIENT_ID='uscp'
export USCP_OIDC_CLIENT_SECRET='...'
export USCP_PUBLIC_URL='https://uscp.internal'
export USCP_RP_ID='uscp.internal'
./controlplane migrate
./controlplane serve # listens on :8080
Step 7 — TLS with an internal CA
There is no public Let's Encrypt in an air-gapped network. Either:
- Put USCP behind an internal load balancer holding a certificate from your internal CA, and
forward https://uscp.internal → http://<host>:8080 (set X-Forwarded-Proto: https), or
- Run Caddy with an explicit internal cert: a
Caddyfilewith
tls /etc/ssl/uscp.crt /etc/ssl/uscp.key reverse-proxying localhost:8080.
Step 8 — Run under systemd (production)
Create /etc/systemd/system/controlplane.service (mirrors what deploy/install.sh generates, minus the online build):
[Unit]
Description=USCP control plane (air-gap)
After=network-online.target
Wants=network-online.target
[Service]
User=uscp
EnvironmentFile=/etc/uscp/controlplane.env
ExecStart=/usr/local/bin/controlplane serve
Restart=always
ProtectSystem=strict
NoNewPrivileges=true
ReadWritePaths=/var/lib/uscp
[Install]
WantedBy=multi-user.target
Put all the exported variables above into /etc/uscp/controlplane.env (mode 0600, owner uscp), then:
sudo install -m0755 controlplane /usr/local/bin/controlplane
sudo useradd --system --home /var/lib/uscp uscp 2>/dev/null || true
sudo systemctl daemon-reload && sudo systemctl enable --now controlplane
Step 9 — Verify
curl -fsS https://uscp.internal/healthz
curl -fsS https://uscp.internal/readyz
curl -s http://localhost:8080/.well-known/api-capabilities | jq .capability_count
Then continue at post-install.md → First login.
Refreshing the license
Entitlements expire (default 365 days). To renew without egress: generate/obtain a new signed bundle on the connected host, transfer it in, replace entitlement.lic (and jwks.json if the signing key rolled), and restart:
sudo cp entitlement.lic jwks.json /etc/uscp/
sudo systemctl restart controlplane
The import is audit-logged. The runtime never reaches the network for licensing.
Kubernetes, air-gapped
Load the image into an internal registry (docker save | docker load, or crane/skopeo over the transfer path), then follow kubernetes.md → Air-gap on Kubernetes to mount the bundle. Use the single-instance preset (values-single.yaml) which already defaults to LICENSING_MODE=airgap.