A machine's filtering is computed from what it was assigned

A rule nobody derives is a rule somebody keeps in step by hand, and five HAL
manifests carry a `scope:` key that reads as a restriction and restricts
nothing. Both halves are closed here.

Manifests are parsed strictly. An unknown key is refused, which is the
discipline the host's declaration parser has always had; `scope:` survived
because nothing rejected it.

A module says what it listens on and who may reach it, and saying from where is
required — a rule with no source is open, and must say so rather than appear to
restrict something. The mesh gathers every assigned module's ports, widens
where two overlap, names every module that wanted each one, and renders one
nftables file per node. What no module declared is closed.

Three things it deliberately does not do: it writes no forward policy, because
what a machine routes is the container runtime's business and dropping there
stops every container on the node; it never flushes the whole ruleset, only
its own table; and it carries no command to load itself, because the link may
not carry an action. A service declares `restart-on` the file instead, which is
the shape that rule leaves.

Also fixes a fault the lab found: certificateFor asked where every node is
without the catalogue, so nothing resolved, every machine looked like it was on
no private network, and every certificate the mesh was asked for was refused
with a reason that was not true. Asking that question without the catalogue is
now refused rather than answered wrongly.
This commit is contained in:
2026-08-31 00:25:12 +02:00
parent 646609c1b2
commit 48735171eb
5 changed files with 577 additions and 3 deletions
+100 -1
View File
@@ -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(