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:
2026-07-04 07:14:33 +02:00
parent fb248961c6
commit e2b6c63ea2
6 changed files with 230 additions and 9 deletions
+10
View File
@@ -101,4 +101,14 @@ var manifest = []Capability{
// ---- 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) ----
{"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},
}
+28 -6
View File
@@ -22,18 +22,40 @@ import (
// Config is the agent configuration.
type Config struct {
Proxmox ProxmoxConfig `json:"proxmox"`
Privileged PrivilegedConfig `json:"privileged"`
Authz AuthzConfig `json:"authz"`
Hub HubConfig `json:"hub"`
Storage StorageConfig `json:"storage"`
Backup BackupConfig `json:"backup"`
Proxmox ProxmoxConfig `json:"proxmox"`
Privileged PrivilegedConfig `json:"privileged"`
Authz AuthzConfig `json:"authz"`
Hub HubConfig `json:"hub"`
Storage StorageConfig `json:"storage"`
Backup BackupConfig `json:"backup"`
Escrow EscrowConfig `json:"escrow"`
LocalAPI LocalAPIConfig `json:"local_api"`
LANResolver LANResolverConfig `json:"lan_resolver"`
WGTunnel WGTunnelConfig `json:"wg_tunnel"`
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
// 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);
+28
View File
@@ -2,10 +2,12 @@ package escrow
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// 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 {
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)
// 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
+51
View File
@@ -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")
}
}