phase 1: WS transport, enrollment, agent that hellos and heartbeats
Lands the protocol layer end-to-end: an agent can be enrolled through the operator UI, store credentials, dial back to the server over WS, complete the protocol_version handshake, and stay connected with periodic heartbeats. Server side: - P1-09 ws.Hub: one Conn per host_id, last-write-wins eviction, json envelope writer with a write mutex, reader, error envelopes. - P1-09 ws.AgentHandler: bearer-auth, accept upgrade, hello-stage (10s deadline, protocol_version checked against api.MinAgentProtocolVersion → ErrProtocolTooOld with help URL on reject), main read loop, defer hub register/unregister. - P1-10 POST /api/agents/enroll consumes a one-time token, mints a persistent agent bearer (sha-256 stored), creates a host row. - P1-10 POST /api/enrollment-tokens (operator, session-auth) issues a 1h one-time token. - P1-11 hello upserts agent_version + restic_version + protocol_version on the host row, flips status to online. - P1-12 heartbeat touches last_seen_at; background sweeper marks hosts offline after 90s without one. - store: hosts table accessors, host_schedule_version, enrollment_tokens FK on consumed_host dropped (audit-only field; the token gets burned before the host row exists). Agent side: - P1-13 internal/agent/config: yaml at /etc/restic-manager/agent.yaml, atomic Save (tmp+fsync+rename), Enrolled() helper. - P1-15 internal/agent/wsclient: dial with bearer + optional TLS cert pinning (sha-256 of leaf), exponential backoff with jitter (1s → 60s cap), heartbeat goroutine, fatal handling for ErrProtocolTooOld. - P1-15 wsclient.Enroll: HTTP POST /api/agents/enroll with sysinfo. - P1-17 internal/agent/sysinfo: hostname/OS/arch/restic-version collection. restic detected by `restic version` parse; absent restic doesn't block startup. - cmd/agent: -enroll-server / -enroll-token flags drive first-run enrollment then exit (so the install script can hand off to systemd to run the persistent service). End-to-end smoke verified: bootstrap → login → issue token → enroll → run agent → server logs `ws agent connected` with the right host_id and protocol_version 1. All tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
package wsclient
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
stdhttp "net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
||||
)
|
||||
|
||||
// EnrollRequest is what we POST to /api/agents/enroll.
|
||||
type EnrollRequest struct {
|
||||
Token string `json:"token"`
|
||||
HostName string `json:"hostname"`
|
||||
OS api.HostOS `json:"os"`
|
||||
Arch api.HostArch `json:"arch"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
ResticVersion string `json:"restic_version"`
|
||||
}
|
||||
|
||||
// EnrollResponse is what the server hands back.
|
||||
type EnrollResponse struct {
|
||||
HostID string `json:"host_id"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
CertPinSHA256 string `json:"cert_pin_sha256,omitempty"`
|
||||
}
|
||||
|
||||
// Enroll exchanges a one-time enrollment token for persistent agent
|
||||
// credentials. Called by the install script on first run.
|
||||
func Enroll(ctx context.Context, serverURL string, req EnrollRequest) (*EnrollResponse, error) {
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agent enroll: marshal: %w", err)
|
||||
}
|
||||
postURL := strings.TrimRight(serverURL, "/") + "/api/agents/enroll"
|
||||
|
||||
httpReq, err := stdhttp.NewRequestWithContext(ctx, stdhttp.MethodPost, postURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agent enroll: build request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &stdhttp.Client{Timeout: 30 * time.Second}
|
||||
res, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agent enroll: post: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
rawRes, _ := io.ReadAll(res.Body)
|
||||
if res.StatusCode != stdhttp.StatusCreated {
|
||||
return nil, fmt.Errorf("agent enroll: server returned %d: %s",
|
||||
res.StatusCode, rawRes)
|
||||
}
|
||||
var er EnrollResponse
|
||||
if err := json.Unmarshal(rawRes, &er); err != nil {
|
||||
return nil, fmt.Errorf("agent enroll: parse response: %w", err)
|
||||
}
|
||||
if er.AgentToken == "" || er.HostID == "" {
|
||||
return nil, fmt.Errorf("agent enroll: incomplete response: %+v", er)
|
||||
}
|
||||
return &er, nil
|
||||
}
|
||||
Reference in New Issue
Block a user