Egress proxies and air-gapped hosts¶
Running a host that cannot reach the public internet directly: declaring an outbound proxy, excluding what must stay direct, and pointing the package managers at internal mirrors.
Three situations, not one¶
"Air-gapped" is used loosely. What agentctl needs from you differs in each case:
| egress | what you configure | |
|---|---|---|
| Proxied — one HTTP/SOCKS5 egress path to the internet | indirect, full | host proxy set only |
| Mirrored — no internet, but internal apt/npm/pip/Go mirrors carry the packages | none | the six ARTIFACTORY_* secrets, and usually a proxy too |
| True air gap — no egress and no mirror | none | not supported; every install step below fetches something |
Most "air-gapped" corporate hosts are the first case. The mirror wiring covers the second and is apt-only — see Internal package mirrors.
What a host fetches¶
apt install agentctl and the first agentctl provision reach these origins. A network
team allow-listing by hostname needs the whole list; each row is the origin, not an
exhaustive URL set.
| origin | what comes from it | when |
|---|---|---|
agentctl.srv.ajil.ch |
agentctl's own apt + rpm repo, HTTP Basic auth | install, every upgrade |
| the distro archive | the whole depends: set — packaging/nfpm.yaml is the authoritative list, not this row. Two entries are worth naming because their absence is a hard failure: nftables (without it apply exits 1) and gnupg (provision dearmors the vendor apt keys below, and the minimal/container 24.04 bases ship only gpgv) |
install |
dl.fedoraproject.org |
epel-release-latest-10.noarch.rpm, then EPEL itself |
provision, dnf hosts only |
astral.sh/uv/<version>/install.sh |
uv | provision |
bun.sh/install |
bun | provision, pi runtime |
nodejs.org/dist/<version> |
Node | provision |
claude.ai/install.sh |
Claude Code | provision, claude runtime |
github.com/zk-org/zk, github.com/tsl0922/ttyd, github.com/kasmtech/KasmVNC |
release binaries — none of the three is in an apt archive | provision |
dl.google.com/linux/chrome + linux_signing_key.pub |
Chrome, and its repo | provision |
packages.cloud.google.com/apt + apt/doc/apt-key.gpg (dnf: yum/repos/cloud-sdk-el10-x86_64 + yum/doc/rpm-package-key-v10.gpg) |
google-cloud-cli, i.e. gcloud, from the vendor repo |
provision |
packages.microsoft.com/repos/azure-cli + keys/microsoft.asc (dnf: yumrepos/azure-cli) |
azure-cli, i.e. az, from the vendor repo. The apt repo is suite-specific, so the line carries this host's codename |
provision |
mirror.openshift.com/pub/openshift-v4/x86_64/clients/ocp/<OcVersion> |
oc, the OpenShift CLI — Red Hat publishes no apt or dnf repo for it, only a versioned tarball |
provision |
dl.cloudsmith.io/public/caddy/stable |
Caddy's apt/rpm repo, gateway hosts on the native path | host caddy |
| Docker Hub | caddy:2-alpine, gateway hosts on the container path |
host caddy |
registry.npmjs.org |
the pi runtime's bun install, anydoc (@firecrawl/anydoc, a pinned bun install -g onto every host), and the video MCP's deps |
provision, video-deps-install.sh |
| PyPI | MCP server dependencies, via uv | provision |
huggingface.co |
the self-hosted PII detector's model weights (openai/privacy-filter, int8 ONNX export only), ~1.5 GB |
apply, only when pii_filter.local is set |
That last row is opt-in and not a new baseline dependency, and it belongs to
--local alone. The other two PII-filter modes add no fetch: an external KServe
detector (pii-filter set <url>) calls your own service, and the in-process detector
(pii-filter set --local-js) ships its weights inside the signed package, so it needs no
egress at all and is the mode to pick on a host that has none. See
PII filter.
Both are fetched at apply, through this host's host_proxy:, and only once: the
download is guarded by a completion token naming the pinned revision, so every later
apply is a no-op until that revision moves. The detector process itself only opens
local files, behind IPAddressDeny=any, and makes no outbound call ever — the weights
must be on disk before it starts, which is exactly why the fetch is an apply step and
not a service-start step.
A mirror must carry the right six files, not the repo. openai/privacy-filter
publishes five ONNX serialisations of one checkpoint plus the torch weights — mirroring
it whole is ~7 GB to run 1.5 GB. The fetch takes six files: config.json,
tokenizer.json, tokenizer_config.json, viterbi_calibration.json,
onnx/model_quantized.onnx and onnx/model_quantized.onnx_data. The service verifies
exactly that list at start, so a mirror missing one of them fails loudly rather than
silently serving a different export.
The version-pinned rows (uv, bun, claude, Node, zk, anydoc, KasmVNC, ttyd, oc,
and the PII detector's model repo + revision) take their version from
internal/converge/versions.go. Regenerate a mirror inventory from that one file.
caddy:2-alpine is not pinned to a patch version; a mirror has to carry whatever
that tag resolves to.
The two vendor apt/dnf key fetches are hard failures. provision runs them as guarded
steps and returns the error, so a host that cannot reach packages.cloud.google.com or
packages.microsoft.com fails agentctl provision outright — it does not degrade to "this
host has no gcloud". The oc tarball fetch behaves the same way. All three are
unconditional on every host, with no per-host opt-out, so they belong on the allow-list even
for a fleet that will never use a cloud CLI.
Nothing is fetched at runtime. The cockpit inlines its fonts instead of linking
fonts.googleapis.com, and the login form ships no external asset.
Declaring the proxy¶
sudo agentctl host proxy set socks5://proxy.corp:1080 # or http://proxy.corp:3128
sudo agentctl apply
sudo systemctl restart agentd.service
Run this before agentctl provision on a fresh host. host proxy set does not route
through agentd, so it works before the daemon exists. It requires root: it commits and
pushes the shared ledger.
The value lands in the ledger under host_proxy:, keyed by this machine's
os.Hostname():
host_proxy:
ixpl0600: "socks5://proxy.corp:1080"
ixpl0601: "socks5://proxy.corp:1080"
Self-scoped, per host. host proxy set writes only the calling host's key, and each
host's apply reads only its own. Other hosts see the entry after a pull and do nothing
with it — run the block once per host even when the value is identical.
agentctl host proxy clear removes the entry. The next apply deletes the rendered
HTTP_PROXY keys rather than leaving services pointed at a proxy that is gone.
What apply renders¶
| destination | for |
|---|---|
/etc/agentctl/env/agentd-proxy.env (0600) + /etc/systemd/system/agentd.service.d/proxy.conf |
agentd |
~/.config/agentctl/agent.env per agent |
a claude agent's claude process |
the pi agent's pi-agent.env |
the pi agent process |
EnvironmentFile=-/etc/agentctl/env/agentd-proxy.env in every native MCP unit, shared and per-user alike |
each catalog MCP's own socket-activated service. An MCP is not a child of the agent and inherits nothing from the two rows above, so this line is what actually puts a connector behind the proxy — it is unconditional rather than enumerated per connector |
| the Slack / Teams / webview gateway env files | outbound token minting, OIDC discovery |
webview-login.env and the auth-broker env |
the same |
/etc/agentctl/env/chrome-proxy.env (0644) |
the persistent browser — Chrome ignores HTTP_PROXY, so this is --proxy-server / --proxy-bypass-list derived from the same source |
Each file gets HTTP_PROXY, HTTPS_PROXY, NO_PROXY and their lowercase twins — tools
disagree about which spelling they read.
apply does not restart agentd.service. It reports that a restart is needed; you issue
it.
SOCKS5 is bridged automatically¶
bun install fails on every tarball against a socks5:// proxy — error:
UnsupportedProxyProtocol, from inside bun's own fetch client. It surfaces as agents add
failing with native provision: pi steady reconcile: exit status 1, an error naming pi for
a proxy-protocol problem. bun's bootstrap installer, git, curl and the OIDC flow all
speak SOCKS5. Only the package fetch does not.
You do not configure a bridge. When host_proxy: is a socks5:// URL, apply reconciles
one:
- installs
privoxyif/usr/sbin/privoxyis absent, - writes
/etc/agentctl/proxy-bridge.conf—forward-socks5 / <host:port> .pluslisten-address 127.0.0.1:28118, - runs it under its own unit,
agentctl-proxy-bridge.service, - disables the OS's packaged
privoxy.serviceso the two can never fight over the port, - and renders
HTTP_PROXY=http://127.0.0.1:28118everywhere instead of the raw SOCKS5 URL.
The raw value stays in the ledger; only what is rendered changes. Set host_proxy: to an
http:// proxy and the bridge is torn down instead — unit disabled, unit and config file
removed.
Two details worth knowing:
- Port 28118, fixed — not configurable, not probed.
- A dedicated unit, not the packaged one. It runs
privoxy --no-daemon <config>in the foreground as root: no forking, no pid file, no privilege drop.
The bridge auto-install is dnf-only
ensureProxyBridge shells out to dnf install -y privoxy. On a Debian/Ubuntu host
with no privoxy present, that call fails and apply prints a warning rather than
erroring — you get no bridge, and bun install still breaks. Run
apt-get install -y privoxy first. Everything after that (config, unit, teardown) is
distro-independent.
Exclusions¶
localhost, 127.0.0.1 and ::1 are always exempt and cannot be removed — every
reconciled service on the host talks to another agentctl process over loopback and must
never route that through the proxy. Add anything else that must stay direct:
sudo agentctl host proxy no-proxy add mirror.corp
sudo agentctl host proxy no-proxy add 10.0.0.0/8
sudo agentctl host proxy no-proxy add https://gw.corp/v1/anthropic # scheme + path stripped
agentctl host proxy no-proxy list
sudo agentctl host proxy no-proxy remove mirror.corp
sudo agentctl host proxy no-proxy clear # loopback stays exempt
Entries accumulate rather than replace, and are stored deduplicated and sorted. NO_PROXY
matches host[:port] or a CIDR only, so a pasted URL has its scheme and path stripped on
the way in. A bare / survives as a CIDR prefix.
Typical entries: an internal package mirror, an internal git origin the ledger pushes to,
and a private model gateway named in an agent's endpoint.base_url
(see Model providers). The proxy config does not read that scalar —
exclude an internal endpoint here or it is dialled through the proxy.
Unlike host proxy set, the no-proxy mutations do route through agentd, so the admin
agent can issue them without a root shell.
Internal package mirrors¶
Six shared-scope secrets, each independently optional. An unset one means "leave that ecosystem alone" — a host with none set converges byte-for-byte as if the feature were absent.
sudo agentctl secret set ARTIFACTORY_APT_URL --value https://artifactory.corp/artifactory/ubuntu-remote
sudo agentctl secret set ARTIFACTORY_APT_TOKEN --value '<user>:<token>'
sudo agentctl secret set ARTIFACTORY_NPM_REGISTRY --value https://artifactory.corp/artifactory/api/npm/npm/
sudo agentctl secret set ARTIFACTORY_NPM_TOKEN --value '<token>'
sudo agentctl secret set ARTIFACTORY_PIP_INDEX_URL --value https://user:token@artifactory.corp/artifactory/api/pypi/pypi/simple
sudo agentctl secret set ARTIFACTORY_GOPROXY --value https://user:token@artifactory.corp/artifactory/api/go/go
sudo agentctl provision
They are read at the start of provision, before the first package install.
| secret | rendered file |
|---|---|
ARTIFACTORY_APT_URL |
/etc/apt/sources.list.d/artifactory.list |
ARTIFACTORY_APT_TOKEN |
/etc/apt/auth.conf.d/artifactory.conf, 0600 root-owned |
ARTIFACTORY_NPM_REGISTRY / _TOKEN |
/etc/npmrc — registry, plus a registry-scoped _authToken |
ARTIFACTORY_PIP_INDEX_URL |
/etc/pip.conf |
ARTIFACTORY_GOPROXY |
the GOPROXY= line in /etc/environment, upserted — other lines untouched |
ARTIFACTORY_APT_URLtakes either a bare repo URL, in which case the host's ownVERSION_CODENAMEandmain restricted universe multiverseare appended, or a full repo-line body with its own suite, components and[options], used verbatim.ARTIFACTORY_APT_TOKENaccepts<token>or<user>:<token>. It reaches only the 0600auth.conf.dfile — never the sources list, never a command line.- pip and Go have no out-of-band credential file, so their token rides in the URL.
/etc/npmrcis 0644 — npm's global config must be readable by every user that runs npm. The npm token is world-readable on the host.- A token with no URL is inert, not an error — same as the secret being unset.
apt only — there is no dnf mirror equivalent
The apt rows above are Debian/Ubuntu-specific. On an rpm host, npm/pip/Go mirroring
works, but nothing points dnf at an internal mirror. A RHEL host that can reach
baseos/appstream/EPEL and agentctl's own repo is fine; one with no outbound access at
all is not supported on the rpm path. See
Install → RHEL 10 (dnf).
Traps¶
| trap | why it happens | fix |
|---|---|---|
An exported ARTIFACTORY_* var never reaches provision |
provision and apply run under sudo with no -E and no env_keep |
Configure mirrors with agentctl secret set <KEY> --value <v> |
secret set KEY thevalue does not store the value |
The value is a flag; a second positional argument is ignored and stdin is read instead | Pass --value, or pipe on stdin — an empty stdin refuses the write |
| A feature scoped out still needs its dependency downloadable | A static import resolves at process start, whichever branch runs | Remove the dependency and start the process in the reduced mode |
| A host provisioned before egress worked stays broken | apply does not re-run a step the ledger already marks provisioned, even with node_modules missing |
Re-run that step by hand, or agentctl agents remove <name> --yes and re-add |
The import trap has the same shape in a Python module-scope import, a Go init(), a docker
FROM and a //go:embed. Reading the code is not evidence that a dependency is optional.
provision is idempotent: a second run installs the packages that come from external
repos. agentctl agents remove <name> --yes is a userdel -r, so a re-add inherits
nothing broken.
"Network unreachable" — three probes before theorising¶
DNS failure, a missing route and a blocked port fail identically at the application layer and need entirely different fixes.
getent hosts <host> # does it resolve?
ip route get <ip> # is there a route?
timeout 5 bash -c 'cat < /dev/tcp/<host>/<port>' # is the port open?
Then check what was actually rendered, because the three layers disagree:
cat /etc/agentctl/env/agentd-proxy.env
systemctl cat agentd.service | grep -i proxy
sudo cat /proc/$(systemctl show agentd -P MainPID)/environ | tr '\0' '\n' | grep -i proxy
A value present in the ledger and absent from the process means apply has not run, or
agentd has not been restarted since it did.