Files
shater/shater/netplane/armor_test.go
T
omarandClaude Opus 5 cbda0fee0a fix(netplane): arm the fail-closed plane before the daemon can
The plane only ever existed while the daemon did. It starts at 99, after fw4 has
already loaded lan→wan ACCEPT, and only reaches ArmHold after waiting out its
predecessor, migrating the schema, building the engine and reading UCI — with a
UPX-compressed binary decompressing off flash first. Every boot therefore had a
window with no protection at all, landing exactly when Wi-Fi comes up and every
client reconnects. A restart, a reload or a package upgrade opened the same
window on purpose: Teardown does not consult the kill switch, and the init script
guarantees the interval is non-empty.

The holding plane is now persisted to /etc/shater/boot.nft on every apply and
loaded by a small service at 21, right after fw4 and netifd. Its presence is the
arm token: it exists only while the last applied config was enabled AND
fail-closed, and goes away the moment either stops being true. Writes are
content-gated — the cron reconcile runs a minute — and atomic, because the one
boot that reads this file is the boot after a power cut.

The service refuses to arm four ways so it can never brick a box, and its
enabled-check reads /etc/rc.d directly rather than asking rc.common, which would
take a blocking flock in the middle of boot. On exit the daemon re-arms only for
restart and reload, read from a snapshot of rc.common's action; anything else,
including an unknown one, degrades to a real stop that also disarms.

An unreadable config used to leave the router bare forever: the arm call sat in
the branch that requires a successful read, and nothing downstream could recover
it. It now arms from the same path.

A network nobody named was neither diverted nor blocked — the divert set is built
from inbounds and rule sources, and the same set scopes the fail-closed drops. It
is now enumerated from the interfaces whose firewall zone the operator forwards
to a WAN zone — their own statement that those clients reach the internet through
this box — and reported critically, by name, with both resolutions. Deliberately
not closed automatically: this router cannot know a guest SSID was meant to be
off the tunnel, and guessing is an outage. A device name that resolved to nothing
is reported the same way, for the same reason: there is no fail-closed action
available for a device we cannot name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:39:16 +03:00

179 lines
5.8 KiB
Go

package netplane
// The BOOT ARMOR's storage contract. It is the artifact three separate windows
// depend on (early boot, restart handoff, unreadable config), so the properties
// that matter are the durability ones: it must survive, it must not wear flash
// out, and a power cut must never leave a half-written ruleset behind for the one
// boot that reads it.
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/sagernet/sing-box/shater/model"
)
func armorTempPath(t *testing.T) string {
t.Helper()
p := filepath.Join(t.TempDir(), "shater", "boot.nft")
orig := BootArmorPath
BootArmorPath = p
t.Cleanup(func() { BootArmorPath = orig })
return p
}
// TestBootArmorSaveIsContentGated: the daemon calls this on every apply and cron
// reconciles once a minute, so an unconditional write is ~525 000 rewrites a year
// of an identical file onto raw flash. An unchanged ruleset must not touch the
// file at all.
func TestBootArmorSaveIsContentGated(t *testing.T) {
path := armorTempPath(t)
if BootArmorPresent() {
t.Fatalf("BootArmorPresent must be false before anything is written")
}
changed, err := SaveBootArmor("table inet shater { }\n")
if err != nil || !changed {
t.Fatalf("first SaveBootArmor = (%v, %v), want (true, nil)", changed, err)
}
if !BootArmorPresent() {
t.Fatalf("BootArmorPresent must be true after a save")
}
st1, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
changed, err = SaveBootArmor("table inet shater { }\n")
if err != nil || changed {
t.Fatalf("identical SaveBootArmor = (%v, %v), want (false, nil) — an unchanged "+
"armor must not be rewritten", changed, err)
}
st2, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
if !st1.ModTime().Equal(st2.ModTime()) {
t.Errorf("the file was rewritten for identical content (mtime %v -> %v)",
st1.ModTime(), st2.ModTime())
}
if changed, err = SaveBootArmor("table inet shater { chain forward { } }\n"); err != nil || !changed {
t.Fatalf("changed SaveBootArmor = (%v, %v), want (true, nil)", changed, err)
}
b, err := os.ReadFile(path)
if err != nil || !strings.Contains(string(b), "chain forward") {
t.Fatalf("file does not hold the new ruleset: %q (%v)", string(b), err)
}
// An empty armor is worse than none: shater-armor would load a file that
// blocks nothing while the log claims the LAN is protected.
if _, err := SaveBootArmor(" \n"); err == nil {
t.Errorf("SaveBootArmor must refuse an empty ruleset")
}
if err := RemoveBootArmor(); err != nil {
t.Fatalf("RemoveBootArmor: %v", err)
}
if BootArmorPresent() {
t.Fatalf("BootArmorPresent must be false after removal")
}
// Removing what is not there is the normal case (every apply of a disabled
// stack), not a fault.
if err := RemoveBootArmor(); err != nil {
t.Errorf("RemoveBootArmor on an absent file = %v, want nil", err)
}
}
// TestBootArmorSaveIsAtomic: nothing but the finished file may ever be visible at
// BootArmorPath. The one boot that reads it is the boot after a power cut.
func TestBootArmorSaveIsAtomic(t *testing.T) {
path := armorTempPath(t)
if _, err := SaveBootArmor("table inet shater { }\n"); err != nil {
t.Fatalf("SaveBootArmor: %v", err)
}
entries, err := os.ReadDir(filepath.Dir(path))
if err != nil {
t.Fatalf("readdir: %v", err)
}
if len(entries) != 1 || entries[0].Name() != filepath.Base(path) {
var names []string
for _, e := range entries {
names = append(names, e.Name())
}
t.Errorf("the save left temp files behind: %v", names)
}
}
// TestLoadBootArmorFeedsNft proves the reinstate path actually reaches the kernel
// through the SAME validated loader every other ruleset uses (nft -c, then nft
// -f), rather than a bare `nft -f <file>` that could half-load a truncated
// snapshot.
func TestLoadBootArmorFeedsNft(t *testing.T) {
armorTempPath(t)
// Nothing saved: not an error, just nothing to do.
loaded, err := LoadBootArmor()
if err != nil || loaded {
t.Fatalf("LoadBootArmor with no armor = (%v, %v), want (false, nil)", loaded, err)
}
const ruleset = "table inet shater { chain forward { } }\n"
if _, err := SaveBootArmor(ruleset); err != nil {
t.Fatalf("SaveBootArmor: %v", err)
}
var rec []string
orig := execCommand
defer func() { execCommand = orig }()
execCommand = func(name string, arg ...string) *exec.Cmd {
rec = append(rec, strings.Join(append([]string{name}, arg...), " "))
cs := append([]string{"-test.run=TestNetplaneHelperProcess", "--", name}, arg...)
cmd := exec.Command(os.Args[0], cs...)
cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1")
return cmd
}
loaded, err = LoadBootArmor()
if err != nil || !loaded {
t.Fatalf("LoadBootArmor = (%v, %v), want (true, nil)", loaded, err)
}
var sawCheck, sawLoad bool
for _, c := range rec {
switch c {
case "nft -c -f -":
sawCheck = true
case "nft -f -":
sawLoad = true
}
}
if !sawCheck || !sawLoad {
t.Errorf("LoadBootArmor must VALIDATE then load (nft -c -f -, nft -f -); commands: %v", rec)
}
}
// TestKillSwitchClosed pins that the daemon's arming decision and the renderer's
// drop decision come from one predicate. Two copies of "is the kill switch
// closed" is how an armed boot ends up protecting a router the operator asked to
// fail open (or the reverse).
func TestKillSwitchClosed(t *testing.T) {
cases := []struct {
val string
want bool
}{
{"", true}, {"closed", true}, {"CLOSED", true}, {"nonsense", true},
{"open", false}, {"OPEN", false}, {" open ", false},
}
for _, c := range cases {
if got := KillSwitchClosed(model.Globals{KillSwitch: c.val}); got != c.want {
t.Errorf("KillSwitchClosed(%q) = %v, want %v", c.val, got, c.want)
}
if got := genGlobalClosed(model.Globals{KillSwitch: c.val}); got != c.want {
t.Errorf("genGlobalClosed(%q) = %v, want %v — the two must not diverge", c.val, got, c.want)
}
}
}