// announce.go — agent-side announce-and-approve enrolment (P2-18c). // // Run path: when the agent has no AgentToken set but RM_SERVER is // configured (and no -enroll-token was supplied), main() switches // into announce mode: // 1. Load (or mint+persist) an Ed25519 keypair in agent.yaml. // 2. POST {hostname, os, arch, agent_version, restic_version, // public_key} to /api/agents/announce. // 3. Print the fingerprint to stderr in a copy-friendly banner so // the operator can compare it against the dashboard. // 4. Open /ws/agent/pending?pending_id=…, sign the nonce with our // private key, wait for an `enrolled` message. // 5. On enrolled: persist the bearer + repo creds, return; main() // then drops into the normal WS run loop with the new bearer. // 6. On reject: server closes the socket with code 4001; we exit // with a clear message. package main import ( "context" "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/json" "errors" "fmt" stdhttp "net/http" "os" "strings" "time" "github.com/coder/websocket" "gitea.dcglab.co.uk/steve/restic-manager/internal/agent/config" "gitea.dcglab.co.uk/steve/restic-manager/internal/agent/secrets" "gitea.dcglab.co.uk/steve/restic-manager/internal/agent/sysinfo" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // announceRequest mirrors the server's announceRequest. Duplicated // here so cmd/agent stays decoupled from the http package. type announceRequest struct { Hostname string `json:"hostname"` OS string `json:"os"` Arch string `json:"arch"` AgentVersion string `json:"agent_version"` ResticVersion string `json:"restic_version"` PublicKey string `json:"public_key"` } type announceResponse struct { PendingID string `json:"pending_id"` Fingerprint string `json:"fingerprint"` HostnameCollision bool `json:"hostname_collision"` } type pendingNonceMessage struct { Type string `json:"type"` Nonce string `json:"nonce"` } type pendingSignedMessage struct { Type string `json:"type"` Signature string `json:"signature"` } type pendingEnrolledMessage struct { Type string `json:"type"` HostID string `json:"host_id"` Bearer string `json:"bearer"` } // doAnnounce runs the full announce → wait-for-accept flow. On // success, persists the bearer + host_id into cfg + writes secrets // for the repo creds the admin supplied at accept time. Returns // only after the bearer has landed (or on hard error / reject). func doAnnounce(serverURL string, cfg *config.Config, agentVersion string) error { ctx, cancel := context.WithTimeout(context.Background(), 24*time.Hour) defer cancel() // Ensure we have a keypair. priv, pub, err := loadOrMintAnnounceKey(cfg) if err != nil { return fmt.Errorf("announce: keypair: %w", err) } fingerprint := store.FingerprintForKey(pub) snap, err := sysinfo.Collect(ctx, cfg.ResticPath) if err != nil { return fmt.Errorf("announce: sysinfo: %w", err) } // POST /api/agents/announce. body, _ := json.Marshal(announceRequest{ Hostname: snap.Hostname, OS: string(snap.OS), Arch: string(snap.Arch), AgentVersion: agentVersion, ResticVersion: snap.ResticVersion, PublicKey: base64.StdEncoding.EncodeToString(pub), }) req, _ := stdhttp.NewRequestWithContext(ctx, "POST", strings.TrimRight(serverURL, "/")+"/api/agents/announce", strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/json") res, err := stdhttp.DefaultClient.Do(req) if err != nil { return fmt.Errorf("announce: POST: %w", err) } rawBody := readAllShort(res) _ = res.Body.Close() if res.StatusCode != stdhttp.StatusOK { return fmt.Errorf("announce: server returned %d: %s", res.StatusCode, rawBody) } var ar announceResponse if err := json.Unmarshal(rawBody, &ar); err != nil { return fmt.Errorf("announce: parse response: %w", err) } // Print the fingerprint banner. fmt.Fprintln(os.Stderr, strings.Repeat("=", 64)) fmt.Fprintln(os.Stderr, " Restic-manager: announce-and-approve enrolment") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, " Hostname : "+snap.Hostname) fmt.Fprintln(os.Stderr, " Server : "+serverURL) fmt.Fprintln(os.Stderr, " Pending ID : "+ar.PendingID) fmt.Fprintln(os.Stderr, " Fingerprint : "+fingerprint) if ar.HostnameCollision { fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, " WARNING: another pending host already uses this hostname.") fmt.Fprintln(os.Stderr, " Confirm the fingerprint above matches what you see in the UI.") } fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, " Compare the fingerprint with the one in the UI before accepting.") fmt.Fprintln(os.Stderr, " Waiting for an admin to accept (1 hour timeout)…") fmt.Fprintln(os.Stderr, strings.Repeat("=", 64)) // Open /ws/agent/pending and run the nonce-sign handshake. wsURL := wsURLFromHTTP(serverURL) + "/ws/agent/pending?pending_id=" + ar.PendingID dialCtx, dialCancel := context.WithTimeout(ctx, 30*time.Second) c, dialRes, err := websocket.Dial(dialCtx, wsURL, nil) dialCancel() if err != nil { return fmt.Errorf("announce: dial pending ws: %w", err) } if dialRes != nil && dialRes.Body != nil { _ = dialRes.Body.Close() } defer func() { _ = c.CloseNow() }() // Read nonce. rctx, rcancel := context.WithTimeout(ctx, 30*time.Second) _, raw, err := c.Read(rctx) rcancel() if err != nil { return fmt.Errorf("announce: read nonce: %w", err) } var nm pendingNonceMessage if err := json.Unmarshal(raw, &nm); err != nil { return fmt.Errorf("announce: parse nonce: %w", err) } nonce, err := base64.StdEncoding.DecodeString(nm.Nonce) if err != nil { return fmt.Errorf("announce: decode nonce: %w", err) } sig := ed25519.Sign(priv, nonce) reply, _ := json.Marshal(pendingSignedMessage{ Type: "signed_nonce", Signature: base64.StdEncoding.EncodeToString(sig), }) wctx, wcancel := context.WithTimeout(ctx, 10*time.Second) if err := c.Write(wctx, websocket.MessageText, reply); err != nil { wcancel() return fmt.Errorf("announce: write signed nonce: %w", err) } wcancel() // Block until enrolled (or reject / disconnect). rctx2, rcancel2 := context.WithTimeout(ctx, 1*time.Hour) defer rcancel2() _, raw2, err := c.Read(rctx2) if err != nil { // CloseError with our reject code 4001 = admin rejected. var ce websocket.CloseError if errors.As(err, &ce) && ce.Code == 4001 { return errors.New("announce: rejected by admin") } return fmt.Errorf("announce: wait for enrolled: %w", err) } var em pendingEnrolledMessage if err := json.Unmarshal(raw2, &em); err != nil { return fmt.Errorf("announce: parse enrolled: %w", err) } if em.Type != "enrolled" || em.Bearer == "" { return fmt.Errorf("announce: bad enrolled payload: %s", raw2) } // Persist the bearer + host_id. cfg.ServerURL = serverURL cfg.HostID = em.HostID cfg.AgentToken = em.Bearer if err := cfg.EnsureSecretsKey(); err != nil { return fmt.Errorf("announce: mint secrets key: %w", err) } // Note: repo creds aren't pushed in the enrolled message — the // server pushes them via `config.update` on first WS hello. The // secrets store will start empty and fill in then. if err := cfg.Save(); err != nil { return fmt.Errorf("announce: save config: %w", err) } // Touch the secrets store so it exists with the right perms. keyBytes, _ := cfg.SecretsKeyBytes() if _, err := secrets.New(cfg.ResolvedSecretsPath(), keyBytes); err != nil { return fmt.Errorf("announce: open secrets store: %w", err) } fmt.Fprintln(os.Stderr, "Accepted. Bearer persisted; reconnecting via the standard WS.") return nil } // loadOrMintAnnounceKey returns the (priv, pub) keypair, generating // + persisting one when AnnounceKey is empty. The private key holds // the public half in its tail 32 bytes per ed25519 convention. func loadOrMintAnnounceKey(cfg *config.Config) (ed25519.PrivateKey, ed25519.PublicKey, error) { if cfg.AnnounceKey != "" { raw, err := base64.StdEncoding.DecodeString(cfg.AnnounceKey) if err != nil { return nil, nil, fmt.Errorf("decode AnnounceKey: %w", err) } if len(raw) != ed25519.PrivateKeySize { return nil, nil, fmt.Errorf("AnnounceKey must be %d bytes, got %d", ed25519.PrivateKeySize, len(raw)) } priv := ed25519.PrivateKey(raw) pub := priv.Public().(ed25519.PublicKey) return priv, pub, nil } pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { return nil, nil, fmt.Errorf("generate keypair: %w", err) } cfg.AnnounceKey = base64.StdEncoding.EncodeToString(priv) if err := cfg.Save(); err != nil { return nil, nil, fmt.Errorf("persist AnnounceKey: %w", err) } return priv, pub, nil } // wsURLFromHTTP swaps the http(s) scheme for ws(s). func wsURLFromHTTP(httpURL string) string { switch { case strings.HasPrefix(httpURL, "https://"): return "wss://" + strings.TrimPrefix(httpURL, "https://") case strings.HasPrefix(httpURL, "http://"): return "ws://" + strings.TrimPrefix(httpURL, "http://") default: return httpURL } } // readAllShort reads up to 64KB of the response body. The announce // response is small; we cap to avoid pathological server replies. func readAllShort(res *stdhttp.Response) []byte { buf := make([]byte, 64*1024) n, _ := res.Body.Read(buf) return buf[:n] }