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>
179 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|