dr: recovered WG-key install + host_loss directive→restore-PLAN (S5 safe halves)
wgtunnel.InstallRecoveredKey: write an escrow-recovered WG private key (create-
only, refuse-overwrite) so the tunnel re-establishes with the same identity/pubkey
(same /32), no keygen. Wired into identity-consume -install-wg-key (opt-in;
pre-S3 blob → logged fresh-keygen fallback). Value never logged.
internal/dr (new): consume the host_loss restore_directive (was logged-ignored)
into an inspectable RestorePlan via the AddConsumer raw seam — per guest
{vmid,archive,target,sizing} + per drive {durable_id→mount} + offsite PBS coord.
DERIVE-AND-SURFACE only; the Consumer has no restore/destroy dependency (execute-
nothing is structural). guest_loss/absent → no plan.
Tests + red-proofs (WG create-only overwrite; plan mode-gate). No secrets on
argv/stdout/logs. The destructive in-place restore is a separate operator-present
STOP-gated drill.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -1,3 +1,24 @@
|
|||||||
|
## v0.69.0 — S5: host-loss DR — recovered WG-key install + directive→restore-PLAN (safe halves) (2026-07-04)
|
||||||
|
|
||||||
|
The two safe, non-destructive mechanical links for host-loss DR (the destructive in-place restore is
|
||||||
|
a separate operator-present, STOP-gated drill).
|
||||||
|
|
||||||
|
- **`internal/wgtunnel.InstallRecoveredKey`** — writes an escrow-recovered WG private key (32-byte
|
||||||
|
base64, re-encoded canonical) to the key file so the tunnel re-establishes with the SAME
|
||||||
|
identity/pubkey (→ the same hub `/32`), no fresh keygen. **CREATE-ONLY** — refuses if a key file
|
||||||
|
exists (a present key may be a live identity); value never logged. Wired into `--selftest=identity-
|
||||||
|
consume -install-wg-key` (opt-in; after `UnwrapIdentityBundle`, installs `bundle.WGPrivateKey`;
|
||||||
|
pre-S3 blob with no WG key → logged fallback to fresh keygen + re-register, which keeps the /32).
|
||||||
|
- **`internal/dr`** (new) — consumes the host_loss `restore_directive` (was logged-and-ignored) into
|
||||||
|
an inspectable **RestorePlan** via the `desired.Syncer.AddConsumer` raw seam: per guest →
|
||||||
|
{vmid, archive, target storage, sizing}; per drive → {durable_id → expected mount}; + the offsite
|
||||||
|
PBS coord. **Derive-and-surface only** — the `Consumer` has NO restore/destroy dependency, so
|
||||||
|
"execute nothing" is structural. `guest_loss`/absent → no plan. Recipe fetched on-demand (rare
|
||||||
|
directive) via a fresh `Collect`.
|
||||||
|
- Tests + red-proofs: WG install (same pubkey/no-keygen; present-key refuse — red-proofed against
|
||||||
|
allow-overwrite); plan (host_loss builds; guest_loss/absent/nil-recipe → none — red-proofed against
|
||||||
|
a relaxed mode gate). No secrets on argv/stdout/logs (field names only).
|
||||||
|
|
||||||
## v0.68.0 — S4.1: tier-aware restore-task deadline (unattended offsite restore-test) (2026-07-04)
|
## v0.68.0 — S4.1: tier-aware restore-task deadline (unattended offsite restore-test) (2026-07-04)
|
||||||
|
|
||||||
The offsite restore-test couldn't complete on the scheduler path because a WAN restore of a large
|
The offsite restore-test couldn't complete on the scheduler path because a WAN restore of a large
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"gitea.dooplex.hu/admin/felhom-agent/internal/capability"
|
"gitea.dooplex.hu/admin/felhom-agent/internal/capability"
|
||||||
"gitea.dooplex.hu/admin/felhom-agent/internal/config"
|
"gitea.dooplex.hu/admin/felhom-agent/internal/config"
|
||||||
"gitea.dooplex.hu/admin/felhom-agent/internal/desired"
|
"gitea.dooplex.hu/admin/felhom-agent/internal/desired"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/dr"
|
||||||
"gitea.dooplex.hu/admin/felhom-agent/internal/escrow"
|
"gitea.dooplex.hu/admin/felhom-agent/internal/escrow"
|
||||||
"gitea.dooplex.hu/admin/felhom-agent/internal/guesthook"
|
"gitea.dooplex.hu/admin/felhom-agent/internal/guesthook"
|
||||||
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||||
@@ -106,6 +107,7 @@ func main() {
|
|||||||
blobPath string
|
blobPath string
|
||||||
expectedFP string
|
expectedFP string
|
||||||
keyDest string
|
keyDest string
|
||||||
|
installWGKey bool
|
||||||
idBundlePath string
|
idBundlePath string
|
||||||
directivePath string
|
directivePath string
|
||||||
swapImage string
|
swapImage string
|
||||||
@@ -133,6 +135,7 @@ func main() {
|
|||||||
flag.StringVar(&blobPath, "blob", "", "for --selftest=escrow-consume: path to the R-wrapped escrow blob file")
|
flag.StringVar(&blobPath, "blob", "", "for --selftest=escrow-consume: path to the R-wrapped escrow blob file")
|
||||||
flag.StringVar(&expectedFP, "fingerprint", "", "for --selftest=escrow-consume: the EXPECTED key fingerprint (the gate target)")
|
flag.StringVar(&expectedFP, "fingerprint", "", "for --selftest=escrow-consume: the EXPECTED key fingerprint (the gate target)")
|
||||||
flag.StringVar(&keyDest, "keydest", "", "for --selftest=escrow-consume: where to install the recovered key (0600)")
|
flag.StringVar(&keyDest, "keydest", "", "for --selftest=escrow-consume: where to install the recovered key (0600)")
|
||||||
|
flag.BoolVar(&installWGKey, "install-wg-key", false, "for --selftest=identity-consume: ALSO install the recovered wg_private_key into wgtunnel's key file (S5 DR; create-only, refuses to overwrite)")
|
||||||
flag.StringVar(&idBundlePath, "identity-bundle", "", "for --selftest=escrow-create: a 0600 JSON file {tunnel_token,pbs_token} to ALSO escrow under R (10D)")
|
flag.StringVar(&idBundlePath, "identity-bundle", "", "for --selftest=escrow-create: a 0600 JSON file {tunnel_token,pbs_token} to ALSO escrow under R (10D)")
|
||||||
flag.StringVar(&directivePath, "directive", "", "for --selftest=escrow-create: a JSON file with the non-secret DR directive (pbs repo/ns, expected fingerprint, tunnel id)")
|
flag.StringVar(&directivePath, "directive", "", "for --selftest=escrow-create: a JSON file with the non-secret DR directive (pbs repo/ns, expected fingerprint, tunnel id)")
|
||||||
flag.StringVar(&custID, "customer-id", "", "for --selftest=provision: the customer id — the hub config-pull target, baked into the guest's bootstrap")
|
flag.StringVar(&custID, "customer-id", "", "for --selftest=provision: the customer id — the hub config-pull target, baked into the guest's bootstrap")
|
||||||
@@ -198,7 +201,7 @@ func main() {
|
|||||||
case "escrow-consume":
|
case "escrow-consume":
|
||||||
os.Exit(runSelftestEscrowConsume(context.Background(), logger, blobPath, expectedFP, keyDest))
|
os.Exit(runSelftestEscrowConsume(context.Background(), logger, blobPath, expectedFP, keyDest))
|
||||||
case "identity-consume":
|
case "identity-consume":
|
||||||
os.Exit(runSelftestIdentityConsume(context.Background(), logger, blobPath, keyDest))
|
os.Exit(runSelftestIdentityConsume(context.Background(), cfg, logger, blobPath, keyDest, installWGKey))
|
||||||
case "controller-swap":
|
case "controller-swap":
|
||||||
os.Exit(runSelftestControllerSwap(context.Background(), cfg, logger, vmid, swapImage))
|
os.Exit(runSelftestControllerSwap(context.Background(), cfg, logger, vmid, swapImage))
|
||||||
}
|
}
|
||||||
@@ -428,6 +431,15 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
|
|||||||
// The "Down" channel sync hook: on each heartbeat, fetch desired-state when the generation
|
// The "Down" channel sync hook: on each heartbeat, fetch desired-state when the generation
|
||||||
// advances. The loop calls it via the EnvelopeObserver seam (hub does not import desired).
|
// advances. The loop calls it via the EnvelopeObserver seam (hub does not import desired).
|
||||||
desiredSyncer := desired.NewSyncer(client, desiredProvider, logger)
|
desiredSyncer := desired.NewSyncer(client, desiredProvider, logger)
|
||||||
|
// S5: consume a host_loss restore_directive into an inspectable restore PLAN (derive + surface,
|
||||||
|
// execute nothing). The recipe is fetched on-demand (rare directive) via a fresh Collect.
|
||||||
|
desiredSyncer.AddConsumer(dr.NewConsumer(func(ctx context.Context) *hub.DRRecipeHostHalf {
|
||||||
|
r, err := collector.Collect(ctx)
|
||||||
|
if err != nil || r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.DRRecipe
|
||||||
|
}, cfg.Backup.RestoreStorage, logger))
|
||||||
// The signed-jobs runner (slice 10B) is wired as a SECOND envelope observer below (after the
|
// The signed-jobs runner (slice 10B) is wired as a SECOND envelope observer below (after the
|
||||||
// gate is built) — when the heartbeat flags pending signed ops, it fetches + verifies + executes.
|
// gate is built) — when the heartbeat flags pending signed ops, it fetches + verifies + executes.
|
||||||
|
|
||||||
@@ -1702,7 +1714,7 @@ func runSelftestEscrowConsume(ctx context.Context, logger *slog.Logger, blobPath
|
|||||||
// and writes the recovered {tunnel_token, pbs_token} JSON to -keydest (0600). R is taken BY HAND from
|
// and writes the recovered {tunnel_token, pbs_token} JSON to -keydest (0600). R is taken BY HAND from
|
||||||
// FELHOM_RECOVERY_CODE (off the command line); the recovered tokens are never logged. The drill then
|
// FELHOM_RECOVERY_CODE (off the command line); the recovered tokens are never logged. The drill then
|
||||||
// uses the tunnel token to re-establish the tunnel + the pbs token for steady-state.
|
// uses the tunnel token to re-establish the tunnel + the pbs token for steady-state.
|
||||||
func runSelftestIdentityConsume(ctx context.Context, logger *slog.Logger, blobPath, keyDest string) int {
|
func runSelftestIdentityConsume(ctx context.Context, cfg config.Config, logger *slog.Logger, blobPath, keyDest string, installWGKey bool) int {
|
||||||
if blobPath == "" || keyDest == "" {
|
if blobPath == "" || keyDest == "" {
|
||||||
fmt.Fprintln(os.Stderr, "selftest=identity-consume requires -blob and -keydest (R via env FELHOM_RECOVERY_CODE)")
|
fmt.Fprintln(os.Stderr, "selftest=identity-consume requires -blob and -keydest (R via env FELHOM_RECOVERY_CODE)")
|
||||||
return 2
|
return 2
|
||||||
@@ -1732,6 +1744,28 @@ func runSelftestIdentityConsume(ctx context.Context, logger *slog.Logger, blobPa
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
fmt.Printf(" [OK] identity recovered (tunnel_token + pbs_token) → %s (0600) — never printed\n", keyDest)
|
fmt.Printf(" [OK] identity recovered (tunnel_token + pbs_token) → %s (0600) — never printed\n", keyDest)
|
||||||
|
|
||||||
|
// S5 DR: install the recovered WG private key so the tunnel re-establishes with the SAME
|
||||||
|
// identity/pubkey (→ the same hub /32), no fresh keygen. Create-only (refuses to overwrite a
|
||||||
|
// present key). The VALUE is never printed — field NAME only.
|
||||||
|
if installWGKey {
|
||||||
|
stateDir := cfg.WGTunnel.WithDefaults().StateDir
|
||||||
|
switch {
|
||||||
|
case bundle.WGPrivateKey == "":
|
||||||
|
fmt.Printf(" [WARN] -install-wg-key set but the recovered bundle has NO wg_private_key (pre-S3 blob) — "+
|
||||||
|
"DR falls back to fresh keygen + re-register (keeps the /32 via hub re-key-in-place). key path: %s\n",
|
||||||
|
wgtunnel.KeyFilePath(stateDir))
|
||||||
|
default:
|
||||||
|
if err := wgtunnel.InstallRecoveredKey(stateDir, bundle.WGPrivateKey); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, " [FAIL] install recovered wg key:", err) // never contains the key value
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
logger.Info("escrow: installed recovered identity field", "field", "wg_private_key",
|
||||||
|
"key_path", wgtunnel.KeyFilePath(stateDir)) // NAME only, never the value
|
||||||
|
fmt.Printf(" [OK] recovered wg_private_key installed at %s (0600, create-only) — tunnel will re-establish with the same identity\n",
|
||||||
|
wgtunnel.KeyFilePath(stateDir))
|
||||||
|
}
|
||||||
|
}
|
||||||
fmt.Println("=== selftest=identity-consume OK ===")
|
fmt.Println("=== selftest=identity-consume OK ===")
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,9 @@ func mapWire(w hub.WireDesiredState, logger *slog.Logger) reconcile.DesiredState
|
|||||||
guests[g.VMID] = dg
|
guests[g.VMID] = dg
|
||||||
}
|
}
|
||||||
if w.RestoreDirective != nil {
|
if w.RestoreDirective != nil {
|
||||||
logger.Info("desired: restore_directive present (consumed in slice 10D — ignored in 10A)",
|
// The reconcile mapping does NOT act on the directive; the DR consumer (raw-consumer seam,
|
||||||
|
// S5 internal/dr) surfaces it as an inspectable restore PLAN — no restore is executed here.
|
||||||
|
logger.Info("desired: restore_directive present (surfaced as a restore PLAN by the DR consumer; not acted on in the reconcile mapping)",
|
||||||
"mode", w.RestoreDirective.Mode)
|
"mode", w.RestoreDirective.Mode)
|
||||||
}
|
}
|
||||||
return reconcile.DesiredState{Guests: guests}
|
return reconcile.DesiredState{Guests: guests}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// Package dr consumes the host-loss restore_directive (slice 10D / S5) into an inspectable restore
|
||||||
|
// PLAN. It is DERIVE-AND-SURFACE only: the plan is logged (and exposed for the report), never
|
||||||
|
// executed — the destructive restore is a separate, operator-present, STOP-gated step. The Consumer
|
||||||
|
// has NO restore/destroy API by construction, so "execute nothing" is a structural guarantee.
|
||||||
|
package dr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RestorePlan is the derived-but-not-executed host-loss plan: per guest → restore coords + sizing;
|
||||||
|
// per drive → durable_id → expected mount. No secrets (coordinates/identifiers/sizes only).
|
||||||
|
type RestorePlan struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Guests []PlannedGuest `json:"guests"`
|
||||||
|
Drives []PlannedDrive `json:"drives"`
|
||||||
|
PBS *hub.DRPBSCoord `json:"pbs,omitempty"` // WHERE the offsite backups live (repo/ns/latest snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlannedGuest is one guest to restore in place, from the offsite datastore, at its original sizing.
|
||||||
|
type PlannedGuest struct {
|
||||||
|
VMID int `json:"vmid"`
|
||||||
|
Archive string `json:"archive,omitempty"` // explicit archive from the directive; "" → resolve latest at restore time
|
||||||
|
TargetStorage string `json:"target_storage"` // where the restored volumes land (e.g. local-lvm)
|
||||||
|
Cores int `json:"cores"`
|
||||||
|
MemoryBytes int64 `json:"memory_bytes"`
|
||||||
|
DiskBytes int64 `json:"disk_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlannedDrive is one data drive to re-attach BY DURABLE_ID (the wrong-disk guard: a match attaches,
|
||||||
|
// a non-match is refused — the matcher, exercised in the Part-4 spike, never resolves to a near disk).
|
||||||
|
type PlannedDrive struct {
|
||||||
|
DurableID string `json:"durable_id"`
|
||||||
|
ExpectedMount string `json:"expected_mount"`
|
||||||
|
Intent string `json:"intent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildRestorePlan derives the plan from a host_loss directive + the live DR recipe. Returns
|
||||||
|
// (nil,false) for a guest_loss/absent directive or a nil recipe (nothing to plan). PURE: reads
|
||||||
|
// nothing, executes nothing — the whole point of this slice's safe half.
|
||||||
|
func BuildRestorePlan(directive *hub.WireRestoreDirective, recipe *hub.DRRecipeHostHalf, restoreStorage string) (*RestorePlan, bool) {
|
||||||
|
if directive == nil || directive.Mode != "host_loss" || recipe == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
plan := &RestorePlan{Mode: directive.Mode, PBS: recipe.PBS}
|
||||||
|
for _, g := range recipe.Guests {
|
||||||
|
pg := PlannedGuest{
|
||||||
|
VMID: g.VMID,
|
||||||
|
TargetStorage: restoreStorage,
|
||||||
|
Cores: g.Cores,
|
||||||
|
MemoryBytes: g.MemoryBytes,
|
||||||
|
DiskBytes: g.DiskBytes,
|
||||||
|
}
|
||||||
|
// The directive may name an explicit archive for a specific guest (else the restore step
|
||||||
|
// resolves the latest snapshot from the PBS coord at execution time).
|
||||||
|
if directive.Archive != "" && (directive.VMID == 0 || directive.VMID == g.VMID) {
|
||||||
|
pg.Archive = directive.Archive
|
||||||
|
}
|
||||||
|
plan.Guests = append(plan.Guests, pg)
|
||||||
|
}
|
||||||
|
for _, d := range recipe.Drives {
|
||||||
|
plan.Drives = append(plan.Drives, PlannedDrive{
|
||||||
|
DurableID: d.DurableID,
|
||||||
|
ExpectedMount: d.MountPath,
|
||||||
|
Intent: d.Intent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return plan, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecipeFunc yields the current DR recipe (the agent-derived scaffolding). It is called ONLY when a
|
||||||
|
// host_loss directive is present (a rare DR event), so an on-demand Collect is acceptable.
|
||||||
|
type RecipeFunc func(ctx context.Context) *hub.DRRecipeHostHalf
|
||||||
|
|
||||||
|
// Consumer implements desired.RawConsumer: on a host_loss restore_directive it builds + SURFACES the
|
||||||
|
// plan (structured log + LastPlan for the report/inspection) and executes NOTHING. A guest_loss or
|
||||||
|
// absent directive clears the plan. It holds no restore/destroy dependency — surfacing is all it can do.
|
||||||
|
type Consumer struct {
|
||||||
|
recipe RecipeFunc
|
||||||
|
restoreStorage string
|
||||||
|
logger *slog.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
lastPlan *RestorePlan
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConsumer builds the DR plan consumer. recipe may be nil (then no plan can be built — logged).
|
||||||
|
func NewConsumer(recipe RecipeFunc, restoreStorage string, logger *slog.Logger) *Consumer {
|
||||||
|
if logger == nil {
|
||||||
|
logger = slog.Default()
|
||||||
|
}
|
||||||
|
return &Consumer{recipe: recipe, restoreStorage: restoreStorage, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnDesiredState implements desired.RawConsumer. Non-host_loss → clear + no-op.
|
||||||
|
func (c *Consumer) OnDesiredState(ctx context.Context, resp *hub.DesiredStateResponse) {
|
||||||
|
if resp == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir := resp.DesiredState.RestoreDirective
|
||||||
|
if dir == nil || dir.Mode != "host_loss" {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.lastPlan = nil
|
||||||
|
c.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var recipe *hub.DRRecipeHostHalf
|
||||||
|
if c.recipe != nil {
|
||||||
|
recipe = c.recipe(ctx)
|
||||||
|
}
|
||||||
|
plan, ok := BuildRestorePlan(dir, recipe, c.restoreStorage)
|
||||||
|
if !ok {
|
||||||
|
c.logger.Warn("dr: host_loss restore_directive present but no DR recipe available yet — cannot build a plan",
|
||||||
|
"directive_vmid", dir.VMID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.lastPlan = plan
|
||||||
|
c.mu.Unlock()
|
||||||
|
// SURFACE only — the destructive restore is a separate, operator-present step.
|
||||||
|
c.logger.Warn("dr: host_loss RESTORE PLAN derived (NOT executed — supervised in-place restore is a separate, gated step)",
|
||||||
|
"mode", plan.Mode, "guests", len(plan.Guests), "drives", len(plan.Drives), "plan", plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastPlan returns the most recently derived plan (nil if none / cleared). For the report + tests.
|
||||||
|
func (c *Consumer) LastPlan() *RestorePlan {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.lastPlan
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package dr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sampleRecipe() *hub.DRRecipeHostHalf {
|
||||||
|
return &hub.DRRecipeHostHalf{
|
||||||
|
RecipeVersion: 1,
|
||||||
|
Guests: []hub.DRGuest{{VMID: 9201, Cores: 2, MemoryBytes: 12 << 30, DiskBytes: 32 << 30}},
|
||||||
|
PBS: &hub.DRPBSCoord{RepoID: "felhom-offsite", Namespace: "demo-felhom-01", LatestSnapshotID: "9201"},
|
||||||
|
Drives: []hub.DRDrive{{DurableID: "uuid:abc", MountPath: "/mnt/felhom-drives/photos", Intent: "enrolled", TotalBytes: 500 << 30}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildRestorePlan_HostLoss: a host_loss directive + recipe yields per-guest {vmid, archive,
|
||||||
|
// target, sizing} + per-drive {durable_id → mount} + the offsite PBS coord.
|
||||||
|
func TestBuildRestorePlan_HostLoss(t *testing.T) {
|
||||||
|
dir := &hub.WireRestoreDirective{Mode: "host_loss", VMID: 9201, Archive: "felhom-offsite:backup/ct/9201/2026-07-04T14:55:44Z"}
|
||||||
|
plan, ok := BuildRestorePlan(dir, sampleRecipe(), "local-lvm")
|
||||||
|
if !ok || plan == nil {
|
||||||
|
t.Fatal("host_loss must yield a plan")
|
||||||
|
}
|
||||||
|
if plan.Mode != "host_loss" || len(plan.Guests) != 1 || len(plan.Drives) != 1 {
|
||||||
|
t.Fatalf("plan shape = %+v", plan)
|
||||||
|
}
|
||||||
|
g := plan.Guests[0]
|
||||||
|
if g.VMID != 9201 || g.TargetStorage != "local-lvm" || g.Cores != 2 || g.DiskBytes != 32<<30 {
|
||||||
|
t.Errorf("planned guest = %+v", g)
|
||||||
|
}
|
||||||
|
if g.Archive != dir.Archive {
|
||||||
|
t.Errorf("planned guest archive = %q, want the directive's %q", g.Archive, dir.Archive)
|
||||||
|
}
|
||||||
|
d := plan.Drives[0]
|
||||||
|
if d.DurableID != "uuid:abc" || d.ExpectedMount != "/mnt/felhom-drives/photos" {
|
||||||
|
t.Errorf("planned drive (durable_id→mount) = %+v", d)
|
||||||
|
}
|
||||||
|
if plan.PBS == nil || plan.PBS.RepoID != "felhom-offsite" {
|
||||||
|
t.Errorf("plan must carry the offsite PBS coord, got %+v", plan.PBS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildRestorePlan_NoPlanCases is the red-proof anchor: guest_loss / absent / nil-recipe yield
|
||||||
|
// NO plan (execute-nothing on the wrong mode). Relaxing the mode gate → the guest_loss case fails.
|
||||||
|
func TestBuildRestorePlan_NoPlanCases(t *testing.T) {
|
||||||
|
if _, ok := BuildRestorePlan(&hub.WireRestoreDirective{Mode: "guest_loss", VMID: 9201}, sampleRecipe(), "local-lvm"); ok {
|
||||||
|
t.Error("guest_loss must NOT yield a host-loss plan")
|
||||||
|
}
|
||||||
|
if _, ok := BuildRestorePlan(nil, sampleRecipe(), "local-lvm"); ok {
|
||||||
|
t.Error("absent directive must NOT yield a plan")
|
||||||
|
}
|
||||||
|
if _, ok := BuildRestorePlan(&hub.WireRestoreDirective{Mode: "host_loss"}, nil, "local-lvm"); ok {
|
||||||
|
t.Error("nil recipe must NOT yield a plan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConsumer_SurfacesPlanNeverExecutes: the consumer surfaces the plan on host_loss, consults the
|
||||||
|
// recipe only then, and clears it otherwise. It has NO restore/destroy dependency (execute-nothing
|
||||||
|
// is structural — the type literally cannot call a restore).
|
||||||
|
func TestConsumer_SurfacesPlanNeverExecutes(t *testing.T) {
|
||||||
|
recipeCalls := 0
|
||||||
|
c := NewConsumer(func(context.Context) *hub.DRRecipeHostHalf { recipeCalls++; return sampleRecipe() }, "local-lvm", nil)
|
||||||
|
ds := func(d *hub.WireRestoreDirective) *hub.DesiredStateResponse {
|
||||||
|
return &hub.DesiredStateResponse{DesiredState: hub.WireDesiredState{RestoreDirective: d}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// non-host_loss → no plan, recipe NOT consulted.
|
||||||
|
c.OnDesiredState(context.Background(), ds(&hub.WireRestoreDirective{Mode: "guest_loss"}))
|
||||||
|
if c.LastPlan() != nil {
|
||||||
|
t.Error("guest_loss set a plan")
|
||||||
|
}
|
||||||
|
if recipeCalls != 0 {
|
||||||
|
t.Errorf("recipe consulted on a non-host_loss directive (%d calls)", recipeCalls)
|
||||||
|
}
|
||||||
|
// host_loss → plan surfaced, recipe consulted once.
|
||||||
|
c.OnDesiredState(context.Background(), ds(&hub.WireRestoreDirective{Mode: "host_loss", VMID: 9201}))
|
||||||
|
p := c.LastPlan()
|
||||||
|
if p == nil || len(p.Guests) != 1 || p.Guests[0].VMID != 9201 {
|
||||||
|
t.Fatalf("host_loss plan = %+v", p)
|
||||||
|
}
|
||||||
|
if recipeCalls != 1 {
|
||||||
|
t.Errorf("recipe calls = %d, want 1", recipeCalls)
|
||||||
|
}
|
||||||
|
// absent directive clears the plan.
|
||||||
|
c.OnDesiredState(context.Background(), ds(nil))
|
||||||
|
if c.LastPlan() != nil {
|
||||||
|
t.Error("absent directive did not clear the plan")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,35 @@ func KeyFilePath(stateDir string) string {
|
|||||||
return filepath.Join(stateDir, "wg", keyFileName)
|
return filepath.Join(stateDir, "wg", keyFileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InstallRecoveredKey writes an escrow-recovered WG private key (base64 of 32 bytes) to the key
|
||||||
|
// file — the S5 host-loss DR step so the tunnel re-establishes with the SAME identity/pubkey (→ the
|
||||||
|
// same hub `/32`), no fresh keygen. **CREATE-ONLY:** it REFUSES if a key file already exists (a
|
||||||
|
// present key may be a live identity — the never-overwrite rule EnsureKey enforces too). The value
|
||||||
|
// is validated (32-byte base64) and stored canonical (re-encoded), matching EnsureKey's on-disk
|
||||||
|
// form; it is NEVER logged (caller logs the field NAME only). No-op guard: an empty privB64 is a
|
||||||
|
// caller error (the bundle lacked a WG key → the fresh-keygen fallback path, not this one).
|
||||||
|
func InstallRecoveredKey(stateDir, privB64 string) error {
|
||||||
|
priv, err := decodeKey([]byte(privB64))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wgtunnel: recovered wg key is not 32-byte base64: %w", err)
|
||||||
|
}
|
||||||
|
dir := filepath.Join(stateDir, "wg")
|
||||||
|
path := filepath.Join(dir, keyFileName)
|
||||||
|
if _, serr := os.Stat(path); serr == nil {
|
||||||
|
return fmt.Errorf("wgtunnel: key file %s already exists — refusing to overwrite (a present key may be a live identity)", path)
|
||||||
|
} else if !os.IsNotExist(serr) {
|
||||||
|
return fmt.Errorf("wgtunnel: stat key file: %w", serr)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return fmt.Errorf("wgtunnel: creating %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
enc := base64.StdEncoding.EncodeToString(priv) + "\n"
|
||||||
|
if err := os.WriteFile(path, []byte(enc), 0o600); err != nil {
|
||||||
|
return fmt.Errorf("wgtunnel: writing recovered key file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// decodeKey parses a key-file payload: base64 of exactly 32 bytes (trailing whitespace ok).
|
// decodeKey parses a key-file payload: base64 of exactly 32 bytes (trailing whitespace ok).
|
||||||
func decodeKey(raw []byte) ([]byte, error) {
|
func decodeKey(raw []byte) ([]byte, error) {
|
||||||
s := string(raw)
|
s := string(raw)
|
||||||
|
|||||||
@@ -8,9 +8,54 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestInstallRecoveredKey_SameIdentityCreateOnly (S5): a recovered WG key installs into a fresh
|
||||||
|
// state dir, EnsureKey LOADS it (no keygen) yielding the SAME pubkey (→ same /32); a second install
|
||||||
|
// REFUSES (create-only, never overwrite); an invalid key errors and writes nothing.
|
||||||
|
func TestInstallRecoveredKey_SameIdentityCreateOnly(t *testing.T) {
|
||||||
|
src := t.TempDir()
|
||||||
|
srcPub, created, err := EnsureKey(src)
|
||||||
|
if err != nil || !created {
|
||||||
|
t.Fatalf("seed EnsureKey: created=%v err=%v", created, err)
|
||||||
|
}
|
||||||
|
recovered, err := readPrivateKeyB64(src) // the base64 the escrow bundle carries
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := t.TempDir()
|
||||||
|
if err := InstallRecoveredKey(dst, recovered); err != nil {
|
||||||
|
t.Fatalf("InstallRecoveredKey: %v", err)
|
||||||
|
}
|
||||||
|
pub, created, err := EnsureKey(dst) // must LOAD, not generate
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if created {
|
||||||
|
t.Error("EnsureKey generated a fresh key instead of loading the installed one (no-keygen negative)")
|
||||||
|
}
|
||||||
|
if pub != srcPub {
|
||||||
|
t.Errorf("recovered pubkey = %q, want same as source %q (same /32)", pub, srcPub)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create-only: a second install REFUSES (a present key may be a live identity).
|
||||||
|
if err := InstallRecoveredKey(dst, recovered); err == nil || !strings.Contains(err.Error(), "refusing to overwrite") {
|
||||||
|
t.Errorf("second install must refuse (create-only), got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid recovered key → error, nothing written.
|
||||||
|
bad := t.TempDir()
|
||||||
|
if err := InstallRecoveredKey(bad, "not-base64!!"); err == nil {
|
||||||
|
t.Error("invalid recovered key accepted")
|
||||||
|
}
|
||||||
|
if _, serr := os.Stat(KeyFilePath(bad)); !os.IsNotExist(serr) {
|
||||||
|
t.Error("a key file was written despite an invalid recovered key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fixed test vector. PROVENANCE: public key generated ONCE with the real `wg pubkey` (
|
// Fixed test vector. PROVENANCE: public key generated ONCE with the real `wg pubkey` (
|
||||||
// wireguard-tools on felhom-hetzner, 2026-07-04) from the spec's published test private key —
|
// wireguard-tools on felhom-hetzner, 2026-07-04) from the spec's published test private key —
|
||||||
// this private key is a PUBLISHED test constant, not a secret.
|
// this private key is a PUBLISHED test constant, not a secret.
|
||||||
|
|||||||
Reference in New Issue
Block a user