diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index 78a7c3f..5e863a4 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -1211,6 +1211,14 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory, func whereEveryoneIs(ctx context.Context, inv *inventory.Inventory, shelf map[string]catalogue.Manifest) (map[string]string, error) { + if shelf == nil { + // Refused rather than answered. Being on the private network is a conclusion about what a + // node resolves to, so with no catalogue nothing resolves and the honest answer is + // "nobody" — which is wrong, indistinguishable from a mesh with no overlay, and refused + // every certificate the mesh was asked for while saying the machine was on no network. + return nil, errors.New( + "asked where everyone is without the catalogue, which cannot be answered") + } places, err := inv.Overlays(ctx) if err != nil { return nil, err @@ -1296,9 +1304,39 @@ func declarationWith(ctx context.Context, inv *inventory.Inventory, node string, break } + // And who else is on the private network, which is what a rule saying "from the mesh" + // resolves to. Every node's address, including this one's: a machine reaching itself by its + // own overlay address rather than by loopback is ordinary, and leaving it out would filter + // the node's own traffic to itself with no rule naming why. + private, err := onThePrivateNetwork(ctx, inv) + if err != nil { + return nil, err + } + return plan.Declaration(catalogue.Rendering{ Settings: settings, Generators: gens, Grants: grants, Needed: needed, - Certificate: certificate, Authority: authority}) + Certificate: certificate, Authority: authority, Mesh: private}) +} + +// onThePrivateNetwork is every node's address on the overlay, sorted. +// +// A node with no address is left out rather than rendered as an empty source: an empty entry in a +// source set is a syntax error in the rule file, and a rule file that does not load leaves the +// node filtering whatever it was filtering before -- the one outcome worse than a wrong rule, +// because nothing reports it. +func onThePrivateNetwork(ctx context.Context, inv *inventory.Inventory) ([]string, error) { + places, err := inv.Overlays(ctx) + if err != nil { + return nil, err + } + var out []string + for _, p := range places { + if strings.TrimSpace(p.Address) != "" { + out = append(out, p.Address) + } + } + sort.Strings(out) + return out, nil } // certificateFor is what the mesh certifies about one machine's internal name. @@ -1332,7 +1370,16 @@ func certificateFor(ctx context.Context, inv *inventory.Inventory, node string) // The name it is certified for. Only a machine on the private network has one — a certificate // for a name nothing resolves is a certificate nothing can check. - where, err := whereEveryoneIs(ctx, inv, nil) + // + // With the catalogue, not without it. Being on the private network is a conclusion about what + // a node resolves to, so a nil shelf resolves nothing and every machine looks like it is on no + // network — which refused every certificate the mesh was asked for, and said the machine was + // not on a network it plainly was. + shelf, err := inv.Catalogue(ctx) + if err != nil { + return "", "", err + } + where, err := whereEveryoneIs(ctx, inv, shelf) if err != nil { return "", "", err } diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index dc962d4..c092aeb 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -53,6 +53,11 @@ type Rendering struct { // name the module gave it. Needed map[string]map[string]string + // Mesh is every node's address on the private network, which is what a rule saying "from the + // mesh" resolves to. Passed in for the same reason grants are: who else is on the network is + // a fact about the mesh, and resolution answers questions about one machine. + Mesh []string + Settings SettingsBy Generators map[string]Generator // Grants are the credentials this node must create, for the provisions it offers. Passed in @@ -80,9 +85,19 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { return nil, err } + // 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. + filtering := AsNftables(r.Filtering(), with.Mesh) + var out []map[string]any for _, m := range r.Modules { resources := m.Resources + if f := m.Filtering; f != nil { + resources = append(append([]map[string]any{}, resources...), map[string]any{ + "id": FilteringID(), "type": "file", "path": f.Into, + "content": filtering, "mode": "0600", + }) + } if c := m.Certificate; c != nil { if with.Certificate == "" { // Asked for and not issued. Refused rather than skipped: a module that serves TLS diff --git a/internal/catalogue/filtering.go b/internal/catalogue/filtering.go new file mode 100644 index 0000000..e61da66 --- /dev/null +++ b/internal/catalogue/filtering.go @@ -0,0 +1,192 @@ +package catalogue + +import ( + "fmt" + "sort" + "strings" +) + +// A Rule is one opening in a node's filter, and what caused it. +// +// **A rule names its source** ([ADR 0007](novox/hq)). Not decoration: the fault this whole +// mechanism exists to prevent is a rule that appears to restrict something and restricts nothing, +// and the first question anybody asks of a generated rule set is *why is this port open* — which +// has no answer unless the derivation carries it through. +type Rule struct { + Port int + Protocol string + // From is who may reach it, as the manifest said: mesh, anywhere, or machine. + From string + // Because is every module that wanted this port open, sorted. More than one is ordinary -- + // two modules can want :443 -- and losing the others would make removing one module look + // like it should have closed a port that must stay open. + Because []string + // Why is what those modules said the port is for, in the same order. + Why []string +} + +// Filtering is the whole rule set for the node this resolved for. +// +// **Derived from what is assigned here, and from the overlay's shape** — a node's open ports are +// 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() []Rule { + // 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. + type opening struct { + port int + protocol string + from string + } + found := map[opening]*Rule{} + for _, m := range r.Modules { + for _, l := range m.Listens { + at := opening{port: l.Port, protocol: l.At(), from: l.From} + rule, seen := found[at] + if !seen { + rule = &Rule{Port: l.Port, Protocol: l.At(), From: l.From} + found[at] = rule + } + rule.Because = append(rule.Because, m.Module) + rule.Why = append(rule.Why, l.Why) + } + } + + out := make([]Rule, 0, len(found)) + for _, rule := range found { + out = append(out, *rule) + } + // By port, then protocol, then source. A rule set that reorders itself between two runs of an + // unchanged mesh is a diff nobody can read, and every consumer of this compares one to the + // last. + sort.Slice(out, func(a, b int) bool { + if out[a].Port != out[b].Port { + return out[a].Port < out[b].Port + } + if out[a].Protocol != out[b].Protocol { + return out[a].Protocol < out[b].Protocol + } + return out[a].From < out[b].From + }) + return widest(out) +} + +// 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: +// keeping both would render two lines where the narrower one restricts nothing, which is exactly +// the appearance-of-restriction this mechanism exists to remove. The narrower rule's sources are +// carried onto the wider one, so the module that wanted it is still named. +func widest(rules []Rule) []Rule { + reach := map[string]int{FromMachine: 0, FromMesh: 1, FromEverywhere: 2} + var out []Rule + for _, rule := range rules { + covered := false + for i := range out { + if out[i].Port != rule.Port || out[i].Protocol != rule.Protocol { + continue + } + if reach[out[i].From] >= reach[rule.From] { + out[i].Because = append(out[i].Because, rule.Because...) + out[i].Why = append(out[i].Why, rule.Why...) + covered = true + break + } + // The other way round: this one is wider, so it replaces what is there and takes its + // sources with it. + rule.Because = append(rule.Because, out[i].Because...) + rule.Why = append(rule.Why, out[i].Why...) + out = append(out[:i], out[i+1:]...) + break + } + if !covered { + out = append(out, rule) + } + } + return out +} + +// AsNftables renders a rule set as a complete nftables configuration. +// +// Complete rather than a fragment: `nft -f` on this file replaces the table entirely, so what the +// node ends up filtering is what the mesh computed and nothing left over from before. A fragment +// appended to whatever was there would make the derivation advisory, and an advisory firewall is +// the unenforced rule again. +// +// `mesh` is rendered as the addresses of the nodes that are actually on the private network, not +// as a subnet. The mesh knows every address; a subnet is a guess that stays wrong quietly, and +// the set shrinks when a node leaves without anybody editing anything. +func AsNftables(rules []Rule, mesh []string) string { + 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") + // Replacing this table and nothing else, every time it is loaded. Declared as an empty table + // first so the delete cannot fail on a machine that has never loaded one — the alternative + // idiom, `flush ruleset`, empties every table on the machine, including the ones the container + // runtime writes for its bridges. Reloading the mesh's rules must not stop every container. + b.WriteString("table inet mesh {}\ndelete table inet mesh\n\n") + b.WriteString("table inet mesh {\n") + b.WriteString("\tchain input {\n") + // Dropping by default is the whole point, and the three lines under it are what keep dropping + // by default from meaning "unreachable": a reply to something this node started, its own + // loopback, and the diagnostic that tells an operator the machine is up and refusing rather + // than gone. + b.WriteString("\t\ttype filter hook input priority filter; policy drop;\n") + b.WriteString("\t\tct state established,related accept\n") + b.WriteString("\t\tct state invalid drop\n") + b.WriteString("\t\tiif lo accept\n") + 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") + + if len(rules) > 0 { + b.WriteString("\n") + } + for _, rule := range rules { + for i, module := range rule.Because { + why := "" + if i < len(rule.Why) && rule.Why[i] != "" { + why = " — " + rule.Why[i] + } + b.WriteString(fmt.Sprintf("\t\t# %s%s\n", module, why)) + } + switch rule.From { + case FromMachine: + // Nothing. A port bound for something else on the same machine is reached over + // loopback, which is already accepted above -- and rendering a rule for it would open + // it to the network, which is the opposite of what the manifest asked for. + b.WriteString(fmt.Sprintf("\t\t# %s/%d is this machine only; loopback already covers it\n", + rule.Protocol, rule.Port)) + case FromMesh: + if len(mesh) == 0 { + // Said, and impossible to render. Left as a comment rather than silently widened + // to anywhere or silently dropped: one of those opens a port nobody asked to + // open, and the other closes one somebody did. + b.WriteString(fmt.Sprintf( + "\t\t# %s/%d wanted the mesh, and this node knows no mesh addresses; closed\n", + rule.Protocol, rule.Port)) + break + } + b.WriteString(fmt.Sprintf("\t\tip saddr { %s } %s dport %d accept\n", + strings.Join(mesh, ", "), rule.Protocol, rule.Port)) + case FromEverywhere: + b.WriteString(fmt.Sprintf("\t\t%s dport %d accept\n", rule.Protocol, rule.Port)) + } + } + + b.WriteString("\t}\n") + // Nothing this node sends is filtered, and it is stated rather than left to nftables' default + // so that reading this file answers the question instead of requiring the reader to know it. + b.WriteString("\tchain output {\n\t\ttype filter hook output priority filter; policy accept;\n\t}\n") + // **No forward chain, deliberately.** What this machine forwards is the container runtime's + // business — docker writes its own rules for the bridges it creates, and a second table + // hooking forward is consulted as well as those, so a drop here drops container traffic that + // docker explicitly allowed. Every container on the node stops, including the control plane. + // + // The mesh has no knowledge with which to compute a forwarding policy: nothing in a manifest + // says what a machine routes. Writing one anyway would be a rule nothing derives, which is the + // fault this whole mechanism exists to remove. + b.WriteString("}\n") + return b.String() +} diff --git a/internal/catalogue/filtering_test.go b/internal/catalogue/filtering_test.go new file mode 100644 index 0000000..a098b8f --- /dev/null +++ b/internal/catalogue/filtering_test.go @@ -0,0 +1,221 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A manifest key nothing reads is worse than a key that is refused: five HAL manifests carried +// `scope:`, which read as a restriction and restricted nothing (novox/hq 04-ISSUES/003). The host's +// declaration parser has always refused unknown keys; manifests never did. +func TestAManifestKeyNothingReadsIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"web","scope":"mesh"}`)) + if err == nil { + t.Fatal("a manifest with a key nothing reads was accepted, which is how scope: survived") + } + if !strings.Contains(err.Error(), "scope") { + t.Fatalf("refused without naming the key, which leaves the author guessing: %v", err) + } +} + +// The same discipline must not refuse a manifest that is merely ordinary. +func TestAnOrdinaryManifestIsStillAccepted(t *testing.T) { + m, err := ParseManifest([]byte( + `{"module":"web","listens":[{"port":443,"from":"anywhere","why":"the site"}]}`)) + if err != nil { + t.Fatalf("an ordinary manifest was refused: %v", err) + } + if len(m.Listens) != 1 || m.Listens[0].At() != "tcp" { + t.Fatalf("listens did not survive parsing: %+v", m.Listens) + } +} + +// A rule with no source is open, and must say so rather than appear to restrict something. +func TestAPortWithNoSourceIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"web","listens":[{"port":443}]}`)) + if err == nil { + t.Fatal("a port that never said who may reach it was accepted; it reads as a restriction") + } + if !strings.Contains(err.Error(), "from where") { + t.Fatalf("refused without saying what is missing: %v", err) + } +} + +func TestASourceNobodyDefinedIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"web","listens":[{"port":443,"from":"lan"}]}`)) + if err == nil { + t.Fatal("a source the mesh cannot render was accepted, and would be silently dropped") + } +} + +// The derivation is over the whole node, not one module. +func TestTheRuleSetIsEveryAssignedModulesPorts(t *testing.T) { + r := Resolution{Modules: []Manifest{ + {Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}}, + {Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}}, + }} + rules := r.Filtering() + if len(rules) != 2 { + t.Fatalf("a node's rule set lost a module's ports: %+v", rules) + } + if rules[0].Port != 443 || rules[1].Port != 5432 { + t.Fatalf("rules are not in a stable order, so every diff is unreadable: %+v", rules) + } +} + +// Every rule names what caused it, and both causes survive. +func TestTwoModulesWantingOnePortAreBothNamed(t *testing.T) { + r := Resolution{Modules: []Manifest{ + {Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere, Why: "the site"}}}, + {Module: "board", Listens: []Listening{{Port: 443, From: FromEverywhere, Why: "the board"}}}, + }} + rules := r.Filtering() + if len(rules) != 1 { + t.Fatalf("one port became %d rules", len(rules)) + } + if len(rules[0].Because) != 2 { + 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) + if !strings.Contains(nft, "web") || !strings.Contains(nft, "board") { + t.Fatalf("the rendered rule set does not name both sources:\n%s", nft) + } +} + +// Widening, and only widening. +func TestAPortOpenToEveryoneIsNotAlsoRestrictedToTheMesh(t *testing.T) { + r := Resolution{Modules: []Manifest{ + {Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}}, + {Module: "board", Listens: []Listening{{Port: 443, From: FromMesh}}}, + }} + rules := r.Filtering() + if len(rules) != 1 { + t.Fatalf("the same port was rendered twice, once restricting nothing: %+v", rules) + } + if rules[0].From != FromEverywhere { + t.Fatalf("the narrower rule won, which closes a port a module asked to open: %+v", rules[0]) + } + if len(rules[0].Because) != 2 { + t.Fatalf("the covered module was dropped along with its rule: %+v", rules[0]) + } +} + +// What is not declared is closed. +func TestWhatNoModuleDeclaredIsClosed(t *testing.T) { + nft := AsNftables((Resolution{Modules: []Manifest{ + {Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}}, + }}).Filtering(), []string{"198.51.100.2"}) + // 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;") { + t.Fatalf("the input chain does not drop by default, so nothing is filtered:\n%s", nft) + } + if strings.Contains(nft, "dport 22") { + t.Fatalf("a port no module declared was opened:\n%s", nft) + } + // Dropping by default must not mean unreachable: the node's own outbound link to the broker + // is the one connection the whole mesh runs on, and it is a reply to something it started. + if !strings.Contains(nft, "ct state established,related accept") { + t.Fatalf("a node applying this would lose its own link to the mesh:\n%s", nft) + } + if !strings.Contains(nft, "iif lo accept") { + t.Fatalf("loopback is filtered, which breaks everything on the machine:\n%s", nft) + } +} + +// Loading it twice must be the same as loading it once, and must not disturb anything else. +// +// `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) + if strings.Contains(nft, "flush ruleset") { + t.Fatalf("loading the rule set empties every table on the machine:\n%s", nft) + } + if !strings.Contains(nft, "delete table inet mesh") { + t.Fatalf("loading it a second time would add to what is there rather than replace it:\n%s", nft) + } + // The delete must not fail on a machine that has never loaded one, or the service does not + // start and the machine filters nothing while reporting a configuration error. + if !strings.Contains(nft, "table inet mesh {}\ndelete table inet mesh") { + t.Fatalf("the delete has nothing to delete on a machine loading this for the first time:\n%s", nft) + } +} + +// The rule set governs what reaches this machine, and nothing else. +// +// A forward policy would be consulted alongside the container runtime's own rules, so dropping +// there stops container traffic the runtime allowed -- every container on the node, control plane +// included. And nothing in a manifest says what a machine routes, so there is nothing to derive it +// from: a forwarding rule here would be exactly the undeclared rule this mechanism removes. +func TestTheRuleSetDoesNotDecideWhatTheMachineForwards(t *testing.T) { + nft := AsNftables(nil, []string{"198.51.100.2"}) + if strings.Contains(nft, "hook forward") { + t.Fatalf("the rule set filters forwarding, which stops every container on the node:\n%s", nft) + } +} + +// "From the mesh" is the addresses the mesh actually has. +func TestFromTheMeshIsTheNodesTheMeshKnows(t *testing.T) { + nft := AsNftables((Resolution{Modules: []Manifest{ + {Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}}, + }}).Filtering(), []string{"198.51.100.2", "198.51.100.3"}) + 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) + } +} + +// The case that must not be widened silently. +func TestAMeshPortOnANodeWithNoMeshIsClosedAndSaysSo(t *testing.T) { + nft := AsNftables((Resolution{Modules: []Manifest{ + {Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}}, + }}).Filtering(), nil) + if strings.Contains(nft, "dport 5432 accept") { + t.Fatalf("a port meant for the mesh was opened to everything:\n%s", nft) + } + if !strings.Contains(nft, "closed") { + t.Fatalf("it was closed silently, which is a fault nobody can find:\n%s", nft) + } +} + +// A port bound for something else on the same machine must not reach the network. +func TestAMachineScopedPortIsNotOpened(t *testing.T) { + nft := AsNftables((Resolution{Modules: []Manifest{ + {Module: "cache", Listens: []Listening{{Port: 6379, From: FromMachine}}}, + }}).Filtering(), []string{"198.51.100.2"}) + if strings.Contains(nft, "dport 6379 accept") { + t.Fatalf("a port for this machine only was opened to the network:\n%s", nft) + } +} + +// The rule set is the machine's, not the asking module's. +func TestTheModuleAskingForTheRuleSetGetsEveryModulesPorts(t *testing.T) { + r := Resolution{Modules: []Manifest{ + {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"}}) + if err != nil { + t.Fatalf("declaration: %v", err) + } + var content string + for _, res := range out { + if res["path"] == "/etc/mesh/filter.nft" { + content, _ = res["content"].(string) + } + } + if content == "" { + t.Fatal("the module that asked for the rule set did not receive one") + } + if !strings.Contains(content, "dport 443 accept") { + t.Fatalf("the rule set holds only the asking module's ports:\n%s", content) + } +} + +func TestAskingForTheRuleSetWithNowhereToPutItIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"firewall","filtering":{"into":""}}`)) + if err == nil { + t.Fatal("a module asked for the rule set and named no path; it would receive nothing") + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 25c26cc..583be20 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -6,6 +6,7 @@ package catalogue import ( + "bytes" "encoding/json" "fmt" "regexp" @@ -212,6 +213,24 @@ type Manifest struct { // module, in a file anybody can read, for ever. Needs map[string]string `json:"needs,omitempty"` + // Listens is what this module accepts connections on, and from where. + // + // **A rule names its source** ([ADR 0007](novox/hq)). A port with no source is open to + // everything that can reach the machine, and saying so is the difference between a manifest + // that restricts something and one that appears to — which is the fault + // [04-ISSUES/003](novox/hq) records, where five manifests carried a `scope:` nothing read. + // + // **Derived, not kept in step by hand.** A machine's open ports are a consequence of what runs + // on it; the mesh gathers these and hands the whole set to whatever enforces them. + Listens []Listening `json:"listens,omitempty"` + + // Filtering is where this module wants the node's whole computed rule set written. + // + // One module per node asks for it, and what it receives is derived from every module's + // `listens` rather than from its own — a firewall is a property of the machine, and a module + // that could only see its own ports would write a rule set that closed everything else. + Filtering *Filtering `json:"filtering,omitempty"` + // Certificate is where this module wants a certificate for its machine's name inside the // mesh, and where the key that goes with it can be found. // @@ -274,6 +293,46 @@ const ( ArtifactUpstream = "upstream" ) +// Listening is one port a module accepts connections on. +type Listening struct { + 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. + Protocol string `json:"protocol,omitempty"` + // From is who may reach it. Required, because a rule with no source is open and must say so + // rather than appear to restrict something. + From string `json:"from"` + // Why this port is open, for somebody reading a generated rule set and wondering. + Why string `json:"why,omitempty"` +} + +// Where a listening port may be reached from. +const ( + // FromMesh is any machine on the private network. What almost everything wants. + FromMesh = "mesh" + // FromEverywhere is the public internet. Deliberately spelled out: a port open to everything + // should be legible as such in the manifest, not the consequence of an omission. + FromEverywhere = "anywhere" + // FromMachine is this machine only — a port bound for something else on the same host. + FromMachine = "machine" +) + +// At is this port's protocol, with the default applied. +func (l Listening) At() string { + if l.Protocol == "" { + return "tcp" + } + return l.Protocol +} + +// Filtering says where a module wants the computed rule set. +type Filtering struct { + // Into is the path to write it to. Whatever loads it is this module's own business — an + // action beside this field, ordinarily — because how a machine enforces rules is a fact about + // the machine and the mesh has no business knowing it. + Into string `json:"into"` +} + // Certificate says where a module wants what the mesh issued for its machine. type Certificate struct { // Into is where the certificate is written. @@ -287,6 +346,10 @@ type Certificate struct { func CertificateID() string { return "certificate" } func AuthorityID() string { return "certificate-authority" } +// FilteringID names the computed rule set, so it is the same resource across every declaration +// and a change to it is an update rather than an addition beside the old one. +func FilteringID() string { return "filtering" } + // NeedID is the resource identity of the file a module's own secret lands in. func NeedID(name string) string { return "needs-" + name } @@ -331,7 +394,16 @@ func ReceivedID(requirement string) string { return "received-" + requirement } // them in one pass or in four. func ParseManifest(raw []byte) (Manifest, error) { var m Manifest - if err := json.Unmarshal(raw, &m); err != nil { + // Strictly. **An unknown key is refused**, which is the discipline the host's declaration + // parser has and manifests lacked (novox/hq 04-ISSUES/003): a `scope:` key survived in five + // manifests, read by nothing, making them appear to restrict a port and restrict nothing. + // + // "An unenforced rule is indistinguishable from a wrong one, and costs more, because people + // believe it" — and a silently-accepted key is worse than unenforced, because a reviewer + // checking whether something is restricted will find that it is, and be wrong. + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&m); err != nil { return Manifest{}, fmt.Errorf("this is not a module manifest: %w", err) } @@ -429,6 +501,33 @@ func ParseManifest(raw []byte) (Manifest, error) { "%s binds %q and does not require it", m.Module, to)) } } + if f := m.Filtering; f != nil && strings.TrimSpace(f.Into) == "" { + problems = append(problems, fmt.Sprintf( + "%s asks for the computed rule set and does not say where to put it", m.Module)) + } + for _, l := range m.Listens { + if l.Port < 1 || l.Port > 65535 { + problems = append(problems, fmt.Sprintf( + "%s listens on port %d, which is not a port", m.Module, l.Port)) + } + switch l.From { + case FromMesh, FromEverywhere, FromMachine: + case "": + // The fault this field exists to prevent. A rule with no source is open, and a + // manifest that omitted it would read as a restriction and be none. + problems = append(problems, fmt.Sprintf( + "%s listens on %d and does not say from where; it is %q, %q or %q", + m.Module, l.Port, FromMesh, FromEverywhere, FromMachine)) + default: + problems = append(problems, fmt.Sprintf( + "%s listens on %d from %q; it is %q, %q or %q", + m.Module, l.Port, l.From, FromMesh, FromEverywhere, FromMachine)) + } + if p := l.At(); p != "tcp" && p != "udp" { + problems = append(problems, fmt.Sprintf( + "%s listens on %d over %q, which is tcp or udp", m.Module, l.Port, p)) + } + } if c := m.Certificate; c != nil { if !strings.HasPrefix(c.Into, "/") { problems = append(problems, fmt.Sprintf(