package apply import ( "context" "errors" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/novox/mesh-host/internal/declaration" "github.com/novox/mesh-host/internal/store" ) // Defends novox/hq ADR 0100: the firewall found on an adopted node stays in force; converging the // node retires it by disabling it, and returning the node to adopted enables it again. type ufwMachine struct { installed, active bool rules []string ruleset string asked []string } func (u *ufwMachine) run(_ context.Context, name string, args ...string) (string, error) { u.asked = append(u.asked, name+" "+strings.Join(args, " ")) switch name { case "nft": return u.ruleset, nil case "ufw": if !u.installed { return "", &exec.Error{Name: name, Err: exec.ErrNotFound} } default: return "", &exec.Error{Name: name, Err: exec.ErrNotFound} } switch args[0] { case "status": if u.active { return "Status: active\n", nil } return "Status: inactive\n", nil case "show": out := "Added user rules (see 'ufw status' for running firewall):\n" for _, r := range u.rules { out += "ufw " + r + "\n" } return out, nil case "--force": u.active = true return "", nil case "disable": u.active = false return "", nil case "delete": want := strings.Join(args[1:], " ") for i, r := range u.rules { if strings.ReplaceAll(r, "'", "") == want { u.rules = append(u.rules[:i], u.rules[i+1:]...) return "", nil } } return "", errors.New("Could not delete non-existent rule") default: // Printed back the way it was given, with the comment quoted as ufw does. line := strings.Join(args[:len(args)-1], " ") + " '" + args[len(args)-1] + "'" u.rules = append(u.rules, line) return "", nil } } func (u *ufwMachine) index(prefix string) int { for i, a := range u.asked { if strings.HasPrefix(a, prefix) { return i } } return -1 } const busOpening = `{"id":"adoption.opening-tcp-5671-incoming","type":"opening","port":5671,"protocol":"tcp","from":"everywhere","path":"incoming"}` func withConf(dir string) string { return `{"id":"x.conf","type":"file","path":"` + filepath.Join(dir, "x.conf") + `","content":"x\n"}` } func applyWith(t *testing.T, d *declaration.Declaration, known store.State, run Runner) (Report, store.State, error) { t.Helper() return ApplyKeeping(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil, KeepIn(t.TempDir())) } func TestAnOpeningOnAMachineWithNoFirewallChangesNothing(t *testing.T) { dir := t.TempDir() u := &ufwMachine{} report, state, err := applyWith(t, adopted(t, `{"taken":[]}`, busOpening+","+withConf(dir)), store.State{}, u.run) if err != nil { t.Fatal(err) } o := outcomeOf(report, "adoption.opening-tcp-5671-incoming") if o.Action != "unchanged" || !strings.Contains(o.Detail, "nothing filters this port") { t.Errorf("an opening with no firewall: %+v", o) } if state.Firewall == nil || state.Firewall.Kind != "none" { t.Errorf("the firewall found was not recorded: %+v", state.Firewall) } } func TestAnUnsupportedFirewallRefusesTheWholeDeclaration(t *testing.T) { dir := t.TempDir() u := &ufwMachine{ruleset: "table inet filter {\n\tchain input {\n\t\ttype filter hook input priority filter; policy drop;\n\t}\n}\n"} _, state, err := applyWith(t, adopted(t, `{"taken":[]}`, busOpening+","+withConf(dir)), store.State{}, u.run) if err == nil || !strings.Contains(err.Error(), "no host speaks that firewall") { t.Fatalf("an unsupported firewall was not refused: %v", err) } if _, statErr := os.Stat(filepath.Join(dir, "x.conf")); !errors.Is(statErr, os.ErrNotExist) { t.Error("part of a refused declaration was applied") } if state.Firewall != nil { t.Errorf("an unsupported firewall was recorded: %+v", state.Firewall) } } func TestConvergingRetiresTheFoundFirewallAndReturningRestoresIt(t *testing.T) { dir := t.TempDir() u := &ufwMachine{installed: true, active: true, rules: []string{"allow 22/tcp"}} // Adopted: the opening goes through ufw. _, state, err := applyWith(t, adopted(t, `{"taken":[]}`, busOpening+","+withConf(dir)), store.State{}, u.run) if err != nil { t.Fatal(err) } if len(u.rules) != 2 || !u.active { t.Fatalf("adopted: rules %v, active %v", u.rules, u.active) } if state.Firewall == nil || state.Firewall.Kind != "ufw" || !state.Firewall.WasActive { t.Fatalf("adopted: firewall recorded as %+v", state.Firewall) } // Converged: the opening's rule goes, and only then is ufw disabled — never reset. u.asked = nil converged := parse(t, `{"declaration":1,"resources":[`+withConf(dir)+`]}`) _, state, err = applyWith(t, converged, state, u.run) if err != nil { t.Fatal(err) } if u.active || !state.Firewall.DisabledByMesh { t.Fatalf("converged: ufw still active (%v) or not recorded as retired (%+v)", u.active, state.Firewall) } if len(u.rules) != 1 || u.rules[0] != "allow 22/tcp" { t.Errorf("converged: the operator's rules were touched, or the mesh's left: %v", u.rules) } // What protected the adopted node goes last: after the derived filter applied and ufw was // retired (novox/hq ADR 0103). if del, dis := u.index("ufw delete"), u.index("ufw disable"); dis < 0 || del < dis { t.Errorf("converged: the opening was removed before ufw was retired: %v", u.asked) } for _, a := range u.asked { if strings.Contains(a, "reset") { t.Errorf("converged: ufw was reset: %s", a) } } // Converged again: nothing more to retire. u.asked = nil if _, state, err = applyWith(t, converged, state, u.run); err != nil { t.Fatal(err) } if u.index("ufw") >= 0 { t.Errorf("a converged node kept talking to a retired ufw: %v", u.asked) } // Returned to adopted: ufw is enabled before the opening is converged through it. u.asked = nil _, state, err = applyWith(t, adopted(t, `{"taken":[]}`, busOpening+","+withConf(dir)), state, u.run) if err != nil { t.Fatal(err) } if !u.active || state.Firewall.DisabledByMesh { t.Fatalf("returned: ufw active %v, record %+v", u.active, state.Firewall) } if en, add := u.index("ufw --force enable"), u.index("ufw allow"); en < 0 || add < en { t.Errorf("returned: ufw was not enabled before the opening was added: %v", u.asked) } if len(u.rules) != 2 { t.Errorf("returned: the opening was not converged again: %v", u.rules) } } func TestAConvergedNodeThatWasNeverAdoptedNeverAsksAboutAFirewall(t *testing.T) { dir := t.TempDir() u := &ufwMachine{installed: true, active: true} if _, _, err := applyWith(t, parse(t, `{"declaration":1,"resources":[`+withConf(dir)+`]}`), store.State{}, u.run); err != nil { t.Fatal(err) } if len(u.asked) != 0 { t.Errorf("a converged apply asked the machine about its firewall: %v", u.asked) } } func TestAnOpeningOnAConvergedNodeIsRefused(t *testing.T) { if _, err := declaration.Parse([]byte(`{"declaration":1,"resources":[` + busOpening + `]}`)); err == nil { t.Error("an opening was accepted on a node the declaration does not say is adopted") } } func TestACarriedApplyOnAnAdoptedNodeLeavesItsFirewallInForce(t *testing.T) { // The bundle, re-applied by the installer or the one-shot CLI, never says a node is adopted. // That is not the controller converging it, so ufw must stay enabled (novox/hq ADR 0100). dir := t.TempDir() u := &ufwMachine{installed: true, active: true, rules: []string{"allow 22/tcp"}} _, state, err := applyWith(t, adopted(t, `{"taken":[]}`, busOpening+","+withConf(dir)), store.State{}, u.run) if err != nil { t.Fatal(err) } u.asked = nil carried := parse(t, `{"declaration":1,"resources":[`+withConf(filepath.Join(dir, "bundle"))+`]}`) _, state, err = ApplyKeeping(context.Background(), archHost(t), carried, state, store.OriginCarried, u.run, nil, nil, nil) if err != nil { t.Fatal(err) } if !u.active || state.Firewall.DisabledByMesh || u.index("ufw disable") >= 0 { t.Fatalf("a carried apply retired the found firewall: active %v, record %+v, asked %v", u.active, state.Firewall, u.asked) } } func TestAFlipThatFailsKeepsTheGuardAndTheOpenings(t *testing.T) { // Converging a node removes its openings and its guard only once everything else applied and // the found firewall is retired. A flip that fails part-way keeps them, so the store is never // left unguarded behind a filter that did not load (novox/hq ADR 0103). dir := t.TempDir() guard := filepath.Join(dir, "guard.nft") guardFile := `{"id":"adoption.guard","type":"file","path":"` + guard + `","content":"table inet mesh_guard {}\n"}` u := &ufwMachine{installed: true, active: true, rules: []string{"allow 22/tcp"}} _, state, err := applyWith(t, adopted(t, `{"taken":[]}`, busOpening+","+guardFile+","+withConf(dir)), store.State{}, u.run) if err != nil { t.Fatal(err) } // The derived filter cannot be written: its path is under a file. blocked := filepath.Join(dir, "not-a-directory") if err := os.WriteFile(blocked, []byte("x"), 0o644); err != nil { t.Fatal(err) } filter := `{"id":"nftables.config","type":"file","path":"` + filepath.Join(blocked, "nftables.conf") + `","content":"table inet mesh {}\n"}` converged := parse(t, `{"declaration":1,"resources":[`+withConf(dir)+`,`+filter+`]}`) u.asked = nil _, state, err = applyWith(t, converged, state, u.run) if err == nil { t.Fatal("the failing flip reported success") } if !u.active || u.index("ufw disable") >= 0 { t.Errorf("the found firewall was retired by a flip that failed: %v", u.asked) } if len(u.rules) != 2 || u.index("ufw delete") >= 0 { t.Errorf("the opening was removed by a flip that failed: %v", u.rules) } if _, statErr := os.Stat(guard); statErr != nil { t.Errorf("the guard was removed by a flip that failed: %v", statErr) } for _, id := range []string{"adoption.guard", "adoption.opening-tcp-5671-incoming"} { if _, ok := state.Find(id); !ok { t.Errorf("%s was forgotten, so the next flip would never remove it", id) } } // Fixed, the next flip completes: filter, retire, and only then the guard and the openings. if err := os.Remove(blocked); err != nil { t.Fatal(err) } u.asked = nil _, state, err = applyWith(t, converged, state, u.run) if err != nil { t.Fatal(err) } if u.active || len(u.rules) != 1 { t.Errorf("the completed flip left ufw active %v, rules %v", u.active, u.rules) } if _, statErr := os.Stat(guard); !errors.Is(statErr, os.ErrNotExist) { t.Errorf("the guard outlived the completed flip: %v", statErr) } if _, ok := state.Find("adoption.guard"); ok { t.Error("the guard is still recorded after the completed flip") } }