Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e09a14ba4c | ||
|
|
1a44d281c2 | ||
|
|
1c8fe65601 | ||
|
|
bea1a1c513 | ||
|
|
21d38c9b0e | ||
|
|
689dd060b0 | ||
|
|
99c4c4ef04 | ||
|
|
e5e1666f91 | ||
|
|
bd58d1c3ef | ||
|
|
d134c89a7c | ||
|
|
a9307d9f33 | ||
|
|
5eb1c9c2b7 | ||
|
|
37b8edeec6 | ||
|
|
424406b2d6 | ||
|
|
90455c61f6 | ||
|
|
1f6793cf0c | ||
|
|
6ef522fbda | ||
|
|
46f65c12a0 | ||
|
|
8f7c02d77a | ||
|
|
a637df01ea | ||
|
|
252eb786a7 | ||
|
|
9d13b0593b | ||
|
|
30d548a762 | ||
|
|
550e4c6acb | ||
|
|
1587fd97f9 | ||
|
|
b5df244096 | ||
|
|
db47bb68e9 | ||
|
|
db84142d38 | ||
|
|
c9204591bf | ||
|
|
e8e707e013 | ||
|
|
0c003774ba | ||
|
|
fbc3d320ea | ||
|
|
cf495e315f | ||
|
|
24f024dd74 | ||
|
|
9acb5f1292 |
@@ -59,7 +59,7 @@ PROVISIONER_IMAGE ?= mesh-provision-postgres:$(VERSION)
|
|||||||
PROVISIONER_DEV_TAG ?= mesh-provision-postgres:development
|
PROVISIONER_DEV_TAG ?= mesh-provision-postgres:development
|
||||||
|
|
||||||
provisioner-image:
|
provisioner-image:
|
||||||
docker build -f examples/postgres-provisioner/Dockerfile \
|
docker build --build-arg GO_BASE=$(GO_BASE) -f examples/postgres-provisioner/Dockerfile \
|
||||||
-t $(PROVISIONER_IMAGE) -t $(PROVISIONER_DEV_TAG) .
|
-t $(PROVISIONER_IMAGE) -t $(PROVISIONER_DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
@@ -70,7 +70,7 @@ OBJECTSTORE_IMAGE ?= mesh-provision-objectstore:$(VERSION)
|
|||||||
OBJECTSTORE_DEV_TAG ?= mesh-provision-objectstore:development
|
OBJECTSTORE_DEV_TAG ?= mesh-provision-objectstore:development
|
||||||
|
|
||||||
objectstore-image:
|
objectstore-image:
|
||||||
docker build -f examples/objectstore-provisioner/Dockerfile \
|
docker build --build-arg GO_BASE=$(GO_BASE) -f examples/objectstore-provisioner/Dockerfile \
|
||||||
-t $(OBJECTSTORE_IMAGE) -t $(OBJECTSTORE_DEV_TAG) .
|
-t $(OBJECTSTORE_IMAGE) -t $(OBJECTSTORE_DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(OBJECTSTORE_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(OBJECTSTORE_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
@@ -81,7 +81,7 @@ REDIS_PROVISIONER_IMAGE ?= mesh-provision-redis:$(VERSION)
|
|||||||
REDIS_PROVISIONER_DEV_TAG ?= mesh-provision-redis:development
|
REDIS_PROVISIONER_DEV_TAG ?= mesh-provision-redis:development
|
||||||
|
|
||||||
redis-provisioner-image:
|
redis-provisioner-image:
|
||||||
docker build -f examples/redis-provisioner/Dockerfile \
|
docker build --build-arg GO_BASE=$(GO_BASE) -f examples/redis-provisioner/Dockerfile \
|
||||||
-t $(REDIS_PROVISIONER_IMAGE) -t $(REDIS_PROVISIONER_DEV_TAG) .
|
-t $(REDIS_PROVISIONER_IMAGE) -t $(REDIS_PROVISIONER_DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(REDIS_PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(REDIS_PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
@@ -91,7 +91,7 @@ PROXY_IMAGE ?= mesh-route-proxy:$(VERSION)
|
|||||||
PROXY_DEV_TAG ?= mesh-route-proxy:development
|
PROXY_DEV_TAG ?= mesh-route-proxy:development
|
||||||
|
|
||||||
proxy-image:
|
proxy-image:
|
||||||
docker build -f examples/route-proxy/Dockerfile -t $(PROXY_IMAGE) -t $(PROXY_DEV_TAG) .
|
docker build --build-arg GO_BASE=$(GO_BASE) -f examples/route-proxy/Dockerfile -t $(PROXY_IMAGE) -t $(PROXY_DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(PROXY_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(PROXY_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
|
|
||||||
|
|||||||
@@ -95,11 +95,22 @@ func reportsReaching(t *testing.T, open *stores, reachable []link.Reach, held ..
|
|||||||
// filter is not sent to one that has not. The anchor reports one, as a real host does; this
|
// filter is not sent to one that has not. The anchor reports one, as a real host does; this
|
||||||
// fixture lacked it from 2026-09-28 and nothing ran the test (issue 177).
|
// fixture lacked it from 2026-09-28 and nothing ran the test (issue 177).
|
||||||
Outward: []string{"eth0"},
|
Outward: []string{"eth0"},
|
||||||
|
// And what filters it (ADR 0168): its front end, the runtime's own, and a chain a
|
||||||
|
// predecessor left in the runtime's user chain.
|
||||||
|
Filters: anchorFilters,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// anchorFilters is what the adopted anchor says filters it: ufw's chains, the runtime's, and a
|
||||||
|
// predecessor's chain the mesh did not write.
|
||||||
|
var anchorFilters = []link.Filter{
|
||||||
|
{Where: "table ip filter, chain ufw-reject-input", Owner: "found-firewall", Refuses: "reject"},
|
||||||
|
{Where: "table ip filter, chain DOCKER", Owner: "runtime", Refuses: `iifname != "docker0" oifname "docker0" drop`},
|
||||||
|
{Where: "table ip filter, chain DOCKER-USER", Owner: "other", Refuses: `iifname "eth0" tcp dport 6000 drop`},
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
heldContainer = link.Held{ID: "hello-web.server", Module: "hello-web", Kind: "container",
|
heldContainer = link.Held{ID: "hello-web.server", Module: "hello-web", Kind: "container",
|
||||||
Target: "hello-web", Since: time.Now()}
|
Target: "hello-web", Since: time.Now()}
|
||||||
@@ -264,6 +275,20 @@ func TestConvergingPreviewsThenChangesAndAdoptingKeepsWhatWasTaken(t *testing.T)
|
|||||||
if strings.Contains(preview, "15672") {
|
if strings.Contains(preview, "15672") {
|
||||||
t.Errorf("a loopback listener is in the preview:\n%s", preview)
|
t.Errorf("a loopback listener is in the preview:\n%s", preview)
|
||||||
}
|
}
|
||||||
|
// What filters the machine now, and the fate of each (novox/hq ADR 0168): the predecessor's
|
||||||
|
// chain is named as not the mesh's and left, so the reader knows before the flip.
|
||||||
|
for _, want := range []string{
|
||||||
|
"table ip filter, chain DOCKER-USER",
|
||||||
|
"NOT THE MESH'S; left in force",
|
||||||
|
`iifname "eth0" tcp dport 6000 drop`,
|
||||||
|
"table ip filter, chain ufw-reject-input",
|
||||||
|
"the found firewall's; retired with it",
|
||||||
|
"the container runtime's own; left",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(preview, want) {
|
||||||
|
t.Errorf("the preview does not say %q:\n%s", want, preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, line := range strings.Split(preview, "\n") {
|
for _, line := range strings.Split(preview, "\n") {
|
||||||
if strings.Contains(line, "5000") && !strings.Contains(line, "WILL CLOSE") {
|
if strings.Contains(line, "5000") && !strings.Contains(line, "WILL CLOSE") {
|
||||||
t.Errorf("an undeclared published port is not said to close: %s", line)
|
t.Errorf("an undeclared published port is not said to close: %s", line)
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node
|
|||||||
if said, err := inv.AdoptionOf(ctx, node.Name); err == nil && len(said.Strays) > 0 {
|
if said, err := inv.AdoptionOf(ctx, node.Name); err == nil && len(said.Strays) > 0 {
|
||||||
showStrays(said.Strays)
|
showStrays(said.Strays)
|
||||||
}
|
}
|
||||||
|
// And what filters it, truthfully (novox/hq ADR 0168): the mesh alone, or not.
|
||||||
|
if filtering, err := inv.FilteringOf(ctx, node.Name); err == nil {
|
||||||
|
showFiltering(filtering, false)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fmt.Printf(" mode adopted since %s\n",
|
fmt.Printf(" mode adopted since %s\n",
|
||||||
@@ -72,10 +76,65 @@ func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
showStrays(said.Strays)
|
showStrays(said.Strays)
|
||||||
|
if filtering, err := inv.FilteringOf(ctx, node.Name); err == nil {
|
||||||
|
showFiltering(filtering, true)
|
||||||
|
}
|
||||||
fmt.Printf(" as of %s\n", said.At.Local().Format(time.DateTime))
|
fmt.Printf(" as of %s\n", said.At.Local().Format(time.DateTime))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// showFiltering says what filters a machine, with owners (novox/hq ADR 0168), and for a converged
|
||||||
|
// machine the state of the firewall it was found with. A machine that has not said is not said to
|
||||||
|
// be filtered by anything.
|
||||||
|
func showFiltering(f inventory.Filtering, adopted bool) {
|
||||||
|
if len(f.Filters) == 0 && f.FoundFirewall == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fw := f.FoundFirewall; fw != nil && !adopted {
|
||||||
|
switch {
|
||||||
|
case fw.Active:
|
||||||
|
fmt.Printf(" found firewall %s is ACTIVE on this converged machine; the next apply retires it again\n", fw.Kind)
|
||||||
|
case fw.RetiredBy == "removed":
|
||||||
|
fmt.Printf(" found firewall %s, removed: the mesh's filter is what filters this machine (novox/hq ADR 0180)\n", fw.Kind)
|
||||||
|
case fw.RetiredBy == inventory.FilterMesh || fw.RetiredBy == "mesh":
|
||||||
|
fmt.Printf(" found firewall %s, retired by the mesh; its configuration stays on disk\n", fw.Kind)
|
||||||
|
case fw.RetiredBy != "":
|
||||||
|
fmt.Printf(" found firewall %s, found inactive — not by the mesh\n", fw.Kind)
|
||||||
|
default:
|
||||||
|
fmt.Printf(" found firewall %s, inactive\n", fw.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(f.Filters) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if f.Alone() {
|
||||||
|
fmt.Printf(" filtered by the mesh alone (%s)\n", filterSummary(f.Filters))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf(" filtered by NOT the mesh alone: %d rule set(s) the mesh did not write refuse traffic here\n", len(f.Others()))
|
||||||
|
for _, x := range f.Filters {
|
||||||
|
if x.Owner == inventory.FilterOther || x.Owner == inventory.FilterFoundFirewall {
|
||||||
|
fmt.Printf(" %-17s %s — %s: %s\n", "", x.Where, x.Owner, x.Refuses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-17s and its own: %s\n", "", filterSummary(f.Filters))
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterSummary counts a machine's filters by owner: "mesh 2, runtime 3, ban 1".
|
||||||
|
func filterSummary(filters []inventory.Filter) string {
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, x := range filters {
|
||||||
|
counts[x.Owner]++
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
for _, owner := range []string{inventory.FilterMesh, inventory.FilterRuntime, inventory.FilterBan, inventory.FilterFoundFirewall, inventory.FilterOther} {
|
||||||
|
if n := counts[owner]; n > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s %d", owner, n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
// showStrays says what a machine runs that the mesh neither wrote nor holds (ADR 0163).
|
// showStrays says what a machine runs that the mesh neither wrote nor holds (ADR 0163).
|
||||||
func showStrays(strays []inventory.Stray) {
|
func showStrays(strays []inventory.Stray) {
|
||||||
if len(strays) == 0 {
|
if len(strays) == 0 {
|
||||||
@@ -661,7 +720,11 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
|
|||||||
}
|
}
|
||||||
derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh,
|
derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh,
|
||||||
outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks}
|
outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks}
|
||||||
preview, saw := previewOf(node, reported, derived, plan, taken, filter, runs[filter])
|
filtering, err := inv.FilteringOf(ctx, node)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
preview, saw := previewOf(node, reported, filtering, derived, plan, taken, filter, runs[filter])
|
||||||
preview += "\n\n preview " + saw
|
preview += "\n\n preview " + saw
|
||||||
if !yes {
|
if !yes {
|
||||||
return preview + fmt.Sprintf("\n\nNothing has changed. Run `converge %s --yes %s` to do "+
|
return preview + fmt.Sprintf("\n\nNothing has changed. Run `converge %s --yes %s` to do "+
|
||||||
@@ -731,7 +794,7 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
|
|||||||
// previewOf is what converging a node will change, before it changes it, and a short digest of
|
// previewOf is what converging a node will change, before it changes it, and a short digest of
|
||||||
// what it said: every reachable thing and its fate, the modules the flip takes and the filter. The
|
// what it said: every reachable thing and its fate, the modules the flip takes and the filter. The
|
||||||
// digest is what the flip is asked to act on, so it changes whenever any of those would.
|
// digest is what the flip is asked to act on, so it changes whenever any of those would.
|
||||||
func previewOf(node string, reported inventory.Adoption, derived derivedFilter,
|
func previewOf(node string, reported inventory.Adoption, filtering inventory.Filtering, derived derivedFilter,
|
||||||
plan catalogue.Resolution, taken []string, filter string, filterAssigned bool) (string, string) {
|
plan catalogue.Resolution, taken []string, filter string, filterAssigned bool) (string, string) {
|
||||||
var said []string
|
var said []string
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -823,6 +886,30 @@ func previewOf(node string, reported inventory.Adoption, derived derivedFilter,
|
|||||||
fmt.Fprintf(&b, " the found firewall (%s) is disabled, never flushed: its configuration stays on disk\n", fw)
|
fmt.Fprintf(&b, " the found firewall (%s) is disabled, never flushed: its configuration stays on disk\n", fw)
|
||||||
}
|
}
|
||||||
said = append(said, fmt.Sprintf("filter %s assigned=%t firewall=%s", filter, filterAssigned, fw))
|
said = append(said, fmt.Sprintf("filter %s assigned=%t firewall=%s", filter, filterAssigned, fw))
|
||||||
|
// What filters the machine now, and the fate of each (novox/hq ADR 0168): the found firewall
|
||||||
|
// retired, the runtime's own and bans left, and what the mesh did not write left and named —
|
||||||
|
// so the reader knows before the flip that the machine will not be filtered by the mesh alone.
|
||||||
|
if len(filtering.Filters) > 0 {
|
||||||
|
b.WriteString("\n what filters the machine now, and what the flip does to each:\n")
|
||||||
|
for _, x := range filtering.Filters {
|
||||||
|
fate := "left: " + x.Owner + "'s"
|
||||||
|
switch x.Owner {
|
||||||
|
case inventory.FilterMesh:
|
||||||
|
fate = "the mesh's guard; replaced by its filter"
|
||||||
|
case inventory.FilterFoundFirewall:
|
||||||
|
fate = "the found firewall's; retired with it"
|
||||||
|
case inventory.FilterRuntime:
|
||||||
|
fate = "the container runtime's own; left"
|
||||||
|
case inventory.FilterBan:
|
||||||
|
fate = "a ban list; left"
|
||||||
|
case inventory.FilterOther:
|
||||||
|
fate = "NOT THE MESH'S; left in force — the machine is not filtered by the mesh alone until you remove it"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " %-50s %s\n", x.Where, fate)
|
||||||
|
fmt.Fprintf(&b, " %-50s %s\n", "", x.Refuses)
|
||||||
|
said = append(said, "filter "+x.Owner+" "+x.Where)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Sorted: the same account, reported in another order, is the same preview.
|
// Sorted: the same account, reported in another order, is the same preview.
|
||||||
sort.Strings(said)
|
sort.Strings(said)
|
||||||
sum := sha256.Sum256([]byte(strings.Join(said, "\n")))
|
sum := sha256.Sum256([]byte(strings.Join(said, "\n")))
|
||||||
|
|||||||
@@ -607,6 +607,10 @@ type answers struct {
|
|||||||
// a consequence of the refusals above: a node that does not resolve is not on the network, and
|
// a consequence of the refusals above: a node that does not resolve is not on the network, and
|
||||||
// a mesh whose hub is that node has no hub.
|
// a mesh whose hub is that node has no hub.
|
||||||
network string
|
network string
|
||||||
|
// filtered is every converged machine that is not filtered by the mesh alone (novox/hq ADR
|
||||||
|
// 0168): what filters it beyond the mesh's own, the runtime's plumbing and bans, by name — a
|
||||||
|
// predecessor's chain, a found firewall in force again. Such a machine is not "all well".
|
||||||
|
filtered map[string]inventory.Filtering
|
||||||
// untaken is, per machine, each assigned module whose resources the machine is holding as it
|
// untaken is, per machine, each assigned module whose resources the machine is holding as it
|
||||||
// found them, and how many — a module that was assigned, sent, and is running none of what it
|
// found them, and how many — a module that was assigned, sent, and is running none of what it
|
||||||
// declares because nothing has taken it (novox/hq ADR 0100, 04-ISSUES/125).
|
// declares because nothing has taken it (novox/hq ADR 0100, 04-ISSUES/125).
|
||||||
|
|||||||
@@ -352,10 +352,14 @@ func settingsCommand(ctx context.Context, args []string) error {
|
|||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "set":
|
case "set":
|
||||||
if len(positionals) != 2 {
|
if len(positionals) != 2 {
|
||||||
return errors.New("settings set <module> <settings.json> [--node <node>]")
|
return errors.New("settings set <module> <settings.json | {…}> [--node <node>]")
|
||||||
}
|
}
|
||||||
raw, err := os.ReadFile(positionals[1])
|
// A file, or the values themselves when they begin with `{` — which is how the mesh's own
|
||||||
if err != nil {
|
// `settings` tool passes them, having no file to hand over (novox/hq issue 198).
|
||||||
|
var raw []byte
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(positionals[1]), "{") {
|
||||||
|
raw = []byte(positionals[1])
|
||||||
|
} else if raw, err = os.ReadFile(positionals[1]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var values map[string]any
|
var values map[string]any
|
||||||
|
|||||||
@@ -397,6 +397,7 @@ func declarationWith(ctx context.Context, open *stores, node string,
|
|||||||
return sendable{}, err
|
return sendable{}, err
|
||||||
}
|
}
|
||||||
return sendable{Resources: composed.Resources, Adoption: adoption,
|
return sendable{Resources: composed.Resources, Adoption: adoption,
|
||||||
|
Received: composed.Received, Mesh: with.Mesh,
|
||||||
LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut}, nil
|
LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+31
-11
@@ -131,10 +131,17 @@ func serve(ctx context.Context) error {
|
|||||||
|
|
||||||
// And the mesh's own verbs, as the seat this control plane holds (novox/hq ADR 0154). Served
|
// And the mesh's own verbs, as the seat this control plane holds (novox/hq ADR 0154). Served
|
||||||
// from the store's row, so what the seat declares is what is answered.
|
// from the store's row, so what the seat declares is what is answered.
|
||||||
handlers, err := seatToolHandlers()
|
handlers, behind, err := seatToolHandlers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if len(behind) > 0 {
|
||||||
|
// Said once, loudly, and then served anyway (novox/hq ADR 0185): the mesh keeps answering
|
||||||
|
// while whatever put an older control plane here is undone.
|
||||||
|
fmt.Printf("this control plane is behind the %s row: it cannot run %s. "+
|
||||||
|
"Those answer the reason when called; everything else is served as usual\n",
|
||||||
|
catalogue.ControllerSeatName, strings.Join(behind, ", "))
|
||||||
|
}
|
||||||
bus, isNATS := server.Bus().(link.OverNATS)
|
bus, isNATS := server.Bus().(link.OverNATS)
|
||||||
if !isNATS {
|
if !isNATS {
|
||||||
return errors.New("the mesh's verbs are served over the bus, and this control plane is not on it")
|
return errors.New("the mesh's verbs are served over the bus, and this control plane is not on it")
|
||||||
@@ -393,11 +400,7 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
fmt.Printf("\n%d node(s) told\n", len(sending))
|
fmt.Printf("\n%d node(s) told\n", len(sending))
|
||||||
// And each machine's memberships, as every other send does (ADR 0160): a push is the one most
|
// And each machine's memberships, as every other send does (ADR 0160): a push is the one most
|
||||||
// operators run, and on 2026-10-01 it was the one path that issued none.
|
// operators run, and on 2026-10-01 it was the one path that issued none.
|
||||||
var told []string
|
if err := issueMemberships(ctx, open, server, sending); err != nil {
|
||||||
for _, s := range sending {
|
|
||||||
told = append(told, s.node)
|
|
||||||
}
|
|
||||||
if err := issueMemberships(ctx, open, server, told); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,11 +718,15 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
|||||||
// And every assignment on those machines its membership (novox/hq ADR 0160): composed from the
|
// And every assignment on those machines its membership (novox/hq ADR 0160): composed from the
|
||||||
// same records the bus's accounts are, so what a runtime serves and what its account may are one
|
// same records the bus's accounts are, so what a runtime serves and what its account may are one
|
||||||
// composition. Issued after the declaration, because the runtime it is for arrives with it.
|
// composition. Issued after the declaration, because the runtime it is for arrives with it.
|
||||||
return issueMemberships(ctx, open, server, names)
|
return issueMemberships(ctx, open, server, sending)
|
||||||
}
|
}
|
||||||
|
|
||||||
// issueMemberships publishes the membership of every module on the named machines.
|
// issueMemberships publishes the membership of every module on the machines just sent.
|
||||||
func issueMemberships(ctx context.Context, open *stores, server *link.Server, names []string) error {
|
//
|
||||||
|
// Each carries what its module receives and the private network's addresses, from the same
|
||||||
|
// composition as the declaration it was sent (novox/hq ADR 0167): a provider reads what it is
|
||||||
|
// given on the bus, and the file written beside it says the same thing.
|
||||||
|
func issueMemberships(ctx context.Context, open *stores, server *link.Server, sent []readyNode) error {
|
||||||
records, err := open.inventory.BusRecords(ctx)
|
records, err := open.inventory.BusRecords(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -734,9 +741,22 @@ func issueMemberships(ctx context.Context, open *stores, server *link.Server, na
|
|||||||
// the push stands, the first failure is named once, and the next push tries again.
|
// the push stands, the first failure is named once, and the next push tries again.
|
||||||
issued, failed := 0, 0
|
issued, failed := 0, 0
|
||||||
var first error
|
var first error
|
||||||
for _, node := range names {
|
for _, s := range sent {
|
||||||
|
node := s.node
|
||||||
for _, d := range records.Assigned[node] {
|
for _, d := range records.Assigned[node] {
|
||||||
body, err := json.Marshal(broker.MembershipFor(node, d, where))
|
membership := broker.MembershipFor(node, d, where)
|
||||||
|
membership.Mesh = s.declared.Mesh
|
||||||
|
for requirement, given := range s.declared.Received[d.Module] {
|
||||||
|
raw, err := json.Marshal(given)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if membership.Receives == nil {
|
||||||
|
membership.Receives = map[string]json.RawMessage{}
|
||||||
|
}
|
||||||
|
membership.Receives[requirement] = raw
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(membership)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -66,6 +67,21 @@ type meshStatus struct {
|
|||||||
// **A document without this said an outage was a well mesh.** Read from what each machine
|
// **A document without this said an outage was a well mesh.** Read from what each machine
|
||||||
// reported, so it is the machine's account and not the mesh's take-time listing.
|
// reported, so it is the machine's account and not the mesh's take-time listing.
|
||||||
Untaken []machineUntaken `json:"untaken,omitempty"`
|
Untaken []machineUntaken `json:"untaken,omitempty"`
|
||||||
|
// Filtered is every converged machine that is not filtered by the mesh alone (novox/hq ADR
|
||||||
|
// 0168), one entry per rule set the mesh did not write — the found firewall in force again,
|
||||||
|
// or a chain nobody speaks for. Absent when every converged machine is filtered by the mesh
|
||||||
|
// alone. A document without this called a machine well while a predecessor's chain refused
|
||||||
|
// what the mesh declared open.
|
||||||
|
Filtered []machineFiltered `json:"filtered,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// machineFiltered is one rule set on a converged machine that the mesh did not write and that
|
||||||
|
// refuses traffic: where it is, whose the host reads it as, and what it refuses.
|
||||||
|
type machineFiltered struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Where string `json:"where"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Refuses string `json:"refuses"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// machineUntaken is one module a machine is holding rather than running, and how many resources of
|
// machineUntaken is one module a machine is holding rather than running, and how many resources of
|
||||||
@@ -173,6 +189,21 @@ func statusAsJSON(asked answers) ([]byte, error) {
|
|||||||
machineUntaken{Node: name, Module: m, Held: asked.untaken[name][m]})
|
machineUntaken{Node: name, Module: m, Held: asked.untaken[name][m]})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
filteredNodes := make([]string, 0, len(asked.filtered))
|
||||||
|
for name := range asked.filtered {
|
||||||
|
filteredNodes = append(filteredNodes, name)
|
||||||
|
}
|
||||||
|
sort.Strings(filteredNodes)
|
||||||
|
for _, name := range filteredNodes {
|
||||||
|
f := asked.filtered[name]
|
||||||
|
if fw := f.FoundFirewall; fw != nil && fw.Active {
|
||||||
|
out.Filtered = append(out.Filtered, machineFiltered{Node: name, Where: "the found firewall",
|
||||||
|
Owner: inventory.FilterFoundFirewall, Refuses: fw.Kind + " is in force again"})
|
||||||
|
}
|
||||||
|
for _, x := range f.Others() {
|
||||||
|
out.Filtered = append(out.Filtered, machineFiltered{Node: name, Where: x.Where, Owner: x.Owner, Refuses: x.Refuses})
|
||||||
|
}
|
||||||
|
}
|
||||||
for name := range asked.refused {
|
for name := range asked.refused {
|
||||||
out.Unresolved = append(out.Unresolved, machineUnresolved{
|
out.Unresolved = append(out.Unresolved, machineUnresolved{
|
||||||
Node: name, Problem: asked.refused[name]})
|
Node: name, Problem: asked.refused[name]})
|
||||||
|
|||||||
@@ -167,3 +167,46 @@ func TestAMachineFailingTheSameWayIsSaidToBeStuck(t *testing.T) {
|
|||||||
t.Fatalf("one failure is not stuck: %v", once)
|
t.Fatalf("one failure is not stuck: %v", once)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A converged machine something other than the mesh filters is named, per rule set, and is not
|
||||||
|
// well (novox/hq ADR 0168); one filtered by the mesh alone is not in the list.
|
||||||
|
func TestAMachineNotFilteredByTheMeshAloneIsNamedAndNotWell(t *testing.T) {
|
||||||
|
alone := inventory.Filtering{Filters: []inventory.Filter{
|
||||||
|
{Where: "table inet mesh, chain input", Owner: inventory.FilterMesh, Refuses: "policy drop"},
|
||||||
|
{Where: "table ip filter, chain DOCKER", Owner: inventory.FilterRuntime, Refuses: "drop"},
|
||||||
|
{Where: "table ip filter, chain f2b-sshd", Owner: inventory.FilterBan, Refuses: "ip saddr 192.0.2.1 reject"},
|
||||||
|
}}
|
||||||
|
if !alone.Alone() {
|
||||||
|
t.Fatal("the mesh's own, the runtime's and a ban are not the mesh alone")
|
||||||
|
}
|
||||||
|
notAlone := inventory.Filtering{
|
||||||
|
Filters: append(alone.Filters, inventory.Filter{Where: "chain HAL-MESH-ONLY (iptables-legacy)",
|
||||||
|
Owner: inventory.FilterOther, Refuses: `-A HAL-MESH-ONLY -m comment --comment "not public" -j DROP`}),
|
||||||
|
FoundFirewall: &inventory.FoundFirewall{Kind: "ufw", Active: true},
|
||||||
|
}
|
||||||
|
asked := answers{nodes: []inventory.Node{{Name: "home-server"}, {Name: "laptop"}},
|
||||||
|
filtered: map[string]inventory.Filtering{"home-server": notAlone}}
|
||||||
|
if asked.well() {
|
||||||
|
t.Fatal("a machine not filtered by the mesh alone reads as well")
|
||||||
|
}
|
||||||
|
body, err := statusAsJSON(asked)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Filtered []map[string]string `json:"filtered"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(parsed.Filtered) != 2 {
|
||||||
|
t.Fatalf("filtered: %v", parsed.Filtered)
|
||||||
|
}
|
||||||
|
if parsed.Filtered[0]["node"] != "home-server" || parsed.Filtered[0]["owner"] != inventory.FilterFoundFirewall ||
|
||||||
|
parsed.Filtered[1]["where"] != "chain HAL-MESH-ONLY (iptables-legacy)" || parsed.Filtered[1]["owner"] != inventory.FilterOther {
|
||||||
|
t.Fatalf("filtered: %v", parsed.Filtered)
|
||||||
|
}
|
||||||
|
if body, _ := statusAsJSON(answers{nodes: asked.nodes}); strings.Contains(string(body), `"filtered"`) {
|
||||||
|
t.Fatal("a mesh filtered by itself alone carries a filtered list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,21 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
switch verb {
|
switch verb {
|
||||||
|
case "command":
|
||||||
|
// The generic verb: the command line as given, split as a shell would split it, with
|
||||||
|
// nothing added — the named verbs add flags a caller cannot reach; this one is the whole
|
||||||
|
// binary and says so in its description (novox/hq ADR 0154, 0175).
|
||||||
|
if err := need("command"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
argv, err := splitCommandLine(str("command"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(argv) == 0 {
|
||||||
|
return nil, errors.New("command names no command")
|
||||||
|
}
|
||||||
|
return argv, nil
|
||||||
case "status":
|
case "status":
|
||||||
return []string{"status", "--json"}, nil
|
return []string{"status", "--json"}, nil
|
||||||
case "nodes":
|
case "nodes":
|
||||||
@@ -129,6 +144,25 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
|||||||
// Half of either shape: the command says its usage, which names both shapes, and that is
|
// Half of either shape: the command says its usage, which names both shapes, and that is
|
||||||
// the answer the caller needs.
|
// the answer the caller needs.
|
||||||
return []string{"rotate"}, nil
|
return []string{"rotate"}, nil
|
||||||
|
case "settings":
|
||||||
|
// `settings set|clear` at a shell (novox/hq issue 198). The values travel as an argument
|
||||||
|
// because a tool has no file to hand the command; the command reads either.
|
||||||
|
if err := need("module"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
argv := []string{"settings", "set", str("module")}
|
||||||
|
switch {
|
||||||
|
case str("clear") == "true":
|
||||||
|
argv = []string{"settings", "clear", str("module")}
|
||||||
|
case str("values") != "":
|
||||||
|
argv = append(argv, str("values"))
|
||||||
|
}
|
||||||
|
// Neither values nor clear: the command says its usage, which names both, and that is the
|
||||||
|
// answer the caller needs — the same as `rotate` given half of either shape.
|
||||||
|
if n := str("node"); n != "" {
|
||||||
|
argv = append(argv, "--node", n)
|
||||||
|
}
|
||||||
|
return argv, nil
|
||||||
case "issue":
|
case "issue":
|
||||||
// The same act as `module issue` at a shell (novox/hq design 25 §4): the account is minted
|
// The same act as `module issue` at a shell (novox/hq design 25 §4): the account is minted
|
||||||
// into the mesh's records and delivered at the machine's next push, which is the caller's to
|
// into the mesh's records and delivered at the machine's next push, which is the caller's to
|
||||||
@@ -196,13 +230,15 @@ func runVerb(ctx context.Context, argv []string) (verbAnswer, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seatToolHandlers are the handlers for every verb the mesh-controller seat declares, from the
|
// seatToolHandlers are the handlers for every verb the mesh-controller seat declares, from the
|
||||||
// store's row, so a verb the row does not carry is not served and a verb it carries that this binary
|
// store's row, so a verb the row does not carry is not served. A verb it carries that this binary
|
||||||
// cannot run is said at start rather than at the first call.
|
// cannot run is named at start and answers the reason when called — never a refusal to serve, which
|
||||||
func seatToolHandlers() (map[string]link.ToolHandler, error) {
|
// would take the whole control plane down for one word (novox/hq ADR 0185).
|
||||||
|
func seatToolHandlers() (map[string]link.ToolHandler, []string, error) {
|
||||||
seat, known := catalogue.SeatNamed(catalogue.ControllerSeatName)
|
seat, known := catalogue.SeatNamed(catalogue.ControllerSeatName)
|
||||||
if !known {
|
if !known {
|
||||||
return nil, fmt.Errorf("this mesh defines no %s seat", catalogue.ControllerSeatName)
|
return nil, nil, fmt.Errorf("this mesh defines no %s seat", catalogue.ControllerSeatName)
|
||||||
}
|
}
|
||||||
|
var behind []string
|
||||||
handlers := map[string]link.ToolHandler{}
|
handlers := map[string]link.ToolHandler{}
|
||||||
for _, v := range seat.Serves {
|
for _, v := range seat.Serves {
|
||||||
verb := v.Name
|
verb := v.Name
|
||||||
@@ -213,8 +249,27 @@ func seatToolHandlers() (map[string]link.ToolHandler, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err := argvFor(verb, sampleArguments(v)); err != nil {
|
if _, err := argvFor(verb, sampleArguments(v)); err != nil {
|
||||||
return nil, fmt.Errorf("the %s seat's row declares %q, which this control plane cannot run: %w",
|
// **A row ahead of this binary is not a reason to go silent.**
|
||||||
catalogue.ControllerSeatName, verb, err)
|
//
|
||||||
|
// The row is the store's and a control plane follows it (novox/hq ADR 0154), so a verb
|
||||||
|
// this build does not know means the row was widened by a newer one — the ordinary
|
||||||
|
// state of a roll-out, and of a push that put an older control plane back. Refusing to
|
||||||
|
// serve at all made that transient fatal: on 2026-10-02 one unknown verb took the whole
|
||||||
|
// mesh off the bus for ten minutes, and the way back was a human running the binary by
|
||||||
|
// hand, because the thing that would have repaired it is the thing that was down
|
||||||
|
// (novox/hq 04-ISSUES/201, ADR 0185).
|
||||||
|
//
|
||||||
|
// So the verbs this binary knows are served, and this one answers the reason instead of
|
||||||
|
// nothing: a caller gets a sentence naming the fault, and everything else keeps working
|
||||||
|
// — including the push that replaces this binary with the one whose verb it is.
|
||||||
|
behind = append(behind, verb)
|
||||||
|
reason := err
|
||||||
|
handlers[verb] = func(context.Context, json.RawMessage) (any, error) {
|
||||||
|
return nil, fmt.Errorf("%s is in this mesh's %s row and the control plane running "+
|
||||||
|
"here cannot run it: %w. It is a verb of a newer build; this one is behind",
|
||||||
|
verb, catalogue.ControllerSeatName, reason)
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
handlers[verb] = func(ctx context.Context, raw json.RawMessage) (any, error) {
|
handlers[verb] = func(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||||
args := map[string]any{}
|
args := map[string]any{}
|
||||||
@@ -230,7 +285,7 @@ func seatToolHandlers() (map[string]link.ToolHandler, error) {
|
|||||||
return runVerb(ctx, argv)
|
return runVerb(ctx, argv)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return handlers, nil
|
return handlers, behind, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// seatTools is what `tools` answers: every seat with a protocol, and the tools each serves, from the
|
// seatTools is what `tools` answers: every seat with a protocol, and the tools each serves, from the
|
||||||
@@ -270,3 +325,57 @@ func sampleArguments(v catalogue.Verb) map[string]any {
|
|||||||
}
|
}
|
||||||
return sample
|
return sample
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitCommandLine splits a command line into words the way a POSIX shell does for the simple
|
||||||
|
// cases a controller command needs: spaces separate, single or double quotes group, a backslash
|
||||||
|
// escapes the next character inside double quotes or outside any. No expansion of anything.
|
||||||
|
func splitCommandLine(line string) ([]string, error) {
|
||||||
|
var words []string
|
||||||
|
var cur strings.Builder
|
||||||
|
inWord := false
|
||||||
|
quote := rune(0)
|
||||||
|
runes := []rune(line)
|
||||||
|
for i := 0; i < len(runes); i++ {
|
||||||
|
r := runes[i]
|
||||||
|
switch {
|
||||||
|
case quote == '\'':
|
||||||
|
if r == '\'' {
|
||||||
|
quote = 0
|
||||||
|
} else {
|
||||||
|
cur.WriteRune(r)
|
||||||
|
}
|
||||||
|
case quote == '"':
|
||||||
|
if r == '"' {
|
||||||
|
quote = 0
|
||||||
|
} else if r == '\\' && i+1 < len(runes) {
|
||||||
|
i++
|
||||||
|
cur.WriteRune(runes[i])
|
||||||
|
} else {
|
||||||
|
cur.WriteRune(r)
|
||||||
|
}
|
||||||
|
case r == '\'' || r == '"':
|
||||||
|
quote = r
|
||||||
|
inWord = true
|
||||||
|
case r == '\\' && i+1 < len(runes):
|
||||||
|
i++
|
||||||
|
cur.WriteRune(runes[i])
|
||||||
|
inWord = true
|
||||||
|
case r == ' ' || r == '\t' || r == '\n':
|
||||||
|
if inWord {
|
||||||
|
words = append(words, cur.String())
|
||||||
|
cur.Reset()
|
||||||
|
inWord = false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
cur.WriteRune(r)
|
||||||
|
inWord = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if quote != 0 {
|
||||||
|
return nil, fmt.Errorf("command has an unclosed %c quote", quote)
|
||||||
|
}
|
||||||
|
if inWord {
|
||||||
|
words = append(words, cur.String())
|
||||||
|
}
|
||||||
|
return words, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -73,6 +74,22 @@ func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `settings` is `settings set|clear` at a shell, with the values passed inline (novox/hq issue 198).
|
||||||
|
func TestSettingsSetsOrClearsALayer(t *testing.T) {
|
||||||
|
argv, err := argvFor("settings", map[string]any{"module": "dnsmasq", "values": `{"a":1}`, "node": "ace"})
|
||||||
|
if err != nil || strings.Join(argv, " ") != `settings set dnsmasq {"a":1} --node ace` {
|
||||||
|
t.Fatalf("set on a machine: %v %v", argv, err)
|
||||||
|
}
|
||||||
|
argv, _ = argvFor("settings", map[string]any{"module": "dnsmasq", "clear": "true"})
|
||||||
|
if strings.Join(argv, " ") != "settings clear dnsmasq" {
|
||||||
|
t.Fatalf("clear for the mesh: %v", argv)
|
||||||
|
}
|
||||||
|
argv, _ = argvFor("settings", map[string]any{"module": "dnsmasq"})
|
||||||
|
if strings.Join(argv, " ") != "settings set dnsmasq" {
|
||||||
|
t.Fatalf("a set with no values falls to the command's usage: %v", argv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// `issue` is `module issue` at a shell: the module and the machine, and nothing that would push. A
|
// `issue` is `module issue` at a shell: the module and the machine, and nothing that would push. A
|
||||||
// module's bus account was mintable only from the controller's command line, so an agent working
|
// module's bus account was mintable only from the controller's command line, so an agent working
|
||||||
// through the tools could not finish a rollout that gave a module one (novox/hq issue 191).
|
// through the tools could not finish a rollout that gave a module one (novox/hq issue 191).
|
||||||
@@ -114,10 +131,13 @@ func TestActsDoNotBlockTheCall(t *testing.T) {
|
|||||||
|
|
||||||
// What `tools` answers is the seats' records, with each verb's schema.
|
// What `tools` answers is the seats' records, with each verb's schema.
|
||||||
func TestToolsAnswersTheSeatsRecords(t *testing.T) {
|
func TestToolsAnswersTheSeatsRecords(t *testing.T) {
|
||||||
handlers, err := seatToolHandlers()
|
handlers, behind, err := seatToolHandlers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if len(behind) != 0 {
|
||||||
|
t.Fatalf("this build cannot run %v of its own seat's verbs", behind)
|
||||||
|
}
|
||||||
if len(handlers) != len(catalogue.ControllerVerbs) {
|
if len(handlers) != len(catalogue.ControllerVerbs) {
|
||||||
t.Fatalf("%d handlers for %d verbs", len(handlers), len(catalogue.ControllerVerbs))
|
t.Fatalf("%d handlers for %d verbs", len(handlers), len(catalogue.ControllerVerbs))
|
||||||
}
|
}
|
||||||
@@ -155,3 +175,77 @@ func TestAJSONVerbsAnswerIsItsStandardOutput(t *testing.T) {
|
|||||||
t.Fatalf("stderr and stdout are both what the command said: %s", answer.Output)
|
t.Fatalf("stderr and stdout are both what the command said: %s", answer.Output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `command` is the generic verb: the command line as given, split as a shell would, nothing added —
|
||||||
|
// so an operator's `node account g14 jochen` is one call through the console rather than a shell on
|
||||||
|
// the control node (novox/hq ADR 0154, ADR 0175).
|
||||||
|
func TestCommandRunsTheLineAsGiven(t *testing.T) {
|
||||||
|
argv, err := argvFor("command", map[string]any{"command": "node account g14 jochen"})
|
||||||
|
if err != nil || strings.Join(argv, " ") != "node account g14 jochen" {
|
||||||
|
t.Fatalf("a plain line: %v %v", argv, err)
|
||||||
|
}
|
||||||
|
argv, err = argvFor("command", map[string]any{"command": `settings set dnsmasq '{"a": "b c"}' --node ace`})
|
||||||
|
if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` {
|
||||||
|
t.Fatalf("a quoted word stays one word: %q %v", argv, err)
|
||||||
|
}
|
||||||
|
argv, err = argvFor("command", map[string]any{"command": `node add "the box" --adopted`})
|
||||||
|
if err != nil || len(argv) != 4 || argv[2] != "the box" {
|
||||||
|
t.Fatalf("double quotes group: %q %v", argv, err)
|
||||||
|
}
|
||||||
|
if _, err := argvFor("command", map[string]any{"command": " "}); err == nil {
|
||||||
|
t.Fatal("an empty line was accepted")
|
||||||
|
}
|
||||||
|
if _, err := argvFor("command", map[string]any{"command": `node "unclosed`}); err == nil {
|
||||||
|
t.Fatal("an unclosed quote was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A verb in the row that this binary cannot run does not take the control plane off the bus: the
|
||||||
|
// rest are served, the unknown one answers the reason, and the start-up names it (novox/hq ADR
|
||||||
|
// 0185). One unknown word cost the mesh ten minutes of silence on 2026-10-02, recoverable only by
|
||||||
|
// a person running the binary by hand — the push that would have repaired it needs the control
|
||||||
|
// plane that was down.
|
||||||
|
func TestARowAheadOfThisBuildIsServedAnyway(t *testing.T) {
|
||||||
|
seat, known := catalogue.SeatNamed(catalogue.ControllerSeatName)
|
||||||
|
if !known {
|
||||||
|
t.Fatal("no controller seat")
|
||||||
|
}
|
||||||
|
// The row as a newer control plane would have written it: every verb this build knows, and one
|
||||||
|
// it does not.
|
||||||
|
widened := seat
|
||||||
|
widened.Serves = append(append([]catalogue.Verb{}, seat.Serves...),
|
||||||
|
catalogue.Verb{Name: "teleport", Description: "a verb from a build that does not exist yet"})
|
||||||
|
rows := catalogue.DefaultSeats()
|
||||||
|
for i := range rows {
|
||||||
|
if rows[i].Name == catalogue.ControllerSeatName {
|
||||||
|
rows[i] = widened
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catalogue.UseSeats(rows)
|
||||||
|
t.Cleanup(func() { catalogue.UseSeats(catalogue.DefaultSeats()) })
|
||||||
|
|
||||||
|
handlers, behind, err := seatToolHandlers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a row with one unknown verb refused to serve at all: %v", err)
|
||||||
|
}
|
||||||
|
if len(behind) != 1 || behind[0] != "teleport" {
|
||||||
|
t.Fatalf("the verbs this build cannot run were reported as %v", behind)
|
||||||
|
}
|
||||||
|
if len(handlers) != len(widened.Serves) {
|
||||||
|
t.Fatalf("%d handlers for %d verbs in the row", len(handlers), len(widened.Serves))
|
||||||
|
}
|
||||||
|
for _, known := range []string{"status", "nodes", "push"} {
|
||||||
|
if handlers[known] == nil {
|
||||||
|
t.Errorf("%s is not served although this build knows it", known)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = handlers["teleport"](context.Background(), nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("the unknown verb answered as though it had run")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"teleport", "cannot run it", "behind"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("the answer does not say %q: %v", want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ type sendable struct {
|
|||||||
// Adoption is nil for a converged node, and then the body is byte for byte what it was before
|
// Adoption is nil for a converged node, and then the body is byte for byte what it was before
|
||||||
// adoption existed: an older host parses the envelope strictly and would refuse the key.
|
// adoption existed: an older host parses the envelope strictly and would refuse the key.
|
||||||
Adoption *adoptionEnvelope
|
Adoption *adoptionEnvelope
|
||||||
|
|
||||||
|
// Received and Mesh are not sent in the declaration. They are what this machine's memberships
|
||||||
|
// are issued with on the bus (novox/hq ADR 0167): each module's received contributions, from
|
||||||
|
// the same composition as its received files, and every machine's private-network address.
|
||||||
|
Received map[string]map[string][]catalogue.Contribution
|
||||||
|
Mesh []string
|
||||||
// LeftOut is every module of the machine's set left out of this declaration because a stored
|
// LeftOut is every module of the machine's set left out of this declaration because a stored
|
||||||
// setting cannot compose with its definition (novox/hq ADR 0163, rule 6), sorted. The host
|
// setting cannot compose with its definition (novox/hq ADR 0163, rule 6), sorted. The host
|
||||||
// keeps that module's held things and touches none of its containers; a machine is told
|
// keeps that module's held things and touches none of its containers; a machine is told
|
||||||
|
|||||||
@@ -227,6 +227,28 @@ func printStatus(asked answers) error {
|
|||||||
" not readable from a commit; that needs a version the host reports as ordered\n\n")
|
" not readable from a commit; that needs a version the host reports as ordered\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(asked.filtered) > 0 {
|
||||||
|
// A converged machine is filtered by the mesh alone, and the mesh says truthfully which
|
||||||
|
// (novox/hq ADR 0168). One that is not — a predecessor's chain still refusing, a found
|
||||||
|
// firewall in force again — is said here, and is not well.
|
||||||
|
machines := make([]string, 0, len(asked.filtered))
|
||||||
|
for name := range asked.filtered {
|
||||||
|
machines = append(machines, name)
|
||||||
|
}
|
||||||
|
sort.Strings(machines)
|
||||||
|
fmt.Printf("%d converged machine(s) are not filtered by the mesh alone:\n", len(machines))
|
||||||
|
for _, name := range machines {
|
||||||
|
f := asked.filtered[name]
|
||||||
|
if fw := f.FoundFirewall; fw != nil && fw.Active {
|
||||||
|
fmt.Printf(" %-12s the found firewall (%s) is in force again; the next apply retires it\n", name, fw.Kind)
|
||||||
|
}
|
||||||
|
for _, x := range f.Others() {
|
||||||
|
fmt.Printf(" %-12s %s (%s): %s\n", name, x.Where, x.Owner, x.Refuses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("\n the mesh wrote none of these and removes none; `node show <node>` lists every filter with its owner\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
if len(asked.untaken) > 0 {
|
if len(asked.untaken) > 0 {
|
||||||
// **Before the adopted line, and it breaks "all well".** An adopted machine is a state
|
// **Before the adopted line, and it breaks "all well".** An adopted machine is a state
|
||||||
// somebody chose and can leave alone; a module assigned to one and never taken is work
|
// somebody chose and can leave alone; a module assigned to one and never taken is work
|
||||||
@@ -357,6 +379,13 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return answers{}, err
|
return answers{}, err
|
||||||
}
|
}
|
||||||
|
// And which converged machines something other than the mesh filters (novox/hq ADR 0168), as
|
||||||
|
// each last reported — the account that was missing when a predecessor's chain refused what the
|
||||||
|
// mesh declared open for eleven hours (04-ISSUES/144, 145).
|
||||||
|
out.filtered, err = filteredMachines(ctx, inv, out.nodes)
|
||||||
|
if err != nil {
|
||||||
|
return answers{}, err
|
||||||
|
}
|
||||||
out.plans, err = inv.RecentPlans(ctx, 5)
|
out.plans, err = inv.RecentPlans(ctx, 5)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return answers{}, err
|
return answers{}, err
|
||||||
@@ -406,6 +435,30 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
|||||||
//
|
//
|
||||||
// A machine that reports no holds contributes nothing, so a converged mesh answers an empty map and
|
// A machine that reports no holds contributes nothing, so a converged mesh answers an empty map and
|
||||||
// the caller prints nothing.
|
// the caller prints nothing.
|
||||||
|
// filteredMachines is every converged machine not filtered by the mesh alone, with what it last said
|
||||||
|
// filters it (novox/hq ADR 0168). An adopted machine keeps its found firewall by design and is not
|
||||||
|
// counted; a machine that has not said is not said to be filtered by anything.
|
||||||
|
func filteredMachines(ctx context.Context, inv *inventory.Inventory, nodes []inventory.Node) (
|
||||||
|
map[string]inventory.Filtering, error) {
|
||||||
|
out := map[string]inventory.Filtering{}
|
||||||
|
for _, n := range nodes {
|
||||||
|
if n.Adopted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f, err := inv.FilteringOf(ctx, n.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("what filters %s cannot be read: %w", n.Name, err)
|
||||||
|
}
|
||||||
|
if len(f.Filters) == 0 && f.FoundFirewall == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !f.Alone() {
|
||||||
|
out[n.Name] = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inventory.Node) (
|
func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inventory.Node) (
|
||||||
map[string]map[string]int, error) {
|
map[string]map[string]int, error) {
|
||||||
|
|
||||||
@@ -442,7 +495,8 @@ func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inven
|
|||||||
// read as success for the whole of the edge cut-over outage (novox/hq 04-ISSUES/125).
|
// read as success for the whole of the edge cut-over outage (novox/hq 04-ISSUES/125).
|
||||||
func (a answers) well() bool {
|
func (a answers) well() bool {
|
||||||
return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 &&
|
return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 &&
|
||||||
len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0
|
len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0 &&
|
||||||
|
len(a.filtered) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostSplit is which machines report which host version, for every version more than one machine
|
// hostSplit is which machines report which host version, for every version more than one machine
|
||||||
|
|||||||
@@ -10,7 +10,10 @@
|
|||||||
# The client is copied from the vendor's own image rather than installed from a distribution:
|
# The client is copied from the vendor's own image rather than installed from a distribution:
|
||||||
# `apk add mc` on Alpine installs Midnight Commander, which is a different program with the same
|
# `apk add mc` on Alpine installs Midnight Commander, which is a different program with the same
|
||||||
# name, and the failure would be a provisioner that starts cleanly and cannot do anything.
|
# name, and the failure would be a provisioner that starts cleanly and cannot do anything.
|
||||||
FROM golang:1.25-alpine AS build
|
# The Go it builds with is the one the manifest pins (build.on GO_BASE), passed by the Makefile and the
|
||||||
|
# build machine alike; the default only serves a hand build, and matches go.mod.
|
||||||
|
ARG GO_BASE=golang:1.26-alpine
|
||||||
|
FROM ${GO_BASE} AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
# Built here so a machine can be given it by the mesh rather than by somebody putting a binary on
|
# Built here so a machine can be given it by the mesh rather than by somebody putting a binary on
|
||||||
# it. Static and FROM scratch for the same reason the control plane's image is: it is fetched by
|
# it. Static and FROM scratch for the same reason the control plane's image is: it is fetched by
|
||||||
# digest and run on a machine, and everything in it is something a person would have to audit.
|
# digest and run on a machine, and everything in it is something a person would have to audit.
|
||||||
FROM golang:1.25-alpine AS build
|
# The Go it builds with is the one the manifest pins (build.on GO_BASE), passed by the Makefile and the
|
||||||
|
# build machine alike; the default only serves a hand build, and matches go.mod.
|
||||||
|
ARG GO_BASE=golang:1.26-alpine
|
||||||
|
FROM ${GO_BASE} AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
#
|
#
|
||||||
# FROM scratch, like the postgres one and unlike the bucket one: it speaks the store's own wire
|
# FROM scratch, like the postgres one and unlike the bucket one: it speaks the store's own wire
|
||||||
# protocol directly and needs no client in the image.
|
# protocol directly and needs no client in the image.
|
||||||
FROM golang:1.25-alpine AS build
|
# The Go it builds with is the one the manifest pins (build.on GO_BASE), passed by the Makefile and the
|
||||||
|
# build machine alike; the default only serves a hand build, and matches go.mod.
|
||||||
|
ARG GO_BASE=golang:1.26-alpine
|
||||||
|
FROM ${GO_BASE} AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
#
|
#
|
||||||
# Static and FROM scratch like the control plane's image, and for the same reason: it is fetched
|
# Static and FROM scratch like the control plane's image, and for the same reason: it is fetched
|
||||||
# by digest and run on a machine, so everything in it is something a person would have to audit.
|
# by digest and run on a machine, so everything in it is something a person would have to audit.
|
||||||
FROM golang:1.25-alpine AS build
|
# The Go it builds with is the one the manifest pins (build.on GO_BASE), passed by the Makefile and the
|
||||||
|
# build machine alike; the default only serves a hand build, and matches go.mod.
|
||||||
|
ARG GO_BASE=golang:1.26-alpine
|
||||||
|
FROM ${GO_BASE} AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What the mesh issued this proxy, read on the bus (novox/hq ADR 0160, ADR 0167).
|
||||||
|
//
|
||||||
|
// **The proxy is told, not left to work it out.** Its membership carries the routes it is given —
|
||||||
|
// the same contributions its file is written from — and every machine's address on the private
|
||||||
|
// network, which is who may be served an internal name. Read once at connect and followed, so a
|
||||||
|
// route added or a machine joining reaches a running proxy without a restart.
|
||||||
|
|
||||||
|
// credential is the bus account the mesh delivered as this module's own secret named broker.
|
||||||
|
type credential struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
Node string `json:"node"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// followMembership connects with the credential in path and applies every membership the mesh
|
||||||
|
// issues this proxy. It retries the first connection for as long as it takes: a proxy that started
|
||||||
|
// before the bus keeps serving the file, and takes the bus when it answers.
|
||||||
|
func followMembership(path string, held *table, fromBus *atomic.Bool) {
|
||||||
|
for {
|
||||||
|
err := followOnce(path, held, fromBus)
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("cannot follow this proxy's membership, serving the file meanwhile: %v", err)
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func followOnce(path string, held *table, fromBus *atomic.Bool) error {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var cred credential
|
||||||
|
if err := json.Unmarshal(raw, &cred); err != nil {
|
||||||
|
return fmt.Errorf("the broker credential is not one: %w", err)
|
||||||
|
}
|
||||||
|
if cred.Node == "" || cred.Module == "" {
|
||||||
|
return fmt.Errorf("the broker credential names no node or module, so it has no membership")
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := []nats.Option{
|
||||||
|
nats.Name(cred.Node + "." + cred.Module),
|
||||||
|
nats.UserInfo(cred.User, cred.Password),
|
||||||
|
// Its own inbox, and nothing wider: every principal is granted `_INBOX.<its user>.>` alone.
|
||||||
|
nats.CustomInboxPrefix("_INBOX." + cred.User),
|
||||||
|
// The bus being restarted is an upgrade, not a reason to stop following.
|
||||||
|
nats.MaxReconnects(-1),
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cred.Fingerprint) != "" {
|
||||||
|
opts = append(opts, nats.Secure(broker.PinnedToFingerprint(cred.Fingerprint)))
|
||||||
|
}
|
||||||
|
conn, err := nats.Connect(cred.URL, opts...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connecting to the bus at %s: %w", broker.BareAddress(cred.URL), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := broker.MembershipSubject(cred.Node, cred.Module)
|
||||||
|
apply := func(body []byte) {
|
||||||
|
var issued broker.Membership
|
||||||
|
if err := json.Unmarshal(body, &issued); err != nil {
|
||||||
|
log.Printf("a membership arrived that is not one: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if took := applyMembership(issued, held); took && !fromBus.Swap(true) {
|
||||||
|
log.Printf("routes now come from this proxy's membership on %s", subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Followed first, read second: an issue landing between the two is applied, not missed.
|
||||||
|
if _, err := conn.Subscribe(subject, func(m *nats.Msg) { apply(m.Data) }); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return fmt.Errorf("cannot follow %s: %w", subject, err)
|
||||||
|
}
|
||||||
|
// The subject-addressed direct get: the one request this account may make of the stream.
|
||||||
|
got, err := conn.Request("$JS.API.DIRECT.GET."+broker.AssignmentsStream+"."+subject, nil, 5*time.Second)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
log.Printf("cannot read the membership issued on %s yet (%v); following it", subject, err)
|
||||||
|
case got.Header.Get("Status") != "" || len(got.Data) == 0:
|
||||||
|
log.Printf("no membership issued on %s yet; serving the file until one is", subject)
|
||||||
|
default:
|
||||||
|
apply(got.Data)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyMembership serves what a membership says, and says whether it said anything about routes.
|
||||||
|
//
|
||||||
|
// A membership with no routes in it is one from a controller older than ADR 0167, and the file stays
|
||||||
|
// the source rather than every route being withdrawn because a field was absent.
|
||||||
|
func applyMembership(issued broker.Membership, held *table) bool {
|
||||||
|
raw, carries := issued.Receives["route"]
|
||||||
|
if !carries {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var contributions []contribution
|
||||||
|
if err := json.Unmarshal(raw, &contributions); err != nil {
|
||||||
|
log.Printf("the routes in this proxy's membership are not contributions, keeping what is served: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
inside, err := sourcesOf(issued.Mesh)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("the mesh in this proxy's membership is unreadable, keeping what is served: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
routes, public := routesOf(contributions)
|
||||||
|
held.set(routes, public)
|
||||||
|
held.setInside(inside)
|
||||||
|
log.Printf("serving %d route(s) from the membership, internal names to %d machine(s): %s",
|
||||||
|
len(routes), len(inside), strings.Join(held.names(), ", "))
|
||||||
|
return true
|
||||||
|
}
|
||||||
+180
-29
@@ -54,12 +54,14 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/acme"
|
"golang.org/x/crypto/acme"
|
||||||
@@ -196,6 +198,63 @@ type table struct {
|
|||||||
// pass ACME's own validation (it has no public DNS to prove it against), so asking for it is
|
// pass ACME's own validation (it has no public DNS to prove it against), so asking for it is
|
||||||
// not merely pointless but the failing order onlyWhatTheMeshSaid exists to prevent.
|
// not merely pointless but the failing order onlyWhatTheMeshSaid exists to prevent.
|
||||||
public map[string]bool
|
public map[string]bool
|
||||||
|
// inside is where a request must come from to be served a name that is only internal: every
|
||||||
|
// machine's address on the private network, as the mesh issued it in this proxy's membership
|
||||||
|
// (novox/hq ADR 0167). Empty until it is issued, and then only the machine itself is inside.
|
||||||
|
inside sources
|
||||||
|
}
|
||||||
|
|
||||||
|
// sources is who may be served an internal name: the private network's addresses as the mesh
|
||||||
|
// issued them. The machine itself is always inside — anything on a machine may call anything on it
|
||||||
|
// (novox/hq ADR 0144) — so loopback needs no entry.
|
||||||
|
type sources []netip.Prefix
|
||||||
|
|
||||||
|
// sourcesOf reads the addresses the mesh issued, each a single address or a range. One that does
|
||||||
|
// not parse is an error, not an entry skipped: the proxy would otherwise serve internal names to
|
||||||
|
// fewer machines than the mesh said, and say nothing.
|
||||||
|
func sourcesOf(mesh []string) (sources, error) {
|
||||||
|
var out sources
|
||||||
|
for _, entry := range mesh {
|
||||||
|
entry = strings.TrimSpace(entry)
|
||||||
|
if prefix, err := netip.ParsePrefix(entry); err == nil {
|
||||||
|
out = append(out, prefix.Masked())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addr, err := netip.ParseAddr(entry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%q is not an address on the private network", entry)
|
||||||
|
}
|
||||||
|
addr = addr.Unmap()
|
||||||
|
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// holds says whether a request from this remote address came from the mesh or the machine itself.
|
||||||
|
//
|
||||||
|
// **By source, which the mesh's guard deliberately is not** — it names interfaces, because a source
|
||||||
|
// address can be claimed by whoever sends the packet. The proxy cannot see the interface a request
|
||||||
|
// arrived on, and here the claim does not carry: a connection needs its replies, and replies to a
|
||||||
|
// mesh address leave by the tunnel, never back to the claimant.
|
||||||
|
func (s sources) holds(remote string) bool {
|
||||||
|
host := remote
|
||||||
|
if h, _, err := net.SplitHostPort(remote); err == nil {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
addr, err := netip.ParseAddr(host)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
addr = addr.Unmap()
|
||||||
|
if addr.IsLoopback() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, prefix := range s {
|
||||||
|
if prefix.Contains(addr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *table) set(routes map[string][]rule, public map[string]bool) {
|
func (t *table) set(routes map[string][]rule, public map[string]bool) {
|
||||||
@@ -314,6 +373,41 @@ func bareHost(host string) string {
|
|||||||
return strings.ToLower(host)
|
return strings.ToLower(host)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hiddenFrom says whether this host must look unrouted to a request from this address: it is
|
||||||
|
// only an internal name, and the request did not come from the private network.
|
||||||
|
//
|
||||||
|
// **The proxy is the only way in to a routed endpoint, so it is what makes `internal` true**
|
||||||
|
// (novox/hq ADR 0138, issue 191). It answers public names on the same listeners, so a request from
|
||||||
|
// anywhere can carry any Host header; a name being internal keeps nobody out unless this check does.
|
||||||
|
// Answered exactly as a name that was never routed, so an outsider learns nothing from asking.
|
||||||
|
func (t *table) hiddenFrom(host, remote string) bool {
|
||||||
|
if !t.eligibleForInternalACME(host) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
return !t.inside.holds(remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setInside replaces who the mesh is, as the membership said.
|
||||||
|
func (t *table) setInside(inside sources) {
|
||||||
|
t.mu.Lock()
|
||||||
|
t.inside = inside
|
||||||
|
t.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// namesSeenFrom is what this proxy says it serves to a request from this address — every routed
|
||||||
|
// name, less the internal-only ones when the request came from outside.
|
||||||
|
func (t *table) namesSeenFrom(remote string) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, name := range t.names() {
|
||||||
|
if !t.hiddenFrom(name, remote) {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (t *table) names() []string {
|
func (t *table) names() []string {
|
||||||
t.mu.RLock()
|
t.mu.RLock()
|
||||||
defer t.mu.RUnlock()
|
defer t.mu.RUnlock()
|
||||||
@@ -343,7 +437,20 @@ func run() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
held := newTable()
|
held := newTable()
|
||||||
|
// **The bus first, the file until it has spoken** (novox/hq ADR 0167). The membership carries
|
||||||
|
// the routes and who the mesh is; the file carries the routes alone, so while the proxy reads
|
||||||
|
// it an internal name is served to this machine and to nobody else — refused, never opened.
|
||||||
|
fromBus := &atomic.Bool{}
|
||||||
|
if credential := strings.TrimSpace(os.Getenv("MESH_BROKER_FILE")); credential != "" {
|
||||||
|
go followMembership(credential, held, fromBus)
|
||||||
|
} else {
|
||||||
|
log.Printf("MESH_BROKER_FILE is not set: routes come from %s alone, and a name that is only "+
|
||||||
|
"internal is served to this machine alone", path)
|
||||||
|
}
|
||||||
read := func() {
|
read := func() {
|
||||||
|
if fromBus.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
routes, public, err := routesFrom(path)
|
routes, public, err := routesFrom(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Kept serving what it had. A file being rewritten is momentarily unreadable, and
|
// Kept serving what it had. A file being rewritten is momentarily unreadable, and
|
||||||
@@ -422,19 +529,7 @@ func run() error {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
tlsConfig := publicManager.TLSConfig()
|
tlsConfig := publicManager.TLSConfig()
|
||||||
if internalManager != nil {
|
tlsConfig.GetCertificate = certificateFor(held, tlsConfig.GetCertificate, internalManager)
|
||||||
// Dispatched by which authority may certify this name at all — the same question
|
|
||||||
// eligibleForInternalACME already answers, asked once more at handshake time rather than
|
|
||||||
// only when an order is placed, since a cached certificate is served here on every request
|
|
||||||
// and never goes through HostPolicy again.
|
|
||||||
fromPublic, fromInternal := tlsConfig.GetCertificate, internalManager.TLSConfig().GetCertificate
|
|
||||||
tlsConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
||||||
if held.eligibleForInternalACME(hello.ServerName) {
|
|
||||||
return fromInternal(hello)
|
|
||||||
}
|
|
||||||
return fromPublic(hello)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: secure,
|
Addr: secure,
|
||||||
@@ -592,6 +687,33 @@ func forThisAuthority(cache, directory string, root []byte) string {
|
|||||||
return filepath.Join(cache, hex.EncodeToString(sum[:])[:16])
|
return filepath.Join(cache, hex.EncodeToString(sum[:])[:16])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// certificateFor picks the certificate a handshake is answered with.
|
||||||
|
//
|
||||||
|
// Dispatched by which authority may certify this name at all — the same question
|
||||||
|
// eligibleForInternalACME already answers, asked once more at handshake time rather than only when
|
||||||
|
// an order is placed, since a cached certificate is served here on every request and never goes
|
||||||
|
// through HostPolicy again. And refused, exactly as an unrouted name is, to a client outside the
|
||||||
|
// private network asking for a name that is only internal: the certificate would name it.
|
||||||
|
func certificateFor(held *table, fromPublic func(*tls.ClientHelloInfo) (*tls.Certificate, error),
|
||||||
|
internalManager *autocert.Manager) func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
|
var fromInternal func(*tls.ClientHelloInfo) (*tls.Certificate, error)
|
||||||
|
if internalManager != nil {
|
||||||
|
fromInternal = internalManager.TLSConfig().GetCertificate
|
||||||
|
}
|
||||||
|
return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
|
if held.eligibleForInternalACME(hello.ServerName) {
|
||||||
|
if hello.Conn != nil && held.hiddenFrom(hello.ServerName, hello.Conn.RemoteAddr().String()) {
|
||||||
|
return nil, fmt.Errorf("no public route for %q in this mesh, so no certificate is asked for",
|
||||||
|
hello.ServerName)
|
||||||
|
}
|
||||||
|
if fromInternal != nil {
|
||||||
|
return fromInternal(hello)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fromPublic(hello)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// newTable is an empty routing table.
|
// newTable is an empty routing table.
|
||||||
func newTable() *table {
|
func newTable() *table {
|
||||||
return &table{to: map[string][]rule{}}
|
return &table{to: map[string][]rule{}}
|
||||||
@@ -600,8 +722,9 @@ func newTable() *table {
|
|||||||
// handler is the proxy itself, separated so it can be driven by a test without a listener.
|
// handler is the proxy itself, separated so it can be driven by a test without a listener.
|
||||||
func handler(held *table) http.Handler {
|
func handler(held *table) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hidden := held.hiddenFrom(r.Host, r.RemoteAddr)
|
||||||
matched, known := held.find(r.Host, r.URL.Path)
|
matched, known := held.find(r.Host, r.URL.Path)
|
||||||
if !known {
|
if hidden || !known {
|
||||||
// **Named, not a bare 404.** A route that was withdrawn and a name that never existed
|
// **Named, not a bare 404.** A route that was withdrawn and a name that never existed
|
||||||
// are different things, and a proxy that says only "not found" makes an operator go
|
// are different things, and a proxy that says only "not found" makes an operator go
|
||||||
// and read the mesh to tell them apart. What it is serving is the answer to both.
|
// and read the mesh to tell them apart. What it is serving is the answer to both.
|
||||||
@@ -609,15 +732,20 @@ func handler(held *table) http.Handler {
|
|||||||
// And since a host may now be routed only on some paths, those are a third thing:
|
// And since a host may now be routed only on some paths, those are a third thing:
|
||||||
// saying "no route for this name" while listing that very name as served is a
|
// saying "no route for this name" while listing that very name as served is a
|
||||||
// contradiction an operator would have to disbelieve the proxy to get past.
|
// contradiction an operator would have to disbelieve the proxy to get past.
|
||||||
|
// **Said in the log as well as to the client.** A name this mesh does not serve, asked
|
||||||
|
// for from outside, is what a scanner does, and the machine's intrusion prevention reads
|
||||||
|
// this proxy's log for exactly that line (novox/hq ADR 0179): the address last, as the
|
||||||
|
// jail's filter expects it.
|
||||||
|
log.Printf("refused: no route for %q, asked from %s", r.Host, r.RemoteAddr)
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
if held.routed(r.Host) {
|
if !hidden && held.routed(r.Host) {
|
||||||
fmt.Fprintf(w, "%s is served here, but no route covers %q.\n",
|
fmt.Fprintf(w, "%s is served here, but no route covers %q.\n",
|
||||||
bareHost(r.Host), r.URL.Path)
|
bareHost(r.Host), r.URL.Path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "no route for %q in this mesh.\nserving: %s\n",
|
fmt.Fprintf(w, "no route for %q in this mesh.\nserving: %s\n",
|
||||||
r.Host, strings.Join(held.names(), ", "))
|
r.Host, strings.Join(held.namesSeenFrom(r.RemoteAddr), ", "))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,6 +844,12 @@ func boolByte(b bool) byte {
|
|||||||
// routesFrom reads what the mesh wrote and turns it into host → the rules for that host, and
|
// routesFrom reads what the mesh wrote and turns it into host → the rules for that host, and
|
||||||
// which of those hosts is a public name — the second is `name`, ACME-eligible; a host reached
|
// which of those hosts is a public name — the second is `name`, ACME-eligible; a host reached
|
||||||
// only through `internal-name` never appears there.
|
// only through `internal-name` never appears there.
|
||||||
|
//
|
||||||
|
// **A route may carry either name, or both** (novox/hq ADR 0138). How far an endpoint reaches
|
||||||
|
// decides which names the mesh composes, so an endpoint that reaches only the private network
|
||||||
|
// arrives with an `internal-name` and no `name`. That is a whole route, not a malformed one: it is
|
||||||
|
// served under its internal name and certified by the internal authority. Only a route with
|
||||||
|
// neither name has nothing to be served under (novox/hq issue 191).
|
||||||
func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -725,17 +859,29 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
|||||||
if err := json.Unmarshal(raw, &said); err != nil {
|
if err := json.Unmarshal(raw, &said); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
routes, public := routesOf(said.Given)
|
||||||
|
return routes, public, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// routesOf turns what the mesh gave into host → the rules for that host, and which hosts are public
|
||||||
|
// names — the same whether the contributions came in the file or in the membership.
|
||||||
|
func routesOf(contributions []contribution) (map[string][]rule, map[string]bool) {
|
||||||
out := map[string][]rule{}
|
out := map[string][]rule{}
|
||||||
public := map[string]bool{}
|
public := map[string]bool{}
|
||||||
for _, c := range said.Given {
|
for _, c := range contributions {
|
||||||
name, _ := c.Values["name"].(string)
|
name, _ := c.Values["name"].(string)
|
||||||
if name == "" {
|
name = strings.TrimSpace(name)
|
||||||
|
internal, _ := c.Values["internal-name"].(string)
|
||||||
|
internal = strings.TrimSpace(internal)
|
||||||
|
if name == "" && internal == "" {
|
||||||
log.Printf("%s on %s asked for a route and named nothing; skipped", c.From, c.Node)
|
log.Printf("%s on %s asked for a route and named nothing; skipped", c.From, c.Node)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
host := strings.ToLower(name)
|
// What the route is called in a log line: its public name when it has one.
|
||||||
public[host] = true
|
called := name
|
||||||
|
if called == "" {
|
||||||
|
called = internal
|
||||||
|
}
|
||||||
|
|
||||||
made := rule{path: asPath(c.Values["path"])}
|
made := rule{path: asPath(c.Values["path"])}
|
||||||
if p, ok := asWhole(c.Values["priority"]); ok {
|
if p, ok := asWhole(c.Values["priority"]); ok {
|
||||||
@@ -752,7 +898,7 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
|||||||
if looksLikeACredential(named) {
|
if looksLikeACredential(named) {
|
||||||
log.Printf("%s on %s declared route %q with a credential in the declaration rather "+
|
log.Printf("%s on %s declared route %q with a credential in the declaration rather "+
|
||||||
"than the name of a secret; the whole route is refused (novox/hq ADR 0108)",
|
"than the name of a secret; the whole route is refused (novox/hq ADR 0108)",
|
||||||
c.From, c.Node, name)
|
c.From, c.Node, called)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
users, err := usersFrom(named)
|
users, err := usersFrom(named)
|
||||||
@@ -770,7 +916,7 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
|||||||
port, ok := asPort(c.Values["port"])
|
port, ok := asPort(c.Values["port"])
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Printf("%s on %s asked for route %q and gave no usable port; skipped",
|
log.Printf("%s on %s asked for route %q and gave no usable port; skipped",
|
||||||
c.From, c.Node, name)
|
c.From, c.Node, called)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Where the mesh says that machine is. Empty means it is this one — a workload beside
|
// Where the mesh says that machine is. Empty means it is this one — a workload beside
|
||||||
@@ -791,7 +937,7 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
|||||||
}
|
}
|
||||||
if scheme != "http" && scheme != "https" {
|
if scheme != "http" && scheme != "https" {
|
||||||
log.Printf("%s on %s asked for route %q with scheme %q, which is neither http "+
|
log.Printf("%s on %s asked for route %q with scheme %q, which is neither http "+
|
||||||
"nor https; skipped", c.From, c.Node, name, scheme)
|
"nor https; skipped", c.From, c.Node, called, scheme)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
made.insecure, _ = c.Values["insecure"].(bool)
|
made.insecure, _ = c.Values["insecure"].(bool)
|
||||||
@@ -802,7 +948,7 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
|||||||
bytes, whole := asWhole(asked)
|
bytes, whole := asWhole(asked)
|
||||||
if !whole || bytes <= 0 {
|
if !whole || bytes <= 0 {
|
||||||
log.Printf("%s on %s asked for route %q with a max-request-body of %v, which is "+
|
log.Printf("%s on %s asked for route %q with a max-request-body of %v, which is "+
|
||||||
"not a whole positive number of bytes; skipped", c.From, c.Node, name, asked)
|
"not a whole positive number of bytes; skipped", c.From, c.Node, called, asked)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
made.maxRequestBody = int64(bytes)
|
made.maxRequestBody = int64(bytes)
|
||||||
@@ -810,19 +956,24 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
|||||||
made.target = fmt.Sprintf("%s://%s:%d", scheme, at, port)
|
made.target = fmt.Sprintf("%s://%s:%d", scheme, at, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
out[host] = append(out[host], made)
|
if name != "" {
|
||||||
|
host := strings.ToLower(name)
|
||||||
|
out[host] = append(out[host], made)
|
||||||
|
public[host] = true
|
||||||
|
}
|
||||||
|
|
||||||
// The internal-network alias, the same rule under a second host — a predecessor proxy
|
// The internal-network name, the same rule under a second host — a predecessor proxy
|
||||||
// answered both for one route, as a convenience (reaching a service over the VPN without a
|
// answered both for one route, as a convenience (reaching a service over the VPN without a
|
||||||
// public TLS round trip), not as an access boundary; composing it here restores exactly
|
// public TLS round trip), not as an access boundary; composing it here restores exactly
|
||||||
// that, nothing more. Absent whenever the node composed no internal name (novox/hq ADR
|
// that, nothing more. Absent whenever the node composed no internal name (novox/hq ADR
|
||||||
// 0056's internalDomain half) — the same "nothing to join a label to" case the public name
|
// 0056's internalDomain half) — the same "nothing to join a label to" case the public name
|
||||||
// already has.
|
// already has. And the only name, when the endpoint reaches no further than the private
|
||||||
if internal, _ := c.Values["internal-name"].(string); strings.TrimSpace(internal) != "" {
|
// network.
|
||||||
|
if internal != "" {
|
||||||
out[strings.ToLower(internal)] = append(out[strings.ToLower(internal)], made)
|
out[strings.ToLower(internal)] = append(out[strings.ToLower(internal)], made)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, public, nil
|
return out, public
|
||||||
}
|
}
|
||||||
|
|
||||||
// asWhole is any whole number the mesh wrote, whatever its magnitude.
|
// asWhole is any whole number the mesh wrote, whatever its magnitude.
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// behind is a workload the proxy can send to, and a table routing one public name and one
|
||||||
|
// internal-only name to it, with the mesh's machines as the membership would issue them.
|
||||||
|
func behind(t *testing.T, mesh ...string) *table {
|
||||||
|
t.Helper()
|
||||||
|
workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
io.WriteString(w, "the workload")
|
||||||
|
}))
|
||||||
|
t.Cleanup(workload.Close)
|
||||||
|
at, _ := url.Parse(workload.URL)
|
||||||
|
host, port, _ := net.SplitHostPort(at.Host)
|
||||||
|
|
||||||
|
routes, public, err := routesFrom(write(t, fmt.Sprintf(`{"given":[
|
||||||
|
{"from":"app","node":"anchor","at":%q,
|
||||||
|
"values":{"name":"app.example","internal-name":"app.anchor.internal","port":%s}},
|
||||||
|
{"from":"admin","node":"anchor","at":%q,
|
||||||
|
"values":{"internal-name":"admin.anchor.internal","port":%s}}
|
||||||
|
]}`, host, port, host, port)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
held := newTable()
|
||||||
|
inside, err := sourcesOf(mesh)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
held.setInside(inside)
|
||||||
|
held.set(routes, public)
|
||||||
|
return held
|
||||||
|
}
|
||||||
|
|
||||||
|
// askFrom is what the proxy answers a request for host coming from remote.
|
||||||
|
func askFrom(held *table, host, remote string) (int, string) {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "http://"+host+"/", nil)
|
||||||
|
r.RemoteAddr = remote
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler(held).ServeHTTP(w, r)
|
||||||
|
return w.Code, w.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// **An internal-only name is served to the private network and to nobody else** (novox/hq ADR
|
||||||
|
// 0138, issue 191). The proxy answers public names on the same listeners, so without this a name
|
||||||
|
// being internal kept nobody out: a request from the internet only had to carry it.
|
||||||
|
func TestAnInternalOnlyNameIsServedOnlyInsideThePrivateNetwork(t *testing.T) {
|
||||||
|
held := behind(t, "10.10.0.1", "10.10.0.7")
|
||||||
|
|
||||||
|
if code, body := askFrom(held, "admin.anchor.internal", "10.10.0.7:51000"); code != http.StatusOK ||
|
||||||
|
body != "the workload" {
|
||||||
|
t.Errorf("a request from the private network was not served: %d %q", code, body)
|
||||||
|
}
|
||||||
|
if code, body := askFrom(held, "admin.anchor.internal", "127.0.0.1:51000"); code != http.StatusOK {
|
||||||
|
t.Errorf("a request from the machine itself was not served: %d %q", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, body := askFrom(held, "admin.anchor.internal", "203.0.113.9:51000")
|
||||||
|
if code != http.StatusNotFound {
|
||||||
|
t.Fatalf("a request from outside the private network reached an internal-only name: %d %q",
|
||||||
|
code, body)
|
||||||
|
}
|
||||||
|
// Answered as a name never routed, and the list of what is served does not name it either —
|
||||||
|
// otherwise the refusal would tell an outsider exactly what to ask for from inside.
|
||||||
|
if strings.Contains(strings.SplitN(body, "\n", 2)[1], "admin.anchor.internal") {
|
||||||
|
t.Errorf("the refusal names the internal-only route to an outsider: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "app.example") {
|
||||||
|
t.Errorf("the refusal stopped listing the public names: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The internal name of a route that also has a public one is internal too: served inside, and to
|
||||||
|
// an outsider only under the public name. Nothing is lost — the outsider has the public name — and a
|
||||||
|
// name stays one thing whichever route it came from.
|
||||||
|
func TestAnInternalAliasOfAPublicRouteIsServedInsideOnly(t *testing.T) {
|
||||||
|
held := behind(t, "10.10.0.1", "10.10.0.7")
|
||||||
|
if code, body := askFrom(held, "app.anchor.internal", "10.10.0.7:51000"); code != http.StatusOK {
|
||||||
|
t.Errorf("the internal alias stopped answering the private network: %d %q", code, body)
|
||||||
|
}
|
||||||
|
if code, _ := askFrom(held, "app.anchor.internal", "203.0.113.9:51000"); code != http.StatusNotFound {
|
||||||
|
t.Errorf("the internal alias was served to an outsider: %d", code)
|
||||||
|
}
|
||||||
|
if code, _ := askFrom(held, "app.example", "203.0.113.9:51000"); code != http.StatusOK {
|
||||||
|
t.Errorf("the public name was refused to an outsider: %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before a membership has said who the mesh is, only the machine itself is inside — refused to
|
||||||
|
// everyone else, never served to everyone.
|
||||||
|
func TestUntilTheMeshIsIssuedAnInternalOnlyNameIsServedToTheMachineAlone(t *testing.T) {
|
||||||
|
held := behind(t)
|
||||||
|
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:51000"); code != http.StatusNotFound {
|
||||||
|
t.Errorf("an internal-only name was served with no private network said: %d", code)
|
||||||
|
}
|
||||||
|
if code, _ := askFrom(held, "admin.anchor.internal", "[::1]:51000"); code != http.StatusOK {
|
||||||
|
t.Errorf("an internal-only name was refused to the machine itself: %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type from struct {
|
||||||
|
net.Conn
|
||||||
|
remote net.Addr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c from) RemoteAddr() net.Addr { return c.remote }
|
||||||
|
|
||||||
|
// The handshake refuses an internal-only name to an outsider too: the certificate would name it,
|
||||||
|
// and serving it would answer the question the routing refuses to.
|
||||||
|
func TestTheHandshakeRefusesAnInternalOnlyNameToAnOutsider(t *testing.T) {
|
||||||
|
held := behind(t, "10.10.0.1", "10.10.0.7")
|
||||||
|
served := &tls.Certificate{}
|
||||||
|
pick := certificateFor(held, func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return served, nil }, nil)
|
||||||
|
hello := func(name, remote string) *tls.ClientHelloInfo {
|
||||||
|
addr, _ := net.ResolveTCPAddr("tcp", remote)
|
||||||
|
return &tls.ClientHelloInfo{ServerName: name, Conn: from{remote: addr}}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := pick(hello("admin.anchor.internal", "203.0.113.9:443")); err == nil {
|
||||||
|
t.Error("an outsider was handed a certificate for an internal-only name")
|
||||||
|
}
|
||||||
|
if got, err := pick(hello("admin.anchor.internal", "10.10.0.7:443")); err != nil || got != served {
|
||||||
|
t.Errorf("a client on the private network was refused: %v", err)
|
||||||
|
}
|
||||||
|
if got, err := pick(hello("app.example", "203.0.113.9:443")); err != nil || got != served {
|
||||||
|
t.Errorf("a public name was refused to an outsider: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mesh is issued as machines' addresses; a range is read as well. One that does not parse is
|
||||||
|
// refused rather than skipped, so a typo never quietly narrows or widens who is inside.
|
||||||
|
func TestTheMeshIsReadAsAddressesAndRanges(t *testing.T) {
|
||||||
|
if _, err := sourcesOf([]string{"10.10.0.1", "not-an-address"}); err == nil {
|
||||||
|
t.Error("an entry that is not an address was accepted")
|
||||||
|
}
|
||||||
|
inside, err := sourcesOf([]string{"10.10.0.1", "fd00::1", "10.20.0.0/24"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for remote, want := range map[string]bool{
|
||||||
|
"10.10.0.1:1": true,
|
||||||
|
"[::ffff:10.10.0.1]:1": true,
|
||||||
|
"[fd00::1]:1": true,
|
||||||
|
"10.20.0.200:1": true,
|
||||||
|
"10.10.0.2:1": false,
|
||||||
|
"192.168.1.10:1": false,
|
||||||
|
"not-an-address": false,
|
||||||
|
} {
|
||||||
|
if inside.holds(remote) != want {
|
||||||
|
t.Errorf("%s inside the mesh: got %v, want %v", remote, !want, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the mesh issues is what is served: the routes in the membership, internal names to the
|
||||||
|
// machines it names (novox/hq ADR 0167).
|
||||||
|
func TestAMembershipIsServedAsIssued(t *testing.T) {
|
||||||
|
held := newTable()
|
||||||
|
took := applyMembership(broker.Membership{
|
||||||
|
Receives: map[string]json.RawMessage{"route": json.RawMessage(`[
|
||||||
|
{"from":"admin","node":"anchor","at":"anchor.internal",
|
||||||
|
"values":{"internal-name":"admin.anchor.internal","port":8080}}]`)},
|
||||||
|
Mesh: []string{"10.10.0.7"},
|
||||||
|
}, held)
|
||||||
|
if !took {
|
||||||
|
t.Fatal("a membership carrying routes was not applied")
|
||||||
|
}
|
||||||
|
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code == http.StatusNotFound {
|
||||||
|
t.Error("a machine the membership names was refused the internal-only route")
|
||||||
|
}
|
||||||
|
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.9:1"); code != http.StatusNotFound {
|
||||||
|
t.Errorf("a machine the membership does not name was served the internal-only route: %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A membership that says nothing about routes is one from a controller that does not issue them,
|
||||||
|
// and changes nothing: the file stays the source rather than every route being withdrawn.
|
||||||
|
func TestAMembershipWithoutRoutesLeavesTheFileServing(t *testing.T) {
|
||||||
|
held := behind(t, "10.10.0.7")
|
||||||
|
before := held.names()
|
||||||
|
if applyMembership(broker.Membership{Mesh: []string{"10.10.0.7"}}, held) {
|
||||||
|
t.Error("a membership without routes was taken as the source of routes")
|
||||||
|
}
|
||||||
|
if got := held.names(); strings.Join(got, ",") != strings.Join(before, ",") {
|
||||||
|
t.Errorf("a membership without routes changed what is served: %v, was %v", got, before)
|
||||||
|
}
|
||||||
|
if applyMembership(broker.Membership{
|
||||||
|
Receives: map[string]json.RawMessage{"route": json.RawMessage(`[]`)},
|
||||||
|
Mesh: []string{"not-an-address"},
|
||||||
|
}, held) {
|
||||||
|
t.Error("a membership whose mesh cannot be read was applied")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,6 +90,50 @@ func TestARouteWithAnInternalNameIsReachableUnderBoth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A route whose endpoint reaches only the private network carries an internal name and no public
|
||||||
|
// one (novox/hq ADR 0138), and is served under that name rather than skipped as naming nothing —
|
||||||
|
// skipping it left every internal-only module unreachable by name (novox/hq issue 191).
|
||||||
|
func TestARouteWithOnlyAnInternalNameIsServed(t *testing.T) {
|
||||||
|
routes, public, err := routesFrom(write(t, `{"given":[
|
||||||
|
{"from":"app","node":"anchor","at":"anchor.internal",
|
||||||
|
"values":{"internal-name":"App.Anchor.Internal","port":8443,"scheme":"https","insecure":true}}
|
||||||
|
]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if targetOf(routes, "app.anchor.internal") != "https://anchor.internal:8443" {
|
||||||
|
t.Fatalf("the internal-only route is not served: %v", routes)
|
||||||
|
}
|
||||||
|
if len(routes) != 1 {
|
||||||
|
t.Errorf("an internal-only route made hosts it never named: %v", routes)
|
||||||
|
}
|
||||||
|
if len(public) != 0 {
|
||||||
|
t.Errorf("an internal-only route made a name eligible for a public certificate: %v", public)
|
||||||
|
}
|
||||||
|
|
||||||
|
held := newTable()
|
||||||
|
held.set(routes, public)
|
||||||
|
if err := onlyInternalNamesTheMeshSaid(held)(context.Background(), "app.anchor.internal"); err != nil {
|
||||||
|
t.Errorf("the internal authority refused the internal-only route's name: %v", err)
|
||||||
|
}
|
||||||
|
if err := onlyWhatTheMeshSaid(held)(context.Background(), "app.anchor.internal"); err == nil {
|
||||||
|
t.Error("a public certificate was ordered for an internal-only name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A route with neither name has nothing to be served under, and is still skipped.
|
||||||
|
func TestARouteWithNeitherNameIsSkipped(t *testing.T) {
|
||||||
|
routes, public, err := routesFrom(write(t, `{"given":[
|
||||||
|
{"from":"app","node":"anchor","at":"anchor.internal","values":{"internal-name":" ","port":8080}}
|
||||||
|
]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(routes) != 0 || len(public) != 0 {
|
||||||
|
t.Errorf("a route that named nothing was served: %v %v", routes, public)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A route with no internal-name composed gets no second host — the ordinary case, unchanged.
|
// A route with no internal-name composed gets no second host — the ordinary case, unchanged.
|
||||||
func TestARouteWithNoInternalNameGetsNoAlias(t *testing.T) {
|
func TestARouteWithNoInternalNameGetsNoAlias(t *testing.T) {
|
||||||
routes, _, err := routesFrom(write(t, `{"given":[
|
routes, _, err := routesFrom(write(t, `{"given":[
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package broker
|
package broker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -33,6 +34,17 @@ type Membership struct {
|
|||||||
Reaches map[string][]string `json:"reaches,omitempty"`
|
Reaches map[string][]string `json:"reaches,omitempty"`
|
||||||
// Tools is where this instance answers what it serves — the runtime's one verb of its own.
|
// Tools is where this instance answers what it serves — the runtime's one verb of its own.
|
||||||
Tools string `json:"tools"`
|
Tools string `json:"tools"`
|
||||||
|
// Receives is what this assignment is given for each requirement it receives, by requirement:
|
||||||
|
// the contributions of every module that asked for it, as the catalogue composed them (novox/hq
|
||||||
|
// ADR 0167). The same list its received file is written from, so the two cannot disagree; a
|
||||||
|
// requirement nobody contributed to is an empty list, never absent. Kept as JSON because the
|
||||||
|
// catalogue owns the shape of a contribution and the bus only carries it.
|
||||||
|
Receives map[string]json.RawMessage `json:"receives,omitempty"`
|
||||||
|
// Mesh is every machine's address on the private network — what a rule saying "from the mesh"
|
||||||
|
// resolves to in the packet filter, issued here from the same list (novox/hq ADR 0167). A
|
||||||
|
// module that must tell the mesh from the world, the route proxy serving an internal name, reads
|
||||||
|
// it here rather than keeping a definition of its own.
|
||||||
|
Mesh []string `json:"mesh,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Served is one address a tool is answered on.
|
// Served is one address a tool is answered on.
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func boundUsed(content string) [][2]string {
|
|||||||
// Three facts the mesh states about any provision, plus whatever the provider said it serves. A
|
// Three facts the mesh states about any provision, plus whatever the provider said it serves. A
|
||||||
// module may not reach a binding it does not have — the same boundary as a secret, for the same
|
// module may not reach a binding it does not have — the same boundary as a secret, for the same
|
||||||
// reason.
|
// reason.
|
||||||
func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]string {
|
func knownFor(m Manifest, needs []Needed, node string) (map[string]map[string]string, error) {
|
||||||
out := map[string]map[string]string{}
|
out := map[string]map[string]string{}
|
||||||
for _, want := range m.Wants() {
|
for _, want := range m.Wants() {
|
||||||
for i := range needs {
|
for i := range needs {
|
||||||
@@ -54,12 +54,20 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str
|
|||||||
if n.Name != want || n.For != m.Module {
|
if n.Name != want || n.For != m.Module {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
as := ConsumerIdentity(node, IdentitySource(m.Slug, m.Module))
|
||||||
values := map[string]string{
|
values := map[string]string{
|
||||||
"at": n.At,
|
"at": n.At,
|
||||||
"from": n.From,
|
"from": n.From,
|
||||||
"as": ConsumerIdentity(node, IdentitySource(m.Slug, m.Module)),
|
"as": as,
|
||||||
}
|
}
|
||||||
for key, value := range n.Serves {
|
// What the provider derives for this consumer rather than for all of them
|
||||||
|
// (novox/hq ADR 0188). Filled here, the one place a provision and the module
|
||||||
|
// requiring it are both in hand.
|
||||||
|
served, err := ServedTo(n.Serves, as)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s requires %s: %w", m.Module, want, err)
|
||||||
|
}
|
||||||
|
for key, value := range served {
|
||||||
// The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
|
// The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
|
||||||
// which is what a float would write and what a connection string would refuse.
|
// which is what a float would write and what a connection string would refuse.
|
||||||
values[key] = plainly(value)
|
values[key] = plainly(value)
|
||||||
@@ -67,7 +75,7 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str
|
|||||||
out[want] = values
|
out[want] = values
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// withOwnNames adds a module's own composed names to what it may name from one binding:
|
// withOwnNames adds a module's own composed names to what it may name from one binding:
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What a provider derives for one consumer, said once in the provider's definition and delivered
|
||||||
|
// to both ends (novox/hq ADR 0188, issue 124).
|
||||||
|
//
|
||||||
|
// A `serves` block is otherwise literal: the same values for every consumer. Where the provider
|
||||||
|
// *names the resource* — a bucket, a database, a vhost — the name is derived from who is asking,
|
||||||
|
// and before this the mesh had no channel for it. The provider recomputed it in its own code and
|
||||||
|
// every consumer transcribed it into its own definition by hand, which is a copy of somebody
|
||||||
|
// else's rule kept in agreement by nobody. One of three transcriptions was wrong for months.
|
||||||
|
//
|
||||||
|
// **The mesh learns no protocol here; it spells its own name in an alphabet it already knows.**
|
||||||
|
// The only fact a served value may name is the identity the mesh itself minted for the consumer,
|
||||||
|
// in one of two alphabets: as it was minted, and as a DNS label. Everything a provider wants
|
||||||
|
// around it — a prefix, a suffix, a separator — it writes around the placeholder, because a
|
||||||
|
// served value is a string.
|
||||||
|
|
||||||
|
// consumerFact is `${consumer:<fact>}` or `${consumer:<fact>:<alphabet>}`.
|
||||||
|
var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`)
|
||||||
|
|
||||||
|
// consumerFacts are what a served value may name about the consumer it is being derived for.
|
||||||
|
// One entry, deliberately: the identity is the one thing about a consumer the mesh itself chose,
|
||||||
|
// so it is the one thing the mesh can hand to a provider without either end guessing.
|
||||||
|
var consumerFacts = []string{"as"}
|
||||||
|
|
||||||
|
// consumerAlphabets are the ways the mesh will write that identity. `dns` is the mesh's own
|
||||||
|
// identifier with its separator written `-` instead of `_` — the whole of the difference between
|
||||||
|
// the alphabet the mesh mints in and the one buckets, vhosts and hostnames accept.
|
||||||
|
var consumerAlphabets = []string{"dns"}
|
||||||
|
|
||||||
|
// ServedTo fills a provider's served values for one consumer.
|
||||||
|
//
|
||||||
|
// `as` is the identity the mesh minted for that consumer — the same string it is told to present
|
||||||
|
// as a login. Values with no placeholder are returned exactly as they were, and a block with no
|
||||||
|
// placeholder at all is returned unchanged, so this costs nothing for the providers that derive
|
||||||
|
// nothing.
|
||||||
|
//
|
||||||
|
// Only strings carry placeholders. A number, a boolean or a nested object is a value the provider
|
||||||
|
// stated outright, and is left alone.
|
||||||
|
func ServedTo(serves map[string]any, as string) (map[string]any, error) {
|
||||||
|
if len(serves) == 0 {
|
||||||
|
return serves, nil
|
||||||
|
}
|
||||||
|
var out map[string]any
|
||||||
|
for _, key := range sortedAnyKeys(serves) {
|
||||||
|
text, ok := serves[key].(string)
|
||||||
|
if !ok || !strings.Contains(text, "${consumer:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filled, err := consumerInto(text, as)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("the value served as %q: %w", key, err)
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
// Copied only once something actually changes: the caller's map is the manifest's,
|
||||||
|
// and a provider that derives nothing must not have it rewritten underneath it.
|
||||||
|
out = make(map[string]any, len(serves))
|
||||||
|
for k, v := range serves {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[key] = filled
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
return serves, nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// consumerInto replaces every `${consumer:…}` in one value.
|
||||||
|
//
|
||||||
|
// **A fact or an alphabet the mesh does not have is refused, not left standing.** Written through,
|
||||||
|
// the literal `${consumer:as}` would reach a configuration file and be read as a bucket name,
|
||||||
|
// failing somewhere that names neither the module nor the mesh — the same reasoning `${bound:…}`
|
||||||
|
// is refused by (boundInto).
|
||||||
|
func consumerInto(value, as string) (string, error) {
|
||||||
|
var failed error
|
||||||
|
out := consumerFact.ReplaceAllStringFunc(value, func(match string) string {
|
||||||
|
parts := consumerFact.FindStringSubmatch(match)
|
||||||
|
fact, alphabet := parts[1], parts[2]
|
||||||
|
if fact != "as" {
|
||||||
|
if failed == nil {
|
||||||
|
failed = fmt.Errorf(
|
||||||
|
"says %s, and the mesh states %s about a consumer", match, orNothing(consumerFacts))
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
switch alphabet {
|
||||||
|
case "":
|
||||||
|
return as
|
||||||
|
case "dns":
|
||||||
|
return asDNSLabel(as)
|
||||||
|
default:
|
||||||
|
if failed == nil {
|
||||||
|
failed = fmt.Errorf(
|
||||||
|
"says %s, and the mesh writes an identity as %s", match, orNothing(consumerAlphabets))
|
||||||
|
}
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if failed != nil {
|
||||||
|
return "", failed
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// asDNSLabel writes a minted identity as a DNS label.
|
||||||
|
//
|
||||||
|
// The mesh's identities are already lower-case letters, digits and `_` (ConsumerIdentity), and
|
||||||
|
// already short enough for the tightest backend they reach (CheckIdentity, twenty characters). So
|
||||||
|
// this is the separator and nothing else — no lower-casing of what is already lower case, no
|
||||||
|
// truncation to a limit the identity is already inside, no padding of a name that is already long
|
||||||
|
// enough. Each of those would be the mesh guessing at a rule it has not been given.
|
||||||
|
func asDNSLabel(as string) string {
|
||||||
|
return strings.ReplaceAll(as, "_", "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckServes refuses a `serves` block that names a consumer fact or an alphabet the mesh does not
|
||||||
|
// have, when the definition is parsed rather than when a consumer is resolved.
|
||||||
|
//
|
||||||
|
// A provision nobody consumes yet still has its rule read: a definition that would be refused the
|
||||||
|
// first time somebody required it is a definition that is wrong now.
|
||||||
|
func CheckServes(m Manifest) []string {
|
||||||
|
var problems []string
|
||||||
|
for _, provision := range sortedServes(m.Serves) {
|
||||||
|
for _, key := range sortedAnyKeys(m.Serves[provision]) {
|
||||||
|
text, ok := m.Serves[provision][key].(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A probe identity, because what is checked is the shape of the statement and not
|
||||||
|
// what any consumer is called.
|
||||||
|
if _, err := consumerInto(text, "mesh_node_module"); err != nil {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s serves %s, and the value it serves as %q %s", m.Module, provision, key, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedServes(serves map[string]map[string]any) []string {
|
||||||
|
out := make([]string, 0, len(serves))
|
||||||
|
for k := range serves {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedAnyKeys(values map[string]any) []string {
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for k := range values {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// derivedFor is what the provider on this machine derives for one consumer of one provision
|
||||||
|
// (novox/hq ADR 0188).
|
||||||
|
//
|
||||||
|
// Settled first, then derived: an operator may set a prefix on what the provider serves and the
|
||||||
|
// mesh still fills the consumer's half of it ([ADR 0174]). Only the keys that actually name the
|
||||||
|
// consumer are returned — the rest of a `serves` block is the same for every consumer and is
|
||||||
|
// already in the provider's own definition, so repeating it here would be a second copy to go
|
||||||
|
// stale.
|
||||||
|
//
|
||||||
|
// The first module in the resolved order that says it serves the provision answers, which is the
|
||||||
|
// choice servedOnThisMachine makes for the consumer's half. Nothing serving it on this machine is
|
||||||
|
// not an error: a contribution can reach a machine whose provider is a record or an adapter, and
|
||||||
|
// then there is nothing derived to tell.
|
||||||
|
func (r Resolution) derivedFor(provision, as string, settings SettingsBy) (map[string]any, error) {
|
||||||
|
for _, m := range r.Modules {
|
||||||
|
serves, said := m.Serves[provision]
|
||||||
|
if !said {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var names map[string]any
|
||||||
|
for key, value := range serves {
|
||||||
|
if text, ok := value.(string); ok && strings.Contains(text, "${consumer:") {
|
||||||
|
if names == nil {
|
||||||
|
names = map[string]any{}
|
||||||
|
}
|
||||||
|
names[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if names == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
settled, err := Settle(names, settings[m.Module])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s serving %s: %w", m.Module, provision, err)
|
||||||
|
}
|
||||||
|
derived, err := ServedTo(settled, as)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s serving %s to %s: %w", m.Module, provision, as, err)
|
||||||
|
}
|
||||||
|
return derived, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// notTranscribed refuses a consumer's file that writes out the value its provider derives for it,
|
||||||
|
// instead of asking for it (novox/hq ADR 0188, issue 124).
|
||||||
|
//
|
||||||
|
// **What would have caught the one wrong instance.** The object store's three consumers each wrote
|
||||||
|
// their bucket into their own configuration by hand. One of them named a predecessor's bucket, and
|
||||||
|
// nothing compared it to what the provider would actually create: the module would have
|
||||||
|
// authenticated successfully and been refused on every object, which reads like a credential fault
|
||||||
|
// and is not one. It looked authoritative for months.
|
||||||
|
//
|
||||||
|
// The test is exact and costs one string search: a definition whose file already contains the
|
||||||
|
// value the mesh is about to derive for it has written down somebody else's rule. It cannot be a
|
||||||
|
// coincidence — a derived value carries the identity the mesh minted for this very consumer on
|
||||||
|
// this very machine, which nothing else would spell out — and it cannot be checked afterwards,
|
||||||
|
// because after substitution every consumer's file contains it legitimately.
|
||||||
|
//
|
||||||
|
// Only values that actually name the consumer are judged. A provider that serves a constant under
|
||||||
|
// the same key serves the same constant to everyone, and a consumer repeating it is redundant
|
||||||
|
// rather than wrong.
|
||||||
|
func notTranscribed(resource map[string]any, known map[string]map[string]string, module string) error {
|
||||||
|
if fmt.Sprint(resource["type"]) != "file" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
content, ok := resource["content"].(string)
|
||||||
|
if !ok || content == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, provision := range sortedKnown(known) {
|
||||||
|
values := known[provision]
|
||||||
|
identity := values["as"]
|
||||||
|
if identity == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, key := range sortedStringKeys(values) {
|
||||||
|
if key == "as" {
|
||||||
|
// The login is not derived from itself, and a consumer that must present it in a
|
||||||
|
// connection string legitimately has it from `${bound:…}` — which is what it will
|
||||||
|
// be after substitution, so this would judge the substitution, not the module.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value := values[key]
|
||||||
|
if value == "" || !namesTheConsumer(value, identity) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, value) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%s writes %q into %v, and that is exactly what %s derives for it — a definition "+
|
||||||
|
"keeping its own copy of somebody else's naming rule is one that can disagree "+
|
||||||
|
"with it, silently. Say ${bound:%s:%s} and be told",
|
||||||
|
module, value, resource["id"], provision, provision, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// namesTheConsumer is whether a derived value was built from this consumer's identity — in the
|
||||||
|
// alphabet it was minted in, or as a DNS label. A value that does not contain it was not derived
|
||||||
|
// from it, whatever else it may be.
|
||||||
|
func namesTheConsumer(value, identity string) bool {
|
||||||
|
return strings.Contains(value, identity) || strings.Contains(value, asDNSLabel(identity))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKnown(known map[string]map[string]string) []string {
|
||||||
|
out := make([]string, 0, len(known))
|
||||||
|
for k := range known {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedStringKeys(values map[string]string) []string {
|
||||||
|
out := make([]string, 0, len(values))
|
||||||
|
for k := range values {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -241,9 +241,14 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
|||||||
// Owner is kept beside the resources because a resource id cannot be split back into its module:
|
// Owner is kept beside the resources because a resource id cannot be split back into its module:
|
||||||
// a module's name may itself contain a dot. What the mesh adds of its own — an opening, the guard —
|
// a module's name may itself contain a dot. What the mesh adds of its own — an opening, the guard —
|
||||||
// has no owner.
|
// has no owner.
|
||||||
|
//
|
||||||
|
// Received is what each module on the machine is given for each requirement it receives — the same
|
||||||
|
// contributions its received file is written from, kept beside it so the mesh can also issue them
|
||||||
|
// on the bus in the module's membership (novox/hq ADR 0167). By module, then requirement.
|
||||||
type Composed struct {
|
type Composed struct {
|
||||||
Resources []map[string]any
|
Resources []map[string]any
|
||||||
Owner map[string]string
|
Owner map[string]string
|
||||||
|
Received map[string]map[string][]Contribution
|
||||||
// LeftOut is every module of this machine's set that was left out of its declaration, and
|
// LeftOut is every module of this machine's set that was left out of its declaration, and
|
||||||
// why (novox/hq ADR 0163, rule 6): a setting stored for it that its definition can no longer
|
// why (novox/hq ADR 0163, rule 6): a setting stored for it that its definition can no longer
|
||||||
// compose. Its held things are kept and its containers untouched — the machine is told so —
|
// compose. Its held things are kept and its containers untouched — the machine is told so —
|
||||||
@@ -267,8 +272,9 @@ func (r Resolution) LeftOut(settings SettingsBy, adopted bool) map[string]string
|
|||||||
// Compose is Declaration with the owner of every resource said.
|
// Compose is Declaration with the owner of every resource said.
|
||||||
func (r Resolution) Compose(with Rendering) (Composed, error) {
|
func (r Resolution) Compose(with Rendering) (Composed, error) {
|
||||||
owner := map[string]string{}
|
owner := map[string]string{}
|
||||||
|
received := map[string]map[string][]Contribution{}
|
||||||
leftOut := map[string]string{}
|
leftOut := map[string]string{}
|
||||||
resources, err := r.compose(with, owner, leftOut)
|
resources, err := r.compose(with, owner, received, leftOut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Composed{}, err
|
return Composed{}, err
|
||||||
}
|
}
|
||||||
@@ -280,7 +286,7 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
|
|||||||
"sealed": with.BusMembership, "mode": "0600",
|
"sealed": with.BusMembership, "mode": "0600",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return Composed{Resources: resources, Owner: owner, LeftOut: leftOut}, nil
|
return Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BusMembershipID names the resource carrying a machine's membership for the new bus, and
|
// BusMembershipID names the resource carrying a machine's membership for the new bus, and
|
||||||
@@ -289,7 +295,8 @@ func BusMembershipID() string { return "bus-membership" }
|
|||||||
|
|
||||||
const BusMembershipPath = "/var/lib/mesh/membership-next.json"
|
const BusMembershipPath = "/var/lib/mesh/membership-next.json"
|
||||||
|
|
||||||
func (r Resolution) compose(with Rendering, owner map[string]string, leftOut map[string]string) ([]map[string]any, error) {
|
func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||||
|
received map[string]map[string][]Contribution, leftOut map[string]string) ([]map[string]any, error) {
|
||||||
// **A setting is judged where it is stored, and an impossible one costs a module, not a
|
// **A setting is judged where it is stored, and an impossible one costs a module, not a
|
||||||
// machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes
|
// machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes
|
||||||
// this module uncomposable; it is left out of the declaration — its held things kept, its
|
// this module uncomposable; it is left out of the declaration — its held things kept, its
|
||||||
@@ -621,7 +628,16 @@ func (r Resolution) compose(with Rendering, owner map[string]string, leftOut map
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)), own)
|
as := ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module))
|
||||||
|
// What the provider derives for THIS consumer, filled here where the consumer is
|
||||||
|
// known (novox/hq ADR 0188). The same fill knownFor does below, so the binding file
|
||||||
|
// and the module's `${bound:…}` substitutions cannot say different things.
|
||||||
|
told := *found
|
||||||
|
told.Serves, err = ServedTo(told.Serves, as)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s is told about %s: %w", m.Module, to, err)
|
||||||
|
}
|
||||||
|
file, err := boundFile(told, m.Binds[to], as, own)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -633,6 +649,12 @@ func (r Resolution) compose(with Rendering, owner map[string]string, leftOut map
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
first = append(first, file)
|
first = append(first, file)
|
||||||
|
if received[m.Module] == nil {
|
||||||
|
received[m.Module] = map[string][]Contribution{}
|
||||||
|
}
|
||||||
|
// Empty rather than absent when nobody contributed, for the reason the file is
|
||||||
|
// written empty: "nothing asked" and "never told" want different responses.
|
||||||
|
received[m.Module][to] = append([]Contribution{}, given[to]...)
|
||||||
}
|
}
|
||||||
if m.Keeps != "" && with.Kept != nil {
|
if m.Keeps != "" && with.Kept != nil {
|
||||||
file, err := keptFile(m.Keeps, with.Kept)
|
file, err := keptFile(m.Keeps, with.Kept)
|
||||||
@@ -681,7 +703,10 @@ func (r Resolution) compose(with Rendering, owner map[string]string, leftOut map
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// And what its bindings say, for the half of a connection that is not secret.
|
// And what its bindings say, for the half of a connection that is not secret.
|
||||||
known := knownFor(m, r.Needs, r.Node)
|
known, err := knownFor(m, r.Needs, r.Node)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
// A requirement answered on this same machine is not in r.Needs — its binding file is
|
// A requirement answered on this same machine is not in r.Needs — its binding file is
|
||||||
// written from `here` (above) — and so `${bound:…}` could not name it, though the file
|
// written from `here` (above) — and so `${bound:…}` could not name it, though the file
|
||||||
// beside it said the same facts. Filled from the same answer, so the two cannot disagree.
|
// beside it said the same facts. Filled from the same answer, so the two cannot disagree.
|
||||||
@@ -698,7 +723,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string, leftOut map
|
|||||||
}
|
}
|
||||||
local := *answered
|
local := *answered
|
||||||
local.For = m.Module
|
local.For = m.Module
|
||||||
for provision, values := range knownFor(m, []Needed{local}, r.Node) {
|
here, err := knownFor(m, []Needed{local}, r.Node)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for provision, values := range here {
|
||||||
known[provision] = values
|
known[provision] = values
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -723,6 +752,17 @@ func (r Resolution) compose(with Rendering, owner map[string]string, leftOut map
|
|||||||
// And the machine underneath, which no binding of its own can tell it.
|
// And the machine underneath, which no binding of its own can tell it.
|
||||||
thisMachine := machineFacts(r, with.Names, with.MeshRange)
|
thisMachine := machineFacts(r, with.Names, with.MeshRange)
|
||||||
|
|
||||||
|
// **A definition that already holds the answer transcribed it** (novox/hq ADR 0188).
|
||||||
|
// Judged over what the module itself declares, and before anything is substituted: the
|
||||||
|
// mesh's own generated files — the binding, the contributions — legitimately carry the
|
||||||
|
// derived value, and after substitution so does every consumer's file, so this is the one
|
||||||
|
// moment the two can be told apart.
|
||||||
|
for _, own := range m.Resources {
|
||||||
|
if err := notTranscribed(own, known, m.Module); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Which of this module's files carry a secret, for the rule that a container may not read
|
// Which of this module's files carry a secret, for the rule that a container may not read
|
||||||
// one of them as its environment without saying so (ADR 0086, issue 041).
|
// one of them as its environment without saying so (ADR 0086, issue 041).
|
||||||
secretFiles := secretFilesOf(resources)
|
secretFiles := secretFilesOf(resources)
|
||||||
@@ -1087,6 +1127,19 @@ type Contribution struct {
|
|||||||
// requirement's name — everything providing `reverse-proxy` understands the same shape, which
|
// requirement's name — everything providing `reverse-proxy` understands the same shape, which
|
||||||
// is what makes swapping one for another cost nothing.
|
// is what makes swapping one for another cost nothing.
|
||||||
Values map[string]any `json:"values"`
|
Values map[string]any `json:"values"`
|
||||||
|
// Derived is what this provider's own definition said it derives for this consumer, already
|
||||||
|
// derived (novox/hq ADR 0188).
|
||||||
|
//
|
||||||
|
// **The provider is told, rather than recomputing it.** A served value may name the consumer's
|
||||||
|
// identity — a bucket named for who is asking, a database prefixed with it — and before this
|
||||||
|
// the rule lived twice: once in the provisioner's code, once transcribed into every consumer's
|
||||||
|
// definition. The mesh fills the provider's own statement here and delivers the same filled
|
||||||
|
// value to the consumer, so the two cannot disagree: there is no second computation to
|
||||||
|
// disagree with.
|
||||||
|
//
|
||||||
|
// Only the keys that are per-consumer. The rest of what the provider serves is the same for
|
||||||
|
// everyone and is in its own definition, where it already is.
|
||||||
|
Derived map[string]any `json:"derived,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// grantPath is where one consumer's sealed credential lands on the providing machine.
|
// grantPath is where one consumer's sealed credential lands on the providing machine.
|
||||||
@@ -1178,12 +1231,17 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
|||||||
// told about it and withdraws the login on its next pass.
|
// told about it and withdraws the login on its next pass.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
as := holderAs(ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)), g.Local)
|
||||||
|
derived, err := r.derivedFor(g.Provision, as, settings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
out[g.Provision] = append(out[g.Provision], Contribution{
|
out[g.Provision] = append(out[g.Provision], Contribution{
|
||||||
From: g.From, Node: g.Consumer, At: g.At, Values: g.Values,
|
From: g.From, Node: g.Consumer, At: g.At, Values: g.Values, Derived: derived,
|
||||||
// One holder per local name: the identity the consumer is known by, and the local name
|
// One holder per local name: the identity the consumer is known by, and the local name
|
||||||
// after it where the module keeps several (ADR 0094). Not a login any backend checks —
|
// after it where the module keeps several (ADR 0094). Not a login any backend checks —
|
||||||
// a secret is not a login — so the identity limit does not apply to the suffix.
|
// a secret is not a login — so the identity limit does not apply to the suffix.
|
||||||
As: holderAs(ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)), g.Local),
|
As: as,
|
||||||
Secret: grantPath(directories[g.Provision], g.Consumer, holderAs(g.From, g.Local)),
|
Secret: grantPath(directories[g.Provision], g.Consumer, holderAs(g.From, g.Local)),
|
||||||
})
|
})
|
||||||
if granted[g.Provision] == nil {
|
if granted[g.Provision] == nil {
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What a provider derives for each consumer, said once and delivered to both ends
|
||||||
|
// (novox/hq ADR 0188, issue 124).
|
||||||
|
//
|
||||||
|
// The failure these are written against: the object store's provisioner derived each consumer's
|
||||||
|
// bucket from the login the mesh minted, in its own code, and the mesh had no channel to tell the
|
||||||
|
// consumer which bucket that was — so all three consumers wrote the answer into their own
|
||||||
|
// definitions by hand. Two were right. One named a predecessor's bucket and would have
|
||||||
|
// authenticated successfully and been refused on every object. Each of them also named the
|
||||||
|
// machine the module happens to run on, which a definition may not do.
|
||||||
|
|
||||||
|
// store is an object store in the shape minio has: it serves a region and a port to everyone, and
|
||||||
|
// a bucket named for whoever is asking.
|
||||||
|
func store() Manifest {
|
||||||
|
return Manifest{
|
||||||
|
Module: "store", Version: "1",
|
||||||
|
Provides: FromAnywhere("s3-bucket"),
|
||||||
|
Listens: []Listening{{Port: 9000, Protocol: "tcp", From: FromMesh}},
|
||||||
|
Serves: map[string]map[string]any{"s3-bucket": {
|
||||||
|
"region": "eu-west",
|
||||||
|
"bucket": "${consumer:as:dns}",
|
||||||
|
}},
|
||||||
|
Receives: map[string]string{"s3-bucket": "/var/lib/store/grants/mesh.json"},
|
||||||
|
Grants: map[string]string{"s3-bucket": "/var/lib/store/grants"},
|
||||||
|
Resources: []map[string]any{{
|
||||||
|
"id": "server", "type": "container", "name": "store", "ports": []any{"9000"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// files is a consumer that writes the bucket into its own configuration — which is the thing it
|
||||||
|
// could not do before, and had to transcribe.
|
||||||
|
func files() Manifest {
|
||||||
|
return Manifest{
|
||||||
|
Module: "files", Version: "1", Slug: "files",
|
||||||
|
Requires: []string{"s3-bucket"},
|
||||||
|
Binds: map[string]string{"s3-bucket": "/var/lib/files/store.json"},
|
||||||
|
Secrets: map[string]string{"s3-bucket": "/var/lib/files/store.secret"},
|
||||||
|
Resources: []map[string]any{{
|
||||||
|
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
||||||
|
"content": "BUCKET=${bound:s3-bucket:bucket}\nREGION=${bound:s3-bucket:region}\n",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pics is a second consumer of the same provider on the same machine: two derivations, neither
|
||||||
|
// the other's.
|
||||||
|
func pics() Manifest {
|
||||||
|
return Manifest{
|
||||||
|
Module: "pics", Version: "1", Slug: "pics",
|
||||||
|
Requires: []string{"s3-bucket"},
|
||||||
|
Binds: map[string]string{"s3-bucket": "/var/lib/pics/store.json"},
|
||||||
|
Secrets: map[string]string{"s3-bucket": "/var/lib/pics/store.secret"},
|
||||||
|
Resources: []map[string]any{{
|
||||||
|
"id": "env", "type": "file", "path": "/var/lib/pics/env", "mode": "0600",
|
||||||
|
"content": "BUCKET=${bound:s3-bucket:bucket}\n",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three places the derived value lands must agree, because agreeing is the whole point: the
|
||||||
|
// consumer's own file, the binding it reads as JSON, and the provider's contributions entry.
|
||||||
|
func TestADerivedValueReachesBothEndsAndAgrees(t *testing.T) {
|
||||||
|
r, err := Resolve(shelf(store(), files()), []string{"store", "files"}, reachable(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := r.Declaration(Rendering{Grants: []Grant{{
|
||||||
|
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||||
|
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
||||||
|
}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mesh minted this identity for the consumer; the bucket is that identity as a DNS label.
|
||||||
|
// Derived here with the mesh's own function, so the test cannot agree with a wrong rule.
|
||||||
|
as := ConsumerIdentity("workstation", IdentitySource("files", "files"))
|
||||||
|
want := strings.ReplaceAll(as, "_", "-")
|
||||||
|
if want == as || !strings.Contains(as, "_") {
|
||||||
|
t.Fatalf("the mesh's identity %q has no separator to rewrite; this test proves nothing", as)
|
||||||
|
}
|
||||||
|
|
||||||
|
env := fileNamed(out, "files.env")
|
||||||
|
if env == nil {
|
||||||
|
t.Fatalf("the consumer was given no file: %v", out)
|
||||||
|
}
|
||||||
|
if got := env["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") {
|
||||||
|
t.Errorf("the consumer's own file was not told the bucket:\n%s\nwant BUCKET=%s", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding := fileNamed(out, "files.bound-s3-bucket")
|
||||||
|
if binding == nil {
|
||||||
|
t.Fatalf("the consumer was given no binding: %v", out)
|
||||||
|
}
|
||||||
|
var said struct {
|
||||||
|
Serves map[string]any `json:"serves"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(binding["content"].(string)), &said); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if said.Serves["bucket"] != want {
|
||||||
|
t.Errorf("the binding says the bucket is %q, want %q", said.Serves["bucket"], want)
|
||||||
|
}
|
||||||
|
// And what is the same for everybody is still the same for everybody.
|
||||||
|
if said.Serves["region"] != "eu-west" {
|
||||||
|
t.Errorf("the binding lost what the provider serves to all: %v", said.Serves)
|
||||||
|
}
|
||||||
|
|
||||||
|
given := storeGrants(t, out)
|
||||||
|
if len(given) != 1 {
|
||||||
|
t.Fatalf("the provider was told about %d consumer(s): %v", len(given), given)
|
||||||
|
}
|
||||||
|
if given[0].Derived["bucket"] != want {
|
||||||
|
t.Errorf("the provider was told the bucket is %v, and the consumer was told %q — "+
|
||||||
|
"the two ends disagree, which is the whole failure", given[0].Derived["bucket"], want)
|
||||||
|
}
|
||||||
|
// Only the per-consumer half. The region is the same for everyone and is already in the
|
||||||
|
// provider's own definition; repeating it here would be a copy to go stale.
|
||||||
|
if _, carried := given[0].Derived["region"]; carried {
|
||||||
|
t.Errorf("the provider was handed back what it already says for everyone: %v", given[0].Derived)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two consumers of one provider on one machine get two buckets, and neither gets the other's.
|
||||||
|
func TestTwoConsumersOfOneProviderGetTheirOwnDerivation(t *testing.T) {
|
||||||
|
r, err := Resolve(shelf(store(), files(), pics()),
|
||||||
|
[]string{"store", "files", "pics"}, reachable(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := r.Declaration(Rendering{Grants: []Grant{
|
||||||
|
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||||
|
Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
||||||
|
{Provision: "s3-bucket", Consumer: "workstation", From: "pics", Slug: "pics",
|
||||||
|
Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
forFiles := strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("files", "files")), "_", "-")
|
||||||
|
forPics := strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("pics", "pics")), "_", "-")
|
||||||
|
if forFiles == forPics {
|
||||||
|
t.Fatal("the two consumers were given the same identity; this test proves nothing")
|
||||||
|
}
|
||||||
|
if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+forFiles+"\n") {
|
||||||
|
t.Errorf("files was not given its own bucket:\n%s", got)
|
||||||
|
}
|
||||||
|
if got := fileNamed(out, "pics.env")["content"].(string); !strings.Contains(got, "BUCKET="+forPics+"\n") {
|
||||||
|
t.Errorf("pics was not given its own bucket:\n%s", got)
|
||||||
|
}
|
||||||
|
var buckets []any
|
||||||
|
for _, g := range storeGrants(t, out) {
|
||||||
|
buckets = append(buckets, g.Derived["bucket"])
|
||||||
|
}
|
||||||
|
if len(buckets) != 2 || buckets[0] == buckets[1] {
|
||||||
|
t.Errorf("the provider was told %v; it must be told one bucket per consumer", buckets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An operator may still set what the provider serves, and the mesh still derives the rest: the
|
||||||
|
// setting is laid on first, then the consumer's half is filled.
|
||||||
|
func TestASettingComposesWithADerivedValue(t *testing.T) {
|
||||||
|
r, err := Resolve(shelf(store(), files()), []string{"store", "files"}, reachable(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := r.Declaration(Rendering{
|
||||||
|
Settings: SettingsBy{"store": {{From: "the operator",
|
||||||
|
Values: map[string]any{"bucket": "team-${consumer:as:dns}"}}}},
|
||||||
|
Grants: []Grant{{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||||
|
Values: map[string]any{}, Sealed: "c2VhbGVk"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := "team-" + strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("files", "files")), "_", "-")
|
||||||
|
if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") {
|
||||||
|
t.Errorf("the operator's prefix did not survive the derivation:\n%s\nwant BUCKET=%s", got, want)
|
||||||
|
}
|
||||||
|
if given := storeGrants(t, out); given[0].Derived["bucket"] != want {
|
||||||
|
t.Errorf("the provider was told %v, the consumer %q", given[0].Derived["bucket"], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fact or an alphabet the mesh does not have is refused where the definition is, not where a
|
||||||
|
// consumer happens to be resolved — and the refusal says what may be said instead.
|
||||||
|
func TestAServedValueNamingSomethingTheMeshDoesNotHaveIsRefused(t *testing.T) {
|
||||||
|
for _, c := range []struct{ value, says string }{
|
||||||
|
{"${consumer:node}", "as"},
|
||||||
|
{"${consumer:as:punycode}", "dns"},
|
||||||
|
} {
|
||||||
|
m := store()
|
||||||
|
m.Serves["s3-bucket"]["bucket"] = c.value
|
||||||
|
raw, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = ParseManifest(raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("%s was accepted", c.value)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), c.value) {
|
||||||
|
t.Errorf("the refusal of %s does not quote it: %v", c.value, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), c.says) {
|
||||||
|
t.Errorf("the refusal of %s does not say what may be said (%q): %v", c.value, c.says, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `dns` is checked against an identity the mesh actually mints, not an invented string.
|
||||||
|
func TestTheDNSAlphabetIsTheMintedIdentityWithItsSeparatorRewritten(t *testing.T) {
|
||||||
|
as := ConsumerIdentity("anchor", IdentitySource("ncloud", "nextcloud"))
|
||||||
|
if err := CheckIdentity("anchor", IdentitySource("ncloud", "nextcloud")); err != nil {
|
||||||
|
t.Fatalf("the mesh would not mint this identity at all: %v", err)
|
||||||
|
}
|
||||||
|
label := asDNSLabel(as)
|
||||||
|
if strings.Contains(label, "_") {
|
||||||
|
t.Errorf("%q is not a DNS label", label)
|
||||||
|
}
|
||||||
|
if strings.ReplaceAll(label, "-", "_") != as {
|
||||||
|
t.Errorf("%q is not %q with its separator rewritten", label, as)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The check that would have caught the one wrong instance: a consumer that writes the derived
|
||||||
|
// value into its own definition instead of asking for it is refused, whether it transcribed the
|
||||||
|
// right answer or a predecessor's.
|
||||||
|
func TestAConsumerThatTranscribesWhatItsProviderDerivesIsRefused(t *testing.T) {
|
||||||
|
as := ConsumerIdentity("workstation", IdentitySource("files", "files"))
|
||||||
|
transcribed := strings.ReplaceAll(as, "_", "-")
|
||||||
|
|
||||||
|
m := files()
|
||||||
|
m.Resources = []map[string]any{{
|
||||||
|
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
||||||
|
// Exactly what the provider will create — correct today, and a copy of a rule that is
|
||||||
|
// not this module's.
|
||||||
|
"content": "BUCKET=" + transcribed + "\n",
|
||||||
|
}}
|
||||||
|
r, err := Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = r.Declaration(Rendering{Grants: []Grant{{
|
||||||
|
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||||
|
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
||||||
|
}}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a definition holding its own copy of the provider's naming rule was accepted")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "${bound:s3-bucket:bucket}") {
|
||||||
|
t.Errorf("the refusal does not say what to write instead: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a constant the provider serves to everyone is not a transcription: repeating it is
|
||||||
|
// redundant, not wrong, and refusing it would be the mesh policing style.
|
||||||
|
m.Resources = []map[string]any{{
|
||||||
|
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
||||||
|
"content": "REGION=eu-west\n",
|
||||||
|
}}
|
||||||
|
r, err = Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := r.Declaration(Rendering{Grants: []Grant{{
|
||||||
|
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
||||||
|
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
||||||
|
}}}); err != nil {
|
||||||
|
t.Errorf("a value the provider serves to everyone was judged a transcription: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeGrants(t *testing.T, out []map[string]any) []Contribution {
|
||||||
|
t.Helper()
|
||||||
|
for _, r := range out {
|
||||||
|
if r["path"] != "/var/lib/store/grants/mesh.json" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Given []Contribution `json:"given"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return parsed.Given
|
||||||
|
}
|
||||||
|
t.Fatalf("the provider was given no contributions file: %v", out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -444,6 +444,18 @@ func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int,
|
|||||||
b.WriteString("\t\t# this machine's own guests reaching outward: not a port opened to anybody\n")
|
b.WriteString("\t\t# this machine's own guests reaching outward: not a port opened to anybody\n")
|
||||||
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } accept\n", inward))
|
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } accept\n", inward))
|
||||||
}
|
}
|
||||||
|
// **The mesh passing through, not arriving.** A machine the mesh routes through — the hub, for
|
||||||
|
// every path between machines that are not co-located (novox/hq ADR 0007) — relays a packet that
|
||||||
|
// came in on the tunnel and leaves on it again, addressed to another machine of the mesh. That is
|
||||||
|
// no port of this machine's: the machine it is for filters it against its own rules. Without
|
||||||
|
// this, the chain below judged a relayed packet by this machine's own published ports, so two
|
||||||
|
// machines behind the hub reached each other only on ports the hub happened to publish for itself
|
||||||
|
// (novox/hq issue 196). In and out on the tunnel both: a packet off the tunnel for this machine's
|
||||||
|
// own containers leaves by a bridge, and still meets the rules below.
|
||||||
|
if tunnel != "" {
|
||||||
|
b.WriteString("\t\t# the mesh passing through to another of its machines, which filters it itself\n")
|
||||||
|
b.WriteString(fmt.Sprintf("\t\tiifname %q oifname %q accept\n", tunnel, tunnel))
|
||||||
|
}
|
||||||
|
|
||||||
if len(rules) > 0 {
|
if len(rules) > 0 {
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|||||||
@@ -887,3 +887,34 @@ func TestAPublicPortNeedsNoGuestLine(t *testing.T) {
|
|||||||
t.Fatalf("a public port was given a guest line it does not need:\n%s", nft)
|
t.Fatalf("a public port was given a guest line it does not need:\n%s", nft)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **The hub relays the mesh** (novox/hq ADR 0007, issue 196). Two machines that are not co-located
|
||||||
|
// reach each other through the hub, so the hub forwards a packet that arrives on the tunnel and
|
||||||
|
// leaves on it. The forward chain judged that packet by the hub's own published ports, and two
|
||||||
|
// machines behind the hub reached each other only on the ports the hub happened to publish.
|
||||||
|
//
|
||||||
|
// Measured: from one home machine to another through the hub, 17 of 55 ports answered, and they
|
||||||
|
// were exactly the hub's own; the SYN for the rest never left the hub.
|
||||||
|
func TestTheMeshPassingThroughIsRelayedNotJudgedAsThisMachines(t *testing.T) {
|
||||||
|
nft := AsNftables(nil, []string{"10.42.0.1", "10.42.0.2"}, false, nil, []string{"eth0"}, "mesh0")
|
||||||
|
relay := `iifname "mesh0" oifname "mesh0" accept`
|
||||||
|
if !strings.Contains(chainBody(t, nft, "forward"), relay) {
|
||||||
|
t.Errorf("the forward chain does not relay the mesh through this machine:\n%s", chainBody(t, nft, "forward"))
|
||||||
|
}
|
||||||
|
// Relaying is not receiving: nothing in the input chain opens because of it.
|
||||||
|
if strings.Contains(chainBody(t, nft, "input"), "oifname") {
|
||||||
|
t.Errorf("the input chain names an outgoing interface, which no packet for this machine has:\n%s",
|
||||||
|
chainBody(t, nft, "input"))
|
||||||
|
}
|
||||||
|
// And off the tunnel into this machine's own containers is still judged: the tunnel is not
|
||||||
|
// accepted wholesale, only in and out on it.
|
||||||
|
if strings.Contains(chainBody(t, nft, "forward"), `iifname "mesh0" accept`) {
|
||||||
|
t.Errorf("the forward chain accepts everything off the tunnel:\n%s", chainBody(t, nft, "forward"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A machine with no tunnel relays nothing, and names no interface it does not have.
|
||||||
|
alone := AsNftables(nil, nil, false, nil, []string{"eth0"}, "")
|
||||||
|
if strings.Contains(alone, "oifname") {
|
||||||
|
t.Errorf("a machine with no tunnel was given a relay rule:\n%s", alone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -102,8 +102,11 @@ func accountHomeOf(account, home string) string {
|
|||||||
func machineInto(resource map[string]any, facts map[string]string, module string) error {
|
func machineInto(resource map[string]any, facts map[string]string, module string) error {
|
||||||
// Content, and now the path and owner too: a module that writes into a person's home names it
|
// Content, and now the path and owner too: a module that writes into a person's home names it
|
||||||
// with ${machine:account-home} and ${machine:account}, which it cannot know until assigned
|
// with ${machine:account-home} and ${machine:account}, which it cannot know until assigned
|
||||||
// (novox/hq to-be 29), the same reason its content names ${machine:address}.
|
// (novox/hq to-be 29), the same reason its content names ${machine:address}. And the name a
|
||||||
for _, field := range []string{"path", "owner", "content"} {
|
// `user` shape sets the login shell of, and the user a user-scoped unit or a process runs as:
|
||||||
|
// the shell module makes the operator's account its holder's login shell, and the desktop's
|
||||||
|
// watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person.
|
||||||
|
for _, field := range []string{"path", "owner", "content", "name", "user"} {
|
||||||
s, ok := resource[field].(string)
|
s, ok := resource[field].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1360,6 +1360,10 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
"%s serves %q to whoever requires it, and does not provide it", m.Module, to))
|
"%s serves %q to whoever requires it, and does not provide it", m.Module, to))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A served value may be derived for the consumer it is served to (novox/hq ADR 0188). Read
|
||||||
|
// here, where the definition is, rather than when somebody first requires it: a rule that
|
||||||
|
// would be refused at the first consumer is wrong from the moment it is written.
|
||||||
|
problems = append(problems, CheckServes(m)...)
|
||||||
for to, where := range m.Binds {
|
for to, where := range m.Binds {
|
||||||
if !placedOrAbsolute(where) {
|
if !placedOrAbsolute(where) {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
@@ -1667,6 +1671,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
problems = append(problems, m.undeclaredMounts()...)
|
problems = append(problems, m.undeclaredMounts()...)
|
||||||
problems = append(problems, m.unknownDirRefs()...)
|
problems = append(problems, m.unknownDirRefs()...)
|
||||||
problems = append(problems, m.unknownAccessRefs()...)
|
problems = append(problems, m.unknownAccessRefs()...)
|
||||||
|
problems = append(problems, m.jailProblems()...)
|
||||||
|
|
||||||
for i, r := range m.Resources {
|
for i, r := range m.Resources {
|
||||||
id, _ := r["id"].(string)
|
id, _ := r["id"].(string)
|
||||||
@@ -1769,6 +1774,46 @@ func (m Manifest) MachineSide(port int) (at int, mayAssign bool) {
|
|||||||
var facilitiesOf = map[string][]string{
|
var facilitiesOf = map[string][]string{
|
||||||
// Both spellings: /var/run is a link to /run on every machine the mesh runs on.
|
// Both spellings: /var/run is a link to /run on every machine the mesh runs on.
|
||||||
"container-runtime": {"/var/run/docker.sock", "/run/docker.sock"},
|
"container-runtime": {"/var/run/docker.sock", "/run/docker.sock"},
|
||||||
|
// The virtualisation daemon's socket, for the lab (novox/hq ADR 0172): it raises machines there.
|
||||||
|
"virtualisation": {"/var/lib/incus/unix.socket"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// jailProblems is every jail this module declares that the machine's intrusion prevention would
|
||||||
|
// refuse (novox/hq ADR 0179).
|
||||||
|
//
|
||||||
|
// **Because one bad pattern stops every jail, not its own.** fail2ban expands `<HOST>` into a named
|
||||||
|
// capture group, so a pattern naming it twice is a duplicate group name, and the daemon refuses the
|
||||||
|
// whole configuration and exits — the machine keeps no bans at all, for any jail, including the one
|
||||||
|
// watching its ssh. Caught live on the control node the day this was built, where a proxy's pattern
|
||||||
|
// matched two shapes of refusal in one line. A pattern matches one shape; several shapes are several
|
||||||
|
// patterns, one per line, as fail2ban's own filters are written.
|
||||||
|
func (m Manifest) jailProblems() []string {
|
||||||
|
var problems []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, j := range m.Jails {
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(j.Name) == "":
|
||||||
|
problems = append(problems, m.Module+" declares a jail with no name")
|
||||||
|
case seen[j.Name]:
|
||||||
|
problems = append(problems, m.Module+" declares two jails called "+strconv.Quote(j.Name))
|
||||||
|
}
|
||||||
|
seen[j.Name] = true
|
||||||
|
if strings.TrimSpace(j.Failregex) == "" {
|
||||||
|
problems = append(problems, m.Module+"'s jail "+strconv.Quote(j.Name)+" says nothing a failed attempt looks like")
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(j.Failregex, "\n") {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n := strings.Count(line, "<HOST>"); n > 1 {
|
||||||
|
problems = append(problems, fmt.Sprintf("%s's jail %s names <HOST> %d times in one pattern; "+
|
||||||
|
"fail2ban reads it as one capture group and refuses the whole configuration, so the machine "+
|
||||||
|
"keeps no bans at all — write one pattern per shape, each naming <HOST> once",
|
||||||
|
m.Module, strconv.Quote(j.Name), n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
// undeclaredMounts is every bind-mount source no declaration covers — see the check above.
|
// undeclaredMounts is every bind-mount source no declaration covers — see the check above.
|
||||||
@@ -1810,6 +1855,12 @@ func (m Manifest) undeclaredMounts() []string {
|
|||||||
claim(p)
|
claim(p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The file a filter module's rule set is written to is declared by `filtering.into`: the mesh
|
||||||
|
// writes it, the module loads it, and the module's runtime may read it back to reload the
|
||||||
|
// mesh's own table (novox/hq ADR 0170).
|
||||||
|
if m.Filtering != nil {
|
||||||
|
claim(m.Filtering.Into)
|
||||||
|
}
|
||||||
// Under a declared directory is declared: a module that says where its data lives has said so
|
// Under a declared directory is declared: a module that says where its data lives has said so
|
||||||
// for what it puts inside.
|
// for what it puts inside.
|
||||||
covers := func(path string) bool {
|
covers := func(path string) bool {
|
||||||
|
|||||||
@@ -98,3 +98,13 @@ func TestAMountOfABoundFactIsAccepted(t *testing.T) {
|
|||||||
t.Fatalf("a mount of the file the mesh writes a binding to was refused: %v", err)
|
t.Fatalf("a mount of the file the mesh writes a binding to was refused: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The file a filter module's rule set is written to is declared by `filtering.into` (novox/hq ADR
|
||||||
|
// 0169): the module's runtime mounts it to reload the mesh's own table, and nothing else declares it.
|
||||||
|
func TestAMountOfTheFilterFileIsDeclaredByFilteringInto(t *testing.T) {
|
||||||
|
_, err := ParseManifest([]byte(`{"module":"nftables","filtering":{"into":"/etc/nftables.conf"},` +
|
||||||
|
`"resources":[` + strings.Replace(aContainerMounting, "%s", "/etc/nftables.conf", 1) + `]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a filter module mounting its own filter file was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A `user` shape and a user-scoped unit name the operator account the way a home file does
|
||||||
|
// (novox/hq ADR 0176, ADR 0177): with ${machine:account}, resolved when the module is assigned.
|
||||||
|
func TestAUserShapeAndAUserScopedUnitNameTheAccount(t *testing.T) {
|
||||||
|
facts := map[string]string{"account": "ops", "account-home": "/home/ops"}
|
||||||
|
login := map[string]any{"type": "user", "id": "login", "name": "${machine:account}", "shell": "/usr/bin/zsh"}
|
||||||
|
if err := machineInto(login, facts, "zsh"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if login["name"] != "ops" {
|
||||||
|
t.Fatalf("the user shape did not learn the account: %v", login["name"])
|
||||||
|
}
|
||||||
|
watcher := map[string]any{"type": "service", "id": "watcher", "unit": "i3-reload-watcher.service",
|
||||||
|
"scope": "user", "user": "${machine:account}"}
|
||||||
|
if err := machineInto(watcher, facts, "i3"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if watcher["user"] != "ops" {
|
||||||
|
t.Fatalf("the user-scoped unit did not learn the account: %v", watcher["user"])
|
||||||
|
}
|
||||||
|
// A machine with no operator account refuses rather than writing the literal.
|
||||||
|
err := machineInto(map[string]any{"type": "user", "id": "login", "name": "${machine:account}"},
|
||||||
|
map[string]string{"address": "10.0.0.1"}, "zsh")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "${machine:account}") {
|
||||||
|
t.Fatalf("a user shape on a machine with no account was not refused by name: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The service manager is a seat of the mesh's own with the unit verbs as its contract (novox/hq
|
||||||
|
// ADR 0177): every verb described, with a schema, taking a scope.
|
||||||
|
func TestTheServiceManagerSeatServesTheUnitVerbs(t *testing.T) {
|
||||||
|
seat, ok := SeatNamed("node-service-manager")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("node-service-manager is not a seat the mesh defines")
|
||||||
|
}
|
||||||
|
if seat.Scope != ScopeNode {
|
||||||
|
t.Fatalf("the service manager is a role each machine has once, and the seat is %s-scoped", seat.Scope)
|
||||||
|
}
|
||||||
|
want := []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal"}
|
||||||
|
var got []string
|
||||||
|
for _, v := range seat.Serves {
|
||||||
|
got = append(got, v.Name)
|
||||||
|
if v.Description == "" || v.Input == nil {
|
||||||
|
t.Fatalf("%s is promised without a description or a schema", v.Name)
|
||||||
|
}
|
||||||
|
props, _ := v.Input["properties"].(map[string]any)
|
||||||
|
if _, has := props["scope"]; !has {
|
||||||
|
t.Fatalf("%s takes no scope, and a user unit could not be asked for", v.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("the seat serves %v, not %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What a provider receives is composed once, and issued twice: as its received file, and in its
|
||||||
|
// membership on the bus (novox/hq ADR 0167). The two are the same list, so a proxy reading the bus
|
||||||
|
// and one reading the file serve the same routes — including the port the machine published, which
|
||||||
|
// is the same-node fix the file already carries.
|
||||||
|
func TestWhatAProviderReceivesIsTheSameOnTheBusAsInItsFile(t *testing.T) {
|
||||||
|
gitea := Manifest{
|
||||||
|
Module: "gitea", Version: "1",
|
||||||
|
Listens: []Listening{{Port: 3000, Protocol: "tcp", From: FromMesh}},
|
||||||
|
Contributes: map[string]map[string]any{"route": {"label": "git", "port": 3000}},
|
||||||
|
Resources: []map[string]any{{
|
||||||
|
"id": "server", "type": "container", "name": "gitea", "ports": []any{"3000"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
r, err := Resolve(shelf(gitea, routeProxy(), stepCA()),
|
||||||
|
[]string{"gitea", "route-proxy", "step-ca"}, reachable(), World{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
composed, err := r.Compose(Rendering{Ports: map[string]map[int]int{"gitea": {3000: 20000}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file := fileNamed(composed.Resources, "route-proxy.received-route")
|
||||||
|
if file == nil {
|
||||||
|
t.Fatal("the proxy was given no routes file")
|
||||||
|
}
|
||||||
|
var written struct {
|
||||||
|
Given []Contribution `json:"given"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(file["content"].(string)), &written); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
issued, said := composed.Received["route-proxy"]["route"]
|
||||||
|
if !said {
|
||||||
|
t.Fatalf("nothing is issued for the proxy to receive on the bus: %v", composed.Received)
|
||||||
|
}
|
||||||
|
// Compared as JSON, which is what both are once they leave the controller.
|
||||||
|
a, _ := json.Marshal(written.Given)
|
||||||
|
b, _ := json.Marshal(issued)
|
||||||
|
var fromFile, fromBus any
|
||||||
|
_ = json.Unmarshal(a, &fromFile)
|
||||||
|
_ = json.Unmarshal(b, &fromBus)
|
||||||
|
if !reflect.DeepEqual(fromFile, fromBus) {
|
||||||
|
t.Errorf("the bus and the file disagree about the routes:\nfile %s\nbus %s", a, b)
|
||||||
|
}
|
||||||
|
if len(issued) != 1 {
|
||||||
|
t.Fatalf("expected one route on the bus, got %v", issued)
|
||||||
|
}
|
||||||
|
if port, ok := asPort(issued[0].Values["port"]); !ok || port != 20000 {
|
||||||
|
t.Errorf("the bus carries a port nothing listens on: %v", issued[0].Values["port"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module that receives nothing is issued nothing to receive.
|
||||||
|
if _, any := composed.Received["gitea"]; any {
|
||||||
|
t.Errorf("a module that receives nothing was issued something: %v", composed.Received["gitea"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,9 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
|||||||
}
|
}
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n",
|
"\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n",
|
||||||
"\nlisten-address=127.0.0.1\n", "\nlisten-address=${machine:address}\n", "\nbind-dynamic\n",
|
// Loopback is the mesh-wide setting's default; a machine answering its own LAN adds its
|
||||||
|
// address there (novox/hq issue 198).
|
||||||
|
"\nlisten-address=${setting:listen-addresses}\n", "\nlisten-address=${machine:address}\n", "\nbind-dynamic\n",
|
||||||
"\ndomain-needed\n", "\nbogus-priv\n",
|
"\ndomain-needed\n", "\nbogus-priv\n",
|
||||||
"\nconf-file=" + m.Facts["node-zones"].Path + "\n",
|
"\nconf-file=" + m.Facts["node-zones"].Path + "\n",
|
||||||
} {
|
} {
|
||||||
@@ -113,7 +115,8 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
|||||||
// issue 111) — the resolver's zones read only the second, and in this scenario the two
|
// issue 111) — the resolver's zones read only the second, and in this scenario the two
|
||||||
// happen to be the same map, since nothing routed is part of it.
|
// happen to be the same map, since nothing routed is part of it.
|
||||||
Names: twoMachines, Machines: twoMachines, Suffix: "internal",
|
Names: twoMachines, Machines: twoMachines, Suffix: "internal",
|
||||||
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
||||||
|
Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -207,9 +210,16 @@ func TestTheResolverOnAMachineOffTheNetworkIsRefused(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err = got.Declaration(Rendering{Names: twoMachines, Suffix: "internal",
|
// Left out of the declaration and said, rather than composed listening nowhere: a module that
|
||||||
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}})
|
// cannot compose on a machine is kept as it is there, with the reason (hq ADR 0163).
|
||||||
if err == nil || !strings.Contains(err.Error(), "${machine:address}") {
|
composed, err := got.Compose(Rendering{Names: twoMachines, Suffix: "internal",
|
||||||
t.Fatalf("a machine off the network was composed a resolver, or refused for another reason: %v", err)
|
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
||||||
|
Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}}})
|
||||||
|
if err == nil && !strings.Contains(composed.LeftOut["dnsmasq"], "${machine:address}") {
|
||||||
|
t.Fatalf("a machine off the network was composed a resolver, or left out for another reason: %v",
|
||||||
|
composed.LeftOut)
|
||||||
|
}
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "${machine:address}") {
|
||||||
|
t.Fatalf("a machine off the network was refused for another reason: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,8 +99,48 @@ var defaultSeats = []Seat{
|
|||||||
{Name: "mesh-build-machine", Scope: ScopeMesh,
|
{Name: "mesh-build-machine", Scope: ScopeMesh,
|
||||||
Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0121"},
|
Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0121"},
|
||||||
{Name: "node-dns-resolver", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
{Name: "node-dns-resolver", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
||||||
{Name: "node-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
// The intrusion prevention's verbs (novox/hq ADR 0179): what a person asks a machine's ban list
|
||||||
{Name: "node-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
// whatever keeps it — who is banned and why, ban one address, let one go. Every holder serves all
|
||||||
|
// four; the jails themselves are composed from the modules the machine runs (to-be 31).
|
||||||
|
{Name: "node-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0121",
|
||||||
|
Serves: []Verb{
|
||||||
|
{Name: "status", Description: "Every jail on this machine with how many it is watching and " +
|
||||||
|
"holding now, and the totals since the jail started; one jail's detail when named.",
|
||||||
|
Input: schema(map[string]string{"jail": "one jail (optional)"}, nil)},
|
||||||
|
{Name: "banned", Description: "Every address banned on this machine right now, with the jail " +
|
||||||
|
"that holds it and when the ban ends.",
|
||||||
|
Input: schema(map[string]string{"jail": "one jail (optional)"}, nil)},
|
||||||
|
{Name: "ban", Description: "Ban one address in one jail now, for the jail's ban time — an " +
|
||||||
|
"operator's act on the live ban list, which the mesh never writes itself.",
|
||||||
|
Input: schema(map[string]string{"ip": "the address", "jail": "the jail to hold it"}, []string{"ip", "jail"})},
|
||||||
|
{Name: "unban", Description: "Let one address go, from one jail or from every jail when none is named.",
|
||||||
|
Input: schema(map[string]string{"ip": "the address", "jail": "one jail (optional)"}, []string{"ip"})},
|
||||||
|
}},
|
||||||
|
// The packet filter's verbs (novox/hq ADR 0170): what a person asks a machine's filter whatever
|
||||||
|
// filter answers — the rules as enforced, reload the mesh's own, remove one thing the mesh did
|
||||||
|
// not write. Every holder serves all three; what differs by filter is the holder's own tools.
|
||||||
|
{Name: "node-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0121",
|
||||||
|
Serves: []Verb{
|
||||||
|
{Name: "rules", Description: "The packet filter as this machine enforces it now: the nftables " +
|
||||||
|
"ruleset and, where the tool exists, the legacy filter's listings. Narrowed to one table or " +
|
||||||
|
"chain when asked.",
|
||||||
|
Input: schema(map[string]string{"table": "one nftables table, as `family name` (optional)",
|
||||||
|
"chain": "one chain of that table (optional)"}, nil)},
|
||||||
|
{Name: "reload", Description: "Load the mesh's own filter again from the file the mesh writes, " +
|
||||||
|
"and answer with the mesh's table as loaded.",
|
||||||
|
Input: schema(map[string]string{}, nil)},
|
||||||
|
{Name: "remove", Description: "Remove one rule set the mesh did not write, named exactly as the " +
|
||||||
|
"host reports it (novox/hq ADR 0168) — `chain X (iptables-legacy)` or `table ip6 filter, chain " +
|
||||||
|
"DOCKER-USER`. Refuses the mesh's tables, the runtime's own chains, a built-in chain and an " +
|
||||||
|
"active found firewall's chains. An operator's act, by name, never a flush.",
|
||||||
|
Input: schema(map[string]string{"where": "the rule set, as `node show` lists it"}, []string{"where"})},
|
||||||
|
}},
|
||||||
|
// The machine's service manager (novox/hq ADR 0177). The host applies every declared unit,
|
||||||
|
// system or user scope; the holder answers questions and operator acts about them, each verb
|
||||||
|
// taking the unit and an optional scope. The holder runs nothing of its own: its verbs are
|
||||||
|
// served by the node tools runtime (ADR 0175).
|
||||||
|
{Name: "node-service-manager", Scope: ScopeNode, Decision: "novox/hq ADR 0177",
|
||||||
|
Serves: serviceManagerVerbs()},
|
||||||
// Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client
|
// Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client
|
||||||
// model change, not a rename, so it stays until that is built.
|
// model change, not a rename, so it stays until that is built.
|
||||||
{Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
{Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
||||||
@@ -374,3 +414,36 @@ func SeatsWithAProtocol() []Seat {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serviceManagerVerbs is the contract every holder of node-service-manager serves (novox/hq ADR
|
||||||
|
// 0177): the units on the machine in both scopes, read and acted on by name. Every verb takes an
|
||||||
|
// optional scope — "system" when absent, "user" for the operator account's own manager — so a
|
||||||
|
// caller asks for a user unit the way it asks for a system one.
|
||||||
|
func serviceManagerVerbs() []Verb {
|
||||||
|
scoped := func(more map[string]string, required []string) map[string]any {
|
||||||
|
props := map[string]string{"scope": "\"system\" (the default) or \"user\": the operator account's own manager"}
|
||||||
|
for k, v := range more {
|
||||||
|
props[k] = v
|
||||||
|
}
|
||||||
|
return schema(props, required)
|
||||||
|
}
|
||||||
|
unit := map[string]string{"unit": "the unit's name, as the service manager knows it"}
|
||||||
|
return []Verb{
|
||||||
|
{Name: "units", Description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.",
|
||||||
|
Input: scoped(map[string]string{"pattern": "a glob the unit's name must match (optional)"}, nil)},
|
||||||
|
{Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.",
|
||||||
|
Input: scoped(unit, []string{"unit"})},
|
||||||
|
{Name: "start", Description: "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at the next apply.",
|
||||||
|
Input: scoped(unit, []string{"unit"})},
|
||||||
|
{Name: "stop", Description: "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state.",
|
||||||
|
Input: scoped(unit, []string{"unit"})},
|
||||||
|
{Name: "restart", Description: "Restart one unit.",
|
||||||
|
Input: scoped(unit, []string{"unit"})},
|
||||||
|
{Name: "enable", Description: "Make one unit start at boot (or at the account's login, in user scope).",
|
||||||
|
Input: scoped(unit, []string{"unit"})},
|
||||||
|
{Name: "disable", Description: "Stop one unit starting at boot (or at login, in user scope).",
|
||||||
|
Input: scoped(unit, []string{"unit"})},
|
||||||
|
{Name: "journal", Description: "The last lines of one unit's journal.",
|
||||||
|
Input: scoped(map[string]string{"unit": unit["unit"], "lines": "how many lines from the end (default 100)"}, []string{"unit"})},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
|||||||
delivered[s.Delivers] = s.Name
|
delivered[s.Delivers] = s.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(Seats()) != 15 {
|
// Sixteen since node-service-manager (novox/hq ADR 0177).
|
||||||
t.Errorf("the mesh defines %d seats rather than 14; the set is closed, so a change here is "+
|
if len(Seats()) != 16 {
|
||||||
|
t.Errorf("the mesh defines %d seats rather than 16; the set is closed, so a change here is "+
|
||||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,22 @@ var ControllerVerbs = []Verb{
|
|||||||
"node": "the machine that runs the module",
|
"node": "the machine that runs the module",
|
||||||
"module": "the module's name",
|
"module": "the module's name",
|
||||||
}, []string{"node", "module"})},
|
}, []string{"node", "module"})},
|
||||||
|
{Name: "settings", Description: "Set what an assignment is configured with: a module's settings for the whole mesh, " +
|
||||||
|
"or for one machine. Replaces that layer whole — what it does not name, it no longer sets — and takes effect " +
|
||||||
|
"at the next push. With clear, removes the layer and the module is back to what its definition says.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"module": "the module's name",
|
||||||
|
"values": "the settings as a JSON object, for set",
|
||||||
|
"node": "one machine; the whole mesh when absent",
|
||||||
|
"clear": "\"true\" to remove the layer instead of setting it",
|
||||||
|
}, []string{"module"})},
|
||||||
|
{Name: "command", Description: "Run one command line of the controller's own, as you would type it at its " +
|
||||||
|
"shell — `node account g14 jochen`, `node show ace`, `module list` — and answer what it printed. The " +
|
||||||
|
"generic verb beside the named ones (novox/hq ADR 0154): everything the binary can do, without a verb " +
|
||||||
|
"per command. Any node may call any tool (ADR 0175), so nothing is held back here.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"command": "the command line, as the controller's binary takes it; quotes group a word with spaces",
|
||||||
|
}, []string{"command"})},
|
||||||
{Name: "build", Description: "Have the build machine build a repository. Answers at once with the build's id: " +
|
{Name: "build", Description: "Have the build machine build a repository. Answers at once with the build's id: " +
|
||||||
"`builds` with that id follows it line by line, and the module is registered when the outcome comes.",
|
"`builds` with that id follows it line by line, and the module is registered when the outcome comes.",
|
||||||
Input: schema(map[string]string{
|
Input: schema(map[string]string{
|
||||||
|
|||||||
@@ -178,6 +178,103 @@ type Stray struct {
|
|||||||
Detail string `json:"detail,omitempty"`
|
Detail string `json:"detail,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A Filter is one place on a machine that refuses traffic, with its owner (novox/hq ADR 0168).
|
||||||
|
type Filter struct {
|
||||||
|
Where string `json:"where"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Refuses string `json:"refuses"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owners of a filter, as the host names them (ADR 0168).
|
||||||
|
const (
|
||||||
|
FilterMesh = "mesh"
|
||||||
|
FilterFoundFirewall = "found-firewall"
|
||||||
|
FilterRuntime = "runtime"
|
||||||
|
FilterBan = "ban"
|
||||||
|
FilterOther = "other"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FoundFirewall is the state of a converged machine's found firewall (ADR 0168): in force now or
|
||||||
|
// not, and how it came to be inactive.
|
||||||
|
type FoundFirewall struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
RetiredBy string `json:"retired_by,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtering is what a machine last said filters it (ADR 0168).
|
||||||
|
type Filtering struct {
|
||||||
|
Filters []Filter
|
||||||
|
FoundFirewall *FoundFirewall
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alone is whether the machine is filtered by the mesh alone: nothing in its list but the mesh's
|
||||||
|
// own, the runtime's plumbing and bans, and no found firewall in force.
|
||||||
|
func (f Filtering) Alone() bool {
|
||||||
|
for _, x := range f.Filters {
|
||||||
|
if x.Owner == FilterOther || x.Owner == FilterFoundFirewall {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f.FoundFirewall == nil || !f.FoundFirewall.Active
|
||||||
|
}
|
||||||
|
|
||||||
|
// Others is every filter that is neither the mesh's, the runtime's nor a ban.
|
||||||
|
func (f Filtering) Others() []Filter {
|
||||||
|
var out []Filter
|
||||||
|
for _, x := range f.Filters {
|
||||||
|
if x.Owner == FilterOther || x.Owner == FilterFoundFirewall {
|
||||||
|
out = append(out, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordFiltering keeps what a machine last said filters it, replacing what was there (ADR 0168).
|
||||||
|
func (i *Inventory) RecordFiltering(ctx context.Context, nodeID string, filters []Filter, found *FoundFirewall) error {
|
||||||
|
raw, err := json.Marshal(nonNil(filters))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var foundRaw any
|
||||||
|
if found != nil {
|
||||||
|
b, err := json.Marshal(found)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
foundRaw = string(b)
|
||||||
|
}
|
||||||
|
_, err = i.store.Pool().Exec(ctx,
|
||||||
|
`update node set filters = $2, found_firewall = $3 where id = $1`, nodeID, raw, foundRaw)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilteringOf is what a machine last said filters it; empty for a machine that never said.
|
||||||
|
func (i *Inventory) FilteringOf(ctx context.Context, name string) (Filtering, error) {
|
||||||
|
var filtersRaw, foundRaw []byte
|
||||||
|
err := i.store.Pool().QueryRow(ctx,
|
||||||
|
`select filters, found_firewall from node where name = $1`, name).Scan(&filtersRaw, &foundRaw)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Filtering{}, fmt.Errorf("%w: %s", ErrNoSuchNode, name)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Filtering{}, err
|
||||||
|
}
|
||||||
|
var out Filtering
|
||||||
|
if len(filtersRaw) > 0 {
|
||||||
|
if err := json.Unmarshal(filtersRaw, &out.Filters); err != nil {
|
||||||
|
return Filtering{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(foundRaw) > 0 {
|
||||||
|
out.FoundFirewall = &FoundFirewall{}
|
||||||
|
if err := json.Unmarshal(foundRaw, out.FoundFirewall); err != nil {
|
||||||
|
return Filtering{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Reach is one thing reachable on an adopted node: a listening socket or a published port.
|
// Reach is one thing reachable on an adopted node: a listening socket or a published port.
|
||||||
type Reach struct {
|
type Reach struct {
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- What filters a machine, with owners, as the host reports it with every apply (novox/hq ADR 0168):
|
||||||
|
-- every table and chain that refuses traffic — the mesh's, the found firewall's, the runtime's own,
|
||||||
|
-- a ban, or other — so the mesh says truthfully what filters a converged machine and names what it
|
||||||
|
-- did not write. And the state of the firewall a converged machine was found with: in force now or
|
||||||
|
-- not, and who retired it.
|
||||||
|
alter table node add column filters jsonb;
|
||||||
|
alter table node add column found_firewall jsonb;
|
||||||
@@ -305,6 +305,23 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// What filters the machine, and the state of its found firewall (novox/hq ADR 0168), whenever
|
||||||
|
// it says — every apply of a host that knows how, adopted or converged; never cleared by a
|
||||||
|
// report that carries none, which is every bare word that the node is there.
|
||||||
|
if len(report.Filters) > 0 || report.FoundFirewall != nil {
|
||||||
|
filters := make([]inventory.Filter, 0, len(report.Filters))
|
||||||
|
for _, f := range report.Filters {
|
||||||
|
filters = append(filters, inventory.Filter{Where: f.Where, Owner: f.Owner, Refuses: f.Refuses})
|
||||||
|
}
|
||||||
|
var found *inventory.FoundFirewall
|
||||||
|
if report.FoundFirewall != nil {
|
||||||
|
found = &inventory.FoundFirewall{Kind: report.FoundFirewall.Kind, Active: report.FoundFirewall.Active,
|
||||||
|
RetiredBy: report.FoundFirewall.RetiredBy}
|
||||||
|
}
|
||||||
|
if err := e.Inventory.RecordFiltering(ctx, node.ID, filters, found); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
// Which of its links face outside (novox/hq ADR 0140), whenever it says so. Recorded on every
|
// Which of its links face outside (novox/hq ADR 0140), whenever it says so. Recorded on every
|
||||||
// report that carries it, adopted or converged, because the filter the mesh composes is written
|
// report that carries it, adopted or converged, because the filter the mesh composes is written
|
||||||
// around it — and never cleared by a report that carries none, which is every bare word that the
|
// around it — and never cleared by a report that carries none, which is every bare word that the
|
||||||
|
|||||||
@@ -218,3 +218,45 @@ func TestWhatAnAdoptedNodeHoldsIsKeptAndAnAliveWordDoesNotWipeIt(t *testing.T) {
|
|||||||
t.Fatalf("a report from an adopted node holding nothing did not empty held: %+v", got)
|
t.Fatalf("a report from an adopted node holding nothing did not empty held: %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What filters a machine, and the state of its found firewall, are kept from every report that
|
||||||
|
// carries them and never cleared by one that does not (novox/hq ADR 0168).
|
||||||
|
func TestWhatFiltersAMachineIsKeptFromItsReport(t *testing.T) {
|
||||||
|
inv, _, _ := heardFrom(t, link.Report{
|
||||||
|
Node: "home-server", Applied: []string{"a"},
|
||||||
|
Filters: []link.Filter{
|
||||||
|
{Where: "table inet mesh, chain forward", Owner: "mesh", Refuses: "policy drop"},
|
||||||
|
{Where: "chain HAL-MESH-ONLY (iptables-legacy)", Owner: "other", Refuses: "-j DROP"},
|
||||||
|
},
|
||||||
|
FoundFirewall: &link.FoundFirewall{Kind: "ufw", Active: false, RetiredBy: "found-inactive"},
|
||||||
|
})
|
||||||
|
ctx := context.Background()
|
||||||
|
f, err := inv.FilteringOf(ctx, "home-server")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(f.Filters) != 2 || f.Filters[1].Owner != inventory.FilterOther || f.Alone() {
|
||||||
|
t.Fatalf("recorded %+v", f)
|
||||||
|
}
|
||||||
|
if f.FoundFirewall == nil || f.FoundFirewall.RetiredBy != "found-inactive" || f.FoundFirewall.Active {
|
||||||
|
t.Fatalf("the found firewall's state: %+v", f.FoundFirewall)
|
||||||
|
}
|
||||||
|
if len(f.Others()) != 1 || f.Others()[0].Where != "chain HAL-MESH-ONLY (iptables-legacy)" {
|
||||||
|
t.Fatalf("others: %+v", f.Others())
|
||||||
|
}
|
||||||
|
// A bare word that the node is there clears nothing.
|
||||||
|
if _, err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{Node: "home-server"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if again, _ := inv.FilteringOf(ctx, "home-server"); len(again.Filters) != 2 {
|
||||||
|
t.Fatalf("a bare report cleared what filters the machine: %+v", again)
|
||||||
|
}
|
||||||
|
// The next full report replaces it: the chain removed by hand is gone from the record.
|
||||||
|
if _, err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{Node: "home-server", Applied: []string{"a"},
|
||||||
|
Filters: []link.Filter{{Where: "table inet mesh, chain forward", Owner: "mesh", Refuses: "policy drop"}}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if again, _ := inv.FilteringOf(ctx, "home-server"); len(again.Filters) != 1 || !again.Alone() {
|
||||||
|
t.Fatalf("the next report did not replace what filters the machine: %+v", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -197,6 +197,15 @@ type Report struct {
|
|||||||
// Strays is what runs on the machine that the mesh neither wrote nor holds (ADR 0163).
|
// Strays is what runs on the machine that the mesh neither wrote nor holds (ADR 0163).
|
||||||
Strays []Stray `json:"strays,omitempty"`
|
Strays []Stray `json:"strays,omitempty"`
|
||||||
|
|
||||||
|
// Filters is what filters the machine now: every table and chain that refuses traffic, with
|
||||||
|
// its owner — the mesh's, the found firewall's, the container runtime's own, a ban, or other
|
||||||
|
// (novox/hq ADR 0168). Every machine reports it, adopted or converged; absent from a host older
|
||||||
|
// than this.
|
||||||
|
Filters []Filter `json:"filters,omitempty"`
|
||||||
|
// FoundFirewall is the state of the firewall a converged machine was found with: in force now
|
||||||
|
// or not, and how it came to be inactive — the mesh disabled it, or it was found so (ADR 0168).
|
||||||
|
FoundFirewall *FoundFirewall `json:"found_firewall,omitempty"`
|
||||||
|
|
||||||
// Profile is what the machine can do, detected again by this apply (novox/hq ADR 0161): the
|
// Profile is what the machine can do, detected again by this apply (novox/hq ADR 0161): the
|
||||||
// same shape enrolment sends, so a machine that gained or lost a capability — switched its
|
// same shape enrolment sends, so a machine that gained or lost a capability — switched its
|
||||||
// network manager — is known at its next push and not at its next enrolment. Absent from a host
|
// network manager — is known at its next push and not at its next enrolment. Absent from a host
|
||||||
@@ -278,6 +287,21 @@ type Held struct {
|
|||||||
Facts map[string]any `json:"facts,omitempty"`
|
Facts map[string]any `json:"facts,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A Filter is one place on a machine that refuses traffic, with its owner (novox/hq ADR 0168):
|
||||||
|
// the host's own shape, carried as data.
|
||||||
|
type Filter struct {
|
||||||
|
Where string `json:"where"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Refuses string `json:"refuses"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FoundFirewall is the state of a converged machine's found firewall (ADR 0168).
|
||||||
|
type FoundFirewall struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
RetiredBy string `json:"retired_by,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// A Stray is a container a machine runs that the mesh neither wrote nor holds (ADR 0163).
|
// A Stray is a container a machine runs that the mesh neither wrote nor holds (ADR 0163).
|
||||||
type Stray struct {
|
type Stray struct {
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
|
|||||||
Reference in New Issue
Block a user