Compare commits

...
Author SHA1 Message Date
mesh-tools c58b311ea7 Merge pull request 'route-proxy from the provisioner's plans with its own contract; the controller forwards accounts to the vault (issue 774)' (#487) from feat/072-moves into main 2026-10-31 10:31:07 +00:00
jschoubben 7a169eae76 Keep the controller on main's mesh-sdk pin and mint until the grants part takes over, so its impact stays small and no account is minted that the bus it loads refuses (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-controller@c58b311ea774
mesh/delivery-group group feat/072-moves merged: every member is merged
mesh/check failed: shanks: the check of novox/mesh-controller#487 failed: go mod download in modules/route-proxy: go: git.novox.be/novox/mesh-controll…
The controller is being removed, so its root module goes back to origin/main's mesh-sdk pin (2da0de7) and
testdata/beside's capture; route-proxy keeps its own pins. Its one functional change stays the account issue:

- Before the grants part's take-over (its key user-list.sealed not saying FollowGrants) the bus loads the
  controller's list, whose hashes come only from its store, so the controller mints as before and hands every
  account it mints, covered or not, to an installed vault at once: no account waits on a node reporting its hash.
- From the take-over it forwards the issue to the vault's issue-account (d791052f) and mints nothing.

And the controller no longer declares or asserts the ASSIGNMENTS stream, which the bus module's tools delete at the
lease write and nothing may make again (research 072 row 33); the verified-sender re-issue stops once it is gone.

Research 072 piece: the gap "vault mints after the lease", the minting piece moves to the vault from the take-over.
2026-10-31 10:37:13 +01:00
jschoubben a07621d761 Pin route-proxy at the group's mesh-sdk head acbf80a and its contract at the published tag v0.1.0, so it builds and reads the contributed payload; refresh its manifest digest for the events its contract names (issue 774) 2026-10-31 10:27:05 +01:00
jschoubben d791052f77 Forward module issue and an assignment's issue to the vault once one is installed, so the controller mints no bus account and none waits on a node reporting here (issue 774; research 072, the controller NATS-native: gap "vault mints after the lease", the minting piece moves to the vault) 2026-10-31 10:02:39 +01:00
jschoubben 22935b9202 Serve route-proxy's routes through its own contract: decode each plan entry's opaque contribution with it, say each name served, withdrawn or refused on the bus, and take who is inside from the seat mesh-placement, since the membership went with the assignments stream (issue 774, research 072 rows 33 and 34) 2026-10-31 09:44:07 +01:00
29 changed files with 765 additions and 564 deletions
@@ -372,7 +372,7 @@ func TestTheAccountsAModuleSpeaksWithOnANode(t *testing.T) {
t.Error("sealed no password")
}
return c.sealErr
})
}, func() error { acts.did("after"); return nil })
if (err != nil) != (c.sealErr != nil) {
t.Fatalf("error %v, want %v", err, c.sealErr)
}
@@ -385,6 +385,52 @@ func TestTheAccountsAModuleSpeaksWithOnANode(t *testing.T) {
})
}
// Issue 774: where a vault is installed the controller mints nothing; `module issue` and an assignment's issue are
// forwarded to the vault's issue-account, and what the account needs beside its value still runs. A refusal fails
// `module issue` and only says itself on an assignment's issue: the vault mints on the want when it hears it.
for _, c := range []struct {
name string
only bool
answer string
refuse error
want string
fails bool
}{
{"module issue: forwarded, minted by the vault", false, `{"minted":true,"account":{"secret":"own/ace/mesh-build-go/broker","generation":1}}`, nil, "after", false},
{"an assignment's issue: forwarded, held already", true, `{"minted":false,"why":"held already"}`, nil, "after", false},
{"an assignment's issue: refused, said", true, "", errors.New("nobody wants it yet"), "after", false},
{"module issue: refused, failed", false, "", errors.New("nobody wants it"), "", true},
} {
t.Run("forwarded to the vault: "+c.name, func(t *testing.T) {
was := vaultIssuer
t.Cleanup(func() { vaultIssuer = was })
var asked []string
vaultIssuer = func(context.Context, accountIssuer) (vaultAsker, bool, error) {
return func(verb string, args map[string]any) (json.RawMessage, error) {
asked = append(asked, verb+" "+fmt.Sprint(args["user"]))
return json.RawMessage(c.answer), c.refuse
}, true, nil
}
acts := &anAccountStore{}
ctx, got := withIssued(t.Context())
if c.only {
ctx = onlyIfAbsent(ctx)
}
err := issueAccount(ctx, acts, inventory.BusUser{Username: "ace.mesh-build-go", Kind: inventory.BusModule},
func(string) error { acts.did("seal"); return nil }, func() error { acts.did("after"); return nil })
if (err != nil) != c.fails || strings.Join(acts.acts, " ") != c.want {
t.Fatalf("error %v, acts %q; want failed %v, acts %q (nothing minted, locked or sealed here)", err,
strings.Join(acts.acts, " "), c.fails, c.want)
}
if strings.Join(asked, ",") != "issue-account ace.mesh-build-go" {
t.Errorf("the vault was asked %v", asked)
}
if minted := len(got.list()) == 1; minted != strings.Contains(c.answer, `"minted":true`) {
t.Errorf("said issued %v", got.list())
}
})
}
// The accounts of a module that no longer resolves on its node are stale, its provisioner client's among them; a
// module still resolved (assigned or required), an account of another kind, and a node whose resolution is unknown
// are left alone (issue 1945: ace's mesh-build-go rows from 17:19, marked sealed by migration 0108).
+52 -12
View File
@@ -2,9 +2,15 @@ package main
import (
"context"
"errors"
"fmt"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/catalogue"
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/inventory"
)
@@ -15,17 +21,19 @@ import (
// controller mints such an account and writes its hash to the bus's user list, as before, and then hands the whole
// value at once to the vault's `accept` with origin `issued`: sealed here to the key the vault made for that call, so
// the vault alone opens it (ADR 0349 clause 8). The vault keeps it as the module's own secret on that machine and
// raises `secret-changed`; a re-issue is the same hand with a new value, which replaces the old in place. An account
// not covered is minted and kept as it was and handed to nobody.
// raises `secret-changed`; a re-issue is the same hand with a new value, which replaces the old in place. Until issue 774 an
// account not covered was minted and kept as it was and handed to nobody.
//
// **Since issue 774 every account the controller mints is handed, covered or not**, once a vault is installed: until
// the grants part's take-over the controller still mints (the bus loads its list), and a vault that holds every value
// at once needs no node to report a hash for it to adopt (ADR 0547 clause 9). From the take-over the vault mints every
// account itself (issueAccount forwards to its issue-account), so this hand is reached only before it, and by
// `rollout mint`'s re-issue of a module's account onto a new bus. The vault never lets such a hand replace an account it
// minted.
// vaultAskerForIssue is the vault as `module issue` asks it. A test replaces it.
var vaultAskerForIssue = func(ctx context.Context) vaultAsker { return theVault(ctx) }
// accountIsCovered says whether the bus accounts minted for m are the vault's to hold (ADR 0374 clause 1).
func accountIsCovered(m catalogue.Manifest) bool {
return catalogue.CarriesVaultSealed(m.Module) || m.Moved
}
// vaultInstalled says whether the vault is assigned to any machine. Until it is, as in genesis (ADR 0113 §15 to §18,
// mesh-host's bootstrap, which issues the bus's and the provisioner's accounts before the vault step), there is no
// vault to hand to: the value is kept in the controller's store as before, and the vault takes it afterwards by the
@@ -44,13 +52,45 @@ func vaultInstalled(ctx context.Context, inv *inventory.Inventory) (bool, error)
return false, nil
}
// handIssuedToVault hands the value just minted for module's own secret name on node to the vault, when the module's
// accounts are covered, and does nothing otherwise. Loud when the vault does not take it: the bus's user list
// already holds the new hash, so a vault that kept the old value would serve a password that no longer logs in.
func handIssuedToVault(ctx context.Context, inv *inventory.Inventory, m catalogue.Manifest, node, name, held string) error {
if !accountIsCovered(m) {
// grantsTookOver says whether the grants part took over the bus's user list: its key `user-list.sealed` says
// FollowGrants (novox/hq ADR 0547 clauses 7 and 8), which the bus node's engine follows. No bucket or no key yet is no
// take-over; a bus that cannot be read is an error, never a guess. A test replaces it.
var grantsTookOver = func(ctx context.Context) (bool, error) {
over := false
err := onTheBus(func(conn *nats.Conn) error {
js, err := jetstream.New(conn)
if err != nil {
return err
}
kv, err := js.KeyValue(ctx, broker.GrantsUserListBucket)
if errors.Is(err, jetstream.ErrBucketNotFound) {
return nil
} else if err != nil {
return err
}
e, err := kv.Get(ctx, broker.GrantsUserListKey)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil
} else if err != nil {
return err
}
g, err := sdkdelivery.DecodeGrantsUserList(e.Value())
if err != nil {
return err
}
over = g.Follow == sdkdelivery.FollowGrants
return nil
})
if err != nil {
return false, fmt.Errorf("whether the grants part took over the bus's user list cannot be read: %w", err)
}
return over, nil
}
// handIssuedToVault hands the value just minted for module's own secret name on node to the vault, once one is
// installed, covered or not (issue 774), and does nothing before. Loud when the vault does not take it: the bus's user
// list already holds the new hash, so a vault that kept the old value would serve a password that no longer logs in.
func handIssuedToVault(ctx context.Context, inv *inventory.Inventory, m catalogue.Manifest, node, name, held string) error {
if installed, err := vaultInstalled(ctx, inv); err != nil {
return fmt.Errorf("whether the vault is installed cannot be read, so %s of %s was not handed to it: %w", name,
m.Module, err)
+69 -8
View File
@@ -8,21 +8,50 @@ import (
"strings"
"testing"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/catalogue"
sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/testbus"
)
// Every other test mints without a vault to hand to: a stand-in that takes anything, replaced by the tests below.
// grantsTookOverOnTheBus is the reader of the grants part's take-over a test reads on its own bus.
var grantsTookOverOnTheBus = grantsTookOver
// Every other test mints without a vault to hand to: a stand-in that takes anything, replaced by the tests below; and
// before the grants part's take-over, which no test bus says.
func init() {
vaultAskerForIssue = func(context.Context) vaultAsker { return newStandInVault(nil).ask }
grantsTookOver = func(context.Context) (bool, error) { return false, nil }
}
// A covered account is handed to the vault when it is minted (novox/hq ADR 0374, issue 787): the whole value, sealed
// to the key the vault made for the call, with origin issued. A module that is neither core nor moved hands nothing.
func TestModuleIssueHandsACoveredAccountToTheVaultAndOnlyThat(t *testing.T) {
// theGrantsPartTookOver says the grants part's take-over is on, or off, for the rest of the test.
func theGrantsPartTookOver(t *testing.T, over bool) {
t.Helper()
was := grantsTookOver
grantsTookOver = func(context.Context) (bool, error) { return over, nil }
t.Cleanup(func() { grantsTookOver = was })
}
// An account is handed to the vault when it is minted (novox/hq ADR 0374, issue 787): the whole value, sealed to the
// key the vault made for the call, with origin issued. Since issue 774 every account is, covered or not, until the
// grants part's take-over, read on the bus as the bus node reads it; from the take-over nothing is minted here and the
// issue is forwarded to the vault's issue-account.
func TestModuleIssueHandsEveryAccountToTheVaultUntilTheTakeOver(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
theVaultIsInstalled(t, open)
js, err := broker.Dial(testbus.URL(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(js.Close)
servingBus.Store(js)
t.Cleanup(func() { servingBus.Store(nil) })
was := grantsTookOver
grantsTookOver = grantsTookOverOnTheBus
t.Cleanup(func() { grantsTookOver = was })
inv := open.inventory
known := broker.Broker{Fingerprint: "ab"}
@@ -66,11 +95,11 @@ func TestModuleIssueHandsACoveredAccountToTheVaultAndOnlyThat(t *testing.T) {
t.Errorf("the vault was told %s, without %s", said, want)
}
}
if n := strings.Count(said, `"origin":"issued"`); n != 3 {
t.Errorf("%d accounts were handed with origin issued, want 3 (the core's two and the moved module's): %s", n, said)
if n := strings.Count(said, `"origin":"issued"`); n != 4 {
t.Errorf("%d accounts were handed with origin issued, want 4 (the core's two, the moved and the plain module's): %s", n, said)
}
if strings.Contains(said, "kept-bot") {
t.Errorf("an account that is neither core nor moved was handed to the vault: %s", said)
if !strings.Contains(said, "kept-bot") {
t.Errorf("an account that is neither core nor moved was not handed to the vault: %s", said)
}
if strings.Contains(said, "first-core-password") || strings.Contains(said, "first-moved-password") {
t.Errorf("a value crossed the bus in the clear: %s", said)
@@ -83,6 +112,38 @@ func TestModuleIssueHandsACoveredAccountToTheVaultAndOnlyThat(t *testing.T) {
!strings.Contains(got, `"url":"nats://10.0.0.1:4222"`) || !strings.Contains(got, `"fingerprint":"ab"`) {
t.Errorf("the vault was handed %q after a re-issue, not the new account whole", got)
}
// The take-over: the grants part's key says the bus node follows its list. From then the vault mints.
api, err := jetstream.New(js.Conn())
if err != nil {
t.Fatal(err)
}
kv, err := api.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: broker.GrantsUserListBucket})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = api.DeleteKeyValue(context.Background(), broker.GrantsUserListBucket) })
client := broker.Principal{Kind: broker.KindProvisioner, Node: "laptop", Module: "nats"}.Username()
for _, follow := range []string{sdkdelivery.FollowController, sdkdelivery.FollowGrants} {
value, err := sdkdelivery.EncodeGrantsUserList(sdkdelivery.GrantsUserList{Follow: follow})
if err != nil {
t.Fatal(err)
}
if _, err := kv.Put(ctx, broker.GrantsUserListKey, value); err != nil {
t.Fatal(err)
}
before, _, _ := inv.BusUserHash(ctx, client)
if err := issueProvisionerClient(ctx, inv, core, "laptop", known, "10.0.0.1:4222"); err != nil {
t.Fatal(err)
}
after, _, _ := inv.BusUserHash(ctx, client)
if minted := before != after; minted != (follow == sdkdelivery.FollowController) {
t.Errorf("following %s's list, the controller minted %s: %v", follow, client, minted)
}
}
if said := vault.said(); strings.Count(said, `"verb":"issue-account"`) != 1 || !strings.Contains(said, `"user":"`+client+`"`) {
t.Errorf("the vault was not asked once, after the take-over, to issue %s: %s", client, said)
}
}
// A vault that does not take the value fails the issue loudly: the bus's user list holds the new hash already.
+132 -44
View File
@@ -1018,8 +1018,21 @@ const issueLockedFor = 2 * time.Minute
// left ace's mesh-build-go with no credential), under the store's lock on the account, so two issuers never interleave
// their look, mint and seal: the hash the bus holds is always the password sealed. A seal that fails leaves the
// account unsealed, issued again by the next issue.
func issueAccount(ctx context.Context, accounts accountIssuer, u inventory.BusUser, seal func(password string) error) error {
//
// **From the grants part's take-over the controller mints nothing** (issue 774, research 072's gap "vault mints after
// the lease"; the controller is deprecated and hands its duties over): the bus then loads the grants part's list, whose
// hashes are the vault's, so the issue is forwarded to the vault's issue-account and only after runs (what the account
// needs beside its value: its durable consumers). **Before it** the bus loads the controller's own list, whose hashes
// come from its store alone, so an account the vault minted would be refused: the controller mints as before and hands
// every account it mints to an installed vault at once (handIssuedToVault), so none waits on a node reporting its hash.
func issueAccount(ctx context.Context, accounts accountIssuer, u inventory.BusUser, seal func(password string) error,
after func() error) error {
only, _ := ctx.Value(mintOnlyIfAbsent{}).(bool)
if ask, installed, err := vaultIssuer(ctx, accounts); err != nil {
return fmt.Errorf("whether the vault issues bus accounts yet cannot be read, so %s is not issued: %w", u.Username, err)
} else if installed {
return forwardToTheVault(ctx, ask, u.Username, only, after)
}
if !only {
password, err := accounts.MintBusPassword(ctx, u)
if err != nil {
@@ -1062,6 +1075,67 @@ func issueAccount(ctx context.Context, accounts accountIssuer, u inventory.BusUs
return nil
}
// vaultIssuer answers the vault to forward an issue to, and whether the issue is the vault's: one is installed
// (vaultInstalled) and the grants part took over the bus's user list (grantsTookOver). A store that is not the
// controller's inventory (a test's) has none. A test replaces it.
var vaultIssuer = func(ctx context.Context, accounts accountIssuer) (vaultAsker, bool, error) {
inv, ok := accounts.(*inventory.Inventory)
if !ok {
return nil, false, nil
}
installed, err := vaultInstalled(ctx, inv)
if err != nil || !installed {
return nil, false, err
}
if over, err := grantsTookOver(ctx); err != nil || !over {
return nil, false, err
}
return vaultAskerForIssue(ctx), true, nil
}
// issuedByTheVault is the vault's answer to issue-account (mesh-vault heldBackOrMinted).
type issuedByTheVault struct {
Minted bool `json:"minted"`
Why string `json:"why"`
Account struct {
Secret string `json:"secret"`
Generation int `json:"generation"`
} `json:"account"`
}
// forwardToTheVault asks the vault to issue user (its verb issue-account) and then runs after. The vault mints only
// what the grants part wants and never anew a value it holds, so asking twice, or with the want's own mint, is harmless.
// An issue on an assignment (only) is not failed by a refusal: the vault mints on the want itself when it is heard, and
// the refusal is said; `module issue` fails with it.
func forwardToTheVault(ctx context.Context, ask vaultAsker, user string, only bool, after func() error) error {
raw, err := ask("issue-account", map[string]any{"user": user})
if err != nil {
if !only {
return fmt.Errorf("the vault is the one issuer of bus accounts and did not issue %s: %w", user, err)
}
fmt.Printf(" %s is the vault's to mint, on the grants part's want of it; issue-account said: %v\n", user, err)
} else {
var got issuedByTheVault
if err := json.Unmarshal(raw, &got); err != nil {
return fmt.Errorf("the vault's answer to issue-account %s does not read: %w", user, err)
}
if got.Minted {
fmt.Printf("bus user %s minted by the vault as %s (generation %d)\n", user, got.Account.Secret, got.Account.Generation)
if g, _ := ctx.Value(issuedNowKey{}).(*issuedNow); g != nil {
g.mu.Lock()
g.users = append(g.users, user)
g.mu.Unlock()
}
} else {
fmt.Printf("bus user %s not minted anew by the vault: %s\n", user, got.Why)
}
}
if after == nil {
return nil
}
return after()
}
// issueOnTheNewBus gives an assigned module its credential on the bus being built.
//
// **Three things differ from a management call, and each is the point of the move.** The credential
@@ -1090,7 +1164,7 @@ func issueOnTheNewBus(ctx context.Context, inv *inventory.Inventory, m catalogue
if err := issueAccount(ctx, inv, inventory.BusUser{Username: user, Kind: busKindOf(m.Module), Node: node,
Module: m.Module}, func(password string) error {
return issueWith(ctx, inv, m, node, busAddress, known, reachable, user, password)
}); err != nil {
}, func() error { return ensureModuleConsumer(m, node, busAddress) }); err != nil {
return err
}
}
@@ -1118,7 +1192,7 @@ func issueProcess(ctx context.Context, inv *inventory.Inventory, m catalogue.Man
return issueAccount(ctx, inv, inventory.BusUser{Username: user, Kind: inventory.BusProcess, Node: node, Module: m.Module},
func(password string) error {
return sealProcess(ctx, inv, m, pr, node, busAddress, known, reachable, user, password)
})
}, func() error { return ensureProcessQueues(m, pr, node, busAddress) })
}
// sealProcess seals a process's minted account to its node, hands it to the vault and makes its work-queue consumers.
@@ -1135,30 +1209,40 @@ func sealProcess(ctx context.Context, inv *inventory.Inventory, m catalogue.Mani
if err := handIssuedToVault(ctx, inv, m, node, name, string(held)); err != nil {
return err
}
if len(pr.Queues) > 0 {
if busAddress == "" {
fmt.Printf(" %s's process %s consumes its work queue; its consumer is created when the bus is "+
"reachable (`push --behind`, then `rollout mint` again is harmless)\n", m.Module, pr.Process)
} else {
js, err := broker.Dial(busAddress)
if err != nil {
return fmt.Errorf("the credential is minted and the mesh cannot reach the bus to create "+
"%s's process %s's work-queue consumer: %w", m.Module, pr.Process, err)
}
defer js.Close()
who := broker.Principal{Kind: broker.KindProcess, Node: node, Module: m.Module, Process: pr.Process}
for _, q := range pr.Queues {
if err := js.EnsureConsumer(broker.QueueConsumer(who, q)); err != nil {
return err
}
}
}
if err := ensureProcessQueues(m, pr, node, busAddress); err != nil {
return err
}
fmt.Printf("bus user %s minted for %s's process %s, scoped to its bus declaration\n", user, m.Module, pr.Process)
fmt.Printf(" sealed to %s as %s. It arrives with the next push — `push %s` to send it\n", node, name, node)
return nil
}
// ensureProcessQueues makes a process's work-queue consumers where the bus can be reached: what its account needs beside
// its value, whoever minted it.
func ensureProcessQueues(m catalogue.Manifest, pr broker.ProcessPrincipal, node, busAddress string) error {
if len(pr.Queues) == 0 {
return nil
}
if busAddress == "" {
fmt.Printf(" %s's process %s consumes its work queue; its consumer is created when the bus is "+
"reachable (`push --behind`, then `rollout mint` again is harmless)\n", m.Module, pr.Process)
return nil
}
js, err := broker.Dial(busAddress)
if err != nil {
return fmt.Errorf("the mesh cannot reach the bus to create %s's process %s's work-queue consumer: %w",
m.Module, pr.Process, err)
}
defer js.Close()
who := broker.Principal{Kind: broker.KindProcess, Node: node, Module: m.Module, Process: pr.Process}
for _, q := range pr.Queues {
if err := js.EnsureConsumer(broker.QueueConsumer(who, q)); err != nil {
return err
}
}
return nil
}
// heldProcessAccount is a process's account, in the provisioner client's form (novox/hq ADR 0436 §2):
// {url, fingerprint, node, module, user, password} — never claims, since a process holds no seat (§3).
func heldProcessAccount(known broker.Broker, reachable, node, module, user, password string) ([]byte, error) {
@@ -1191,7 +1275,7 @@ func issueProvisionerClient(ctx context.Context, inv *inventory.Inventory, m cat
return issueAccount(ctx, inv, inventory.BusUser{Username: user, Kind: inventory.BusProvisioner, Node: node,
Module: m.Module}, func(password string) error {
return sealProvisionerClient(ctx, inv, m, node, known, reachable, user, password)
})
}, nil)
}
// sealProvisionerClient seals the provisioner client's minted account to its machine and hands it to the vault.
@@ -1263,28 +1347,8 @@ func issueWith(ctx context.Context, inv *inventory.Inventory, m catalogue.Manife
if err := handIssuedToVault(ctx, inv, m, node, "broker", string(held)); err != nil {
return err
}
// And how it hears what it consumes. Derived from its declaration, and only when it declared
// something: a module that consumes nothing needs no consumer, and creating one would be a
// durable subscription nobody reads.
if consumer, needed := broker.ConsumerFor(broker.Principal{
Kind: broker.KindModule, Node: node, Module: m.Module,
Emits: m.EmitsAll(), Consumes: m.Consumes, Serves: m.Tools,
}); needed {
if busAddress == "" {
fmt.Printf(" %s consumes; its consumer is created when the bus is reachable (`push --behind`, then "+
"`rollout mint` again is harmless)\n", m.Module)
} else {
js, err := broker.Dial(busAddress)
if err != nil {
return fmt.Errorf("the credential is minted and the mesh cannot reach the bus to create "+
"how %s hears what it consumes: %w", m.Module, err)
}
defer js.Close()
if err := js.EnsureConsumer(consumer); err != nil {
return err
}
}
if err := ensureModuleConsumer(m, node, busAddress); err != nil {
return err
}
fmt.Printf("bus user %s minted for %s, scoped to what it emits and consumes\n", user, m.Module)
@@ -1294,6 +1358,30 @@ func issueWith(ctx context.Context, inv *inventory.Inventory, m catalogue.Manife
return nil
}
// ensureModuleConsumer makes how a module hears what it consumes, where the bus can be reached: what its account needs
// beside its value, whoever minted it. Derived from its declaration, and only when it declared something: a module that
// consumes nothing needs no consumer, and creating one would be a durable subscription nobody reads.
func ensureModuleConsumer(m catalogue.Manifest, node, busAddress string) error {
consumer, needed := broker.ConsumerFor(broker.Principal{
Kind: broker.KindModule, Node: node, Module: m.Module,
Emits: m.EmitsAll(), Consumes: m.Consumes, Serves: m.Tools,
})
if !needed {
return nil
}
if busAddress == "" {
fmt.Printf(" %s consumes; its consumer is created when the bus is reachable (`push --behind`, then "+
"`rollout mint` again is harmless)\n", m.Module)
return nil
}
js, err := broker.Dial(busAddress)
if err != nil {
return fmt.Errorf("the mesh cannot reach the bus to create how %s hears what it consumes: %w", m.Module, err)
}
defer js.Close()
return js.EnsureConsumer(consumer)
}
// whereItComesFrom is the provenance a module handed over by hand records, and what a record must
// say to be worth anything later.
//
@@ -21,7 +21,9 @@ import (
// account against the store — no bus to dial, busAddress "" — for mesh-mail's real manifest shape (review
// round 1 finding 1: no own secret `broker` at all, its tools served by the tool runner), and issuing it
// again is the path every bus account takes (`rollout mint`'s KindProcess case, and a re-run of `module
// issue`): it rotates the password, and the old hash no longer matches.
// issue`): it rotates the password, and the old hash no longer matches. Issue 774: every account minted is handed to the
// vault, covered or not, until the grants part's take-over; from it the issue is forwarded to the vault's issue-account,
// and the controller's hash stays as it was.
func TestModuleIssueMintsAndDeliversAProcesssAccount(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
@@ -29,7 +31,6 @@ func TestModuleIssueMintsAndDeliversAProcesssAccount(t *testing.T) {
theVaultIsInstalled(t, open)
m := mailSenderWithProcess()
m.Moved = true // so handIssuedToVault's accountIsCovered is true, and the hand-off is exercised whole
register(t, open, m)
vault := newStandInVault(t)
@@ -84,4 +85,13 @@ func TestModuleIssueMintsAndDeliversAProcesssAccount(t *testing.T) {
if hash1 == hash2 {
t.Fatal("issuing the process's account again did not rotate its password")
}
theGrantsPartTookOver(t, true)
issue()
if hash3, held, err := inv.BusUserHash(ctx, user); err != nil || !held || hash3 != hash2 {
t.Fatalf("the controller minted again after the grants part's take-over (held %v, %v)", held, err)
}
if said := vault.said(); !strings.Contains(said, `"verb":"issue-account"`) || !strings.Contains(said, `"user":"`+user+`"`) {
t.Errorf("the vault was not asked to issue %s after the take-over: %s", user, said)
}
}
@@ -207,8 +207,13 @@ func giveVerifiedSendersBack(ctx context.Context, d *doctor) {
}
// reissueVerifiedSendersOn is reissueVerifiedSenders on the bus: memberships read from the AssignmentsStream and
// published to it.
// published to it. Once the stream is gone (the bus module's tools delete it at the lease write, research 072 row 33)
// the memberships are the grants part's alone, and there is nothing here to issue again.
func reissueVerifiedSendersOn(ctx context.Context, bus link.OverNATS, records broker.Records, where broker.Placements) int {
if _, err := bus.JS.StreamInfo(broker.AssignmentsStream, nats.Context(ctx)); errors.Is(err, nats.ErrStreamNotFound) {
fmt.Printf(" the assignments stream is gone: the grants part issues the memberships, none is given a verified sender here\n")
return 0
}
return reissueVerifiedSenders(records, where,
func(node, module string) ([]byte, uint64, error) {
m, err := bus.JS.GetLastMsg(broker.AssignmentsStream, broker.MembershipSubject(node, module))
@@ -6,6 +6,8 @@ import (
"encoding/json"
"testing"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/link"
"github.com/novox/mesh-controller/internal/testbus"
@@ -17,7 +19,10 @@ func TestNatsAMembershipIssuedWithoutItsVerifiedSenderIsIssuedAgainWhenTheJudgem
t.Fatal(err)
}
t.Cleanup(js.Close)
if err := broker.AssertMeshStreams(js); err != nil {
// The assignments stream is no longer the mesh's (research 072 row 33): it is asserted by nothing, and stays on a
// bus only until the lease write deletes it. Made here as it is left there.
if _, err := js.Context().AddStream(&nats.StreamConfig{Name: broker.AssignmentsStream,
Subjects: []string{"mesh.assignment.*.*"}, MaxMsgsPerSubject: 1, AllowDirect: true}); err != nil {
t.Fatal(err)
}
bus := link.OverNATS{Conn: js.Conn(), JS: js.Context()}
@@ -49,6 +49,12 @@ func (v *standInVault) ask(verb string, args map[string]any) (json.RawMessage, e
// The vault asks the desk's prompt and the provider's tool itself, in its own name (issue 723): one step from
// the controller, which carries no key and no sealed value.
switch verb {
case "issue-account":
// The vault is the one issuer once installed (issue 774): the controller forwards, and keeps nothing.
if v.refuse != nil {
return nil, v.refuse
}
return json.Marshal(map[string]any{"minted": true, "account": map[string]any{"user": args["user"], "generation": 1}})
case "secret-ask":
if v.refuse != nil {
return nil, v.refuse
+7 -1
View File
@@ -21,7 +21,13 @@ func TestAModuleStreamOnARealBus(t *testing.T) {
t.Fatal(err)
}
defer js.Close()
for _, s := range []Stream{MeshStreams()[3], mailStream.Stream()} {
var events Stream
for _, s := range MeshStreams() {
if s.Name == EventsStream {
events = s
}
}
for _, s := range []Stream{events, mailStream.Stream()} {
_ = js.js.DeleteStream(s.Name)
if err := js.EnsureStream(s); err != nil {
t.Fatalf("%s: %v", s.Name, err)
@@ -14,7 +14,9 @@ import (
// queues are left on the bus until a cleanup removes them, and no module may take their names meanwhile. So the catalogue
// reserves every name of the broker's own, and may reserve these retired ones beside them.
func TestTheCataloguesReservedStreamNamesAreTheMeshsOwn(t *testing.T) {
retired := []string{"CHECKS_OTHER", "CHECKS_READY"}
// And ASSIGNMENTS (research 072 row 33): the bus module's tools delete it at the lease write, and the controller
// asserts it no more, so it is never made again.
retired := []string{"CHECKS_OTHER", "CHECKS_READY", "ASSIGNMENTS"}
var own []string
for _, s := range MeshStreams() {
own = append(own, s.Name)
+3 -8
View File
@@ -171,14 +171,9 @@ func MeshStreams() []Stream {
Why: "one declaration per node, always the newest; a node that sees sequence n refuses " +
"n-1 by construction (issue 107)",
},
{
Name: AssignmentsStream,
Subjects: []string{"mesh.assignment.*.*"},
Retention: RetentionLastPerSubject,
Direct: true,
Why: "one membership per assignment, always the newest: what the mesh issued this module " +
"on this machine to serve and to reach (ADR 0160); read directly by the runtime it is for",
},
// The assignments stream (ADR 0160) is no longer the mesh's: a runtime reads its membership from the grants
// part's state memberships (ADR 0571 clause 7), and the bus module's tools delete the stream at the lease write
// (research 072 row 33), so it is not declared here, which would make it again at the controller's next start.
{
Name: EventsStream,
// A seat's own events ride here too: they are 1:many like any event, and the
+5 -4
View File
@@ -123,10 +123,11 @@ func subjectMatches(filter, subject string) bool {
// Each relationship's retention is the thing that makes it what it is (design 29 §4).
func TestEachStreamCarriesTheRetentionItsShapeNeeds(t *testing.T) {
want := map[string]Retention{
"CONTROL": RetentionWorkQueue,
"NODES": RetentionLastPerSubject,
"EVENTS": RetentionLimits,
"ASSIGNMENTS": RetentionLastPerSubject,
"CONTROL": RetentionWorkQueue,
"NODES": RetentionLastPerSubject,
"EVENTS": RetentionLimits,
// No ASSIGNMENTS: the bus module's tools delete it at the lease write and nothing makes it again (research 072
// row 33); a runtime reads its membership from the grants part's state.
// What a consumer gave up on (novox/hq issue 330): the server's notice taken once, the message
// kept until somebody acts.
"DEAD_LETTER_NOTICES": RetentionWorkQueue,
+25 -6
View File
@@ -9,10 +9,27 @@ The shipping form of the reference reverse proxy (novox/hq ADR 0007, novox/hq 08
is simply deprecated/invalid"). A module that must be reachable declares `requires: ["route"]` and contributes the
name it wants and the port it listens on; this module provides `route`. The controller composes each consumer's plan,
and the seat `mesh-provisioner` keeps it in its state `plans`: the plan's entry for the route names this proxy as
its provider and carries what the consumer contributes, composed (`given`: its names joined to the node's domains, its
endpoint's port, its policy), and where the consumer's machine is on the private network (`consumer-at`). The proxy
follows the plans on the bus and forwards by the `Host` header of each request to `http://<consumer-at>:<port>`, or
over loopback for a consumer on its own machine.
its provider and carries what the consumer contributes, composed (its names joined to the node's domains, its
endpoint's port, its policy), and where the consumer's machine is on the private network, as one opaque payload
(`contributed`) the seat never reads. The proxy follows the plans on the bus, reads each contribution with its own
contract (below), and forwards by the `Host` header of each request to `http://<at>:<port>`, or over loopback for a
consumer on its own machine.
## Its contract: a package of its own
**route-proxy defines its own contract, and other modules use it** (the operator, 2026-10-31). It is the Go module
`git.novox.be/novox/mesh-controller/modules/route-proxy/contract`, in `contract/` here with its own `go.mod`, standard
library only, released by tags `modules/route-proxy/contract/vX.Y.Z`, the way mesh-sdk is released (`go/vX.Y.Z`); this
module depends on it by its version like any other. It holds:
- **the route a consumer asks** (`Request`, read strictly by `DecodeRequest`: no field it does not name, a name of
either kind, a port where the rule forwards, http or https, a positive body limit, a secret's file never a
credential) and its JSON schema (`route.schema.json`, `Schema`), the form of a manifest's `contributes.route`;
- **the events this proxy says** on `mesh.mod.route-proxy.event.<type>`: `route.served` and `route.withdrawn` as the
names it serves change, `route.refused` with why for a route a plan gives it and it does not serve (`Event`);
- **what it keeps on the bus**, its states `routes` and `unrouted`, their keys and records (`Route`, `Unrouted`,
`Other`, `Key`, `Split`);
- **its tools' names and arguments** (`ToolRoutes`, `ToolRoute`, `ToolUnrouted`, `RoutesArgs`, `RouteArgs`).
The controller does not write the proxy's configuration. It composes the facts into the plans and the proxy turns them
into whatever it runs. That boundary is why swapping this proxy for another costs nothing to the modules that publish
@@ -24,12 +41,14 @@ migration scaffolding still listed there.
|---|---|---|
| `provides` | `route` (scope `mesh`, answered by the helper) | a module on any node may require it |
| `serves.route` | `{}` | a route hands back a name, not a credential — the consumer already knows the name it asked for |
| `reads` | `mesh-provisioner.plans`, `mesh-provisioner.provisions` | the plans are where the routes are; the helper reads both |
| `reads` | `mesh-provisioner.plans`, `mesh-provisioner.provisions`, `mesh-delivery-assigner.placement` | the plans are where the routes are; the helper reads both; the placement says who is inside |
| `emits` | `route.served`, `route.withdrawn`, `route.refused` | what it serves, said as it changes (the contract's `Event`) |
| `listens` | `80` and `443`, both `from: anywhere` | the one machine with a public opening; the firewall opens these for free (ADR 0045) |
Until the bus answers, the proxy routes nothing and says so on its log; it refuses to start without its broker
credential (`MESH_BROKER_FILE`). Who is inside the private network, and so may be served an internal-only name, is
still read from its membership (novox/hq ADR 0167).
every node's address on it as the seat mesh-placement decides it (mesh-sdk `delivery.InsideAddresses`); until that
is read, an internal-only name is served to this machine alone.
## Which issuer: Let's Encrypt, stated here (novox/hq ADR 0226)
@@ -14,7 +14,7 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/provider"
"route-proxy/internal/routestate"
"git.novox.be/novox/mesh-controller/modules/route-proxy/contract"
)
func say(format string, args ...any) {
@@ -36,7 +36,7 @@ func main() {
}
}()
}
tools := front{routes: stdio.State(routestate.RoutesState), unrouted: stdio.State(routestate.UnroutedState),
tools := front{routes: stdio.State(contract.RoutesState), unrouted: stdio.State(contract.UnroutedState),
now: time.Now}.tools()
if err := stdio.Serve("", tools); err != nil {
say("%v", err)
@@ -13,13 +13,13 @@ package main
import (
"git.novox.be/novox/mesh-sdk/go/provider"
"route-proxy/internal/routestate"
"git.novox.be/novox/mesh-controller/modules/route-proxy/contract"
)
// ProvisionName is what route-proxy provides, as its manifest says, and ModuleName this module.
const (
ProvisionName = routestate.Provision
ModuleName = routestate.Module
ProvisionName = contract.Provision
ModuleName = contract.Module
)
// newProvider is route-proxy answering its consumers over bus on node: the route, served as the manifest serves it
@@ -130,12 +130,13 @@ func TestRouteProxyIsNotMovedAndKeepsItsOwnFiles(t *testing.T) {
for _, k := range []string{"build", "provides", "state", "reads", "tools", "replaces"} {
delete(m, k)
}
// Refreshed on purpose for the events its contract names (`emits`: route.served, route.withdrawn, route.refused).
pinned, err := json.Marshal(m) // sorted keys, compact
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(pinned)
if got := hex.EncodeToString(sum[:]); got != "e96702560003ae7f27490d27fd2353f8c6680d8e16864e15fdc0a833108bc2e5" {
if got := hex.EncodeToString(sum[:]); got != "3a44c56cc021be4008f521d16fa02e1c0f0d2eedab2f4d0b60f7d3177269e542" {
t.Fatalf("route-proxy's own manifest changed: digest %s", got)
}
// The two acme templates are pinned the same way (issue 849): they are what the proxy's certificates are asked
+20 -20
View File
@@ -14,14 +14,14 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"route-proxy/internal/routestate"
"git.novox.be/novox/mesh-controller/modules/route-proxy/contract"
)
// The tools' names, as the manifest declares them.
const (
RoutesTool = "route_proxy_routes"
RouteTool = "route_proxy_route"
UnroutedTool = "route_proxy_unrouted"
RoutesTool = contract.ToolRoutes
RouteTool = contract.ToolRoute
UnroutedTool = contract.ToolUnrouted
)
// reader is one of the module's states, as stdio.KeptState reads it.
@@ -46,9 +46,9 @@ type RouteLine struct {
// unknown before any request.
Reachable string `json:"reachable"`
// Certificate is its outcome, issuer and expiry in words; ExpiresInDays is the whole days until not-after.
Certificate string `json:"certificate"`
ExpiresInDays *int `json:"expires_in_days,omitempty"`
LastAnswer *routestate.Answer `json:"last_answer,omitempty"`
Certificate string `json:"certificate"`
ExpiresInDays *int `json:"expires_in_days,omitempty"`
LastAnswer *contract.Answer `json:"last_answer,omitempty"`
}
func (f front) tools() []stdio.Tool {
@@ -99,7 +99,7 @@ func read[T any](state reader, node string) ([]T, error) {
}
var out []T
for _, key := range keys {
at, _ := routestate.Split(key)
at, _ := contract.Split(key)
if node != "" && at != node {
continue
}
@@ -120,7 +120,7 @@ func read[T any](state reader, node string) ([]T, error) {
}
func (f front) list(node string) (map[string]any, error) {
routes, err := read[routestate.Route](f.routes, node)
routes, err := read[contract.Route](f.routes, node)
if err != nil {
return nil, err
}
@@ -131,7 +131,7 @@ func (f front) list(node string) (map[string]any, error) {
if line.Reachable == "no" {
unreachable++
}
if line.ExpiresInDays != nil && *line.ExpiresInDays < 14 || r.Certificate.Outcome == routestate.Failed {
if line.ExpiresInDays != nil && *line.ExpiresInDays < 14 || r.Certificate.Outcome == contract.Failed {
expiring++
}
lines = append(lines, line)
@@ -146,7 +146,7 @@ func (f front) list(node string) (map[string]any, error) {
"certificates_failing_or_within_14_days": expiring}, nil
}
func (f front) line(r routestate.Route) RouteLine {
func (f front) line(r contract.Route) RouteLine {
line := RouteLine{Name: r.Name, Proxy: r.Proxy, Reach: r.Reach, LastAnswer: r.LastAnswer, Reachable: "unknown",
Targets: []string{}}
for _, t := range r.Targets {
@@ -182,7 +182,7 @@ func (f front) line(r routestate.Route) RouteLine {
}
// describe is one rule in a few words: path → module@node at target, or what it answers with.
func describe(t routestate.Target) string {
func describe(t contract.Target) string {
path := t.Path
if path == "" {
path = "/"
@@ -209,11 +209,11 @@ func (f front) one(name, node string) (map[string]any, error) {
if name == "" {
return nil, fmt.Errorf("name is the site to read, such as app.example.org")
}
routes, err := read[routestate.Route](f.routes, node)
routes, err := read[contract.Route](f.routes, node)
if err != nil {
return nil, err
}
var found []routestate.Route
var found []contract.Route
for _, r := range routes {
if r.Name == name {
found = append(found, r)
@@ -248,32 +248,32 @@ func (f front) refusals(node string) (map[string]any, error) {
}
sort.Slice(asked, func(i, j int) bool { return asked[i].AskedAt.After(asked[j].AskedAt) })
if asked == nil {
asked = []routestate.Unrouted{}
asked = []contract.Unrouted{}
}
return map[string]any{"unrouted": asked, "names": len(asked), "other_refusals": others}, nil
}
// asked is the names refused and kept by name, and each proxy's count of the refusals not kept by name.
func (f front) asked(node string) ([]routestate.Unrouted, []routestate.Other, error) {
func (f front) asked(node string) ([]contract.Unrouted, []contract.Other, error) {
keys, err := f.unrouted.Keys()
if err != nil {
return nil, nil, err
}
names, others := keySet{}, keySet{}
for _, key := range keys {
if _, name := routestate.Split(key); name == routestate.OtherName {
if _, name := contract.Split(key); name == contract.OtherName {
others[key] = true
} else {
names[key] = true
}
}
asked, err := read[routestate.Unrouted](only{f.unrouted, names}, node)
asked, err := read[contract.Unrouted](only{f.unrouted, names}, node)
if err != nil {
return nil, nil, err
}
counted, err := read[routestate.Other](only{f.unrouted, others}, node)
counted, err := read[contract.Other](only{f.unrouted, others}, node)
if counted == nil {
counted = []routestate.Other{}
counted = []contract.Other{}
}
return asked, counted, err
}
@@ -9,7 +9,7 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"route-proxy/internal/routestate"
"git.novox.be/novox/mesh-controller/modules/route-proxy/contract"
)
// held is a state as the bus holds it, read-only.
@@ -40,19 +40,19 @@ func TestTheWebFrontsToolsAnswerFromTheBus(t *testing.T) {
now := time.Date(2026, 10, 30, 21, 0, 0, 0, time.UTC)
expires := now.Add(30 * 24 * time.Hour)
routes := held{
"anchor.grafana.example": routestate.Route{Name: "grafana.example", Proxy: "anchor", Reach: routestate.ReachPublic,
Targets: []routestate.Target{{Module: "grafana", Node: "home-server", Target: "http://10.0.0.2:3000"}},
Certificate: routestate.Certificate{Authority: routestate.ReachPublic, Issuer: "Let's Encrypt R11", NotAfter: &expires, Outcome: routestate.Issued},
LastAnswer: &routestate.Answer{Status: 502, At: now, Error: "dial tcp 10.0.0.2:3000: connect: connection refused"}},
"anchor.drive.example": routestate.Route{Name: "drive.example", Proxy: "anchor", Reach: routestate.ReachPublic,
Targets: []routestate.Target{{Module: "nextcloud", Node: "home-server", Target: "http://10.0.0.2:8080"}},
Certificate: routestate.Certificate{Authority: routestate.ReachPublic},
LastAnswer: &routestate.Answer{Status: 200, At: now}},
"anchor.grafana.example": contract.Route{Name: "grafana.example", Proxy: "anchor", Reach: contract.ReachPublic,
Targets: []contract.Target{{Module: "grafana", Node: "home-server", Target: "http://10.0.0.2:3000"}},
Certificate: contract.Certificate{Authority: contract.ReachPublic, Issuer: "Let's Encrypt R11", NotAfter: &expires, Outcome: contract.Issued},
LastAnswer: &contract.Answer{Status: 502, At: now, Error: "dial tcp 10.0.0.2:3000: connect: connection refused"}},
"anchor.drive.example": contract.Route{Name: "drive.example", Proxy: "anchor", Reach: contract.ReachPublic,
Targets: []contract.Target{{Module: "nextcloud", Node: "home-server", Target: "http://10.0.0.2:8080"}},
Certificate: contract.Certificate{Authority: contract.ReachPublic},
LastAnswer: &contract.Answer{Status: 200, At: now}},
}
unrouted := held{
"anchor.old.example": routestate.Unrouted{Name: "old.example", Proxy: "anchor", AskedAt: now.Add(-time.Hour), Times: 3},
"anchor.wiki.example": routestate.Unrouted{Name: "wiki.example", Proxy: "anchor", AskedAt: now, Times: 1},
"anchor._other": routestate.Other{Proxy: "anchor", Minute: now, LastMinute: 40, SinceStart: 900},
"anchor.old.example": contract.Unrouted{Name: "old.example", Proxy: "anchor", AskedAt: now.Add(-time.Hour), Times: 3},
"anchor.wiki.example": contract.Unrouted{Name: "wiki.example", Proxy: "anchor", AskedAt: now, Times: 1},
"anchor._other": contract.Other{Proxy: "anchor", Minute: now, LastMinute: 40, SinceStart: 900},
}
tools := map[string]stdio.Tool{}
for _, tool := range (front{routes: routes, unrouted: unrouted, now: func() time.Time { return now }}).tools() {
+100 -34
View File
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"log"
"sort"
"strings"
"sync/atomic"
"time"
@@ -11,20 +12,21 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/membership"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/provisions"
"route-proxy/internal/routestate"
"git.novox.be/novox/mesh-controller/modules/route-proxy/contract"
)
// keptStates is every state route-proxy keeps, as both its halves write them: the routes and the refusals this
// proxy writes, readable by others, and the bound entries the provider helper writes.
func keptStates() []announce.State {
return []announce.State{
{Name: routestate.BoundState},
{Name: routestate.RoutesState, Readable: true},
{Name: routestate.UnroutedState, Readable: true},
{Name: contract.BoundState},
{Name: contract.RoutesState, Readable: true},
{Name: contract.UnroutedState, Readable: true},
}
}
@@ -32,12 +34,13 @@ func keptStates() []announce.State {
//
// **The routes are the seat mesh-provisioner's plans, alone** (issue 774, research 072 row 34): every plan's entry
// naming this proxy as a route's provider, followed, so a route added or withdrawn reaches a running proxy without a
// restart. **Who is inside** is this proxy's membership (novox/hq ADR 0160, ADR 0167): every machine's address on the
// private network, which is who may be served an internal name; until it is read, an internal name is served to this
// machine alone.
// restart, and said on the bus as it changes (contract.EventServed, EventWithdrawn, EventRefused). **Who is inside**
// is every node's address on the private network as the seat mesh-placement decides it (mesh-sdk
// delivery.InsideAddresses), which is who may be served an internal name; until it is read, an internal name is
// served to this machine alone.
// follow connects with the credential in path, follows the plans and the membership, and retries the connection for as
// long as it takes: until the bus answers, the proxy routes nothing, and says so.
// follow connects with the credential in path, follows the plans and the placement, and retries the connection for
// as long as it takes: until the bus answers, the proxy routes nothing, and says so.
func follow(path string, held *table) {
for {
err := followOnce(path, held)
@@ -64,39 +67,40 @@ func followOnce(path string, held *table) error {
if err := conn.Keeps(keptStates()...); err != nil {
log.Printf("cannot say on the bus what this proxy keeps, so it keeps nothing there: %v", err)
} else {
held.ledger.attach(account.Node, conn.State(routestate.RoutesState), conn.State(routestate.UnroutedState))
held.ledger.attach(account.Node, conn.State(contract.RoutesState), conn.State(contract.UnroutedState))
}
if err := followPlans(conn.Read(provisions.PlansRead), newPlanned(account.Node), held); err != nil {
say := func(eventType string, e contract.Event) {
if err := conn.Emit(eventType, e); err != nil {
log.Printf("cannot say %s of %q on the bus: %v", eventType, e.Name, err)
}
}
// Who is inside first, so the first table is served to the machines it is meant for.
if err := followInside(conn.Read(delivery.PlacementRead), held); err != nil {
log.Printf("cannot follow %s, so an internal name is served to this machine alone: %v", delivery.PlacementRead, err)
}
if err := followPlans(conn.Read(provisions.PlansRead), newPlanned(account.Node, say), held, say); err != nil {
conn.Close()
return err
}
subject := membership.Subject(account.Node, account.Module)
err = conn.FollowMembership(func(issued membership.Membership) {
applyMembership(issued, held)
}, func(err error) { log.Print(err) })
if err != nil {
log.Printf("cannot follow this proxy's membership on %s, so an internal name is served to this machine "+
"alone: %v", subject, err)
}
return nil
}
// planWatch is the seat's plans as this proxy watches them: every current key, then every change.
// planWatch is a state this proxy watches: every current key, then every change.
type planWatch interface {
Watch(key string, handle func(stdio.StateChange) error) error
}
// followPlans serves what the plans give this proxy: the whole table once every current plan is read, then again on
// every change that alters it. A table built from part of the plans would withdraw every route not yet read.
func followPlans(plans planWatch, given *planned, held *table) error {
func followPlans(plans planWatch, given *planned, held *table, say func(string, contract.Event)) error {
var read atomic.Bool // every current plan is read: from here, each change is served as it comes
err := plans.Watch("", func(c stdio.StateChange) error {
var value []byte
if c.Op != "delete" {
if c.Op != "delete" && c.Op != "purge" {
value = c.Value
}
if changed := given.take(c.Key, value); changed && read.Load() {
serve(given, held)
serve(given, held, say)
}
return nil
})
@@ -104,25 +108,87 @@ func followPlans(plans planWatch, given *planned, held *table) error {
return fmt.Errorf("watching %s: %w", provisions.PlansRead, err)
}
read.Store(true)
serve(given, held)
serve(given, held, say)
return nil
}
// serve replaces what the proxy serves with what the plans give it.
func serve(given *planned, held *table) {
// serve replaces what the proxy serves with what the plans give it, and says each name it now serves or serves no
// more.
func serve(given *planned, held *table, say func(string, contract.Event)) {
before := held.served()
routes, public := routesOf(given.contributions())
held.set(routes, public)
after := held.served()
for _, e := range servedChanges(given.node, before, after) {
say(e.kind, e.Event)
}
log.Printf("serving %d route(s) from the seat's plans: %s", len(routes), strings.Join(held.names(), ", "))
}
// applyMembership takes who is inside the mesh from a membership, and says whether it could read it.
func applyMembership(issued membership.Membership, held *table) bool {
inside, err := sourcesOf(issued.Mesh)
// change is one event serve says.
type change struct {
kind string
contract.Event
}
// servedChanges is what changed between two tables' names, each name once, in order: a name now served, or now
// served at another reach or by another module, is served; a name no longer served is withdrawn.
func servedChanges(proxy string, before, after map[string]contract.Event) []change {
var out []change
for _, name := range sortedNames(after) {
if was, ok := before[name]; !ok || was != after[name] {
e := after[name]
e.Proxy = proxy
out = append(out, change{contract.EventServed, e})
}
}
for _, name := range sortedNames(before) {
if _, ok := after[name]; !ok {
out = append(out, change{contract.EventWithdrawn, contract.Event{Proxy: proxy, Name: name}})
}
}
return out
}
func sortedNames(m map[string]contract.Event) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// followInside follows the seat mesh-placement's state and serves an internal name to every node's address on the
// private network it decides, each time it changes (mesh-sdk delivery.InsideAddresses; research 072 rows 33 and 34:
// the membership that carried them went with the assignments stream). A key that does not read keeps who is inside
// as it was.
func followInside(placement planWatch, held *table) error {
keys := delivery.NewPlacementKeys()
var read atomic.Bool
apply := func() {
inside, err := sourcesOf(delivery.InsideAddresses(keys.All()))
if err != nil {
log.Printf("the placement says an address that is not one, so who is inside stays as it was: %v", err)
return
}
held.setInside(inside)
log.Printf("internal names are served to %d machine(s)", len(inside))
}
err := placement.Watch("", func(c stdio.StateChange) error {
if _, err := keys.Put(c.Key, c.Value, c.Revision, c.Op == "delete" || c.Op == "purge"); err != nil {
log.Printf("the placement's key %s does not read, so who is inside stays as it was: %v", c.Key, err)
return nil
}
if read.Load() {
apply()
}
return nil
})
if err != nil {
log.Printf("the mesh in this proxy's membership is unreadable, keeping who is inside as it was: %v", err)
return false
return err
}
held.setInside(inside)
log.Printf("internal names are served to %d machine(s)", len(inside))
return true
read.Store(true)
apply()
return nil
}
+33 -33
View File
@@ -17,7 +17,7 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"route-proxy/internal/routestate"
"git.novox.be/novox/mesh-controller/modules/route-proxy/contract"
)
// What this proxy says on the bus about the names it serves and the names it refuses (issue 774).
@@ -55,9 +55,9 @@ type ledger struct {
node string
routes, unrouted kvStore
served map[string]*routestate.Route
previous map[string]routestate.Route // what the bus held for this machine when it was reached
asked map[string]*routestate.Unrouted
served map[string]*contract.Route
previous map[string]contract.Route // what the bus held for this machine when it was reached
asked map[string]*contract.Unrouted
answerWritten map[string]time.Time
askedWritten map[string]time.Time
@@ -69,7 +69,7 @@ type ledger struct {
domains map[string]bool
newMinute time.Time
newInMinute int
other routestate.Other
other contract.Other
otherMinute time.Time
otherInMinute int
otherDirty bool
@@ -81,9 +81,9 @@ type ledger struct {
func newLedger() *ledger {
return &ledger{
now: time.Now,
served: map[string]*routestate.Route{},
previous: map[string]routestate.Route{},
asked: map[string]*routestate.Unrouted{},
served: map[string]*contract.Route{},
previous: map[string]contract.Route{},
asked: map[string]*contract.Unrouted{},
answerWritten: map[string]time.Time{},
askedWritten: map[string]time.Time{},
dirtyRoutes: map[string]bool{},
@@ -113,12 +113,12 @@ func (l *ledger) attach(node string, routes, unrouted kvStore) {
if l == nil {
return
}
before := map[string]routestate.Route{}
before := map[string]contract.Route{}
if keys, err := routes.Keys(); err != nil {
log.Printf("cannot read back what this proxy said it serves: %v", err)
} else {
for _, key := range keys {
at, name := routestate.Split(key)
at, name := contract.Split(key)
if at != node {
continue
}
@@ -126,7 +126,7 @@ func (l *ledger) attach(node string, routes, unrouted kvStore) {
if err != nil || entry == nil {
continue
}
var r routestate.Route
var r contract.Route
if json.Unmarshal(entry.Value, &r) == nil {
before[name] = r
}
@@ -152,7 +152,7 @@ func (l *ledger) attach(node string, routes, unrouted kvStore) {
}
// keepWhatWasKnown carries a certificate and a last answer read back from the bus onto a route that knows none yet.
func keepWhatWasKnown(r *routestate.Route, before routestate.Route) {
func keepWhatWasKnown(r *contract.Route, before contract.Route) {
if r.Certificate.Outcome == "" && r.Certificate.NotAfter == nil && before.Certificate.Authority == r.Certificate.Authority {
r.Certificate = before.Certificate
}
@@ -170,14 +170,14 @@ func (l *ledger) serving(routes map[string][]rule, public map[string]bool) {
l.mu.Lock()
defer l.mu.Unlock()
for name, rules := range routes {
reach := routestate.ReachInternal
reach := contract.ReachInternal
if public[name] {
reach = routestate.ReachPublic
reach = contract.ReachPublic
}
targets := targetsOf(rules)
r := l.served[name]
if r == nil {
r = &routestate.Route{Name: name}
r = &contract.Route{Name: name}
if before, ok := l.previous[name]; ok {
r.Certificate.Authority = reach
keepWhatWasKnown(r, before)
@@ -190,7 +190,7 @@ func (l *ledger) serving(routes map[string][]rule, public map[string]bool) {
l.dirtyRoutes[name] = true
}
if r.Certificate.Authority != reach {
r.Certificate = routestate.Certificate{Authority: reach}
r.Certificate = contract.Certificate{Authority: reach}
l.dirtyRoutes[name] = true
}
if _, was := l.asked[name]; was {
@@ -200,7 +200,7 @@ func (l *ledger) serving(routes map[string][]rule, public map[string]bool) {
}
l.domains = map[string]bool{}
for name := range routes {
if d := routestate.Registrable(name); d != "" {
if d := contract.Registrable(name); d != "" {
l.domains[d] = true
}
}
@@ -214,10 +214,10 @@ func (l *ledger) serving(routes map[string][]rule, public map[string]bool) {
l.wake()
}
func targetsOf(rules []rule) []routestate.Target {
out := make([]routestate.Target, 0, len(rules))
func targetsOf(rules []rule) []contract.Target {
out := make([]contract.Target, 0, len(rules))
for _, r := range rules {
out = append(out, routestate.Target{Path: r.path, Module: r.from, Node: r.node, Target: r.target,
out = append(out, contract.Target{Path: r.path, Module: r.from, Node: r.node, Target: r.target,
Deny: r.policy.deny, Redirect: r.policy.redirectTo, Auth: len(r.policy.users) > 0 || r.policy.sealed != "",
Sealed: r.policy.sealed})
}
@@ -241,7 +241,7 @@ func (l *ledger) answered(name, path string, status int, failed error) {
why = failed.Error()
}
before := r.LastAnswer
r.LastAnswer = &routestate.Answer{Status: status, At: now, Error: why, Path: path}
r.LastAnswer = &contract.Answer{Status: status, At: now, Error: why, Path: path}
if before != nil && before.Status == status && before.Error == why && now.Sub(l.answerWritten[name]) < refreshAfter {
return
}
@@ -268,11 +268,11 @@ func (l *ledger) refused(name string, internalOnly, handshake bool) {
now := l.now()
a := l.asked[name]
if a == nil {
if !routestate.Keyable(name) || !l.domains[routestate.Registrable(name)] || !l.mayAddName(now) {
if !contract.Keyable(name) || !l.domains[contract.Registrable(name)] || !l.mayAddName(now) {
l.countOther(now)
return
}
a = &routestate.Unrouted{Name: name}
a = &contract.Unrouted{Name: name}
l.asked[name] = a
}
a.AskedAt, a.InternalOnly, a.Handshake = now, internalOnly, handshake
@@ -345,7 +345,7 @@ func (l *ledger) certified(name string, leaf *x509.Certificate) {
notAfter := leaf.NotAfter.UTC()
r.Certificate.Issuer, r.Certificate.NotAfter = issuerOf(leaf), &notAfter
r.Certificate.RenewedAt, r.Certificate.At = &now, &now
r.Certificate.Outcome, r.Certificate.Error = routestate.Issued, ""
r.Certificate.Outcome, r.Certificate.Error = contract.Issued, ""
l.dirtyRoutes[name] = true
l.wake()
}
@@ -362,13 +362,13 @@ func (l *ledger) handedOut(name string, leaf *x509.Certificate) {
return
}
notAfter := leaf.NotAfter.UTC()
if r.Certificate.NotAfter != nil && r.Certificate.NotAfter.Equal(notAfter) && r.Certificate.Outcome != routestate.Failed {
if r.Certificate.NotAfter != nil && r.Certificate.NotAfter.Equal(notAfter) && r.Certificate.Outcome != contract.Failed {
return
}
now := l.now()
r.Certificate.Issuer, r.Certificate.NotAfter, r.Certificate.At = issuerOf(leaf), &notAfter, &now
if r.Certificate.Outcome != routestate.Issued || r.Certificate.Error != "" {
r.Certificate.Outcome, r.Certificate.Error = routestate.Cached, ""
if r.Certificate.Outcome != contract.Issued || r.Certificate.Error != "" {
r.Certificate.Outcome, r.Certificate.Error = contract.Cached, ""
}
l.dirtyRoutes[name] = true
l.wake()
@@ -386,11 +386,11 @@ func (l *ledger) certificateFailed(name string, failed error) {
return
}
now := l.now()
same := r.Certificate.Outcome == routestate.Failed && r.Certificate.Error == failed.Error()
same := r.Certificate.Outcome == contract.Failed && r.Certificate.Error == failed.Error()
if same && r.Certificate.At != nil && now.Sub(*r.Certificate.At) < refreshAfter {
return
}
r.Certificate.Outcome, r.Certificate.Error, r.Certificate.At = routestate.Failed, failed.Error(), &now
r.Certificate.Outcome, r.Certificate.Error, r.Certificate.At = contract.Failed, failed.Error(), &now
l.dirtyRoutes[name] = true
l.wake()
}
@@ -423,7 +423,7 @@ func (l *ledger) flush() {
l.forgetOldestAsked()
var puts []put
for name := range l.dirtyRoutes {
p := put{store: l.routes, key: routestate.Key(l.node, name), route: true}
p := put{store: l.routes, key: contract.Key(l.node, name), route: true}
if r := l.served[name]; r != nil {
c := *r
c.Proxy = l.node
@@ -432,7 +432,7 @@ func (l *ledger) flush() {
puts = append(puts, p)
}
for name := range l.dirtyAsked {
p := put{store: l.unrouted, key: routestate.Key(l.node, name)}
p := put{store: l.unrouted, key: contract.Key(l.node, name)}
if a := l.asked[name]; a != nil {
c := *a
c.Proxy = l.node
@@ -443,7 +443,7 @@ func (l *ledger) flush() {
if l.otherDirty {
o := l.other
o.Proxy = l.node
puts = append(puts, put{store: l.unrouted, key: routestate.Key(l.node, routestate.OtherName), value: o})
puts = append(puts, put{store: l.unrouted, key: contract.Key(l.node, contract.OtherName), value: o})
}
l.dirtyRoutes, l.dirtyAsked, l.otherDirty = map[string]bool{}, map[string]bool{}, false
l.mu.Unlock()
@@ -466,7 +466,7 @@ func (l *ledger) flush() {
log.Printf("cannot write %s to the bus, kept for the next change: %v", p.key, err)
}
l.routesFailing = true
_, name := routestate.Split(p.key)
_, name := contract.Split(p.key)
l.dirtyRoutes[name] = true
default:
if !l.askFailed {
@@ -25,7 +25,7 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
"route-proxy/internal/routestate"
"git.novox.be/novox/mesh-controller/modules/route-proxy/contract"
)
// fakeState is a state on the bus, in memory, counting what was written to it.
@@ -68,13 +68,13 @@ func (f *fakeState) Get(key string) (*stdio.StateEntry, error) {
return &stdio.StateEntry{Key: key, Value: v}, nil
}
func (f *fakeState) route(t *testing.T, key string) *routestate.Route {
func (f *fakeState) route(t *testing.T, key string) *contract.Route {
t.Helper()
raw, ok := f.values[key]
if !ok {
return nil
}
var r routestate.Route
var r contract.Route
if err := json.Unmarshal(raw, &r); err != nil {
t.Fatal(err)
}
@@ -117,8 +117,8 @@ func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) {
}()
_, port, _ := net.SplitHostPort(gone)
contributions := []contribution{
{From: "grafana", Node: "anchor", At: "127.0.0.1", Values: map[string]any{"name": "grafana.example", "port": mustFloat(t, port)}},
{From: "drive", Node: "home-server", At: "10.0.0.2", Values: map[string]any{"internal-name": "drive.internal", "port": 8080.0}},
{From: "grafana", Node: "anchor", At: "127.0.0.1", Route: contract.Request{Name: "grafana.example", Port: int(mustFloat(t, port))}},
{From: "drive", Node: "home-server", At: "10.0.0.2", Route: contract.Request{InternalName: "drive.internal", Port: 8080}},
}
for _, row := range []struct {
@@ -130,12 +130,12 @@ func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) {
func(t *testing.T, held *table) {},
func(t *testing.T, routes, _ *fakeState) {
r := routes.route(t, "anchor.grafana.example")
if r == nil || r.Proxy != "anchor" || r.Reach != routestate.ReachPublic || len(r.Targets) != 1 ||
if r == nil || r.Proxy != "anchor" || r.Reach != contract.ReachPublic || len(r.Targets) != 1 ||
r.Targets[0].Module != "grafana" || r.Targets[0].Node != "anchor" || r.Targets[0].Target != "http://127.0.0.1:"+port {
t.Fatalf("grafana's route is not on the bus as served: %+v", r)
}
if d := routes.route(t, "anchor.drive.internal"); d == nil || d.Reach != routestate.ReachInternal ||
d.Certificate.Authority != routestate.ReachInternal {
if d := routes.route(t, "anchor.drive.internal"); d == nil || d.Reach != contract.ReachInternal ||
d.Certificate.Authority != contract.ReachInternal {
t.Fatalf("an internal-only name is not on the bus as internal: %+v", d)
}
}},
@@ -172,7 +172,7 @@ func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) {
req.RemoteAddr = "192.0.2.7:51000"
handler(held).ServeHTTP(httptest.NewRecorder(), req)
held.ledger.flush()
var a routestate.Unrouted
var a contract.Unrouted
raw := held.ledger.unrouted.(*fakeState).values["anchor.wiki.grafana.example"]
if err := json.Unmarshal(raw, &a); err != nil || a.Times != 1 || a.AskedAt.IsZero() {
t.Fatalf("the refused name is not on the bus as asked: %s", raw)
@@ -181,7 +181,7 @@ func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) {
t.Fatalf("who asked is on the bus: %s", raw)
}
routes, public := routesOf(append(contributions, contribution{From: "grafana", Node: "anchor",
Values: map[string]any{"name": "wiki.grafana.example", "port": 3000.0}}))
Route: contract.Request{Name: "wiki.grafana.example", Port: 3000}}))
held.set(routes, public)
},
func(t *testing.T, _, unrouted *fakeState) {
@@ -197,7 +197,7 @@ func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) {
_, _ = pick(&tls.ClientHelloInfo{ServerName: "forgotten.grafana.example"})
},
func(t *testing.T, _, unrouted *fakeState) {
var a routestate.Unrouted
var a contract.Unrouted
if err := json.Unmarshal(unrouted.values["anchor.forgotten.grafana.example"], &a); err != nil || !a.Handshake {
t.Fatalf("a handshake refused for its name is not on the bus: %v", unrouted.values)
}
@@ -232,7 +232,7 @@ func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) {
held.ledger.refused("w.scan.test", false, false)
},
func(t *testing.T, _, unrouted *fakeState) {
var o routestate.Other
var o contract.Other
if err := json.Unmarshal(unrouted.values["anchor._other"], &o); err != nil || o.LastMinute != 2 || o.SinceStart != 78 {
t.Fatalf("the refusals not kept by name are not counted on the bus: %s", unrouted.values["anchor._other"])
}
@@ -247,7 +247,7 @@ func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) {
},
func(t *testing.T, routes, _ *fakeState) {
c := routes.route(t, "anchor.grafana.example").Certificate
if c.Outcome != routestate.Issued || c.Issuer != "Let's Encrypt R11" || c.NotAfter == nil ||
if c.Outcome != contract.Issued || c.Issuer != "Let's Encrypt R11" || c.NotAfter == nil ||
!c.NotAfter.Equal(notAfter) || c.RenewedAt == nil {
t.Fatalf("the issued certificate is not on the bus: %+v", c)
}
@@ -261,7 +261,7 @@ func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) {
},
func(t *testing.T, routes, _ *fakeState) {
c := routes.route(t, "anchor.grafana.example").Certificate
if c.Outcome != routestate.Failed || c.Error != "acme: urn:ietf:params:acme:error:rateLimited" {
if c.Outcome != contract.Failed || c.Error != "acme: urn:ietf:params:acme:error:rateLimited" {
t.Fatalf("the failed certificate is not on the bus: %+v", c)
}
}},
+42 -132
View File
@@ -64,6 +64,8 @@ import (
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
"golang.org/x/crypto/bcrypt"
"git.novox.be/novox/mesh-controller/modules/route-proxy/contract"
)
// Where public certificates come from when nothing says otherwise.
@@ -116,15 +118,16 @@ func onlyInternalNamesTheMeshSaid(held *table) autocert.HostPolicy {
}
}
// contribution is one route a consumer asked for: the module, its machine, where that machine is, and what it
// contributes, composed by the mesh (its plan's entry for the route, plans.go).
// contribution is one route a consumer asked for: the module, its machine, where that machine is, the local name of
// its contribution, and the route, composed by the mesh and read with route-proxy's contract (plans.go).
type contribution struct {
From string `json:"from"`
Node string `json:"node"`
// At is where that machine is on the private network. The mesh knows it; a proxy that had to
// build it from the node name would be a naming convention copied into every provider.
At string `json:"at"`
Values map[string]any `json:"values"`
At string `json:"at"`
Local string `json:"local,omitempty"`
Route contract.Request `json:"route"`
}
// policy is what a rule does with a request that matched it.
@@ -407,6 +410,24 @@ func (t *table) namesSeenFrom(remote string) []string {
return out
}
// served is every name served, as an event says it: how far it reaches and the module whose rule serves it first.
func (t *table) served() map[string]contract.Event {
t.mu.RLock()
defer t.mu.RUnlock()
out := make(map[string]contract.Event, len(t.to))
for name, rules := range t.to {
e := contract.Event{Name: name, Reach: contract.ReachInternal}
if t.public[name] {
e.Reach = contract.ReachPublic
}
if len(rules) > 0 {
e.Module, e.Node = rules[0].from, rules[0].node
}
out[name] = e
}
return out
}
func (t *table) names() []string {
t.mu.RLock()
defer t.mu.RUnlock()
@@ -857,38 +878,15 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
out := map[string][]rule{}
public := map[string]bool{}
for _, c := range contributions {
name, _ := c.Values["name"].(string)
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)
continue
}
// What the route is called in a log line: its public name when it has one.
called := name
if called == "" {
called = internal
}
made := rule{path: asPath(c.Values["path"])}
if p, ok := asWhole(c.Values["priority"]); ok {
made.priority = p
}
made.policy.deny, _ = c.Values["deny"].(bool)
made.policy.redirectTo, _ = c.Values["redirect"].(string)
if named, carried := c.Values["auth"].(string); carried && strings.TrimSpace(named) != "" {
// **A declaration names a secret; it never holds one** (ADR 0108). Refused rather than
// tolerated, and the whole rule is dropped rather than served unprotected — the
// rejected option cannot come back by accident, which is the failure this check exists
// to make impossible.
if looksLikeACredential(named) {
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)",
c.From, c.Node, called)
continue
}
// The contract admitted the route whole (contract.Request.Validate): it names a host, a rule that forwards
// gives a port, its scheme is http or https, its body limit is whole and positive, and its auth names a
// secret's file rather than holding a credential (novox/hq ADR 0108).
r := c.Route
name := strings.TrimSpace(r.Name)
internal := strings.TrimSpace(r.InternalName)
made := rule{path: r.Prefix(), priority: r.Priority}
made.policy.deny, made.policy.redirectTo = r.Deny, r.Redirect
if named := strings.TrimSpace(r.Auth); named != "" {
users, err := usersFrom(named)
if err != nil {
// Fail closed: the rule is kept so the name stays routed and answers, and it
@@ -900,13 +898,7 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
}
// Only a rule that actually proxies needs somewhere to send the request.
if !made.policy.deny && made.policy.redirectTo == "" {
port, ok := asPort(c.Values["port"])
if !ok {
log.Printf("%s on %s asked for route %q and gave no usable port; skipped",
c.From, c.Node, called)
continue
}
if r.Forwards() {
// Where the mesh says that machine is. Empty means it is this one — a workload beside
// the proxy is ordinary, and reaching it over loopback is both correct and the only
// thing that works when there is no private network.
@@ -916,32 +908,12 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
}
// http unless the contribution says otherwise. A backend that terminates its own TLS
// with a certificate this proxy has no reason to trust — Mailu's webmail front is the
// first of these — is the reason `insecure` exists, and it stays the exception: every
// other target the mesh hands this proxy is a plain workload on the private network.
scheme, _ := c.Values["scheme"].(string)
scheme = strings.ToLower(strings.TrimSpace(scheme))
if scheme == "" {
scheme = "http"
// first of these — is the reason `insecure` exists, and it stays the exception.
made.insecure = r.Insecure
if r.MaxRequestBody != nil {
made.maxRequestBody = *r.MaxRequestBody
}
if scheme != "http" && scheme != "https" {
log.Printf("%s on %s asked for route %q with scheme %q, which is neither http "+
"nor https; skipped", c.From, c.Node, called, scheme)
continue
}
made.insecure, _ = c.Values["insecure"].(bool)
// A limit this proxy cannot read is a route it does not serve, named like a port that
// is not a port. Serving it without the limit would carry exactly what the module said
// not to carry, and report success doing it.
if asked, said := c.Values["max-request-body"]; said {
bytes, whole := asWhole(asked)
if !whole || bytes <= 0 {
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, called, asked)
continue
}
made.maxRequestBody = int64(bytes)
}
made.target = fmt.Sprintf("%s://%s:%d", scheme, at, port)
made.target = fmt.Sprintf("%s://%s:%d", r.SchemeOrDefault(), at, r.Port)
}
made.from, made.node = c.From, c.Node
@@ -955,9 +927,8 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
// 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
// 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
// already has. And the only name, when the endpoint reaches no further than the private
// network.
// 0056's internalDomain half). And the only name, when the endpoint reaches no further than
// the private network.
if internal != "" {
out[strings.ToLower(internal)] = append(out[strings.ToLower(internal)], made)
}
@@ -965,50 +936,6 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
return out, public
}
// asWhole is any whole number the mesh wrote, whatever its magnitude.
//
// **Not asPort.** Priority was read with the port reader first, which caps at 65535 — so a rule
// declared at a priority above that silently became priority 0 and stopped shadowing the route it
// exists to shadow. The one real rule this has to reproduce is declared at 100000, so the bug was
// exactly load-bearing. A priority is an ordering, not a port: it has no range.
func asWhole(v any) (int, bool) {
switch n := v.(type) {
case float64:
// JSON makes a float of every number, so a non-integral one was not meant as a priority.
if n != float64(int(n)) {
return 0, false
}
return int(n), true
case int:
return n, true
}
return 0, false
}
// asPath is the path prefix a rule is scoped to, or "" for every path.
func asPath(v any) string {
p, _ := v.(string)
p = strings.TrimSpace(p)
if p == "" {
return ""
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return p
}
// looksLikeACredential is the check that keeps a secret out of a declaration.
//
// It errs towards refusing: a value holding a `:` (the htpasswd separator) or opening with a bcrypt
// identifier is a credential, not a path, and no filesystem path the mesh writes needs either. A
// false refusal is a loud log and a route that does not serve; a false accept is a credential
// committed to a declaration, which is the thing being prevented.
func looksLikeACredential(v string) bool {
v = strings.TrimSpace(v)
return strings.Contains(v, ":") || strings.HasPrefix(v, "$2")
}
// usersFrom reads the credentials the mesh mounted, in the one format every htpasswd already is.
func usersFrom(path string) (map[string]string, error) {
raw, err := os.ReadFile(path)
@@ -1033,23 +960,6 @@ func usersFrom(path string) (map[string]string, error) {
return users, nil
}
// asPort accepts what JSON makes of a number, which is a float even when it was written 8080.
func asPort(v any) (int, bool) {
switch n := v.(type) {
case float64:
if n < 1 || n > 65535 {
return 0, false
}
return int(n), true
case int:
if n < 1 || n > 65535 {
return 0, false
}
return n, true
}
return 0, false
}
// towards proxies to one backend and tells it what the client asked: **X-Forwarded-Proto, -Host and
// -For**, set from the request this proxy received. A backend that builds its own addresses — a forge
// writing its clone URL into a page, a login redirect — otherwise sees the plain HTTP hop from this
+54 -30
View File
@@ -3,12 +3,13 @@ package main
// The routes this proxy serves, read from the seat mesh-provisioner's plans and nothing else (issue 774; research 072
// row 34, the operator on 2026-10-31: "there is only 1 path, the other is simply deprecated/invalid").
//
// **A route is a provision.** A module that must be reachable requires `route`; the controller composes its plan on
// its node, and the plan's entry for the route names this proxy as its provider and carries what the module
// contributes, composed as the received file used to carry it (its names joined to the node's domains, its endpoint's
// port, its policy), and where the module's machine is on the private network. The proxy follows the plans on the bus
// **A route is a provision, in route-proxy's own contract.** A module that must be reachable requires `route`; the
// controller composes its plan on its node, and the plan's entry for the route names this proxy as its provider and
// carries what the module contributes, composed (its names joined to the node's domains, its endpoint's port), and
// where the module's machine is on the private network, as one opaque payload the seat never reads (mesh-sdk
// catalogue.PlanProvision.Contributed). The proxy decodes each contribution with its contract (contract.DecodeRequest)
// and serves every entry naming it. There is no file and no second source: a route the plans do not name is not
// served.
// served, and a route the contract refuses is said refused (contract.EventRefused), never served half.
import (
"fmt"
@@ -19,23 +20,28 @@ import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/provisions"
"route-proxy/internal/routestate"
"git.novox.be/novox/mesh-controller/modules/route-proxy/contract"
)
// planned is the routes the seat's plans give this proxy on its node, by plan key, kept so a plan that cannot be read
// leaves what it gave served (a bind missed would read as a route withdrawn).
type planned struct {
node string
// said is where a refusal is told: the bus's events, nothing in a test that does not ask.
said func(eventType string, e contract.Event)
mu sync.Mutex
by map[string][]contribution
}
func newPlanned(node string) *planned {
return &planned{node: node, by: map[string][]contribution{}}
func newPlanned(node string, said func(string, contract.Event)) *planned {
if said == nil {
said = func(string, contract.Event) {}
}
return &planned{node: node, said: said, by: map[string][]contribution{}}
}
// take is one change of a plan's key: its value, or nil when the key was deleted. It answers whether what is served
// changed, and says on the log what it could not serve.
// changed, and says on the log and the bus what it could not serve.
func (p *planned) take(key string, value []byte) bool {
p.mu.Lock()
defer p.mu.Unlock()
@@ -44,13 +50,14 @@ func (p *planned) take(key string, value []byte) bool {
delete(p.by, key)
return had
}
gave, problems, err := routesOfPlan(p.node, key, value)
gave, refused, err := routesOfPlan(p.node, key, value)
if err != nil {
log.Printf("the plan %s is not readable, so what it routes is kept as it was: %v", key, err)
return false
}
for _, problem := range problems {
log.Print(problem)
for _, r := range refused {
log.Printf("the plan %s gives %s on %s a route this proxy does not serve: %s", key, r.Module, r.Node, r.Why)
p.said(contract.EventRefused, r)
}
if len(gave) == 0 {
_, had := p.by[key]
@@ -77,12 +84,12 @@ func (p *planned) contributions() []contribution {
return out
}
// routesOfPlan is the routes one plan gives the proxy on node: each contribution of each entry naming this proxy on
// node as the route's provider, reached on its own machine over loopback and on another at the address the plan says.
// A plan of another module, or one routing elsewhere, gives none. Problems are what the plan names and the proxy
// cannot serve, said rather than skipped silently: an entry carrying nothing to serve (a controller before the plans
// carried it), or a consumer elsewhere with no address on the private network.
func routesOfPlan(node, key string, value []byte) ([]contribution, []string, error) {
// routesOfPlan is the routes one plan gives the proxy on node: each contribution the plan's entries naming this proxy
// on node as the route's provider carry, decoded with the contract, reached on its own machine over loopback and on
// another at the address the plan says. A plan of another module, or one routing elsewhere, gives none. Refused is
// what the plan names and the proxy does not serve, each with why: an entry carrying nothing to serve, a payload or a
// route the contract does not read, or a consumer elsewhere with no address on the private network.
func routesOfPlan(node, key string, value []byte) ([]contribution, []contract.Event, error) {
plan, err := provisions.DecodePlan(value)
if err != nil {
return nil, nil, err
@@ -91,33 +98,50 @@ func routesOfPlan(node, key string, value []byte) ([]contribution, []string, err
return nil, nil, fmt.Errorf("it is the plan of %s.%s, under the key %s", plan.Node, plan.Module, key)
}
var out []contribution
var problems []string
var refused []contract.Event
refuse := func(local, why string) {
refused = append(refused, contract.Event{Proxy: node, Module: plan.Module, Node: plan.Node, Local: local, Why: why})
}
named, carried := false, false
for _, e := range plan.Plan.Provisions {
if !routesHere(node, e) {
continue
}
if len(e.Given) == 0 {
problems = append(problems, fmt.Sprintf("the plan %s names this proxy for its route and gives nothing "+
"to serve; it is not served", key))
named = true
// What the module contributes is on the requirement's first entry alone, however many local names it has.
if len(e.Contributed) == 0 {
continue
}
carried = true
given, err := catalogue.DecodeContributed(e.Contributed)
if err != nil {
refuse("", err.Error())
continue
}
at := ""
if plan.Node != node {
if e.ConsumerAt == "" {
problems = append(problems, fmt.Sprintf("the plan %s routes %s on %s here and says no address on "+
"the private network for it; it is not served", key, plan.Module, plan.Node))
if given.At == "" {
refuse("", fmt.Sprintf("it runs on %s and its plan says no address on the private network for it", plan.Node))
continue
}
at = e.ConsumerAt
at = given.At
}
for _, g := range e.Given {
out = append(out, contribution{From: plan.Module, Node: plan.Node, At: at, Values: g.Values})
for _, one := range given.Each {
route, err := contract.DecodeRequest(one.Values)
if err != nil {
refuse(one.Local, err.Error())
continue
}
out = append(out, contribution{From: plan.Module, Node: plan.Node, At: at, Local: one.Local, Route: route})
}
}
return out, problems, nil
if named && !carried {
refuse("", "its plan names this proxy for its route and carries nothing to serve")
}
return out, refused, nil
}
// routesHere is whether a plan's entry is a route this proxy on node provides.
func routesHere(node string, e catalogue.PlanProvision) bool {
return e.Provision == routestate.Provision && e.Provider == node && e.ProviderModule == routestate.Module
return e.Provision == contract.Provision && e.Provider == node && e.ProviderModule == contract.Module
}
@@ -10,11 +10,15 @@ import (
"net/http/httptest"
"net/url"
"reflect"
"slices"
"sort"
"strings"
"testing"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/membership"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-controller/modules/route-proxy/contract"
)
// behind is a workload the proxy can send to, and a table routing one public name and one
@@ -189,28 +193,42 @@ func plan(node, module, entries string) string {
`"provisions":[%s]}}`, node, module, entries)
}
// contributed is a plan entry's opaque payload: the consumer's machine at at, and each contribution by local name.
func contributed(at string, each ...string) string {
var parts []string
for i := 0; i+1 < len(each); i += 2 {
parts = append(parts, fmt.Sprintf(`{"local":%q,"values":%s}`, each[i], each[i+1]))
}
return fmt.Sprintf(`"contributed":{"v":1,"at":%q,"each":[%s]}`, at, strings.Join(parts, ","))
}
// What the plans give is what is served, and nothing else (issue 774, research 072 row 34): each entry naming this
// proxy on its node as the route's provider, every contribution of it, reached over loopback on this machine and at
// the plan's address on another; an entry routing elsewhere, giving nothing or naming no address is not served. A
// change is served as it comes, a plan that cannot be read keeps what it gave, and a deleted one withdraws it. The
// membership says only who is inside: an internal-only name is served to the machines it names.
func TestThePlansAreServedAndTheMembershipSaysWhoIsInside(t *testing.T) {
const gitea = `{"provision":"route","provider":"anchor","provider-module":"route-proxy","consumer-at":"10.10.0.7",` +
`"given":[{"local":"web","values":{"name":"git.example","internal-name":"git.anchor.internal","port":3000}},` +
`{"local":"api","values":{"internal-name":"git-api.anchor.internal","port":3001}}]}`
// proxy on its node as the route's provider, every contribution of it read with route-proxy's contract, reached over
// loopback on this machine and at the plan's address on another; an entry routing elsewhere, carrying nothing, naming
// no address or a route the contract refuses is not served, and is said refused. A change is served as it comes and
// said served or withdrawn, a plan that cannot be read keeps what it gave, and a deleted one withdraws it. The
// placement says only who is inside: an internal-only name is served to the machines it decides addresses for.
func TestThePlansAreServedAndThePlacementSaysWhoIsInside(t *testing.T) {
const route = `{"provision":"route","provider":"anchor","provider-module":"route-proxy",`
gitea := route + contributed("10.10.0.7",
"web", `{"name":"git.example","internal-name":"git.anchor.internal","port":3000}`,
"api", `{"internal-name":"git-api.anchor.internal","port":3001}`,
"refused", `{"name":"bad.example","port":3002,"host":"x"}`) + `}`
plans := &fakePlans{current: map[string]string{
"laptop.gitea": plan("laptop", "gitea", gitea+`,{"provision":"postgres-database","provider":"anchor",`+
`"provider-module":"postgres","given":[{"values":{"name":"gitea"}}]}`),
"anchor.blog": plan("anchor", "blog", `{"provision":"route","provider":"anchor","provider-module":"route-proxy",`+
`"consumer-at":"10.10.0.1","given":[{"values":{"name":"blog.example","port":2368}}]}`),
`"provider-module":"postgres",`+contributed("10.10.0.7", "", `{"name":"gitea"}`)+`}`),
"anchor.blog": plan("anchor", "blog", route+contributed("10.10.0.1", "", `{"name":"blog.example","port":2368}`)+`}`),
"anchor.other": plan("anchor", "other", `{"provision":"route","provider":"edge","provider-module":"route-proxy",`+
`"given":[{"values":{"name":"other.example","port":80}}]}`),
"anchor.older": plan("anchor", "older", `{"provision":"route","provider":"anchor","provider-module":"route-proxy"}`),
"laptop.lost": plan("laptop", "lost", `{"provision":"route","provider":"anchor","provider-module":"route-proxy",`+
`"given":[{"values":{"name":"lost.example","port":80}}]}`),
contributed("", "", `{"name":"other.example","port":80}`)+`}`),
"anchor.older": plan("anchor", "older", route+`"local":""}`),
"laptop.lost": plan("laptop", "lost", route+contributed("", "", `{"name":"lost.example","port":80}`)+`}`),
}}
held := newTable()
if err := followPlans(plans, newPlanned("anchor"), held); err != nil {
var said []string
say := func(kind string, e contract.Event) {
said = append(said, kind+" "+e.Proxy+" "+e.Name+" "+e.Module+" "+e.Local+" "+e.Why)
}
if err := followPlans(plans, newPlanned("anchor", say), held, say); err != nil {
t.Fatal(err)
}
served := func() map[string]string {
@@ -230,17 +248,36 @@ func TestThePlansAreServedAndTheMembershipSaysWhoIsInside(t *testing.T) {
if !held.eligibleForACME("git.example") || held.eligibleForACME("git-api.anchor.internal") {
t.Error("a public name is not certified publicly, or an internal-only one is")
}
// Each refusal said with why, and each name served said once.
sort.Strings(said)
for _, w := range []string{
"route.refused anchor gitea refused not a route this contract reads: json: unknown field \"host\"",
"route.refused anchor lost it runs on laptop and its plan says no address on the private network for it",
"route.refused anchor older its plan names this proxy for its route and carries nothing to serve",
"route.served anchor blog.example blog ",
"route.served anchor git-api.anchor.internal gitea ",
} {
if !slices.Contains(said, w) {
t.Errorf("not said %q, said:\n%s", w, strings.Join(said, "\n"))
}
}
if len(said) != 7 {
t.Errorf("said %d events, want 3 refusals and 4 names served:\n%s", len(said), strings.Join(said, "\n"))
}
for _, row := range []struct {
name, key, value string
served []string
withdrawn []string
say []string
}{
{"a route arrives", "anchor.new", plan("anchor", "new", `{"provision":"route","provider":"anchor",`+
`"provider-module":"route-proxy","given":[{"values":{"name":"new.example","port":81}}]}`),
[]string{"new.example"}, nil},
{"an unreadable plan keeps what it gave", "laptop.gitea", `{"v":1,`, []string{"git.example"}, nil},
{"a deleted plan withdraws its routes", "laptop.gitea", "", nil, []string{"git.example", "git-api.anchor.internal"}},
{"a route arrives", "anchor.new", plan("anchor", "new", route+contributed("", "", `{"name":"new.example","port":81}`)+`}`),
[]string{"new.example"}, nil, []string{"route.served anchor new.example new "}},
{"an unreadable plan keeps what it gave", "laptop.gitea", `{"v":1,`, []string{"git.example"}, nil, nil},
{"a deleted plan withdraws its routes", "laptop.gitea", "", nil, []string{"git.example", "git-api.anchor.internal"},
[]string{"route.withdrawn anchor git-api.anchor.internal ", "route.withdrawn anchor git.anchor.internal ",
"route.withdrawn anchor git.example "}},
} {
said = nil
change := stdio.StateChange{Key: row.key, Op: "put", Value: json.RawMessage(row.value)}
if row.value == "" {
change = stdio.StateChange{Key: row.key, Op: "delete"}
@@ -259,22 +296,36 @@ func TestThePlansAreServedAndTheMembershipSaysWhoIsInside(t *testing.T) {
t.Errorf("%s: %s is still served", row.name, host)
}
}
if !slices.Equal(said, row.say) {
t.Errorf("%s: said %q, want %q", row.name, said, row.say)
}
}
held = behind(t)
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code != http.StatusNotFound {
t.Errorf("before a membership, a machine of the mesh was served an internal-only name: %d", code)
t.Errorf("before the placement is read, a machine of the mesh was served an internal-only name: %d", code)
}
if !applyMembership(membership.Membership{Mesh: []string{"10.10.0.7"}}, held) {
t.Fatal("a membership naming the mesh was not applied")
laptop, err := delivery.PlacementValues(delivery.Placement{Node: "laptop", Decided: delivery.NodeDecided{Address: "10.10.0.7"}})
if err != nil {
t.Fatal(err)
}
placement := &fakePlans{current: map[string]string{}}
for k, v := range laptop {
placement.current[k] = string(v)
}
if err := followInside(placement, held); err != nil {
t.Fatal(err)
}
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")
t.Error("a machine the placement decides an address for was refused the internal-only route")
}
if applyMembership(membership.Membership{Mesh: []string{"not-an-address"}}, held) {
t.Error("a membership whose mesh cannot be read was applied")
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.8:1"); code != http.StatusNotFound {
t.Error("a machine the placement does not name was served the internal-only route")
}
if err := placement.handle(stdio.StateChange{Key: "laptop.node", Op: "put", Value: json.RawMessage(`{`)}); err != nil {
t.Fatal(err)
}
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code == http.StatusNotFound {
t.Error("an unreadable membership changed who is inside")
t.Error("an unreadable placement key changed who is inside")
}
}
@@ -9,18 +9,32 @@ import (
"net/http/httptest"
"strings"
"testing"
"git.novox.be/novox/mesh-controller/modules/route-proxy/contract"
)
// routesFrom is the table a test's routes make, written as `{"given":[<contribution>…]}`: the contributions the plans
// give the proxy (plans.go), each as routesOfPlan makes it.
// routesFrom is the table a test's routes make, written as `{"given":[{"from","node","at","values"}…]}`: the
// contributions the plans give the proxy, each read with route-proxy's contract as routesOfPlan reads it, and one the
// contract refuses left out, as routesOfPlan leaves it out.
func routesFrom(body string) (map[string][]rule, map[string]bool, error) {
var said struct {
Given []contribution `json:"given"`
Given []struct {
From, Node, At string
Values json.RawMessage
} `json:"given"`
}
if err := json.Unmarshal([]byte(body), &said); err != nil {
return nil, nil, err
}
routes, public := routesOf(said.Given)
var given []contribution
for _, g := range said.Given {
route, err := contract.DecodeRequest(g.Values)
if err != nil {
continue
}
given = append(given, contribution{From: g.From, Node: g.Node, At: g.At, Route: route})
}
routes, public := routesOf(given)
return routes, public, nil
}
+2 -1
View File
@@ -3,12 +3,13 @@ module route-proxy
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030231858-b9ef1e4fcb68
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a
github.com/nats-io/nats.go v1.54.0
golang.org/x/crypto v0.57.0
)
require (
git.novox.be/novox/mesh-controller/modules/route-proxy/contract v0.1.0
github.com/klauspost/compress v1.20.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
+4 -2
View File
@@ -1,5 +1,7 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030231858-b9ef1e4fcb68 h1:2rMJX0RMIBaN5NDIaEZVwbj5MFism1O3bRayEz+WlOs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030231858-b9ef1e4fcb68/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-controller/modules/route-proxy/contract v0.1.0 h1:89jE1oBK+l7gFJjz6wx8mUD3KxrqSDa7pUTlQkzC1do=
git.novox.be/novox/mesh-controller/modules/route-proxy/contract v0.1.0/go.mod h1:Yr+Xp89kpMiKH6kA06EU+d9I2CyzrK9yMX7J3pSv0hE=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
@@ -1,158 +0,0 @@
// Package routestate is what the web front keeps on the bus about the names it serves, and about the names it was asked
// for and does not serve (issue 774): the shape both halves of route-proxy agree on. The proxy writes it, on change,
// in its states `routes` and `unrouted`, announced readable; the module's tools read it from there, so the question
// "is this site reachable, and is its certificate good" is answered from the bus rather than with curl.
//
// A key is `<node>.<name>`: the machine whose proxy answers, then the name as asked, lower-cased. A node's name holds
// no dot, so the first dot splits them.
package routestate
import (
"net"
"regexp"
"strings"
"time"
)
// Module is route-proxy, and Provision what it provides: what a plan's entry names when this proxy is a route's provider
// (both halves read the seat mesh-provisioner's plans for it, issue 774).
const (
Module = "route-proxy"
Provision = "route"
)
// The states route-proxy keeps, by their local names (the buckets are route-proxy_<name>).
const (
// RoutesState holds one key per served name: where it forwards, its certificate, the last answer it got.
RoutesState = "routes"
// UnroutedState holds one key per name the proxy answered "no route for" with, and when it was last asked.
UnroutedState = "unrouted"
// BoundState is the provider helper's bucket of consumer entries (mesh-sdk go/provider).
BoundState = "bound"
)
// The words a route's reach and a certificate's authority are said in.
const (
ReachPublic = "public"
ReachInternal = "internal"
)
// The words a certificate's outcome is said in.
const (
// Issued: the authority issued it while this proxy ran, at RenewedAt.
Issued = "issued"
// Cached: this proxy served it from what it kept, and saw no issuance of it since it started.
Cached = "cached"
// Failed: the last attempt to have one failed, Error says why.
Failed = "failed"
)
// Target is one rule a name is served by: what a request matching it reaches, or what it is answered with.
type Target struct {
// Path is the path prefix the rule is scoped to; empty is every path.
Path string `json:"path,omitempty"`
// Module and Node are the consumer that contributed the route and the machine it runs on.
Module string `json:"module,omitempty"`
Node string `json:"node,omitempty"`
// Target is where the request is forwarded, scheme://address:port; empty for a rule that answers itself.
Target string `json:"target,omitempty"`
Deny bool `json:"deny,omitempty"`
Redirect string `json:"redirect,omitempty"`
// Auth says the rule asks for credentials; Sealed says why it cannot check them, and so refuses everything.
Auth bool `json:"auth,omitempty"`
Sealed string `json:"sealed,omitempty"`
}
// Certificate is what this proxy knows of a name's certificate.
type Certificate struct {
// Authority is which issuer certifies the name: public (the ACME directory the module names) or internal (the
// mesh's own).
Authority string `json:"authority"`
Issuer string `json:"issuer,omitempty"`
NotAfter *time.Time `json:"not_after,omitempty"`
// RenewedAt is when an authority last issued it to this proxy.
RenewedAt *time.Time `json:"renewed_at,omitempty"`
// Outcome is issued, cached or failed; empty until a handshake or an issuance said anything.
Outcome string `json:"outcome,omitempty"`
Error string `json:"error,omitempty"`
At *time.Time `json:"at,omitempty"`
}
// Answer is the last answer the proxy got from upstream for a name: the status it carried back, or the error that
// stood in for one (a 502).
type Answer struct {
Status int `json:"status"`
At time.Time `json:"at"`
Error string `json:"error,omitempty"`
Path string `json:"path,omitempty"`
}
// Route is one served name, the value of a key of RoutesState.
type Route struct {
Name string `json:"name"`
// Proxy is the machine whose proxy serves it.
Proxy string `json:"proxy"`
// Reach is public (a route's own name) or internal (only its internal-name, served inside the private network).
Reach string `json:"reach"`
Targets []Target `json:"targets"`
Certificate Certificate `json:"certificate"`
LastAnswer *Answer `json:"last_answer,omitempty"`
}
// Unrouted is one name the proxy answered "no route for", the value of a key of UnroutedState.
type Unrouted struct {
Name string `json:"name"`
Proxy string `json:"proxy"`
// AskedAt is when the name was last asked for. Who asked is not kept here: the journal keeps it, for the
// machine's intrusion prevention.
AskedAt time.Time `json:"asked_at"`
// Times is how often it was asked since the proxy last started.
Times int `json:"times"`
// InternalOnly says the name is served, but only inside the private network, and was asked from outside.
InternalOnly bool `json:"internal_only,omitempty"`
// Handshake says it was asked in a TLS handshake (the name a client sent), not in an HTTP request.
Handshake bool `json:"handshake,omitempty"`
}
// OtherName is the name of the one key per proxy that counts the refusals not kept by name: names under no domain
// the proxy serves (what a scan sends), and names past the limit of new names a minute. Its key is
// `<node>._other`; no host name begins with an underscore.
const OtherName = "_other"
// Other is the value of a proxy's OtherName key.
type Other struct {
Proxy string `json:"proxy"`
// Minute is the last whole minute counted, and LastMinute how many refusals it held.
Minute time.Time `json:"minute"`
LastMinute int `json:"last_minute"`
// SinceStart is every refusal not kept by name since the proxy started.
SinceStart int `json:"since_start"`
}
// Registrable is the domain a name is registered under, as its last two labels; "" for an address or a single
// label. Two labels is wrong for a public suffix of two (co.uk); it only decides which refused names are kept by name.
func Registrable(name string) string {
if net.ParseIP(strings.Trim(name, "[]")) != nil {
return ""
}
labels := strings.Split(name, ".")
if len(labels) < 2 {
return ""
}
return strings.Join(labels[len(labels)-2:], ".")
}
var keyable = regexp.MustCompile(`^[a-z0-9_]([a-z0-9_.-]{0,251}[a-z0-9_])?$`)
// Keyable says whether a name can be a key's name: what a host name or an address is. A name asked for in a request
// is whatever a client sent; one no key could hold is no site anybody runs.
func Keyable(name string) bool { return keyable.MatchString(name) && !strings.Contains(name, "..") }
// Key is the key of name served or asked at node's proxy.
func Key(node, name string) string { return node + "." + name }
// Split is a key's node and name.
func Split(key string) (node, name string) {
node, name, _ = strings.Cut(key, ".")
return node, name
}
+6
View File
@@ -245,6 +245,11 @@
"route_proxy_route",
"route_proxy_unrouted"
],
"emits": [
"route.served",
"route.withdrawn",
"route.refused"
],
"replaces": {
"route_proxy_routes": [
"curl -I"
@@ -257,6 +262,7 @@
]
},
"reads": [
"mesh-delivery-assigner.placement",
"mesh-provisioner.plans",
"mesh-provisioner.provisions"
]