feat: join-key enrollment (v1.5.0, protocol v1.2.0 1.1)

Installing the agent with one key is now all it takes to add a host.

New `join_key` config field, presented while auth_token is empty. The SSO
exchanges it for this agent's own token and the public key it must pin,
both delivered in the config frame; the agent persists them and blanks
the join key. Nothing has to be copied between two machines by hand.

PersistEnrollment rewrites only the credential lines -- line-based rather
than a YAML round-trip -- so operator comments, the capability matrix and
formatting survive. It re-reads afterwards so the credential is live
without a restart, and keeps the file 0600.

The connect URL carries ?hostname= so a self-enrolling host is named
after itself, and the agent refuses to connect at all (with a long
back-off) when it has no credential rather than presenting an empty one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 10:34:06 -04:00
parent dc14274edc
commit 0a5011cd42
8 changed files with 296 additions and 18 deletions
+33 -1
View File
@@ -121,7 +121,21 @@ func connectWebSocket(cm *ConfigManager, exec Executor) {
log.Fatalf("Invalid ServerURL: %v", err)
}
u.Path = "/api/agent/ws"
u.RawQuery = "token=" + url.QueryEscape(cfg.AuthToken)
// Our own token once enrolled, else the join key. The hostname lets the
// server name a self-enrolling host something meaningful instead of a
// generated placeholder.
q := url.Values{}
q.Set("token", cfg.Credential())
if hn, err := os.Hostname(); err == nil && hn != "" {
q.Set("hostname", hn)
}
u.RawQuery = q.Encode()
if cfg.Credential() == "" {
log.Printf("No auth_token or join_key in %s -- nothing to authenticate with. Retrying in %s.", cm.configPath, authRetryInterval)
time.Sleep(authRetryInterval)
continue
}
// Never log u.String(): RawQuery carries the auth token, and agent logs
// are routinely shipped around and pasted into issues.
@@ -292,6 +306,24 @@ func handleCommand(cm *ConfigManager, msg WSMessage, c MessageWriter, exec Execu
sendResponse("ok", "update applied successfully; restarting agent...")
os.Exit(0)
case "config":
// A config frame carrying credentials means the server accepted our
// join key and enrolled this host. Persist what it issued -- our own
// per-agent token and the public key to pin -- so the next connection
// authenticates as this agent rather than re-enrolling, and so signed
// commands can be verified. This is what lets an install ship with only
// a join key and still end up fully configured.
if enrolled, _ := msg.Payload["enrolled"].(bool); enrolled {
token, _ := msg.Payload["auth_token"].(string)
pubKey, _ := msg.Payload["public_key"].(string)
if err := cm.PersistEnrollment(token, pubKey); err != nil {
log.Printf("Enrolled, but could not persist credentials: %v", err)
log.Printf("This agent will re-enroll on every reconnect until %s is writable.", cm.configPath)
} else {
log.Printf("Enrolled with the SSO. Credentials written to %s; the join key is no longer needed.", cm.configPath)
}
sendResponse("ok", "enrollment stored")
return
}
log.Printf("Received config payload: %v", msg.Payload)
sendResponse("ok", "Configuration received")
case "reboot":