Install with containers¶
This is one of three peer shapes (see Install). The Compose stack runs port49d,
port49-broker and a three-member etcd with client-certificate auth.
Read the four requirements on the Install page first, then follow the guide below, which is the deployment's own README.
One peer shape beside systemd and Kubernetes; nothing in port49 requires it. The stack is
port49d, port49-broker and a three-member etcd with client-certificate auth. make
compose-smoke brings it up on a fresh PKI, logs a device in, recreates every container with
its volumes kept, and checks the session, a token minted before the recreate, and the broker's
broker.state_dir all survived. It also checks that port49d names all three etcd members at or
above the version floor, that a device key version added by writing a file takes effect with no
restart, that an admin revoke with no key press is refused, that the break-glass revoke and lift
work, and that no secret is in any container's environment. CI runs it on every pull request.
Trial¶
From the repository root:
go run ./tests/compose/cmd/compose-pki # writes deploy/compose/local/ (valid 24h, git-ignored)
docker compose -f deploy/compose/compose.yaml up -d --build
Mint a token with the trial's client certificate (alice@example.com):
port49 login --broker https://127.0.0.1:8443 \
--cert deploy/compose/local/broker/client.pem --key deploy/compose/local/broker/client-key.pem \
--broker-ca deploy/compose/local/broker/broker-ca.pem
Bring the whole stack up the first time, or run docker compose build first: only setup and
port49-broker build, and etcd-init and port49d reuse their tags.
The trial PKI protects nothing real. TACACS+ is published on 127.0.0.1:49, the broker on
127.0.0.1:8443, and each component's metrics on 127.0.0.1:9049 and 127.0.0.1:9050.
Ready is not "running". port49d and port49-broker each have a healthcheck that runs
healthcheck --ready, which probes /readyz, every ten seconds (the first thirty are a grace
period). docker compose ps shows healthy only when it passes; running alone says the process
started. You can ask the same question from the host with curl 127.0.0.1:9049/readyz (port49d) and
curl 127.0.0.1:9050/readyz (the broker), on the metrics ports; a 200 is ready, anything else or no
answer is not.
Inputs, each an environment variable with a default: PORT49_COMPOSE_LOCAL (./local),
PORT49_COMPOSE_CONFIG (./config), PORT49_COMPOSE_SUBNET (172.30.49.0/24),
PORT49_TACACS_PUBLISH (127.0.0.1:49), PORT49_BROKER_PUBLISH (127.0.0.1:8443).
The files¶
setup runs as root at every up and copies each component's files into a volume of its own,
so no volume holds both etcd identities:
| Volume | Holds | Mounted in |
|---|---|---|
port49d-etc |
port49d.yaml, policy, inventory, etcd CA, port49d's etcd certificate and key, secrets/encryption.key |
port49d, read-only |
port49d-keys |
the device keys, one file per version (tacacs_v1, ...); the directory root:65532 0750, each file root:65532 0440 |
port49d, read-only at /run/port49/keys |
broker-etc |
port49-broker.yaml, policy, inventory, etcd CA, the broker's etcd and TLS files, secrets/encryption.key |
port49-broker, read-only |
etcd-pki |
the CA and the three members' certificates and keys | etcd-1..3, read-only |
etcd-root |
etcd's root certificate and key, and store-roles' output |
etcd-init only |
etcd-N-data |
each member's data | that member |
broker-state |
broker.state_dir |
port49-broker |
port49d-spool |
the accounting spool | port49d |
No secret is in any container's environment, so docker inspect shows none. Each is a file:
- A device key is a file in
port49d-keys, named by the policy as{version: 1, secret_ref: file:/run/port49/keys/tacacs_v1}. A new version is a new file and a line in the policy, with no restart (below;make compose-smokeruns that cell). - Each component's sealing key is
secrets/encryption.keyin its own-etcvolume (root0755 directory, the file655320400), the same 32 bytes in both. It is not under/run/port49/keys: a policy may name any file there.
setup places a key once. At every up, per key file: absent in the volume, it is installed; the
same bytes, it is left alone; different bytes, setup stops the stack with one line naming the file
(keys are never replaced in place) and the volume is untouched; gone from local/, the installed
file is kept and a line says so. To change a key, add a new version (below, and "Rotating the
encryption: key"), never edit local/keys/ or local/secrets/ under a running stack. The policy
is the opposite: setup copies config/policy.yaml over the volumes' at every up, so
edit config/policy.yaml on the host, then docker compose up -d. port49d reads it at
policy.watch_interval with no restart. A policy edited inside a volume is reverted at the next up.
That holds only while the image is the one the containers were made from: an up -d after the
image was rebuilt (an up -d --build, or a docker compose build after a git pull) recreates
port49d and the broker, so the policy arrives by a restart, not a reload. Keep image changes and
policy edits apart: rebuild with docker compose build and up -d as one change, then make policy
edits with a plain up -d.
Docker API access is root-equivalent, so guard it as you would the host: docker run with a volume
reads every file in it.
Compose's own secrets: is measured (lab L84, tests/lab/results/L84-systemd-docker.md) and is not used
for device keys. A file-backed secret starts port49d only if the host file is root:65532 mode 0440:
Compose bind-mounts it as it is and does not apply the long syntax's uid, gid or mode, and the file
fence refuses 0444 (the short syntax's documented mode) and a file the operator owns. And adding a second
secret means docker compose up -d, which recreates port49d: a key version cannot be added with no restart.
The port49d-keys volume below is the way for device keys. secrets: may carry the encryption key, which is
read at start and goes through the same file fence (the cell measured a device key's file; the encryption key
was not measured on its own).
Adding a key version¶
A device key is a file named in the policy. Add a version by writing a new file and a new line;
never rewrite a file in place (port49d reads each file once, and says so if one changes). Run this
from the repository root, with PORT49_COMPOSE_LOCAL as for up if you set it, and the project
name port49 (docker compose -p changes the volume prefix):
docker run --rm -i -v port49_port49d-keys:/k --entrypoint sh port49-setup:dev -c '
DEVICE_KEY=$1 # a new name for every version: never an existing one
umask 077 && cd /k || exit 1
[ ! -e "$DEVICE_KEY" ] && [ ! -L "$DEVICE_KEY" ] || { echo "port49: $DEVICE_KEY exists; pick a new key id" >&2; exit 1; }
t=; trap "rm -f \"\$t\"" EXIT; trap "exit 1" INT TERM HUP
t=$(mktemp ./.key.XXXXXX) || exit 1
head -c 16 /dev/urandom | od -An -tx1 | tr -d " \n" > "$t" && [ "$(wc -c < "$t")" -eq 32 ] &&
chown 0:65532 "$t" && chmod 0440 "$t" || { echo "port49: the device key was not made" >&2; exit 1; }
ln "$t" "$DEVICE_KEY" || { echo "port49: $DEVICE_KEY exists; pick a new key id" >&2; exit 1; }
' sh tacacs_v2
It is the recipe of deploy/systemd/README.md ("Adding a key version"), in the volume: a temporary
file made in the destination, a no-clobber hard link, nothing left behind on a failure. Type the
key into the device from the volume, and keep the new file's name for the policy. Then add
{version: 2, secret_ref: "file:/run/port49/keys/tacacs_v2"} to the key set in config/policy.yaml, under accept: (moving the
old version out of current: once each device carries the new key), and run docker compose up -d:
setup copies the policy into both -etc volumes. port49d reads the policy at policy.watch_interval (5 s
here) and the new version takes effect with no restart: port49_key_secret_files_changed stays 0.
Order matters. Put the key file in place first, then add the policy line that names it. To remove a
key, take the policy line out first, then the file. The other order is a 3am outage: a policy line naming a
file that is not there makes port49d refuse its start (lstat ...: no such file), so on a restart it
crash-loops until you restore the file in the port49d-keys volume or take the policy line out.
A deployment¶
- Bring your own PKI under
PORT49_COMPOSE_LOCAL, in the layoutcompose-pkiwrites (etcd/,broker/,keys/tacacs_v1,secrets/port49d-encryption.keyandsecrets/broker-encryption.key; the two sealing keys hold the same 32 bytes). A device key is the shared secret you type into the device; a sealing key is 32 random bytes, made once (the recipe indeploy/systemd/README.md, "Where the secrets live", with the destination changed). Editconfig/for your policy and CRL or OCSP (the example setscertificate_status.disabled, which a deployment must not). - List devices in
inventory.yamlby the address port49d sees. A published port may change that address; what it does is policy engine spec §15 L39 (a). Measured: kept for an off-host device (modelled;tests/lab/results/L39-docker-proxy-on-arista_eos.mdand-off-), masqueraded to the compose gateway for a device on another Docker network on the same host. A connection docker-proxy itself relays is unmeasured. etcd-initappliesport49d store-roles' output once and enables auth. After a port49 upgrade that changes the roles, re-applystore-roles' output by hand; it is never done silently.- One store or two. The example runs one etcd cluster, so
stores.sessionis omitted: the session store is the directory store. A deployment in several regions givesstores.directoryan etcd stretched one member per region and each region'sstores.sessionan etcd of its own;port49d store-rolesthen prints a section per store: run the directory's once against the stretched cluster, and each region's session section against that region's etcd.port49 store status --metrics 127.0.0.1:9049(the metrics port the stack publishes) shows each store as port49d sees it. Name every member's URL inendpoints:, as the example does, never a load balancer or a VIP: port49d prints the version of each at its start and refuses a member below the floor. Complete single-site and multi-region store blocks are indeploy/examples/stores/(written at the systemd paths: change the/etc/port49prefix only if your volumes differ). context_cacheis off here, as everywhere by default, and so isbroker.directory(see Directory below).
Directory¶
The trial has no directory: a subject names its username, and a group subject mints for no
one (port49-broker says so at its start). config/port49-broker.yaml carries a commented
broker.directory: block to resolve humans and their groups from LDAP instead. What each
key does:
servers: the pool, eachuri(ldaps://, orldap://withstart_tls: true), asiteand apriority. The broker prefers servers in its own site, then the highestpriority, then file order.siteorsite_env(defaultPORT49_SITE): this broker's site. SetPORT49_SITEper broker container in the service'senvironment:(it is not a secret); replicas in different sites set different values.ca_file: verifies the servers' certificates.bind: how the broker authenticates.gssapi(principal,keytab_file,krb5_conf),external(tls_cert_file,tls_key_file) orsimple(bind_dnandpassword_secret_ref, the weakest: the password is named byfile:/path, read when the directory opens, never printed; use a file inbroker-etc, notenv:NAME, which would show indocker inspect). Samba AD (lab L43):externalis refused with LDAP result 1, which port49 counts as a server fault (every breaker opens,port49_directory_server_updrops to 0), so usesimpleorgssapi;gssapiis refused underldap server require strong auth = yesand accepted underallow_sasl_over_tls. Windows Server AD: L44.base_dn,groups_base_dn,principal_attribute,username_attribute: where a person and their groups are found, and which attributes name them. Only directory administrators may write the two attributes: two entries holding one value are both refused.certificate_mapping: how a client certificate finds its entry.issuer_serialandsidare strong; the others needallow_weak: trueandissuers.nested_groups:in_chain(Active Directory; OpenLDAP answers it empty, so userecursivethere),recursiveordirect.grace: how long a stored membership answers when no server does.prewarm: how often, and for how many principals, group members are looked up ahead of their first login.broker.refresh_interval: the scheduled re-verification of every live principal.
The keytab and krb5.conf go in the broker's configuration volume (broker-etc, under
/etc/port49/), the keytab mode 0600 and owned by uid 65532, as the image runs. A keytab
that cannot be read, or holds no key for the principal, stops the start with the key named.
A directory outage never makes /readyz fail: a session on a stored membership goes on
within grace, and past session.staleness_ceiling (default 4h) a command is denied
context_stale.
Admin operations¶
You run port49 revoke and port49 directory ... on your own workstation, through your agent;
nothing is run in the stack. Each admin-level call asks for a press of your security key, once
per operation, and an exposed certificate or token alone authorizes nothing admin-level.
port49 revoke --principal user:bob@example.com revokes, --lift lifts, and --list shows the
marks in force.
The broker needs the attestation roots, the right in the policy, and a session key on your security
key; the three are set out in deploy/systemd/README.md ("Admin operations"). In this stack:
- put the PEM roots in
PORT49_COMPOSE_LOCAL/broker/attestation-roots.pem;setupcopies it to/etc/port49/broker/attestation-roots.pemwhen it is there, and the broker config'sbroker.identity.per_login_mfa.hardware_key_touch.attestation_roots_filenames that path. A changed roots file isdocker compose up -d(setup copies it) thendocker compose kill -s HUP port49-broker(the broker re-reads the roots file at SIGHUP; a file it cannot use keeps the roots in force and logs one line saying so); - grant
api: [revoke_others, lift_revocations]to at least two on-call admins byidentity:inconfig/policy.yaml, even if agroup:subject also holds it: a group's rights resolve through the directory, which may be down when you need them.
Break-glass, with the stack up but no admin able to reach a running broker: stop the broker, revoke through etcd as the broker's own user (the image), and start it again. This stops issuance while the broker is down, so it is for when no admin can use the API.
docker compose stop port49-broker
docker compose run --rm --no-deps port49-broker revoke --offline --principal user:bob@example.com
docker compose run --rm --no-deps port49-broker revoke --offline --list
docker compose start port49-broker
--lift instead of the principal's revoke lifts it. init-enrollments and export-enrollments
are local subcommands of the same form: docker compose run --rm port49-broker init-enrollments.
Run init-enrollments before the broker's first start; a broker already running finds the new store
within 30 seconds, and until then its sign-ins answer enrollment_store_lost.
port49 store status --metrics 127.0.0.1:9050 reads the broker's stores on the metrics port.
The 3am table¶
What the operator sees; the exact lines are the CLI's (spec §5.1.6).
| Condition | What you see | What to do |
|---|---|---|
| Session key not on a security key (TPM, Secure Enclave, file) | port49: admin operations need a security key press, and your session key is in the TPM: ...; exit 1, nothing sent |
log in with a session key on the security key |
| Attestation does not chain | 401 admin_touch_refused: the touch slot's attestation does not verify: ... |
add the root or intermediate to attestation-roots.pem, then docker compose up -d and docker compose kill -s HUP port49-broker |
| No touch in time | hwkey: no touch within 15s; touch the key when it blinks and try again |
run it again |
| The identity provider is down | nothing: your port49 certificate is still valid | revoke as usual |
| Your certificate expired and the provider is down | port49 login --oidc cannot sign in |
another on-call admin revokes; the break-glass above only if none can |
| No attestation roots configured | 503 admin_touch_unavailable: no attestation roots are configured ... |
set the file |
| The store is down | 503 store_unavailable |
the break-glass above, once the store is back but the broker is not |
broker.state_dir¶
- One named volume per broker. Never
--scale, neverdeploy.replicas, never an anonymous volume. A second broker is a second service with a volume of its own. - A fresh named volume copies uid 65532, mode 0700 from the image. A bind mount, or a volume
filled by hand, owned by root is not refused at start (root is an accepted owner), but the
first
marks.jsonor witness write fails, as the 65532 process cannot write it. Fix:
docker run --rm -v <volume>:/s alpine:3.22 sh -c 'chown 65532:65532 /s && chmod 0700 /s'
OIDC¶
The trial is certificate-only. To accept identity-provider tokens, replace broker.client_ca_file
with the broker.identity block shown commented in config/port49-broker.yaml; an OIDC-only
broker (no x509 verifier) is valid. The provider's signing keys are kept in broker.state_dir, so
a broker restarted during a provider outage still validates tokens until jwks_max_age; a fetch
failure never changes /readyz.
- The broker must reach the provider's discovery and JWKS URLs. Where the site's egress is a proxy,
set
HTTPS_PROXYon the broker service; the start line says how each issuer is fetched. - Register
https://<broker>:8443/v1/logoutas the provider's back-channel logout URL, with "backchannel logout session required" on. - Each machine client's
subis read from the provider: in Keycloak, the client's "Service account roles" user id; in Entra ID, the service principal's object id.
The issuing CA, and a second broker¶
port49-broker init-issuer makes the issuing CA on one broker, as the broker's user:
docker compose exec port49-broker port49-broker init-issuer
A second broker is a second service with a broker-state volume of its own, and it must hold the
same CA, or it refuses to start. Copy both files into its volume, keeping them private:
docker run --rm -v port49_broker-state:/a -v port49_broker2-state:/b alpine:3.22 sh -c \
'install -o 65532 -g 65532 -m 0600 /a/issuing-ca.key /b/ && install -o 65532 -g 65532 -m 0644 /a/issuing-ca.pem /b/'
Complete single-site and multi-region store blocks are in deploy/examples/stores/.
Rotating the encryption: key¶
One key, in both components, from one secret, in three steps. A new key is a new file beside the old, never a rewrite.
- Make the new sealing key once, as
secrets/encryption-e2.keyin both-etcvolumes (655320400), the same 32 bytes in each, with this one command from any directory (projectport49;docker compose -pchanges the volume prefix). It draws until the last byte is not a newline, makes both temporary files in the destination directories, links them no-clobber, and leaves nothing behind on a failure. It never overwrites: an existingencryption-e2.keyis refused.
docker run --rm -i -v port49_port49d-etc:/e -v port49_broker-etc:/b --entrypoint sh port49-setup:dev -c '
KEY_ID=$1 # a new id for every key: never an existing one
: "${KEY_ID:?set KEY_ID to the new key id}"
umask 077 && cd /e/secrets && cd /b/secrets || exit 1
n=encryption-$KEY_ID.key
for f in /e/secrets/$n /b/secrets/$n; do
[ ! -e "$f" ] && [ ! -L "$f" ] || { echo "port49: $f exists; pick a new key id" >&2; exit 1; }
done
t=; u=; trap "rm -f \"\$t\" \"\$u\"" EXIT; trap "exit 1" INT TERM HUP
t=$(mktemp /e/secrets/.key.XXXXXX) && u=$(mktemp /b/secrets/.key.XXXXXX) || exit 1
ok=
for i in 1 2 3 4 5 6 7 8 9 10; do
head -c 32 /dev/urandom > "$t" && [ "$(wc -c < "$t")" -eq 32 ] &&
[ "$(tail -c1 "$t" | od -An -tx1 | tr -d " ")" != 0a ] && ok=1 && break
done
[ -n "$ok" ] || { echo "port49: no usable 32-byte key in 10 draws" >&2; exit 1; }
cat "$t" > "$u" && chown 65532:65532 "$t" "$u" && chmod 0400 "$t" "$u" ||
{ echo "port49: the sealing key was not made" >&2; exit 1; }
ln "$t" "/e/secrets/$n" || { echo "port49: /e/secrets/$n exists; pick a new key id" >&2; exit 1; }
ln "$u" "/b/secrets/$n" || { rm -f "/e/secrets/$n"; echo "port49: /b/secrets/$n exists; pick a new key id" >&2; exit 1; }
' sh e2
Add it to config/port49d.yaml and config/port49-broker.yaml under encryption: as previous: {id: c2, secret_ref:
file:/etc/port49/secrets/encryption-e2.key}, run docker compose up -d (setup copies the config
files) and docker compose restart port49d port49-broker. Both open what either key sealed.
2. Swap: make c2 current: and c1 previous: in both files, and restart both.
3. Remove previous: and the old file only after the scheduled directory pass (every refresh_interval)
has re-sealed the second-factor records under c2. A record that opens under neither key is never
"not enrolled": that sign-in is 503 store_unavailable, and the broker's line names
port49 directory reset-second-factor. Remove the old key from local/secrets/ first
(port49d-encryption.key and broker-encryption.key are the old key's), so an up in between cannot install it
again, and then from the volumes
(docker run --rm -v port49_port49d-etc:/e -v port49_broker-etc:/b alpine:3.22 rm /e/secrets/encryption.key /b/secrets/encryption.key,
with the old file's name). Setup skips a sealing key that is in neither place; once the config no longer names the
old file, every later up starts clean and the old key stays gone.
A process whose window lacks the directory's current key id refuses to start, naming the directory store and the id. Two store blocks reaching one etcd cluster at one prefix are refused too.
When it will not start¶
docker compose logs port49d (or port49-broker) holds the line.
restart: unless-stopped has no limit. A start that fails is tried again, with a restart delay that
grows to a minute, until you fix the cause or docker compose stop it. So docker compose ps shows a
restarting container, and the log repeats the line.
A port49d whose etcd is down at boot comes back on its own when etcd does.
| Refusal | The line | Where | Fix |
|---|---|---|---|
| A secret file missing or unreadable | config: encryption.current.secret_ref (id "c1") file:/etc/port49/secrets/encryption.key: open ...: permission denied (or the file it names); a key file: config: keys.k version 1 secret_ref file:/run/port49/keys/tacacs_v1: ... |
docker compose logs port49d |
write the file as the table above says (setup installs a key that is absent, and refuses one that differs; it never rewrites one) |
| The fence refuses a directory | ...is mode drwxrwxr-x, writable by others than its owner; whoever can write it decides who gets access - chmod go-w it ... |
same | fix the mode the line names in the volume (docker run --rm -v <volume>:/v alpine:3.22 chmod go-w /v/<dir>) |
| A key readable by a group the container is not in | ...is group-readable by gid N, which this process is not in (gid M); ... |
same | the container's group is 65532: chgrp 65532 the file in the volume (chmod 0600 only if the container's user owns the file: on a root:65532 file it makes the key unreadable) |
| The issuing CA differs from the store's | issuance: this broker's issuing CA (...) is not the one the store records (...) |
docker compose logs port49-broker |
copy issuing-ca.pem and .key from a broker that holds it (above) |
| etcd below the floor | etcd at <url> is version <v>, below the floor port49 needs (3.4.24, 3.5.8 or 3.6): ... |
docker compose logs port49d |
upgrade the member |
| etcd unreachable at boot | a store refusal after start_timeout (30 s unless you set it), then the container restarts |
docker compose logs port49d |
nothing: it retries; start etcd |
etcd's disk¶
etcd needs a disk with sustained WAL fsync p99 under 10 ms (etcd's own guidance;
datacenter NVMe or a cloud SSD volume). A drive that stalls pushes port49's store calls past the
500 ms call timeout in bursts. The etcd_disk_slow alert (deploy/alerts) fires when the WAL fsync
or backend commit p99 stays over its bound for 10 minutes. On a GCE pd-ssd (400 GB, declared by the operator) the load run passed every cell with a WAL fsync mean of 0.72 to 0.86 ms; a consumer QLC drive failed it (the notes are the load-* files in tests/lab/results/).
port49d's accounting spool directory needs the same, under 10 ms (port49's alert threshold, not etcd's figure): each accounting record is acked only after an fsync, so a slow-fsync disk caps accounting latency. The port49_accounting_spool_disk_slow alert watches it.
Backups¶
Each etcd data volume and broker-state (the runbook is
Backup and restore).