diff --git a/cmd/mesh-controller/adopting_test.go b/cmd/mesh-controller/adopting_test.go index 88443fd..73c94aa 100644 --- a/cmd/mesh-controller/adopting_test.go +++ b/cmd/mesh-controller/adopting_test.go @@ -95,11 +95,22 @@ func reportsReaching(t *testing.T, open *stores, reachable []link.Reach, held .. // filter is not sent to one that has not. The anchor reports one, as a real host does; this // fixture lacked it from 2026-09-28 and nothing ran the test (issue 177). Outward: []string{"eth0"}, + // And what filters it (ADR 0168): its front end, the runtime's own, and a chain a + // predecessor left in the runtime's user chain. + Filters: anchorFilters, }); err != nil { t.Fatal(err) } } +// anchorFilters is what the adopted anchor says filters it: ufw's chains, the runtime's, and a +// predecessor's chain the mesh did not write. +var anchorFilters = []link.Filter{ + {Where: "table ip filter, chain ufw-reject-input", Owner: "found-firewall", Refuses: "reject"}, + {Where: "table ip filter, chain DOCKER", Owner: "runtime", Refuses: `iifname != "docker0" oifname "docker0" drop`}, + {Where: "table ip filter, chain DOCKER-USER", Owner: "other", Refuses: `iifname "eth0" tcp dport 6000 drop`}, +} + var ( heldContainer = link.Held{ID: "hello-web.server", Module: "hello-web", Kind: "container", Target: "hello-web", Since: time.Now()} @@ -264,6 +275,20 @@ func TestConvergingPreviewsThenChangesAndAdoptingKeepsWhatWasTaken(t *testing.T) if strings.Contains(preview, "15672") { t.Errorf("a loopback listener is in the preview:\n%s", preview) } + // What filters the machine now, and the fate of each (novox/hq ADR 0168): the predecessor's + // chain is named as not the mesh's and left, so the reader knows before the flip. + for _, want := range []string{ + "table ip filter, chain DOCKER-USER", + "NOT THE MESH'S; left in force", + `iifname "eth0" tcp dport 6000 drop`, + "table ip filter, chain ufw-reject-input", + "the found firewall's; retired with it", + "the container runtime's own; left", + } { + if !strings.Contains(preview, want) { + t.Errorf("the preview does not say %q:\n%s", want, preview) + } + } for _, line := range strings.Split(preview, "\n") { if strings.Contains(line, "5000") && !strings.Contains(line, "WILL CLOSE") { t.Errorf("an undeclared published port is not said to close: %s", line) diff --git a/cmd/mesh-controller/adoption.go b/cmd/mesh-controller/adoption.go index de35605..fc88e59 100644 --- a/cmd/mesh-controller/adoption.go +++ b/cmd/mesh-controller/adoption.go @@ -29,6 +29,10 @@ func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node if said, err := inv.AdoptionOf(ctx, node.Name); err == nil && len(said.Strays) > 0 { showStrays(said.Strays) } + // And what filters it, truthfully (novox/hq ADR 0168): the mesh alone, or not. + if filtering, err := inv.FilteringOf(ctx, node.Name); err == nil { + showFiltering(filtering, false) + } return nil } fmt.Printf(" mode adopted since %s\n", @@ -72,10 +76,63 @@ func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node } } showStrays(said.Strays) + if filtering, err := inv.FilteringOf(ctx, node.Name); err == nil { + showFiltering(filtering, true) + } fmt.Printf(" as of %s\n", said.At.Local().Format(time.DateTime)) return nil } +// showFiltering says what filters a machine, with owners (novox/hq ADR 0168), and for a converged +// machine the state of the firewall it was found with. A machine that has not said is not said to +// be filtered by anything. +func showFiltering(f inventory.Filtering, adopted bool) { + if len(f.Filters) == 0 && f.FoundFirewall == nil { + return + } + if fw := f.FoundFirewall; fw != nil && !adopted { + switch { + case fw.Active: + fmt.Printf(" found firewall %s is ACTIVE on this converged machine; the next apply retires it again\n", fw.Kind) + case fw.RetiredBy == inventory.FilterMesh || fw.RetiredBy == "mesh": + fmt.Printf(" found firewall %s, retired by the mesh; its configuration stays on disk\n", fw.Kind) + case fw.RetiredBy != "": + fmt.Printf(" found firewall %s, found inactive — not by the mesh\n", fw.Kind) + default: + fmt.Printf(" found firewall %s, inactive\n", fw.Kind) + } + } + if len(f.Filters) == 0 { + return + } + if f.Alone() { + fmt.Printf(" filtered by the mesh alone (%s)\n", filterSummary(f.Filters)) + return + } + fmt.Printf(" filtered by NOT the mesh alone: %d rule set(s) the mesh did not write refuse traffic here\n", len(f.Others())) + for _, x := range f.Filters { + if x.Owner == inventory.FilterOther || x.Owner == inventory.FilterFoundFirewall { + fmt.Printf(" %-17s %s — %s: %s\n", "", x.Where, x.Owner, x.Refuses) + } + } + fmt.Printf(" %-17s and its own: %s\n", "", filterSummary(f.Filters)) +} + +// filterSummary counts a machine's filters by owner: "mesh 2, runtime 3, ban 1". +func filterSummary(filters []inventory.Filter) string { + counts := map[string]int{} + for _, x := range filters { + counts[x.Owner]++ + } + var parts []string + for _, owner := range []string{inventory.FilterMesh, inventory.FilterRuntime, inventory.FilterBan, inventory.FilterFoundFirewall, inventory.FilterOther} { + if n := counts[owner]; n > 0 { + parts = append(parts, fmt.Sprintf("%s %d", owner, n)) + } + } + return strings.Join(parts, ", ") +} + // showStrays says what a machine runs that the mesh neither wrote nor holds (ADR 0163). func showStrays(strays []inventory.Stray) { if len(strays) == 0 { @@ -661,7 +718,11 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s } derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh, outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks} - preview, saw := previewOf(node, reported, derived, plan, taken, filter, runs[filter]) + filtering, err := inv.FilteringOf(ctx, node) + if err != nil { + return "", err + } + preview, saw := previewOf(node, reported, filtering, derived, plan, taken, filter, runs[filter]) preview += "\n\n preview " + saw if !yes { return preview + fmt.Sprintf("\n\nNothing has changed. Run `converge %s --yes %s` to do "+ @@ -731,7 +792,7 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s // previewOf is what converging a node will change, before it changes it, and a short digest of // what it said: every reachable thing and its fate, the modules the flip takes and the filter. The // digest is what the flip is asked to act on, so it changes whenever any of those would. -func previewOf(node string, reported inventory.Adoption, derived derivedFilter, +func previewOf(node string, reported inventory.Adoption, filtering inventory.Filtering, derived derivedFilter, plan catalogue.Resolution, taken []string, filter string, filterAssigned bool) (string, string) { var said []string var b strings.Builder @@ -823,6 +884,30 @@ func previewOf(node string, reported inventory.Adoption, derived derivedFilter, fmt.Fprintf(&b, " the found firewall (%s) is disabled, never flushed: its configuration stays on disk\n", fw) } said = append(said, fmt.Sprintf("filter %s assigned=%t firewall=%s", filter, filterAssigned, fw)) + // What filters the machine now, and the fate of each (novox/hq ADR 0168): the found firewall + // retired, the runtime's own and bans left, and what the mesh did not write left and named — + // so the reader knows before the flip that the machine will not be filtered by the mesh alone. + if len(filtering.Filters) > 0 { + b.WriteString("\n what filters the machine now, and what the flip does to each:\n") + for _, x := range filtering.Filters { + fate := "left: " + x.Owner + "'s" + switch x.Owner { + case inventory.FilterMesh: + fate = "the mesh's guard; replaced by its filter" + case inventory.FilterFoundFirewall: + fate = "the found firewall's; retired with it" + case inventory.FilterRuntime: + fate = "the container runtime's own; left" + case inventory.FilterBan: + fate = "a ban list; left" + case inventory.FilterOther: + fate = "NOT THE MESH'S; left in force — the machine is not filtered by the mesh alone until you remove it" + } + fmt.Fprintf(&b, " %-50s %s\n", x.Where, fate) + fmt.Fprintf(&b, " %-50s %s\n", "", x.Refuses) + said = append(said, "filter "+x.Owner+" "+x.Where) + } + } // Sorted: the same account, reported in another order, is the same preview. sort.Strings(said) sum := sha256.Sum256([]byte(strings.Join(said, "\n"))) diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index 1b27345..5b969b2 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -607,6 +607,10 @@ type answers struct { // a consequence of the refusals above: a node that does not resolve is not on the network, and // a mesh whose hub is that node has no hub. network string + // filtered is every converged machine that is not filtered by the mesh alone (novox/hq ADR + // 0168): what filters it beyond the mesh's own, the runtime's plumbing and bans, by name — a + // predecessor's chain, a found firewall in force again. Such a machine is not "all well". + filtered map[string]inventory.Filtering // untaken is, per machine, each assigned module whose resources the machine is holding as it // found them, and how many — a module that was assigned, sent, and is running none of what it // declares because nothing has taken it (novox/hq ADR 0100, 04-ISSUES/125). diff --git a/cmd/mesh-controller/readable.go b/cmd/mesh-controller/readable.go index e08ed50..9894092 100644 --- a/cmd/mesh-controller/readable.go +++ b/cmd/mesh-controller/readable.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "github.com/novox/mesh-controller/internal/inventory" "sort" "time" ) @@ -66,6 +67,21 @@ type meshStatus struct { // **A document without this said an outage was a well mesh.** Read from what each machine // reported, so it is the machine's account and not the mesh's take-time listing. Untaken []machineUntaken `json:"untaken,omitempty"` + // Filtered is every converged machine that is not filtered by the mesh alone (novox/hq ADR + // 0168), one entry per rule set the mesh did not write — the found firewall in force again, + // or a chain nobody speaks for. Absent when every converged machine is filtered by the mesh + // alone. A document without this called a machine well while a predecessor's chain refused + // what the mesh declared open. + Filtered []machineFiltered `json:"filtered,omitempty"` +} + +// machineFiltered is one rule set on a converged machine that the mesh did not write and that +// refuses traffic: where it is, whose the host reads it as, and what it refuses. +type machineFiltered struct { + Node string `json:"node"` + Where string `json:"where"` + Owner string `json:"owner"` + Refuses string `json:"refuses"` } // machineUntaken is one module a machine is holding rather than running, and how many resources of @@ -173,6 +189,21 @@ func statusAsJSON(asked answers) ([]byte, error) { machineUntaken{Node: name, Module: m, Held: asked.untaken[name][m]}) } } + filteredNodes := make([]string, 0, len(asked.filtered)) + for name := range asked.filtered { + filteredNodes = append(filteredNodes, name) + } + sort.Strings(filteredNodes) + for _, name := range filteredNodes { + f := asked.filtered[name] + if fw := f.FoundFirewall; fw != nil && fw.Active { + out.Filtered = append(out.Filtered, machineFiltered{Node: name, Where: "the found firewall", + Owner: inventory.FilterFoundFirewall, Refuses: fw.Kind + " is in force again"}) + } + for _, x := range f.Others() { + out.Filtered = append(out.Filtered, machineFiltered{Node: name, Where: x.Where, Owner: x.Owner, Refuses: x.Refuses}) + } + } for name := range asked.refused { out.Unresolved = append(out.Unresolved, machineUnresolved{ Node: name, Problem: asked.refused[name]}) diff --git a/cmd/mesh-controller/readable_test.go b/cmd/mesh-controller/readable_test.go index c9d07fa..09f6fcb 100644 --- a/cmd/mesh-controller/readable_test.go +++ b/cmd/mesh-controller/readable_test.go @@ -167,3 +167,46 @@ func TestAMachineFailingTheSameWayIsSaidToBeStuck(t *testing.T) { t.Fatalf("one failure is not stuck: %v", once) } } + +// A converged machine something other than the mesh filters is named, per rule set, and is not +// well (novox/hq ADR 0168); one filtered by the mesh alone is not in the list. +func TestAMachineNotFilteredByTheMeshAloneIsNamedAndNotWell(t *testing.T) { + alone := inventory.Filtering{Filters: []inventory.Filter{ + {Where: "table inet mesh, chain input", Owner: inventory.FilterMesh, Refuses: "policy drop"}, + {Where: "table ip filter, chain DOCKER", Owner: inventory.FilterRuntime, Refuses: "drop"}, + {Where: "table ip filter, chain f2b-sshd", Owner: inventory.FilterBan, Refuses: "ip saddr 192.0.2.1 reject"}, + }} + if !alone.Alone() { + t.Fatal("the mesh's own, the runtime's and a ban are not the mesh alone") + } + notAlone := inventory.Filtering{ + Filters: append(alone.Filters, inventory.Filter{Where: "chain HAL-MESH-ONLY (iptables-legacy)", + Owner: inventory.FilterOther, Refuses: `-A HAL-MESH-ONLY -m comment --comment "not public" -j DROP`}), + FoundFirewall: &inventory.FoundFirewall{Kind: "ufw", Active: true}, + } + asked := answers{nodes: []inventory.Node{{Name: "home-server"}, {Name: "laptop"}}, + filtered: map[string]inventory.Filtering{"home-server": notAlone}} + if asked.well() { + t.Fatal("a machine not filtered by the mesh alone reads as well") + } + body, err := statusAsJSON(asked) + if err != nil { + t.Fatal(err) + } + var parsed struct { + Filtered []map[string]string `json:"filtered"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatal(err) + } + if len(parsed.Filtered) != 2 { + t.Fatalf("filtered: %v", parsed.Filtered) + } + if parsed.Filtered[0]["node"] != "home-server" || parsed.Filtered[0]["owner"] != inventory.FilterFoundFirewall || + parsed.Filtered[1]["where"] != "chain HAL-MESH-ONLY (iptables-legacy)" || parsed.Filtered[1]["owner"] != inventory.FilterOther { + t.Fatalf("filtered: %v", parsed.Filtered) + } + if body, _ := statusAsJSON(answers{nodes: asked.nodes}); strings.Contains(string(body), `"filtered"`) { + t.Fatal("a mesh filtered by itself alone carries a filtered list") + } +} diff --git a/cmd/mesh-controller/status.go b/cmd/mesh-controller/status.go index cef21ce..1c8cc97 100644 --- a/cmd/mesh-controller/status.go +++ b/cmd/mesh-controller/status.go @@ -227,6 +227,28 @@ func printStatus(asked answers) error { " not readable from a commit; that needs a version the host reports as ordered\n\n") } + if len(asked.filtered) > 0 { + // A converged machine is filtered by the mesh alone, and the mesh says truthfully which + // (novox/hq ADR 0168). One that is not — a predecessor's chain still refusing, a found + // firewall in force again — is said here, and is not well. + machines := make([]string, 0, len(asked.filtered)) + for name := range asked.filtered { + machines = append(machines, name) + } + sort.Strings(machines) + fmt.Printf("%d converged machine(s) are not filtered by the mesh alone:\n", len(machines)) + for _, name := range machines { + f := asked.filtered[name] + if fw := f.FoundFirewall; fw != nil && fw.Active { + fmt.Printf(" %-12s the found firewall (%s) is in force again; the next apply retires it\n", name, fw.Kind) + } + for _, x := range f.Others() { + fmt.Printf(" %-12s %s (%s): %s\n", name, x.Where, x.Owner, x.Refuses) + } + } + fmt.Printf("\n the mesh wrote none of these and removes none; `node show ` lists every filter with its owner\n\n") + } + if len(asked.untaken) > 0 { // **Before the adopted line, and it breaks "all well".** An adopted machine is a state // somebody chose and can leave alone; a module assigned to one and never taken is work @@ -357,6 +379,13 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) { if err != nil { return answers{}, err } + // And which converged machines something other than the mesh filters (novox/hq ADR 0168), as + // each last reported — the account that was missing when a predecessor's chain refused what the + // mesh declared open for eleven hours (04-ISSUES/144, 145). + out.filtered, err = filteredMachines(ctx, inv, out.nodes) + if err != nil { + return answers{}, err + } out.plans, err = inv.RecentPlans(ctx, 5) if err != nil { return answers{}, err @@ -406,6 +435,30 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) { // // A machine that reports no holds contributes nothing, so a converged mesh answers an empty map and // the caller prints nothing. +// filteredMachines is every converged machine not filtered by the mesh alone, with what it last said +// filters it (novox/hq ADR 0168). An adopted machine keeps its found firewall by design and is not +// counted; a machine that has not said is not said to be filtered by anything. +func filteredMachines(ctx context.Context, inv *inventory.Inventory, nodes []inventory.Node) ( + map[string]inventory.Filtering, error) { + out := map[string]inventory.Filtering{} + for _, n := range nodes { + if n.Adopted { + continue + } + f, err := inv.FilteringOf(ctx, n.Name) + if err != nil { + return nil, fmt.Errorf("what filters %s cannot be read: %w", n.Name, err) + } + if len(f.Filters) == 0 && f.FoundFirewall == nil { + continue + } + if !f.Alone() { + out[n.Name] = f + } + } + return out, nil +} + func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inventory.Node) ( map[string]map[string]int, error) { @@ -442,7 +495,8 @@ func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inven // read as success for the whole of the edge cut-over outage (novox/hq 04-ISSUES/125). func (a answers) well() bool { return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 && - len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0 + len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0 && + len(a.filtered) == 0 } // hostSplit is which machines report which host version, for every version more than one machine diff --git a/internal/inventory/adoption.go b/internal/inventory/adoption.go index 60531d2..02f3da4 100644 --- a/internal/inventory/adoption.go +++ b/internal/inventory/adoption.go @@ -178,6 +178,103 @@ type Stray struct { Detail string `json:"detail,omitempty"` } +// A Filter is one place on a machine that refuses traffic, with its owner (novox/hq ADR 0168). +type Filter struct { + Where string `json:"where"` + Owner string `json:"owner"` + Refuses string `json:"refuses"` +} + +// Owners of a filter, as the host names them (ADR 0168). +const ( + FilterMesh = "mesh" + FilterFoundFirewall = "found-firewall" + FilterRuntime = "runtime" + FilterBan = "ban" + FilterOther = "other" +) + +// FoundFirewall is the state of a converged machine's found firewall (ADR 0168): in force now or +// not, and how it came to be inactive. +type FoundFirewall struct { + Kind string `json:"kind"` + Active bool `json:"active"` + RetiredBy string `json:"retired_by,omitempty"` +} + +// Filtering is what a machine last said filters it (ADR 0168). +type Filtering struct { + Filters []Filter + FoundFirewall *FoundFirewall +} + +// Alone is whether the machine is filtered by the mesh alone: nothing in its list but the mesh's +// own, the runtime's plumbing and bans, and no found firewall in force. +func (f Filtering) Alone() bool { + for _, x := range f.Filters { + if x.Owner == FilterOther || x.Owner == FilterFoundFirewall { + return false + } + } + return f.FoundFirewall == nil || !f.FoundFirewall.Active +} + +// Others is every filter that is neither the mesh's, the runtime's nor a ban. +func (f Filtering) Others() []Filter { + var out []Filter + for _, x := range f.Filters { + if x.Owner == FilterOther || x.Owner == FilterFoundFirewall { + out = append(out, x) + } + } + return out +} + +// RecordFiltering keeps what a machine last said filters it, replacing what was there (ADR 0168). +func (i *Inventory) RecordFiltering(ctx context.Context, nodeID string, filters []Filter, found *FoundFirewall) error { + raw, err := json.Marshal(nonNil(filters)) + if err != nil { + return err + } + var foundRaw any + if found != nil { + b, err := json.Marshal(found) + if err != nil { + return err + } + foundRaw = string(b) + } + _, err = i.store.Pool().Exec(ctx, + `update node set filters = $2, found_firewall = $3 where id = $1`, nodeID, raw, foundRaw) + return err +} + +// FilteringOf is what a machine last said filters it; empty for a machine that never said. +func (i *Inventory) FilteringOf(ctx context.Context, name string) (Filtering, error) { + var filtersRaw, foundRaw []byte + err := i.store.Pool().QueryRow(ctx, + `select filters, found_firewall from node where name = $1`, name).Scan(&filtersRaw, &foundRaw) + if errors.Is(err, pgx.ErrNoRows) { + return Filtering{}, fmt.Errorf("%w: %s", ErrNoSuchNode, name) + } + if err != nil { + return Filtering{}, err + } + var out Filtering + if len(filtersRaw) > 0 { + if err := json.Unmarshal(filtersRaw, &out.Filters); err != nil { + return Filtering{}, err + } + } + if len(foundRaw) > 0 { + out.FoundFirewall = &FoundFirewall{} + if err := json.Unmarshal(foundRaw, out.FoundFirewall); err != nil { + return Filtering{}, err + } + } + return out, nil +} + // Reach is one thing reachable on an adopted node: a listening socket or a published port. type Reach struct { Protocol string `json:"protocol"` diff --git a/internal/inventory/migrations/0054-a-machine-says-what-filters-it.sql b/internal/inventory/migrations/0054-a-machine-says-what-filters-it.sql new file mode 100644 index 0000000..8c2fbfe --- /dev/null +++ b/internal/inventory/migrations/0054-a-machine-says-what-filters-it.sql @@ -0,0 +1,7 @@ +-- What filters a machine, with owners, as the host reports it with every apply (novox/hq ADR 0168): +-- every table and chain that refuses traffic — the mesh's, the found firewall's, the runtime's own, +-- a ban, or other — so the mesh says truthfully what filters a converged machine and names what it +-- did not write. And the state of the firewall a converged machine was found with: in force now or +-- not, and who retired it. +alter table node add column filters jsonb; +alter table node add column found_firewall jsonb; diff --git a/internal/link/enrolment.go b/internal/link/enrolment.go index b560596..d84714c 100644 --- a/internal/link/enrolment.go +++ b/internal/link/enrolment.go @@ -305,6 +305,23 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err return false, err } } + // What filters the machine, and the state of its found firewall (novox/hq ADR 0168), whenever + // it says — every apply of a host that knows how, adopted or converged; never cleared by a + // report that carries none, which is every bare word that the node is there. + if len(report.Filters) > 0 || report.FoundFirewall != nil { + filters := make([]inventory.Filter, 0, len(report.Filters)) + for _, f := range report.Filters { + filters = append(filters, inventory.Filter{Where: f.Where, Owner: f.Owner, Refuses: f.Refuses}) + } + var found *inventory.FoundFirewall + if report.FoundFirewall != nil { + found = &inventory.FoundFirewall{Kind: report.FoundFirewall.Kind, Active: report.FoundFirewall.Active, + RetiredBy: report.FoundFirewall.RetiredBy} + } + if err := e.Inventory.RecordFiltering(ctx, node.ID, filters, found); err != nil { + return false, err + } + } // Which of its links face outside (novox/hq ADR 0140), whenever it says so. Recorded on every // report that carries it, adopted or converged, because the filter the mesh composes is written // around it — and never cleared by a report that carries none, which is every bare word that the diff --git a/internal/link/heard_test.go b/internal/link/heard_test.go index 6174c4b..8941cab 100644 --- a/internal/link/heard_test.go +++ b/internal/link/heard_test.go @@ -218,3 +218,45 @@ func TestWhatAnAdoptedNodeHoldsIsKeptAndAnAliveWordDoesNotWipeIt(t *testing.T) { t.Fatalf("a report from an adopted node holding nothing did not empty held: %+v", got) } } + +// What filters a machine, and the state of its found firewall, are kept from every report that +// carries them and never cleared by one that does not (novox/hq ADR 0168). +func TestWhatFiltersAMachineIsKeptFromItsReport(t *testing.T) { + inv, _, _ := heardFrom(t, link.Report{ + Node: "home-server", Applied: []string{"a"}, + Filters: []link.Filter{ + {Where: "table inet mesh, chain forward", Owner: "mesh", Refuses: "policy drop"}, + {Where: "chain HAL-MESH-ONLY (iptables-legacy)", Owner: "other", Refuses: "-j DROP"}, + }, + FoundFirewall: &link.FoundFirewall{Kind: "ufw", Active: false, RetiredBy: "found-inactive"}, + }) + ctx := context.Background() + f, err := inv.FilteringOf(ctx, "home-server") + if err != nil { + t.Fatal(err) + } + if len(f.Filters) != 2 || f.Filters[1].Owner != inventory.FilterOther || f.Alone() { + t.Fatalf("recorded %+v", f) + } + if f.FoundFirewall == nil || f.FoundFirewall.RetiredBy != "found-inactive" || f.FoundFirewall.Active { + t.Fatalf("the found firewall's state: %+v", f.FoundFirewall) + } + if len(f.Others()) != 1 || f.Others()[0].Where != "chain HAL-MESH-ONLY (iptables-legacy)" { + t.Fatalf("others: %+v", f.Others()) + } + // A bare word that the node is there clears nothing. + if _, err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{Node: "home-server"}); err != nil { + t.Fatal(err) + } + if again, _ := inv.FilteringOf(ctx, "home-server"); len(again.Filters) != 2 { + t.Fatalf("a bare report cleared what filters the machine: %+v", again) + } + // The next full report replaces it: the chain removed by hand is gone from the record. + if _, err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{Node: "home-server", Applied: []string{"a"}, + Filters: []link.Filter{{Where: "table inet mesh, chain forward", Owner: "mesh", Refuses: "policy drop"}}}); err != nil { + t.Fatal(err) + } + if again, _ := inv.FilteringOf(ctx, "home-server"); len(again.Filters) != 1 || !again.Alone() { + t.Fatalf("the next report did not replace what filters the machine: %+v", again) + } +} diff --git a/internal/link/protocol.go b/internal/link/protocol.go index 5e63894..38348c4 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -197,6 +197,15 @@ type Report struct { // Strays is what runs on the machine that the mesh neither wrote nor holds (ADR 0163). Strays []Stray `json:"strays,omitempty"` + // Filters is what filters the machine now: every table and chain that refuses traffic, with + // its owner — the mesh's, the found firewall's, the container runtime's own, a ban, or other + // (novox/hq ADR 0168). Every machine reports it, adopted or converged; absent from a host older + // than this. + Filters []Filter `json:"filters,omitempty"` + // FoundFirewall is the state of the firewall a converged machine was found with: in force now + // or not, and how it came to be inactive — the mesh disabled it, or it was found so (ADR 0168). + FoundFirewall *FoundFirewall `json:"found_firewall,omitempty"` + // Profile is what the machine can do, detected again by this apply (novox/hq ADR 0161): the // same shape enrolment sends, so a machine that gained or lost a capability — switched its // network manager — is known at its next push and not at its next enrolment. Absent from a host @@ -278,6 +287,21 @@ type Held struct { Facts map[string]any `json:"facts,omitempty"` } +// A Filter is one place on a machine that refuses traffic, with its owner (novox/hq ADR 0168): +// the host's own shape, carried as data. +type Filter struct { + Where string `json:"where"` + Owner string `json:"owner"` + Refuses string `json:"refuses"` +} + +// FoundFirewall is the state of a converged machine's found firewall (ADR 0168). +type FoundFirewall struct { + Kind string `json:"kind"` + Active bool `json:"active"` + RetiredBy string `json:"retired_by,omitempty"` +} + // A Stray is a container a machine runs that the mesh neither wrote nor holds (ADR 0163). type Stray struct { Kind string `json:"kind"`