-
Notifications
You must be signed in to change notification settings - Fork 0
OPERATIONS
⚠️ Auto-generated from the repository — do not edit here. Source: https://github.com/luisgf/ssh-broker/tree/main/docs
Operational runbook: building, running, adding hosts, hot-reload, PKI, and reference configs. For the design rationale see ARCHITECTURE.md; for the security posture see THREAT_MODEL.md.
cd /path/to/ssh-broker
# 1. Start the signer (must be running before the broker starts)
./signer.sh start # background, PID in signer.pid, log in signer.log
./signer.sh status
./signer.sh log # tail -f signer.log
./signer.sh stop
./signer.sh restart
# 2. The MCP (mcp-broker) is started by the MCP client (e.g. OpenCode / Claude
# Code) on connect. It requires the signer to be running: if it cannot
# GET /v1/hosts, the broker fails to start.
# 3. Rebuild after changes (make embeds the git-tag version into the binaries)
make install # all binaries → ~/bin
make signer # or just oneCompiled binaries: ~/bin/mcp-broker · ~/bin/mcp-broker-http · ~/bin/signer
· ~/bin/broker-ctl. make install injects the version from git describe --tags; a plain go build ./cmd/... still works but reports a dev-<commit>
version. Run make version to see what would be embedded.
Order matters: always start the signer before opening the MCP client. With multiple broker replicas, note that session/approval/behavior state is in-memory per process (single-instance only — see THREAT_MODEL.md).
signer.json is the single source of truth. Edit it (or use broker-ctl host add) and reload the signer; the broker picks up the change in ≤
hosts_refresh_seconds without a restart.
"hosts": {
"web01": {
"addr": "10.0.0.21:22",
"user": "deploy",
"host_key": "ssh-ed25519 AAAA...",
"principal": "host:web01",
"source_address": "",
"max_ttl_seconds": 120,
"allow_as_bastion": false,
"groups": ["prod-web"], // RBAC: groups this host belongs to
"allow_sudo": true,
"allowed_sudo_users": ["root", "deploy"],
"allow_pty": true
}
},
"callers": {
"broker-1": { "allowed_groups": ["prod-web"] } // CN → allowed groups
}Bastions: if the host uses
"jump": "bastion", the bastion must share the host's groups, or the broker cannot resolve the jump chain.
Backward compatible: a CN absent from
callershas no group restriction and sees every host.
Obtain the host_key:
ssh-keyscan -t ed25519 <ip-or-hostname>
# copy only the "ssh-ed25519 AAAA..." part (without the hostname prefix)In the target's /etc/ssh/sshd_config:
TrustedUserCAKeys /etc/ssh/ssh_broker_ca.pub # copy pki/ssh_ca.pub
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
LogLevel VERBOSE
AllowTcpForwarding no # yes on bastions
X11Forwarding no
PermitTunnel no
# PermitTTY yes # default; uncomment only if it was disabled
Create /etc/ssh/auth_principals/<user> with the host's principal (e.g.
host:web01). For elevation, add the sudoers entry described in
ARCHITECTURE.md § Privilege elevation.
See also deploy/sshd_config.snippet.
The signer re-reads signer.json without restarting, atomically replacing the
hosts policy, max_ttl_seconds, reload_callers, and the CA key(s). If the
new config is invalid, the previous state is preserved. listen, TLS, and
audit_log require a full restart.
broker-ctl reload # SIGHUP if local, else POST /v1/reload (mTLS)
# alternatives:
kill -HUP "$(cat signer.pid)"
./signer.sh restart-
POST /v1/reload(mTLS): only CNs inreload_callersmay invoke it (others → 403). Emptyreload_callersdisables the HTTP endpoint. -
SIGHUP: local reload, bypasses the allowlist.
The broker does not need a reload: it refreshes /v1/hosts every
hosts_refresh_seconds.
# Build
go build -o ~/bin/broker-ctl ./cmd/broker-ctlGlobal options (before the subcommand):
broker-ctl [--config <signer.json>] <command> [args]
broker-ctl --version [--verbose] # print the build version--config is a global option and must precede the subcommand
(broker-ctl --config /etc/signer.json host list), consistent with the other
binaries. It defaults to ./signer.json.
Breaking change (v1.15.0):
--configno longer works after the subcommand. Replacebroker-ctl host list --config fwithbroker-ctl --config f host list.
# Add host (with automatic ssh-keyscan)
broker-ctl host add --name web01 --addr 10.0.0.1:22 --user deploy --scan \
--sudo --pty --groups prod-web --callers broker-1
# Add host with a manual key
broker-ctl host add --name web01 --addr 10.0.0.1:22 --user deploy \
--host-key "ssh-ed25519 AAAA..." --ttl 120
# Add host with a command policy (allowlist)
broker-ctl host add --name web01 --addr 10.0.0.1:22 --user deploy --scan \
--policy-mode allowlist --allow "^uptime$,^df -h" --shell-parse
# Update an existing host preserving its command_policy
broker-ctl host add --name web01 --addr 10.0.0.1:22 --user deploy --scan --force
# (no --policy-mode/--allow/--deny flags → CommandPolicy copied from existing entry)
# Update an existing host replacing its command_policy
broker-ctl host add --name web01 --addr 10.0.0.1:22 --user deploy --scan --force \
--policy-mode denylist --deny "rm -rf"
# List hosts (columns: JUMP, SRC_ADDR, SUDO_USERS, CALLERS, POLICY)
broker-ctl host list
# Remove host
broker-ctl host remove web01host add flags:
| Flag | Required | Default | Description |
|---|---|---|---|
--name |
✓ | — | Logical host name |
--addr |
✓ | — |
host:port of the SSH server |
--user |
✓ | — | Remote SSH account |
--host-key |
✓* | — | Host key (authorized_keys). - = read stdin |
--scan |
✓* | — | Fetch the key with ssh-keyscan (alternative to --host-key) |
--principal |
host:<name> |
SSH principal in the cert | |
--ttl |
120 |
max_ttl_seconds |
|
--jump |
— | Name of the preceding bastion | |
--source-address |
— | Bastion egress IP/CIDR | |
--sudo |
false | allow_sudo=true |
|
--sudo-users |
— |
allowed_sudo_users (comma-separated) |
|
--pty |
false | allow_pty=true |
|
--groups |
— | RBAC groups (comma-separated) | |
--callers |
— | CNs allowed on this host (comma-separated) | |
--bastion |
false | allow_as_bastion=true |
|
--force |
false | Update if it exists, preserving every field whose flag you don't pass (see note) | |
--policy-mode |
— |
allowlist | denylist | off
|
|
--allow |
— | Allowlist patterns (RE2 regex, comma-separated) | |
--deny |
— | Denylist patterns (RE2 regex, comma-separated) | |
--require-approval |
— | Require-approval patterns (RE2 regex, comma-separated) | |
--shell-parse |
false | Parse commands as POSIX sh before evaluating the policy |
* Either --host-key or --scan is required, but not both. --scan honours
the port in --addr (and IPv6 literals).
Partial update with
--force(v1.12.6): a--forceupdate starts from the existing entry and overrides only the fields whose flags you pass; any field you omit (sudo, groups, callers, TTL,command_policy, …) keeps its current value. Sohost add --name web01 --addr newip:22 --user deploy --scan --forcechanges just the address and leaves sudoers/groups/policy intact. A flag set explicitly to empty (--groups "",--sudo=false) still clears its field. (--addr,--user, and--host-key/--scanare always required and thus always written.)Command-policy sub-flags are also merged field-granularly (v1.13.0): passing e.g. only
--require-approvalupdates that list and keeps the existing--policy-mode/--allow/--deny/--shell-parse. Previously any single policy sub-flag rebuilt the wholecommand_policyfrom flag defaults, silently downgrading the host tomode:off(firewall disabled, sessions re-enabled).
broker-ctl ca-keys add --name _default --type pem --path pki/ssh_ca
broker-ctl ca-keys add --name prod-web --type akv \
--vault-url https://myvault.vault.azure.net/ --key-name ssh-ca-web
broker-ctl ca-keys list
broker-ctl ca-keys remove prod-webbroker-ctl callers add --name broker-1 --groups prod-web,staging
broker-ctl callers add --name broker-1 --groups prod-web --force # update
broker-ctl callers list
broker-ctl callers remove broker-1broker-ctl reload
broker-ctl --config /path/to/signer.json reload # alternative config (global flag)# Explain a host's composed (group + inline) command policy, evaluate a command offline
broker-ctl policy explain --host web01 --command 'systemctl restart nginx'
# Mine an audit log for advisory suggestions (read-only — changes nothing)
broker-ctl policy recommend --audit signer_audit.log --min-count 5
# [PROMOTE] web01 ^systemctl restart nginx$ 47x, 47 human-approved
# [DEAD] web01 ^journalctl 0 matches in window -> review/remove
# Durable change via the validated mutation API (mTLS; CN must be in reload_callers).
# Validated before persist, written atomically, applied in-memory, audited:
broker-ctl policy add --host web01 --allow '^systemctl status [a-z0-9_.-]+$'
broker-ctl policy remove --host web01 --allow '^journalctl 'A grant widens an allowlist host for a while without editing signer.json —
it lives in memory and expires on its own. Operator-only (mTLS, CN in
reload_callers), audited, and widen-only: a grant only adds allow patterns,
applies only on a host that is already allowlist-active, and can never override a
baseline deny. Cap the maximum TTL with max_grant_ttl_seconds in signer.json.
# Incident: web01 (allowlist) denies 'systemctl restart nginx'. Grant it for 2 hours.
broker-ctl policy grant --host web01 --allow '^systemctl restart nginx$' --ttl 2h
# → granted on web01: allow "^systemctl restart nginx$" for 2h0m0s (id 42d1..., expires ...Z)
# Verify without running anything (dry-run flips denied -> allowed):
broker-ctl policy explain --host web01 --command 'systemctl restart nginx' # static view
# …and from the agent side, ssh_execute --dry_run now reports ALLOWED.
# Scope a grant to one broker CN or one end user (default = host-wide):
broker-ctl policy grant --host web01 --allow '^systemctl restart nginx$' --ttl 2h --caller broker-1
broker-ctl policy grant --host web01 --allow '^systemctl restart nginx$' --ttl 2h --end-user alice
# List active grants; revoke early (otherwise it just expires):
broker-ctl policy grants
# ID HOST EXPIRES (UTC) SCOPE ALLOW
# 42d1eabd7c73b474c85e75a7 web01 2026-06-19T14:00:00Z any ^systemctl restart nginx$
broker-ctl policy revoke 42d1eabd7c73b474c85e75a7Notes: a grant on a non-allowlist host is refused (409 — it would be a no-op
and would invert the host to default-deny); grants survive a config reload but are
dropped on a signer restart (fail-safe — the baseline is more restrictive); every
create/revoke is in the signed audit log (grant-created / grant-revoked).
broker-ctl approval list
broker-ctl approval allow <id>
broker-ctl approval deny <id>
# Approve-and-learn (v1.18.0): also waive RE-approval for this exact command for a
# while, so it runs without prompting again until the waiver expires. The signer
# mints a host-wide approval waiver (honoured only because the control plane is a
# trusted_forwarder); it shows up in 'policy grants' and is revocable like any grant.
broker-ctl approval allow <id> --learn --ttl 2h
broker-ctl policy grants # the waiver appears as waive-approval[^cmd$]
broker-ctl policy revoke <grant-id> # end it early (otherwise it just expires)A waiver only un-gates an already-allowed command (it never widens allow/deny),
so it is safe even on a default-allow host that carries a require_approval rule. The
TTL is clamped to max_grant_ttl_seconds if that cap is set. Every mint is audited
(approval-waiver-created, linked to the originating approval id).
# Follow the broker log live (shows the last 20 lines first)
broker-ctl audit tail --log audit.log
broker-ctl audit tail --log audit.log -n 50
# Follow the signer log (certificate issuances)
broker-ctl audit tail --log signer_audit.log
# Filter (host, caller, outcome, date; combinable)
broker-ctl audit show --log audit.log --host web01
broker-ctl audit show --log audit.log --outcome denied
broker-ctl audit show --log signer_audit.log --outcome issued --since 2026-06-05
broker-ctl audit show --log audit.log --host db01 --outcome denied --limit 20
# JSON for jq pipelines
broker-ctl audit show --log audit.log --outcome denied --json | jq .
broker-ctl audit show --log audit.log --json | jq 'select(.serial==1042)'
# Verify the hash chain
broker-ctl audit verify --log audit.log
broker-ctl audit verify --log signer_audit.log
# Verify chain + Ed25519 signatures
broker-ctl audit verify --log audit.log --key pki/audit.seed
broker-ctl audit verify --log signer_audit.log --key pki/signer_audit.seed
# Verify the WHOLE chain across rotated segments (<log> plus <log>.<timestamp>),
# checking cross-file linkage so a dropped or truncated segment is detected.
# Single-file verify accepts the first prev_hash as an unchecked seed; --all does not.
broker-ctl audit verify --log audit.log --all --key pki/audit.seedSee USAGE.md § 7 for the full audit-review guide (jq recipes, field reference, chain-integrity details).
Every binary reports its build version. Short by default (script-friendly),
detailed with --verbose:
broker-ctl --version # e.g. v1.15.0
broker-ctl --version --verbose # version + Go toolchain + os/arch + VCS revision
broker-ctl version # equivalent subcommand form
broker-ctl version --verbose
signer --version # same flags on every binary
broker --version --verboseThe version is injected from the git tag at build time (make build); a plain
go build falls back to the module version or the VCS revision recorded by the
Go toolchain, so it is never a stale hard-coded string.
Generated locally — never commit pki/ to git (it holds private keys).
| File | Description | Rotate when |
|---|---|---|
pki/ssh_ca |
SSH CA private key (Ed25519) | CA rotation |
pki/ssh_ca.pub |
SSH CA public key | — (copy to hosts as TrustedUserCAKeys) |
pki/mtls_ca.{key,crt} |
TLS CA (self-signed, 10y) for broker↔signer mTLS | 2036 |
pki/signer.{key,crt} |
Signer server cert (SAN: 127.0.0.1, localhost) | 2036 |
pki/broker.{key,crt} |
Broker client cert (CN=broker-1) | 2036 |
pki/audit.seed |
Ed25519 seed for the broker log | do not rotate (breaks the chain) |
pki/signer_audit.seed |
Ed25519 seed for the signer log | do not rotate (breaks the chain) |
Production CA custody belongs in an HSM/KMS/Secure Enclave. The seam is ready:
ca.LoadCAFromPEMreturns anssh.Signer; replace it withssh.NewSignerFromSigner(kmsClient)(AKV already supported — see ARCHITECTURE.md § Multi-CA).
The system issues ephemeral SSH credentials, but its own control-plane PKI is long-lived and must be rotated deliberately. There is no automation for this yet — follow these procedures.
SSH CA key (pki/ssh_ca). Hosts pin it via TrustedUserCAKeys, so rotation
needs a transition window where both the old and new CA are trusted:
- Generate the new CA key and add it to
signer.jsonas a per-group CA (ca_keys, see ARCHITECTURE.md § Multi-CA) or stage it alongside the currentca_key. - Distribute the new public key to every managed host, appending it to the
TrustedUserCAKeysfile (a host may trust multiple CA keys — keep both lines during the transition). Reloadsshd(systemctl reload sshd). - Switch issuance to the new CA (point the host group at the new
ca_keysentry, or replaceca_key) andbroker-ctl reloadthe signer. - Once all live certificates signed by the old CA have expired (≤
max_ttl, i.e. minutes), remove the old public key from every host'sTrustedUserCAKeysand reloadsshd.
Multi-CA (v1.11.0) makes step 1–3 per host group, so you can rotate one group at a time instead of the whole fleet.
mTLS PKI (pki/mtls_ca, signer.crt, broker.crt, control-plane cert).
These are self-signed with a 10-year validity, which is itself a long-lived
credential. To rotate the issuing mtls_ca (the higher-impact case):
- Generate a new
mtls_caand issue new server/client certs from it. - During transition, configure each service's
client_cato trust both the old and new CA (concatenate the two CA PEMs into the file referenced byclient_ca). Restart the services (TLS config is not hot-reloaded). - Roll out the new client certs (
broker.crt, control-plane cert) and server certs (signer.crt). - Remove the old CA from the
client_cabundles and restart.
To rotate only a leaf cert (e.g. a compromised broker.crt) without changing the
CA: issue a new cert from the existing mtls_ca, deploy it, and — because there
is no CRL on the mTLS path — rely on the broker CN allowlists (callers,
allowed_callers, reload_callers, trusted_forwarders) to deny the old CN if
it must be revoked before expiry.
Audit seeds are not certificates and must not be rotated — replacing
pki/*.seedbreaks the hash/signature chain of existing logs (see the table above). Archive the seed with the log if you ever retire a log file.
| File | Purpose |
|---|---|
config.json |
Active broker config (remote mode) |
config.example.json |
Reference with local + remote modes; allow_sudo/allow_pty/command_policy/approval_wait_seconds
|
signer.json |
Active signer config (single source of truth for hosts) |
signer.example.json |
Reference with per-host allow_sudo/allowed_sudo_users/allow_pty/groups/command_policy + callers + trusted_forwarders
|
control-plane.example.json |
Control plane reference: signer block, sign_callers (broker/approver role separation), approval (notifier/callers/timeout), behavior, trusted_forwarders, mTLS |
deploy/sshd_config.snippet |
sshd_config fragment + NOPASSWD sudoers for managed hosts |
- The signer must be running before the broker / MCP client starts.
-
hosts_refresh_seconds: 30is a development value. In production raise it to 300 (5 min) or more. - To use elevation on a real host: set
allow_sudo: trueinsigner.json, reload the signer, and configure NOPASSWD sudoers on the host. Verify withssh_execute(server, "id", sudo=true). - To use PTY: set
allow_pty: trueand reload. Usessh_execute(..., pty=true)(one-shot) orssh_session_open(server, mode="pty")(interactive). - To use group RBAC (broker mTLS): add
"groups"per host and acallerssection. Issue a new CN signed bypki/mtls_ca.crtfor each restricted broker and add it tocallers. Include any bastion in the same groups. - To use the HTTP+OAuth frontend (
cmd/mcp-broker-http): configure theoauthblock andresource_urlinconfig.json. Provideserver_cert/server_key(noclient_ca— auth is the bearer token). For per-user RBAC add"groups_claim": "groups"and thegroupsfield on the relevant hosts. -
Physical broker/signer separation (different machines) requires a new SAN
on the signer cert with the real IP/hostname, and updating
config.jsonwith that URL. -
Broker/approver role separation (control plane): the signing path
(
/v1/sign,/v1/hosts,/v1/sign/result) is restricted to brokers. List the broker CNs insign_callers; with no list, a CN inapproval.callersis denied the sign path (an approver is not a broker). This stops an approver certificate, signed by the sameclient_ca, from originating signing requests. -
Config is strictly validated at load: an unknown or misspelled key
(e.g.
sign_callerinstead ofsign_callers) is rejected at startup/reload rather than silently ignored, so a typo cannot quietly leave a setting open._*comment keys and the reserved_defaultgroup are still accepted.