v0.289.0: off-site key cannot delete — append-only rclone transport, box sends only its public key (hub registrar), retention only inside a hub window behind the fake-snapshot guard (decisions 68-69, R-820, R-822)
gates / gates (push) Successful in 27s
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
This commit is contained in:
@@ -17,16 +17,13 @@ import (
|
||||
"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 ConsumerFunc func(ctx context.Context) (string, error)
|
||||
|
||||
func (f ConsumerFunc) Consume(ctx context.Context) (string, error) { return f(ctx) }
|
||||
|
||||
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 {
|
||||
@@ -40,48 +37,125 @@ type SettleFunc func() (version, floor string, updateRunning, floorKnown bool)
|
||||
|
||||
func (f SettleFunc) SettleState() (string, string, bool, bool) { return f() }
|
||||
|
||||
// --- HTTPConsumer: POST the hub consume-password endpoint with the per-customer API key ---
|
||||
|
||||
type HTTPConsumer struct {
|
||||
// --- 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 HTTPConsumer) Consume(ctx context.Context) (string, error) {
|
||||
func (c HubRegistrar) post(ctx context.Context, path string, body any) ([]byte, error) {
|
||||
if c.HubURL == "" || c.CustomerID == "" || c.APIKey == "" {
|
||||
return "", fmt.Errorf("offsite-apply: consume: hub url/customer/apikey not configured")
|
||||
return nil, fmt.Errorf("offsite-apply: hub url/customer/apikey not configured")
|
||||
}
|
||||
hc := c.HC
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: 20 * time.Second}
|
||||
hc = &http.Client{Timeout: 3 * time.Minute} // the hub does an SSH round trip to the provider
|
||||
}
|
||||
url := strings.TrimRight(c.HubURL, "/") + "/api/v1/offsite/consume-password/" + c.CustomerID
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
|
||||
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 "", err
|
||||
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 "", err
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return "", fmt.Errorf("no unconsumed offsite password (already consumed or none provisioned)")
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("consume: HTTP %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("hub %s: HTTP %d: %s", path, resp.StatusCode, truncate(raw))
|
||||
}
|
||||
var body struct {
|
||||
Password string `json:"password"`
|
||||
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
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil || body.Password == "" {
|
||||
return "", fmt.Errorf("consume: malformed response")
|
||||
var r struct {
|
||||
Installed bool `json:"installed"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
return body.Password, nil // NEVER logged
|
||||
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
|
||||
}
|
||||
|
||||
// --- KeyscanScanner: capture the box host key (x/crypto/ssh, no binary) → fingerprint + known_hosts line ---
|
||||
@@ -145,72 +219,21 @@ func (ED25519KeyGen) Generate() (string, string, error) {
|
||||
return privPEM, pubLine, nil
|
||||
}
|
||||
|
||||
// --- SSHCopyIDInstaller: install the pubkey via the proven `sshpass -e ssh-copy-id -p N -s -f`, verify ---
|
||||
|
||||
type SSHCopyIDInstaller struct{}
|
||||
|
||||
func (SSHCopyIDInstaller) Install(ctx context.Context, host, user string, port int, password, privPEM, pubAuthorized, knownHosts string) error {
|
||||
if strings.TrimSpace(knownHosts) == "" {
|
||||
return fmt.Errorf("ssh-copy-id: empty known_hosts — refusing to install without a pinned host key")
|
||||
}
|
||||
// ssh-copy-id -s (SFTP mode) mktemp's its batch file under ~/.ssh and dies LOCALLY if the directory
|
||||
// doesn't exist — the container image ships without /root/.ssh (live finding: the one-time password was
|
||||
// consumed, then the install failed before ever connecting).
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
if err := os.MkdirAll(filepath.Join(home, ".ssh"), 0o700); err != nil {
|
||||
return fmt.Errorf("ssh-copy-id: ensure ~/.ssh (needed by -s mode): %w", err)
|
||||
}
|
||||
}
|
||||
work, err := os.MkdirTemp("", "felhom-keyinstall-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(work)
|
||||
pubPath := filepath.Join(work, "id.pub")
|
||||
privPath := filepath.Join(work, "id")
|
||||
khPath := filepath.Join(work, "known_hosts")
|
||||
if err := os.WriteFile(pubPath, []byte(pubAuthorized), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(privPath, []byte(privPEM), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
// Pin the scanner-VERIFIED host key: StrictHostKeyChecking=yes against this known_hosts refuses any
|
||||
// other key (no accept-new/TOFU) — the ssh-copy-id + verify sessions connect ONLY to the box whose
|
||||
// fingerprint the bridge already matched against the hub descriptor.
|
||||
if err := os.WriteFile(khPath, []byte(knownHosts+"\n"), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
// Install (SSHPASS env is read by `sshpass -e`; the password never appears on argv).
|
||||
install := exec.CommandContext(ctx, "sshpass", "-e", "ssh-copy-id", "-p", strconv.Itoa(port), "-s", "-f",
|
||||
"-i", pubPath, "-o", "StrictHostKeyChecking=yes", "-o", "UserKnownHostsFile="+khPath, user+"@"+host)
|
||||
install.Env = append(os.Environ(), "SSHPASS="+password)
|
||||
if out, err := install.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("ssh-copy-id: %w: %s", err, truncate(out))
|
||||
}
|
||||
// Verify passwordless key auth (an SFTP no-op; the box's restricted shell only offers SFTP).
|
||||
verify := exec.CommandContext(ctx, "sftp", "-b", "-", "-P", strconv.Itoa(port),
|
||||
"-i", privPath, "-oBatchMode=yes", "-oStrictHostKeyChecking=yes", "-oUserKnownHostsFile="+khPath, user+"@"+host)
|
||||
verify.Stdin = strings.NewReader("pwd\n")
|
||||
if out, err := verify.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("key-auth verify failed after install: %w: %s", err, truncate(out))
|
||||
}
|
||||
return 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)
|
||||
}
|
||||
|
||||
// --- SFTPKeyAuthProber: does the ALREADY-INSTALLED key still authenticate? (key-auth-first) ---
|
||||
|
||||
// SFTPKeyAuthProber probes passwordless auth with the existing installed key (KeyPath), pinned to the
|
||||
// freshly-verified knownHosts line. No key file → ok=false (fresh guest). The probe never logs secrets.
|
||||
type SFTPKeyAuthProber struct {
|
||||
KeyPath string // the installed key, e.g. <dataDir>/offbox/ssh_key
|
||||
Timeout time.Duration // per-probe budget; 0 → 20s
|
||||
}
|
||||
|
||||
func (p SFTPKeyAuthProber) Probe(ctx context.Context, host, user string, port int, knownHosts string) (string, bool) {
|
||||
pem, err := os.ReadFile(p.KeyPath)
|
||||
if err != nil {
|
||||
return "", false // no existing key — a fresh guest; take the full path
|
||||
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 {
|
||||
@@ -220,21 +243,44 @@ func (p SFTPKeyAuthProber) Probe(ctx context.Context, host, user string, port in
|
||||
defer cancel()
|
||||
work, err := os.MkdirTemp("", "felhom-keyprobe-")
|
||||
if err != nil {
|
||||
return "", false
|
||||
return false
|
||||
}
|
||||
defer os.RemoveAll(work)
|
||||
khPath := filepath.Join(work, "known_hosts")
|
||||
if err := os.WriteFile(khPath, []byte(knownHosts+"\n"), 0o600); err != nil {
|
||||
return "", false
|
||||
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
|
||||
}
|
||||
probe := exec.CommandContext(pctx, "sftp", "-b", "-", "-P", strconv.Itoa(port),
|
||||
"-i", p.KeyPath, "-oBatchMode=yes", "-oConnectTimeout=10",
|
||||
"-oStrictHostKeyChecking=yes", "-oUserKnownHostsFile="+khPath, user+"@"+host)
|
||||
probe.Stdin = strings.NewReader("pwd\n")
|
||||
if err := probe.Run(); err != nil {
|
||||
return "", false // auth refused / unreachable — fall through to the full path
|
||||
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()
|
||||
}
|
||||
}
|
||||
return string(pem), true
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user