Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb7b71dcc5 |
@@ -0,0 +1,29 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-host/internal/link"
|
||||||
|
"github.com/novox/mesh-host/internal/outward"
|
||||||
|
"github.com/novox/mesh-host/internal/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
func reportOn(networks ...outward.Network) link.Report {
|
||||||
|
return link.Report{Outward: []string{"eth0"},
|
||||||
|
Profile: profileAsReported(profile.Profile{Machine: &profile.Machine{Networks: networks}})}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **A change of the networks a machine is attached to is said unasked** (novox/hq ADR 0340 §7): a port
|
||||||
|
// reached from the local network is admitted from them, so a laptop that joins another network is said at
|
||||||
|
// the next reconcile, not at the next push — and the same networks again are not news.
|
||||||
|
func TestAChangedNetworkIsSaidUnasked(t *testing.T) {
|
||||||
|
w := &adoptionWatch{}
|
||||||
|
home := reportOn(outward.Network{Link: "eth0", Network: "192.168.1.0/24"})
|
||||||
|
w.said(home)
|
||||||
|
if w.differs(reportOn(outward.Network{Link: "eth0", Network: "192.168.1.0/24"})) {
|
||||||
|
t.Fatal("the same networks read as a change, so the machine would speak on every reconcile")
|
||||||
|
}
|
||||||
|
if !w.differs(reportOn(outward.Network{Link: "eth0", Network: "10.20.0.0/16"})) {
|
||||||
|
t.Fatal("a machine that moved to another network does not say so until it is pushed to")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1397,6 +1397,12 @@ func adoptionFingerprint(r link.Report) string {
|
|||||||
// it could only speak when a declaration arrived, and a declaration cannot be composed until it
|
// it could only speak when a declaration arrived, and a declaration cannot be composed until it
|
||||||
// has spoken: a machine waiting for a push that is waiting for the machine.
|
// has spoken: a machine waiting for a push that is waiting for the machine.
|
||||||
parts := []string{"firewall=" + r.Firewall, "outward=" + strings.Join(r.Outward, ",")}
|
parts := []string{"firewall=" + r.Firewall, "outward=" + strings.Join(r.Outward, ",")}
|
||||||
|
// **And the networks those links are on** (novox/hq ADR 0340 §7): a port reached from the local
|
||||||
|
// network is admitted from them, so a laptop that joins another network, or a lease that moves, is
|
||||||
|
// said at the next reconcile rather than at the next push or the next day.
|
||||||
|
if networks := attachedNetworksOf(r); networks != "" {
|
||||||
|
parts = append(parts, "networks="+networks)
|
||||||
|
}
|
||||||
for _, h := range r.Held {
|
for _, h := range r.Held {
|
||||||
parts = append(parts, "held "+h.ID+"="+h.Changed)
|
parts = append(parts, "held "+h.ID+"="+h.Changed)
|
||||||
}
|
}
|
||||||
@@ -1428,6 +1434,26 @@ func adoptionFingerprint(r link.Report) string {
|
|||||||
return strings.Join(parts, "\n")
|
return strings.Join(parts, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attachedNetworksOf is the networks a report's profile says the machine is attached to, as one line: the
|
||||||
|
// part of what the machine is made of that the filter is composed from (novox/hq ADR 0340).
|
||||||
|
func attachedNetworksOf(r link.Report) string {
|
||||||
|
machine, ok := r.Profile["machine"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
networks, ok := machine["networks"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
said := make([]string, 0, len(networks))
|
||||||
|
for _, n := range networks {
|
||||||
|
if entry, ok := n.(map[string]any); ok {
|
||||||
|
said = append(said, fmt.Sprintf("%v %v", entry["link"], entry["network"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(said, ",")
|
||||||
|
}
|
||||||
|
|
||||||
// applyState is how an apply went, as the fingerprint keeps it: refused, clean, or failed with what it did not
|
// applyState is how an apply went, as the fingerprint keeps it: refused, clean, or failed with what it did not
|
||||||
// apply and which keys its failure is said under.
|
// apply and which keys its failure is said under.
|
||||||
func applyState(r link.Report) string {
|
func applyState(r link.Report) string {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package outward
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Network is one network this machine is directly attached to, and the link it is on (novox/hq ADR
|
||||||
|
// 0340 §7): the address and prefix of the network, not of the machine's own address on it.
|
||||||
|
//
|
||||||
|
// **Every one is said, and the machine judges none of them.** Which count as the machine's local
|
||||||
|
// network — private ranges only, never a public subnet, never a link-local one — is the controller's
|
||||||
|
// judgement (ADR 0340 §2), made where the filter is composed and shown with why; a machine that left a
|
||||||
|
// network out would make that judgement silently.
|
||||||
|
type Network struct {
|
||||||
|
Link string `json:"link"`
|
||||||
|
Network string `json:"network"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddressesOf is the addresses on one link, as address and prefix. A parameter so a test can hold a
|
||||||
|
// machine's links without one.
|
||||||
|
type AddressesOf func(link string) ([]netip.Prefix, error)
|
||||||
|
|
||||||
|
// RealAddresses reads one link's addresses from the kernel.
|
||||||
|
func RealAddresses(link string) ([]netip.Prefix, error) {
|
||||||
|
iface, err := net.InterfaceByName(link)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
addrs, err := iface.Addrs()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []netip.Prefix
|
||||||
|
for _, a := range addrs {
|
||||||
|
ipnet, ok := a.(*net.IPNet)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addr, ok := netip.AddrFromSlice(ipnet.IP)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bits, _ := ipnet.Mask.Size()
|
||||||
|
out = append(out, netip.PrefixFrom(addr.Unmap(), bits))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Networks is the networks on each of these links — the links that face outside (Links) — sorted by link
|
||||||
|
// and network, without repeats. A link that has gone since Links read it has no networks rather than
|
||||||
|
// failing the rest; a link whose addresses cannot be read fails, so the machine says the fact is unknown
|
||||||
|
// rather than reporting a partial account as the whole.
|
||||||
|
func Networks(links []string, addressesOf AddressesOf) ([]Network, error) {
|
||||||
|
if addressesOf == nil {
|
||||||
|
addressesOf = RealAddresses
|
||||||
|
}
|
||||||
|
seen := map[Network]bool{}
|
||||||
|
var out []Network
|
||||||
|
for _, link := range links {
|
||||||
|
prefixes, err := addressesOf(link)
|
||||||
|
if err != nil {
|
||||||
|
if isNoSuchInterface(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("cannot read the addresses on %s: %w", link, err)
|
||||||
|
}
|
||||||
|
for _, p := range prefixes {
|
||||||
|
n := Network{Link: link, Network: p.Masked().String()}
|
||||||
|
if !seen[n] {
|
||||||
|
seen[n] = true
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Link != out[j].Link {
|
||||||
|
return out[i].Link < out[j].Link
|
||||||
|
}
|
||||||
|
return out[i].Network < out[j].Network
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNoSuchInterface is the error net.InterfaceByName gives for a link that is not there.
|
||||||
|
func isNoSuchInterface(err error) bool {
|
||||||
|
var op *net.OpError
|
||||||
|
return errors.As(err, &op) && op.Err != nil && op.Err.Error() == "no such network interface"
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package outward
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/netip"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func prefixes(s ...string) []netip.Prefix {
|
||||||
|
var out []netip.Prefix
|
||||||
|
for _, p := range s {
|
||||||
|
out = append(out, netip.MustParsePrefix(p))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Each link facing outside says the networks it is attached to** (novox/hq ADR 0340 §7), as the
|
||||||
|
// network's address and prefix, never the machine's own address on it — and every one, public and
|
||||||
|
// link-local included, because which count is the controller's judgement and said with why.
|
||||||
|
func TestEachOutwardLinkSaysTheNetworksItIsOn(t *testing.T) {
|
||||||
|
addrs := map[string][]netip.Prefix{
|
||||||
|
"eth0": prefixes("192.168.1.10/24", "fe80::1/64", "fd12:3456:789a::5/64"),
|
||||||
|
"eth1": prefixes("203.0.113.7/24"),
|
||||||
|
// Not asked: the bridge is not a link facing outside.
|
||||||
|
"docker0": prefixes("172.17.0.1/16"),
|
||||||
|
}
|
||||||
|
got, err := Networks([]string{"eth1", "eth0"}, func(link string) ([]netip.Prefix, error) { return addrs[link], nil })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []Network{
|
||||||
|
{Link: "eth0", Network: "192.168.1.0/24"},
|
||||||
|
{Link: "eth0", Network: "fd12:3456:789a::/64"},
|
||||||
|
{Link: "eth0", Network: "fe80::/64"},
|
||||||
|
{Link: "eth1", Network: "203.0.113.0/24"},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("got %+v\nwant %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A link whose addresses cannot be read fails the whole, so the machine says the fact is unknown rather
|
||||||
|
// than reporting part of its networks as all of them.
|
||||||
|
func TestAnUnreadableLinkIsNotAPartialAccount(t *testing.T) {
|
||||||
|
_, err := Networks([]string{"eth0"}, func(string) ([]netip.Prefix, error) { return nil, errors.New("denied") })
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("an unreadable link was reported as having no networks")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A link gone between reading the routing table and reading its addresses has no networks, and the
|
||||||
|
// rest are still said.
|
||||||
|
func TestALinkGoneSinceItWasListedHasNoNetworks(t *testing.T) {
|
||||||
|
got, err := Networks([]string{"gone0"}, RealAddresses)
|
||||||
|
if err != nil || len(got) != 0 {
|
||||||
|
t.Fatalf("got %v, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-host/internal/outward"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Machine is what this machine is made of, as it says itself (novox/hq issue 160): what somebody
|
// Machine is what this machine is made of, as it says itself (novox/hq issue 160): what somebody
|
||||||
@@ -50,6 +52,11 @@ type Machine struct {
|
|||||||
// Filesystems is each local filesystem mounted, by where, with its size and what is free.
|
// Filesystems is each local filesystem mounted, by where, with its size and what is free.
|
||||||
Filesystems []Filesystem `json:"filesystems,omitempty"`
|
Filesystems []Filesystem `json:"filesystems,omitempty"`
|
||||||
|
|
||||||
|
// Networks is every network the machine is directly attached to on a link facing outside it, as the
|
||||||
|
// link and the network's address and prefix (novox/hq ADR 0340 §7): what a port reached from
|
||||||
|
// `local-network` is resolved from. Every one is said; which count is the controller's judgement.
|
||||||
|
Networks []outward.Network `json:"networks,omitempty"`
|
||||||
|
|
||||||
// Unknown names each fact that could not be determined, and why.
|
// Unknown names each fact that could not be determined, and why.
|
||||||
Unknown map[string]string `json:"unknown,omitempty"`
|
Unknown map[string]string `json:"unknown,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -79,11 +86,12 @@ const (
|
|||||||
FactTimezone = "timezone"
|
FactTimezone = "timezone"
|
||||||
FactFilesystems = "filesystems"
|
FactFilesystems = "filesystems"
|
||||||
FactPublicAddress = "public address"
|
FactPublicAddress = "public address"
|
||||||
|
FactNetworks = "networks"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MachineFacts is every fact Unknown may name, in the order a reader shows them.
|
// MachineFacts is every fact Unknown may name, in the order a reader shows them.
|
||||||
var MachineFacts = []string{FactKernelRelease, FactDistribution, FactVersion, FactProcessor, FactCores,
|
var MachineFacts = []string{FactKernelRelease, FactDistribution, FactVersion, FactProcessor, FactCores,
|
||||||
FactThreads, FactMemory, FactVirtual, FactUptime, FactTimezone, FactFilesystems, FactPublicAddress}
|
FactThreads, FactMemory, FactVirtual, FactUptime, FactTimezone, FactFilesystems, FactNetworks, FactPublicAddress}
|
||||||
|
|
||||||
// publicAddressUnknown is why the public uplink address is not said. A machine behind a NAT cannot see
|
// publicAddressUnknown is why the public uplink address is not said. A machine behind a NAT cannot see
|
||||||
// its public address from itself, and asking a service outside the mesh is a dependency the mesh does not
|
// its public address from itself, and asking a service outside the mesh is a dependency the mesh does not
|
||||||
@@ -101,11 +109,24 @@ type Sources struct {
|
|||||||
// Statfs is (size, free) in bytes of the filesystem mounted at a path.
|
// Statfs is (size, free) in bytes of the filesystem mounted at a path.
|
||||||
Statfs func(path string) (size, free int64, err error)
|
Statfs func(path string) (size, free int64, err error)
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
|
// Networks is the networks on the links facing outside this machine (novox/hq ADR 0340).
|
||||||
|
Networks func() ([]outward.Network, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RealSources reads this machine.
|
// RealSources reads this machine.
|
||||||
func RealSources() Sources {
|
func RealSources() Sources {
|
||||||
return Sources{Read: os.ReadFile, Readlink: os.Readlink, Run: ExecRunner, Statfs: statfs, Now: time.Now}
|
return Sources{Read: os.ReadFile, Readlink: os.Readlink, Run: ExecRunner, Statfs: statfs, Now: time.Now,
|
||||||
|
Networks: attachedNetworks}
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachedNetworks is this machine's networks on the links that face outside it, read the way the report
|
||||||
|
// reads those links (novox/hq ADR 0140), so the two never disagree about which links those are.
|
||||||
|
func attachedNetworks() ([]outward.Network, error) {
|
||||||
|
links, err := outward.Links("", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return outward.Networks(links, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func statfs(path string) (int64, int64, error) {
|
func statfs(path string) (int64, int64, error) {
|
||||||
@@ -193,6 +214,18 @@ func DescribeMachine(ctx context.Context, src Sources) Machine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The networks it is attached to, for the local network (novox/hq ADR 0340): every one on a link facing
|
||||||
|
// outside, or why none is said — never an empty list that reads as a machine on no network.
|
||||||
|
if src.Networks == nil {
|
||||||
|
unknown(FactNetworks, "this node-engine was given no way to read its links")
|
||||||
|
} else if networks, err := src.Networks(); err != nil {
|
||||||
|
unknown(FactNetworks, err.Error())
|
||||||
|
} else if len(networks) == 0 {
|
||||||
|
unknown(FactNetworks, "no link facing outside this machine carries an address")
|
||||||
|
} else {
|
||||||
|
m.Networks = networks
|
||||||
|
}
|
||||||
|
|
||||||
unknown(FactPublicAddress, publicAddressUnknown)
|
unknown(FactPublicAddress, publicAddressUnknown)
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-host/internal/outward"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The parsing layer, with the machine's files given (novox/hq ADR 0017 allows a fake here and only here);
|
// The parsing layer, with the machine's files given (novox/hq ADR 0017 allows a fake here and only here);
|
||||||
@@ -40,6 +42,9 @@ func given(read map[string]string) Sources {
|
|||||||
Run: func(context.Context, string, ...string) (string, error) { return "none\n", errors.New("exited 1") },
|
Run: func(context.Context, string, ...string) (string, error) { return "none\n", errors.New("exited 1") },
|
||||||
Statfs: func(p string) (int64, int64, error) { return 1000, 400, nil },
|
Statfs: func(p string) (int64, int64, error) { return 1000, 400, nil },
|
||||||
Now: func() time.Time { return time.Date(2026, 10, 12, 15, 0, 0, 0, time.UTC) },
|
Now: func() time.Time { return time.Date(2026, 10, 12, 15, 0, 0, 0, time.UTC) },
|
||||||
|
Networks: func() ([]outward.Network, error) {
|
||||||
|
return []outward.Network{{Link: "eth0", Network: "192.168.1.0/24"}, {Link: "eth1", Network: "203.0.113.0/24"}}, nil
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +97,7 @@ func TestMachine_aFactItCannotReadIsSaidUnknownNeverZero(t *testing.T) {
|
|||||||
src := given(map[string]string{})
|
src := given(map[string]string{})
|
||||||
src.Readlink = func(string) (string, error) { return "", os.ErrNotExist }
|
src.Readlink = func(string) (string, error) { return "", os.ErrNotExist }
|
||||||
src.Run = func(context.Context, string, ...string) (string, error) { return "", errors.New("not found") }
|
src.Run = func(context.Context, string, ...string) (string, error) { return "", errors.New("not found") }
|
||||||
|
src.Networks = func() ([]outward.Network, error) { return nil, errors.New("cannot read the routing table") }
|
||||||
m := DescribeMachine(context.Background(), src)
|
m := DescribeMachine(context.Background(), src)
|
||||||
|
|
||||||
raw, _ := json.Marshal(m)
|
raw, _ := json.Marshal(m)
|
||||||
@@ -183,3 +189,26 @@ func TestAgainstThisMachine_machineSaysWhatItIs(t *testing.T) {
|
|||||||
raw, _ := json.MarshalIndent(m, "", " ")
|
raw, _ := json.MarshalIndent(m, "", " ")
|
||||||
t.Logf("%s", raw)
|
t.Logf("%s", raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **The networks it is attached to are among its facts** (novox/hq ADR 0340 §7), every one — the public
|
||||||
|
// one too, which the controller judges and never counts — and a machine with none on a link facing
|
||||||
|
// outside says why rather than reporting an empty list.
|
||||||
|
func TestMachine_saysTheNetworksItIsAttachedTo(t *testing.T) {
|
||||||
|
m := DescribeMachine(context.Background(), given(files))
|
||||||
|
raw, _ := json.Marshal(m)
|
||||||
|
var said struct {
|
||||||
|
Networks []map[string]string `json:"networks"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(raw, &said)
|
||||||
|
if len(said.Networks) != 2 || said.Networks[0]["link"] != "eth0" || said.Networks[0]["network"] != "192.168.1.0/24" ||
|
||||||
|
said.Networks[1]["network"] != "203.0.113.0/24" {
|
||||||
|
t.Fatalf("networks said as %s", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
src := given(files)
|
||||||
|
src.Networks = func() ([]outward.Network, error) { return nil, nil }
|
||||||
|
m = DescribeMachine(context.Background(), src)
|
||||||
|
if len(m.Networks) != 0 || !strings.Contains(m.Unknown[FactNetworks], "no link facing outside") {
|
||||||
|
t.Fatalf("a machine with no network said %v, unknown %q", m.Networks, m.Unknown[FactNetworks])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user