diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 5bdb970..103a880 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -897,6 +897,24 @@ 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 + } + for port, reach := range reaches { + 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 } @@ -1065,7 +1083,11 @@ 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) + } + composeName(values, r.PublicDomain, r.At, reaches) out[to] = append(out[to], Contribution{From: m.Module, Values: values}) } // Several contributions to one requirement (ADR 0094's sibling for `contributes`): an @@ -1079,7 +1101,11 @@ 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) + } + composeName(values, r.PublicDomain, r.At, reaches) out[to] = append(out[to], Contribution{From: m.Module, Values: values}) } } @@ -1110,10 +1136,34 @@ 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) { if values == nil { return } + // **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 := asPort(values["port"]); 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 diff --git a/internal/catalogue/filtering.go b/internal/catalogue/filtering.go index e246e87..6fb556e 100644 --- a/internal/catalogue/filtering.go +++ b/internal/catalogue/filtering.go @@ -701,3 +701,129 @@ 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} + +// 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{} + 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 +} diff --git a/internal/catalogue/reach_test.go b/internal/catalogue/reach_test.go new file mode 100644 index 0000000..436498a --- /dev/null +++ b/internal/catalogue/reach_test.go @@ -0,0 +1,175 @@ +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.** One statement, and the rule it produces is the one the reach +// means — which is the whole claim of ADR 0138 and the reason reach is not two settings. +func TestTheFilterFollowsTheSameReach(t *testing.T) { + for _, c := range []struct{ reach, want string }{ + {ReachInternal, FromMesh}, + {ReachPublic, FromEverywhere}, + {ReachBoth, FromEverywhere}, + {ReachMachine, FromMachine}, + } { + r := Resolution{Node: "anchor", Modules: []Manifest{aRoutedWeb()}} + 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 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") + } +} diff --git a/internal/catalogue/settings.go b/internal/catalogue/settings.go index 854a9cf..0a12ee0 100644 --- a/internal/catalogue/settings.go +++ b/internal/catalogue/settings.go @@ -186,6 +186,12 @@ 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 + } unused = append(unused, fmt.Sprintf( "%s sets %q, and %s has no file or contribution to merge it into", layer.From, key, m.Module))