diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index ea5d8ed..3ca4de7 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -138,8 +138,19 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { } // Once, from every module's listens -- not per module. A module receiving only its own ports - // would write a rule set that closed every other module on the machine. - rules, err := r.Filtering(with.Generators, with.Ports) + // would write a rule set that closed every other module on the machine. Each module's per-node + // exposure settings override its listens' source first (novox/hq ADR 0051). + exposure := map[string]map[int]string{} + for _, m := range r.Modules { + e, err := Exposure(m, with.Settings[m.Module]) + if err != nil { + return nil, err + } + if e != nil { + exposure[m.Module] = e + } + } + rules, err := r.Filtering(with.Generators, with.Ports, exposure) if err != nil { return nil, err } diff --git a/internal/catalogue/filtering.go b/internal/catalogue/filtering.go index 36a1aba..53e9def 100644 --- a/internal/catalogue/filtering.go +++ b/internal/catalogue/filtering.go @@ -3,6 +3,7 @@ package catalogue import ( "fmt" "sort" + "strconv" "strings" ) @@ -31,7 +32,7 @@ type Rule struct { // a consequence of what runs on it, not a second list kept in step by hand. Nothing else opens a // port: **what is not declared is closed**, which is the property that makes the derivation worth // having rather than merely tidy. -func (r Resolution) Filtering(computed map[string]Generator, ports map[string]map[int]int) ([]Rule, error) { +func (r Resolution) Filtering(computed map[string]Generator, ports map[string]map[int]int, exposure map[string]map[int]string) ([]Rule, error) { // Keyed by what actually distinguishes an opening. Two modules wanting :443 from the mesh is // one rule with two sources; one wanting it from the mesh and another from anywhere is two, // and they are collapsed below -- deliberately, and only in the widening direction. @@ -70,10 +71,17 @@ func (r Resolution) Filtering(computed map[string]Generator, ports map[string]ma if at, known := ports[m.Module][l.Port]; known { port = at } - at := opening{port: port, protocol: l.At(), from: l.From} + // The source, with any per-node exposure setting applied. The override names the port + // the module declares, so it travels with that port to wherever the machine publishes + // it (novox/hq ADR 0051): the same module is internal on one node and public on another. + from := l.From + if override, set := exposure[m.Module][l.Port]; set { + from = override + } + at := opening{port: port, protocol: l.At(), from: from} rule, seen := found[at] if !seen { - rule = &Rule{Port: port, Protocol: l.At(), From: l.From} + rule = &Rule{Port: port, Protocol: l.At(), From: from} found[at] = rule } rule.Because = append(rule.Because, m.Module) @@ -100,6 +108,62 @@ func (r Resolution) Filtering(computed map[string]Generator, ports map[string]ma return widest(out), nil } +// ExposeSetting is the settings key that overrides a listen's source per node (novox/hq ADR 0051): +// +// {"expose": {"5432": "anywhere"}} +// +// makes the module's port 5432 open to the public on the node this is set for, whatever the +// manifest's default. It keys on the port the module declares — the mesh maps that to where the +// machine publishes, and the override travels with it. +const ExposeSetting = "expose" + +// Exposure reads a module's per-node exposure overrides from its settings: declared-port → source. +// +// It refuses an override for a port the module does not listen on, or to a source that is not a +// real one — an exposure setting that reaches no port, or names a source nothing enforces, is the +// "reads as a restriction and is none" fault this whole mechanism exists to prevent (novox/hq +// ADR 0048/0050). A module with no `expose` setting yields nothing and keeps its manifest defaults. +func Exposure(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 + } + + out := map[int]string{} + for _, layer := range layers { + raw, ok := layer.Values[ExposeSetting] + if !ok { + continue + } + entries, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s: %s is a { port: source } map, and %q set it to something else", + m.Module, ExposeSetting, layer.From) + } + for portText, value := range entries { + port, err := strconv.Atoi(portText) + if err != nil { + return nil, fmt.Errorf("%s exposes %q, which is not a port", m.Module, portText) + } + if !listened[port] { + return nil, fmt.Errorf( + "%s exposes port %d, which it does not listen on — the setting reaches nothing", + m.Module, port) + } + source, ok := value.(string) + if !ok || (source != FromMesh && source != FromEverywhere && source != FromMachine) { + return nil, fmt.Errorf("%s exposes port %d as %v; it is %q, %q or %q", + m.Module, port, value, FromMesh, FromEverywhere, FromMachine) + } + out[port] = source + } + } + if len(out) == 0 { + return nil, nil + } + return out, nil +} + // widest drops a rule that another already covers. // // A port open to anywhere is not additionally restricted by a second rule opening it to the mesh: diff --git a/internal/catalogue/filtering_test.go b/internal/catalogue/filtering_test.go index 204e37b..e222189 100644 --- a/internal/catalogue/filtering_test.go +++ b/internal/catalogue/filtering_test.go @@ -369,7 +369,7 @@ func TestAMachineOffTheNetworkIsBoundToItselfByAnAddressThatWorks(t *testing.T) // mustFilter is the rule set, refusing to continue if it could not be computed. func mustFilter(t *testing.T, r Resolution, computed map[string]Generator) []Rule { t.Helper() - rules, err := r.Filtering(computed, nil) + rules, err := r.Filtering(computed, nil, nil) if err != nil { t.Fatalf("no rule set could be computed: %v", err) } @@ -440,7 +440,7 @@ func TestAComputedModulesOwnListensAreNotLost(t *testing.T) { func TestAGeneratorThatCannotSayRefusesTheRuleSet(t *testing.T) { _, err := Resolution{Node: "anchor", Modules: []Manifest{{Module: "networking", Computed: "mesh-network"}}, - }.Filtering(map[string]Generator{"mesh-network": cannotSay{}}, nil) + }.Filtering(map[string]Generator{"mesh-network": cannotSay{}}, nil, nil) if err == nil { t.Fatal("a machine whose open ports could not be computed was given a rule set anyway") } @@ -578,3 +578,47 @@ func named(r Resolution) []string { } return out } + +// A port's source is a per-node setting, not a manifest constant: the same module is internal on +// one machine and public on another (novox/hq ADR 0051). postgres listens from the mesh by default; +// a setting on one node exposes it to anywhere, and the rule set follows. +func TestExposureSettingOverridesAListensSource(t *testing.T) { + postgres := Manifest{Module: "postgres", Version: "1", + Listens: []Listening{{Port: 5432, From: FromMesh, Why: "the database"}}} + + base, err := Resolution{Node: "ace", Modules: []Manifest{postgres}}.Filtering(nil, nil, nil) + if err != nil { + t.Fatalf("filtering: %v", err) + } + if len(base) != 1 || base[0].From != FromMesh { + t.Fatalf("without a setting, the default source is the mesh: %+v", base) + } + + expose := map[string]map[int]string{"postgres": {5432: FromEverywhere}} + exposed, err := Resolution{Node: "novox", Modules: []Manifest{postgres}}.Filtering(nil, nil, expose) + if err != nil { + t.Fatalf("filtering: %v", err) + } + if len(exposed) != 1 || exposed[0].From != FromEverywhere { + t.Fatalf("the setting did not open the port to anywhere: %+v", exposed) + } +} + +// Exposure refuses a setting that names a port the module does not listen on, or a source that is +// not a real one — a setting reaching nothing is worse than none (novox/hq ADR 0048/0051). +func TestExposureRefusesAPortNotListenedOnAndABadSource(t *testing.T) { + postgres := Manifest{Module: "postgres", Listens: []Listening{{Port: 5432, From: FromMesh}}} + layer := func(port, source string) []Layer { + return []Layer{{From: "node", Values: map[string]any{ExposeSetting: map[string]any{port: source}}}} + } + + if _, err := Exposure(postgres, layer("5432", FromEverywhere)); err != nil { + t.Fatalf("a valid exposure was refused: %v", err) + } + if _, err := Exposure(postgres, layer("6379", FromEverywhere)); err == nil { + t.Error("a port the module does not listen on was accepted") + } + if _, err := Exposure(postgres, layer("5432", "everyone")); err == nil { + t.Error("a source that is not mesh/anywhere/machine was accepted") + } +} diff --git a/internal/catalogue/settings.go b/internal/catalogue/settings.go index ef1fc8d..608f8a6 100644 --- a/internal/catalogue/settings.go +++ b/internal/catalogue/settings.go @@ -171,6 +171,11 @@ func UnusedSettings(m Manifest, layers []Layer) []string { var unused []string for _, layer := range layers { for key := range layer.Values { + // `expose` is a real destination for a module that listens: it overrides a port's + // source (novox/hq ADR 0051), validated in Exposure, so it is not stray here. + if key == ExposeSetting && 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))