An assignment says how far an endpoint reaches, and three things read it
novox/hq ADR 0138. Reachability was settled three times over: the filter read a listen's source with expose able to override it; 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 enforced by nothing — while a public certificate for that name was obtained anyway. Measured on the control node: an identity provider holding a 90-day public certificate and a 24-hour internal one, neither asked for. `reach` is one value per endpoint, per node — machine, internal, public or both — and the filter's source and the composed names both follow it. The authority needs no work: the proxy already asks the public authority for a route's own name and its internal authority for the internal one, so controlling the names controls the authority. Joined by the port, which a route already names: 35 of the catalogue's 36 route entries name a port 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, left alone. Nothing said composes both names and follows the manifest's `from`, so every mesh already running is unchanged until an assignment speaks. A port that says both reach and expose is refused: they say the same thing in different words, and the filter would follow one while the names followed the other.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user