From b5df2440962b9e1e3a51fe7c5d97bfa9dac3da7d Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 2 Oct 2026 12:00:12 +0200 Subject: [PATCH] The mesh says what filters a converged machine: filters kept per node, shown by node show, named by status, and previewed with their fates (hq ADR 0168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host reports every table and chain that refuses traffic with its owner, and a converged machine's found firewall's state. The controller keeps both on the node's record (migration 0054), shows them on node show, names every converged machine something other than the mesh filters in status — text and JSON, and such a machine is not well — and the converge preview lists what filters the machine with the fate of each: retired with the front end, left as the runtime's, left as a ban, or left in force and not the mesh's. What was invisible for eleven hours (issues 144, 145) is said by name. --- cmd/mesh-controller/adopting_test.go | 25 +++++ cmd/mesh-controller/adoption.go | 89 ++++++++++++++++- cmd/mesh-controller/build.go | 4 + cmd/mesh-controller/readable.go | 31 ++++++ cmd/mesh-controller/readable_test.go | 43 ++++++++ cmd/mesh-controller/status.go | 56 ++++++++++- internal/inventory/adoption.go | 97 +++++++++++++++++++ .../0054-a-machine-says-what-filters-it.sql | 7 ++ internal/link/enrolment.go | 17 ++++ internal/link/heard_test.go | 42 ++++++++ internal/link/protocol.go | 24 +++++ 11 files changed, 432 insertions(+), 3 deletions(-) create mode 100644 internal/inventory/migrations/0054-a-machine-says-what-filters-it.sql 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"`