wgtunnel: S3 Part 3 — FELHOM_WG sudoers + capabilities + config (DEFAULT OFF) + main wiring + escrow join
Sudoers: fixed-path conf install, enable/restart/disable, latest-handshakes-only wg read (dump FORBIDDEN — the S1 incident). 6 capability-manifest entries (Critical=false until S4 makes the tunnel load-bearing). WGTunnelConfig with enabled=false DEFAULT (the safety gate: a v0.64.0 rollout without explicit config is a no-op). Daemon wiring mirrors lanresolver + AddConsumer + SetWireguardReporter; --selftest=wgtunnel single-shot. IdentityBundle +wg_private_key (omitempty; pre-S3 blobs cannot be retrofitted — documented) with escrow-create auto-inject (field name only in logs). Red-proof (e) run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -41,6 +41,7 @@ import (
|
|||||||
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
||||||
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
|
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
|
||||||
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-agent/internal/wgtunnel"
|
||||||
)
|
)
|
||||||
|
|
||||||
// version is the agent version. Overridable at build time with
|
// version is the agent version. Overridable at build time with
|
||||||
@@ -179,6 +180,8 @@ func main() {
|
|||||||
os.Exit(runSelftestPBSVerify(context.Background(), cfg, logger))
|
os.Exit(runSelftestPBSVerify(context.Background(), cfg, logger))
|
||||||
case "lanresolver":
|
case "lanresolver":
|
||||||
os.Exit(runSelftestLANResolver(context.Background(), cfg, logger, vmid))
|
os.Exit(runSelftestLANResolver(context.Background(), cfg, logger, vmid))
|
||||||
|
case "wgtunnel":
|
||||||
|
os.Exit(runSelftestWGTunnel(context.Background(), cfg, logger))
|
||||||
case "bring-up":
|
case "bring-up":
|
||||||
os.Exit(runSelftestBringUp(context.Background(), cfg, logger, mode, archive, vmid, hostname, keep,
|
os.Exit(runSelftestBringUp(context.Background(), cfg, logger, mode, archive, vmid, hostname, keep,
|
||||||
bringUpSizing{RootfsGrowGB: rootfsGrow, DataVolGrowGB: dataVolGrow, DataVolMount: dataVolMount,
|
bringUpSizing{RootfsGrowGB: rootfsGrow, DataVolGrowGB: dataVolGrow, DataVolMount: dataVolMount,
|
||||||
@@ -201,6 +204,54 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runSelftestWGTunnel is the supervised single-shot bring-up (S3): keygen/register (marker-
|
||||||
|
// gated), one desired-state fetch, one apply, then the status stanza. Runs regardless of
|
||||||
|
// wg_tunnel.enabled (the operator invoked it deliberately) but uses the config's state
|
||||||
|
// dir/runner exactly as the daemon would.
|
||||||
|
func runSelftestWGTunnel(ctx context.Context, cfg config.Config, logger *slog.Logger) int {
|
||||||
|
fmt.Printf("=== felhom-agent %s selftest=wgtunnel ===\n", version)
|
||||||
|
wt := cfg.WGTunnel.WithDefaults()
|
||||||
|
client, err := hub.NewClient(cfg.Hub, logger)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, " [FAIL] hub client:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
mode := proxmox.RunnerMode(cfg.Privileged.Mode)
|
||||||
|
if mode == "" {
|
||||||
|
mode = proxmox.RunnerSudo
|
||||||
|
}
|
||||||
|
runner := &proxmox.ExecRunner{Mode: mode, SudoPath: cfg.Privileged.SudoPath}
|
||||||
|
mgr := wgtunnel.NewManager(runner, client, wt.StateDir, logger)
|
||||||
|
|
||||||
|
// One registration/adopt pass with no desired data, then one fetch + apply.
|
||||||
|
mgr.Apply(ctx, false, nil)
|
||||||
|
var fetched bool
|
||||||
|
var block *hub.WireWireguard
|
||||||
|
if resp, err := client.FetchDesiredState(ctx); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, " [WARN] desired-state fetch failed (apply skipped):", err)
|
||||||
|
} else {
|
||||||
|
fetched = true
|
||||||
|
block = resp.DesiredState.Wireguard
|
||||||
|
fmt.Printf(" desired-state generation=%d wireguard-block=%v\n", resp.Generation, block != nil)
|
||||||
|
}
|
||||||
|
mgr.Apply(ctx, fetched, block)
|
||||||
|
|
||||||
|
st := mgr.Status(ctx)
|
||||||
|
fmt.Printf(" status: pubkey=%s registered=%v active=%v assigned_ip=%s", st.Pubkey, st.Registered, st.Active, st.AssignedIP)
|
||||||
|
if st.LastHandshakeAgeS != nil {
|
||||||
|
fmt.Printf(" handshake_age_s=%d", *st.LastHandshakeAgeS)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
if st.Registered && (block == nil || st.Active) {
|
||||||
|
fmt.Println(" [OK] wgtunnel selftest complete")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if !st.Registered {
|
||||||
|
fmt.Println(" [WARN] not registered (hub unreachable or endpoint unset) — see log above")
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// newProxmoxClient builds the read-path proxmox client from config.
|
// newProxmoxClient builds the read-path proxmox client from config.
|
||||||
func newProxmoxClient(cfg config.Config) (*proxmox.Client, error) {
|
func newProxmoxClient(cfg config.Config) (*proxmox.Client, error) {
|
||||||
return proxmox.NewClient(proxmox.Config{
|
return proxmox.NewClient(proxmox.Config{
|
||||||
@@ -583,6 +634,28 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Offsite WG tunnel (S3, doc 06 §3.3): keygen + one-shot hub registration + wg-quick@wg-felhom
|
||||||
|
// managed from the hub-served desired-state block. DEFAULT OFF (the safety gate): a v0.64.0
|
||||||
|
// rollout to a box without explicit wg_tunnel.enabled=true is a no-op — no keygen, no
|
||||||
|
// registration, no report stanza.
|
||||||
|
wgServers := 0
|
||||||
|
var wgLoop *wgtunnel.Loop
|
||||||
|
{
|
||||||
|
wt := cfg.WGTunnel.WithDefaults()
|
||||||
|
if wt.Enabled {
|
||||||
|
wtMode := proxmox.RunnerMode(cfg.Privileged.Mode)
|
||||||
|
if wtMode == "" {
|
||||||
|
wtMode = proxmox.RunnerSudo
|
||||||
|
}
|
||||||
|
wtRunner := &proxmox.ExecRunner{Mode: wtMode, SudoPath: cfg.Privileged.SudoPath}
|
||||||
|
wgMgr := wgtunnel.NewManager(wtRunner, client, wt.StateDir, logger)
|
||||||
|
wgLoop = wgtunnel.NewLoop(wgMgr, time.Duration(wt.IntervalSeconds)*time.Second, logger)
|
||||||
|
desiredSyncer.AddConsumer(wgLoop) // raw desired-state → the wireguard block
|
||||||
|
collector.SetWireguardReporter(wgLoop) // heartbeat status stanza
|
||||||
|
logger.Info("wgtunnel: enabled", "interval_s", wt.IntervalSeconds, "state_dir", wt.StateDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Run reconcile, the hub loop, the storage watchdog, the restore-test scheduler, the PBS
|
// Run reconcile, the hub loop, the storage watchdog, the restore-test scheduler, the PBS
|
||||||
// verify loop, (optionally) the local-API server, and (optionally) the LAN resolver loop
|
// verify loop, (optionally) the local-API server, and (optionally) the LAN resolver loop
|
||||||
// concurrently; any one returning ends the daemon (ctx cancel tears down the rest).
|
// concurrently; any one returning ends the daemon (ctx cancel tears down the rest).
|
||||||
@@ -641,10 +714,14 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
|
|||||||
lanServers = 1
|
lanServers = 1
|
||||||
go func() { errc <- lanLoop.Run(ctx) }()
|
go func() { errc <- lanLoop.Run(ctx) }()
|
||||||
}
|
}
|
||||||
|
if wgLoop != nil {
|
||||||
|
wgServers = 1
|
||||||
|
go func() { errc <- wgLoop.Run(ctx) }()
|
||||||
|
}
|
||||||
|
|
||||||
err = <-errc
|
err = <-errc
|
||||||
stop() // tear down the siblings on the first exit
|
stop() // tear down the siblings on the first exit
|
||||||
for i := 0; i < 4+localServers+lanServers; i++ { // wait for the other goroutines
|
for i := 0; i < 4+localServers+lanServers+wgServers; i++ { // wait for the other goroutines
|
||||||
<-errc
|
<-errc
|
||||||
}
|
}
|
||||||
if err != nil && err != context.Canceled {
|
if err != nil && err != context.Canceled {
|
||||||
@@ -1497,6 +1574,25 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// S3: auto-inject the offsite WG private key into the escrowed identity when the key file
|
||||||
|
// exists — a NEW escrow run should always capture the live tunnel identity. Field NAME only
|
||||||
|
// in logs, never the value. No key file + no bundle flag → pre-S3 behavior, byte-compatible.
|
||||||
|
{
|
||||||
|
wgKeyPath := wgtunnel.KeyFilePath(cfg.WGTunnel.WithDefaults().StateDir)
|
||||||
|
probe := identity
|
||||||
|
if probe == nil {
|
||||||
|
probe = &escrow.IdentityBundle{}
|
||||||
|
}
|
||||||
|
attached, err := escrow.AttachWGKey(probe, wgKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "selftest=escrow-create: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if attached {
|
||||||
|
identity = probe
|
||||||
|
logger.Info("escrow: identity bundle: +wg_private_key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("=== felhom-agent %s selftest=escrow-create (storage=%s posture=%s identity=%v) ===\n", version, storage, escrow.DefaultPosture, identity != nil)
|
fmt.Printf("=== felhom-agent %s selftest=escrow-create (storage=%s posture=%s identity=%v) ===\n", version, storage, escrow.DefaultPosture, identity != nil)
|
||||||
// NB: nothing about R is logged. The logger never sees R; only stdout does, once.
|
// NB: nothing about R is logged. The logger never sees R; only stdout does, once.
|
||||||
|
|||||||
@@ -150,4 +150,18 @@ Cmnd_Alias FELHOM_NETMOUNT = \
|
|||||||
/usr/bin/systemctl stop -- *.automount, \
|
/usr/bin/systemctl stop -- *.automount, \
|
||||||
/usr/bin/rm -f /etc/systemd/system/mnt-felhom*
|
/usr/bin/rm -f /etc/systemd/system/mnt-felhom*
|
||||||
|
|
||||||
felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK, FELHOM_PROVISION, FELHOM_FORMAT, FELHOM_DNSMASQ, FELHOM_GUESTHOOK, FELHOM_INTERMEDIARY, FELHOM_CONTROLLERSWAP, FELHOM_STALELOCK, FELHOM_NETMOUNT
|
# Offsite WG tunnel (S3, doc 06 §3.3). The agent manages wg-quick@wg-felhom as an agent-managed
|
||||||
|
# host service (the dnsmasq/lanresolver shape): conf staged in the agent-owned StateDir (never
|
||||||
|
# /tmp), installed 0600 to the FIXED destination, unit enable/restart/disable. The ONLY wg read
|
||||||
|
# is `latest-handshakes` — `wg show <if> dump` is FORBIDDEN everywhere (its interface line
|
||||||
|
# carries the PRIVATE KEY; the S1 session-log incident). Both install paths are FIXED (no glob):
|
||||||
|
# the agent has exactly one tunnel conf to manage.
|
||||||
|
Cmnd_Alias FELHOM_WG = \
|
||||||
|
/usr/bin/apt-get install -y -q wireguard-tools, \
|
||||||
|
/usr/bin/install -o root -g root -m 0600 -- /var/lib/felhom-agent/wg/wg-felhom.conf /etc/wireguard/wg-felhom.conf, \
|
||||||
|
/usr/bin/systemctl enable --now wg-quick@wg-felhom, \
|
||||||
|
/usr/bin/systemctl restart wg-quick@wg-felhom, \
|
||||||
|
/usr/bin/systemctl disable --now wg-quick@wg-felhom, \
|
||||||
|
/usr/bin/wg show wg-felhom latest-handshakes
|
||||||
|
|
||||||
|
felhom-agent ALL=(root) NOPASSWD: FELHOM_MOUNT, FELHOM_DISK, FELHOM_PROVISION, FELHOM_FORMAT, FELHOM_DNSMASQ, FELHOM_GUESTHOOK, FELHOM_INTERMEDIARY, FELHOM_CONTROLLERSWAP, FELHOM_STALELOCK, FELHOM_NETMOUNT, FELHOM_WG
|
||||||
|
|||||||
@@ -101,4 +101,14 @@ var manifest = []Capability{
|
|||||||
// ---- Stale-lock recovery (FELHOM_STALELOCK, v0.49.0; Critical: a guest stuck behind a stale
|
// ---- Stale-lock recovery (FELHOM_STALELOCK, v0.49.0; Critical: a guest stuck behind a stale
|
||||||
// reboot-during-backup lock can't start → the customer box stays DOWN until this clears it) ----
|
// reboot-during-backup lock can't start → the customer box stays DOWN until this clears it) ----
|
||||||
{"stalelock-unlock", "reboot-during-backup stale-lock recovery", "/usr/sbin/pct", []string{"unlock", "9201"}, true},
|
{"stalelock-unlock", "reboot-during-backup stale-lock recovery", "/usr/sbin/pct", []string{"unlock", "9201"}, true},
|
||||||
|
|
||||||
|
// ---- Offsite WG tunnel (FELHOM_WG, S3/v0.64.0; Critical: false — the tunnel is not yet
|
||||||
|
// load-bearing (offsite backup rides it only from S4, which flips the backup-path entries
|
||||||
|
// to Critical). The handshake read is the ONLY wg invocation (never `dump`). ----
|
||||||
|
{"wg-tools-install", "wireguard-tools package install", "/usr/bin/apt-get", []string{"install", "-y", "-q", "wireguard-tools"}, false},
|
||||||
|
{"wg-conf-install", "wg-felhom conf install", "/usr/bin/install", []string{"-o", "root", "-g", "root", "-m", "0600", "--", "/var/lib/felhom-agent/wg/wg-felhom.conf", "/etc/wireguard/wg-felhom.conf"}, false},
|
||||||
|
{"wg-enable", "wg-quick@wg-felhom enable", "/usr/bin/systemctl", []string{"enable", "--now", "wg-quick@wg-felhom"}, false},
|
||||||
|
{"wg-restart", "wg-quick@wg-felhom restart (conf change)", "/usr/bin/systemctl", []string{"restart", "wg-quick@wg-felhom"}, false},
|
||||||
|
{"wg-disable", "wg-quick@wg-felhom disable (revocation)", "/usr/bin/systemctl", []string{"disable", "--now", "wg-quick@wg-felhom"}, false},
|
||||||
|
{"wg-handshake-read", "tunnel handshake-age read", "/usr/bin/wg", []string{"show", "wg-felhom", "latest-handshakes"}, false},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,18 +22,40 @@ import (
|
|||||||
|
|
||||||
// Config is the agent configuration.
|
// Config is the agent configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Proxmox ProxmoxConfig `json:"proxmox"`
|
Proxmox ProxmoxConfig `json:"proxmox"`
|
||||||
Privileged PrivilegedConfig `json:"privileged"`
|
Privileged PrivilegedConfig `json:"privileged"`
|
||||||
Authz AuthzConfig `json:"authz"`
|
Authz AuthzConfig `json:"authz"`
|
||||||
Hub HubConfig `json:"hub"`
|
Hub HubConfig `json:"hub"`
|
||||||
Storage StorageConfig `json:"storage"`
|
Storage StorageConfig `json:"storage"`
|
||||||
Backup BackupConfig `json:"backup"`
|
Backup BackupConfig `json:"backup"`
|
||||||
Escrow EscrowConfig `json:"escrow"`
|
Escrow EscrowConfig `json:"escrow"`
|
||||||
LocalAPI LocalAPIConfig `json:"local_api"`
|
LocalAPI LocalAPIConfig `json:"local_api"`
|
||||||
LANResolver LANResolverConfig `json:"lan_resolver"`
|
LANResolver LANResolverConfig `json:"lan_resolver"`
|
||||||
|
WGTunnel WGTunnelConfig `json:"wg_tunnel"`
|
||||||
LogLevel string `json:"log_level"` // debug|info|warn|error (default info)
|
LogLevel string `json:"log_level"` // debug|info|warn|error (default info)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WGTunnelConfig configures the offsite WireGuard tunnel (S3, doc 06). **Enabled DEFAULTS TO
|
||||||
|
// FALSE — the safety gate:** agent releases roll to near-production boxes, and auto-registering
|
||||||
|
// one into the DEV endpoint on update would be wrong. Enable explicitly per box; the default
|
||||||
|
// flips only when the production endpoint exists (a later, deliberate decision).
|
||||||
|
type WGTunnelConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
IntervalSeconds int `json:"interval_seconds"` // reconcile cadence; default 60
|
||||||
|
StateDir string `json:"state_dir"` // key/marker/staged-conf under <StateDir>/wg/; default /var/lib/felhom-agent (the FELHOM_WG sudoers install entry hard-codes this default)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDefaults fills interval + state dir.
|
||||||
|
func (w WGTunnelConfig) WithDefaults() WGTunnelConfig {
|
||||||
|
if w.IntervalSeconds == 0 {
|
||||||
|
w.IntervalSeconds = 60
|
||||||
|
}
|
||||||
|
if w.StateDir == "" {
|
||||||
|
w.StateDir = "/var/lib/felhom-agent"
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
// LANResolverConfig configures the host-level split-horizon DNS resolver (internal/lanresolver): a
|
// LANResolverConfig configures the host-level split-horizon DNS resolver (internal/lanresolver): a
|
||||||
// dnsmasq the agent manages so LAN clients reach their guest DIRECTLY at the same hostname + real cert.
|
// dnsmasq the agent manages so LAN clients reach their guest DIRECTLY at the same hostname + real cert.
|
||||||
// Disabled unless Enable is set. HostIP defaults to the local-API bridge IP (the host LAN anchor);
|
// Disabled unless Enable is set. HostIP defaults to the local-API bridge IP (the host LAN anchor);
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package escrow
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Slice 10D.1 — IDENTITY escrow. The K-escrow (above) wraps the PBS *encryption key* via the
|
// Slice 10D.1 — IDENTITY escrow. The K-escrow (above) wraps the PBS *encryption key* via the
|
||||||
@@ -24,6 +26,32 @@ var ageBinary = "/usr/bin/age"
|
|||||||
type IdentityBundle struct {
|
type IdentityBundle struct {
|
||||||
TunnelToken string `json:"tunnel_token"` // the Cloudflare tunnel connector token
|
TunnelToken string `json:"tunnel_token"` // the Cloudflare tunnel connector token
|
||||||
PBSToken string `json:"pbs_token"` // the PBS access token (steady-state; rotated on re-establish)
|
PBSToken string `json:"pbs_token"` // the PBS access token (steady-state; rotated on re-establish)
|
||||||
|
// WGPrivateKey is the offsite WG tunnel private key (S3; base64, 32 bytes). OPTIONAL: escrow
|
||||||
|
// blobs created before S3 lack it and CANNOT be retro-fitted (R is never retained) — S5 DR
|
||||||
|
// falls back to fresh-key re-registration, which keeps the box's /32 (hub S2 re-key-in-place).
|
||||||
|
WGPrivateKey string `json:"wg_private_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttachWGKey injects the offsite WG private key into the bundle when the key file exists (S3
|
||||||
|
// escrow-create auto-inject). Returns whether it attached. The VALUE is validated (base64, 32
|
||||||
|
// bytes) but never logged by callers — log the field NAME only. A missing key file is a clean
|
||||||
|
// no-attach (pre-S3 behavior, byte-compatible bundle); a corrupt one is an error (the operator
|
||||||
|
// should know their escrow would silently lack a live identity).
|
||||||
|
func AttachWGKey(b *IdentityBundle, keyPath string) (bool, error) {
|
||||||
|
raw, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("escrow: reading wg key file: %w", err)
|
||||||
|
}
|
||||||
|
s := strings.TrimSpace(string(raw))
|
||||||
|
dec, err := base64.StdEncoding.DecodeString(s)
|
||||||
|
if err != nil || len(dec) != 32 {
|
||||||
|
return false, fmt.Errorf("escrow: wg key file %s is corrupt (not 32-byte base64)", keyPath)
|
||||||
|
}
|
||||||
|
b.WGPrivateKey = s
|
||||||
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WrapIdentity wraps arbitrary bundle bytes under `R` via `age -p` (scrypt + ChaCha20-Poly1305) and
|
// WrapIdentity wraps arbitrary bundle bytes under `R` via `age -p` (scrypt + ChaCha20-Poly1305) and
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package escrow
|
||||||
|
|
||||||
|
// S3 Group D — the WG-key escrow join. Red-proof (e): remove the auto-inject call and the
|
||||||
|
// bundle-contains-key assertion fails.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAttachWGKey(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
keyPath := filepath.Join(dir, "private.key")
|
||||||
|
|
||||||
|
// Missing key file → clean no-attach (pre-S3 bundles stay byte-compatible).
|
||||||
|
b := &IdentityBundle{TunnelToken: "tt", PBSToken: "pt"}
|
||||||
|
attached, err := AttachWGKey(b, keyPath)
|
||||||
|
if err != nil || attached {
|
||||||
|
t.Fatalf("missing file: attached=%v err=%v", attached, err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(b)
|
||||||
|
if string(raw) != `{"tunnel_token":"tt","pbs_token":"pt"}` {
|
||||||
|
t.Fatalf("bundle without key marshals with extra fields: %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Present key file → attached, field carried.
|
||||||
|
key := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
||||||
|
os.WriteFile(keyPath, []byte(key+"\n"), 0o600)
|
||||||
|
attached, err = AttachWGKey(b, keyPath)
|
||||||
|
if err != nil || !attached {
|
||||||
|
t.Fatalf("present file: attached=%v err=%v", attached, err)
|
||||||
|
}
|
||||||
|
if b.WGPrivateKey != key {
|
||||||
|
t.Fatalf("bundle key = %q", b.WGPrivateKey)
|
||||||
|
}
|
||||||
|
raw, _ = json.Marshal(b)
|
||||||
|
var back IdentityBundle
|
||||||
|
json.Unmarshal(raw, &back)
|
||||||
|
if back.WGPrivateKey != key {
|
||||||
|
t.Fatal("wg_private_key does not survive the bundle round-trip")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Corrupt key file → error (the operator must know their escrow would lack the identity).
|
||||||
|
os.WriteFile(keyPath, []byte("garbage"), 0o600)
|
||||||
|
if _, err := AttachWGKey(&IdentityBundle{}, keyPath); err == nil {
|
||||||
|
t.Fatal("corrupt key file attached silently")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user