Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c58b311ea7 | ||
|
|
7a169eae76 | ||
|
|
a07621d761 | ||
|
|
d791052f77 | ||
|
|
22935b9202 |
@@ -372,7 +372,7 @@ func TestTheAccountsAModuleSpeaksWithOnANode(t *testing.T) {
|
|||||||
t.Error("sealed no password")
|
t.Error("sealed no password")
|
||||||
}
|
}
|
||||||
return c.sealErr
|
return c.sealErr
|
||||||
})
|
}, func() error { acts.did("after"); return nil })
|
||||||
if (err != nil) != (c.sealErr != nil) {
|
if (err != nil) != (c.sealErr != nil) {
|
||||||
t.Fatalf("error %v, want %v", err, c.sealErr)
|
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
|
// 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
|
// 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).
|
// are left alone (issue 1945: ace's mesh-build-go rows from 17:19, marked sealed by migration 0108).
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
"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"
|
"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
|
// 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
|
// 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
|
// 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
|
// raises `secret-changed`; a re-issue is the same hand with a new value, which replaces the old in place. Until issue 774 an
|
||||||
// not covered is minted and kept as it was and handed to nobody.
|
// 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.
|
// vaultAskerForIssue is the vault as `module issue` asks it. A test replaces it.
|
||||||
var vaultAskerForIssue = func(ctx context.Context) vaultAsker { return theVault(ctx) }
|
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,
|
// 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
|
// 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
|
// 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
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handIssuedToVault hands the value just minted for module's own secret name on node to the vault, when the module's
|
// grantsTookOver says whether the grants part took over the bus's user list: its key `user-list.sealed` says
|
||||||
// accounts are covered, and does nothing otherwise. Loud when the vault does not take it: the bus's user list
|
// FollowGrants (novox/hq ADR 0547 clauses 7 and 8), which the bus node's engine follows. No bucket or no key yet is no
|
||||||
// already holds the new hash, so a vault that kept the old value would serve a password that no longer logs in.
|
// take-over; a bus that cannot be read is an error, never a guess. A test replaces it.
|
||||||
func handIssuedToVault(ctx context.Context, inv *inventory.Inventory, m catalogue.Manifest, node, name, held string) error {
|
var grantsTookOver = func(ctx context.Context) (bool, error) {
|
||||||
if !accountIsCovered(m) {
|
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
|
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 {
|
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,
|
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)
|
m.Module, err)
|
||||||
|
|||||||
@@ -8,21 +8,50 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go/jetstream"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
"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/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() {
|
func init() {
|
||||||
vaultAskerForIssue = func(context.Context) vaultAsker { return newStandInVault(nil).ask }
|
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
|
// theGrantsPartTookOver says the grants part's take-over is on, or off, for the rest of the test.
|
||||||
// to the key the vault made for the call, with origin issued. A module that is neither core nor moved hands nothing.
|
func theGrantsPartTookOver(t *testing.T, over bool) {
|
||||||
func TestModuleIssueHandsACoveredAccountToTheVaultAndOnlyThat(t *testing.T) {
|
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)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
theVaultIsInstalled(t, open)
|
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
|
inv := open.inventory
|
||||||
known := broker.Broker{Fingerprint: "ab"}
|
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)
|
t.Errorf("the vault was told %s, without %s", said, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if n := strings.Count(said, `"origin":"issued"`); n != 3 {
|
if n := strings.Count(said, `"origin":"issued"`); n != 4 {
|
||||||
t.Errorf("%d accounts were handed with origin issued, want 3 (the core's two and the moved module's): %s", n, said)
|
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") {
|
if !strings.Contains(said, "kept-bot") {
|
||||||
t.Errorf("an account that is neither core nor moved was handed to the vault: %s", said)
|
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") {
|
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)
|
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"`) {
|
!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)
|
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.
|
// A vault that does not take the value fails the issue loudly: the bus's user list holds the new hash already.
|
||||||
|
|||||||
+132
-44
@@ -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
|
// 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
|
// 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.
|
// 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)
|
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 {
|
if !only {
|
||||||
password, err := accounts.MintBusPassword(ctx, u)
|
password, err := accounts.MintBusPassword(ctx, u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1062,6 +1075,67 @@ func issueAccount(ctx context.Context, accounts accountIssuer, u inventory.BusUs
|
|||||||
return nil
|
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.
|
// 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
|
// **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,
|
if err := issueAccount(ctx, inv, inventory.BusUser{Username: user, Kind: busKindOf(m.Module), Node: node,
|
||||||
Module: m.Module}, func(password string) error {
|
Module: m.Module}, func(password string) error {
|
||||||
return issueWith(ctx, inv, m, node, busAddress, known, reachable, user, password)
|
return issueWith(ctx, inv, m, node, busAddress, known, reachable, user, password)
|
||||||
}); err != nil {
|
}, func() error { return ensureModuleConsumer(m, node, busAddress) }); err != nil {
|
||||||
return err
|
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},
|
return issueAccount(ctx, inv, inventory.BusUser{Username: user, Kind: inventory.BusProcess, Node: node, Module: m.Module},
|
||||||
func(password string) error {
|
func(password string) error {
|
||||||
return sealProcess(ctx, inv, m, pr, node, busAddress, known, reachable, user, password)
|
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.
|
// 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 {
|
if err := handIssuedToVault(ctx, inv, m, node, name, string(held)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(pr.Queues) > 0 {
|
if err := ensureProcessQueues(m, pr, node, busAddress); err != nil {
|
||||||
if busAddress == "" {
|
return err
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fmt.Printf("bus user %s minted for %s's process %s, scoped to its bus declaration\n", user, m.Module, pr.Process)
|
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)
|
fmt.Printf(" sealed to %s as %s. It arrives with the next push — `push %s` to send it\n", node, name, node)
|
||||||
return nil
|
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):
|
// 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).
|
// {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) {
|
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,
|
return issueAccount(ctx, inv, inventory.BusUser{Username: user, Kind: inventory.BusProvisioner, Node: node,
|
||||||
Module: m.Module}, func(password string) error {
|
Module: m.Module}, func(password string) error {
|
||||||
return sealProvisionerClient(ctx, inv, m, node, known, reachable, user, password)
|
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.
|
// 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 {
|
if err := handIssuedToVault(ctx, inv, m, node, "broker", string(held)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := ensureModuleConsumer(m, node, busAddress); err != nil {
|
||||||
// And how it hears what it consumes. Derived from its declaration, and only when it declared
|
return err
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("bus user %s minted for %s, scoped to what it emits and consumes\n", user, m.Module)
|
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
|
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
|
// whereItComesFrom is the provenance a module handed over by hand records, and what a record must
|
||||||
// say to be worth anything later.
|
// 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
|
// 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
|
// 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
|
// 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) {
|
func TestModuleIssueMintsAndDeliversAProcesssAccount(t *testing.T) {
|
||||||
open := aMesh(t)
|
open := aMesh(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -29,7 +31,6 @@ func TestModuleIssueMintsAndDeliversAProcesssAccount(t *testing.T) {
|
|||||||
theVaultIsInstalled(t, open)
|
theVaultIsInstalled(t, open)
|
||||||
|
|
||||||
m := mailSenderWithProcess()
|
m := mailSenderWithProcess()
|
||||||
m.Moved = true // so handIssuedToVault's accountIsCovered is true, and the hand-off is exercised whole
|
|
||||||
register(t, open, m)
|
register(t, open, m)
|
||||||
|
|
||||||
vault := newStandInVault(t)
|
vault := newStandInVault(t)
|
||||||
@@ -84,4 +85,13 @@ func TestModuleIssueMintsAndDeliversAProcesssAccount(t *testing.T) {
|
|||||||
if hash1 == hash2 {
|
if hash1 == hash2 {
|
||||||
t.Fatal("issuing the process's account again did not rotate its password")
|
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
|
// 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 {
|
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,
|
return reissueVerifiedSenders(records, where,
|
||||||
func(node, module string) ([]byte, uint64, error) {
|
func(node, module string) ([]byte, uint64, error) {
|
||||||
m, err := bus.JS.GetLastMsg(broker.AssignmentsStream, broker.MembershipSubject(node, module))
|
m, err := bus.JS.GetLastMsg(broker.AssignmentsStream, broker.MembershipSubject(node, module))
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
"github.com/novox/mesh-controller/internal/testbus"
|
"github.com/novox/mesh-controller/internal/testbus"
|
||||||
@@ -17,7 +19,10 @@ func TestNatsAMembershipIssuedWithoutItsVerifiedSenderIsIssuedAgainWhenTheJudgem
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(js.Close)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
bus := link.OverNATS{Conn: js.Conn(), JS: js.Context()}
|
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 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.
|
// the controller, which carries no key and no sealed value.
|
||||||
switch verb {
|
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":
|
case "secret-ask":
|
||||||
if v.refuse != nil {
|
if v.refuse != nil {
|
||||||
return nil, v.refuse
|
return nil, v.refuse
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ func TestAModuleStreamOnARealBus(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer js.Close()
|
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)
|
_ = js.js.DeleteStream(s.Name)
|
||||||
if err := js.EnsureStream(s); err != nil {
|
if err := js.EnsureStream(s); err != nil {
|
||||||
t.Fatalf("%s: %v", s.Name, err)
|
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
|
// 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.
|
// reserves every name of the broker's own, and may reserve these retired ones beside them.
|
||||||
func TestTheCataloguesReservedStreamNamesAreTheMeshsOwn(t *testing.T) {
|
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
|
var own []string
|
||||||
for _, s := range MeshStreams() {
|
for _, s := range MeshStreams() {
|
||||||
own = append(own, s.Name)
|
own = append(own, s.Name)
|
||||||
|
|||||||
@@ -171,14 +171,9 @@ func MeshStreams() []Stream {
|
|||||||
Why: "one declaration per node, always the newest; a node that sees sequence n refuses " +
|
Why: "one declaration per node, always the newest; a node that sees sequence n refuses " +
|
||||||
"n-1 by construction (issue 107)",
|
"n-1 by construction (issue 107)",
|
||||||
},
|
},
|
||||||
{
|
// The assignments stream (ADR 0160) is no longer the mesh's: a runtime reads its membership from the grants
|
||||||
Name: AssignmentsStream,
|
// part's state memberships (ADR 0571 clause 7), and the bus module's tools delete the stream at the lease write
|
||||||
Subjects: []string{"mesh.assignment.*.*"},
|
// (research 072 row 33), so it is not declared here, which would make it again at the controller's next start.
|
||||||
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",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Name: EventsStream,
|
Name: EventsStream,
|
||||||
// A seat's own events ride here too: they are 1:many like any event, and the
|
// A seat's own events ride here too: they are 1:many like any event, and the
|
||||||
|
|||||||
@@ -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).
|
// Each relationship's retention is the thing that makes it what it is (design 29 §4).
|
||||||
func TestEachStreamCarriesTheRetentionItsShapeNeeds(t *testing.T) {
|
func TestEachStreamCarriesTheRetentionItsShapeNeeds(t *testing.T) {
|
||||||
want := map[string]Retention{
|
want := map[string]Retention{
|
||||||
"CONTROL": RetentionWorkQueue,
|
"CONTROL": RetentionWorkQueue,
|
||||||
"NODES": RetentionLastPerSubject,
|
"NODES": RetentionLastPerSubject,
|
||||||
"EVENTS": RetentionLimits,
|
"EVENTS": RetentionLimits,
|
||||||
"ASSIGNMENTS": RetentionLastPerSubject,
|
// 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
|
// What a consumer gave up on (novox/hq issue 330): the server's notice taken once, the message
|
||||||
// kept until somebody acts.
|
// kept until somebody acts.
|
||||||
"DEAD_LETTER_NOTICES": RetentionWorkQueue,
|
"DEAD_LETTER_NOTICES": RetentionWorkQueue,
|
||||||
|
|||||||
@@ -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
|
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,
|
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
|
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
|
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 (`consumer-at`). The proxy
|
endpoint's port, its policy), and where the consumer's machine is on the private network, as one opaque payload
|
||||||
follows the plans on the bus and forwards by the `Host` header of each request to `http://<consumer-at>:<port>`, or
|
(`contributed`) the seat never reads. The proxy follows the plans on the bus, reads each contribution with its own
|
||||||
over loopback for a consumer on its own machine.
|
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
|
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
|
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 |
|
| `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 |
|
| `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) |
|
| `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
|
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
|
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)
|
## Which issuer: Let's Encrypt, stated here (novox/hq ADR 0226)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
"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) {
|
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()
|
now: time.Now}.tools()
|
||||||
if err := stdio.Serve("", tools); err != nil {
|
if err := stdio.Serve("", tools); err != nil {
|
||||||
say("%v", err)
|
say("%v", err)
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ package main
|
|||||||
import (
|
import (
|
||||||
"git.novox.be/novox/mesh-sdk/go/provider"
|
"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.
|
// ProvisionName is what route-proxy provides, as its manifest says, and ModuleName this module.
|
||||||
const (
|
const (
|
||||||
ProvisionName = routestate.Provision
|
ProvisionName = contract.Provision
|
||||||
ModuleName = routestate.Module
|
ModuleName = contract.Module
|
||||||
)
|
)
|
||||||
|
|
||||||
// newProvider is route-proxy answering its consumers over bus on node: the route, served as the manifest serves it
|
// 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"} {
|
for _, k := range []string{"build", "provides", "state", "reads", "tools", "replaces"} {
|
||||||
delete(m, k)
|
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
|
pinned, err := json.Marshal(m) // sorted keys, compact
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
sum := sha256.Sum256(pinned)
|
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)
|
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
|
// The two acme templates are pinned the same way (issue 849): they are what the proxy's certificates are asked
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ import (
|
|||||||
|
|
||||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
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.
|
// The tools' names, as the manifest declares them.
|
||||||
const (
|
const (
|
||||||
RoutesTool = "route_proxy_routes"
|
RoutesTool = contract.ToolRoutes
|
||||||
RouteTool = "route_proxy_route"
|
RouteTool = contract.ToolRoute
|
||||||
UnroutedTool = "route_proxy_unrouted"
|
UnroutedTool = contract.ToolUnrouted
|
||||||
)
|
)
|
||||||
|
|
||||||
// reader is one of the module's states, as stdio.KeptState reads it.
|
// reader is one of the module's states, as stdio.KeptState reads it.
|
||||||
@@ -46,9 +46,9 @@ type RouteLine struct {
|
|||||||
// unknown before any request.
|
// unknown before any request.
|
||||||
Reachable string `json:"reachable"`
|
Reachable string `json:"reachable"`
|
||||||
// Certificate is its outcome, issuer and expiry in words; ExpiresInDays is the whole days until not-after.
|
// Certificate is its outcome, issuer and expiry in words; ExpiresInDays is the whole days until not-after.
|
||||||
Certificate string `json:"certificate"`
|
Certificate string `json:"certificate"`
|
||||||
ExpiresInDays *int `json:"expires_in_days,omitempty"`
|
ExpiresInDays *int `json:"expires_in_days,omitempty"`
|
||||||
LastAnswer *routestate.Answer `json:"last_answer,omitempty"`
|
LastAnswer *contract.Answer `json:"last_answer,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f front) tools() []stdio.Tool {
|
func (f front) tools() []stdio.Tool {
|
||||||
@@ -99,7 +99,7 @@ func read[T any](state reader, node string) ([]T, error) {
|
|||||||
}
|
}
|
||||||
var out []T
|
var out []T
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
at, _ := routestate.Split(key)
|
at, _ := contract.Split(key)
|
||||||
if node != "" && at != node {
|
if node != "" && at != node {
|
||||||
continue
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -131,7 +131,7 @@ func (f front) list(node string) (map[string]any, error) {
|
|||||||
if line.Reachable == "no" {
|
if line.Reachable == "no" {
|
||||||
unreachable++
|
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++
|
expiring++
|
||||||
}
|
}
|
||||||
lines = append(lines, line)
|
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
|
"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",
|
line := RouteLine{Name: r.Name, Proxy: r.Proxy, Reach: r.Reach, LastAnswer: r.LastAnswer, Reachable: "unknown",
|
||||||
Targets: []string{}}
|
Targets: []string{}}
|
||||||
for _, t := range r.Targets {
|
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.
|
// 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
|
path := t.Path
|
||||||
if path == "" {
|
if path == "" {
|
||||||
path = "/"
|
path = "/"
|
||||||
@@ -209,11 +209,11 @@ func (f front) one(name, node string) (map[string]any, error) {
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
return nil, fmt.Errorf("name is the site to read, such as app.example.org")
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var found []routestate.Route
|
var found []contract.Route
|
||||||
for _, r := range routes {
|
for _, r := range routes {
|
||||||
if r.Name == name {
|
if r.Name == name {
|
||||||
found = append(found, r)
|
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) })
|
sort.Slice(asked, func(i, j int) bool { return asked[i].AskedAt.After(asked[j].AskedAt) })
|
||||||
if asked == nil {
|
if asked == nil {
|
||||||
asked = []routestate.Unrouted{}
|
asked = []contract.Unrouted{}
|
||||||
}
|
}
|
||||||
return map[string]any{"unrouted": asked, "names": len(asked), "other_refusals": others}, nil
|
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.
|
// 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()
|
keys, err := f.unrouted.Keys()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
names, others := keySet{}, keySet{}
|
names, others := keySet{}, keySet{}
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
if _, name := routestate.Split(key); name == routestate.OtherName {
|
if _, name := contract.Split(key); name == contract.OtherName {
|
||||||
others[key] = true
|
others[key] = true
|
||||||
} else {
|
} else {
|
||||||
names[key] = true
|
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 {
|
if err != nil {
|
||||||
return nil, nil, err
|
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 {
|
if counted == nil {
|
||||||
counted = []routestate.Other{}
|
counted = []contract.Other{}
|
||||||
}
|
}
|
||||||
return asked, counted, err
|
return asked, counted, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
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.
|
// 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)
|
now := time.Date(2026, 10, 30, 21, 0, 0, 0, time.UTC)
|
||||||
expires := now.Add(30 * 24 * time.Hour)
|
expires := now.Add(30 * 24 * time.Hour)
|
||||||
routes := held{
|
routes := held{
|
||||||
"anchor.grafana.example": routestate.Route{Name: "grafana.example", Proxy: "anchor", Reach: routestate.ReachPublic,
|
"anchor.grafana.example": contract.Route{Name: "grafana.example", Proxy: "anchor", Reach: contract.ReachPublic,
|
||||||
Targets: []routestate.Target{{Module: "grafana", Node: "home-server", Target: "http://10.0.0.2:3000"}},
|
Targets: []contract.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},
|
Certificate: contract.Certificate{Authority: contract.ReachPublic, Issuer: "Let's Encrypt R11", NotAfter: &expires, Outcome: contract.Issued},
|
||||||
LastAnswer: &routestate.Answer{Status: 502, At: now, Error: "dial tcp 10.0.0.2:3000: connect: connection refused"}},
|
LastAnswer: &contract.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,
|
"anchor.drive.example": contract.Route{Name: "drive.example", Proxy: "anchor", Reach: contract.ReachPublic,
|
||||||
Targets: []routestate.Target{{Module: "nextcloud", Node: "home-server", Target: "http://10.0.0.2:8080"}},
|
Targets: []contract.Target{{Module: "nextcloud", Node: "home-server", Target: "http://10.0.0.2:8080"}},
|
||||||
Certificate: routestate.Certificate{Authority: routestate.ReachPublic},
|
Certificate: contract.Certificate{Authority: contract.ReachPublic},
|
||||||
LastAnswer: &routestate.Answer{Status: 200, At: now}},
|
LastAnswer: &contract.Answer{Status: 200, At: now}},
|
||||||
}
|
}
|
||||||
unrouted := held{
|
unrouted := held{
|
||||||
"anchor.old.example": routestate.Unrouted{Name: "old.example", Proxy: "anchor", AskedAt: now.Add(-time.Hour), Times: 3},
|
"anchor.old.example": contract.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.wiki.example": contract.Unrouted{Name: "wiki.example", Proxy: "anchor", AskedAt: now, Times: 1},
|
||||||
"anchor._other": routestate.Other{Proxy: "anchor", Minute: now, LastMinute: 40, SinceStart: 900},
|
"anchor._other": contract.Other{Proxy: "anchor", Minute: now, LastMinute: 40, SinceStart: 900},
|
||||||
}
|
}
|
||||||
tools := map[string]stdio.Tool{}
|
tools := map[string]stdio.Tool{}
|
||||||
for _, tool := range (front{routes: routes, unrouted: unrouted, now: func() time.Time { return now }}).tools() {
|
for _, tool := range (front{routes: routes, unrouted: unrouted, now: func() time.Time { return now }}).tools() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -11,20 +12,21 @@ import (
|
|||||||
|
|
||||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
"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/membership"
|
||||||
"git.novox.be/novox/mesh-sdk/go/process"
|
"git.novox.be/novox/mesh-sdk/go/process"
|
||||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
"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
|
// 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.
|
// proxy writes, readable by others, and the bound entries the provider helper writes.
|
||||||
func keptStates() []announce.State {
|
func keptStates() []announce.State {
|
||||||
return []announce.State{
|
return []announce.State{
|
||||||
{Name: routestate.BoundState},
|
{Name: contract.BoundState},
|
||||||
{Name: routestate.RoutesState, Readable: true},
|
{Name: contract.RoutesState, Readable: true},
|
||||||
{Name: routestate.UnroutedState, 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
|
// **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
|
// 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
|
// restart, and said on the bus as it changes (contract.EventServed, EventWithdrawn, EventRefused). **Who is inside**
|
||||||
// private network, which is who may be served an internal name; until it is read, an internal name is served to this
|
// is every node's address on the private network as the seat mesh-placement decides it (mesh-sdk
|
||||||
// machine alone.
|
// 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
|
// follow connects with the credential in path, follows the plans and the placement, and retries the connection for
|
||||||
// long as it takes: until the bus answers, the proxy routes nothing, and says so.
|
// as long as it takes: until the bus answers, the proxy routes nothing, and says so.
|
||||||
func follow(path string, held *table) {
|
func follow(path string, held *table) {
|
||||||
for {
|
for {
|
||||||
err := followOnce(path, held)
|
err := followOnce(path, held)
|
||||||
@@ -64,39 +67,40 @@ func followOnce(path string, held *table) error {
|
|||||||
if err := conn.Keeps(keptStates()...); err != nil {
|
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)
|
log.Printf("cannot say on the bus what this proxy keeps, so it keeps nothing there: %v", err)
|
||||||
} else {
|
} 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()
|
conn.Close()
|
||||||
return err
|
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
|
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 {
|
type planWatch interface {
|
||||||
Watch(key string, handle func(stdio.StateChange) error) error
|
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
|
// 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.
|
// 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
|
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 {
|
err := plans.Watch("", func(c stdio.StateChange) error {
|
||||||
var value []byte
|
var value []byte
|
||||||
if c.Op != "delete" {
|
if c.Op != "delete" && c.Op != "purge" {
|
||||||
value = c.Value
|
value = c.Value
|
||||||
}
|
}
|
||||||
if changed := given.take(c.Key, value); changed && read.Load() {
|
if changed := given.take(c.Key, value); changed && read.Load() {
|
||||||
serve(given, held)
|
serve(given, held, say)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -104,25 +108,87 @@ func followPlans(plans planWatch, given *planned, held *table) error {
|
|||||||
return fmt.Errorf("watching %s: %w", provisions.PlansRead, err)
|
return fmt.Errorf("watching %s: %w", provisions.PlansRead, err)
|
||||||
}
|
}
|
||||||
read.Store(true)
|
read.Store(true)
|
||||||
serve(given, held)
|
serve(given, held, say)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// serve replaces what the proxy serves with what the plans give it.
|
// serve replaces what the proxy serves with what the plans give it, and says each name it now serves or serves no
|
||||||
func serve(given *planned, held *table) {
|
// more.
|
||||||
|
func serve(given *planned, held *table, say func(string, contract.Event)) {
|
||||||
|
before := held.served()
|
||||||
routes, public := routesOf(given.contributions())
|
routes, public := routesOf(given.contributions())
|
||||||
held.set(routes, public)
|
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(), ", "))
|
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.
|
// change is one event serve says.
|
||||||
func applyMembership(issued membership.Membership, held *table) bool {
|
type change struct {
|
||||||
inside, err := sourcesOf(issued.Mesh)
|
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 {
|
if err != nil {
|
||||||
log.Printf("the mesh in this proxy's membership is unreadable, keeping who is inside as it was: %v", err)
|
return err
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
held.setInside(inside)
|
read.Store(true)
|
||||||
log.Printf("internal names are served to %d machine(s)", len(inside))
|
apply()
|
||||||
return true
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
|
|
||||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
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).
|
// 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
|
node string
|
||||||
routes, unrouted kvStore
|
routes, unrouted kvStore
|
||||||
|
|
||||||
served map[string]*routestate.Route
|
served map[string]*contract.Route
|
||||||
previous map[string]routestate.Route // what the bus held for this machine when it was reached
|
previous map[string]contract.Route // what the bus held for this machine when it was reached
|
||||||
asked map[string]*routestate.Unrouted
|
asked map[string]*contract.Unrouted
|
||||||
|
|
||||||
answerWritten map[string]time.Time
|
answerWritten map[string]time.Time
|
||||||
askedWritten map[string]time.Time
|
askedWritten map[string]time.Time
|
||||||
@@ -69,7 +69,7 @@ type ledger struct {
|
|||||||
domains map[string]bool
|
domains map[string]bool
|
||||||
newMinute time.Time
|
newMinute time.Time
|
||||||
newInMinute int
|
newInMinute int
|
||||||
other routestate.Other
|
other contract.Other
|
||||||
otherMinute time.Time
|
otherMinute time.Time
|
||||||
otherInMinute int
|
otherInMinute int
|
||||||
otherDirty bool
|
otherDirty bool
|
||||||
@@ -81,9 +81,9 @@ type ledger struct {
|
|||||||
func newLedger() *ledger {
|
func newLedger() *ledger {
|
||||||
return &ledger{
|
return &ledger{
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
served: map[string]*routestate.Route{},
|
served: map[string]*contract.Route{},
|
||||||
previous: map[string]routestate.Route{},
|
previous: map[string]contract.Route{},
|
||||||
asked: map[string]*routestate.Unrouted{},
|
asked: map[string]*contract.Unrouted{},
|
||||||
answerWritten: map[string]time.Time{},
|
answerWritten: map[string]time.Time{},
|
||||||
askedWritten: map[string]time.Time{},
|
askedWritten: map[string]time.Time{},
|
||||||
dirtyRoutes: map[string]bool{},
|
dirtyRoutes: map[string]bool{},
|
||||||
@@ -113,12 +113,12 @@ func (l *ledger) attach(node string, routes, unrouted kvStore) {
|
|||||||
if l == nil {
|
if l == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
before := map[string]routestate.Route{}
|
before := map[string]contract.Route{}
|
||||||
if keys, err := routes.Keys(); err != nil {
|
if keys, err := routes.Keys(); err != nil {
|
||||||
log.Printf("cannot read back what this proxy said it serves: %v", err)
|
log.Printf("cannot read back what this proxy said it serves: %v", err)
|
||||||
} else {
|
} else {
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
at, name := routestate.Split(key)
|
at, name := contract.Split(key)
|
||||||
if at != node {
|
if at != node {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@ func (l *ledger) attach(node string, routes, unrouted kvStore) {
|
|||||||
if err != nil || entry == nil {
|
if err != nil || entry == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var r routestate.Route
|
var r contract.Route
|
||||||
if json.Unmarshal(entry.Value, &r) == nil {
|
if json.Unmarshal(entry.Value, &r) == nil {
|
||||||
before[name] = r
|
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.
|
// 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 {
|
if r.Certificate.Outcome == "" && r.Certificate.NotAfter == nil && before.Certificate.Authority == r.Certificate.Authority {
|
||||||
r.Certificate = before.Certificate
|
r.Certificate = before.Certificate
|
||||||
}
|
}
|
||||||
@@ -170,14 +170,14 @@ func (l *ledger) serving(routes map[string][]rule, public map[string]bool) {
|
|||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
for name, rules := range routes {
|
for name, rules := range routes {
|
||||||
reach := routestate.ReachInternal
|
reach := contract.ReachInternal
|
||||||
if public[name] {
|
if public[name] {
|
||||||
reach = routestate.ReachPublic
|
reach = contract.ReachPublic
|
||||||
}
|
}
|
||||||
targets := targetsOf(rules)
|
targets := targetsOf(rules)
|
||||||
r := l.served[name]
|
r := l.served[name]
|
||||||
if r == nil {
|
if r == nil {
|
||||||
r = &routestate.Route{Name: name}
|
r = &contract.Route{Name: name}
|
||||||
if before, ok := l.previous[name]; ok {
|
if before, ok := l.previous[name]; ok {
|
||||||
r.Certificate.Authority = reach
|
r.Certificate.Authority = reach
|
||||||
keepWhatWasKnown(r, before)
|
keepWhatWasKnown(r, before)
|
||||||
@@ -190,7 +190,7 @@ func (l *ledger) serving(routes map[string][]rule, public map[string]bool) {
|
|||||||
l.dirtyRoutes[name] = true
|
l.dirtyRoutes[name] = true
|
||||||
}
|
}
|
||||||
if r.Certificate.Authority != reach {
|
if r.Certificate.Authority != reach {
|
||||||
r.Certificate = routestate.Certificate{Authority: reach}
|
r.Certificate = contract.Certificate{Authority: reach}
|
||||||
l.dirtyRoutes[name] = true
|
l.dirtyRoutes[name] = true
|
||||||
}
|
}
|
||||||
if _, was := l.asked[name]; was {
|
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{}
|
l.domains = map[string]bool{}
|
||||||
for name := range routes {
|
for name := range routes {
|
||||||
if d := routestate.Registrable(name); d != "" {
|
if d := contract.Registrable(name); d != "" {
|
||||||
l.domains[d] = true
|
l.domains[d] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,10 +214,10 @@ func (l *ledger) serving(routes map[string][]rule, public map[string]bool) {
|
|||||||
l.wake()
|
l.wake()
|
||||||
}
|
}
|
||||||
|
|
||||||
func targetsOf(rules []rule) []routestate.Target {
|
func targetsOf(rules []rule) []contract.Target {
|
||||||
out := make([]routestate.Target, 0, len(rules))
|
out := make([]contract.Target, 0, len(rules))
|
||||||
for _, r := range 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 != "",
|
Deny: r.policy.deny, Redirect: r.policy.redirectTo, Auth: len(r.policy.users) > 0 || r.policy.sealed != "",
|
||||||
Sealed: 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()
|
why = failed.Error()
|
||||||
}
|
}
|
||||||
before := r.LastAnswer
|
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 {
|
if before != nil && before.Status == status && before.Error == why && now.Sub(l.answerWritten[name]) < refreshAfter {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -268,11 +268,11 @@ func (l *ledger) refused(name string, internalOnly, handshake bool) {
|
|||||||
now := l.now()
|
now := l.now()
|
||||||
a := l.asked[name]
|
a := l.asked[name]
|
||||||
if a == nil {
|
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)
|
l.countOther(now)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a = &routestate.Unrouted{Name: name}
|
a = &contract.Unrouted{Name: name}
|
||||||
l.asked[name] = a
|
l.asked[name] = a
|
||||||
}
|
}
|
||||||
a.AskedAt, a.InternalOnly, a.Handshake = now, internalOnly, handshake
|
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()
|
notAfter := leaf.NotAfter.UTC()
|
||||||
r.Certificate.Issuer, r.Certificate.NotAfter = issuerOf(leaf), ¬After
|
r.Certificate.Issuer, r.Certificate.NotAfter = issuerOf(leaf), ¬After
|
||||||
r.Certificate.RenewedAt, r.Certificate.At = &now, &now
|
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.dirtyRoutes[name] = true
|
||||||
l.wake()
|
l.wake()
|
||||||
}
|
}
|
||||||
@@ -362,13 +362,13 @@ func (l *ledger) handedOut(name string, leaf *x509.Certificate) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
notAfter := leaf.NotAfter.UTC()
|
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
|
return
|
||||||
}
|
}
|
||||||
now := l.now()
|
now := l.now()
|
||||||
r.Certificate.Issuer, r.Certificate.NotAfter, r.Certificate.At = issuerOf(leaf), ¬After, &now
|
r.Certificate.Issuer, r.Certificate.NotAfter, r.Certificate.At = issuerOf(leaf), ¬After, &now
|
||||||
if r.Certificate.Outcome != routestate.Issued || r.Certificate.Error != "" {
|
if r.Certificate.Outcome != contract.Issued || r.Certificate.Error != "" {
|
||||||
r.Certificate.Outcome, r.Certificate.Error = routestate.Cached, ""
|
r.Certificate.Outcome, r.Certificate.Error = contract.Cached, ""
|
||||||
}
|
}
|
||||||
l.dirtyRoutes[name] = true
|
l.dirtyRoutes[name] = true
|
||||||
l.wake()
|
l.wake()
|
||||||
@@ -386,11 +386,11 @@ func (l *ledger) certificateFailed(name string, failed error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := l.now()
|
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 {
|
if same && r.Certificate.At != nil && now.Sub(*r.Certificate.At) < refreshAfter {
|
||||||
return
|
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.dirtyRoutes[name] = true
|
||||||
l.wake()
|
l.wake()
|
||||||
}
|
}
|
||||||
@@ -423,7 +423,7 @@ func (l *ledger) flush() {
|
|||||||
l.forgetOldestAsked()
|
l.forgetOldestAsked()
|
||||||
var puts []put
|
var puts []put
|
||||||
for name := range l.dirtyRoutes {
|
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 {
|
if r := l.served[name]; r != nil {
|
||||||
c := *r
|
c := *r
|
||||||
c.Proxy = l.node
|
c.Proxy = l.node
|
||||||
@@ -432,7 +432,7 @@ func (l *ledger) flush() {
|
|||||||
puts = append(puts, p)
|
puts = append(puts, p)
|
||||||
}
|
}
|
||||||
for name := range l.dirtyAsked {
|
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 {
|
if a := l.asked[name]; a != nil {
|
||||||
c := *a
|
c := *a
|
||||||
c.Proxy = l.node
|
c.Proxy = l.node
|
||||||
@@ -443,7 +443,7 @@ func (l *ledger) flush() {
|
|||||||
if l.otherDirty {
|
if l.otherDirty {
|
||||||
o := l.other
|
o := l.other
|
||||||
o.Proxy = l.node
|
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.dirtyRoutes, l.dirtyAsked, l.otherDirty = map[string]bool{}, map[string]bool{}, false
|
||||||
l.mu.Unlock()
|
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)
|
log.Printf("cannot write %s to the bus, kept for the next change: %v", p.key, err)
|
||||||
}
|
}
|
||||||
l.routesFailing = true
|
l.routesFailing = true
|
||||||
_, name := routestate.Split(p.key)
|
_, name := contract.Split(p.key)
|
||||||
l.dirtyRoutes[name] = true
|
l.dirtyRoutes[name] = true
|
||||||
default:
|
default:
|
||||||
if !l.askFailed {
|
if !l.askFailed {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
|
|
||||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
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.
|
// 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
|
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()
|
t.Helper()
|
||||||
raw, ok := f.values[key]
|
raw, ok := f.values[key]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var r routestate.Route
|
var r contract.Route
|
||||||
if err := json.Unmarshal(raw, &r); err != nil {
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -117,8 +117,8 @@ func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
_, port, _ := net.SplitHostPort(gone)
|
_, port, _ := net.SplitHostPort(gone)
|
||||||
contributions := []contribution{
|
contributions := []contribution{
|
||||||
{From: "grafana", Node: "anchor", At: "127.0.0.1", Values: map[string]any{"name": "grafana.example", "port": mustFloat(t, port)}},
|
{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", Values: map[string]any{"internal-name": "drive.internal", "port": 8080.0}},
|
{From: "drive", Node: "home-server", At: "10.0.0.2", Route: contract.Request{InternalName: "drive.internal", Port: 8080}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, row := range []struct {
|
for _, row := range []struct {
|
||||||
@@ -130,12 +130,12 @@ func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) {
|
|||||||
func(t *testing.T, held *table) {},
|
func(t *testing.T, held *table) {},
|
||||||
func(t *testing.T, routes, _ *fakeState) {
|
func(t *testing.T, routes, _ *fakeState) {
|
||||||
r := routes.route(t, "anchor.grafana.example")
|
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 {
|
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)
|
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 ||
|
if d := routes.route(t, "anchor.drive.internal"); d == nil || d.Reach != contract.ReachInternal ||
|
||||||
d.Certificate.Authority != routestate.ReachInternal {
|
d.Certificate.Authority != contract.ReachInternal {
|
||||||
t.Fatalf("an internal-only name is not on the bus as internal: %+v", d)
|
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"
|
req.RemoteAddr = "192.0.2.7:51000"
|
||||||
handler(held).ServeHTTP(httptest.NewRecorder(), req)
|
handler(held).ServeHTTP(httptest.NewRecorder(), req)
|
||||||
held.ledger.flush()
|
held.ledger.flush()
|
||||||
var a routestate.Unrouted
|
var a contract.Unrouted
|
||||||
raw := held.ledger.unrouted.(*fakeState).values["anchor.wiki.grafana.example"]
|
raw := held.ledger.unrouted.(*fakeState).values["anchor.wiki.grafana.example"]
|
||||||
if err := json.Unmarshal(raw, &a); err != nil || a.Times != 1 || a.AskedAt.IsZero() {
|
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)
|
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)
|
t.Fatalf("who asked is on the bus: %s", raw)
|
||||||
}
|
}
|
||||||
routes, public := routesOf(append(contributions, contribution{From: "grafana", Node: "anchor",
|
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)
|
held.set(routes, public)
|
||||||
},
|
},
|
||||||
func(t *testing.T, _, unrouted *fakeState) {
|
func(t *testing.T, _, unrouted *fakeState) {
|
||||||
@@ -197,7 +197,7 @@ func TestTheBusIsToldWhatIsServedAndRefused(t *testing.T) {
|
|||||||
_, _ = pick(&tls.ClientHelloInfo{ServerName: "forgotten.grafana.example"})
|
_, _ = pick(&tls.ClientHelloInfo{ServerName: "forgotten.grafana.example"})
|
||||||
},
|
},
|
||||||
func(t *testing.T, _, unrouted *fakeState) {
|
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 {
|
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)
|
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)
|
held.ledger.refused("w.scan.test", false, false)
|
||||||
},
|
},
|
||||||
func(t *testing.T, _, unrouted *fakeState) {
|
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 {
|
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"])
|
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) {
|
func(t *testing.T, routes, _ *fakeState) {
|
||||||
c := routes.route(t, "anchor.grafana.example").Certificate
|
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 {
|
!c.NotAfter.Equal(notAfter) || c.RenewedAt == nil {
|
||||||
t.Fatalf("the issued certificate is not on the bus: %+v", c)
|
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) {
|
func(t *testing.T, routes, _ *fakeState) {
|
||||||
c := routes.route(t, "anchor.grafana.example").Certificate
|
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)
|
t.Fatalf("the failed certificate is not on the bus: %+v", c)
|
||||||
}
|
}
|
||||||
}},
|
}},
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ import (
|
|||||||
"golang.org/x/crypto/acme"
|
"golang.org/x/crypto/acme"
|
||||||
"golang.org/x/crypto/acme/autocert"
|
"golang.org/x/crypto/acme/autocert"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-controller/modules/route-proxy/contract"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Where public certificates come from when nothing says otherwise.
|
// 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
|
// contribution is one route a consumer asked for: the module, its machine, where that machine is, the local name of
|
||||||
// contributes, composed by the mesh (its plan's entry for the route, plans.go).
|
// its contribution, and the route, composed by the mesh and read with route-proxy's contract (plans.go).
|
||||||
type contribution struct {
|
type contribution struct {
|
||||||
From string `json:"from"`
|
From string `json:"from"`
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
// At is where that machine is on the private network. The mesh knows it; a proxy that had to
|
// 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.
|
// build it from the node name would be a naming convention copied into every provider.
|
||||||
At string `json:"at"`
|
At string `json:"at"`
|
||||||
Values map[string]any `json:"values"`
|
Local string `json:"local,omitempty"`
|
||||||
|
Route contract.Request `json:"route"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// policy is what a rule does with a request that matched it.
|
// 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
|
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 {
|
func (t *table) names() []string {
|
||||||
t.mu.RLock()
|
t.mu.RLock()
|
||||||
defer t.mu.RUnlock()
|
defer t.mu.RUnlock()
|
||||||
@@ -857,38 +878,15 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
|
|||||||
out := map[string][]rule{}
|
out := map[string][]rule{}
|
||||||
public := map[string]bool{}
|
public := map[string]bool{}
|
||||||
for _, c := range contributions {
|
for _, c := range contributions {
|
||||||
name, _ := c.Values["name"].(string)
|
// The contract admitted the route whole (contract.Request.Validate): it names a host, a rule that forwards
|
||||||
name = strings.TrimSpace(name)
|
// gives a port, its scheme is http or https, its body limit is whole and positive, and its auth names a
|
||||||
internal, _ := c.Values["internal-name"].(string)
|
// secret's file rather than holding a credential (novox/hq ADR 0108).
|
||||||
internal = strings.TrimSpace(internal)
|
r := c.Route
|
||||||
if name == "" && internal == "" {
|
name := strings.TrimSpace(r.Name)
|
||||||
log.Printf("%s on %s asked for a route and named nothing; skipped", c.From, c.Node)
|
internal := strings.TrimSpace(r.InternalName)
|
||||||
continue
|
made := rule{path: r.Prefix(), priority: r.Priority}
|
||||||
}
|
made.policy.deny, made.policy.redirectTo = r.Deny, r.Redirect
|
||||||
// What the route is called in a log line: its public name when it has one.
|
if named := strings.TrimSpace(r.Auth); named != "" {
|
||||||
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
|
|
||||||
}
|
|
||||||
users, err := usersFrom(named)
|
users, err := usersFrom(named)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fail closed: the rule is kept so the name stays routed and answers, and it
|
// 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.
|
// Only a rule that actually proxies needs somewhere to send the request.
|
||||||
if !made.policy.deny && made.policy.redirectTo == "" {
|
if r.Forwards() {
|
||||||
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
|
|
||||||
}
|
|
||||||
// Where the mesh says that machine is. Empty means it is this one — a workload beside
|
// Where the mesh says that machine is. Empty means it is this one — a workload beside
|
||||||
// the proxy is ordinary, and reaching it over loopback is both correct and the only
|
// the proxy is ordinary, and reaching it over loopback is both correct and the only
|
||||||
// thing that works when there is no private network.
|
// 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
|
// 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
|
// 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
|
// first of these — is the reason `insecure` exists, and it stays the exception.
|
||||||
// other target the mesh hands this proxy is a plain workload on the private network.
|
made.insecure = r.Insecure
|
||||||
scheme, _ := c.Values["scheme"].(string)
|
if r.MaxRequestBody != nil {
|
||||||
scheme = strings.ToLower(strings.TrimSpace(scheme))
|
made.maxRequestBody = *r.MaxRequestBody
|
||||||
if scheme == "" {
|
|
||||||
scheme = "http"
|
|
||||||
}
|
}
|
||||||
if scheme != "http" && scheme != "https" {
|
made.target = fmt.Sprintf("%s://%s:%d", r.SchemeOrDefault(), at, r.Port)
|
||||||
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.from, made.node = c.From, c.Node
|
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
|
// answered both for one route, as a convenience (reaching a service over the VPN without a
|
||||||
// public TLS round trip), not as an access boundary; composing it here restores exactly
|
// public TLS round trip), not as an access boundary; composing it here restores exactly
|
||||||
// that, nothing more. Absent whenever the node composed no internal name (novox/hq ADR
|
// that, nothing more. Absent whenever the node composed no internal name (novox/hq ADR
|
||||||
// 0056's internalDomain half) — the same "nothing to join a label to" case the public name
|
// 0056's internalDomain half). And the only name, when the endpoint reaches no further than
|
||||||
// already has. And the only name, when the endpoint reaches no further than the private
|
// the private network.
|
||||||
// network.
|
|
||||||
if internal != "" {
|
if internal != "" {
|
||||||
out[strings.ToLower(internal)] = append(out[strings.ToLower(internal)], made)
|
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
|
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.
|
// usersFrom reads the credentials the mesh mounted, in the one format every htpasswd already is.
|
||||||
func usersFrom(path string) (map[string]string, error) {
|
func usersFrom(path string) (map[string]string, error) {
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
@@ -1033,23 +960,6 @@ func usersFrom(path string) (map[string]string, error) {
|
|||||||
return users, nil
|
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
|
// 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
|
// -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
|
// writing its clone URL into a page, a login redirect — otherwise sees the plain HTTP hop from this
|
||||||
|
|||||||
@@ -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
|
// 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").
|
// 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
|
// **A route is a provision, in route-proxy's own contract.** A module that must be reachable requires `route`; the
|
||||||
// its node, and the plan's entry for the route names this proxy as its provider and carries what the module
|
// controller composes its plan on its node, and the plan's entry for the route names this proxy as its provider and
|
||||||
// contributes, composed as the received file used to carry it (its names joined to the node's domains, its endpoint's
|
// carries what the module contributes, composed (its names joined to the node's domains, its endpoint's port), and
|
||||||
// port, its policy), and where the module's machine is on the private network. The proxy follows the plans on the bus
|
// 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
|
// 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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -19,23 +20,28 @@ import (
|
|||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
"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
|
// 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).
|
// leaves what it gave served (a bind missed would read as a route withdrawn).
|
||||||
type planned struct {
|
type planned struct {
|
||||||
node string
|
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
|
mu sync.Mutex
|
||||||
by map[string][]contribution
|
by map[string][]contribution
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPlanned(node string) *planned {
|
func newPlanned(node string, said func(string, contract.Event)) *planned {
|
||||||
return &planned{node: node, by: map[string][]contribution{}}
|
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
|
// 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 {
|
func (p *planned) take(key string, value []byte) bool {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
@@ -44,13 +50,14 @@ func (p *planned) take(key string, value []byte) bool {
|
|||||||
delete(p.by, key)
|
delete(p.by, key)
|
||||||
return had
|
return had
|
||||||
}
|
}
|
||||||
gave, problems, err := routesOfPlan(p.node, key, value)
|
gave, refused, err := routesOfPlan(p.node, key, value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("the plan %s is not readable, so what it routes is kept as it was: %v", key, err)
|
log.Printf("the plan %s is not readable, so what it routes is kept as it was: %v", key, err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, problem := range problems {
|
for _, r := range refused {
|
||||||
log.Print(problem)
|
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 {
|
if len(gave) == 0 {
|
||||||
_, had := p.by[key]
|
_, had := p.by[key]
|
||||||
@@ -77,12 +84,12 @@ func (p *planned) contributions() []contribution {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// routesOfPlan is the routes one plan gives the proxy on node: each contribution of each entry naming this proxy on
|
// routesOfPlan is the routes one plan gives the proxy on node: each contribution the plan's entries naming this proxy
|
||||||
// node as the route's provider, reached on its own machine over loopback and on another at the address the plan says.
|
// on node as the route's provider carry, decoded with the contract, reached on its own machine over loopback and on
|
||||||
// A plan of another module, or one routing elsewhere, gives none. Problems are what the plan names and the proxy
|
// another at the address the plan says. A plan of another module, or one routing elsewhere, gives none. Refused is
|
||||||
// cannot serve, said rather than skipped silently: an entry carrying nothing to serve (a controller before the plans
|
// what the plan names and the proxy does not serve, each with why: an entry carrying nothing to serve, a payload or a
|
||||||
// carried it), or a consumer elsewhere with no address on the private network.
|
// 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, []string, error) {
|
func routesOfPlan(node, key string, value []byte) ([]contribution, []contract.Event, error) {
|
||||||
plan, err := provisions.DecodePlan(value)
|
plan, err := provisions.DecodePlan(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
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)
|
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 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 {
|
for _, e := range plan.Plan.Provisions {
|
||||||
if !routesHere(node, e) {
|
if !routesHere(node, e) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(e.Given) == 0 {
|
named = true
|
||||||
problems = append(problems, fmt.Sprintf("the plan %s names this proxy for its route and gives nothing "+
|
// What the module contributes is on the requirement's first entry alone, however many local names it has.
|
||||||
"to serve; it is not served", key))
|
if len(e.Contributed) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
carried = true
|
||||||
|
given, err := catalogue.DecodeContributed(e.Contributed)
|
||||||
|
if err != nil {
|
||||||
|
refuse("", err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
at := ""
|
at := ""
|
||||||
if plan.Node != node {
|
if plan.Node != node {
|
||||||
if e.ConsumerAt == "" {
|
if given.At == "" {
|
||||||
problems = append(problems, fmt.Sprintf("the plan %s routes %s on %s here and says no address on "+
|
refuse("", fmt.Sprintf("it runs on %s and its plan says no address on the private network for it", plan.Node))
|
||||||
"the private network for it; it is not served", key, plan.Module, plan.Node))
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
at = e.ConsumerAt
|
at = given.At
|
||||||
}
|
}
|
||||||
for _, g := range e.Given {
|
for _, one := range given.Each {
|
||||||
out = append(out, contribution{From: plan.Module, Node: plan.Node, At: at, Values: g.Values})
|
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.
|
// routesHere is whether a plan's entry is a route this proxy on node provides.
|
||||||
func routesHere(node string, e catalogue.PlanProvision) bool {
|
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/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
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
|
// 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)
|
`"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
|
// 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
|
// proxy on its node as the route's provider, every contribution of it read with route-proxy's contract, reached over
|
||||||
// the plan's address on another; an entry routing elsewhere, giving nothing or naming no address is not served. A
|
// loopback on this machine and at the plan's address on another; an entry routing elsewhere, carrying nothing, naming
|
||||||
// change is served as it comes, a plan that cannot be read keeps what it gave, and a deleted one withdraws it. The
|
// no address or a route the contract refuses is not served, and is said refused. A change is served as it comes and
|
||||||
// membership says only who is inside: an internal-only name is served to the machines it names.
|
// said served or withdrawn, a plan that cannot be read keeps what it gave, and a deleted one withdraws it. The
|
||||||
func TestThePlansAreServedAndTheMembershipSaysWhoIsInside(t *testing.T) {
|
// placement says only who is inside: an internal-only name is served to the machines it decides addresses for.
|
||||||
const gitea = `{"provision":"route","provider":"anchor","provider-module":"route-proxy","consumer-at":"10.10.0.7",` +
|
func TestThePlansAreServedAndThePlacementSaysWhoIsInside(t *testing.T) {
|
||||||
`"given":[{"local":"web","values":{"name":"git.example","internal-name":"git.anchor.internal","port":3000}},` +
|
const route = `{"provision":"route","provider":"anchor","provider-module":"route-proxy",`
|
||||||
`{"local":"api","values":{"internal-name":"git-api.anchor.internal","port":3001}}]}`
|
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{
|
plans := &fakePlans{current: map[string]string{
|
||||||
"laptop.gitea": plan("laptop", "gitea", gitea+`,{"provision":"postgres-database","provider":"anchor",`+
|
"laptop.gitea": plan("laptop", "gitea", gitea+`,{"provision":"postgres-database","provider":"anchor",`+
|
||||||
`"provider-module":"postgres","given":[{"values":{"name":"gitea"}}]}`),
|
`"provider-module":"postgres",`+contributed("10.10.0.7", "", `{"name":"gitea"}`)+`}`),
|
||||||
"anchor.blog": plan("anchor", "blog", `{"provision":"route","provider":"anchor","provider-module":"route-proxy",`+
|
"anchor.blog": plan("anchor", "blog", route+contributed("10.10.0.1", "", `{"name":"blog.example","port":2368}`)+`}`),
|
||||||
`"consumer-at":"10.10.0.1","given":[{"values":{"name":"blog.example","port":2368}}]}`),
|
|
||||||
"anchor.other": plan("anchor", "other", `{"provision":"route","provider":"edge","provider-module":"route-proxy",`+
|
"anchor.other": plan("anchor", "other", `{"provision":"route","provider":"edge","provider-module":"route-proxy",`+
|
||||||
`"given":[{"values":{"name":"other.example","port":80}}]}`),
|
contributed("", "", `{"name":"other.example","port":80}`)+`}`),
|
||||||
"anchor.older": plan("anchor", "older", `{"provision":"route","provider":"anchor","provider-module":"route-proxy"}`),
|
"anchor.older": plan("anchor", "older", route+`"local":""}`),
|
||||||
"laptop.lost": plan("laptop", "lost", `{"provision":"route","provider":"anchor","provider-module":"route-proxy",`+
|
"laptop.lost": plan("laptop", "lost", route+contributed("", "", `{"name":"lost.example","port":80}`)+`}`),
|
||||||
`"given":[{"values":{"name":"lost.example","port":80}}]}`),
|
|
||||||
}}
|
}}
|
||||||
held := newTable()
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
served := func() map[string]string {
|
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") {
|
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")
|
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 {
|
for _, row := range []struct {
|
||||||
name, key, value string
|
name, key, value string
|
||||||
served []string
|
served []string
|
||||||
withdrawn []string
|
withdrawn []string
|
||||||
|
say []string
|
||||||
}{
|
}{
|
||||||
{"a route arrives", "anchor.new", plan("anchor", "new", `{"provision":"route","provider":"anchor",`+
|
{"a route arrives", "anchor.new", plan("anchor", "new", route+contributed("", "", `{"name":"new.example","port":81}`)+`}`),
|
||||||
`"provider-module":"route-proxy","given":[{"values":{"name":"new.example","port":81}}]}`),
|
[]string{"new.example"}, nil, []string{"route.served anchor new.example new "}},
|
||||||
[]string{"new.example"}, nil},
|
{"an unreadable plan keeps what it gave", "laptop.gitea", `{"v":1,`, []string{"git.example"}, nil, 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 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)}
|
change := stdio.StateChange{Key: row.key, Op: "put", Value: json.RawMessage(row.value)}
|
||||||
if row.value == "" {
|
if row.value == "" {
|
||||||
change = stdio.StateChange{Key: row.key, Op: "delete"}
|
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)
|
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)
|
held = behind(t)
|
||||||
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code != http.StatusNotFound {
|
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) {
|
laptop, err := delivery.PlacementValues(delivery.Placement{Node: "laptop", Decided: delivery.NodeDecided{Address: "10.10.0.7"}})
|
||||||
t.Fatal("a membership naming the mesh was not applied")
|
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 {
|
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) {
|
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.8:1"); code != http.StatusNotFound {
|
||||||
t.Error("a membership whose mesh cannot be read was applied")
|
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 {
|
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"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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
|
// routesFrom is the table a test's routes make, written as `{"given":[{"from","node","at","values"}…]}`: the
|
||||||
// give the proxy (plans.go), each as routesOfPlan makes it.
|
// 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) {
|
func routesFrom(body string) (map[string][]rule, map[string]bool, error) {
|
||||||
var said struct {
|
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 {
|
if err := json.Unmarshal([]byte(body), &said); err != nil {
|
||||||
return nil, nil, err
|
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
|
return routes, public, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ module route-proxy
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
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
|
github.com/nats-io/nats.go v1.54.0
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.57.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
git.novox.be/novox/mesh-controller/modules/route-proxy/contract v0.1.0
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
|
|||||||
@@ -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-controller/modules/route-proxy/contract v0.1.0 h1:89jE1oBK+l7gFJjz6wx8mUD3KxrqSDa7pUTlQkzC1do=
|
||||||
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/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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
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=
|
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
|
|
||||||
}
|
|
||||||
@@ -245,6 +245,11 @@
|
|||||||
"route_proxy_route",
|
"route_proxy_route",
|
||||||
"route_proxy_unrouted"
|
"route_proxy_unrouted"
|
||||||
],
|
],
|
||||||
|
"emits": [
|
||||||
|
"route.served",
|
||||||
|
"route.withdrawn",
|
||||||
|
"route.refused"
|
||||||
|
],
|
||||||
"replaces": {
|
"replaces": {
|
||||||
"route_proxy_routes": [
|
"route_proxy_routes": [
|
||||||
"curl -I"
|
"curl -I"
|
||||||
@@ -257,6 +262,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"reads": [
|
"reads": [
|
||||||
|
"mesh-delivery-assigner.placement",
|
||||||
"mesh-provisioner.plans",
|
"mesh-provisioner.plans",
|
||||||
"mesh-provisioner.provisions"
|
"mesh-provisioner.provisions"
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user