The filter constrains what arrives from outside, and names no network
The forward chain blocked everything passing through the machine and then allowed the machine's own containers back by naming their address ranges: 172.16.0.0/12 and 192.168.128.0/17 fixed here, the rest recorded per machine by 0043. Every way of keeping that list correct fails — a constant describes one machine, a recorded range goes stale in silence and cannot tell a network the mesh made from one a predecessor left behind, and generating it from the modules would put half the rule set on the machine. The mesh has no position on a container reaching outward: that is not a port opened to anybody. So both chains are written around the links traffic arrives on. What did not arrive from outside is accepted in one line; what did meets the declared rules. The tunnel is named beside the outward links rather than treated as inside, or a port nothing declares would be reachable from every machine in the mesh. A machine that has not reported an outward link is sent no filter and keeps the one it has, refused where a person reads it rather than as a rule set that will not load. Removes the two constants, `node networks`, and the column behind it. novox/hq ADR 0140, superseding 0137 and 0139.
This commit is contained in:
@@ -59,7 +59,11 @@ func anchorRendering(adopted bool) Rendering {
|
||||
Values: map[string]any{ExposeSetting: map[string]any{"5000": FromEverywhere}}}}},
|
||||
Mesh: []string{"10.42.0.1"},
|
||||
Foundation: []int{5671},
|
||||
Adopted: adopted,
|
||||
// What the machine reported faces outside, which every rule in the filter is written
|
||||
// around (novox/hq ADR 0140).
|
||||
OutwardLinks: []string{"eth0"},
|
||||
TunnelInterface: "mesh0",
|
||||
Adopted: adopted,
|
||||
// Genesis takes the foundation's modules.
|
||||
Taken: map[string]bool{"postgres": true, "lavinmq": true},
|
||||
}
|
||||
@@ -575,11 +579,13 @@ func TestAGivenMachineSideReachesTheFilterTheOpeningAndTheConsumer(t *testing.T)
|
||||
}
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{forge}}
|
||||
with := Rendering{
|
||||
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, given)},
|
||||
Given: map[string]map[int]int{"forge": given},
|
||||
Mesh: []string{"10.77.0.1"},
|
||||
Adopted: true,
|
||||
Taken: map[string]bool{"forge": true},
|
||||
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, given)},
|
||||
Given: map[string]map[int]int{"forge": given},
|
||||
Mesh: []string{"10.77.0.1"},
|
||||
Adopted: true,
|
||||
OutwardLinks: []string{"eth0"},
|
||||
TunnelInterface: "mesh0",
|
||||
Taken: map[string]bool{"forge": true},
|
||||
}
|
||||
|
||||
// What the runtime is handed: the machine's own port on the outside, the container's within.
|
||||
@@ -660,9 +666,11 @@ func TestALongFormPortIsOpenedWhereTheManifestPublishesIt(t *testing.T) {
|
||||
forge := aForge()
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{forge}}
|
||||
composed, err := r.Compose(Rendering{
|
||||
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, nil)},
|
||||
Mesh: []string{"10.77.0.1"},
|
||||
Adopted: true,
|
||||
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, nil)},
|
||||
Mesh: []string{"10.77.0.1"},
|
||||
Adopted: true,
|
||||
OutwardLinks: []string{"eth0"},
|
||||
TunnelInterface: "mesh0",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -124,10 +124,15 @@ type Rendering struct {
|
||||
// nothing on this node keeps them, or the mesh has no operator key.
|
||||
Kept *KeptExport
|
||||
|
||||
// Routed is the networks this machine routes for what it hosts, beyond the container runtime's
|
||||
// own default pools, which the filter allows without being told (novox/hq ADR 0137). A node-level
|
||||
// fact: the machine routes them, and the module that loads the filter may be replaced.
|
||||
Routed []string
|
||||
// OutwardLinks is the links this machine reported as facing outside it, which the filter is
|
||||
// written around (novox/hq ADR 0140). Empty means the machine has not said, and the mesh
|
||||
// composes no filter for it rather than writing a rule around a link with no name.
|
||||
OutwardLinks []string
|
||||
|
||||
// TunnelInterface is the interface the mesh's private network runs on, named here rather than
|
||||
// imported because the overlay package rests on this one. Traffic arriving on it is the mesh's,
|
||||
// not this machine's own guest, so the filter admits it only by a rule.
|
||||
TunnelInterface string
|
||||
|
||||
// Foundation is the ports the mesh itself needs reachable on every machine, which no module
|
||||
// declares because the foundation is not a module (novox/hq 04-ISSUES/051 and 052). The broker
|
||||
@@ -350,7 +355,21 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtering := AsNftables(rules, with.Mesh, r.PublicDomain != "", with.Foundation, with.Routed)
|
||||
// **A machine that has not said which links face outside is sent no filter** (novox/hq ADR
|
||||
// 0140). The whole chain is written around those links: with none, the rule that lets this
|
||||
// machine's own guests keep working would name an empty set, which nftables refuses, and a rule
|
||||
// set that does not load is a machine filtering nothing while its unit reports success. Refused
|
||||
// here, where a person reads it, rather than on the machine — and the machine keeps the filter
|
||||
// it already has.
|
||||
if filters := r.filtersHere(); filters != "" && len(with.OutwardLinks) == 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"%s cannot be sent a filter: it has not reported which of its links face outside, and "+
|
||||
"every rule in the chain is written around them. It reports that on each apply; "+
|
||||
"`node show %s` says whether it has. Until then %s is not sent, and the machine "+
|
||||
"keeps the filter it has", r.Node, r.Node, filters)
|
||||
}
|
||||
filtering := AsNftables(rules, with.Mesh, r.PublicDomain != "", with.Foundation,
|
||||
with.OutwardLinks, with.TunnelInterface)
|
||||
|
||||
var out []map[string]any
|
||||
for _, m := range r.Modules {
|
||||
@@ -857,6 +876,17 @@ func mapping(written string) (outer, inner int, address string, ok bool) {
|
||||
return outer, inner, strings.Join(parts[:len(parts)-2], ":"), true
|
||||
}
|
||||
|
||||
// filtersHere is the module on this node that loads the machine's packet filter, or empty when none
|
||||
// does. Named rather than counted: a refusal that says which module is one step from acted on.
|
||||
func (r Resolution) filtersHere() string {
|
||||
for _, m := range r.Modules {
|
||||
if m.Filtering != nil {
|
||||
return m.Module
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Rules is the rule set this node's filter is derived from: every module's listens, what was
|
||||
// computed for this machine, and each module's per-node exposure. The same answer whether the node
|
||||
// is adopted or converged — the one loads it as a filter, the other declares it as openings.
|
||||
|
||||
@@ -230,7 +230,23 @@ const SSHPort = 22
|
||||
// It is a floor for the same reason ssh is. A machine nobody can reach is a machine nobody can
|
||||
// repair; a machine the mesh cannot reach is a machine the mesh cannot manage. Neither is a thing
|
||||
// any module asks for, and neither may be derived away.
|
||||
func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int, routed []string) string {
|
||||
func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int,
|
||||
outwardLinks []string, tunnel string) string {
|
||||
// The links that are not this machine's own: the ones facing outside, and the mesh's tunnel.
|
||||
// Traffic arriving on any of them is admitted only by a rule below; traffic arriving anywhere
|
||||
// else is this machine's own guest and is not something the mesh has a position on.
|
||||
//
|
||||
// The tunnel is named here deliberately. Treating it as "not outside" would make a port nothing
|
||||
// declares reachable from every machine in the mesh, which is the derivation abandoned.
|
||||
quoted := make([]string, 0, len(outwardLinks)+1)
|
||||
for _, link := range outwardLinks {
|
||||
quoted = append(quoted, fmt.Sprintf("%q", link))
|
||||
}
|
||||
if tunnel != "" {
|
||||
quoted = append(quoted, fmt.Sprintf("%q", tunnel))
|
||||
}
|
||||
inward := strings.Join(quoted, ", ")
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# Computed by the mesh from what is assigned to this node.\n")
|
||||
b.WriteString("# Edits are lost on the next declaration; change a module's listens instead.\n\n")
|
||||
@@ -252,19 +268,20 @@ func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int, rou
|
||||
b.WriteString("\t\ticmp type echo-request accept\n")
|
||||
b.WriteString("\t\ticmpv6 type { echo-request, nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept\n")
|
||||
|
||||
// **What a machine it routes for must be able to ask it** (novox/hq ADR 0137). A guest on one of
|
||||
// these networks gets its address and its names from this machine, over the bridge it is on, and
|
||||
// those two questions arrive at the input chain like any other. Denied, the guest never gets an
|
||||
// address and never resolves a name — which is not "a closed port" but a network that does not
|
||||
// work at all, and it is this machine's own guest asking.
|
||||
// **What this machine's own guests must be able to ask it** (novox/hq ADR 0140). A guest gets
|
||||
// its address and its names from this machine, over the link it is on, and those two questions
|
||||
// arrive at the input chain like any other. Denied, the guest never gets an address and never
|
||||
// resolves a name — which is not "a closed port" but a network that does not work at all, and it
|
||||
// is this machine's own guest asking.
|
||||
//
|
||||
// Only these ports, and only for a network that was named: everything else a guest might want
|
||||
// from its host is a port somebody declares, like every other port on this machine.
|
||||
for _, network := range routed {
|
||||
family := saddrFamily(network)
|
||||
b.WriteString("\t\t# address and name service for a network this machine routes\n")
|
||||
b.WriteString(fmt.Sprintf("\t\t%s saddr %s udp dport { 53, 67 } accept\n", family, network))
|
||||
b.WriteString(fmt.Sprintf("\t\t%s saddr %s tcp dport 53 accept\n", family, network))
|
||||
// Asked for by the link it arrives on rather than by the address it comes from, for the reason
|
||||
// the forward chain below no longer names an address: a range describes one machine and goes
|
||||
// stale in silence. Anything arriving from outside, or over the tunnel, is not a guest of this
|
||||
// machine and asks through a port somebody declared, like everything else.
|
||||
if len(inward) > 0 {
|
||||
b.WriteString("\t\t# this machine's own guests asking it for an address and for names\n")
|
||||
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } udp dport { 53, 67 } accept\n", inward))
|
||||
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } tcp dport 53 accept\n", inward))
|
||||
}
|
||||
|
||||
// **ssh, always, and not because a module asked.**
|
||||
@@ -376,26 +393,36 @@ func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int, rou
|
||||
// about the ports most worth protecting. Rehearsed on three machines: loading these rules
|
||||
// refused a port on the host and left a published container port reachable (novox/hq issue 047).
|
||||
//
|
||||
// The way through is the one the system being replaced already used: deny by default here, and
|
||||
// then explicitly allow the runtime's own networks, so containers keep working while everything
|
||||
// else has to be asked for.
|
||||
// **What it constrains is traffic arriving from OUTSIDE this machine, and nothing else**
|
||||
// (novox/hq ADR 0140).
|
||||
//
|
||||
// It used to deny everything here and then allow the machine's own containers back by naming
|
||||
// the address ranges they sit on — two ranges fixed in this file and the rest recorded per
|
||||
// machine. Every way of keeping that list correct failed. A constant describes one machine. A
|
||||
// recorded range goes stale in silence and cannot tell a network the mesh made from one a
|
||||
// predecessor left behind. Generating it from the modules would have put half this rule set on
|
||||
// the machine.
|
||||
//
|
||||
// The list should not exist, because the mesh has no position on a container reaching outward:
|
||||
// that is not a port opened to anybody. So traffic that did not arrive from outside is accepted
|
||||
// in one line, and what did arrive from outside is allowed only where a rule below admits it.
|
||||
//
|
||||
// The tunnel is not "not outside". Accepting everything off it would make a port nothing
|
||||
// declares reachable from any machine in the mesh, which is the derivation abandoned — so it is
|
||||
// named here beside the outward links, and traffic arriving on it meets the rules below like
|
||||
// anything else.
|
||||
b.WriteString("\tchain forward {\n")
|
||||
b.WriteString("\t\ttype filter hook forward priority filter; policy drop;\n")
|
||||
b.WriteString("\t\tct state established,related accept\n")
|
||||
b.WriteString("\t\tct state invalid drop\n")
|
||||
b.WriteString("\n")
|
||||
// What the container runtime created. Without these, denying by default stops every container
|
||||
// on the machine — which is exactly the failure the absent chain was avoiding, avoided properly.
|
||||
for _, network := range runtimeNetworks {
|
||||
b.WriteString(fmt.Sprintf("\t\t# %s\n", network.why))
|
||||
b.WriteString(fmt.Sprintf("\t\tip saddr %s accept\n", network.cidr))
|
||||
}
|
||||
// And what this machine says it routes beyond them (novox/hq ADR 0137). Added to the defaults
|
||||
// above, never replacing them: a machine that names one range has not stopped hosting whatever
|
||||
// was already on the runtime's own.
|
||||
for _, network := range routed {
|
||||
b.WriteString("\t\t# a network this machine routes for what it hosts\n")
|
||||
b.WriteString(fmt.Sprintf("\t\t%s saddr %s accept\n", saddrFamily(network), network))
|
||||
// Only when there is a link to name. An empty set is a line nftables refuses, and a rule set
|
||||
// that does not load is a machine filtering nothing while its unit reports success — so the
|
||||
// chain denies rather than renders nonsense. Composing a declaration for a machine that has
|
||||
// named none is refused upstream, so this is a floor and not a path anything travels.
|
||||
if inward != "" {
|
||||
b.WriteString("\t\t# this machine's own guests reaching outward: not a port opened to anybody\n")
|
||||
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } accept\n", inward))
|
||||
}
|
||||
|
||||
if len(rules) > 0 {
|
||||
@@ -452,28 +479,6 @@ func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int, rou
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// runtimeNetworks are the container runtime's own networks, which must keep working when the
|
||||
// forward chain denies by default.
|
||||
//
|
||||
// Taken from what the system being replaced allows, which has been carrying this machine's traffic
|
||||
// for months: the runtime's bridge range and the range its compose files are given. A machine whose
|
||||
// runtime is configured with something else needs this to say so — which is a thing the mesh cannot
|
||||
// derive and a reason this list is named here rather than computed.
|
||||
var runtimeNetworks = []struct{ cidr, why string }{
|
||||
{"172.16.0.0/12", "the container runtime's bridge networks"},
|
||||
{"192.168.128.0/17", "the networks its compose files are given"},
|
||||
}
|
||||
|
||||
// saddrFamily is the match a network's family is written with: `ip saddr` or `ip6 saddr`. One match
|
||||
// for both families is a syntax error, and a ruleset that does not load is a machine filtering
|
||||
// nothing while its service reports a fault — the same reason byFamily below exists.
|
||||
func saddrFamily(network string) string {
|
||||
if strings.Contains(network, ":") {
|
||||
return "ip6"
|
||||
}
|
||||
return "ip"
|
||||
}
|
||||
|
||||
// byFamily splits addresses into the two nftables understands separately.
|
||||
//
|
||||
// `ip saddr` and `ip6 saddr` are different matches, and one set holding both families is a syntax
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestTheBrokersPortIsOpenedThoughNoModuleDeclaresIt(t *testing.T) {
|
||||
// A machine on the private network, with one ordinary module rule, and nothing that mentions
|
||||
// the broker — which is every machine.
|
||||
rules := []Rule{{Port: 8080, From: FromMesh, Because: []string{"some-module"}}}
|
||||
out := AsNftables(rules, []string{"10.42.0.1"}, false, []int{brokerPort}, nil)
|
||||
out := AsNftables(rules, []string{"10.42.0.1"}, false, []int{brokerPort}, nil, "mesh0")
|
||||
|
||||
if !strings.Contains(out, "tcp dport 5671 accept") {
|
||||
t.Fatalf("the broker's port is not opened, so no machine could enrol:\n%s", out)
|
||||
@@ -48,7 +48,7 @@ func TestTheBrokersPortIsOpenedThoughNoModuleDeclaresIt(t *testing.T) {
|
||||
// And a mesh that was never told about a broker still gets a ruleset, rather than an empty one or
|
||||
// a panic. A control plane in that state cannot issue tokens either, which is where it surfaces.
|
||||
func TestNoBrokerMeansNoFoundationRuleRatherThanNoRuleset(t *testing.T) {
|
||||
out := AsNftables(nil, []string{"10.42.0.1"}, false, nil, nil)
|
||||
out := AsNftables(nil, []string{"10.42.0.1"}, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(out, "table inet mesh") {
|
||||
t.Fatalf("no ruleset at all:\n%s", out)
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A machine's own guests keep working when the filter it is given denies by default.
|
||||
//
|
||||
// **The forward chain allowed the container runtime's two default pools and nothing else** — named
|
||||
// in this package's code with a comment saying a machine configured otherwise "needs this to say
|
||||
// so", and no way to say it (novox/hq ADR 0137). Measured on a workstation on 2026-09-28: the flip
|
||||
// to the derived filter cut egress for five of its container networks, allocated from ranges those
|
||||
// two defaults do not cover, and for every network its test beds create. Nothing reported a fault.
|
||||
// The guests simply could not reach anything, and the machine went on saying it had applied what it
|
||||
// was told.
|
||||
func TestWhatAMachineSaysItRoutesKeepsBeingForwarded(t *testing.T) {
|
||||
const beds = "10.0.0.0/8"
|
||||
|
||||
ruleset := AsNftables(nil, []string{"10.10.0.1"}, false, nil, []string{beds})
|
||||
|
||||
forward := chainOf(t, ruleset, "forward")
|
||||
if !strings.Contains(forward, "ip saddr "+beds+" accept") {
|
||||
t.Errorf("the forward chain does not accept what the machine says it routes (%s):\n%s",
|
||||
beds, forward)
|
||||
}
|
||||
// The defaults stay. A machine that names one range has not stopped hosting whatever was
|
||||
// already on the runtime's own pools, and losing those would trade one silent breakage for
|
||||
// another.
|
||||
for _, network := range runtimeNetworks {
|
||||
if !strings.Contains(forward, "ip saddr "+network.cidr+" accept") {
|
||||
t.Errorf("naming a network dropped the runtime's own %s:\n%s", network.cidr, forward)
|
||||
}
|
||||
}
|
||||
|
||||
// And its guests can still ask this machine the two questions that make a network usable at
|
||||
// all: what is my address, and what is that name.
|
||||
input := chainOf(t, ruleset, "input")
|
||||
for _, want := range []string{
|
||||
"ip saddr " + beds + " udp dport { 53, 67 } accept",
|
||||
"ip saddr " + beds + " tcp dport 53 accept",
|
||||
} {
|
||||
if !strings.Contains(input, want) {
|
||||
t.Errorf("the input chain is missing %q, so a guest on %s gets no address and "+
|
||||
"resolves no name:\n%s", want, beds, input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A machine that says nothing is filtered exactly as it was before this existed.
|
||||
//
|
||||
// The change has to be additive on every machine already converged: novox has been carrying this
|
||||
// mesh's public services behind the derived filter for weeks, and a new line in its ruleset is a
|
||||
// change to a production firewall nobody asked for.
|
||||
func TestAMachineThatNamesNoNetworksIsFilteredAsBefore(t *testing.T) {
|
||||
rules := []Rule{{Port: 443, Protocol: "tcp", From: FromEverywhere, Because: []string{"proxy"}}}
|
||||
|
||||
said := AsNftables(rules, []string{"10.10.0.1"}, true, []int{4222}, nil)
|
||||
quiet := AsNftables(rules, []string{"10.10.0.1"}, true, []int{4222}, []string{})
|
||||
|
||||
if said != quiet {
|
||||
t.Errorf("nil and empty render differently:\n%s\n---\n%s", said, quiet)
|
||||
}
|
||||
if strings.Contains(said, "a network this machine routes") {
|
||||
t.Errorf("a machine that named nothing carries a line about what it routes:\n%s", said)
|
||||
}
|
||||
}
|
||||
|
||||
// The two families are matched differently, and one set holding both is a syntax error — a ruleset
|
||||
// that does not load is a machine filtering nothing while its unit reports a fault.
|
||||
func TestARoutedNetworkIsMatchedInItsOwnFamily(t *testing.T) {
|
||||
ruleset := AsNftables(nil, nil, false, nil, []string{"10.0.0.0/8", "fd00::/8"})
|
||||
|
||||
if !strings.Contains(ruleset, "ip saddr 10.0.0.0/8 accept") {
|
||||
t.Errorf("the v4 network is not matched as ip saddr:\n%s", ruleset)
|
||||
}
|
||||
if !strings.Contains(ruleset, "ip6 saddr fd00::/8 accept") {
|
||||
t.Errorf("the v6 network is not matched as ip6 saddr:\n%s", ruleset)
|
||||
}
|
||||
if strings.Contains(ruleset, "ip saddr fd00::/8") {
|
||||
t.Errorf("a v6 network is matched as ip saddr, which nftables refuses:\n%s", ruleset)
|
||||
}
|
||||
}
|
||||
|
||||
// chainOf is one chain's body, so a test about the forward chain cannot pass on a line in the input
|
||||
// chain that happens to look the same.
|
||||
func chainOf(t *testing.T, ruleset, name string) string {
|
||||
t.Helper()
|
||||
start := strings.Index(ruleset, "chain "+name+" {")
|
||||
if start < 0 {
|
||||
t.Fatalf("no chain %q in:\n%s", name, ruleset)
|
||||
}
|
||||
rest := ruleset[start:]
|
||||
end := strings.Index(rest, "\n\t}")
|
||||
if end < 0 {
|
||||
t.Fatalf("chain %q does not end:\n%s", name, rest)
|
||||
}
|
||||
return rest[:end]
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func TestTwoModulesWantingOnePortAreBothNamed(t *testing.T) {
|
||||
t.Fatalf("a module that wanted this port open is not named: %+v", rules[0])
|
||||
}
|
||||
// The consequence, which is the reason this matters: removing web must not read as closing 443.
|
||||
nft := AsNftables(rules, nil, false, nil, nil)
|
||||
nft := AsNftables(rules, nil, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "web") || !strings.Contains(nft, "board") {
|
||||
t.Fatalf("the rendered rule set does not name both sources:\n%s", nft)
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func TestAPortOpenToEveryoneIsNotAlsoRestrictedToTheMesh(t *testing.T) {
|
||||
func TestWhatNoModuleDeclaredIsClosed(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||
}}, nil), []string{"198.51.100.2"}, false, nil, nil)
|
||||
}}, nil), []string{"198.51.100.2"}, false, nil, nil, "mesh0")
|
||||
// Naming the chain, not just the policy: the forward chain drops too, and an assertion on
|
||||
// "policy drop" alone passes while the input chain accepts everything. It did, once, here.
|
||||
if !strings.Contains(nft, "type filter hook input priority filter; policy drop;") {
|
||||
@@ -134,7 +134,7 @@ func TestWhatNoModuleDeclaredIsClosed(t *testing.T) {
|
||||
// `flush ruleset` would do the first and not the second: it empties every table on the machine,
|
||||
// including the ones the container runtime writes for its bridges.
|
||||
func TestReloadingReplacesOnlyTheMeshsOwnRules(t *testing.T) {
|
||||
nft := AsNftables(nil, nil, false, nil, nil)
|
||||
nft := AsNftables(nil, nil, false, nil, nil, "mesh0")
|
||||
if strings.Contains(nft, "flush ruleset") {
|
||||
t.Fatalf("loading the rule set empties every table on the machine:\n%s", nft)
|
||||
}
|
||||
@@ -160,22 +160,122 @@ func TestReloadingReplacesOnlyTheMeshsOwnRules(t *testing.T) {
|
||||
// So the chain exists and denies by default, and the runtime's own networks are allowed explicitly
|
||||
// — which is how the system being replaced has been doing it on these machines for months.
|
||||
func TestWhatIsForwardedIsGovernedToo(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, nil)
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "hook forward priority filter; policy drop") {
|
||||
t.Fatalf("forwarded traffic is not governed, so container ports are open:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// And containers keep working, which is the whole reason the chain was left out before.
|
||||
func TestTheRuntimesOwnNetworksKeepWorking(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, nil)
|
||||
for _, network := range []string{"172.16.0.0/12", "192.168.128.0/17"} {
|
||||
if !strings.Contains(nft, "ip saddr "+network+" accept") {
|
||||
t.Fatalf("%s is not allowed, so denying by default stops every container:\n%s", network, nft)
|
||||
// And this machine's own guests keep working, which is the whole reason the chain was left out
|
||||
// before — by not being mentioned (novox/hq ADR 0140).
|
||||
//
|
||||
// It used to be done by naming the address ranges they sit on: two fixed here and the rest recorded
|
||||
// per machine. That list broke a workstation's containers at a flip and could not be made correct,
|
||||
// because a range describes one machine and cannot tell a network the mesh made from one a
|
||||
// predecessor left behind. What replaced it is a single line about the links traffic arrives on.
|
||||
func TestThisMachinesOwnGuestsKeepWorkingWithoutBeingNamed(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0"}, "mesh0")
|
||||
if !strings.Contains(nft, `iifname != { "eth0", "mesh0" } accept`) {
|
||||
t.Fatalf("what did not arrive from outside is not accepted, so this machine's own guests "+
|
||||
"reach nothing:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// No address of a machine's own networks appears anywhere in a rendered filter.
|
||||
//
|
||||
// This is the assertion that fails against the previous behaviour, and it is why it is written on
|
||||
// the text rather than on an outcome: the two ranges were a constant in this file, so nothing but
|
||||
// reading the output catches one creeping back in.
|
||||
func TestNoNetworkOfTheMachinesOwnIsNamed(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0"}, "mesh0")
|
||||
for _, gone := range []string{"172.16.0.0/12", "192.168.128.0/17", "saddr 192.168", "saddr 172."} {
|
||||
if strings.Contains(nft, gone) {
|
||||
t.Fatalf("%q is named, and a range describes one machine and goes stale in silence:\n%s",
|
||||
gone, nft)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **The tunnel is constrained, not treated as inside.**
|
||||
//
|
||||
// Accepting everything arriving over the private network would make a port nothing declares
|
||||
// reachable from every machine in the mesh — the derivation abandoned, and a rule that reads as a
|
||||
// restriction while restricting nothing. So the tunnel is named beside the outward links, and
|
||||
// traffic arriving on it meets the declared rules like anything else.
|
||||
func TestTheTunnelIsConstrainedLikeAnOutwardLink(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0"}, "mesh0")
|
||||
line := `iifname != { "eth0", "mesh0" } accept`
|
||||
if !strings.Contains(nft, line) {
|
||||
t.Fatalf("the tunnel is not constrained, so an undeclared port is reachable from any "+
|
||||
"machine in the mesh:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine with two links facing outside has both constrained. Asserted on the one line, because a
|
||||
// rule covering one and not the other would leave a machine filtering half of what reaches it.
|
||||
func TestEveryOutwardLinkIsConstrained(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0", "wlan0"}, "mesh0")
|
||||
if !strings.Contains(nft, `iifname != { "eth0", "wlan0", "mesh0" } accept`) {
|
||||
t.Fatalf("not every outward link is constrained:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// A guest asks its host for an address and for names, and those two arrive at the input chain. Asked
|
||||
// for by the link they arrive on, so a resolver bound anywhere but an outward link keeps answering.
|
||||
func TestGuestsMayAskTheirHostForAnAddressAndNames(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0"}, "mesh0")
|
||||
for _, want := range []string{
|
||||
`iifname != { "eth0", "mesh0" } udp dport { 53, 67 } accept`,
|
||||
`iifname != { "eth0", "mesh0" } tcp dport 53 accept`,
|
||||
} {
|
||||
if !strings.Contains(nft, want) {
|
||||
t.Fatalf("a guest cannot ask its host for an address or a name, which is not a closed "+
|
||||
"port but a network that does not work:\n%s", nft)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With no link named at all the chain denies rather than rendering an empty set, which nftables
|
||||
// refuses — and a rule set that does not load is a machine filtering nothing while its unit reports
|
||||
// success. Composing a declaration for such a machine is refused upstream; this is the floor.
|
||||
func TestNoLinkNamedRendersNoCatchAllRatherThanAnEmptySet(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, nil, "")
|
||||
if strings.Contains(nft, "{ }") || strings.Contains(nft, "iifname != {}") {
|
||||
t.Fatalf("an empty set is rendered, which nftables refuses:\n%s", nft)
|
||||
}
|
||||
if !strings.Contains(nft, "hook forward priority filter; policy drop") {
|
||||
t.Fatalf("the forward chain does not deny:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine that has not said which links face outside is sent no filter, and the refusal names the
|
||||
// module that would have loaded it so the reader knows what is being withheld.
|
||||
func TestAMachineThatNamedNoOutwardLinkIsSentNoFilter(t *testing.T) {
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{
|
||||
{Module: "nftables", Filtering: &Filtering{Into: "/etc/mesh/filter.nft"}},
|
||||
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||
}}
|
||||
_, err := r.Declaration(Rendering{Mesh: []string{"198.51.100.2"}, TunnelInterface: "mesh0"})
|
||||
if err == nil {
|
||||
t.Fatal("a machine that named no outward link was sent a filter written around none")
|
||||
}
|
||||
for _, want := range []string{"anchor", "nftables", "face outside"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("the refusal does not say %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And a machine that names none but loads no filter is not refused: there is nothing to write.
|
||||
func TestAMachineWithNoFilterModuleIsNotRefused(t *testing.T) {
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{
|
||||
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||
}}
|
||||
if _, err := r.Declaration(Rendering{Mesh: []string{"198.51.100.2"}}); err != nil {
|
||||
t.Fatalf("a machine that loads no filter was refused one: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A published port is matched by what the client asked for, not by where the packet ends up.
|
||||
//
|
||||
// The runtime rewrites the destination before this chain sees it, so a rule naming the published
|
||||
@@ -183,7 +283,7 @@ func TestTheRuntimesOwnNetworksKeepWorking(t *testing.T) {
|
||||
func TestAPublishedPortIsMatchedByWhatWasAskedFor(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "web", Listens: []Listening{{Port: 8080, From: FromEverywhere}}},
|
||||
}}, nil), []string{"198.51.100.2"}, false, nil, nil)
|
||||
}}, nil), []string{"198.51.100.2"}, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "ct original proto-dst 8080 accept") {
|
||||
t.Fatalf("the forwarded rule does not match the port a client asked for:\n%s", nft)
|
||||
}
|
||||
@@ -193,7 +293,7 @@ func TestAPublishedPortIsMatchedByWhatWasAskedFor(t *testing.T) {
|
||||
func TestAMeshScopedPortIsMeshScopedWhenForwarded(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
}}, nil), []string{"198.51.100.2"}, false, nil, nil)
|
||||
}}, nil), []string{"198.51.100.2"}, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "ip saddr { 198.51.100.2 } ct original proto-dst 5432 accept") {
|
||||
t.Fatalf("a mesh-only port is reachable from anywhere once forwarded:\n%s", nft)
|
||||
}
|
||||
@@ -203,7 +303,7 @@ func TestAMeshScopedPortIsMeshScopedWhenForwarded(t *testing.T) {
|
||||
func TestFromTheMeshIsTheNodesTheMeshKnows(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
}}, nil), []string{"198.51.100.2", "198.51.100.3"}, false, nil, nil)
|
||||
}}, nil), []string{"198.51.100.2", "198.51.100.3"}, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "ip saddr { 198.51.100.2, 198.51.100.3 } tcp dport 5432 accept") {
|
||||
t.Fatalf("a mesh-scoped port was not restricted to the mesh's addresses:\n%s", nft)
|
||||
}
|
||||
@@ -213,7 +313,7 @@ func TestFromTheMeshIsTheNodesTheMeshKnows(t *testing.T) {
|
||||
func TestAMeshPortOnANodeWithNoMeshIsClosedAndSaysSo(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
}}, nil), nil, false, nil, nil)
|
||||
}}, nil), nil, false, nil, nil, "mesh0")
|
||||
if strings.Contains(nft, "dport 5432 accept") {
|
||||
t.Fatalf("a port meant for the mesh was opened to everything:\n%s", nft)
|
||||
}
|
||||
@@ -226,7 +326,7 @@ func TestAMeshPortOnANodeWithNoMeshIsClosedAndSaysSo(t *testing.T) {
|
||||
func TestAMachineScopedPortIsNotOpened(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "cache", Listens: []Listening{{Port: 6379, From: FromMachine}}},
|
||||
}}, nil), []string{"198.51.100.2"}, false, nil, nil)
|
||||
}}, nil), []string{"198.51.100.2"}, false, nil, nil, "mesh0")
|
||||
if strings.Contains(nft, "dport 6379 accept") {
|
||||
t.Fatalf("a port for this machine only was opened to the network:\n%s", nft)
|
||||
}
|
||||
@@ -238,7 +338,8 @@ func TestTheModuleAskingForTheRuleSetGetsEveryModulesPorts(t *testing.T) {
|
||||
{Module: "firewall", Filtering: &Filtering{Into: "/etc/mesh/filter.nft"}},
|
||||
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||
}}
|
||||
out, err := r.Declaration(Rendering{Mesh: []string{"198.51.100.2"}})
|
||||
out, err := r.Declaration(Rendering{Mesh: []string{"198.51.100.2"},
|
||||
OutwardLinks: []string{"eth0"}, TunnelInterface: "mesh0"})
|
||||
if err != nil {
|
||||
t.Fatalf("declaration: %v", err)
|
||||
}
|
||||
@@ -268,7 +369,7 @@ func TestAskingForTheRuleSetWithNowhereToPutItIsRefused(t *testing.T) {
|
||||
func TestAMeshOnBothAddressFamiliesRendersBoth(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
}}, nil), []string{"198.51.100.2", "2001:db8::2"}, false, nil, nil)
|
||||
}}, nil), []string{"198.51.100.2", "2001:db8::2"}, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "ip saddr { 198.51.100.2 } tcp dport 5432 accept") {
|
||||
t.Fatalf("the machines with v4 addresses were dropped:\n%s", nft)
|
||||
}
|
||||
@@ -296,7 +397,8 @@ func TestWhatTheMeshComputesIsAppliedBeforeWhatTheModuleDeclared(t *testing.T) {
|
||||
"restart-on": []any{"filtering"}},
|
||||
},
|
||||
}}}
|
||||
out, err := r.Declaration(Rendering{Mesh: []string{"198.51.100.2"}})
|
||||
out, err := r.Declaration(Rendering{Mesh: []string{"198.51.100.2"},
|
||||
OutwardLinks: []string{"eth0"}, TunnelInterface: "mesh0"})
|
||||
if err != nil {
|
||||
t.Fatalf("declaration: %v", err)
|
||||
}
|
||||
@@ -672,7 +774,7 @@ func TestExposureRefusesAPortNotListenedOnAndABadSource(t *testing.T) {
|
||||
// loading the rules lives on conntrack until it drops, and then the machine is reached from a
|
||||
// rescue console (novox/hq issue 047).
|
||||
func TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2", "198.51.100.3"}, false, nil, nil)
|
||||
nft := AsNftables(nil, []string{"198.51.100.2", "198.51.100.3"}, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "ip saddr { 198.51.100.2, 198.51.100.3 } tcp dport 22 accept") {
|
||||
t.Fatalf("ssh is not open to the mesh, so a machine can lock everyone out:\n%s", nft)
|
||||
}
|
||||
@@ -685,7 +787,7 @@ func TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned(t *testing.T) {
|
||||
// And from outside as well, on a machine that faces outward — because that is the way in when the
|
||||
// private network is the thing that broke.
|
||||
func TestSSHIsOpenFromOutsideOnAMachineThatFacesIt(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil)
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "\t\ttcp dport 22 accept") {
|
||||
t.Fatalf("a machine reachable from outside does not answer ssh there:\n%s", nft)
|
||||
}
|
||||
@@ -697,7 +799,7 @@ func TestSSHIsOpenFromOutsideOnAMachineThatFacesIt(t *testing.T) {
|
||||
// to narrow the rule to, so narrowing it shuts the port entirely — on the first machine anybody
|
||||
// adopts, reached over the network, closed by the act of adopting it.
|
||||
func TestSSHIsNeverLeftWithoutARule(t *testing.T) {
|
||||
nft := AsNftables(nil, nil, false, nil, nil)
|
||||
nft := AsNftables(nil, nil, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "tcp dport 22 accept") {
|
||||
t.Fatalf("a machine with no mesh addresses has no ssh rule, so adopting it locks it:\n%s", nft)
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@ func TestPrintRehearsalRuleset(t *testing.T) {
|
||||
rules := mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "pub", Listens: []Listening{{Port: 8099, From: FromMesh, Why: "the thing it serves"}}},
|
||||
}}, nil)
|
||||
t.Log("\n" + AsNftables(rules, []string{"192.0.2.20"}, true, nil, nil))
|
||||
t.Log("\n" + AsNftables(rules, []string{"192.0.2.20"}, true, nil, nil, "mesh0"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user