c1a73b24b3
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
335 lines
11 KiB
Go
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
|
|
}
|