55bb6c3d32
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
295 lines
9.2 KiB
Go
295 lines
9.2 KiB
Go
package offsiteapply
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
)
|
|
|
|
// --- fakes ---
|
|
|
|
// fakeRegistrar is the hub's key registrar. `calls` counts Register — the one call that writes to the
|
|
// provider; it is the "did the bridge go to the hub" signal the settle-gate and retry tests count.
|
|
type fakeRegistrar struct {
|
|
calls int
|
|
confirms []string
|
|
gotPub string
|
|
wrongFP bool
|
|
err error
|
|
confirmEr error
|
|
panics bool
|
|
}
|
|
|
|
func (f *fakeRegistrar) Register(_ context.Context, pub string) (string, error) {
|
|
if f.panics {
|
|
panic("register must NOT be called")
|
|
}
|
|
f.calls++
|
|
f.gotPub = pub
|
|
if f.err != nil {
|
|
return "", f.err
|
|
}
|
|
if f.wrongFP {
|
|
return "SHA256:somebody-else", nil
|
|
}
|
|
return FingerprintOf(pub)
|
|
}
|
|
|
|
func (f *fakeRegistrar) Confirm(_ context.Context, fp string) error {
|
|
f.confirms = append(f.confirms, fp)
|
|
return f.confirmEr
|
|
}
|
|
|
|
type fakeScanner struct {
|
|
fp, line string
|
|
err error
|
|
}
|
|
|
|
func (f *fakeScanner) Scan(_ context.Context, _ string, _ int) (string, string, error) {
|
|
return f.fp, f.line, f.err
|
|
}
|
|
|
|
// realKeyGen returns ONE real pair (the bridge parses keys now — fingerprints are real).
|
|
type realKeyGen struct{ priv, pub string }
|
|
|
|
func (f *realKeyGen) Generate() (string, string, error) { return f.priv, f.pub, nil }
|
|
|
|
// fakeProber models the provider: a key reaches the pinned server once the registrar installed it
|
|
// (pinnedAfterRegister) or from the start (pinnedInitially).
|
|
type fakeProber struct {
|
|
reg *fakeRegistrar
|
|
pinnedInitially bool
|
|
pinnedAfterRegister bool
|
|
calls int
|
|
gotKH, gotPriv string
|
|
}
|
|
|
|
func (f *fakeProber) Probe(_ context.Context, _, _ string, _ int, kh, priv string) bool {
|
|
f.calls++
|
|
f.gotKH, f.gotPriv = kh, priv
|
|
return f.pinnedInitially || (f.pinnedAfterRegister && f.reg.calls > 0)
|
|
}
|
|
|
|
type fakeEnabler struct {
|
|
err error
|
|
calls int
|
|
gotHost string
|
|
gotKnownHost string
|
|
gotPriv string
|
|
gotQuotaGB int
|
|
}
|
|
|
|
func (f *fakeEnabler) ConfigureOffbox(_ context.Context, host, _ string, _ int, _, privPEM, knownHosts string, quotaGB int) error {
|
|
f.calls++
|
|
f.gotHost, f.gotKnownHost, f.gotPriv, f.gotQuotaGB = host, knownHosts, privPEM, quotaGB
|
|
return f.err
|
|
}
|
|
|
|
var testPriv, testPub = func() (string, string) {
|
|
p, q, err := ED25519KeyGen{}.Generate()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return p, q
|
|
}()
|
|
|
|
var otherPriv, otherPub = func() (string, string) {
|
|
p, q, _ := ED25519KeyGen{}.Generate()
|
|
return p, q
|
|
}()
|
|
|
|
func newBridge(t *testing.T, o config.OffsiteConfig) (*Bridge, *fakeRegistrar, *fakeProber, *fakeEnabler, *bytes.Buffer) {
|
|
t.Helper()
|
|
cfg := &config.Config{}
|
|
cfg.Offsite = o
|
|
reg := &fakeRegistrar{}
|
|
pr := &fakeProber{reg: reg, pinnedAfterRegister: true}
|
|
en := &fakeEnabler{}
|
|
var logbuf bytes.Buffer
|
|
b := &Bridge{
|
|
Cfg: cfg,
|
|
Registrar: reg,
|
|
Scanner: &fakeScanner{fp: "SHA256:goodfp", line: "[h]:23 ssh-ed25519 AAAAKEY"},
|
|
KeyGen: &realKeyGen{priv: testPriv, pub: testPub},
|
|
Prober: pr,
|
|
Enabler: en,
|
|
MarkerPath: filepath.Join(t.TempDir(), "offbox", "applied_marker"),
|
|
Logger: log.New(&logbuf, "", 0),
|
|
}
|
|
return b, reg, pr, en, &logbuf
|
|
}
|
|
|
|
func goodOffsite() config.OffsiteConfig {
|
|
return config.OffsiteConfig{Enabled: true, Type: "shared", Host: "h", User: "u", Port: 23, RepoPath: "/home/felhom-repo", QuotaGB: 50, HostFingerprint: "SHA256:goodfp"}
|
|
}
|
|
|
|
// A fresh box: verify host key → register the PUBLIC key → prove the pin → configure → confirm → marker.
|
|
// What crosses to the hub is a public key and nothing else.
|
|
func TestBridge_FreshBoxRegistersPublicKeyOnly(t *testing.T) {
|
|
b, reg, pr, en, logbuf := newBridge(t, goodOffsite())
|
|
if err := b.Reconcile(context.Background()); err != nil {
|
|
t.Fatalf("reconcile: %v", err)
|
|
}
|
|
if reg.calls != 1 || reg.gotPub != testPub {
|
|
t.Fatalf("register calls=%d pub=%q", reg.calls, reg.gotPub)
|
|
}
|
|
if strings.Contains(reg.gotPub, "PRIVATE") {
|
|
t.Fatal("the private key was sent to the hub")
|
|
}
|
|
if pr.calls != 2 || pr.gotKH != "[h]:23 ssh-ed25519 AAAAKEY" {
|
|
t.Fatalf("probe calls=%d kh=%q (want before+after register, pinned to the scanned host key)", pr.calls, pr.gotKH)
|
|
}
|
|
fp, _ := FingerprintOf(testPub)
|
|
if en.calls != 1 || en.gotPriv != testPriv || en.gotQuotaGB != 50 {
|
|
t.Fatalf("enabler: %+v", en)
|
|
}
|
|
if len(reg.confirms) != 1 || reg.confirms[0] != fp {
|
|
t.Fatalf("confirm = %v, want [%s]", reg.confirms, fp)
|
|
}
|
|
if _, err := os.Stat(b.MarkerPath); err != nil {
|
|
t.Fatalf("marker not persisted: %v", err)
|
|
}
|
|
if !strings.Contains(logbuf.String(), "append-only") {
|
|
t.Fatalf("log does not say append-only:\n%s", logbuf.String())
|
|
}
|
|
}
|
|
|
|
// THE MIGRATION: a box upgraded from the password era has an installed (unpinned) key and an applied
|
|
// marker written under the OLD descriptor hash. The new hash ("|pinned-v1") re-applies once; the SAME
|
|
// key is registered (no new keypair) and comes back pinned.
|
|
func TestBridge_UpgradedBoxReRegistersSameKey(t *testing.T) {
|
|
b, reg, _, en, _ := newBridge(t, goodOffsite())
|
|
b.Existing = func() string { return otherPriv }
|
|
b.KeyGen = nil // must not be needed
|
|
o := goodOffsite()
|
|
if err := os.MkdirAll(filepath.Dir(b.MarkerPath), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The pre-v0.289.0 marker for this exact descriptor:
|
|
if err := os.WriteFile(b.MarkerPath, []byte(legacyDescriptorHash(o)), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := b.Reconcile(context.Background()); err != nil {
|
|
t.Fatalf("reconcile: %v", err)
|
|
}
|
|
if reg.calls != 1 || reg.gotPub != otherPub {
|
|
t.Fatalf("the upgraded box must re-register its EXISTING key: calls=%d pub=%q", reg.calls, reg.gotPub)
|
|
}
|
|
if en.gotPriv != otherPriv {
|
|
t.Fatal("the existing key was not kept")
|
|
}
|
|
}
|
|
|
|
// A restart / descriptor change on a box whose key is already pinned: no hub write at all.
|
|
func TestBridge_AlreadyPinnedNeedsNoRegister(t *testing.T) {
|
|
b, reg, pr, en, _ := newBridge(t, goodOffsite())
|
|
b.Existing = func() string { return testPriv }
|
|
pr.pinnedInitially = true
|
|
if err := b.Reconcile(context.Background()); err != nil {
|
|
t.Fatalf("reconcile: %v", err)
|
|
}
|
|
if reg.calls != 0 || en.calls != 1 {
|
|
t.Fatalf("register=%d enable=%d", reg.calls, en.calls)
|
|
}
|
|
}
|
|
|
|
// The registrar answered but the key still does not reach the PINNED server (e.g. an unpinned line
|
|
// won): refuse — never configure a key that can delete.
|
|
func TestBridge_RegisteredButNotPinnedRefuses(t *testing.T) {
|
|
b, reg, pr, en, _ := newBridge(t, goodOffsite())
|
|
pr.pinnedAfterRegister = false
|
|
if err := b.Reconcile(context.Background()); err == nil {
|
|
t.Fatal("a key that does not reach the pinned server must refuse")
|
|
}
|
|
if reg.calls != 1 || en.calls != 0 {
|
|
t.Fatalf("register=%d enable=%d", reg.calls, en.calls)
|
|
}
|
|
if _, err := os.Stat(b.MarkerPath); err == nil {
|
|
t.Fatal("marker persisted after a refusal")
|
|
}
|
|
}
|
|
|
|
func TestBridge_HubInstalledADifferentKeyRefuses(t *testing.T) {
|
|
b, reg, _, en, _ := newBridge(t, goodOffsite())
|
|
reg.wrongFP = true
|
|
if err := b.Reconcile(context.Background()); err == nil || en.calls != 0 {
|
|
t.Fatalf("err=%v enable=%d", err, en.calls)
|
|
}
|
|
}
|
|
|
|
// Host-key mismatch → refuse before anything touches the hub.
|
|
func TestBridge_HostKeyMismatchRefuses(t *testing.T) {
|
|
b, reg, pr, en, _ := newBridge(t, goodOffsite())
|
|
b.Scanner = &fakeScanner{fp: "SHA256:EVIL", line: "x"}
|
|
reg.panics = true
|
|
if err := b.Reconcile(context.Background()); err == nil || !strings.Contains(err.Error(), "MISMATCH") {
|
|
t.Fatalf("err = %v", err)
|
|
}
|
|
if pr.calls != 0 || en.calls != 0 {
|
|
t.Fatal("probe/configure ran after a host-key mismatch")
|
|
}
|
|
}
|
|
|
|
func TestBridge_IdempotentMarker(t *testing.T) {
|
|
b, reg, _, _, _ := newBridge(t, goodOffsite())
|
|
if err := b.Reconcile(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reg.panics = true
|
|
if err := b.Reconcile(context.Background()); err != nil {
|
|
t.Fatalf("second reconcile: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBridge_RegisterFailIsFailSafe(t *testing.T) {
|
|
b, reg, _, en, _ := newBridge(t, goodOffsite())
|
|
reg.err = errors.New("hub down")
|
|
if err := b.Reconcile(context.Background()); err == nil {
|
|
t.Fatal("want error")
|
|
}
|
|
if en.calls != 0 {
|
|
t.Fatal("configured after a failed register")
|
|
}
|
|
if _, err := os.Stat(b.MarkerPath); err == nil {
|
|
t.Fatal("marker persisted after failure")
|
|
}
|
|
}
|
|
|
|
// A failed confirm leaves an extra PINNED line (cannot delete) — not a failure of the apply.
|
|
func TestBridge_ConfirmFailureIsNotFatal(t *testing.T) {
|
|
b, reg, _, en, logbuf := newBridge(t, goodOffsite())
|
|
reg.confirmEr = errors.New("hub blip")
|
|
if err := b.Reconcile(context.Background()); err != nil {
|
|
t.Fatalf("reconcile: %v", err)
|
|
}
|
|
if en.calls != 1 || !strings.Contains(logbuf.String(), "confirm key") {
|
|
t.Fatalf("enable=%d log=%s", en.calls, logbuf.String())
|
|
}
|
|
}
|
|
|
|
func TestBridge_DisabledNoOp(t *testing.T) {
|
|
o := goodOffsite()
|
|
o.Enabled = false
|
|
b, reg, _, en, _ := newBridge(t, o)
|
|
reg.panics = true
|
|
if err := b.Reconcile(context.Background()); err != nil || en.calls != 0 {
|
|
t.Fatalf("err=%v enable=%d", err, en.calls)
|
|
}
|
|
}
|
|
|
|
// legacyDescriptorHash is the pre-v0.289.0 marker key, byte for byte as v0.288.0 computed it.
|
|
func legacyDescriptorHash(o config.OffsiteConfig) string {
|
|
sum := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%s|%d|%s|%s|%d", o.Type, o.Host, o.User, o.Port, o.RepoPath, o.HostFingerprint, o.QuotaGB)))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|