A machine says which of its links face outside #45
@@ -33,6 +33,7 @@ import (
|
|||||||
"github.com/novox/mesh-host/internal/identity"
|
"github.com/novox/mesh-host/internal/identity"
|
||||||
"github.com/novox/mesh-host/internal/inventory"
|
"github.com/novox/mesh-host/internal/inventory"
|
||||||
"github.com/novox/mesh-host/internal/link"
|
"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/profile"
|
||||||
"github.com/novox/mesh-host/internal/reachable"
|
"github.com/novox/mesh-host/internal/reachable"
|
||||||
"github.com/novox/mesh-host/internal/store"
|
"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)}
|
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
|
// 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).
|
// node never reads as converged (novox/hq ADR 0100).
|
||||||
for _, h := range updated.Held {
|
for _, h := range updated.Held {
|
||||||
|
|||||||
@@ -110,6 +110,20 @@ type Report struct {
|
|||||||
// and the mesh's up in its place, and where the found configuration's original was kept.
|
// and the mesh's up in its place, and where the found configuration's original was kept.
|
||||||
Tunnel *CarriedTunnel `json:"tunnel,omitempty"`
|
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
|
// 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.
|
// ADR 0105). Not an account of the machine: a report carrying one says nothing else.
|
||||||
Rekey *Rekey `json:"rekey,omitempty"`
|
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