package offsiteapply import ( "context" "crypto/ed25519" "encoding/json" "encoding/pem" "errors" "fmt" "io" "net" "net/http" "os" "os/exec" "path/filepath" "strconv" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/knownhosts" ) // --- func adapters (convenient wiring in main.go) --- type EnablerFunc func(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string, quotaGB int) error func (f EnablerFunc) ConfigureOffbox(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string, quotaGB int) error { return f(ctx, host, user, port, repoPath, privPEM, knownHosts, quotaGB) } // SettleFunc adapts a plain func to a SettleProvider (thin adapter over the Updater in main.go — // the StackDataProvider pattern). It reads the updater's OWN knowledge; the bridge never fetches the // floor a second way (no second floor path). type SettleFunc func() (version, floor string, updateRunning, floorKnown bool) func (f SettleFunc) SettleState() (string, string, bool, bool) { return f() } // --- HubRegistrar: the box's half of the hub key registrar (decision 69, hub >= v0.127.0) --- // // The box sends ONLY its public key; the hub writes it into the Storage Box sub-account's // authorized_keys pinned to `rclone serve restic --stdio --append-only `. Until controller // v0.289.0 the box fetched the sub-account PASSWORD here (consume-password) — and that password can // rewrite authorized_keys, i.e. remove the pin (measured 2026-10-03, R-820). No response this client // reads can carry a password; TestHubRegistrar_NeverAsksForAPassword pins the paths it calls. type HubRegistrar struct { HubURL string CustomerID string APIKey string HC *http.Client } func (c HubRegistrar) post(ctx context.Context, path string, body any) ([]byte, error) { if c.HubURL == "" || c.CustomerID == "" || c.APIKey == "" { return nil, fmt.Errorf("offsite-apply: hub url/customer/apikey not configured") } hc := c.HC if hc == nil { hc = &http.Client{Timeout: 3 * time.Minute} // the hub does an SSH round trip to the provider } var rd io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { return nil, err } rd = strings.NewReader(string(b)) } url := strings.TrimRight(c.HubURL, "/") + "/api/v1/offsite/" + path + "/" + c.CustomerID req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, rd) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+c.APIKey) req.Header.Set("Content-Type", "application/json") resp, err := hc.Do(req) if err != nil { return nil, err } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("hub %s: HTTP %d: %s", path, resp.StatusCode, truncate(raw)) } return raw, nil } // Register asks the hub to install pub pinned append-only; returns the key's fingerprint as the hub saw it. func (c HubRegistrar) Register(ctx context.Context, pub string) (string, error) { raw, err := c.post(ctx, "register-key", map[string]string{"public_key": strings.TrimSpace(pub)}) if err != nil { return "", err } var r struct { Installed bool `json:"installed"` Fingerprint string `json:"fingerprint"` } if err := json.Unmarshal(raw, &r); err != nil || !r.Installed || r.Fingerprint == "" { return "", fmt.Errorf("hub register-key: malformed response") } return r.Fingerprint, nil } // Confirm tells the hub the box now uses fp; the hub removes every other key line. func (c HubRegistrar) Confirm(ctx context.Context, fp string) error { _, err := c.post(ctx, "confirm-key", map[string]string{"fingerprint": fp}) return err } // MoveAside asks the hub to set the repository aside (never deletes) — the box's pinned key cannot. func (c HubRegistrar) MoveAside(ctx context.Context) (string, error) { raw, err := c.post(ctx, "move-aside", nil) if err != nil { return "", err } var r struct { MovedTo string `json:"moved_to"` } if err := json.Unmarshal(raw, &r); err != nil || r.MovedTo == "" { return "", fmt.Errorf("hub move-aside: malformed response") } return r.MovedTo, nil } // --- HubWindowClient: the box's half of the clean-up window (decision 68) --- type HubWindowClient struct{ Registrar HubRegistrar } func (c HubWindowClient) Open(ctx context.Context, countBefore int) (backup.OffsiteWindow, error) { raw, err := c.Registrar.post(ctx, "window-open", map[string]int{"count_before": countBefore}) if err != nil { return backup.OffsiteWindow{}, err } var r struct { Granted bool `json:"granted"` WindowID int64 `json:"window_id"` NewestAllowed string `json:"newest_allowed"` MaxRemove int `json:"max_remove"` Reason string `json:"reason"` } if err := json.Unmarshal(raw, &r); err != nil { return backup.OffsiteWindow{}, fmt.Errorf("hub window-open: malformed response") } w := backup.OffsiteWindow{Granted: r.Granted, ID: r.WindowID, MaxRemove: r.MaxRemove, Reason: r.Reason} if r.Granted { t, perr := time.Parse(time.RFC3339, r.NewestAllowed) if perr != nil { return backup.OffsiteWindow{}, fmt.Errorf("hub window-open: bad newest_allowed") } w.NewestAllowed = t } return w, nil } func (c HubWindowClient) Close(ctx context.Context, res backup.OffsiteWindowResult) error { _, err := c.Registrar.post(ctx, "window-close", res) return err } // --- HubAbandonClient: the box's half of the hub's set-aside deletion (decision 74) --- type HubAbandonClient struct{ Registrar HubRegistrar } func (c HubAbandonClient) Request(ctx context.Context, path string) (time.Time, error) { raw, err := c.Registrar.post(ctx, "abandon-request", map[string]string{"path": path}) if err != nil { return time.Time{}, err } var r struct { State string `json:"state"` DueAt string `json:"due_at"` } if err := json.Unmarshal(raw, &r); err != nil || r.State != "pending" { return time.Time{}, fmt.Errorf("hub abandon-request: unexpected answer (state %q)", r.State) } t, err := time.Parse(time.RFC3339, r.DueAt) if err != nil { return time.Time{}, fmt.Errorf("hub abandon-request: bad due_at") } return t, nil } func (c HubAbandonClient) Status(ctx context.Context) (string, error) { raw, err := c.Registrar.post(ctx, "abandon-status", nil) if err != nil { return "", err } var r struct { State string `json:"state"` } if err := json.Unmarshal(raw, &r); err != nil || r.State == "" { return "", fmt.Errorf("hub abandon-status: malformed") } return r.State, nil } func (c HubAbandonClient) Cancel(ctx context.Context) error { _, err := c.Registrar.post(ctx, "abandon-cancel", nil) return err } // --- KeyscanScanner: capture the box host key (x/crypto/ssh, no binary) → fingerprint + known_hosts line --- type KeyscanScanner struct { Timeout time.Duration } var errScanCaptured = errors.New("host key captured") func (s KeyscanScanner) Scan(ctx context.Context, host string, port int) (string, string, error) { timeout := s.Timeout if timeout == 0 { timeout = 10 * time.Second } var fp, line string cfg := &ssh.ClientConfig{ User: "felhom-keyscan", Timeout: timeout, HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error { fp = ssh.FingerprintSHA256(key) line = knownhosts.Line([]string{knownhosts.Normalize(net.JoinHostPort(host, strconv.Itoa(port)))}, key) return errScanCaptured }, } d := net.Dialer{Timeout: timeout} conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port))) if err != nil { return "", "", fmt.Errorf("dial: %w", err) } defer conn.Close() c, _, _, herr := ssh.NewClientConn(conn, host, cfg) if c != nil { c.Close() } if fp != "" && line != "" { return fp, line, nil } return "", "", fmt.Errorf("host-key handshake: %w", herr) } // --- ED25519KeyGen: a fresh keypair (OpenSSH private PEM + authorized_keys pub line) --- type ED25519KeyGen struct{} func (ED25519KeyGen) Generate() (string, string, error) { pub, priv, err := ed25519.GenerateKey(nil) if err != nil { return "", "", err } block, err := ssh.MarshalPrivateKey(priv, "felhom-offbox") if err != nil { return "", "", err } sshPub, err := ssh.NewPublicKey(pub) if err != nil { return "", "", err } privPEM := string(pem.EncodeToMemory(block)) pubLine := string(ssh.MarshalAuthorizedKey(sshPub)) // includes trailing newline return privPEM, pubLine, nil } // --- PinnedProber: does this key reach the PINNED append-only server? --- // // Measured on the provider 2026-10-03 (audits/offsite-lock-build-2026-10-03/partA/A5): `ssh -i // host probe` with stdin closed exits 0 and prints rclone's NOTICE when the key is pinned (the forced // rclone starts, sees EOF, exits); an UNPINNED key gets the restricted shell → "Command not found", // exit 8. A refused key exits 255. So ok = exit 0 AND "rclone" in the output — a POSITIVE observable. type PinnedProber struct { Timeout time.Duration // 0 → 20 s // Run is the exec seam (tests); nil → real ssh. Run func(ctx context.Context, args []string) ([]byte, error) } func (p PinnedProber) Probe(ctx context.Context, host, user string, port int, knownHosts, privPEM string) bool { if strings.TrimSpace(privPEM) == "" { return false } timeout := p.Timeout if timeout == 0 { timeout = 20 * time.Second } pctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() work, err := os.MkdirTemp("", "felhom-keyprobe-") if err != nil { return false } defer os.RemoveAll(work) khPath, keyPath := filepath.Join(work, "known_hosts"), filepath.Join(work, "id") if os.WriteFile(khPath, []byte(knownHosts+"\n"), 0o600) != nil || os.WriteFile(keyPath, []byte(privPEM), 0o600) != nil { return false } args := []string{"-p", strconv.Itoa(port), "-i", keyPath, "-oBatchMode=yes", "-oConnectTimeout=10", "-oIdentitiesOnly=yes", "-oStrictHostKeyChecking=yes", "-oUserKnownHostsFile=" + khPath, user + "@" + host, "probe"} run := p.Run if run == nil { run = func(ctx context.Context, args []string) ([]byte, error) { cmd := exec.CommandContext(ctx, "ssh", args...) cmd.Stdin = strings.NewReader("") return cmd.CombinedOutput() } } out, err := run(pctx, args) return err == nil && strings.Contains(string(out), "rclone") } // PublicKeyOf derives the authorized_keys line of an OpenSSH private key (the migration path: a box // whose key predates the pin registers the SAME key, so the hub re-writes it pinned). func PublicKeyOf(privPEM string) (string, error) { signer, err := ssh.ParsePrivateKey([]byte(privPEM)) if err != nil { return "", err } return string(ssh.MarshalAuthorizedKey(signer.PublicKey())), nil } // FingerprintOf returns the SHA256 fingerprint of an authorized_keys line. func FingerprintOf(pub string) (string, error) { pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pub)) if err != nil { return "", err } return ssh.FingerprintSHA256(pk), nil } func truncate(b []byte) string { s := strings.TrimSpace(string(b)) if len(s) > 300 { return s[:300] + "…" } return s }