A ctlplne studio product

Air-gapped install and no-phone-home mode

trstctl can run in a disconnected network: certificate issuance, the native secret store, audit, policy, and the web/API surface keep working with no public outbound network path. The air-gap mode has two layers:

  • Runtime egress guard: set TRSTCTL_AIRGAP_ENABLED=true. Public outbound HTTP(S) is denied unless you explicitly allow a host or CIDR. Private and loopback destinations are allowed when TRSTCTL_AIRGAP_ALLOW_PRIVATE=true.
  • Kubernetes network policy: use deploy/helm/trstctl/values-airgap.yaml. It keeps the chart's default-deny posture and scopes PostgreSQL/NATS egress to operator-owned private CIDRs instead of leaving datastore ports open to any IP.

Air-gap mode also fails closed for product telemetry and cloud AI model egress: TRSTCTL_TELEMETRY_ENABLED=true and TRSTCTL_AI_MODEL_MODE=cloud are rejected when air-gap is enabled. Local OTLP collectors, local AI runtimes, PostgreSQL, and NATS can still be used when they live on private addresses or explicit allowlists.

The native secret store is opt-in in the standard Helm chart (secrets.enableAPI=false). The air-gap overlay sets secrets.enableAPI=true, which renders TRSTCTL_SECRETS_ENABLE_API=true so the documented offline store and rotation APIs are served. Keep operator authentication and a durable operator-owned KEK configured before using this surface.

What runs inside, and what needs a path out

Air-gap mode denies public destinations by default. The rows below that need a path out only work when you allowlist the exact host or CIDR, which keeps every outbound dependency explicit and auditable.

Capability In an air-gapped install
Certificate issuance from the built-in CA and your own hierarchy Runs inside.
The native secret store, policy, audit, and the web and API surface Runs inside.
License verification Runs inside: an offline Ed25519 signature check, no license server.
Evidence export and verification Runs inside; signed exports verify offline with pinned keys (trstctl-cli audit verify).
Product telemetry Rejected under air-gap mode. A local OTLP collector on an allowlisted private host is allowed.
AI assistant A local model runtime on an allowlisted private host runs inside; cloud model mode is rejected.
Connectors, DNS providers and external authorities that live inside the gap (web servers, appliances, AD CS, EJBCA, Vault, RFC 2136 or acme-dns) Run inside when their hosts are allowlisted private addresses.
Public ACME authorities, cloud DNS providers, cloud certificate stores, SaaS external authorities Need a path out: allowlist the exact host, or leave them unconfigured.
Transparency-log monitoring Needs a path out to the public logs.

Build the transfer bundle

On a connected build host, verify the release image first, then build the bundle:

export VERSION=v0.5.4
export IMAGE=ghcr.io/ctlplne/trstctl:v0.5.4
export PLATFORM=linux/amd64 # or linux/arm64; match the disconnected nodes

scripts/verify-image.sh "$IMAGE"
make airgap-bundle VERSION="$VERSION" IMAGE="$IMAGE" PLATFORM="$PLATFORM"

If the release image is already staged on the build host, pin the image ID from the trusted release verification record and use the local source mode:

export TRSTCTL_AIRGAP_IMAGE_SOURCE=local
export TRSTCTL_AIRGAP_IMAGE_ID=sha256:<the-verified-image-id>
make airgap-bundle VERSION="$VERSION" IMAGE="$IMAGE" PLATFORM="$PLATFORM"

This mode never calls docker pull. It refuses a missing ID, a different local image ID, or a different image platform. The bundle manifest records the exact image ID and source, and CHECKSUMS.txt covers the saved image tarball. An image ID proves local byte identity, not publisher identity: verify the release signature and provenance before staging it. Obtain the expected ID from that verification record rather than recomputing the expected value from the same mutable image tag at bundle time. The manifest's image_id identifies the build-host tag before docker save. Some Docker image stores change the displayed ID when loading the selected single-platform image from a multi-platform tag. The received archive checksum and executable commit checks below qualify the transferred artifact; comparing the two Docker IDs as strings is not a portable transfer test.

The output is dist/airgap/trstctl-<version>-<os>-<architecture>-airgap.tar.gz plus a .sha256 checksum. A bundle contains exactly the named platform so a build host cannot silently substitute its own architecture. Build one bundle per node architecture when the disconnected environment is mixed amd64/arm64. The bundle contains:

  • the Helm chart and values-airgap.yaml;
  • docs/airgap.md, docs/install.md, docs/configuration.md, and docs/telemetry.md;
  • a docker save tarball for the trstctl release image;
  • CHECKSUMS.txt for every file inside the bundle.

Move both the archive and .sha256 file into the disconnected environment and verify them there:

shasum -a 256 -c trstctl-0.5.4-linux-amd64-airgap.tar.gz.sha256
tar -xzf trstctl-0.5.4-linux-amd64-airgap.tar.gz
cd trstctl-0.5.4-linux-amd64-airgap
shasum -a 256 -c CHECKSUMS.txt
cat images/trstctl-image.platform # must match the disconnected nodes

The outer .sha256 names only the archive basename, so this check verifies the received copy even when the connected build host is unavailable. Keep the original staging directory inaccessible during a restore rehearsal to catch a checksum that accidentally points back to that directory.

The server image carries the signer, agent, and operator binaries. The standalone trstctl-cli is a separate release asset, so transfer its exact platform archive, manifest, SHA256SUMS, and provenance alongside the server bundle when operators need CLI access inside the gap. Verify those assets on the connected side as described in Install the exact API client, then check the transferred archive and manifest again inside the gap after loading the server image below. Keep the CLI assets in ../cli-assets beside the extracted server bundle. The archive includes no API token or CA trust file; supply those separately when connecting.

For a disconnected Compose evaluation, use the exact transferred image in the one-shot backup worker. It attaches the live signer custody volume only while writing the encrypted full artifact, uses the same internal-only Docker network as the running stack, and pulls no image. Keep the backup encryption key in separate local custody and rehearse a restore into fresh, isolated datastores before relying on the archive.

Load and install

Load the image into the offline registry or directly onto each node:

docker load -i images/trstctl-image.tar
docker tag "$(cat images/trstctl-image.ref)" registry.airgap.local/trstctl:v0.5.4
docker push registry.airgap.local/trstctl:v0.5.4

Install the separately transferred CLI for the operator host. This example uses linux_amd64; choose the matching archive for another architecture. The full source commit in its manifest must match both executable versions:

cli_dir=../cli-assets
version=0.5.4
platform=linux_amd64
archive="trstctl-cli_${version}_${platform}.tar.gz"
manifest="trstctl-cli_${version}_manifest.json"
checksums="trstctl-cli_${version}_SHA256SUMS"
grep -E " (${archive}|${manifest})$" "$cli_dir/$checksums" |
  ( cd "$cli_dir" && sha256sum -c - )
expected_commit="$(jq -r .source_commit "$cli_dir/$manifest")"
case "$expected_commit" in (*[!0-9a-f]*|'') exit 1;; esac
[ "${#expected_commit}" -eq 40 ]
docker run --rm --pull never --network none \
  --entrypoint /usr/local/bin/trstctl "$(cat images/trstctl-image.ref)" --version |
  grep -F "commit ${expected_commit}"
(
  cd "$cli_dir"
  tar -xzf "$archive"
  ./trstctl-cli --version | grep -F "commit ${expected_commit}"
)

Install with private PostgreSQL and NATS endpoints. Replace the CIDRs in manifests/values-airgap.yaml with the cluster/VPC ranges that contain your datastores, DNS, and ingress controller. Production-style external NATS also requires an independent approval provider and an operator-provided signer authorization client in the control-plane image. The official trstctl image in the bundle does not include that organization-specific client. Add it to a derived image before crossing the air gap, rescan and sign that image, and mirror it under the repository used below. The example client reads sign-intent JSON on stdin and writes one base64 token on stdout without mounting the signer verifier secret into the control plane:

helm upgrade --install trstctl charts/trstctl \
  --namespace trstctl --create-namespace \
  -f manifests/values-airgap.yaml \
  --set image.repository=registry.airgap.local/trstctl \
  --set image.tag=v0.5.4 \
  --set postgres.dsn='postgres://user:pass@pg.internal:5432/trstctl?sslmode=require' \
  --set nats.url='nats://nats.internal:4222' \
  --set kek.existingSecret=trstctl-kek \
  --set signer.auth.tokenCommand=/opt/trstctl-auth/bin/signer-token-provider

The rendered control-plane ConfigMap sets:

TRSTCTL_AIRGAP_ENABLED=true
TRSTCTL_AIRGAP_ALLOW_PRIVATE=true
TRSTCTL_AIRGAP_ALLOW_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
TRSTCTL_TELEMETRY_ENABLED=false
TRSTCTL_SECRETS_ENABLE_API=true

Use an operator-managed KEK Secret in production. kek.generate=true is only for short-lived evaluation because losing that generated key makes the sealed CA key unrecoverable.

Install a host agent from the same transferred image

The image in this bundle also contains trstctl-agent at /usr/local/bin/trstctl-agent. A web server or other host-executed connector needs that agent on the target host: loading the control-plane image onto a cluster does not install an agent beside the target service. In the disconnected network, copy the verified bundle to each Linux target host whose architecture matches images/trstctl-image.platform. Run this block from the extracted bundle directory after checking the outer archive and CHECKSUMS.txt as above:

(
set -eu
case "$(uname -s)/$(uname -m)" in
  Linux/x86_64) host_platform=linux/amd64 ;;
  Linux/aarch64|Linux/arm64) host_platform=linux/arm64 ;;
  *) echo "unsupported host architecture for this bundle" >&2; exit 1 ;;
esac
test "$(cat images/trstctl-image.platform)" = "$host_platform"
sha256sum -c CHECKSUMS.txt
docker load -i images/trstctl-image.tar
image="$(cat images/trstctl-image.ref)"
docker image inspect "$image" >/dev/null  # requires the exact locally loaded tag
mkdir -p ./agent-install
(
  container="$(docker create --pull never --entrypoint /usr/local/bin/trstctl-agent "$image" --version)"
  trap 'docker rm "$container" >/dev/null 2>&1 || true' EXIT
  docker cp "$container:/usr/local/bin/trstctl-agent" ./agent-install/trstctl-agent
)
chmod 0755 ./agent-install/trstctl-agent
docker run --rm --pull never --network none --entrypoint /usr/local/bin/trstctl-agent "$image" --version
./agent-install/trstctl-agent --version  # full source commit must match the line above
sha256sum ./agent-install/trstctl-agent
sudo install -m 0755 ./agent-install/trstctl-agent /usr/local/bin/trstctl-agent
)

This uses only the checksummed transferred image; the host does not download an agent from GitHub or rebuild it from source. Keep the image's release signature verification and the bundle's checksum verification in the transfer record. The printed executable hash identifies the exact installed agent. For a containerized target, run the same image with trstctl-agent as its entrypoint and mount only the target's approved credential paths and persistent agent state; the Kubernetes agent example uses this same image path.

In the console, mint a one-time host enrollment token. Save it to a 0600 file on the target, pin the control-plane and agent-channel CA certificates in a local bundle, and run the agent with --bootstrap-token-file, --ca-bundle, persistent --cert and --key paths, --server-name, --relay-claim, a host execution profile, and a persistent encrypted rollback directory. Enable only the intended connector and claimable job kinds (endpoint.renew, connector.test, and connector.rollback for a host-generated certificate). The full target schema and host allowlist are in Deployment connectors. Treat agent enrollment, target test, served-certificate verification, and a cold agent restart as part of installation; possession of the binary is not deployment proof. Remove the consumed bootstrap-token file after the enrolled certificate is persisted and reconnects successfully.

Verify zero public egress

Before opening the service to users, prove the no-phone-home posture:

  1. Render the chart and inspect egress:

    helm template trstctl charts/trstctl -f manifests/values-airgap.yaml \
      --set image.repository=registry.airgap.local/trstctl \
      --set image.tag=v0.5.4 \
      --set postgres.dsn='postgres://user:pass@pg.internal:5432/trstctl?sslmode=require' \
      --set nats.url='nats://nats.internal:4222' \
      --set kek.existingSecret=trstctl-kek \
      --set signer.auth.tokenCommand=/opt/trstctl-auth/bin/signer-token-provider |
      grep -A20 'kind: NetworkPolicy'
    

    The PostgreSQL/NATS rule should have ipBlock CIDRs that match your private network. Do not allow 0.0.0.0/0.

  2. Confirm runtime config:

    kubectl -n trstctl exec deploy/trstctl -c trstctl -- trstctl --check-config | grep air_gap
    
  3. Issue a certificate and manage a secret through the served API or CLI while your network monitor watches for public egress. The COMP-03 served integration test drives the same path: create owner, create identity, transition it to issued, create a native secret, rotate it, and assert the egress guard trip counter stays zero after a synthetic public-endpoint tripwire proves the guard is armed.

Allowing local collectors or local AI

Local observability is compatible with air-gap mode:

export TRSTCTL_OTLP_ENABLED=true
export TRSTCTL_OTLP_ENDPOINT=http://otel-collector.observability.svc:4318
export TRSTCTL_OTLP_INSECURE=true
export TRSTCTL_AIRGAP_ALLOW_HOSTS=otel-collector.observability.svc

Local model runtimes are also compatible when they run on a private host:

export TRSTCTL_AI_MODEL_MODE=local
export TRSTCTL_AI_MODEL_RUNTIME=ollama
export TRSTCTL_AI_MODEL_ENDPOINT=http://ollama.ai.svc:11434
export TRSTCTL_AIRGAP_ALLOW_HOSTS=ollama.ai.svc

Cloud AI mode remains rejected under air-gap mode even if the cloud host is listed. That is deliberate: an air-gapped install should not depend on a public SaaS model.

Updating later

Build a new bundle on a connected workstation, transfer it in, verify the archive checksum and internal CHECKSUMS.txt, load the new image into the offline registry, then run a normal Helm upgrade with the same values-airgap.yaml. Do not let cluster nodes pull directly from a public registry; the point of the bundle is that all artifact movement is explicit, checked, and auditable. When the exact replacement image is already staged, use the pinned local source mode above and compare the loaded image ID with the manifest before upgrading.

Rendered live from github.com/ctlplne/trstctl — found a mistake? edit this page.