Auth and tokens
JACO has three distinct credentials and one filesystem-permission trust
boundary. Knowing which one a command needs prevents the most common
"why am I getting token_invalid?" mistakes.
Source-of-truth code:
internal/controlplane/admission/admission.go.
The three credentials
Operator token
A 64-character hex string bound to an identity name (e.g.
bootstrap, alice, ci-deploy). Used for every state-changing
RPC over the cross-host gRPC listener. Issued by:
jaco cluster init— prints the first one once. Identitybootstrap. Carriesallows_privileged = false.jaco token issue --name <id> [--allow-privileged]— mints additional ones. The plaintext is printed once; only the SHA-256 hash is stored in raft asToken{identity, hashed_secret, issued_at, allows_privileged}.
allows_privileged flag (issue #119)
A token's persisted allows_privileged flag gates admission of
manifests that set compose privileged: true or a non-empty
security_opt: list. Apply checks BOTH the token flag AND a
matching labels: { "jaco.io/allow-privileged": "true" } on the
service before admitting; see
Supported compose fields → Privileged services.
Issuing the flag is itself not gated on the caller already being
privileged — any valid operator token can mint a privileged one. The
second fence (the service label) is what defends against accidental
privileged: true in a manifest from a non-privileged author.
Local unix-socket callers bypass the token check entirely (the socket permissions already gate operator-class access); the label fence still runs.
Revoked via jaco token revoke <id>. Revocation is a raft write
applied on every node; the next RPC presented with the revoked
token returns Error{code: token_revoked} cluster-wide within one
apply (well under the spec's 5 s bar).
Pass via --token <hex> or JACO_TOKEN.
Join token
A single-use, 24-hour-TTL token used to attach a new node to an
existing cluster. Hashed in raft as JoinToken{hashed_secret, issued_at, expires_at, consumed_at?}. The plaintext is printed once.
Mint with jaco node issue-join-token (operator-authenticated).
Consume with jaco node join --token <hex> (or
JACO_JOIN_TOKEN). Marked consumed_at on use; cannot be reused.
Cluster CA
The cluster's self-signed CA, generated by jaco cluster init.
Replicated to every node via raft (private key included; never leaves
the cluster boundary). Off-node CLI calls pin this cert via
--ca-cert (default /var/lib/jaco/node/ca.crt, override via
JACO_CA_CERT) so the dial verifies the daemon's server cert against
the cluster CA.
Today, without --ca-cert, the dial falls back to
InsecureSkipVerify with a one-line stderr warning — fine for v0
bootstrapping while the CA distribution UX is in flight, but pin it
in any environment you care about.
jaco node issue-join-token --show-ca prints the cluster CA PEM to
stdout so you can stash it on the joining node before running
jaco node join.
Registry credentials
Container-registry credentials used at image-pull time are replicated
across the raft and consumed by every node — operators set them once
via jaco registry login and every node
(including ones that join later) can pull from private registries
without per-node ~/.docker/config.json.
The entity is RegistryCredential{registry, username, secret, updated_at}, keyed by a canonical host[:port][/namespace[/segment...]]
string. The host portion folds Docker Hub variants to docker.io and
preserves non-default ports verbatim; any optional /namespace suffix is
lower-cased and trim-of-trailing-slash. Two credentials under the same
host but different namespace prefixes (ghcr.io/personal vs
ghcr.io/company) coexist as independent entries; the prior collapse
to bare-host stomped them, which this scheme fixes.
At image-pull time the reconciler derives the full
host[:port]/<repository-path> from the image ref and picks the stored
credential whose key is the longest path-aligned prefix of that
lookup key — so ghcr.io/personal/repo:tag selects ghcr.io/personal
when present, falling back to a bare ghcr.io entry only when no
namespace-scoped key matches. The chosen credential becomes the
base64-encoded X-Registry-Auth blob threaded into
image.PullOptions.RegistryAuth. A missing credential is not an
error — the pull proceeds anonymously.
At-rest posture. Registry secrets sit in raft unencrypted, behind
the same filesystem-permission + WireGuard-transport trust boundary
as the cluster CA private key (ca_key in ClusterMeta) and ACME
private keys (CertBlob). This is acceptable for the initial drop
because the trust posture is symmetric across all replicated secrets;
envelope encryption is a possible follow-up that should arguably
cover certs/CA in the same pass rather than singling out registry
creds (issue #101
notes this explicitly).
Values resolved from a jaco.yaml top-level
environment: file are
baked into the resolved compose bytes at apply time and ride raft as
part of the per-deployment compose_yaml field — so they sit under
the same trust posture as compose_yaml already does today, not
a new secret category.
Replication semantics. Add/Remove gate on the raft leader and
propagate via a normal raft Apply, so the credential is on every
follower within one apply cycle (well under the spec's 5 s bar).
FSMSnapshot includes repeated RegistryCredential registry_credentials = 18 so a fresh node that installs a snapshot
picks up every credential without operator re-entry.
Read paths never expose the secret. jaco registry list returns
a RegistryCredentialSummary (registry + username +
updated_at); the wire type has no secret field. Audit events for
upsert/remove carry registry + username only — symmetric with
TOKEN_ISSUE which records only the identity. The only way to
rotate a credential is to login again with the new secret; there is
no read-back.
The unix-socket trust boundary
On a cluster node, the local daemon listens on
/var/run/jaco/jaco.sock, mode 0660, group jaco. Anyone in the
jaco group can drive the local daemon without presenting a bearer
token — the socket's filesystem permissions ARE the auth boundary;
the kernel enforces group membership at connect(2).
Audit events for socket-origin RPCs are attributed to the special
identity local, so on-node actions remain traceable even though no
token was involved.
isUnixPeer in admission.go fails closed: when peer info is missing
or the network is anything other than unix, the bearer-token check
applies. The bypass triggers only for genuine unix-socket peers — TCP
loopback still requires a token.
Pre-init exception
Before jaco cluster init or jaco node join runs, the daemon is
uninitialized. The admission gate accepts exactly three methods
without a token:
Cluster.InitCluster.JoinCluster.Status
Every other RPC returns
Error{code: cluster_uninitialized} with Unavailable gRPC status.
This is what jaco cluster status on a fresh node calls, so liveness
probes work before the cluster exists.
After Init or Join completes, the gate flips and the standard token / socket-trust admission applies to every RPC.
Which auth path does each command use?
Every operator command honors the same dial shape, encoded in
dialOperator():
--server <host:port>set → TCP path, bearer token required (--tokenorJACO_TOKEN).--serverunset → unix-socket path, no token; the socket file must exist (--socketorJACO_SOCKEToverrides the default).
A subset of commands (rollback, delete, token *, node list)
currently require --server; the unix-socket path for those RPCs is
planned. The CLI pages list per-command auth explicitly.
Audit trail
Every state-changing RPC writes an AuditEvent{identity, type, payload} through raft. The identity is the resolved name (e.g.
alice, ci-deploy) for token-authenticated RPCs, local for
socket-trust RPCs, bootstrap for the first-token operator, or
system for daemon-driven events (e.g.
isolation_ruleset_reconciled).
See jaco audit and
Status and errors for the audit-type enum.
Recommended posture
- Mint one operator token per human + per CI job. Bind each to a meaningful identity so audit events are useful.
- Revoke as soon as a token leaves the password manager (someone left the team, a CI runner rolled keys, a token leaked into a log).
- On-node operations from root (
sudo jaco …) over the unix socket are simpler and safer than a token round-trip — use them when you're on the box. - Distribute the cluster CA cert with your operator-tools onboarding;
do not rely on the
InsecureSkipVerifyfallback.