Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e810907b2 | ||
|
|
2b20a12c4a | ||
|
|
9c83dacfce | ||
|
|
64ba053f3b | ||
|
|
96416bd8a7 | ||
|
|
4d2003d77b | ||
|
|
aaad02fd38 | ||
|
|
c68d3a7432 | ||
|
|
a5209bd849 | ||
|
|
bdf965dab6 | ||
|
|
b4da20ecc0 | ||
|
|
4b33b72160 | ||
|
|
d5505fe3d4 | ||
|
|
264c9e41e9 | ||
|
|
76ac3c99bd | ||
|
|
fe5988c536 | ||
|
|
ed5d467d90 | ||
|
|
228d0226dd | ||
|
|
6215ff0760 | ||
|
|
54812306be | ||
|
|
ce9e20fbbc | ||
|
|
878690697e | ||
|
|
ad97297576 | ||
|
|
04f9f378b0 | ||
|
|
1c3f44a526 |
@@ -309,7 +309,7 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
|
||||
return "", err
|
||||
}
|
||||
derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh,
|
||||
outward: plan.PublicDomain != ""}
|
||||
outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks}
|
||||
preview, saw := previewOf(node, reported, derived, plan, taken, filter, runs[filter])
|
||||
preview += "\n\n preview " + saw
|
||||
if !yes {
|
||||
@@ -414,6 +414,18 @@ func previewOf(node string, reported inventory.Adoption, derived derivedFilter,
|
||||
b.WriteString(" not previewed: traffic the machine routes that is not a published port " +
|
||||
"(a tunnel, NAT in the found firewall) — the derived filter drops it unless a module " +
|
||||
"declares it\n")
|
||||
// Which links the filter constrains, said rather than left to the sentence above (novox/hq ADR
|
||||
// 0140). Everything arriving anywhere else is this machine's own guest and keeps working — which
|
||||
// is what a reader most wants to know, because the previous shape of this filter cut a machine's
|
||||
// guests off at the flip without saying so, and that is how this was found.
|
||||
if len(derived.outwardLinks) > 0 {
|
||||
b.WriteString(fmt.Sprintf(" it filters what arrives on: %s, and on the private network "+
|
||||
"— everything its own guests send keeps working\n",
|
||||
strings.Join(derived.outwardLinks, ", ")))
|
||||
} else {
|
||||
b.WriteString(" it has reported no link facing outside, so no filter can be composed " +
|
||||
"for it — the flip is refused until it reports one\n")
|
||||
}
|
||||
|
||||
isTaken := map[string]bool{}
|
||||
for _, m := range taken {
|
||||
@@ -473,6 +485,10 @@ type derivedFilter struct {
|
||||
// mesh is every address on the private network; outward says the machine faces outside.
|
||||
mesh []string
|
||||
outward bool
|
||||
// outwardLinks is the links this machine reported as facing outside it (novox/hq ADR 0140).
|
||||
// The filter constrains what arrives on them; everything arriving elsewhere is this machine's
|
||||
// own guest and is not filtered.
|
||||
outwardLinks []string
|
||||
}
|
||||
|
||||
// closesOutside is what a narrowing from everywhere to the private network is called: it closes.
|
||||
@@ -498,6 +514,12 @@ func (d derivedFilter) fate(r inventory.Reach) string {
|
||||
return "stays open — the mesh's own, from anywhere"
|
||||
}
|
||||
}
|
||||
// This machine's own guests ask it for an address and for names, and those two arrive here
|
||||
// (novox/hq ADR 0140). Admitted by the link they arrive on, so a listener bound anywhere but an
|
||||
// outward link keeps answering them.
|
||||
if (r.Protocol == "udp" && (r.Port == 53 || r.Port == 67)) || (r.Protocol == "tcp" && r.Port == 53) {
|
||||
return "stays open — this machine's own guests asking it for an address and for names"
|
||||
}
|
||||
for _, rule := range d.rules {
|
||||
if rule.Port != r.Port || rule.Protocol != r.Protocol {
|
||||
continue
|
||||
|
||||
@@ -148,6 +148,9 @@ func usage() {
|
||||
node public-domain <name> the domain it composes its routed names under
|
||||
node public-domain <name> <d> ...set it to d
|
||||
node public-domain <name> --clear ...it faces the outside no longer
|
||||
node networks <name> the networks it routes for what it hosts
|
||||
node networks <name> <cidr>... ...set them; its filter forwards these too
|
||||
node networks <name> --clear ...only the container runtime's own
|
||||
token issue --node <name> a one-time right to join, for an existing record
|
||||
token issue --new <name> create the record and issue for it
|
||||
token issue ... --adopted ...for a machine in use, which joins adopted
|
||||
|
||||
@@ -66,6 +66,18 @@ func nodeCommand(ctx context.Context, args []string) error {
|
||||
// because the damage is already done by the time it prints.
|
||||
return publicDomain(ctx, inv, args[1:])
|
||||
|
||||
case "networks":
|
||||
// Removed by novox/hq ADR 0140, which superseded the record that added it. The filter no
|
||||
// longer names any network: it constrains what arrives from outside the machine and says
|
||||
// nothing about what did not, so there is no list to keep. Answered rather than met with
|
||||
// "unknown command", because this was the documented way to stop a flip cutting a machine's
|
||||
// containers off and somebody will reasonably still type it.
|
||||
return errors.New("`node networks` is gone (novox/hq ADR 0140). The filter constrains what " +
|
||||
"arrives from outside this machine and says nothing about traffic that did not, so no " +
|
||||
"network is named anywhere and nothing needs to be said to keep a machine's own " +
|
||||
"containers reaching outward. The machine reports which of its links face outside; see " +
|
||||
"`node show <name>`")
|
||||
|
||||
case "account":
|
||||
// The operator's login on this machine (novox/hq to-be 29): what a home-scoped file is
|
||||
// owned by and which account `ssh <node>` uses. Reports with no argument; sets with one;
|
||||
|
||||
@@ -640,13 +640,19 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
// Which of this machine's links face outside, which is what the derived filter is written
|
||||
// around (novox/hq ADR 0140). Reported by the machine, never set.
|
||||
outwardLinks, err := inv.OutwardLinksOf(ctx, node)
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
return catalogue.Rendering{
|
||||
BusMembership: memberships[node],
|
||||
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Ports: ports,
|
||||
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
||||
Machines: machines,
|
||||
Suffix: overlay.Suffix(), MeshRange: meshRange, Accounts: accounts, Foundation: foundation,
|
||||
Kept: kept, Adopted: record.Adopted,
|
||||
Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation,
|
||||
Kept: kept, Adopted: record.Adopted, OutwardLinks: outwardLinks,
|
||||
Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, Built: built,
|
||||
BusUsers: busUsers,
|
||||
}, record, nil
|
||||
|
||||
@@ -162,7 +162,13 @@ func declare(ctx context.Context, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
server, err := connectLink(ctx, nil, nil, nil)
|
||||
// **With the inventory, so the bus is raised** (novox/hq ADR 0134, design 30). A module's
|
||||
// declaration and how it hears what it consumes move together: its consumer is derived from the
|
||||
// same records this declaration is composed from. Raised only when the control plane started
|
||||
// serving, a module that gained a `consumes` was sent a declaration it could act on and a
|
||||
// consumer that never delivered the event — and nothing anywhere said the two disagreed
|
||||
// (found on review, 2026-09-28). Everything the raise does is idempotent.
|
||||
server, err := connectLink(ctx, inv, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ func (r Registry) has(ctx context.Context, url string, accept ...string) (bool,
|
||||
return false, err
|
||||
}
|
||||
for _, media := range accept {
|
||||
request.Header.Set("Accept", media)
|
||||
request.Header.Add("Accept", media)
|
||||
}
|
||||
response, err := r.client().Do(request)
|
||||
if err != nil {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -153,6 +153,26 @@ func (b *Build) problems(module string) []string {
|
||||
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+
|
||||
"for it", module, a.Name))
|
||||
}
|
||||
// **A system, for a language that compiles to a binary** (novox/hq ADR 0142). A binary
|
||||
// is pinned to one operating system at link time so a host refuses to touch a machine
|
||||
// it was not built for (novox/hq ADR 0005); an artifact that says nothing would be
|
||||
// compiled for whatever the build machine happened to be, which reads as portable and
|
||||
// is not.
|
||||
if compiled := compilesToABinary(a.Language); compiled && strings.TrimSpace(a.System) == "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q is compiled to a binary and says no system, so it would be built for "+
|
||||
"whatever the build machine happens to be. Declare one artifact per "+
|
||||
"system: %s", module, a.Name, spokenSystems()))
|
||||
} else if !compiled && strings.TrimSpace(a.System) != "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q names the system %q and is written in %q, which compiles to code that "+
|
||||
"runs anywhere — a system that decides nothing reads as though it did",
|
||||
module, a.Name, a.System, a.Language))
|
||||
} else if compiled && !knownSystem(a.System) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: %q is built for %q, and a system is %s",
|
||||
module, a.Name, a.System, spokenSystems()))
|
||||
}
|
||||
} else {
|
||||
if a.From == "" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
@@ -193,3 +213,42 @@ func oneOrOther(n int) string {
|
||||
}
|
||||
return "them"
|
||||
}
|
||||
|
||||
// Systems the mesh builds binaries for, which is the set a host may be pinned to (novox/hq ADR 0005).
|
||||
//
|
||||
// **A closed list, and the host's own, not the compiler's.** These are not the values a Go toolchain
|
||||
// would call an operating system — the difference between two of them is a C library, not a kernel.
|
||||
// They are what a machine reports itself to be and what a host is linked to refuse, so the list that
|
||||
// matters is the one the host understands.
|
||||
var systems = []string{"alpine", "android", "arch"}
|
||||
|
||||
// knownSystem is whether the mesh builds for it.
|
||||
func knownSystem(system string) bool {
|
||||
want := strings.ToLower(strings.TrimSpace(system))
|
||||
for _, s := range systems {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// spokenSystems is the list as a refusal says it, so a reader is one edit from right.
|
||||
func spokenSystems() string {
|
||||
return strings.Join(systems, ", ")
|
||||
}
|
||||
|
||||
// compilesToABinary is whether this language's bundle is a binary for one operating system rather
|
||||
// than code that runs wherever its interpreter does.
|
||||
//
|
||||
// **Asked of the language, not of the artifact.** A module says what it is written in; what that
|
||||
// implies is the mesh's to know, exactly as the compiler is (novox/hq ADR 0142). Asking the artifact
|
||||
// would let two artifacts in one language disagree about whether they are portable.
|
||||
func compilesToABinary(language string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(language)) {
|
||||
case "go":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// bundleFor is a manifest whose one artifact is a bundle in the given language and system.
|
||||
func bundleFor(language, system string) Manifest {
|
||||
return Manifest{Module: "a-component", Build: &Build{Artifacts: []Artifact{
|
||||
{Name: "binary", Kind: ArtifactBundle, Language: language, System: system},
|
||||
}}}
|
||||
}
|
||||
|
||||
func problemsOf(t *testing.T, m Manifest) string {
|
||||
t.Helper()
|
||||
return strings.Join(m.Build.problems(m.Module), "\n")
|
||||
}
|
||||
|
||||
// **A language that compiles to a binary must say which system.**
|
||||
//
|
||||
// A binary is pinned to one operating system at link time, so a host refuses to touch a machine it
|
||||
// was not built for. An artifact that says nothing would be compiled for whatever the build machine
|
||||
// happened to be — which reads as portable and is not, and is the fault this check exists for.
|
||||
func TestABinaryMustSayWhichSystemItIsFor(t *testing.T) {
|
||||
got := problemsOf(t, bundleFor("go", ""))
|
||||
if !strings.Contains(got, "says no system") {
|
||||
t.Fatalf("a compiled bundle with no system was accepted:\n%s", got)
|
||||
}
|
||||
// And the refusal names what it could have said, so a reader is one edit from right.
|
||||
for _, system := range []string{"alpine", "android", "arch"} {
|
||||
if !strings.Contains(got, system) {
|
||||
t.Fatalf("the refusal does not name %q as a choice:\n%s", system, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestABinaryThatNamesASystemIsAccepted(t *testing.T) {
|
||||
if got := problemsOf(t, bundleFor("go", "arch")); got != "" {
|
||||
t.Fatalf("a compiled bundle naming a system was refused:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A system the mesh does not build for is refused where it is written. These are the host's own
|
||||
// names, not a compiler's: the difference between two of them is a C library rather than a kernel,
|
||||
// so a value that looks like an operating system to a toolchain is still wrong here.
|
||||
func TestASystemTheMeshDoesNotBuildForIsRefused(t *testing.T) {
|
||||
for _, wrong := range []string{"linux", "debian", "darwin"} {
|
||||
got := problemsOf(t, bundleFor("go", wrong))
|
||||
if !strings.Contains(got, "and a system is") {
|
||||
t.Fatalf("%q was accepted as a system:\n%s", wrong, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **And a language that runs anywhere must not name one.** A system that decides nothing reads as
|
||||
// though it did, which is the same fault as a restriction that restricts nothing (novox/hq ADR 0045).
|
||||
func TestAPortableBundleMayNotNameASystem(t *testing.T) {
|
||||
got := problemsOf(t, bundleFor("typescript", "arch"))
|
||||
if !strings.Contains(got, "runs anywhere") {
|
||||
t.Fatalf("a portable bundle was allowed to name a system:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPortableBundleNamingNoSystemIsAccepted(t *testing.T) {
|
||||
if got := problemsOf(t, bundleFor("typescript", "")); got != "" {
|
||||
t.Fatalf("an ordinary bundle was refused:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// One component, one artifact per system: the shape the mesh's own binaries are declared in, and the
|
||||
// reason the target is the artifact's rather than the recipe's.
|
||||
func TestOneArtifactPerSystemIsAccepted(t *testing.T) {
|
||||
m := Manifest{Module: "the-host", Build: &Build{Artifacts: []Artifact{
|
||||
{Name: "arch", Kind: ArtifactBundle, Language: "go", System: "arch"},
|
||||
{Name: "alpine", Kind: ArtifactBundle, Language: "go", System: "alpine"},
|
||||
{Name: "android", Kind: ArtifactBundle, Language: "go", System: "android"},
|
||||
}}}
|
||||
if got := problemsOf(t, m); got != "" {
|
||||
t.Fatalf("one artifact per system was refused:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEveryCatalogueManifestParses runs the real catalogue through the real gate.
|
||||
//
|
||||
// Not a fixture: the point is whether the manifests as written are accepted by the control plane that
|
||||
// will read them, and a copy of one manifest proves nothing about the other seventy-one.
|
||||
func TestEveryCatalogueManifestParses(t *testing.T) {
|
||||
root := os.Getenv("MESH_CATALOGUE")
|
||||
if root == "" {
|
||||
t.Skip("set MESH_CATALOGUE to a catalogue checkout to run this")
|
||||
}
|
||||
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil || len(found) == 0 {
|
||||
t.Fatalf("no manifests under %s: %v", root, err)
|
||||
}
|
||||
named, routed := 0, 0
|
||||
for _, p := range found {
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", p, err)
|
||||
}
|
||||
m, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", filepath.Base(filepath.Dir(p)), err)
|
||||
continue
|
||||
}
|
||||
for _, l := range m.Listens {
|
||||
if l.Name != "" {
|
||||
named++
|
||||
}
|
||||
}
|
||||
for port := range RoutedPorts(m) {
|
||||
_ = port
|
||||
routed++
|
||||
}
|
||||
}
|
||||
t.Logf("%d manifests, %d named endpoints, %d routed endpoints resolved", len(found), named, routed)
|
||||
if named == 0 {
|
||||
t.Fatal("no endpoint in the catalogue is named, so this proved nothing")
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,16 @@ type Rendering struct {
|
||||
// nothing on this node keeps them, or the mesh has no operator key.
|
||||
Kept *KeptExport
|
||||
|
||||
// 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
|
||||
// is the one that matters: a machine dials it to enrol, and a firewall derived only from
|
||||
@@ -345,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)
|
||||
// **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 {
|
||||
@@ -852,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.
|
||||
@@ -862,6 +897,35 @@ func (r Resolution) Rules(with Rendering) ([]Rule, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// And how far each endpoint reaches, which says the same thing to the filter and more
|
||||
// besides (novox/hq ADR 0138). Folded in here rather than beside: the filter has one
|
||||
// question — from where — and a reach answers it, so giving it two inputs would let them
|
||||
// disagree. Reaches refuses a port that both name, so this cannot silently prefer one.
|
||||
reaches, err := Reaches(m, with.Settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// **Only for an endpoint the proxy does not serve.** A routed endpoint's port is how the
|
||||
// proxy reaches it and nothing else (ADR 0045), so `public` there asks for a public name and
|
||||
// says nothing about the port — opening it to the world as well would undo the arrangement
|
||||
// the proxy exists for, and would silently reopen a port an operator had narrowed.
|
||||
//
|
||||
// Found by trying to express a real module: one whose routed name must be public and whose
|
||||
// machine-side port must not be. Under one value for both, there was no way to say it.
|
||||
routed := RoutedPorts(m)
|
||||
for port, reach := range reaches {
|
||||
if routed[port] {
|
||||
continue
|
||||
}
|
||||
source, ok := FilterSource(reach)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: %q is not a reach the filter can read", m.Module, reach)
|
||||
}
|
||||
if e == nil {
|
||||
e = map[int]string{}
|
||||
}
|
||||
e[port] = source
|
||||
}
|
||||
if e != nil {
|
||||
exposure[m.Module] = e
|
||||
}
|
||||
@@ -1030,7 +1094,16 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
||||
}
|
||||
composeName(values, r.PublicDomain, r.At)
|
||||
reaches, err := Reaches(m, settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
||||
}
|
||||
blocks, err := Endpoints(m, settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
||||
}
|
||||
portOfEndpoint(values, endpointPorts(m))
|
||||
composeName(values, r.PublicDomain, r.At, reaches, endpointPorts(m), blocks)
|
||||
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
||||
}
|
||||
// Several contributions to one requirement (ADR 0094's sibling for `contributes`): an
|
||||
@@ -1044,7 +1117,16 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err)
|
||||
}
|
||||
composeName(values, r.PublicDomain, r.At)
|
||||
reaches, err := Reaches(m, settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err)
|
||||
}
|
||||
blocks, err := Endpoints(m, settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err)
|
||||
}
|
||||
portOfEndpoint(values, endpointPorts(m))
|
||||
composeName(values, r.PublicDomain, r.At, reaches, endpointPorts(m), blocks)
|
||||
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
||||
}
|
||||
}
|
||||
@@ -1075,10 +1157,44 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
||||
// the running mesh keeps serving the full names it has. And a labelled contribution on a node with
|
||||
// no public domain composes nothing — there is nothing to join it to — which reads downstream as a
|
||||
// route that named no host, the same as it would have before this existed.
|
||||
func composeName(values map[string]any, publicDomain, internalDomain string) {
|
||||
func composeName(values map[string]any, publicDomain, internalDomain string, reaches map[int]string,
|
||||
ports map[string]int, blocks map[string]Endpoint) {
|
||||
if values == nil {
|
||||
return
|
||||
}
|
||||
// **The subdomain an assignment gave this endpoint**, before the name is joined (novox/hq ADR
|
||||
// 0138). The module contributes a label because it names its own parts; an assignment may say a
|
||||
// different one, because where a thing lives under a domain is the operator's to choose and used
|
||||
// to require editing the module to change.
|
||||
if name, ok := values[RouteEndpoint].(string); ok {
|
||||
if ep, said := blocks[strings.TrimSpace(name)]; said && ep.Label != "" {
|
||||
values["label"] = ep.Label
|
||||
}
|
||||
}
|
||||
// **How far the endpoint this route serves reaches decides which names exist** (novox/hq ADR
|
||||
// 0138). Both were composed whenever the node had both domains, so every routed module got a
|
||||
// public name and an internal one whether anybody wanted them or not — and a certificate for
|
||||
// each, because the proxy certifies the names it is given.
|
||||
//
|
||||
// Joined by the port: a route entry names the port it serves and the module declares a listen on
|
||||
// it. An entry with no port is not an endpoint's route but a rule about a name — a path-level
|
||||
// refusal shadowing another route — and it inherits whatever that route's names turned out to
|
||||
// be, which is why it is left alone here.
|
||||
//
|
||||
// Nothing said is both names, as before. That is what keeps every mesh already running identical
|
||||
// until an assignment speaks.
|
||||
wantPublic, wantInternal := true, true
|
||||
if port, ok := endpointPortOf(values, ports); ok {
|
||||
if reach, said := reaches[port]; said {
|
||||
wantPublic, wantInternal = WantsPublicName(reach), WantsInternalName(reach)
|
||||
}
|
||||
}
|
||||
if !wantPublic {
|
||||
publicDomain = ""
|
||||
}
|
||||
if !wantInternal {
|
||||
internalDomain = ""
|
||||
}
|
||||
if _, already := values["name"]; already {
|
||||
// A full name was given rather than a label. Left as-is: this is the legacy shape, and the
|
||||
// point of the label is to not have to write the full name — a contribution that wrote both
|
||||
@@ -1698,3 +1814,57 @@ func prepared(from map[string]any) map[string]any {
|
||||
delete(step, "reload-on")
|
||||
return step
|
||||
}
|
||||
|
||||
// endpointPortOf is the port the endpoint a route serves listens on: looked up by the name the route
|
||||
// gives, or read from the port it repeats (novox/hq ADR 0138).
|
||||
//
|
||||
// `ports` maps this module's endpoint names to their ports, computed once per module rather than
|
||||
// re-scanned per contribution.
|
||||
func endpointPortOf(values map[string]any, ports map[string]int) (int, bool) {
|
||||
if name, ok := values[RouteEndpoint].(string); ok {
|
||||
if port, found := ports[strings.TrimSpace(name)]; found {
|
||||
return port, true
|
||||
}
|
||||
}
|
||||
return asPort(values["port"])
|
||||
}
|
||||
|
||||
// endpointPorts is a module's endpoint names against the ports they listen on.
|
||||
func endpointPorts(m Manifest) map[string]int {
|
||||
out := map[string]int{}
|
||||
for _, l := range m.Listens {
|
||||
if name := strings.TrimSpace(l.Name); name != "" {
|
||||
out[name] = l.Port
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// portOfEndpoint fills in the port of the endpoint a contribution names, in place.
|
||||
//
|
||||
// **A contribution that names an endpoint must still carry that endpoint's port**, because everything
|
||||
// downstream reads the port: the provider is told where to reach the consumer, and the machine-side
|
||||
// redirection that turns a declared port into the number the machine published is keyed on it
|
||||
// (atMachinePort). A route that named only its endpoint left the proxy with no port at all, and a
|
||||
// proxy with no port has nothing to dial.
|
||||
//
|
||||
// Found before it shipped and after the catalogue had already been changed to name endpoints — the
|
||||
// manifests were merged and the mesh had not yet picked them up, so nothing was broken yet. The
|
||||
// declared port, not the machine one: the redirection happens later and is keyed on the declared
|
||||
// number, so filling in the machine port here would be redirected a second time or not at all.
|
||||
func portOfEndpoint(values map[string]any, ports map[string]int) {
|
||||
if values == nil {
|
||||
return
|
||||
}
|
||||
if _, already := values["port"]; already {
|
||||
// A route that says both is its own answer; the older shape repeated the port and is still read.
|
||||
return
|
||||
}
|
||||
name, ok := values[RouteEndpoint].(string)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if port, found := ports[strings.TrimSpace(name)]; found {
|
||||
values["port"] = port
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// aMediaServer is the shape one port number per key cannot express: two endpoints of different kinds.
|
||||
// A web surface a proxy serves under a subdomain, and a protocol port clients dial directly because
|
||||
// the client expects that number.
|
||||
func aMediaServer() Manifest {
|
||||
return Manifest{
|
||||
Module: "media",
|
||||
Listens: []Listening{
|
||||
{Name: "web", Port: 80, From: FromMesh, Why: "the app, behind the proxy"},
|
||||
{Name: "stream", Port: 32400, From: FromEverywhere, Fixed: true,
|
||||
Why: "the client dials this number; the protocol chose it"},
|
||||
},
|
||||
Contributes: map[string]map[string]any{
|
||||
"route": {"label": "media", RouteEndpoint: "web"},
|
||||
},
|
||||
// Both endpoints are published by its container, which is what lets a machine port be given
|
||||
// for either: the mesh moves a port the module publishes, never one it merely listens on.
|
||||
Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "name": "media",
|
||||
"ports": []any{"80", "32400"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// **A route names the endpoint it serves.** A route and a listen both carried a port and nothing said
|
||||
// they were the same thing; now one of them says so.
|
||||
func TestARouteNamesTheEndpointItServes(t *testing.T) {
|
||||
m := aMediaServer()
|
||||
if port, ok := EndpointPort(m, "web"); !ok || port != 80 {
|
||||
t.Fatalf("the web endpoint resolves to %d (%v), want 80", port, ok)
|
||||
}
|
||||
if port, ok := EndpointPort(m, "stream"); !ok || port != 32400 {
|
||||
t.Fatalf("the stream endpoint resolves to %d (%v), want 32400", port, ok)
|
||||
}
|
||||
if _, ok := EndpointPort(m, "absent"); ok {
|
||||
t.Fatal("an endpoint the module does not declare resolved to a port")
|
||||
}
|
||||
}
|
||||
|
||||
// And the routed set is read through the name, so the endpoint the proxy serves is known without a
|
||||
// reader joining two numbers.
|
||||
func TestTheRoutedEndpointIsFoundByName(t *testing.T) {
|
||||
routed := RoutedPorts(aMediaServer())
|
||||
if !routed[80] {
|
||||
t.Fatalf("the routed endpoint was not found by name: %v", routed)
|
||||
}
|
||||
// And the directly-dialled one is not routed, which is what lets its reach govern its port.
|
||||
if routed[32400] {
|
||||
t.Fatalf("the endpoint clients dial directly reads as routed: %v", routed)
|
||||
}
|
||||
}
|
||||
|
||||
// **Two endpoints of different shapes, configured as themselves.** The web endpoint's reach asks for
|
||||
// names and leaves its port to the proxy; the stream endpoint's reach governs its port, because
|
||||
// clients dial it and there is no name.
|
||||
func TestTwoEndpointsOfDifferentShapesAreConfiguredSeparately(t *testing.T) {
|
||||
m := aMediaServer()
|
||||
settings := SettingsBy{"media": {{From: "node anchor", Values: map[string]any{
|
||||
ReachSetting: map[string]any{"80": ReachBoth, "32400": ReachPublic},
|
||||
}}}}
|
||||
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{m},
|
||||
PublicDomain: "example.test", At: "anchor.internal"}
|
||||
rules, err := r.Rules(Rendering{Settings: settings})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rule := range rules {
|
||||
switch rule.Port {
|
||||
case 80:
|
||||
if rule.From != FromMesh {
|
||||
t.Fatalf("the routed endpoint's port opened to %q; the proxy is how it is reached",
|
||||
rule.From)
|
||||
}
|
||||
case 32400:
|
||||
if rule.From != FromEverywhere {
|
||||
t.Fatalf("the directly-dialled endpoint's port is %q, want anywhere", rule.From)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And the routed one carries both names, asked for by the same statement.
|
||||
given, err := r.contributions(settings, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var public, internal string
|
||||
for _, c := range given["route"] {
|
||||
public, _ = c.Values["name"].(string)
|
||||
internal, _ = c.Values["internal-name"].(string)
|
||||
}
|
||||
if public != "media.example.test" || internal != "media.anchor.internal" {
|
||||
t.Fatalf("names are %q and %q, want both", public, internal)
|
||||
}
|
||||
}
|
||||
|
||||
// A route naming an endpoint the module does not declare reaches nothing, and is refused where it is
|
||||
// written rather than resolving to no port and serving nothing.
|
||||
func TestARouteNamingAnEndpointTheModuleLacksIsRefused(t *testing.T) {
|
||||
m := aMediaServer()
|
||||
m.Contributes["route"][RouteEndpoint] = "absent"
|
||||
got := strings.Join(RouteProblems(m), "\n")
|
||||
if !strings.Contains(got, "does not declare") {
|
||||
t.Fatalf("a route naming an absent endpoint was accepted:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **Two endpoints called the same would make an assignment configure whichever was read last.** The
|
||||
// point of a name is that it identifies one thing.
|
||||
func TestTwoEndpointsWithOneNameAreRefused(t *testing.T) {
|
||||
m := Manifest{Module: "twice", Listens: []Listening{
|
||||
{Name: "web", Port: 80, From: FromMesh},
|
||||
{Name: "web", Port: 8080, From: FromMesh},
|
||||
}}
|
||||
got := strings.Join(endpointNameProblems(m), "\n")
|
||||
if !strings.Contains(got, "could mean either") {
|
||||
t.Fatalf("two endpoints with one name were accepted:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A name that is not a name is refused where it is written: it ends up in something a person types.
|
||||
func TestAnEndpointNameIsHeldToItsShape(t *testing.T) {
|
||||
for _, wrong := range []string{"Web", "web port", "3000", "-web", "web_surface"} {
|
||||
m := Manifest{Module: "odd", Listens: []Listening{{Name: wrong, Port: 80, From: FromMesh}}}
|
||||
if got := strings.Join(endpointNameProblems(m), "\n"); !strings.Contains(got, "a name is lowercase") {
|
||||
t.Fatalf("%q was accepted as an endpoint name:\n%s", wrong, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **Every endpoint in the catalogue is unnamed today, and must stay valid.** The word ships one
|
||||
// release before anything uses it.
|
||||
func TestAnUnnamedEndpointIsStillValid(t *testing.T) {
|
||||
m := Manifest{Module: "ordinary", Listens: []Listening{{Port: 443, From: FromEverywhere}}}
|
||||
if got := endpointNameProblems(m); len(got) != 0 {
|
||||
t.Fatalf("an unnamed endpoint was refused: %v", got)
|
||||
}
|
||||
if got := RouteProblems(m); len(got) != 0 {
|
||||
t.Fatalf("a module with no route was refused: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **A route that names an endpoint still carries that endpoint's port.**
|
||||
//
|
||||
// Everything downstream reads the port: the provider is told where to reach the consumer, and the
|
||||
// redirection that turns a declared port into the number the machine published is keyed on it. A route
|
||||
// naming only its endpoint left the proxy with no port, and a proxy with no port has nothing to dial.
|
||||
//
|
||||
// Caught after the catalogue had already been changed to name endpoints, and before the mesh picked
|
||||
// those manifests up — which is the only reason nothing broke.
|
||||
func TestARouteNamingAnEndpointStillCarriesItsPort(t *testing.T) {
|
||||
m := aMediaServer()
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{m},
|
||||
PublicDomain: "example.test", At: "anchor.internal"}
|
||||
given, err := r.contributions(nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var saw bool
|
||||
for _, c := range given["route"] {
|
||||
saw = true
|
||||
port, ok := asPort(c.Values["port"])
|
||||
if !ok {
|
||||
t.Fatalf("the route carries no port, so the proxy has nothing to dial: %v", c.Values)
|
||||
}
|
||||
if port != 80 {
|
||||
t.Fatalf("the route carries port %d, want the web endpoint's 80", port)
|
||||
}
|
||||
}
|
||||
if !saw {
|
||||
t.Fatal("the module contributed no route")
|
||||
}
|
||||
}
|
||||
|
||||
// And the declared port, not the machine one: the redirection to where the machine published it
|
||||
// happens later and is keyed on the declared number, so filling the machine port in here would be
|
||||
// redirected twice or not at all.
|
||||
func TestTheEndpointsDeclaredPortIsFilledInNotTheMachineOne(t *testing.T) {
|
||||
m := aMediaServer()
|
||||
values := map[string]any{RouteEndpoint: "web", "label": "media"}
|
||||
portOfEndpoint(values, endpointPorts(m))
|
||||
if got, _ := asPort(values["port"]); got != 80 {
|
||||
t.Fatalf("filled in port %d, want the declared 80", got)
|
||||
}
|
||||
// Then the ordinary redirection puts it where the machine published it.
|
||||
moved := atMachinePort(values, m.Module, map[string]map[int]int{"media": {80: 20009}})
|
||||
if got, _ := asPort(moved["port"]); got != 20009 {
|
||||
t.Fatalf("after redirection the port is %d, want the machine's 20009", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A route that repeats a port keeps it, because that is the older shape and still read.
|
||||
func TestARouteThatRepeatsItsPortKeepsIt(t *testing.T) {
|
||||
values := map[string]any{RouteEndpoint: "web", "port": 8080}
|
||||
portOfEndpoint(values, map[string]int{"web": 80})
|
||||
if got, _ := asPort(values["port"]); got != 8080 {
|
||||
t.Fatalf("the port it stated was overwritten with %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func configured(block map[string]any) SettingsBy {
|
||||
return SettingsBy{"media": {{From: "node anchor",
|
||||
Values: map[string]any{EndpointsSetting: block}}}}
|
||||
}
|
||||
|
||||
// **One block per endpoint, saying all three things.** The machine port, the subdomain and the reach
|
||||
// were `ports`, the route's label and `reach`, each keyed by a port number, so configuring a module
|
||||
// with two endpoints of different shapes meant knowing which number was which.
|
||||
func TestAnEndpointsBlockSaysPortLabelAndReach(t *testing.T) {
|
||||
m := aMediaServer()
|
||||
// stream's reach NARROWS what the manifest says — the manifest has it from anywhere, the
|
||||
// assignment says internal. Chosen deliberately: a reach that agrees with the manifest proves
|
||||
// nothing about whether the block was read at all.
|
||||
settings := configured(map[string]any{
|
||||
"web": map[string]any{"port": 20009, "label": "cinema", "reach": ReachBoth},
|
||||
"stream": map[string]any{"reach": ReachInternal},
|
||||
})
|
||||
|
||||
// The machine port, where the mapping is read.
|
||||
given, err := GivenPorts(m, settings["media"])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if given[80] != 20009 {
|
||||
t.Fatalf("the web endpoint is on machine port %d, want 20009: %v", given[80], given)
|
||||
}
|
||||
|
||||
// The reach, where the filter reads it.
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{m},
|
||||
PublicDomain: "example.test", At: "anchor.internal"}
|
||||
rules, err := r.Rules(Rendering{Settings: settings})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if rule.Port == 32400 && rule.From != FromMesh {
|
||||
t.Fatalf("the directly-dialled endpoint is %q; the assignment narrowed it to the private "+
|
||||
"network and the manifest's 'anywhere' should not win", rule.From)
|
||||
}
|
||||
if rule.Port == 80 && rule.From != FromMesh {
|
||||
t.Fatalf("the routed endpoint's port opened to %q; the proxy is how it is reached", rule.From)
|
||||
}
|
||||
}
|
||||
|
||||
// And the subdomain, where the name is composed — the assignment's, not the module's.
|
||||
nodes, err := r.contributions(settings, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range nodes["route"] {
|
||||
if got, _ := c.Values["name"].(string); got != "cinema.example.test" {
|
||||
t.Fatalf("the public name is %q, want the label the assignment gave", got)
|
||||
}
|
||||
if got, _ := c.Values["internal-name"].(string); got != "cinema.anchor.internal" {
|
||||
t.Fatalf("the internal name is %q, want the label the assignment gave", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A block that says only a reach leaves the port to the mesh and the label to the module, which is the
|
||||
// ordinary case and must not require writing the other two.
|
||||
func TestABlockMaySayOnlyAReach(t *testing.T) {
|
||||
m := aMediaServer()
|
||||
settings := configured(map[string]any{"web": map[string]any{"reach": ReachInternal}})
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{m},
|
||||
PublicDomain: "example.test", At: "anchor.internal"}
|
||||
nodes, err := r.contributions(settings, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range nodes["route"] {
|
||||
if got, _ := c.Values["name"].(string); got != "" {
|
||||
t.Fatalf("an internal endpoint composed the public name %q", got)
|
||||
}
|
||||
// The module's own label, untouched.
|
||||
if got, _ := c.Values["internal-name"].(string); got != "media.anchor.internal" {
|
||||
t.Fatalf("the internal name is %q, want the module's own label", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An endpoint the module does not declare reaches nothing, and the refusal says what it does declare.
|
||||
func TestConfiguringAnEndpointTheModuleLacksIsRefused(t *testing.T) {
|
||||
_, err := Endpoints(aMediaServer(), configured(map[string]any{
|
||||
"admin": map[string]any{"reach": ReachInternal}})["media"])
|
||||
if err == nil || !strings.Contains(err.Error(), "does not declare") {
|
||||
t.Fatalf("configuring an absent endpoint was accepted: %v", err)
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "stream") {
|
||||
t.Fatalf("the refusal does not name what the module declares: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReachInABlockIsHeldToTheFourValues(t *testing.T) {
|
||||
_, err := Endpoints(aMediaServer(), configured(map[string]any{
|
||||
"web": map[string]any{"reach": "mesh"}})["media"])
|
||||
if err == nil || !strings.Contains(err.Error(), "a reach is") {
|
||||
t.Fatalf("a filter word was accepted as a reach: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// **Two places giving one endpoint a machine port is the confusion this key exists to end.**
|
||||
func TestAnEndpointGivenAPortTwiceIsRefused(t *testing.T) {
|
||||
m := aMediaServer()
|
||||
_, err := GivenPorts(m, []Layer{{From: "node anchor", Values: map[string]any{
|
||||
EndpointsSetting: map[string]any{"web": map[string]any{"port": 20009}},
|
||||
PortsSetting: map[string]any{"80": 30000},
|
||||
}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "published once") {
|
||||
t.Fatalf("an endpoint given two machine ports was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// And the same for its reach, said once here and once through the older key.
|
||||
func TestAnEndpointWhoseReachIsAlsoExposedIsRefused(t *testing.T) {
|
||||
_, err := Endpoints(aMediaServer(), []Layer{{From: "node anchor", Values: map[string]any{
|
||||
EndpointsSetting: map[string]any{"web": map[string]any{"reach": ReachInternal}},
|
||||
ExposeSetting: map[string]any{"80": FromEverywhere},
|
||||
}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "same thing in different words") {
|
||||
t.Fatalf("a reach said two ways was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A module whose endpoints are unnamed cannot be configured this way, and is told so rather than
|
||||
// having a block silently reach nothing — which is every module in the catalogue today.
|
||||
func TestAModuleWithNoNamedEndpointsIsToldSo(t *testing.T) {
|
||||
m := Manifest{Module: "media", Listens: []Listening{{Port: 80, From: FromMesh}}}
|
||||
_, err := Endpoints(m, configured(map[string]any{"web": map[string]any{"reach": ReachBoth}})["media"])
|
||||
if err == nil || !strings.Contains(err.Error(), "no endpoints by name") {
|
||||
t.Fatalf("a module with no named endpoints accepted a block: %v", err)
|
||||
}
|
||||
}
|
||||
+432
-21
@@ -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) 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,6 +268,22 @@ func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int) str
|
||||
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 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.
|
||||
//
|
||||
// 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.**
|
||||
//
|
||||
// Every other line in this chain is derived from what is assigned here, which is the whole
|
||||
@@ -342,6 +374,25 @@ func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int) str
|
||||
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } %s dport %d accept\n",
|
||||
strings.Join(six, ", "), rule.Protocol, rule.Port))
|
||||
}
|
||||
// **And this machine's own guests, which are part of what it hosts** (novox/hq ADR 0100:
|
||||
// the store is reachable "from a container on the node itself").
|
||||
//
|
||||
// The addresses above are the machines' own on the private network. A container reaching a
|
||||
// port on the machine it runs on comes from a bridge, so it matches none of them — and with
|
||||
// the runtime routing directly, that packet is delivered to this machine rather than
|
||||
// forwarded, so the forward chain's allowance never sees it either.
|
||||
//
|
||||
// Measured, and it was an outage: after a machine was converged, every module that reached
|
||||
// another by the machine's own name timed out. A web application logged
|
||||
// "connection to server at novox.internal (10.10.0.1), port 6852 failed: timeout expired"
|
||||
// for eleven hours while the mesh reported the machine healthy.
|
||||
//
|
||||
// Asked for by the link it arrives on, for the reason §4 no longer names an address: a
|
||||
// range describes one machine and goes stale in silence.
|
||||
if inward != "" {
|
||||
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } %s dport %d accept\n",
|
||||
inward, rule.Protocol, rule.Port))
|
||||
}
|
||||
case FromEverywhere:
|
||||
b.WriteString(fmt.Sprintf("\t\t%s dport %d accept\n", rule.Protocol, rule.Port))
|
||||
}
|
||||
@@ -361,19 +412,36 @@ func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int) str
|
||||
// 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))
|
||||
// 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 {
|
||||
@@ -430,18 +498,6 @@ func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int) str
|
||||
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"},
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -499,6 +555,21 @@ const MeshWideLayer = "the mesh"
|
||||
// two mappings share a number, it is an entry one of them writes over the other's, and the reader
|
||||
// that finds the survivor disagrees with the reader that recomputes it.
|
||||
func GivenPorts(m Manifest, layers []Layer) (map[int]int, error) {
|
||||
// An endpoint's own block may put it on a machine port, which is the same thing `ports` says about
|
||||
// the number rather than about the endpoint (novox/hq ADR 0138). Collected first and then let the
|
||||
// older key be read, which refuses a port said twice.
|
||||
byName, err := Endpoints(m, layers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
named := map[int]int{}
|
||||
for name, ep := range byName {
|
||||
if ep.Port == 0 {
|
||||
continue
|
||||
}
|
||||
named[endpointPorts(m)[name]] = ep.Port
|
||||
}
|
||||
|
||||
// Every name a setting may use, and the mapping it names.
|
||||
names := map[int][]publishing{}
|
||||
for _, p := range publishedPorts(m) {
|
||||
@@ -597,6 +668,17 @@ func GivenPorts(m Manifest, layers []Layer) (map[int]int, error) {
|
||||
out[key], by[key] = at, port
|
||||
}
|
||||
}
|
||||
// And what the endpoints' own blocks put them on. Refused rather than merged where both keys name
|
||||
// one endpoint: two places giving a port is the confusion this key exists to end.
|
||||
for wanted, at := range named {
|
||||
if was, twice := out[wanted]; twice && was != at {
|
||||
return nil, fmt.Errorf(
|
||||
"%s puts its port %d on %d through %s and on %d through %s — one endpoint, two "+
|
||||
"machine ports, and it is published once. Keep the endpoint's own block",
|
||||
m.Module, wanted, at, EndpointsSetting, was, PortsSetting)
|
||||
}
|
||||
out[wanted] = at
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -664,3 +746,332 @@ func sortedPorts(of map[int]int) []int {
|
||||
sort.Ints(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ReachSetting is the settings key that says how far one of a module's endpoints reaches, per node
|
||||
// (novox/hq ADR 0138):
|
||||
//
|
||||
// {"reach": {"3000": "internal"}}
|
||||
//
|
||||
// **One value, three readers.** Reachability used to be settled three times over: the filter read a
|
||||
// listen's source, which `expose` could override; the proxy composed a public name and an internal
|
||||
// name for every route it was given, because it could; and the certificate authority followed from
|
||||
// which names existed. Each was defensible and the combination was unstated, so "this endpoint must
|
||||
// not be public" could not be written and was therefore enforced by nothing — while a public
|
||||
// certificate for that very name was obtained anyway.
|
||||
//
|
||||
// It keys on the port the module declares, the same key `ports` and `expose` use. A route names that
|
||||
// port too, which is what lets one statement reach the names as well as the filter: of the 36 route
|
||||
// entries in the catalogue, 35 name a port that the same module declares a listen on, and the one
|
||||
// that does not is a path-level refusal — a rule about a name rather than an endpoint.
|
||||
const ReachSetting = "reach"
|
||||
|
||||
// How far an endpoint reaches. Four values, because they have to cover everything `expose` could say
|
||||
// as well as the two names.
|
||||
const (
|
||||
// ReachMachine is this machine only: not the private network, not the world, and no name.
|
||||
ReachMachine = "machine"
|
||||
// ReachInternal is the private network, under the internal name and not the public one.
|
||||
ReachInternal = "internal"
|
||||
// ReachPublic is the world, under the public name and not the internal one.
|
||||
ReachPublic = "public"
|
||||
// ReachBoth is the world, under both names — each certified by its own authority.
|
||||
//
|
||||
// The filter cannot distinguish this from ReachPublic, and should not try: the mesh's addresses
|
||||
// are a subset of anywhere. What differs is the names, which is the whole reason reach is not
|
||||
// simply the filter's vocabulary with nicer words.
|
||||
ReachBoth = "both"
|
||||
)
|
||||
|
||||
// reaches is every value, in the order a refusal lists them.
|
||||
var reaches = []string{ReachMachine, ReachInternal, ReachPublic, ReachBoth}
|
||||
|
||||
// RoutedPorts are the ports a module serves through a proxy, taken from its route contributions.
|
||||
//
|
||||
// **A routed endpoint's port is how the proxy reaches it, and nothing else.** That is ADR 0045's
|
||||
// decision and it is older than reach: a public service listens `from: mesh`, only the proxy reaches
|
||||
// it, and it is exposed by name. So `public` on a routed endpoint asks for a public *name*; opening
|
||||
// that port to the world as well would undo the arrangement the proxy exists for.
|
||||
//
|
||||
// Measured before this was written, not reasoned: a module's routed name answered from the internet
|
||||
// over TLS while its machine-side port was refused from the same place. The port is not the path.
|
||||
func RoutedPorts(m Manifest) map[int]bool {
|
||||
out := map[int]bool{}
|
||||
note := func(values map[string]any) {
|
||||
// **The endpoint it serves, by name where it says one.** A route repeating a port number is
|
||||
// the older shape and still read: 35 of the catalogue's 36 route entries name a port their
|
||||
// module declares a listen on (novox/hq ADR 0138).
|
||||
if name, ok := values[RouteEndpoint].(string); ok {
|
||||
if port, found := EndpointPort(m, name); found {
|
||||
out[port] = true
|
||||
return
|
||||
}
|
||||
}
|
||||
if port, ok := asPort(values["port"]); ok {
|
||||
out[port] = true
|
||||
}
|
||||
}
|
||||
if values, ok := m.Contributes["route"]; ok {
|
||||
note(values)
|
||||
}
|
||||
for _, values := range m.ContributesMany["route"] {
|
||||
note(values)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FilterSource is the source a reach means to the packet filter.
|
||||
//
|
||||
// `public` and `both` are the same here. A reach that opened a port to the mesh and not to the world
|
||||
// would be `internal`; there is no reach that opens it to the world and *not* to the mesh, because a
|
||||
// filter cannot express "everyone except these" and nobody has asked for it.
|
||||
func FilterSource(reach string) (string, bool) {
|
||||
switch reach {
|
||||
case ReachMachine:
|
||||
return FromMachine, true
|
||||
case ReachInternal:
|
||||
return FromMesh, true
|
||||
case ReachPublic, ReachBoth:
|
||||
return FromEverywhere, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// WantsPublicName is whether a reach asks for the route's public name to be composed.
|
||||
func WantsPublicName(reach string) bool { return reach == ReachPublic || reach == ReachBoth }
|
||||
|
||||
// WantsInternalName is whether a reach asks for the route's internal name to be composed.
|
||||
func WantsInternalName(reach string) bool { return reach == ReachInternal || reach == ReachBoth }
|
||||
|
||||
// Reaches reads a module's per-node reach settings: declared port → how far it reaches.
|
||||
//
|
||||
// It refuses a reach for a port the module does not listen on, or a value that is not one of the
|
||||
// four — the "reads as a restriction and is none" fault this whole mechanism exists to prevent
|
||||
// (novox/hq ADR 0043/0045). It also refuses a port that `expose` names as well: the two say the same
|
||||
// thing in different words, and a module whose reach and exposure disagree would have the filter
|
||||
// following one and the names following the other, which is the very confusion ADR 0138 removes.
|
||||
//
|
||||
// A module with no `reach` setting yields nothing, and everything behaves exactly as before: the
|
||||
// filter follows the manifest's `from`, and both names are composed. That is what keeps every machine
|
||||
// already running unchanged until an assignment says otherwise.
|
||||
func Reaches(m Manifest, layers []Layer) (map[int]string, error) {
|
||||
listened := make(map[int]bool, len(m.Listens))
|
||||
for _, l := range m.Listens {
|
||||
listened[l.Port] = true
|
||||
}
|
||||
|
||||
exposed, err := Exposure(m, layers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := map[int]string{}
|
||||
// What an endpoint's own block says, which is the same statement in the shape that names the
|
||||
// endpoint rather than its port (novox/hq ADR 0138). Read first so the older key, which says less,
|
||||
// cannot quietly win over the newer one that says more.
|
||||
blocks, err := Endpoints(m, layers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
declared := endpointPorts(m)
|
||||
for name, ep := range blocks {
|
||||
if ep.Reach == "" {
|
||||
continue
|
||||
}
|
||||
out[declared[name]] = ep.Reach
|
||||
}
|
||||
for _, layer := range layers {
|
||||
raw, ok := layer.Values[ReachSetting]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
entries, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: %s is a { port: reach } map, and %q set it to something else",
|
||||
m.Module, ReachSetting, layer.From)
|
||||
}
|
||||
for portText, value := range entries {
|
||||
port, err := strconv.Atoi(portText)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s says how far %q reaches, which is not a port", m.Module, portText)
|
||||
}
|
||||
if !listened[port] {
|
||||
return nil, fmt.Errorf(
|
||||
"%s says how far port %d reaches, which it does not listen on — the setting "+
|
||||
"reaches nothing", m.Module, port)
|
||||
}
|
||||
reach, ok := value.(string)
|
||||
if !ok || !slices.Contains(reaches, reach) {
|
||||
return nil, fmt.Errorf("%s says port %d reaches %v; a reach is %s",
|
||||
m.Module, port, value, strings.Join(reaches, ", "))
|
||||
}
|
||||
if _, both := exposed[port]; both {
|
||||
return nil, fmt.Errorf(
|
||||
"%s sets both %s and %s for port %d. They say the same thing in different "+
|
||||
"words, and the filter would follow one while its names followed the other "+
|
||||
"— which is what %s exists to stop. Keep %s",
|
||||
m.Module, ReachSetting, ExposeSetting, port, ReachSetting, ReachSetting)
|
||||
}
|
||||
out[port] = reach
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RouteEndpoint is the key a route contribution names the endpoint it serves with, instead of
|
||||
// repeating that endpoint's port (novox/hq ADR 0138).
|
||||
//
|
||||
// **A route and a listen both carried a port, and nothing said they were the same thing.** They
|
||||
// always were — a route serves one of the module's own endpoints — but a reader had to join two
|
||||
// numbers, and an assignment configuring "the web endpoint" had to know which number that was. A
|
||||
// route that names the endpoint says what it means, and the mesh looks the port up.
|
||||
const RouteEndpoint = "endpoint"
|
||||
|
||||
// RouteProblems holds a module's route contributions to naming an endpoint it actually has.
|
||||
//
|
||||
// A route naming an endpoint the module does not declare reaches nothing, and is refused where it is
|
||||
// written rather than resolving to no port and serving nothing — the fault this repository names most
|
||||
// often, a declaration that reads as though it did something.
|
||||
func RouteProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
check := func(where string, values map[string]any) {
|
||||
name, ok := values[RouteEndpoint].(string)
|
||||
if !ok || strings.TrimSpace(name) == "" {
|
||||
return
|
||||
}
|
||||
if _, found := EndpointPort(m, name); !found {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s routes %s to the endpoint %q, which it does not declare", m.Module, where, name))
|
||||
}
|
||||
}
|
||||
if values, ok := m.Contributes["route"]; ok {
|
||||
check("a name", values)
|
||||
}
|
||||
for local, values := range m.ContributesMany["route"] {
|
||||
check(local, values)
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// EndpointsSetting is the settings key that configures a module's endpoints by name, per node
|
||||
// (novox/hq ADR 0138):
|
||||
//
|
||||
// {"endpoints": {"web": {"port": 20009, "label": "media", "reach": "both"},
|
||||
// "stream": {"reach": "public"}}}
|
||||
//
|
||||
// **One block per endpoint, instead of three keys joined by a number.** Which machine port it lands
|
||||
// on, the subdomain a proxy serves it under, and how far it reaches are the three things an operator
|
||||
// says when a module is assigned, and they were said in `ports`, in the route's label and in `reach`,
|
||||
// each keyed by the port. A module with two endpoints of different shapes — a web surface behind the
|
||||
// proxy and a protocol port clients dial directly — could only be configured by a reader who knew
|
||||
// which number was which.
|
||||
//
|
||||
// Every field is optional. A block that says only a reach leaves the port to the mesh and the label to
|
||||
// the module, which is the ordinary case.
|
||||
const EndpointsSetting = "endpoints"
|
||||
|
||||
// Endpoint is what an assignment says about one of a module's endpoints.
|
||||
type Endpoint struct {
|
||||
// Port is the machine-side port it is published on. Zero means the mesh assigns one, which it
|
||||
// does anyway — a fixed port is the module's claim and is honoured without being said here.
|
||||
Port int
|
||||
// Label is the subdomain a proxy serves it under, overriding the one the module contributes.
|
||||
Label string
|
||||
// Reach is how far it reaches: machine, internal, public or both.
|
||||
Reach string
|
||||
}
|
||||
|
||||
// Endpoints reads a module's per-node endpoint configuration, by endpoint name.
|
||||
//
|
||||
// It refuses a name the module does not declare — the setting would reach nothing — and a reach that
|
||||
// is not one of the four. It also refuses an endpoint whose port or reach is said twice, once here and
|
||||
// once through the older key: two places saying the same thing is what this key exists to end, and
|
||||
// letting both stand would mean the mesh followed whichever it read last.
|
||||
func Endpoints(m Manifest, layers []Layer) (map[string]Endpoint, error) {
|
||||
declared := endpointPorts(m)
|
||||
exposed, err := Exposure(m, layers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := map[string]Endpoint{}
|
||||
for _, layer := range layers {
|
||||
raw, ok := layer.Values[EndpointsSetting]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
blocks, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: %s is a { endpoint: { … } } map, and %q set it to something else",
|
||||
m.Module, EndpointsSetting, layer.From)
|
||||
}
|
||||
for name, body := range blocks {
|
||||
port, known := declared[name]
|
||||
if !known {
|
||||
return nil, fmt.Errorf(
|
||||
"%s configures the endpoint %q, which it does not declare — the setting reaches "+
|
||||
"nothing. It declares %s", m.Module, name, spokenEndpoints(m))
|
||||
}
|
||||
values, ok := body.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: the endpoint %q is configured with something that is not a "+
|
||||
"block of settings", m.Module, name)
|
||||
}
|
||||
ep := out[name]
|
||||
if reach, said := values["reach"]; said {
|
||||
text, ok := reach.(string)
|
||||
if !ok || !slices.Contains(reaches, text) {
|
||||
return nil, fmt.Errorf("%s says the endpoint %q reaches %v; a reach is %s",
|
||||
m.Module, name, reach, strings.Join(reaches, ", "))
|
||||
}
|
||||
if _, also := exposed[port]; also {
|
||||
return nil, fmt.Errorf(
|
||||
"%s says how far %q reaches and also exposes port %d. They say the same thing "+
|
||||
"in different words; keep the endpoint's own block",
|
||||
m.Module, name, port)
|
||||
}
|
||||
ep.Reach = text
|
||||
}
|
||||
if at, said := values["port"]; said {
|
||||
machine, ok := asPort(at)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s puts the endpoint %q on %v, which is not a port",
|
||||
m.Module, name, at)
|
||||
}
|
||||
ep.Port = machine
|
||||
}
|
||||
if label, said := values["label"]; said {
|
||||
text, ok := label.(string)
|
||||
if !ok || strings.TrimSpace(text) == "" {
|
||||
return nil, fmt.Errorf("%s gives the endpoint %q a label that is not a name: %v",
|
||||
m.Module, name, label)
|
||||
}
|
||||
ep.Label = strings.TrimSpace(text)
|
||||
}
|
||||
out[name] = ep
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// spokenEndpoints is what a module's endpoints are called, as a refusal lists them — so a reader who
|
||||
// named one wrongly is one edit from right, and a module that has named none is told so.
|
||||
func spokenEndpoints(m Manifest) string {
|
||||
names := make([]string, 0, len(m.Listens))
|
||||
for _, l := range m.Listens {
|
||||
if name := strings.TrimSpace(l.Name); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "no endpoints by name"
|
||||
}
|
||||
sort.Strings(names)
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
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), []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)
|
||||
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)
|
||||
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)
|
||||
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), []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), []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), []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, 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), []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), []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)
|
||||
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)
|
||||
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,8 +799,44 @@ 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// **This machine's own guests are part of what it hosts** (novox/hq ADR 0100: the store is reachable
|
||||
// "from a container on the node itself").
|
||||
//
|
||||
// The addresses a mesh-scoped rule admits are the machines' own on the private network. A container
|
||||
// reaching a port on the machine it runs on comes from a bridge, matching none of them — and where the
|
||||
// runtime routes directly, that packet is delivered to this machine rather than forwarded, so the
|
||||
// forward chain's allowance never sees it either.
|
||||
//
|
||||
// Measured, and it was an outage: after a machine was converged, every module reaching another by the
|
||||
// machine's own name timed out for eleven hours while the mesh reported the machine healthy.
|
||||
func TestAMeshScopedPortAdmitsThisMachinesOwnGuests(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
}}, nil), []string{"10.10.0.1", "10.10.0.2"}, false, nil, []string{"eth0"}, "mesh0")
|
||||
|
||||
// The private network's own addresses, as before.
|
||||
if !strings.Contains(nft, "ip saddr { 10.10.0.1, 10.10.0.2 } tcp dport 5432 accept") {
|
||||
t.Fatalf("the private network no longer reaches a mesh-scoped port:\n%s", nft)
|
||||
}
|
||||
// And this machine's guests, by the link they arrive on.
|
||||
if !strings.Contains(nft, `iifname != { "eth0", "mesh0" } tcp dport 5432 accept`) {
|
||||
t.Fatalf("a container on this machine cannot reach a mesh-scoped port on it, which is the "+
|
||||
"outage this test exists for:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// A port open to everything needs no such line — it is already open to a guest.
|
||||
func TestAPublicPortNeedsNoGuestLine(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||
}}, nil), []string{"10.10.0.1"}, false, nil, []string{"eth0"}, "mesh0")
|
||||
if strings.Count(nft, `iifname != { "eth0", "mesh0" } tcp dport 443`) != 0 {
|
||||
t.Fatalf("a public port was given a guest line it does not need:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,6 +571,21 @@ type Artifact struct {
|
||||
// image built from this same module's own repository, the same as every other artifact.
|
||||
Context *ArtifactContext `json:"context,omitempty"`
|
||||
|
||||
// System is the operating system this artifact is compiled for, for a bundle whose output is a
|
||||
// binary rather than portable code (novox/hq ADR 0142).
|
||||
//
|
||||
// **Named by the artifact, not by the recipe.** A toolchain deliberately accepts nothing from
|
||||
// the module — anything a module could override there it would be writing a Dockerfile to
|
||||
// override — and yet a compiled binary is per operating system, pinned at link time so a host
|
||||
// refuses to touch a machine it was not built for (novox/hq ADR 0005). The way out is that the
|
||||
// target is a property of the artifact: one artifact declared per system, one build each, and
|
||||
// the recipe stays the mesh's.
|
||||
//
|
||||
// Empty for a bundle whose output runs anywhere, which is every interpreted language, and for
|
||||
// every other kind. A bundle in a language that compiles to a binary must say one, because
|
||||
// "compiled for whatever the build machine happened to be" is the fault this exists to prevent.
|
||||
System string `json:"system,omitempty"`
|
||||
|
||||
// Language is what this module's code is written in, for a bundle.
|
||||
//
|
||||
// **Declared, never guessed.** Inferring it from what files happen to be present makes a
|
||||
@@ -642,8 +657,23 @@ const (
|
||||
// on is a fact, and it should be written once.
|
||||
const ArtifactStoreProvision = "artifact-store"
|
||||
|
||||
// Listening is one port a module accepts connections on.
|
||||
// Listening is one endpoint a module serves: a port it accepts connections on, and what may be said
|
||||
// about that port from outside the module.
|
||||
type Listening struct {
|
||||
// Name is what this endpoint is called, so an assignment and a route can refer to it as one thing
|
||||
// (novox/hq ADR 0138).
|
||||
//
|
||||
// **Because a port number is not a name.** Three facts have to be said about an endpoint when a
|
||||
// module is assigned — which machine port it lands on, the subdomain a proxy serves it under, and
|
||||
// how far it reaches — and they were said in three places keyed by the port. A module with two
|
||||
// endpoints of different shapes, a web surface behind a proxy and a protocol port clients dial
|
||||
// directly, cannot be configured that way without a reader joining numbers by hand.
|
||||
//
|
||||
// The module's to choose, like the route's label: it names its own parts. Lowercase, and unique
|
||||
// within the module, so a reference to it is unambiguous. Empty is allowed and means an endpoint
|
||||
// nothing refers to by name, which is every endpoint in the catalogue until they are named.
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
Port int `json:"port"`
|
||||
// Protocol is "tcp" or "udp". Absent means tcp, which is what almost everything is — and a
|
||||
// field that had to be written every time would be written wrongly some of the time.
|
||||
@@ -1250,6 +1280,8 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
"%s listens on %d over %q, which is tcp or udp", m.Module, l.Port, p))
|
||||
}
|
||||
}
|
||||
problems = append(problems, endpointNameProblems(m)...)
|
||||
problems = append(problems, RouteProblems(m)...)
|
||||
for _, port := range m.Guards {
|
||||
if port < 1 || port > 65535 {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
@@ -1674,3 +1706,53 @@ func (m Manifest) undeclaredMounts() []string {
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// endpointName is what an endpoint may be called: lowercase letters, digits and dashes, starting
|
||||
// with a letter. The same shape a label has, because both end up in something a person types.
|
||||
var endpointName = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||
|
||||
// endpointNameProblems holds a module's endpoint names to being usable as references (novox/hq ADR
|
||||
// 0138).
|
||||
//
|
||||
// **Unique, because the point of a name is that it identifies one thing.** Two endpoints called the
|
||||
// same would make an assignment that configures one silently configure whichever the mesh read last
|
||||
// — the shape of fault this repository keeps finding, where a declaration appears to say something
|
||||
// and says something else.
|
||||
func endpointNameProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
seen := map[string]int{}
|
||||
for _, l := range m.Listens {
|
||||
name := strings.TrimSpace(l.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if !endpointName.MatchString(name) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s calls the endpoint on port %d %q; a name is lowercase letters, digits and "+
|
||||
"dashes, starting with a letter", m.Module, l.Port, l.Name))
|
||||
continue
|
||||
}
|
||||
if before, already := seen[name]; already {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s calls both port %d and port %d %q, so anything naming that endpoint could mean "+
|
||||
"either", m.Module, before, l.Port, name))
|
||||
continue
|
||||
}
|
||||
seen[name] = l.Port
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// EndpointPort is the port of the endpoint a module calls this, and whether it has one.
|
||||
func EndpointPort(m Manifest, name string) (int, bool) {
|
||||
want := strings.TrimSpace(name)
|
||||
if want == "" {
|
||||
return 0, false
|
||||
}
|
||||
for _, l := range m.Listens {
|
||||
if strings.TrimSpace(l.Name) == want {
|
||||
return l.Port, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
t.Log("\n" + AsNftables(rules, []string{"192.0.2.20"}, true, nil, nil, "mesh0"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// a web module with one routed endpoint, the shape almost every routed module in the catalogue has.
|
||||
func aRoutedWeb() Manifest {
|
||||
return Manifest{
|
||||
Module: "web",
|
||||
Listens: []Listening{{Port: 3000, From: FromMesh}},
|
||||
Contributes: map[string]map[string]any{
|
||||
"route": {"label": "app", "port": 3000},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func reachSet(reach string) SettingsBy {
|
||||
return SettingsBy{"web": {{From: "node anchor",
|
||||
Values: map[string]any{ReachSetting: map[string]any{"3000": reach}}}}}
|
||||
}
|
||||
|
||||
// namesFor renders the contribution a routed module makes and returns the two names it carries.
|
||||
func namesFor(t *testing.T, m Manifest, settings SettingsBy) (public, internal string) {
|
||||
t.Helper()
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{m},
|
||||
PublicDomain: "example.test", At: "anchor.internal"}
|
||||
given, err := r.contributions(settings, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("contributions: %v", err)
|
||||
}
|
||||
for _, c := range given["route"] {
|
||||
p, _ := c.Values["name"].(string)
|
||||
i, _ := c.Values["internal-name"].(string)
|
||||
return p, i
|
||||
}
|
||||
t.Fatal("the module contributed no route")
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// **Nothing said composes both names, exactly as before.** This is the assertion that keeps every
|
||||
// mesh already running identical until an assignment speaks, and it is the one that would break first
|
||||
// if reach were read where it should not be.
|
||||
func TestAnEndpointWithNoReachKeepsBothNames(t *testing.T) {
|
||||
public, internal := namesFor(t, aRoutedWeb(), nil)
|
||||
if public != "app.example.test" || internal != "app.anchor.internal" {
|
||||
t.Fatalf("names are %q and %q, want both composed as before", public, internal)
|
||||
}
|
||||
}
|
||||
|
||||
// An internal endpoint has an internal name and no public one — so the proxy serves it inside, and
|
||||
// the public authority is never asked for a name nobody wanted. This is what "must not be public"
|
||||
// could not say before.
|
||||
func TestAnInternalEndpointHasNoPublicName(t *testing.T) {
|
||||
public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachInternal))
|
||||
if public != "" {
|
||||
t.Fatalf("an internal endpoint composed the public name %q", public)
|
||||
}
|
||||
if internal != "app.anchor.internal" {
|
||||
t.Fatalf("internal name is %q, want app.anchor.internal", internal)
|
||||
}
|
||||
}
|
||||
|
||||
// And the mirror: a public endpoint gets the public name and not the internal one, so the mesh's own
|
||||
// authority is not asked to certify a name the service is not reached by.
|
||||
func TestAPublicEndpointHasNoInternalName(t *testing.T) {
|
||||
public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachPublic))
|
||||
if internal != "" {
|
||||
t.Fatalf("a public endpoint composed the internal name %q", internal)
|
||||
}
|
||||
if public != "app.example.test" {
|
||||
t.Fatalf("public name is %q, want app.example.test", public)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBothComposesBothNames(t *testing.T) {
|
||||
public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachBoth))
|
||||
if public == "" || internal == "" {
|
||||
t.Fatalf("both should compose both names, got %q and %q", public, internal)
|
||||
}
|
||||
}
|
||||
|
||||
// **The filter reads the same value — for an endpoint the proxy does not serve.**
|
||||
//
|
||||
// A routed endpoint's port is how the proxy reaches it and nothing else (ADR 0045): a public service
|
||||
// listens from the mesh, only the proxy reaches it, and it is exposed by name. So on a routed
|
||||
// endpoint the reach asks for a name and the port keeps what the manifest said.
|
||||
func TestAnUnroutedEndpointsPortFollowsItsReach(t *testing.T) {
|
||||
// The same module with its route taken away: now the port is the only way in, so reach governs it.
|
||||
bare := aRoutedWeb()
|
||||
bare.Contributes = nil
|
||||
|
||||
for _, c := range []struct{ reach, want string }{
|
||||
{ReachInternal, FromMesh},
|
||||
{ReachPublic, FromEverywhere},
|
||||
{ReachBoth, FromEverywhere},
|
||||
{ReachMachine, FromMachine},
|
||||
} {
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{bare}}
|
||||
rules, err := r.Rules(Rendering{Settings: reachSet(c.reach)})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: rules: %v", c.reach, err)
|
||||
}
|
||||
found := false
|
||||
for _, rule := range rules {
|
||||
if rule.Port == 3000 {
|
||||
found = true
|
||||
if rule.From != c.want {
|
||||
t.Fatalf("reach %q made the filter say %q, want %q", c.reach, rule.From, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("reach %q produced no rule for the port", c.reach)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **A public name does not open the machine's port**, which is the case that found this.
|
||||
//
|
||||
// A module whose routed name must be public and whose machine-side port must not be had no way to say
|
||||
// so while one value drove both. Under one value it could not be expressed; the port would reopen.
|
||||
func TestAPublicNameLeavesARoutedPortAsTheManifestSaid(t *testing.T) {
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{aRoutedWeb()},
|
||||
PublicDomain: "example.test", At: "anchor.internal"}
|
||||
rules, err := r.Rules(Rendering{Settings: reachSet(ReachPublic)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if rule.Port == 3000 && rule.From != FromMesh {
|
||||
t.Fatalf("a public reach opened a routed port to %q; the proxy is how it is reached",
|
||||
rule.From)
|
||||
}
|
||||
}
|
||||
// And the name it asked for is there, so the reach was not simply ignored.
|
||||
public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachPublic))
|
||||
if public != "app.example.test" || internal != "" {
|
||||
t.Fatalf("names are %q and %q, want the public one only", public, internal)
|
||||
}
|
||||
}
|
||||
|
||||
// A reach for a port the module does not listen on reaches nothing, and is refused where it is
|
||||
// written rather than accepted and ignored.
|
||||
func TestAReachForAPortTheModuleDoesNotListenOnIsRefused(t *testing.T) {
|
||||
_, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor",
|
||||
Values: map[string]any{ReachSetting: map[string]any{"9999": ReachInternal}}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "reaches nothing") {
|
||||
t.Fatalf("a reach naming an undeclared port was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A value that is not a reach is refused, and the refusal names the four so a reader is one edit from
|
||||
// right. "mesh" is the tempting wrong answer, because that is the filter's word for nearly the same
|
||||
// thing.
|
||||
func TestAValueThatIsNotAReachIsRefused(t *testing.T) {
|
||||
for _, wrong := range []string{"mesh", "anywhere", "private", "true"} {
|
||||
_, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor",
|
||||
Values: map[string]any{ReachSetting: map[string]any{"3000": wrong}}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "a reach is") {
|
||||
t.Fatalf("%q was accepted as a reach: %v", wrong, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **A port that says both reach and expose is refused.** They say the same thing in different words,
|
||||
// and accepting both would have the filter follow one while the names followed the other — the
|
||||
// disagreement ADR 0138 exists to remove, reintroduced by the migration away from the older word.
|
||||
func TestReachAndExposeForOnePortAreRefused(t *testing.T) {
|
||||
_, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor", Values: map[string]any{
|
||||
ReachSetting: map[string]any{"3000": ReachInternal},
|
||||
ExposeSetting: map[string]any{"3000": FromEverywhere},
|
||||
}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "same thing in different") {
|
||||
t.Fatalf("a port set both ways was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A path-level refusal carries no port: it is a rule about a name, not an endpoint, and it inherits
|
||||
// whatever that name turned out to be. Narrowing the endpoint must not silently drop it.
|
||||
func TestARuleWithNoPortIsLeftAlone(t *testing.T) {
|
||||
m := aRoutedWeb()
|
||||
m.ContributesMany = map[string]map[string]map[string]any{
|
||||
"route": {"refused": {"label": "app", "path": "/internal", "deny": true}},
|
||||
}
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{m},
|
||||
PublicDomain: "example.test", At: "anchor.internal"}
|
||||
given, err := r.contributions(reachSet(ReachInternal), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sawDeny bool
|
||||
for _, c := range given["route"] {
|
||||
if deny, _ := c.Values["deny"].(bool); deny {
|
||||
sawDeny = true
|
||||
// It keeps both, because it named no endpoint to be narrowed by.
|
||||
if c.Values["name"] == nil || c.Values["internal-name"] == nil {
|
||||
t.Fatalf("the path rule lost a name it shadows: %v", c.Values)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawDeny {
|
||||
t.Fatal("the path rule was dropped")
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,17 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
|
||||
if key == PortsSetting {
|
||||
continue
|
||||
}
|
||||
// `reach` says how far one of this module's endpoints reaches (novox/hq ADR 0138) — the
|
||||
// filter's source, which names are composed, and therefore which authority certifies
|
||||
// them. Validated in Reaches, so not stray.
|
||||
if key == ReachSetting && len(m.Listens) > 0 {
|
||||
continue
|
||||
}
|
||||
// `endpoints` configures a module's endpoints by name — the machine port, the subdomain and
|
||||
// the reach as one block each (novox/hq ADR 0138). Validated in Endpoints, so not stray.
|
||||
if key == EndpointsSetting && len(m.Listens) > 0 {
|
||||
continue
|
||||
}
|
||||
unused = append(unused, fmt.Sprintf(
|
||||
"%s sets %q, and %s has no file or contribution to merge it into",
|
||||
layer.From, key, m.Module))
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- The networks a machine routes for what it hosts, beyond the container runtime's own defaults.
|
||||
--
|
||||
-- novox/hq ADR 0137. The derived packet filter denies forwarding by default and then allows the
|
||||
-- container runtime's two default pools, named in the controller's code with a comment saying that
|
||||
-- a machine configured otherwise "needs this to say so" — and no way to say it. So the filter was
|
||||
-- correct only on a machine whose runtime used the defaults, and silently wrong on any other.
|
||||
--
|
||||
-- Measured on 2026-09-28: flipping a workstation to the derived filter cut egress for five of its
|
||||
-- container networks and for every network its test beds create, because those are allocated from
|
||||
-- ranges the two defaults do not cover. Nothing reported a fault; the containers simply could not
|
||||
-- reach anything.
|
||||
--
|
||||
-- A node-level fact, beside the node's public domain and for the same reason: it is a property of
|
||||
-- the machine, not of whichever module happens to load the filter today. Swapping that module must
|
||||
-- not lose it.
|
||||
--
|
||||
-- Null for a machine that routes nothing but the runtime's defaults, which is the ordinary case and
|
||||
-- what every machine held before this column existed.
|
||||
alter table node add column routed_networks jsonb;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Which of a machine's links face outside it, replacing the networks it was told to say it routes.
|
||||
--
|
||||
-- novox/hq ADR 0140, superseding 0137 and 0139. The derived filter blocked everything passing
|
||||
-- through a machine and then allowed the machine's own containers back by naming the address ranges
|
||||
-- they sit on: two ranges fixed in the controller's source, the rest recorded by 0043's column.
|
||||
--
|
||||
-- Every route to a correct list 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 — measured
|
||||
-- on the control-node, where six ranges fall outside the constants and two of the six belong to
|
||||
-- services the mesh does not run. Generating the list from the modules put half the 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. The filter constrains what arrives from OUTSIDE the machine and
|
||||
-- says nothing about what did not, which needs one fact instead of a list — which links "outside"
|
||||
-- arrives on.
|
||||
--
|
||||
-- Reported by the machine on every apply, never recorded by hand, so it cannot go stale. Null for a
|
||||
-- machine that has not reported yet; the mesh composes no filter for such a machine and leaves the
|
||||
-- one it has, because a rule written around a link with no name is a rule set that does not load.
|
||||
alter table node add column outward_links jsonb;
|
||||
|
||||
-- What 0043 recorded is not migrated into it. The ranges answered a question that no longer exists,
|
||||
-- and every machine that named one keeps working without it: the traffic those ranges allowed is now
|
||||
-- allowed by not having arrived from outside.
|
||||
alter table node drop column routed_networks;
|
||||
@@ -518,6 +518,61 @@ func (i *Inventory) PublicDomainOf(ctx context.Context, name string) (string, er
|
||||
return *domain, nil
|
||||
}
|
||||
|
||||
// RecordOutwardLinks keeps the links a machine reported as facing outside it.
|
||||
//
|
||||
// A reported fact, not a setting (novox/hq ADR 0140). It replaces the networks a machine used to be
|
||||
// told to say it routes: the filter blocked everything passing through and then allowed the machine's
|
||||
// own containers back by naming their address ranges, and every way of keeping that list correct
|
||||
// failed — a constant describes one machine, and a recorded range goes stale in silence. The filter
|
||||
// now constrains what arrives from outside and says nothing about what did not, and the one thing it
|
||||
// needs is which links "outside" arrives on. The machine reads that from its own routing table on
|
||||
// every apply, so it cannot go stale and nobody types it.
|
||||
//
|
||||
// An empty list clears it, which is what a machine with no route off itself reports. The mesh then
|
||||
// composes no filter for that machine at all.
|
||||
func (i *Inventory) RecordOutwardLinks(ctx context.Context, id string, links []string) error {
|
||||
var kept []string
|
||||
for _, name := range links {
|
||||
if name = strings.TrimSpace(name); name != "" {
|
||||
kept = append(kept, name)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
_, err := i.store.Pool().Exec(ctx,
|
||||
`update node set outward_links = null where id = $1`, id)
|
||||
return err
|
||||
}
|
||||
body, err := json.Marshal(kept)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = i.store.Pool().Exec(ctx,
|
||||
`update node set outward_links = $2 where id = $1`, id, string(body))
|
||||
return err
|
||||
}
|
||||
|
||||
// OutwardLinksOf is the links a machine reported as facing outside it, empty when it has reported
|
||||
// none — which is a machine the mesh composes no filter for.
|
||||
func (i *Inventory) OutwardLinksOf(ctx context.Context, name string) ([]string, error) {
|
||||
var body []byte
|
||||
err := i.store.Pool().QueryRow(ctx,
|
||||
`select outward_links from node where name = $1`, name).Scan(&body)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrNoSuchNode, name)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var links []string
|
||||
if err := json.Unmarshal(body, &links); err != nil {
|
||||
return nil, fmt.Errorf("the outward links recorded for %s are not a list: %w", name, err)
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
|
||||
// RecordOverlayKey keeps the public half a node generated.
|
||||
func (i *Inventory) RecordOverlayKey(ctx context.Context, node, key string) error {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
|
||||
@@ -301,6 +301,17 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
// Which of its links face outside (novox/hq ADR 0140), whenever it says so. Recorded on every
|
||||
// report that carries it, adopted or converged, because the filter the mesh composes is written
|
||||
// around it — and never cleared by a report that carries none, which is every bare word that the
|
||||
// node is there. A machine whose routing table it could not read reports nothing rather than
|
||||
// guessing, and keeps whatever it last said; a machine with genuinely no route off itself is one
|
||||
// the mesh composes no filter for at all.
|
||||
if len(report.Outward) > 0 {
|
||||
if err := e.Inventory.RecordOutwardLinks(ctx, node.ID, report.Outward); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
// What it says about the tunnel it carried (novox/hq ADR 0105), whenever it says it.
|
||||
if report.Tunnel != nil {
|
||||
if err := e.Inventory.RecordCarriedTunnel(ctx, node.ID, inventory.Carried{
|
||||
|
||||
@@ -170,6 +170,20 @@ type Report struct {
|
||||
// Firewall is the firewall found on the machine — "ufw" or "none" — and empty on a node that
|
||||
// was never asked, which is every converged one.
|
||||
Firewall string `json:"firewall,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 the filter the mesh
|
||||
// composes for it is written around these and nothing else.
|
||||
//
|
||||
// **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 the machine has not said. The mesh composes no filter for such a machine and
|
||||
// leaves the one it has: a rule written around a link with no name is a rule set that does not
|
||||
// load, and that is a machine filtering nothing while its unit reports success.
|
||||
Outward []string `json:"outward,omitempty"`
|
||||
// Reachable is what can be reached on the machine now: every listening socket and every
|
||||
// published container port. Only an adopted node reports it; it is what converging previews.
|
||||
Reachable []Reach `json:"reachable,omitempty"`
|
||||
|
||||
+11
-14
@@ -346,7 +346,8 @@ func (s *Server) reported(ctx context.Context, m Control) {
|
||||
// whenever it arrives, which is the behaviour the mesh has had all along.
|
||||
func staleAgainst(report Report) string {
|
||||
if report.Rekey != nil || report.Tunnel != nil || len(report.Held) > 0 ||
|
||||
report.Firewall != "" || len(report.Reachable) > 0 || len(report.Carried) > 0 {
|
||||
report.Firewall != "" || len(report.Reachable) > 0 || len(report.Carried) > 0 ||
|
||||
len(report.Outward) > 0 {
|
||||
return ""
|
||||
}
|
||||
return report.Declared
|
||||
@@ -476,7 +477,15 @@ func (s *Server) catchingUp(ctx context.Context, m Control) {
|
||||
// module's event from a module called "control-plane", which does not exist — so the
|
||||
// controller's own account refused it, every catalogue that asked what it missed was
|
||||
// answered with nothing, and its graph kept the gap (found 2026-09-28).
|
||||
if err := s.bus.PublishSeatEvent(ctx, MeshControllerSeat, KeyBuiltBefore, replayed(a)); err != nil {
|
||||
body, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
// A body that cannot be written is this program's fault, not the bus's, and publishing
|
||||
// an empty one would put a fact on the mesh that says nothing.
|
||||
s.log.Printf("cannot re-announce %s at %s: %v", a.Module, short(a.Commit), err)
|
||||
_ = m.Took()
|
||||
return
|
||||
}
|
||||
if err := s.bus.PublishSeatEvent(ctx, MeshControllerSeat, KeyBuiltBefore, body); err != nil {
|
||||
// Said and abandoned rather than retried: the catalogue asks again every time it
|
||||
// starts, and half a graph delivered twice is no better than half delivered once.
|
||||
s.log.Printf("replaying %s at %s failed, and the rest is abandoned: %v",
|
||||
@@ -601,15 +610,3 @@ func (s *Server) saysWhatItDid(ctx context.Context, report Report) {
|
||||
s.log.Printf("could not say that %s %s: %v", report.Node, event, err)
|
||||
}
|
||||
}
|
||||
|
||||
// replayed is one announcement as the control plane states it. The same body the build machine's
|
||||
// outcome carries, because what the catalogue does with it is the same.
|
||||
func replayed(a Announcement) []byte {
|
||||
raw, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
// A body that cannot be marshalled is a programming error, not a bus failure, and an empty
|
||||
// one is refused by the reader rather than silently taken as an announcement of nothing.
|
||||
return nil
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user