R-861: narrow the agent's root grants — exact sudo patterns, felhom-priv-apply content checker, fixed hook/parent files in the bundle, signed self-update verified as root, escrow root reads pinned
gates / gates (push) Successful in 20s
gates / gates (push) Successful in 20s
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:
@@ -81,10 +81,8 @@ var manifest = []Capability{
|
||||
{"drives-mkdir-sub", "per-drive stable dir create", "/usr/bin/mkdir", []string{"-p", "/mnt/felhom-drives/felhom-usb"}, false, ""},
|
||||
{"drives-mkdir-data", "felhom-data namespace create", "/usr/bin/mkdir", []string{"-p", "/mnt/felhom-usb/felhom-data"}, false, ""},
|
||||
{"drives-chown-data", "felhom-data guest-root chown", "/usr/bin/chown", []string{"100000:100000", "/mnt/felhom-usb/felhom-data"}, false, ""},
|
||||
{"parent-script-install", "shared-parent boot script install", "/usr/bin/install", []string{"-m", "0755", "--", "/tmp/felhom-shared-parent-123456789.sh", "/usr/local/sbin/felhom-shared-parent.sh"}, false, ""},
|
||||
{"parent-unit-install", "shared-parent boot unit install", "/usr/bin/install", []string{"-m", "0644", "--", "/tmp/felhom-shared-parent-123456789.service", "/etc/systemd/system/felhom-shared-parent.service"}, false, ""},
|
||||
{"parent-unit-enable", "shared-parent boot-persistence enable", "/usr/bin/systemctl", []string{"enable", "felhom-shared-parent.service"}, false, ""},
|
||||
{"parent-bind-mp8", "parent bind into guest at provision", "/usr/sbin/pct", []string{"set", "9201", "-mp8", "/mnt/felhom-drives"}, false, ""},
|
||||
{"parent-bind-mp8", "parent bind into guest at provision", "/usr/sbin/pct", []string{"set", "9201", "-mp8", "/mnt/felhom-drives,mp=/mnt/felhom-drives"}, false, ""},
|
||||
|
||||
// ---- Disk inspect / format gate (Critical: the data-bearing classifier + format) ----
|
||||
{"disk-blkid", "disk data-bearing classify (format gate)", "/usr/sbin/blkid", []string{"-p", "-o", "export", "/dev/sda"}, true, ""},
|
||||
@@ -95,11 +93,11 @@ var manifest = []Capability{
|
||||
{"disk-lvs", "thin-pool usage read", "/usr/sbin/lvs", []string{"--reportformat", "json", "--units", "b", "-o", "lv_name,data_percent,metadata_percent", "--", "pve/data"}, false, ""},
|
||||
|
||||
// ---- Storage mount units (watchdog re-mount) ----
|
||||
{"mount-unit-install", "fs-UUID mount unit install", "/usr/bin/install", []string{"-o", "root", "-g", "root", "-m", "0644", "--", "/var/lib/felhom-agent/units/felhom-x.mount", "/etc/systemd/system/felhom-x.mount"}, false, ""},
|
||||
{"mount-unit-install", "fs-UUID mount unit install (root content check, R-861)", "/usr/local/sbin/felhom-priv-apply", []string{"unit", "mnt-felhom\\x2dx.mount"}, false, ""},
|
||||
{"mount-daemon-reload", "systemd reload after unit write", "/usr/bin/systemctl", []string{"daemon-reload"}, false, ""},
|
||||
{"mount-unit-enable", "mount unit enable", "/usr/bin/systemctl", []string{"enable", "--now", "--", "felhom-x.mount"}, false, ""},
|
||||
{"mount-unit-disable", "mount unit disable", "/usr/bin/systemctl", []string{"disable", "--", "felhom-x.mount"}, false, ""},
|
||||
{"mount-unit-stop", "mount unit stop", "/usr/bin/systemctl", []string{"stop", "--", "felhom-x.mount"}, false, ""},
|
||||
{"mount-unit-enable", "mount unit enable", "/usr/bin/systemctl", []string{"enable", "--now", "--", "mnt-felhom\\x2dx.mount"}, false, ""},
|
||||
{"mount-unit-disable", "mount unit disable", "/usr/bin/systemctl", []string{"disable", "--", "mnt-felhom\\x2dx.mount"}, false, ""},
|
||||
{"mount-unit-stop", "mount unit stop", "/usr/bin/systemctl", []string{"stop", "--", "mnt-felhom\\x2dx.mount"}, false, ""},
|
||||
|
||||
// ---- Network storage re-arm + cleanup (CAMPAIGN-3 F10/F1) ----
|
||||
{"netmount-reset-failed", "NAS automount re-arm after start-limit (F10)", "/usr/bin/systemctl", []string{"reset-failed", "--", "mnt-felhom\\x2ddrives-media.automount"}, false, ""},
|
||||
@@ -110,18 +108,17 @@ var manifest = []Capability{
|
||||
|
||||
// ---- Provisioning back-half ----
|
||||
{"provision-chown", "bootstrap mount guest-root chown", "/usr/bin/chown", []string{"-R", "100000:100000", "/var/lib/felhom-agent/guests/9201"}, false, ""},
|
||||
{"provision-config-mount", "bootstrap config bind mount", "/usr/sbin/pct", []string{"set", "9201", "-mp0", "/var/lib/felhom-agent/guests/9201"}, false, ""},
|
||||
{"provision-config-mount", "bootstrap config bind mount", "/usr/sbin/pct", []string{"set", "9201", "-mp0", "/var/lib/felhom-agent/guests/9201/bootstrap,mp=/etc/felhom-bootstrap,ro=1"}, false, ""},
|
||||
{"provision-onboot", "customer guest autostart (onboot)", "/usr/sbin/pct", []string{"set", "9201", "-onboot", "1"}, false, ""},
|
||||
|
||||
// ---- Pre-start self-heal hook + guest lifecycle ----
|
||||
{"guesthook-install", "pre-start hook snippet install", "/usr/bin/install", []string{"-m", "0755", "--", "/tmp/felhom-guest-hook-123456789.sh", "/var/lib/vz/snippets/felhom-guest-hook.sh"}, false, ""},
|
||||
{"guesthook-register", "pre-start hook register", "/usr/sbin/pct", []string{"set", "9201", "--hookscript", "local:snippets/felhom-guest-hook.sh"}, false, ""},
|
||||
{"guesthook-delete-mp", "dead mountpoint slot delete (C1 net)", "/usr/sbin/pct", []string{"set", "9201", "--delete", "mp0"}, false, ""},
|
||||
{"guest-reboot", "enroll activate-binds reboot", "/usr/sbin/pct", []string{"reboot", "9201"}, false, ""},
|
||||
|
||||
// ---- LAN split-horizon resolver (dnsmasq) ----
|
||||
{"dnsmasq-install", "dnsmasq package install", "/usr/bin/apt-get", []string{"install", "-y", "-q", "dnsmasq"}, false, ""},
|
||||
{"dnsmasq-write", "dnsmasq drop-in write", "/usr/bin/install", []string{"-m", "0644", "/tmp/felhom-resolver-x.conf", "/etc/dnsmasq.d/felhom-x.conf"}, false, ""},
|
||||
{"dnsmasq-write", "dnsmasq drop-in write (root content check, R-861)", "/usr/local/sbin/felhom-priv-apply", []string{"dnsmasq", "/tmp/felhom-resolver-123456789.conf", "felhom-x.conf"}, false, ""},
|
||||
{"dnsmasq-enable", "dnsmasq enable", "/usr/bin/systemctl", []string{"enable", "--now", "dnsmasq"}, false, ""},
|
||||
|
||||
// ---- OS updates, guest fast lane (`11` §5.4.1; the wrapper holds every rule) ----
|
||||
@@ -160,7 +157,7 @@ var manifest = []Capability{
|
||||
// (one-time bootstrap) and disable (revocation, a deliberate teardown) stay non-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"}, true, ""},
|
||||
{"wg-conf-install", "wg-felhom conf install (root content check, R-861)", "/usr/local/sbin/felhom-priv-apply", []string{"wg"}, true, ""},
|
||||
{"wg-enable", "wg-quick@wg-felhom enable", "/usr/bin/systemctl", []string{"enable", "--now", "wg-quick@wg-felhom"}, true, ""},
|
||||
{"wg-restart", "wg-quick@wg-felhom restart (conf change)", "/usr/bin/systemctl", []string{"restart", "wg-quick@wg-felhom"}, true, ""},
|
||||
{"wg-disable", "wg-quick@wg-felhom disable (revocation)", "/usr/bin/systemctl", []string{"disable", "--now", "wg-quick@wg-felhom"}, false, ""},
|
||||
@@ -192,7 +189,6 @@ var manifest = []Capability{
|
||||
// operator-driven op, not a steady-state serving path — a degraded grant means "can't
|
||||
// self-update" (fall back to a manual SSH deploy), not a serving outage. The apply repr uses a
|
||||
// staging-dir path + a placeholder sha (list-mode never runs it). ----
|
||||
{"selfupdate-apply", "agent self-update apply (A/B flip)", "/usr/local/sbin/felhom-selfupdate-guarded", []string{"apply", "/var/lib/felhom-agent/selfupdate/felhom-agent-0.0.0", "0000000000000000000000000000000000000000000000000000000000000000"}, false, ""},
|
||||
{"selfupdate-commit", "agent self-update commit", "/usr/local/sbin/felhom-selfupdate-guarded", []string{"commit"}, false, ""},
|
||||
{"selfupdate-rollback", "agent self-update rollback", "/usr/local/sbin/felhom-selfupdate-guarded", []string{"rollback"}, false, ""},
|
||||
}
|
||||
|
||||
@@ -56,8 +56,10 @@ func parseSudoersEntries(t *testing.T, text string) []string {
|
||||
var cur strings.Builder
|
||||
for i := 0; i < len(raw); i++ {
|
||||
c := raw[i]
|
||||
if c == '\\' && i+1 < len(raw) {
|
||||
cur.WriteByte(raw[i+1]) // unescape: keep the next char literally (\, → , ; \: → :)
|
||||
if c == '\\' && i+1 < len(raw) && strings.IndexByte(",:=", raw[i+1]) >= 0 {
|
||||
// unescape the sudoers grammar escapes only (\, → , ; \: → : ; \= → =). Every other backslash is kept: in
|
||||
// a regex entry (R-861) `\.` `\{` `\\` mean exactly what sudo's regex engine reads.
|
||||
cur.WriteByte(raw[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
@@ -108,10 +110,24 @@ func globToRegex(pat string) *regexp.Regexp {
|
||||
return regexp.MustCompile(b.String())
|
||||
}
|
||||
|
||||
// entryRegex compiles one sudoers entry. R-861 (v0.146.0): an entry whose ARGUMENTS are a sudo regular expression
|
||||
// (`^...$`, sudo >= 1.9.10) is matched as one — the binary literally, then a space, then the arguments joined by spaces
|
||||
// (sudo's own rule). Every other entry is an fnmatch glob (globToRegex). The parser has already undone the sudoers
|
||||
// escapes (`\,` `\:` `\=` `\\`), which leaves a valid RE2 pattern.
|
||||
func entryRegex(e string) *regexp.Regexp {
|
||||
if i := strings.IndexByte(e, ' '); i > 0 {
|
||||
bin, args := e[:i], e[i+1:]
|
||||
if strings.HasPrefix(args, "^") && strings.HasSuffix(args, "$") {
|
||||
return regexp.MustCompile("^" + regexp.QuoteMeta(bin) + " " + args[1:])
|
||||
}
|
||||
}
|
||||
return globToRegex(e)
|
||||
}
|
||||
|
||||
// matchesAny reports whether cmdline matches at least one sudoers entry pattern.
|
||||
func matchesAny(cmdline string, entries []string) bool {
|
||||
for _, e := range entries {
|
||||
if globToRegex(e).MatchString(cmdline) {
|
||||
if entryRegex(e).MatchString(cmdline) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package capability
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-861 (agent v0.146.0): the attacks the old globs let through, each as the exact argv a compromised agent would
|
||||
// send. NONE may match any entry of the new sudoers. The same list ran against the REAL sudo 1.9.16 (a throwaway
|
||||
// container, and `sudo -l -U felhom-agent` on both demo boxes after the bundle): audits/hub-safety-2026-10-05/partF/.
|
||||
//
|
||||
// RED-PROOF: run this list against the v0.145.0 sudoers (git show v0.145.0:configs/felhom-agent.sudoers) → most of
|
||||
// these MATCH (recorded in the same evidence folder).
|
||||
var r861Injections = []string{
|
||||
// a raw host disk for a guest (pct options smuggled through a vmid glob)
|
||||
"/usr/sbin/pct set 9201 --dev0 /dev/sda -onboot 1",
|
||||
"/usr/sbin/pct set 9201 --dev0 /dev/sda -mp8 /mnt/felhom-drives",
|
||||
"/usr/sbin/pct set 9201 --delete mp0 --dev0 /dev/sda",
|
||||
"/usr/sbin/pct set 9201 -mp0 /var/lib/felhom-agent/guests/9201/bootstrap,mp=/x --dev0 /dev/sda",
|
||||
// a bind mount over /etc through traversal
|
||||
"/usr/bin/mount --bind /mnt/../var/lib/felhom-agent/x/felhom-data /mnt/felhom-drives/x",
|
||||
"/usr/bin/mount --bind /mnt/a/felhom-data /mnt/felhom-drives/../../etc/sudoers.d",
|
||||
"/usr/bin/umount /mnt/felhom-drives/x /",
|
||||
"/usr/bin/chown 100000:100000 /mnt/a/felhom-data /etc/shadow",
|
||||
"/usr/bin/mkdir -p /mnt/felhom-drives/x /etc/systemd/system/evil.mount",
|
||||
// root-read files the agent writes: gone as `install` lines
|
||||
"/usr/bin/install -o root -g root -m 0644 -- /var/lib/felhom-agent/units/x.mount /etc/systemd/system/etc-sudoers.d.mount",
|
||||
"/usr/bin/install -m 0755 -- /tmp/felhom-guest-hook-1.sh /var/lib/vz/snippets/felhom-guest-hook.sh",
|
||||
"/usr/bin/install -m 0755 -- /tmp/felhom-shared-parent-1.sh /usr/local/sbin/felhom-shared-parent.sh",
|
||||
"/usr/bin/install -m 0644 /tmp/felhom-resolver-1.conf /etc/dnsmasq.d/felhom-x.conf",
|
||||
"/usr/bin/install -o root -g root -m 0600 -- /var/lib/felhom-agent/wg/wg-felhom.conf /etc/wireguard/wg-felhom.conf",
|
||||
"/usr/bin/install -o root -g root -m 0644 -- /var/lib/felhom-agent/felhom-sshd/sshd_config /etc/felhom-sshd/sshd_config",
|
||||
// the unsigned binary flip
|
||||
"/usr/local/sbin/felhom-selfupdate-guarded apply /var/lib/felhom-agent/selfupdate/felhom-agent-9.9.9 0000000000000000000000000000000000000000000000000000000000000000",
|
||||
// enabling or removing anything that is not ours
|
||||
"/usr/bin/systemctl enable --now -- mnt-hdd_1.mount evil.service",
|
||||
"/usr/bin/systemctl enable --now -- etc-sudoers.d.mount",
|
||||
"/usr/bin/rm -f /etc/systemd/system/mnt-felhomx /etc/passwd",
|
||||
"/usr/bin/rm -f /etc/dnsmasq.d/felhom-x.conf /etc/shadow",
|
||||
"/usr/bin/rmdir /mnt/felhom-drives/x /etc",
|
||||
// nftables commands chained after a set element
|
||||
"/usr/sbin/nft add element inet felhom_oob operator_ips { 10.77.0.250 } ; flush ruleset",
|
||||
// extra options to read-only tools
|
||||
"/usr/sbin/smartctl -a -j /dev/sda -s off",
|
||||
"/usr/sbin/lvs --reportformat json --units b -o lv_name,data_percent,metadata_percent -- pve/data --config x",
|
||||
"/usr/sbin/pct exec 9201 --keep-env -- docker inspect -f x felhom-controller",
|
||||
"/usr/sbin/pct unlock 9201 --whatever",
|
||||
// the checker with a path it must never take
|
||||
"/usr/local/sbin/felhom-priv-apply unit ../../etc/x.mount",
|
||||
"/usr/local/sbin/felhom-priv-apply dnsmasq /etc/shadow felhom-x.conf",
|
||||
"/usr/local/sbin/felhom-priv-apply wg /etc/shadow",
|
||||
}
|
||||
|
||||
func TestSudoersRefusesTheR861Injections(t *testing.T) {
|
||||
data, err := os.ReadFile(sudoersPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries := parseSudoersEntries(t, string(data))
|
||||
for _, c := range r861Injections {
|
||||
if matchesAny(c, entries) {
|
||||
t.Errorf("the sudoers still allows: %s", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Slice 10D.1 — IDENTITY escrow. The K-escrow (above) wraps the PBS *encryption key* via the
|
||||
@@ -71,7 +73,7 @@ func HashResticPassword(pw string) string {
|
||||
// (non-empty) but NEVER logged by callers — log the field NAME only (mirrors AttachWGKey). A missing file
|
||||
// is a clean no-attach (pre-fork-4 behavior, byte-compatible bundle).
|
||||
func AttachResticPassword(b *IdentityBundle, stagePath string) (bool, error) {
|
||||
raw, err := os.ReadFile(stagePath)
|
||||
raw, err := readStagedNoFollow(stagePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
@@ -92,7 +94,7 @@ func AttachResticPassword(b *IdentityBundle, stagePath string) (bool, error) {
|
||||
// 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)
|
||||
raw, err := readStagedNoFollow(keyPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
@@ -180,3 +182,27 @@ func UnwrapIdentityBundle(ctx context.Context, blob []byte, recoveryCode string)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// readStagedNoFollow reads a file the AGENT staged, for the escrow ceremony that runs as ROOT (FELHOM_ESCROW). R-861
|
||||
// (agent v0.146.0): both files live in the agent's own directory, so a compromised agent could put a SYMLINK there
|
||||
// (to any root-only file) and the root ceremony would seal that file into the blob and hand the agent R — a root file
|
||||
// read. So: no symlink (O_NOFOLLOW), a regular file, at most 4 KiB. A missing file keeps its os.IsNotExist meaning.
|
||||
// Pinned by TestAttach_RefusesASymlink.
|
||||
func readStagedNoFollow(path string) ([]byte, error) {
|
||||
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
fi, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !fi.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%s is not a regular file", path)
|
||||
}
|
||||
if fi.Size() > 4096 {
|
||||
return nil, fmt.Errorf("%s is larger than 4 KiB", path)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(f, 4097))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package escrow
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-861 (agent v0.146.0): the root escrow ceremony reads two files from the AGENT's directory. A symlink there to a
|
||||
// root-only file must never be read (it would be sealed under R and handed to the agent).
|
||||
// RED-PROOF (audits/hub-safety-2026-10-05/partF/red-proof.txt): use os.ReadFile in readStagedNoFollow → this fails.
|
||||
func TestAttach_RefusesASymlink(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
secret := filepath.Join(d, "root-only")
|
||||
if err := os.WriteFile(secret, []byte("ROOT-ONLY-CANARY"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(d, "restic_repo_password")
|
||||
if err := os.Symlink(secret, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b IdentityBundle
|
||||
if ok, err := AttachResticPassword(&b, link); err == nil || ok || b.ResticRepoPassword != "" {
|
||||
t.Fatalf("a symlinked staged file was read: ok=%v err=%v value-set=%v", ok, err, b.ResticRepoPassword != "")
|
||||
}
|
||||
if ok, err := AttachWGKey(&b, link); err == nil || ok {
|
||||
t.Fatalf("a symlinked wg key was read: ok=%v err=%v", ok, err)
|
||||
}
|
||||
// control: the real staged file is still read; a missing one is still a clean no-attach
|
||||
real := filepath.Join(d, "real")
|
||||
_ = os.WriteFile(real, []byte("pw\n"), 0o600)
|
||||
if ok, err := AttachResticPassword(&b, real); err != nil || !ok || b.ResticRepoPassword != "pw" {
|
||||
t.Fatalf("control: the plain staged file was not read: %v %v", ok, err)
|
||||
}
|
||||
if ok, err := AttachResticPassword(&b, filepath.Join(d, "absent")); err != nil || ok {
|
||||
t.Fatalf("control: a missing file must stay a clean no-attach: %v %v", ok, err)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ const (
|
||||
PortFile = ConfDir + "/port"
|
||||
// PidFile is the instance pidfile (NOT a RuntimeDirectory — that is the G1 incident cause).
|
||||
PidFile = "/run/felhom-sshd.pid"
|
||||
// PrivApply is the root content checker that installs the config and felhom-op's key (R-861, agent v0.146.0).
|
||||
PrivApply = "/usr/local/sbin/felhom-priv-apply"
|
||||
// Unit is the systemd unit name.
|
||||
Unit = "felhom-sshd"
|
||||
// OperatorUser is the default operator login (scoped sudo; key in AuthKeysDir only).
|
||||
|
||||
@@ -143,7 +143,8 @@ func (m *Manager) Apply(ctx context.Context, block *hub.WireWireguard) (int, err
|
||||
m.logger.Error("felhomsshd: staged config failed sshd -t — NOT installing", "err", err, "stderr", strings.TrimSpace(string(errOut)))
|
||||
return port, err
|
||||
}
|
||||
if _, errOut, err := m.runner.Run(ctx, "install", "-o", "root", "-g", "root", "-m", "0644", "--", m.stagedConfPath(), ConfPath); err != nil {
|
||||
// R-861 (v0.146.0): the root checker installs it, and only if it is renderConfig's template for some port.
|
||||
if _, errOut, err := m.runner.Run(ctx, PrivApply, "sshd-config"); err != nil {
|
||||
m.logger.Error("felhomsshd: config install failed", "err", err, "stderr", strings.TrimSpace(string(errOut)))
|
||||
return port, err
|
||||
}
|
||||
@@ -181,7 +182,9 @@ func (m *Manager) applyAuthorizedKeys(ctx context.Context, sshKey string) {
|
||||
m.logger.Error("felhomsshd: staging authorized_keys", "err", err)
|
||||
return
|
||||
}
|
||||
if _, errOut, err := m.runner.Run(ctx, "install", "-o", "root", "-g", "root", "-m", "0644", "--", staged, AuthKeysUserPath); err != nil {
|
||||
// R-861: the root checker installs it — one plain public key, no options (command=, from=, …), or empty.
|
||||
_ = staged
|
||||
if _, errOut, err := m.runner.Run(ctx, PrivApply, "sshd-key"); err != nil {
|
||||
m.logger.Error("felhomsshd: authorized_keys install failed", "err", err, "stderr", strings.TrimSpace(string(errOut)))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package felhomsshd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/privapplytest"
|
||||
)
|
||||
|
||||
// R-861: renderConfig is byte-identical to the checker's template (only the Port varies); a changed line is refused.
|
||||
// RED-PROOF: change one directive in renderConfig → this fails (the two templates drifted).
|
||||
func TestPrivApply_AcceptsTheRenderedConfig(t *testing.T) {
|
||||
for _, port := range []int{2222, 8822, 60022} {
|
||||
conf, err := renderConfig(port)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := privapplytest.Check(t, "sshd-config", "", conf); got != "OK" {
|
||||
t.Errorf("port %d: %s", port, got)
|
||||
}
|
||||
}
|
||||
conf, _ := renderConfig(8822)
|
||||
if got := privapplytest.Check(t, "sshd-config", "", conf+"StrictModes no\n"); !strings.HasPrefix(got, "REFUSED") {
|
||||
t.Fatalf("control: an extra directive was not refused: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -38,35 +38,26 @@ const snippetBody = `#!/bin/sh
|
||||
exit 0
|
||||
`
|
||||
|
||||
// InstallSnippet writes the pre-start hook wrapper into the PVE snippets dir (idempotent, root-owned,
|
||||
// executable). The agent runs as a non-root service user, so it writes an agent-writable temp file then
|
||||
// `install`s it host-root (same pattern as the bootstrap mount + dnsmasq drop-ins). Safe to call repeatedly.
|
||||
// The temp file is a RANDOM-named os.CreateTemp (audit B1): a fixed, predictable /tmp name could be
|
||||
// pre-created by another local user and rewritten between our write and root's install (TOCTOU into a
|
||||
// root-executed hookscript). The final mode comes from `install -m`, so the 0600 temp is fine.
|
||||
func InstallSnippet(ctx context.Context, runner proxmox.Runner) error {
|
||||
f, err := os.CreateTemp("", "felhom-guest-hook-*.sh")
|
||||
// SnippetReady (R-861, agent v0.146.0) reports whether the pre-start hook is in place: a regular file at path whose
|
||||
// content is exactly snippetBody. The hook is a FIXED, ROOT-OWNED file that arrives with the signed config bundle
|
||||
// (configs/felhom-guest-hook.sh, pinned byte-identical by TestSnippetEqualsTheBundle). The agent no longer installs it:
|
||||
// until v0.146.0 it `install`ed it from /tmp, and Proxmox runs a hookscript as root at every guest start — so the
|
||||
// install grant was a root shell for a compromised agent. A missing or different hook is an error the caller logs; it
|
||||
// then does NOT register the hook (a guest whose hookscript is missing does not start).
|
||||
func SnippetReady(path string) error {
|
||||
fi, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("guesthook: create temp snippet: %w", err)
|
||||
return fmt.Errorf("guesthook: %s is missing — it arrives with the signed config bundle (agent_config_update): %w", path, err)
|
||||
}
|
||||
tmp := f.Name()
|
||||
defer os.Remove(tmp)
|
||||
if _, err := f.WriteString(snippetBody); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("guesthook: write temp snippet: %w", err)
|
||||
if !fi.Mode().IsRegular() {
|
||||
return fmt.Errorf("guesthook: %s is not a regular file", path)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("guesthook: close temp snippet: %w", err)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("guesthook: read %s: %w", path, err)
|
||||
}
|
||||
// Ensure the snippets dir exists FIRST (B2, DRILL-day0-cleanroom-2026-07-03): a fresh PVE has
|
||||
// no /var/lib/vz/snippets, and `install` (without -D) won't create the parent — the whole
|
||||
// hook install silently failed on a freshly-bootstrapped box. Fenced root op like the install
|
||||
// itself; idempotent.
|
||||
if _, stderr, err := runner.Run(ctx, "mkdir", "-p", SnippetDir); err != nil {
|
||||
return fmt.Errorf("guesthook: ensure snippets dir %s: %w: %s", SnippetDir, err, string(stderr))
|
||||
}
|
||||
if _, stderr, err := runner.Run(ctx, "install", "-m", "0755", "--", tmp, SnippetPath); err != nil {
|
||||
return fmt.Errorf("guesthook: install snippet to %s: %w: %s", SnippetPath, err, string(stderr))
|
||||
if string(b) != snippetBody {
|
||||
return fmt.Errorf("guesthook: %s differs from this agent's hook — the next config bundle replaces it", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -29,100 +29,34 @@ func (r *recordingRunner) RunStdin(ctx context.Context, _ io.Reader, name string
|
||||
return r.Run(ctx, name, args...)
|
||||
}
|
||||
|
||||
// TestInstallSnippet_RandomTempName is the audit-B1 negative test: the staged install SOURCE must be a
|
||||
// RANDOM os.CreateTemp name (felhom-guest-hook-<random>.sh), never the fixed, pre-creatable
|
||||
// /tmp/felhom-guest-hook.sh (a local TOCTOU into a root-executed hookscript), and two consecutive
|
||||
// installs must stage through DIFFERENT paths.
|
||||
func TestInstallSnippet_RandomTempName(t *testing.T) {
|
||||
r := &recordingRunner{}
|
||||
if err := InstallSnippet(context.Background(), r); err != nil {
|
||||
t.Fatalf("InstallSnippet #1: %v", err)
|
||||
// R-861 (agent v0.146.0): the hook file comes with the signed config bundle; the agent never installs it, only checks.
|
||||
// RED-PROOF (audits/hub-safety-2026-10-05/partF/red-proof.txt): make SnippetReady accept any content → the
|
||||
// "differs" case fails.
|
||||
func TestSnippetReady(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
p := filepath.Join(d, "felhom-guest-hook.sh")
|
||||
if err := SnippetReady(p); err == nil {
|
||||
t.Fatal("a missing hook read as ready — the guest would get a hookscript that does not exist")
|
||||
}
|
||||
if err := InstallSnippet(context.Background(), r); err != nil {
|
||||
t.Fatalf("InstallSnippet #2: %v", err)
|
||||
_ = os.WriteFile(p, []byte("#!/bin/sh\nid > /tmp/x\n"), 0o755)
|
||||
if err := SnippetReady(p); err == nil {
|
||||
t.Fatal("a hook with other content read as ready")
|
||||
}
|
||||
var installs [][]string
|
||||
for _, call := range r.calls {
|
||||
if call[0] == "install" {
|
||||
installs = append(installs, call)
|
||||
}
|
||||
_ = os.WriteFile(p, []byte(snippetBody), 0o755)
|
||||
if err := SnippetReady(p); err != nil {
|
||||
t.Fatalf("the bundle's hook was not accepted: %v", err)
|
||||
}
|
||||
if len(installs) != 2 {
|
||||
t.Fatalf("expected 2 install calls, got %d: %v", len(installs), r.calls)
|
||||
}
|
||||
|
||||
randomName := regexp.MustCompile(`felhom-guest-hook-[^/\\]+\.sh$`)
|
||||
fixedName := regexp.MustCompile(`felhom-guest-hook\.sh$`)
|
||||
var srcs []string
|
||||
for i, call := range installs {
|
||||
// install -m 0755 -- <src> <dest>
|
||||
if len(call) != 6 {
|
||||
t.Fatalf("call %d: unexpected vector %v", i, call)
|
||||
}
|
||||
src, dest := call[4], call[5]
|
||||
if dest != SnippetPath {
|
||||
t.Errorf("call %d: dest = %q, want %q", i, dest, SnippetPath)
|
||||
}
|
||||
if !randomName.MatchString(src) {
|
||||
t.Errorf("call %d: source %q does not match the random felhom-guest-hook-*.sh pattern", i, src)
|
||||
}
|
||||
if fixedName.MatchString(src) {
|
||||
t.Errorf("call %d: source %q is the FIXED predictable temp name (B1 TOCTOU)", i, src)
|
||||
}
|
||||
srcs = append(srcs, src)
|
||||
}
|
||||
if srcs[0] == srcs[1] {
|
||||
t.Errorf("two consecutive installs staged through the SAME source path %q — must be random per call", srcs[0])
|
||||
}
|
||||
// Non-hollow: the staged file must actually carry the snippet body at install time.
|
||||
for i, c := range r.srcContent {
|
||||
if c != snippetBody {
|
||||
t.Errorf("call %d: staged content is not the snippet body (got %d bytes)", i, len(c))
|
||||
}
|
||||
}
|
||||
// And the temp is cleaned up after.
|
||||
for _, src := range srcs {
|
||||
if _, err := os.Stat(src); err == nil {
|
||||
t.Errorf("staged temp %q left behind (defer os.Remove missing)", src)
|
||||
}
|
||||
l := filepath.Join(d, "link.sh")
|
||||
_ = os.Symlink(p, l)
|
||||
if err := SnippetReady(l); err == nil {
|
||||
t.Fatal("a symlink read as the hook")
|
||||
}
|
||||
}
|
||||
|
||||
// B2 Scenario D (DRILL-day0-cleanroom-2026-07-03): on a fresh PVE, /var/lib/vz/snippets does not
|
||||
// exist and `install` (no -D) cannot create it — the drill saw
|
||||
// `install: cannot create regular file … No such file or directory` and the guest silently got no
|
||||
// pre-start self-heal hook. InstallSnippet must therefore issue a `mkdir -p <SnippetDir>` fenced op
|
||||
// BEFORE the `install` op. Pre-fix wrong outcome: no mkdir call at all — only the doomed install.
|
||||
func TestInstallSnippet_EnsuresSnippetsDirFirst(t *testing.T) {
|
||||
r := &recordingRunner{}
|
||||
if err := InstallSnippet(context.Background(), r); err != nil {
|
||||
t.Fatalf("InstallSnippet: %v", err)
|
||||
}
|
||||
|
||||
mkdirIdx, installIdx := -1, -1
|
||||
for i, call := range r.calls {
|
||||
switch call[0] {
|
||||
case "mkdir":
|
||||
if mkdirIdx == -1 {
|
||||
mkdirIdx = i
|
||||
want := []string{"mkdir", "-p", SnippetDir}
|
||||
if len(call) != 3 || call[1] != want[1] || call[2] != want[2] {
|
||||
t.Errorf("mkdir vector = %v, want %v (the sudoers fence matches exactly this argv)", call, want)
|
||||
}
|
||||
}
|
||||
case "install":
|
||||
if installIdx == -1 {
|
||||
installIdx = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if mkdirIdx == -1 {
|
||||
t.Fatalf("no `mkdir -p %s` op issued — on a fresh box the snippet install fails ENOENT (B2); calls: %v", SnippetDir, r.calls)
|
||||
}
|
||||
if installIdx == -1 {
|
||||
t.Fatalf("no install op issued; calls: %v", r.calls)
|
||||
}
|
||||
if mkdirIdx > installIdx {
|
||||
t.Fatalf("mkdir (call %d) must PRECEDE install (call %d) — order: %v", mkdirIdx, installIdx, r.calls)
|
||||
// The bundle's copy is byte-identical to the body the agent checks against.
|
||||
func TestSnippetEqualsTheBundle(t *testing.T) {
|
||||
got, err := os.ReadFile(filepath.Join("..", "..", "configs", "felhom-guest-hook.sh"))
|
||||
if err != nil || string(got) != snippetBody {
|
||||
t.Fatalf("configs/felhom-guest-hook.sh differs from snippetBody (err %v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ const (
|
||||
DropinDir = "/etc/dnsmasq.d"
|
||||
// BaseDropin holds the host-wide listen/upstream config (one per host).
|
||||
BaseDropin = "felhom-resolver-base.conf"
|
||||
// PrivApply is the root content checker that installs a drop-in (R-861).
|
||||
PrivApply = "/usr/local/sbin/felhom-priv-apply"
|
||||
)
|
||||
|
||||
// RenderBase returns the host-wide dnsmasq drop-in: bind to the host LAN IP (+ loopback), no-resolv,
|
||||
@@ -263,7 +265,13 @@ func (m *Manager) writeFileIfChanged(ctx context.Context, path, content, mode st
|
||||
return false, fmt.Errorf("write temp: %w", err)
|
||||
}
|
||||
tmp.Close()
|
||||
if _, errOut, err := m.runner.Run(ctx, "install", "-m", mode, tmpName, path); err != nil {
|
||||
// R-861 (v0.146.0): a dnsmasq drop-in reaches /etc/dnsmasq.d only through the root checker, which allows exactly
|
||||
// the lines RenderBase/RenderGuestDropin write (a `dhcp-script=` would run as root). Mode is fixed (0644) there.
|
||||
_ = mode
|
||||
if filepath.Dir(path) != DropinDir {
|
||||
return false, fmt.Errorf("drop-in %s is not in %s", path, DropinDir)
|
||||
}
|
||||
if _, errOut, err := m.runner.Run(ctx, PrivApply, "dnsmasq", tmpName, filepath.Base(path)); err != nil {
|
||||
return false, fmt.Errorf("install %s: %s: %w", path, strings.TrimSpace(string(errOut)), err)
|
||||
}
|
||||
return true, nil
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package lanresolver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/privapplytest"
|
||||
)
|
||||
|
||||
// R-861: both drop-ins the resolver renders are accepted by the root checker; a dhcp-script line is not.
|
||||
func TestPrivApply_AcceptsTheRenderedDropins(t *testing.T) {
|
||||
if got := privapplytest.Check(t, "dnsmasq", BaseDropin, RenderBase("192.168.0.104", []string{"1.1.1.1", "8.8.8.8"})); got != "OK" {
|
||||
t.Errorf("base drop-in: %s", got)
|
||||
}
|
||||
if got := privapplytest.Check(t, "dnsmasq", DropinName("demo-hp"), RenderGuestDropin("demo-hp", "enkisfelhom.hu", "192.168.0.138")); got != "OK" {
|
||||
t.Errorf("guest drop-in: %s", got)
|
||||
}
|
||||
evil := RenderGuestDropin("demo-hp", "enkisfelhom.hu", "192.168.0.138") + "dhcp-script=/var/lib/felhom-agent/x\n"
|
||||
if got := privapplytest.Check(t, "dnsmasq", "felhom-x.conf", evil); !strings.HasPrefix(got, "REFUSED") {
|
||||
t.Fatalf("control: dhcp-script was not refused: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -121,32 +121,33 @@ func (b *GuestBinder) EnsureSharedParent(ctx context.Context) error {
|
||||
// only the script (the unit was unchanged), so the earlier unit-only gate never redeployed it —
|
||||
// leaving hosts running the pre-fix script (no make-private), whose self-bind stays in root's shared
|
||||
// peer group and DOUBLES every drive bind. Comparing both files closes that deploy gap.
|
||||
if sharedParentInstallStale(sharedParentUnitPath, sharedParentScriptPath) {
|
||||
if ierr := b.installSharedParentUnit(ctx); ierr != nil {
|
||||
b.logger.Warn("shared-parent: boot-persistence (re)install failed (live setup OK; survives until host reboot)", "err", ierr)
|
||||
}
|
||||
if err := b.ensureSharedParentBoot(ctx, sharedParentUnitPath, sharedParentScriptPath, sharedParentWantsLink); err != nil {
|
||||
b.logger.Warn("shared-parent: boot persistence not enabled (live setup OK; survives until host reboot)", "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stageTemp writes content to a fresh random-named temp file (os.CreateTemp pattern — `*` is replaced
|
||||
// by a random string) and returns its path. Caller removes it after the privileged `install`.
|
||||
func stageTemp(pattern, content string) (string, error) {
|
||||
f, err := os.CreateTemp("", pattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
// sharedParentWantsLink exists once `systemctl enable felhom-shared-parent.service` ran (WantedBy=pve-guests.service).
|
||||
const sharedParentWantsLink = "/etc/systemd/system/pve-guests.service.wants/felhom-shared-parent.service"
|
||||
|
||||
// ensureSharedParentBoot (R-861, agent v0.146.0) — the boot script and its unit are ROOT-OWNED FIXED files that arrive
|
||||
// with the signed config bundle (configs/felhom-shared-parent.{sh,service}, byte-identical to the constants above,
|
||||
// pinned by TestSharedParentFilesEqualTheBundle). The agent NEVER installs them any more: until v0.146.0 it installed
|
||||
// them from /tmp, and a script a root unit runs at boot was a root shell for a compromised agent. Here it only checks
|
||||
// them, and enables the unit (a fixed sudoers line) when the bundle has put it in place but nothing has enabled it — a
|
||||
// fresh install. Missing or different files: one warning, nothing run (the next bundle brings them).
|
||||
func (b *GuestBinder) ensureSharedParentBoot(ctx context.Context, unitPath, scriptPath, wantsLink string) error {
|
||||
if sharedParentInstallStale(unitPath, scriptPath) {
|
||||
return fmt.Errorf("%s or %s is missing or differs from this agent's — it arrives with the signed config bundle (agent_config_update); the agent does not install it (R-861)", scriptPath, unitPath)
|
||||
}
|
||||
name := f.Name()
|
||||
if _, err := f.WriteString(content); err != nil {
|
||||
f.Close()
|
||||
os.Remove(name)
|
||||
return "", err
|
||||
if _, err := os.Lstat(wantsLink); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(name)
|
||||
return "", err
|
||||
if err := b.run(ctx, "systemctl", "enable", "felhom-shared-parent.service"); err != nil {
|
||||
return fmt.Errorf("enable unit: %w", err)
|
||||
}
|
||||
return name, nil
|
||||
b.logger.Info("shared-parent: boot-persistence unit enabled (files from the config bundle)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// sharedParentInstallStale reports whether the on-disk boot script OR unit is missing or differs from
|
||||
@@ -162,37 +163,6 @@ func sharedParentInstallStale(unitPath, scriptPath string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// installSharedParentUnit writes the script + unit (from agent-written temps) and enables the unit so the
|
||||
// shared parent is re-established on every host boot before pve-guests. Idempotent. The temps are
|
||||
// RANDOM-named os.CreateTemp files (audit B1): a fixed, predictable /tmp name could be pre-created by
|
||||
// another local user and rewritten between our write and root's install (TOCTOU into a root-executed
|
||||
// boot script). The final modes come from `install -m`, so the 0600 temps are fine.
|
||||
func (b *GuestBinder) installSharedParentUnit(ctx context.Context) error {
|
||||
tmpScript, err := stageTemp("felhom-shared-parent-*.sh", sharedParentScript)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write temp script: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpScript)
|
||||
if err := b.run(ctx, "install", "-m", "0755", "--", tmpScript, sharedParentScriptPath); err != nil {
|
||||
return fmt.Errorf("install script: %w", err)
|
||||
}
|
||||
tmpUnit, err := stageTemp("felhom-shared-parent-*.service", sharedParentUnit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write temp unit: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpUnit)
|
||||
if err := b.run(ctx, "install", "-m", "0644", "--", tmpUnit, sharedParentUnitPath); err != nil {
|
||||
return fmt.Errorf("install unit: %w", err)
|
||||
}
|
||||
if err := b.run(ctx, "systemctl", "daemon-reload"); err != nil {
|
||||
return fmt.Errorf("daemon-reload: %w", err)
|
||||
}
|
||||
if err := b.run(ctx, "systemctl", "enable", "felhom-shared-parent.service"); err != nil {
|
||||
return fmt.Errorf("enable unit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttachDrive binds a drive's felhom-data namespace under the stable parent so it appears live in the
|
||||
// guest at the returned stable path (via propagation — no pct, no reboot). `where` is the drive's RAW
|
||||
// host PVE mount (/mnt/<name>); only `<where>/felhom-data` crosses into the guest (confinement). The
|
||||
|
||||
@@ -4,94 +4,82 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// stagingRecorderRunner is a fake proxmox.Runner recording every call vector and snapshotting each
|
||||
// install SOURCE file's content at call time (the deferred os.Remove erases it afterwards).
|
||||
type stagingRecorderRunner struct {
|
||||
calls [][]string
|
||||
srcContent map[string]string // install dest → staged source content
|
||||
}
|
||||
// R-861 (agent v0.146.0): the shared-parent boot script and unit arrive with the signed config bundle; the agent never
|
||||
// installs them. It checks them and enables the unit when nothing has.
|
||||
//
|
||||
// RED-PROOF (audits/hub-safety-2026-10-05/partF/red-proof.txt): put the old installSharedParentUnit call back (an
|
||||
// `install` of a /tmp file) → TestSharedParentBoot_NeverInstalls fails.
|
||||
|
||||
func (r *stagingRecorderRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||
type callRecorder struct{ calls [][]string }
|
||||
|
||||
func (r *callRecorder) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||
r.calls = append(r.calls, append([]string{name}, args...))
|
||||
if name == "install" && len(args) >= 2 {
|
||||
src, dest := args[len(args)-2], args[len(args)-1]
|
||||
if r.srcContent == nil {
|
||||
r.srcContent = map[string]string{}
|
||||
}
|
||||
b, _ := os.ReadFile(src)
|
||||
r.srcContent[dest] = string(b)
|
||||
}
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (r *stagingRecorderRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
||||
func (r *callRecorder) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
||||
return r.Run(ctx, name, args...)
|
||||
}
|
||||
|
||||
// installSources returns the install-call source paths keyed by destination.
|
||||
func (r *stagingRecorderRunner) installSources() map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for _, c := range r.calls {
|
||||
if c[0] == "install" && len(c) >= 3 {
|
||||
src, dest := c[len(c)-2], c[len(c)-1]
|
||||
out[dest] = append(out[dest], src)
|
||||
}
|
||||
func bootFiles(t *testing.T, script, unit string) (string, string, string) {
|
||||
t.Helper()
|
||||
d := t.TempDir()
|
||||
sp, up := filepath.Join(d, "felhom-shared-parent.sh"), filepath.Join(d, "felhom-shared-parent.service")
|
||||
if script != "" {
|
||||
_ = os.WriteFile(sp, []byte(script), 0o755)
|
||||
}
|
||||
return out
|
||||
if unit != "" {
|
||||
_ = os.WriteFile(up, []byte(unit), 0o644)
|
||||
}
|
||||
return sp, up, filepath.Join(d, "wants-link")
|
||||
}
|
||||
|
||||
// TestInstallSharedParent_RandomTempName is the audit-B1 negative test for the shared-parent boot
|
||||
// persistence install: both staged install SOURCES (script + unit) must be RANDOM os.CreateTemp names
|
||||
// (felhom-shared-parent-<random>.sh / .service), never the fixed, pre-creatable /tmp names (a local
|
||||
// TOCTOU into a root-executed boot script), and two consecutive installs must use DIFFERENT paths.
|
||||
func TestInstallSharedParent_RandomTempName(t *testing.T) {
|
||||
r := &stagingRecorderRunner{}
|
||||
func TestSharedParentBoot_NeverInstalls(t *testing.T) {
|
||||
for _, c := range []struct{ name, script, unit string }{
|
||||
{"both missing", "", ""},
|
||||
{"script differs", "#!/bin/sh\necho old\n", sharedParentUnit},
|
||||
{"unit missing", sharedParentScript, ""},
|
||||
} {
|
||||
r := &callRecorder{}
|
||||
b := NewGuestBinder(r, nil)
|
||||
sp, up, link := bootFiles(t, c.script, c.unit)
|
||||
if err := b.ensureSharedParentBoot(context.Background(), up, sp, link); err == nil {
|
||||
t.Errorf("%s: no error — the operator would not learn the bundle is missing", c.name)
|
||||
}
|
||||
if len(r.calls) != 0 {
|
||||
t.Errorf("%s: the agent ran %v — it must install nothing (R-861)", c.name, r.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedParentBoot_EnablesOnceTheBundleBroughtTheFiles(t *testing.T) {
|
||||
r := &callRecorder{}
|
||||
b := NewGuestBinder(r, nil)
|
||||
if err := b.installSharedParentUnit(context.Background()); err != nil {
|
||||
t.Fatalf("installSharedParentUnit #1: %v", err)
|
||||
sp, up, link := bootFiles(t, sharedParentScript, sharedParentUnit)
|
||||
if err := b.ensureSharedParentBoot(context.Background(), up, sp, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := b.installSharedParentUnit(context.Background()); err != nil {
|
||||
t.Fatalf("installSharedParentUnit #2: %v", err)
|
||||
if len(r.calls) != 1 || len(r.calls[0]) != 3 || r.calls[0][0] != "systemctl" || r.calls[0][1] != "enable" ||
|
||||
r.calls[0][2] != "felhom-shared-parent.service" {
|
||||
t.Fatalf("want exactly `systemctl enable felhom-shared-parent.service`, got %v", r.calls)
|
||||
}
|
||||
_ = os.Symlink(up, link)
|
||||
r.calls = nil
|
||||
if err := b.ensureSharedParentBoot(context.Background(), up, sp, link); err != nil || len(r.calls) != 0 {
|
||||
t.Fatalf("already enabled: want no calls, got %v (%v)", r.calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
srcs := r.installSources()
|
||||
cases := []struct {
|
||||
dest string
|
||||
random *regexp.Regexp
|
||||
fixed *regexp.Regexp
|
||||
content string
|
||||
}{
|
||||
{sharedParentScriptPath, regexp.MustCompile(`felhom-shared-parent-[^/\\]+\.sh$`),
|
||||
regexp.MustCompile(`felhom-shared-parent\.sh$`), sharedParentScript},
|
||||
{sharedParentUnitPath, regexp.MustCompile(`felhom-shared-parent-[^/\\]+\.service$`),
|
||||
regexp.MustCompile(`felhom-shared-parent\.service$`), sharedParentUnit},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := srcs[tc.dest]
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("dest %s: expected 2 install calls, got %d (%v)", tc.dest, len(got), got)
|
||||
}
|
||||
for i, src := range got {
|
||||
if !tc.random.MatchString(src) {
|
||||
t.Errorf("dest %s call %d: source %q does not match the random temp pattern", tc.dest, i, src)
|
||||
}
|
||||
if tc.fixed.MatchString(src) {
|
||||
t.Errorf("dest %s call %d: source %q is the FIXED predictable temp name (B1 TOCTOU)", tc.dest, i, src)
|
||||
}
|
||||
if _, err := os.Stat(src); err == nil {
|
||||
t.Errorf("dest %s: staged temp %q left behind (defer os.Remove missing)", tc.dest, src)
|
||||
}
|
||||
}
|
||||
if got[0] == got[1] {
|
||||
t.Errorf("dest %s: two consecutive installs staged through the SAME source %q — must be random per call", tc.dest, got[0])
|
||||
}
|
||||
// Non-hollow: the staged file carried the real content at install time.
|
||||
if r.srcContent[tc.dest] != tc.content {
|
||||
t.Errorf("dest %s: staged content mismatch (got %d bytes, want %d)", tc.dest, len(r.srcContent[tc.dest]), len(tc.content))
|
||||
// The bundle's copies are byte-identical to the constants the agent compares against.
|
||||
func TestSharedParentFilesEqualTheBundle(t *testing.T) {
|
||||
for file, want := range map[string]string{"felhom-shared-parent.sh": sharedParentScript, "felhom-shared-parent.service": sharedParentUnit} {
|
||||
got, err := os.ReadFile(filepath.Join("..", "..", "configs", file))
|
||||
if err != nil || string(got) != want {
|
||||
t.Errorf("configs/%s differs from the agent's constant (err %v)", file, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,7 +364,7 @@ func TestWrapperSuite(t *testing.T) {
|
||||
t.Skip("python3 not available")
|
||||
}
|
||||
// the OS wrapper and (agent v0.142.0) the crash guard — both root programs in configs/ with their own suites
|
||||
for _, suite := range []string{"../../configs/test_felhom_os_apply.py", "../../configs/test_felhom_crash_guard.py", "../../configs/test_felhom_config_bundle.py"} {
|
||||
for _, suite := range []string{"../../configs/test_felhom_os_apply.py", "../../configs/test_felhom_crash_guard.py", "../../configs/test_felhom_config_bundle.py", "../../configs/test_felhom_priv_apply.py"} {
|
||||
cmd := exec.Command(py, "-B", suite)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package privapplytest runs configs/felhom-priv-apply in CHECK-ONLY mode from Go tests (R-861): each renderer's
|
||||
// real output must be accepted by the root checker, so the two can never drift apart unnoticed. Test-only helper.
|
||||
package privapplytest
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Check writes content to a temp file and runs `felhom-priv-apply --check <verb> [name] <file>`. It returns the
|
||||
// checker's verdict line ("OK" or "REFUSED [rule] …"). Skips when python3 is absent.
|
||||
func Check(t *testing.T, verb, name, content string) string {
|
||||
t.Helper()
|
||||
py, err := exec.LookPath("python3")
|
||||
if err != nil {
|
||||
t.Skip("python3 not available")
|
||||
}
|
||||
_, here, _, _ := runtime.Caller(0)
|
||||
wrapper := filepath.Join(filepath.Dir(here), "..", "..", "configs", "felhom-priv-apply")
|
||||
f := filepath.Join(t.TempDir(), "staged")
|
||||
if err := os.WriteFile(f, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
args := []string{"-B", wrapper, "--check", verb}
|
||||
if name != "" {
|
||||
args = append(args, name)
|
||||
}
|
||||
out, _ := exec.Command(py, append(args, f)...).CombinedOutput()
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
@@ -186,8 +186,9 @@ func (b *BackHalf) Provision(ctx context.Context, in Input) (Result, error) {
|
||||
// 6. Install + register the pre-start self-heal hook (C1 net): if a data drive is absent at a future
|
||||
// boot, the hook creates a placeholder for its missing bind source so the guest still starts.
|
||||
// Best-effort + non-fatal — it's defense-in-depth; a provision must not fail over the hook.
|
||||
if err := guesthook.InstallSnippet(ctx, b.runner); err != nil {
|
||||
b.logger.Warn("provision: pre-start hook snippet install failed (non-fatal)", "vmid", in.VMID, "err", err)
|
||||
// R-861 (v0.146.0): the hook FILE comes with the signed config bundle; the agent only checks it and registers it.
|
||||
if err := guesthook.SnippetReady(guesthook.SnippetPath); err != nil {
|
||||
b.logger.Warn("provision: pre-start hook not in place — not registering it (non-fatal)", "vmid", in.VMID, "err", err)
|
||||
} else if err := guesthook.Register(ctx, b.runner, in.VMID); err != nil {
|
||||
b.logger.Warn("provision: pre-start hook registration failed (non-fatal)", "vmid", in.VMID, "err", err)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package selfupdate
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
|
||||
@@ -47,6 +49,7 @@ type Executor struct {
|
||||
username string
|
||||
token string
|
||||
stateDir string
|
||||
planDir string // felhom-os-apply's plan dir; "" = defaultPlanDir (tests set a temp dir)
|
||||
runner WrapperRunner
|
||||
httpClient *http.Client
|
||||
logger *slog.Logger
|
||||
@@ -126,18 +129,70 @@ func (e *Executor) Execute(ctx context.Context, op string, params json.RawMessag
|
||||
return fmt.Errorf("agent_update: chmod staged: %w", err)
|
||||
}
|
||||
|
||||
// Hand off to the root wrapper. It re-verifies the sha, flips A/B, writes the pending marker,
|
||||
// and schedules the detached restart. After this the new binary starts; the commit is the
|
||||
// Manager's job once it has dwelled cleanly.
|
||||
e.logger.Warn("agent_update: handing staged binary to the guarded wrapper", "staged", staged, "version", p.Version)
|
||||
stdout, stderr, err := e.runner.Run(ctx, wrapperPath, "apply", staged, p.SHA256)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agent_update: wrapper apply failed: %w (stderr: %s)", err, string(stderr))
|
||||
// R-861 (v0.146.0): hand the SIGNED job to the root wrapper felhom-os-apply (mode agent_update). It verifies the
|
||||
// operator's signature itself (root-owned signers file, this host, the window, the nonce), re-hashes the staged
|
||||
// file against the SIGNED sha, and only then runs the A/B flip (felhom-selfupdate-guarded apply, no longer in the
|
||||
// agent's sudoers). Until v0.146.0 the agent passed the sha to the flip itself, so a compromised agent could
|
||||
// install any binary — and the binary is what the escrow ceremony and the guest hook run as root.
|
||||
so, ok := signedjobs.SignedOpFrom(ctx)
|
||||
if !ok {
|
||||
return fmt.Errorf("agent_update: no signed envelope in the context — the root wrapper could not verify it")
|
||||
}
|
||||
e.logger.Warn("agent_update: apply handed off; restart scheduled", "version", p.Version, "wrapper", trim(stdout))
|
||||
planDir := e.planDir
|
||||
if planDir == "" {
|
||||
planDir = defaultPlanDir
|
||||
}
|
||||
if err := os.MkdirAll(planDir, 0o700); err != nil {
|
||||
return fmt.Errorf("agent_update: plan dir: %w", err)
|
||||
}
|
||||
plan, _ := json.Marshal(map[string]any{"release_id": "agent-" + p.Version, "layer": "host", "mode": "agent_update",
|
||||
"staged": staged, "signed": map[string]string{"blob_b64": base64.StdEncoding.EncodeToString(so.Blob), "sig": string(so.Sig)}})
|
||||
planPath := filepath.Join(planDir, "plan-agentupdate-"+p.Version+".json")
|
||||
if err := os.WriteFile(planPath, plan, 0o600); err != nil {
|
||||
return fmt.Errorf("agent_update: write plan: %w", err)
|
||||
}
|
||||
defer os.Remove(planPath)
|
||||
e.logger.Warn("agent_update: handing the signed job to the root wrapper (felhom-os-apply agent_update)", "staged", staged, "version", p.Version)
|
||||
stdout, stderr, err := e.runner.Run(ctx, osApplyPath, "--plan", planPath)
|
||||
rep := parseOSApplyReport(stdout)
|
||||
var r struct {
|
||||
Refused json.RawMessage `json:"refused"`
|
||||
Failed json.RawMessage `json:"failed"`
|
||||
AgentUpdate json.RawMessage `json:"agent_update"`
|
||||
}
|
||||
jerr := json.Unmarshal([]byte(rep), &r)
|
||||
refused := len(r.Refused) > 0 && string(r.Refused) != "null"
|
||||
if err != nil || jerr != nil || refused || len(r.Failed) > 0 || len(r.AgentUpdate) == 0 {
|
||||
return fmt.Errorf("agent_update: the root wrapper did not apply it: %v (report: %s; stderr: %s)", err, trimStr(rep), trim(stderr))
|
||||
}
|
||||
e.logger.Warn("agent_update: signed update verified as root and handed off; restart scheduled", "version", p.Version, "report", trimStr(rep))
|
||||
return nil
|
||||
}
|
||||
|
||||
// osApplyPath is the root wrapper that verifies the signed agent_update (R-861). Fixed, never config-overridable.
|
||||
const osApplyPath = "/usr/local/sbin/felhom-os-apply"
|
||||
|
||||
// defaultPlanDir is felhom-os-apply's ONLY plan directory (PLAN_DIR there; osupdate.DefaultPlanDir here).
|
||||
const defaultPlanDir = "/var/lib/felhom-agent/os"
|
||||
|
||||
// parseOSApplyReport returns the JSON after the wrapper's last "OSAPPLY-REPORT " line ("" when there is none).
|
||||
func parseOSApplyReport(stdout []byte) string {
|
||||
rep := ""
|
||||
for _, line := range strings.Split(string(stdout), "\n") {
|
||||
if strings.HasPrefix(line, "OSAPPLY-REPORT ") {
|
||||
rep = strings.TrimPrefix(line, "OSAPPLY-REPORT ")
|
||||
}
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
func trimStr(s string) string {
|
||||
if len(s) > 400 {
|
||||
return s[:400] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// download streams url → dest (0644, fsync'd) and returns the lowercase-hex sha256 of the bytes.
|
||||
func (e *Executor) download(ctx context.Context, url, dest string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
|
||||
@@ -15,34 +15,56 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
|
||||
)
|
||||
|
||||
// fakeWrapper records the verbs the executor/manager shell out, and returns a configurable error.
|
||||
// fakeWrapper records the verbs the executor/manager shell out, and returns a configurable error. For the os-apply
|
||||
// call it snapshots the plan file at call time (the executor removes it afterwards) and answers with report.
|
||||
type fakeWrapper struct {
|
||||
mu sync.Mutex
|
||||
calls [][]string
|
||||
err error
|
||||
mu sync.Mutex
|
||||
calls [][]string
|
||||
err error
|
||||
plans []map[string]any
|
||||
report string // the OSAPPLY-REPORT JSON; "" = a successful agent_update
|
||||
}
|
||||
|
||||
func (f *fakeWrapper) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, append([]string{name}, args...))
|
||||
if name == osApplyPath && len(args) == 2 && args[0] == "--plan" {
|
||||
var p map[string]any
|
||||
b, _ := os.ReadFile(args[1])
|
||||
_ = json.Unmarshal(b, &p)
|
||||
f.plans = append(f.plans, p)
|
||||
rep := f.report
|
||||
if rep == "" {
|
||||
rep = `{"agent_update": {"version": "x", "wrapper_rc": 0}, "mode": "agent_update", "refused": null}`
|
||||
}
|
||||
return []byte("OSAPPLY-REPORT " + rep + "\n"), nil, f.err
|
||||
}
|
||||
return []byte("ok"), nil, f.err
|
||||
}
|
||||
|
||||
// applyCalls are the hand-offs to the root wrapper (felhom-os-apply --plan …). Since v0.146.0 the agent never calls
|
||||
// `felhom-selfupdate-guarded apply` itself.
|
||||
func (f *fakeWrapper) applyCalls() [][]string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out [][]string
|
||||
for _, c := range f.calls {
|
||||
if len(c) >= 2 && c[1] == "apply" {
|
||||
if c[0] == osApplyPath || (len(c) >= 2 && c[1] == "apply") {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func signedCtx() context.Context {
|
||||
return signedjobs.WithSignedOp(context.Background(), &reconcile.SignedOp{Blob: []byte(`{"op":"agent_update"}`), Sig: []byte("SIG")})
|
||||
}
|
||||
|
||||
func sha256Of(b []byte) string {
|
||||
h := sha256.Sum256(b)
|
||||
return hex.EncodeToString(h[:])
|
||||
@@ -65,6 +87,7 @@ func newExec(t *testing.T, srv *httptest.Server, wrap WrapperRunner) (*Executor,
|
||||
Runner: wrap,
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
})
|
||||
e.planDir = t.TempDir()
|
||||
return e, stateDir
|
||||
}
|
||||
|
||||
@@ -84,7 +107,7 @@ func TestExecutor_HappyPath(t *testing.T) {
|
||||
e, stateDir := newExec(t, srv, wrap)
|
||||
|
||||
sha := sha256Of(body)
|
||||
if err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", sha)); err != nil {
|
||||
if err := e.Execute(signedCtx(), "agent_update", updateParamsJSON(t, "0.70.1", sha)); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
staged := filepath.Join(stateDir, "selfupdate", "felhom-agent-0.70.1")
|
||||
@@ -93,11 +116,46 @@ func TestExecutor_HappyPath(t *testing.T) {
|
||||
t.Fatalf("staged binary missing/mismatch: %v", err)
|
||||
}
|
||||
calls := wrap.applyCalls()
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("apply invoked %d times, want 1 (%v)", len(calls), wrap.calls)
|
||||
if len(calls) != 1 || calls[0][0] != osApplyPath || calls[0][1] != "--plan" {
|
||||
t.Fatalf("want exactly one hand-off to felhom-os-apply --plan, got %v", wrap.calls)
|
||||
}
|
||||
if calls[0][2] != staged || calls[0][3] != sha {
|
||||
t.Errorf("apply args = %v, want [.. apply %s %s]", calls[0], staged, sha)
|
||||
// R-861: the plan carries the SIGNED envelope and the staged path; the wrapper, not the agent, decides.
|
||||
p := wrap.plans[0]
|
||||
sg, _ := p["signed"].(map[string]any)
|
||||
if p["mode"] != "agent_update" || p["layer"] != "host" || p["staged"] != staged || sg["sig"] != "SIG" || sg["blob_b64"] == "" {
|
||||
t.Errorf("plan = %v, want mode agent_update + the staged path + the signed envelope", p)
|
||||
}
|
||||
if _, err := os.Stat(calls[0][2]); !os.IsNotExist(err) {
|
||||
t.Error("the plan file was left behind")
|
||||
}
|
||||
}
|
||||
|
||||
// R-861: without the signed envelope nothing reaches the root wrapper.
|
||||
// RED-PROOF (audits/hub-safety-2026-10-05/partF/red-proof.txt): call `felhom-selfupdate-guarded apply` directly again
|
||||
// → the happy path's "exactly one hand-off to felhom-os-apply" fails.
|
||||
func TestExecutor_NoEnvelopeNoHandOff(t *testing.T) {
|
||||
body := []byte("good bytes")
|
||||
srv := artifactServer(t, body)
|
||||
defer srv.Close()
|
||||
wrap := &fakeWrapper{}
|
||||
e, _ := newExec(t, srv, wrap)
|
||||
if err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", sha256Of(body))); err == nil {
|
||||
t.Fatal("an update with no signed envelope was handed on")
|
||||
}
|
||||
if len(wrap.applyCalls()) != 0 {
|
||||
t.Fatalf("the root wrapper was called without an envelope: %v", wrap.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal in the wrapper's report is a failure, even when its exit code reads 0.
|
||||
func TestExecutor_WrapperRefusalSurfaces(t *testing.T) {
|
||||
body := []byte("good bytes")
|
||||
srv := artifactServer(t, body)
|
||||
defer srv.Close()
|
||||
wrap := &fakeWrapper{report: `{"mode": "agent_update", "refused": {"code": "R3", "reason": "the operator signature does not verify"}}`}
|
||||
e, _ := newExec(t, srv, wrap)
|
||||
if err := e.Execute(signedCtx(), "agent_update", updateParamsJSON(t, "0.70.1", sha256Of(body))); err == nil {
|
||||
t.Fatal("a refused signed update read as applied")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +216,7 @@ func TestExecutor_WrapperFailureSurfaces(t *testing.T) {
|
||||
defer srv.Close()
|
||||
wrap := &fakeWrapper{err: errors.New("wrapper refused: sha mismatch")}
|
||||
e, _ := newExec(t, srv, wrap)
|
||||
if err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", sha256Of(body))); err == nil {
|
||||
if err := e.Execute(signedCtx(), "agent_update", updateParamsJSON(t, "0.70.1", sha256Of(body))); err == nil {
|
||||
t.Fatal("wrapper failure must surface")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,10 @@ type MountSpec struct {
|
||||
type Binaries struct {
|
||||
Systemctl string
|
||||
Install string
|
||||
// PrivApply is the root content checker (R-861, agent v0.146.0): a unit file reaches /etc/systemd/system only
|
||||
// through `felhom-priv-apply unit <name>`, which reads the staged copy from /var/lib/felhom-agent/units/ and
|
||||
// refuses anything the renderers here never produce (a bind over /etc, a Where outside /mnt, …).
|
||||
PrivApply string
|
||||
Smartctl string
|
||||
Lvs string
|
||||
Blkid string // device signature probe (8C data-bearing detection)
|
||||
@@ -119,6 +123,9 @@ func (b Binaries) withDefaults() Binaries {
|
||||
if b.Install == "" {
|
||||
b.Install = "/usr/bin/install"
|
||||
}
|
||||
if b.PrivApply == "" {
|
||||
b.PrivApply = "/usr/local/sbin/felhom-priv-apply"
|
||||
}
|
||||
if b.Smartctl == "" {
|
||||
b.Smartctl = "/usr/sbin/smartctl"
|
||||
}
|
||||
@@ -246,9 +253,9 @@ func (h *SudoHostOps) EnsureMount(ctx context.Context, spec MountSpec) error {
|
||||
return fmt.Errorf("storage: staging unit: %w", err)
|
||||
}
|
||||
|
||||
dest := filepath.Join(h.unitDir, unitName)
|
||||
// install as root (atomic copy with fixed mode/owner) — fixed arg vector.
|
||||
if err := h.run(ctx, h.bins.Install, "-o", "root", "-g", "root", "-m", "0644", "--", stagePath, dest); err != nil {
|
||||
// R-861: the root checker installs it (fixed source dir, fixed destination dir, content checked) — never a
|
||||
// plain `install` of a file the agent wrote.
|
||||
if err := h.run(ctx, h.bins.PrivApply, "unit", unitName); err != nil {
|
||||
return fmt.Errorf("storage: installing unit %s: %w", unitName, err)
|
||||
}
|
||||
if err := h.run(ctx, h.bins.Systemctl, "daemon-reload"); err != nil {
|
||||
|
||||
@@ -55,8 +55,9 @@ func TestHostOps_MountLifecycle(t *testing.T) {
|
||||
if len(rr.calls) != 3 {
|
||||
t.Fatalf("expected 3 commands, got %d: %v", len(rr.calls), rr.calls)
|
||||
}
|
||||
if rr.calls[0][0] != "/usr/bin/install" || !contains(rr.calls[0], "0644") {
|
||||
t.Errorf("call[0] not the install: %v", rr.calls[0])
|
||||
// R-861: the unit is installed by the root content checker, named — never a plain `install` of a staged path.
|
||||
if rr.calls[0][0] != "/usr/local/sbin/felhom-priv-apply" || len(rr.calls[0]) != 3 || rr.calls[0][1] != "unit" {
|
||||
t.Errorf("call[0] not the checker's unit install: %v", rr.calls[0])
|
||||
}
|
||||
if !contains(rr.calls[1], "daemon-reload") {
|
||||
t.Errorf("call[1] not daemon-reload: %v", rr.calls[1])
|
||||
|
||||
@@ -100,7 +100,7 @@ func TestMigrateNetworkUnits_RewritesDriftedOnceIdempotent(t *testing.T) {
|
||||
if strings.Contains(joined, "daemon-reload") {
|
||||
reloads++
|
||||
}
|
||||
if strings.Contains(joined, "install") {
|
||||
if strings.Contains(joined, "felhom-priv-apply unit") {
|
||||
installs++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,9 +236,13 @@ func (s NetworkMountSpec) mountOptions() string {
|
||||
"file_mode=0664",
|
||||
"dir_mode=0775",
|
||||
"_netdev",
|
||||
"nosuid",
|
||||
"nodev",
|
||||
}, ",")
|
||||
}
|
||||
return "vers=4.1,soft,timeo=50,retrans=2,noatime,_netdev,retry=0"
|
||||
// R-861 (v0.146.0): nosuid,nodev — a set-uid file or a device node on a server outside the box must never act on
|
||||
// the host. felhom-priv-apply refuses a network unit without them.
|
||||
return "vers=4.1,soft,timeo=50,retrans=2,noatime,_netdev,retry=0,nosuid,nodev"
|
||||
}
|
||||
|
||||
// renderNetworkMountUnit builds the .mount unit (triggered by the .automount; deliberately NO [Install]
|
||||
@@ -409,8 +413,8 @@ func (h *SudoHostOps) installUnit(ctx context.Context, unitName, content string)
|
||||
if err := os.WriteFile(stagePath, []byte(content), 0o644); err != nil {
|
||||
return fmt.Errorf("netmount: staging unit %s: %w", unitName, err)
|
||||
}
|
||||
dest := filepath.Join(h.unitDir, unitName)
|
||||
if err := h.run(ctx, h.bins.Install, "-o", "root", "-g", "root", "-m", "0644", "--", stagePath, dest); err != nil {
|
||||
// R-861: through the root checker (felhom-priv-apply unit), never a plain install of an agent-written file.
|
||||
if err := h.run(ctx, h.bins.PrivApply, "unit", unitName); err != nil {
|
||||
return fmt.Errorf("netmount: installing unit %s: %w", unitName, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -264,7 +264,7 @@ func TestEnsureNetworkMount_Commands(t *testing.T) {
|
||||
switch {
|
||||
case strings.Contains(joined, "mkdir") && strings.Contains(joined, "/mnt/felhom-drives/media"):
|
||||
sawMkdir = true
|
||||
case strings.Contains(joined, "install"):
|
||||
case strings.Contains(joined, "felhom-priv-apply unit"):
|
||||
installs++
|
||||
case strings.Contains(joined, "daemon-reload"):
|
||||
sawReload = true
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/privapplytest"
|
||||
)
|
||||
|
||||
// R-861: every unit the agent renders is one the root checker installs — the name it computes, the Where, the
|
||||
// options. RED-PROOF: drop "nosuid","nodev" from mountOptions → the network cases are REFUSED [U5].
|
||||
func TestPrivApply_AcceptsTheRenderedUnits(t *testing.T) {
|
||||
local := MountSpec{Name: "x", UUID: "91d2dc2d-2d28-4929-9bdd-3e11fa2f41ae", Where: "/mnt/hdd_1", FSType: "ext4"}
|
||||
name, _ := UnitNameForMount(local.Where)
|
||||
if got := privapplytest.Check(t, "unit", name, renderMountUnit(local)); got != "OK" {
|
||||
t.Errorf("local unit %s: %s", name, got)
|
||||
}
|
||||
local.FSType = ""
|
||||
if got := privapplytest.Check(t, "unit", name, renderMountUnit(local)); got != "OK" {
|
||||
t.Errorf("local unit without Type: %s", got)
|
||||
}
|
||||
for _, spec := range []NetworkMountSpec{
|
||||
{Name: "media", Protocol: ProtocolNFS, Server: "10.0.0.5", Export: "/srv/media", MappedUID: 1000, MappedGID: 1000},
|
||||
{Name: "photos", Protocol: ProtocolSMB, Server: "nas.lan", Export: "photos", CredsRef: "/etc/felhom/netmount/photos.cred", MappedUID: 1000, MappedGID: 1000},
|
||||
} {
|
||||
mu, err := UnitNameForMount(spec.Where())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := privapplytest.Check(t, "unit", mu, renderNetworkMountUnit(spec)); got != "OK" {
|
||||
t.Errorf("%s .mount: %s", spec.Name, got)
|
||||
}
|
||||
au := strings.TrimSuffix(mu, ".mount") + ".automount"
|
||||
if got := privapplytest.Check(t, "unit", au, renderNetworkAutomountUnit(spec)); got != "OK" {
|
||||
t.Errorf("%s .automount: %s", spec.Name, got)
|
||||
}
|
||||
}
|
||||
// control: the checker is really looking — a unit over /etc is refused
|
||||
evil := strings.Replace(renderMountUnit(MountSpec{UUID: local.UUID, Where: "/mnt/hdd_1"}), "Where=/mnt/hdd_1", "Where=/etc/sudoers.d", 1)
|
||||
if got := privapplytest.Check(t, "unit", name, evil); !strings.HasPrefix(got, "REFUSED") {
|
||||
t.Fatalf("control: a unit over /etc/sudoers.d was not refused: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ const (
|
||||
Iface = "wg-felhom"
|
||||
// confDest is the installed conf path — must match the FELHOM_WG sudoers entry EXACTLY.
|
||||
confDest = "/etc/wireguard/wg-felhom.conf"
|
||||
// privApply is the root content checker that installs confDest (R-861).
|
||||
privApply = "/usr/local/sbin/felhom-priv-apply"
|
||||
// unit is the systemd unit the sudoers allowlists.
|
||||
unit = "wg-quick@wg-felhom"
|
||||
|
||||
@@ -507,8 +509,9 @@ func (m *Manager) ensureTunnelLocked(ctx context.Context, block *hub.WireWiregua
|
||||
m.logger.Error("wgtunnel: staging conf", "err", err)
|
||||
return
|
||||
}
|
||||
// argv matches the FELHOM_WG sudoers entry exactly (fixed source + dest).
|
||||
if _, errOut, err := m.runner.Run(ctx, "install", "-o", "root", "-g", "root", "-m", "0600", "--", m.stagedConfPath(), confDest); err != nil {
|
||||
// R-861 (v0.146.0): the root checker installs the staged conf (fixed source /var/lib/felhom-agent/wg/, fixed
|
||||
// destination, 0600) and refuses any key renderConf never writes — PostUp/PreUp run as root under wg-quick.
|
||||
if _, errOut, err := m.runner.Run(ctx, privApply, "wg"); err != nil {
|
||||
m.logger.Error("wgtunnel: conf install failed", "err", err, "stderr", strings.TrimSpace(string(errOut)))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -286,7 +286,7 @@ func TestScenarioA_RegisterThenApplyOrdering(t *testing.T) {
|
||||
// Block arrives → conf staged + installed + enabled.
|
||||
pub := localPub(t, m)
|
||||
m.Apply(ctx, true, testBlock(pub))
|
||||
if rr.count("install") != 1 || rr.count("systemctl") != 1 {
|
||||
if rr.count(privApply) != 1 || rr.count("systemctl") != 1 {
|
||||
t.Fatalf("apply execs = %v", rr.calls)
|
||||
}
|
||||
if got := rr.lastSystemctl(); strings.Join(got, " ") != "systemctl enable --now wg-quick@wg-felhom" {
|
||||
@@ -419,7 +419,7 @@ func TestAdoptLostMarkerWithKnownKey(t *testing.T) {
|
||||
if mk == nil || mk.Pubkey != pub || mk.AssignedIP != "10.77.0.2/32" {
|
||||
t.Fatalf("adoption marker = %+v", mk)
|
||||
}
|
||||
if rr.count("install") != 1 {
|
||||
if rr.count(privApply) != 1 {
|
||||
t.Errorf("adopt did not proceed to conf apply: %v", rr.calls)
|
||||
}
|
||||
}
|
||||
@@ -686,13 +686,13 @@ func TestInitialResolveFailureNoTeardown(t *testing.T) {
|
||||
m.Apply(ctx, false, nil) // register (no DNS)
|
||||
pub := localPub(t, m)
|
||||
m.Apply(ctx, true, testBlock(pub)) // resolve fails → cannot apply
|
||||
if rr.count("install") != 0 || rr.count("systemctl") != 0 {
|
||||
if rr.count(privApply) != 0 || rr.count("systemctl") != 0 {
|
||||
t.Errorf("applied/tore-down despite a resolve failure: %v", rr.calls)
|
||||
}
|
||||
// DNS returns → the tunnel comes up on the next tick.
|
||||
fr.set(netip.MustParseAddr("167.233.158.164"))
|
||||
m.Apply(ctx, true, testBlock(pub))
|
||||
if rr.count("install") != 1 {
|
||||
if rr.count(privApply) != 1 {
|
||||
t.Errorf("did not recover after DNS returned: %v", rr.calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package wgtunnel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/privapplytest"
|
||||
)
|
||||
|
||||
// R-861: the conf renderConf writes (with and without the operator OOB peer) is accepted; a PostUp line is not.
|
||||
func TestPrivApply_AcceptsTheRenderedConf(t *testing.T) {
|
||||
priv := "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||
b := testBlock("CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=")
|
||||
for _, oob := range []string{"", "10.77.0.250"} {
|
||||
b.OOBPeerIP = oob
|
||||
conf, err := renderConf(b, priv, "49.12.1.2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := privapplytest.Check(t, "wg", "", conf); got != "OK" {
|
||||
t.Errorf("rendered conf (oob=%q): %s", oob, got)
|
||||
}
|
||||
if got := privapplytest.Check(t, "wg", "", strings.Replace(conf, "MTU = 1280", "MTU = 1280\nPostUp = id", 1)); !strings.HasPrefix(got, "REFUSED") {
|
||||
t.Fatalf("control: PostUp was not refused: %s", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user