Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0d11c2439 | ||
|
|
155689672c | ||
|
|
e82789a322 | ||
|
|
99562947f3 | ||
|
|
c171c64d3a | ||
|
|
0dc5515099 | ||
|
|
58c0e715c7 | ||
|
|
c5b229f95d | ||
|
|
a9c49724b5 | ||
|
|
296ec5ece6 | ||
|
|
45b9a507a1 | ||
|
|
5c410aaf54 | ||
|
|
c82e933268 | ||
|
|
e212da6bd2 |
@@ -33,6 +33,7 @@ import (
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
"github.com/novox/mesh-host/internal/inventory"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/outward"
|
||||
"github.com/novox/mesh-host/internal/profile"
|
||||
"github.com/novox/mesh-host/internal/reachable"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
@@ -1263,6 +1264,15 @@ func applyAndKeep(ctx context.Context, opts options, raw []byte, signed *store.D
|
||||
}
|
||||
|
||||
report := link.Report{Carried: carriedPorts(updated), Declared: digestOf(raw)}
|
||||
// Which of this machine's links face outside, for the filter the mesh writes around them
|
||||
// (novox/hq ADR 0140). Reported whatever the node's mode: a converged node's filter needs it,
|
||||
// and an adopted one becomes converged without a further round trip. A machine that cannot read
|
||||
// its own routing table says nothing rather than guessing, and is sent no filter.
|
||||
if links, err := outward.Links(""); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "mesh-host: applied, and could not read which links face outside: %v\n", err)
|
||||
} else {
|
||||
report.Outward = links
|
||||
}
|
||||
// What this node found and holds, its firewall, and what is reachable on it — so an adopted
|
||||
// node never reads as converged (novox/hq ADR 0100).
|
||||
for _, h := range updated.Held {
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh-bus-conf/accounts.conf",
|
||||
"mode": "0600",
|
||||
"content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.API.>\", \"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"_INBOX.enrol.>\", \"mesh.control.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\"] }\n subscribe: { allow: [\"$JS.API.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n"
|
||||
"content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.API.>\", \"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"_INBOX.enrol.>\", \"mesh.control.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\"] }\n subscribe: { allow: [\"$JS.API.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.seat.mesh-build-machine.event.built\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "broker",
|
||||
|
||||
+68
-1
@@ -320,6 +320,9 @@ func ApplyKeeping(
|
||||
// is a different thing — one is "this machine could not do it", the other is "this was never
|
||||
// a declaration", and they are fixed in different places.
|
||||
var failures []*Error
|
||||
// Modules whose own step did not complete. What follows *within such a module* is not attempted;
|
||||
// the rest of the machine is (novox/hq ADR 0136).
|
||||
gated := map[string]bool{}
|
||||
for i, resource := range ordered {
|
||||
if !orphansRemoved && i == guardFirst {
|
||||
// **Only a guard that is up may let the filter go.** Removing the derived filter's
|
||||
@@ -337,6 +340,17 @@ func ApplyKeeping(
|
||||
return report, known, err
|
||||
}
|
||||
}
|
||||
// **A module whose step did not complete is skipped from there on** (novox/hq ADR 0136).
|
||||
// Reported rather than passed over in silence: "not attempted" and "nothing to do" are
|
||||
// different answers, and only one of them is somebody's to fix.
|
||||
if module, ours := moduleOf(resource.Identity()); ours && gated[module] {
|
||||
report.Outcomes = append(report.Outcomes, Outcome{
|
||||
ID: resource.Identity(), Type: string(resource.Kind()), Target: resource.Target(),
|
||||
Action: "skipped", Detail: "a step this module declares did not complete",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// **On an adopted node, what is found is kept until its module is taken** (novox/hq ADR
|
||||
// 0100, ADR 0103). Before anything is applied: whatever of a module not yet taken is
|
||||
// present with no record of this host making it — or would reach what is — is held as it
|
||||
@@ -465,9 +479,26 @@ func ApplyKeeping(
|
||||
gates = true
|
||||
}
|
||||
if gates {
|
||||
failed.Gated = true
|
||||
// **A module's step gates that module, not the machine** (novox/hq ADR 0136).
|
||||
//
|
||||
// Stopping the whole apply is what this loop's own comment above calls holding a
|
||||
// machine hostage, and it was already rejected for every other shape (04-ISSUES/011).
|
||||
// A step exists to make something true before the next thing in *its module* needs it
|
||||
// — a store seeded before the broker starts, a schema prepared before the version
|
||||
// that needs it runs — so that is exactly how far the gate reaches. Everything else
|
||||
// on the machine is independent state and is attempted.
|
||||
//
|
||||
// An action still gates the machine: the bootstrap is a row of them, each making the
|
||||
// next possible, and they belong to no module.
|
||||
if module, ours := moduleOf(resource.Identity()); ours {
|
||||
gated[module] = true
|
||||
log(fmt.Sprintf(" gated %s.*: a step it declares did not complete, so the rest "+
|
||||
"of it was not attempted", module))
|
||||
continue
|
||||
}
|
||||
failed.Done = report
|
||||
failed.Others = len(failures) - 1
|
||||
failed.Gated = true
|
||||
return report, known, failed
|
||||
}
|
||||
continue
|
||||
@@ -1470,6 +1501,20 @@ func containerSpecReading(r *declaration.Container, declares, reads map[string]s
|
||||
for _, a := range r.Args {
|
||||
b.WriteString("arg " + a + "\n")
|
||||
}
|
||||
// **The mesh's names are part of what a container is** (novox/hq 04-ISSUES/135). A container
|
||||
// resolves every other machine and every public name through the entries the mesh gives it at
|
||||
// creation, and nothing re-reads them afterwards — so a container left alone when the roster
|
||||
// moved is one that cannot reach anything by name, for ever, while every check reports it
|
||||
// running. That is exactly what happened when this mesh's overlay range changed: one container
|
||||
// whose image and files never changed kept an address five days out of date and restarted
|
||||
// 2286 times against a database it could no longer find.
|
||||
//
|
||||
// Sorted, so the digest does not move for a reordering nobody made.
|
||||
hosts := append([]string(nil), r.Hosts...)
|
||||
sort.Strings(hosts)
|
||||
for _, h := range hosts {
|
||||
b.WriteString("host " + h + "\n")
|
||||
}
|
||||
// The resolver and address are part of what was declared: a container whose dns or ip moved
|
||||
// is a different container, or the fields could never reach one that already ran — which is
|
||||
// exactly how their first deployment silently changed nothing.
|
||||
@@ -2247,3 +2292,25 @@ func meshMadeUnits(known store.State) map[string]bool {
|
||||
}
|
||||
return made
|
||||
}
|
||||
|
||||
// moduleOf is the module a declared resource belongs to.
|
||||
//
|
||||
// The mesh composes a module's resource ids as `<module>.<its own id>`, and **a module's name may
|
||||
// contain a dot** — `novox.be` is one on this mesh — while a resource's own id never does. So the
|
||||
// owner is everything before the *last* dot; reading to the first one would make `novox.be.server`
|
||||
// belong to a module called "novox", and a gate would then skip whatever else happened to start that
|
||||
// way.
|
||||
//
|
||||
// False for what the mesh declares in its own right: the foundation's resources carry no dot at all,
|
||||
// and the adoption's are named for the mesh rather than for a module. Both belong to no module, and
|
||||
// their gate is therefore the machine's.
|
||||
func moduleOf(identity string) (string, bool) {
|
||||
if strings.HasPrefix(identity, declaration.AdoptionPrefix) {
|
||||
return "", false
|
||||
}
|
||||
at := strings.LastIndex(identity, ".")
|
||||
if at <= 0 {
|
||||
return "", false
|
||||
}
|
||||
return identity[:at], true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
)
|
||||
|
||||
// **A container's mesh names are part of what it is** (novox/hq 04-ISSUES/135).
|
||||
//
|
||||
// A container resolves every machine and every public name through the entries it was given when it
|
||||
// was created, and nothing re-reads them. So a container the host leaves alone because nothing else
|
||||
// about it changed is a container that cannot reach anything by name — for ever, while every check
|
||||
// reports it running. That is what happened when this mesh's overlay range moved: one container kept
|
||||
// an address five days out of date and restarted 2286 times against a database it could no longer
|
||||
// find, and the host compared everything about it except that.
|
||||
func TestAContainersMeshNamesAreComparedLikeTheRestOfIt(t *testing.T) {
|
||||
was := &declaration.Container{
|
||||
Name: "umami", Image: "ghcr.io/example/umami@sha256:" + zeros(64),
|
||||
Hosts: []string{"novox.internal:10.42.0.1", "umami.novox.be:10.42.0.1"},
|
||||
}
|
||||
moved := &declaration.Container{
|
||||
Name: was.Name, Image: was.Image,
|
||||
Hosts: []string{"novox.internal:10.10.0.1", "umami.novox.be:10.10.0.1"},
|
||||
}
|
||||
if containerSpecReading(was, nil, nil) == containerSpecReading(moved, nil, nil) {
|
||||
t.Fatal("a container whose mesh names moved compares equal, so it is never recreated")
|
||||
}
|
||||
|
||||
// And the order they arrive in is not a change: the digest must not move for a reordering
|
||||
// nobody made.
|
||||
reordered := &declaration.Container{
|
||||
Name: moved.Name, Image: moved.Image,
|
||||
Hosts: []string{moved.Hosts[1], moved.Hosts[0]},
|
||||
}
|
||||
if containerSpecReading(moved, nil, nil) != containerSpecReading(reordered, nil, nil) {
|
||||
t.Fatal("the same names in another order read as a different container")
|
||||
}
|
||||
|
||||
// A container the mesh gives no names is unaffected, so nothing is recreated for a field it
|
||||
// does not set.
|
||||
plain := &declaration.Container{Name: "plex", Image: was.Image}
|
||||
if containerSpecReading(plain, nil, nil) == containerSpecReading(was, nil, nil) {
|
||||
return // different for other reasons, which is fine
|
||||
}
|
||||
}
|
||||
|
||||
func zeros(n int) string {
|
||||
out := make([]byte, n)
|
||||
for i := range out {
|
||||
out[i] = '0'
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -316,3 +316,85 @@ func TestAContainerNamingARunOnceStepIsRecreatedWhenItRan(t *testing.T) {
|
||||
t.Errorf("the recreation did not name the step as its reason: %+v", server)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedStepGatesItsModuleAndNotTheMachine(t *testing.T) {
|
||||
// **The blast radius of a step is its module** (novox/hq ADR 0136). A step exists to make
|
||||
// something true before the next thing in its own module needs it — a store seeded before the
|
||||
// broker starts, a schema prepared before the version that needs it runs. Stopping the whole
|
||||
// apply is what this host's own loop calls holding a machine hostage, and it was already
|
||||
// rejected for every other shape (04-ISSUES/011): a module whose database is briefly
|
||||
// unreachable must not stop every module declared after it.
|
||||
var startedNames []string
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "27.0\n", nil
|
||||
case "container":
|
||||
return "false\t\n", errors.New("no such container")
|
||||
case "run":
|
||||
startedNames = append(startedNames, nameOf(args))
|
||||
if nameOf(args) == "catalogue-prepare" {
|
||||
return "", errors.New("exit status 1") // the schema could not be reached
|
||||
}
|
||||
return "deadbeef\n", nil
|
||||
case "rm":
|
||||
return "", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"mesh-catalog.runtime-prepare","type":"container","name":"catalogue-prepare","image":"`+pinned+`","run-once":true},
|
||||
{"id":"mesh-catalog.runtime","type":"container","name":"catalogue","image":"`+pinned+`"},
|
||||
{"id":"gitea.server","type":"container","name":"forge","image":"`+pinned+`"}
|
||||
]}`)
|
||||
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a failed step was not reported as a failure")
|
||||
}
|
||||
started := map[string]bool{}
|
||||
for _, n := range startedNames {
|
||||
started[n] = true
|
||||
}
|
||||
if started["catalogue"] {
|
||||
t.Error("the module's own workload ran although its step did not complete")
|
||||
}
|
||||
if !started["forge"] {
|
||||
t.Error("another module was not attempted, so one module's step held the machine hostage")
|
||||
}
|
||||
// And the machine's own account says which was not attempted, rather than leaving it to be
|
||||
// inferred from silence.
|
||||
var skipped string
|
||||
for _, o := range report.Outcomes {
|
||||
if o.Action == "skipped" {
|
||||
skipped = o.ID
|
||||
}
|
||||
}
|
||||
if skipped != "mesh-catalog.runtime" {
|
||||
t.Errorf("the report does not say what was not attempted: %q", skipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAResourcesOwnerIsReadToTheLastDot(t *testing.T) {
|
||||
// **A module's name may contain a dot.** `novox.be` is one on this mesh, so reading a resource's
|
||||
// owner to the first dot would make its resources belong to something called "novox" — and a gate
|
||||
// would skip whatever else happened to start that way. A resource's own id never contains one,
|
||||
// which is what makes the last dot the boundary.
|
||||
for identity, want := range map[string]string{
|
||||
"novox.be.server": "novox.be",
|
||||
"mesh-catalog.runtime-prepare": "mesh-catalog",
|
||||
"gitea.admin-bootstrap": "gitea",
|
||||
} {
|
||||
got, ours := moduleOf(identity)
|
||||
if !ours || got != want {
|
||||
t.Errorf("%q belongs to %q (%v), want %q", identity, got, ours, want)
|
||||
}
|
||||
}
|
||||
// What the mesh declares in its own right belongs to no module: the foundation's resources carry
|
||||
// no dot, and the adoption's are the mesh's.
|
||||
for _, identity := range []string{"container-runtime", "store-ready", "adoption.guard", ".server"} {
|
||||
if _, ours := moduleOf(identity); ours {
|
||||
t.Errorf("%q was read as a module's", identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,20 @@ type Report struct {
|
||||
// and the mesh's up in its place, and where the found configuration's original was kept.
|
||||
Tunnel *CarriedTunnel `json:"tunnel,omitempty"`
|
||||
|
||||
// Outward is the links on this machine that face outside it — the ones carrying a default
|
||||
// route (novox/hq ADR 0140). Every node reports it, adopted or converged, because a converged
|
||||
// node's filter is written around it.
|
||||
//
|
||||
// **It replaces a list of addresses.** The filter used to block everything passing through the
|
||||
// machine and then allow the machine's own containers back by naming the ranges they sit on.
|
||||
// A range describes one machine and goes stale in silence; the link carrying the default route
|
||||
// is read afresh on every report and does not change when a module is added or removed.
|
||||
//
|
||||
// Empty means this machine has no route off itself. The mesh then composes no filter for it and
|
||||
// leaves the one it has, rather than writing a rule around a link with no name — a rule set
|
||||
// that does not load is a machine filtering nothing while its unit reports success.
|
||||
Outward []string `json:"outward,omitempty"`
|
||||
|
||||
// Rekey is this node taking a found tunnel's key as its overlay key after enrolment (novox/hq
|
||||
// ADR 0105). Not an account of the machine: a report carrying one says nothing else.
|
||||
Rekey *Rekey `json:"rekey,omitempty"`
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Package outward reads which of this machine's links face outside it (novox/hq ADR 0140).
|
||||
//
|
||||
// The filter the mesh derives constrains traffic arriving from outside the machine and says nothing
|
||||
// about traffic that did not. To write that rule the mesh has to know which links "outside" arrives
|
||||
// on, and that is a thing only the machine can say — so it says it, once per report, the way it
|
||||
// already reports the kind of firewall it found and the tunnel it carried.
|
||||
//
|
||||
// **It replaces a list of addresses.** The filter used to allow the machine's own containers back
|
||||
// through by naming the address ranges they sit on: two ranges fixed in the control plane's source
|
||||
// and the rest typed by an operator. A range describes one machine and goes stale silently
|
||||
// (novox/hq 04-ISSUES/137 and /141). A link that carries the default route is a fact the machine
|
||||
// reads afresh every time, and it does not change when a module is added or removed.
|
||||
//
|
||||
// It reads the kernel's routing tables directly rather than asking a program. A module naming a
|
||||
// program the machine does not have is how the mesh already reported success while doing nothing
|
||||
// (novox/hq 04-ISSUES/136), and every machine has /proc.
|
||||
package outward
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProcNet is where the kernel publishes its routing tables. A parameter so a test can hold a
|
||||
// routing table without one.
|
||||
const ProcNet = "/proc/net"
|
||||
|
||||
// Links are the interfaces carrying a default route, for both address families, sorted and without
|
||||
// repeats.
|
||||
//
|
||||
// A machine may have more than one: a laptop with a cable and a radio has two, and both face
|
||||
// outside. A machine with none — no route off itself — returns nothing, and the mesh refuses to
|
||||
// compose a filter for it rather than writing a rule around a link with no name, which would be a
|
||||
// rule set that does not load and a machine filtering nothing while its unit reports success.
|
||||
func Links(procNet string) ([]string, error) {
|
||||
if procNet == "" {
|
||||
procNet = ProcNet
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
|
||||
four, err := defaultsV4(filepath.Join(procNet, "route"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
six, err := defaultsV6(filepath.Join(procNet, "ipv6_route"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, name := range append(four, six...) {
|
||||
if name != "" && name != "lo" {
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(seen))
|
||||
for name := range seen {
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// defaultsV4 reads /proc/net/route, whose columns are
|
||||
//
|
||||
// Iface Destination Gateway Flags RefCnt Use Metric Mask ...
|
||||
//
|
||||
// with addresses in hexadecimal. A default route is destination zero with mask zero — the mask
|
||||
// matters, because a route to the zero address with a real mask is not a default route.
|
||||
func defaultsV4(path string) ([]string, error) {
|
||||
lines, err := rows(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, fields := range lines {
|
||||
if len(fields) < 8 {
|
||||
continue
|
||||
}
|
||||
if isZeroHex(fields[1]) && isZeroHex(fields[7]) {
|
||||
out = append(out, fields[0])
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// defaultsV6 reads /proc/net/ipv6_route, whose columns are
|
||||
//
|
||||
// dest destprefix src srcprefix nexthop metric refcnt use flags iface
|
||||
//
|
||||
// A default route is the zero destination with a zero prefix length.
|
||||
func defaultsV6(path string) ([]string, error) {
|
||||
lines, err := rows(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, fields := range lines {
|
||||
if len(fields) < 10 {
|
||||
continue
|
||||
}
|
||||
if isZeroHex(fields[0]) && isZeroHex(fields[1]) {
|
||||
out = append(out, fields[9])
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// rows reads a routing table into fields per line, skipping a header and blank lines. A table that
|
||||
// is not there is not an error: a machine without the second address family has no file for it,
|
||||
// and that is not a machine that cannot be filtered.
|
||||
func rows(path string) ([][]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read the routing table at %s: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var out [][]string
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "Iface") {
|
||||
continue
|
||||
}
|
||||
out = append(out, strings.Fields(line))
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("cannot read the routing table at %s: %w", path, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isZeroHex is whether a hexadecimal field is all zeroes, whatever its width — the v4 table writes
|
||||
// eight digits and the v6 table thirty-two, and a prefix length is two.
|
||||
func isZeroHex(field string) bool {
|
||||
if field == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Trim(strings.ToLower(field), "0") == ""
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package outward
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A routing table as the kernel writes it: a default route, a route to the zero address that is not
|
||||
// one, and a route on the loopback. Only the default route's link faces outside.
|
||||
const routeV4 = `Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
|
||||
enp9s0 00000000 01FEA8C0 0003 0 0 100 00000000 0 0 0
|
||||
docker0 000011AC 00000000 0001 0 0 0 0000FFFF 0 0 0
|
||||
enp9s0 00000000 00000000 0001 0 0 100 00FFFFFF 0 0 0
|
||||
lo 00000000 00000000 0003 0 0 0 00000000 0 0 0
|
||||
`
|
||||
|
||||
const routeV6 = `00000000000000000000000000000000 00 00000000000000000000000000000000 00 fe800000000000000000000000000001 00000400 00000001 00000000 00000003 wlan0
|
||||
fd0000000000000000000000000000000 40 00000000000000000000000000000000 00 00000000000000000000000000000000 00000100 00000000 00000000 00000001 enp9s0
|
||||
`
|
||||
|
||||
func write(t *testing.T, dir, name, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinksAreTheOnesCarryingADefaultRoute(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, "route", routeV4)
|
||||
write(t, dir, "ipv6_route", routeV6)
|
||||
|
||||
got, err := Links(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The cable from the v4 table and the radio from the v6 one. Not docker0, whose route is not a
|
||||
// default; not the loopback, which faces nothing; and not the v6 route with a real prefix.
|
||||
want := []string{"enp9s0", "wlan0"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("outward links are %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A route to the zero address with a real mask is not a default route. Trusting the destination
|
||||
// alone would name every link with such a route as facing outside, and a filter that treats an
|
||||
// internal bridge as outward constrains this machine's own guests — the fault ADR 0140 removes.
|
||||
func TestAZeroDestinationWithAMaskIsNotADefaultRoute(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, "route", `Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
|
||||
br-abc 00000000 00000000 0001 0 0 0 00FFFFFF 0 0 0
|
||||
`)
|
||||
got, err := Links(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("outward links are %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine with no route off itself says so, rather than guessing. The mesh refuses to compose a
|
||||
// filter for it; a rule written around a link with no name does not load, and a rule set that does
|
||||
// not load is a machine filtering nothing while its unit reports success.
|
||||
func TestNoDefaultRouteIsNoLinks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, "route", "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n")
|
||||
got, err := Links(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("outward links are %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine without the second address family has no file for it. That is not a machine that cannot
|
||||
// be filtered, so a missing table is read as no routes rather than as a failure.
|
||||
func TestAMissingTableIsNotAFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, "route", routeV4)
|
||||
got, err := Links(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("a missing v6 table should not fail: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, []string{"enp9s0"}) {
|
||||
t.Fatalf("outward links are %v, want [enp9s0]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The same link carrying a default route in both families is reported once.
|
||||
func TestALinkIsReportedOnce(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, "route", routeV4)
|
||||
write(t, dir, "ipv6_route",
|
||||
"00000000000000000000000000000000 00 00000000000000000000000000000000 00 "+
|
||||
"fe800000000000000000000000000001 00000400 00000001 00000000 00000003 enp9s0\n")
|
||||
got, err := Links(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, []string{"enp9s0"}) {
|
||||
t.Fatalf("outward links are %v, want [enp9s0]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Against this machine's own routing table, so the parse is held to what the kernel actually writes
|
||||
// and not only to a fixture written to agree with it.
|
||||
func TestAgainstThisMachinesOwnTable(t *testing.T) {
|
||||
got, err := Links("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Skip("this machine has no default route")
|
||||
}
|
||||
t.Logf("this machine's outward links: %v", got)
|
||||
}
|
||||
Reference in New Issue
Block a user