Files
felhom-controller/controller/internal/offsiteapply/seams.go
T

335 lines
11 KiB
Go

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 <repo>`. 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 <key>
// 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
}