Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fbf658c16 | ||
|
|
bc31745607 | ||
|
|
e5c2eb20f2 |
@@ -27,8 +27,18 @@ build:
|
|||||||
IMAGE ?= mesh-controller:$(VERSION)
|
IMAGE ?= mesh-controller:$(VERSION)
|
||||||
DEV_TAG ?= mesh-controller:development
|
DEV_TAG ?= mesh-controller:development
|
||||||
|
|
||||||
|
# The base the module declares, read from the manifest rather than written here twice.
|
||||||
|
#
|
||||||
|
# **`make image` was broken and stayed broken**, because the Dockerfile's fallback base was a Go
|
||||||
|
# older than go.mod asks for: every build died at `go mod download` with "go.mod requires go >=
|
||||||
|
# 1.26.0", and the pipeline never saw it because the pipeline passes the declared base in. Anybody
|
||||||
|
# building the image by hand hit it and had to find the digest themselves (novox/hq 04-ISSUES/146,
|
||||||
|
# what it cost).
|
||||||
|
GO_BASE ?= $(shell python3 -c "import json;print(next(o['image'] for o in json.load(open('module.json'))['build']['on'] if o['arg']=='GO_BASE'))" 2>/dev/null)
|
||||||
|
|
||||||
image:
|
image:
|
||||||
docker build --build-arg VERSION=$(VERSION) -t $(IMAGE) -t $(DEV_TAG) .
|
@test -n "$(GO_BASE)" || { echo "module.json declares no GO_BASE; pass GO_BASE=<image> or fix the manifest"; exit 1; }
|
||||||
|
docker build --build-arg GO_BASE=$(GO_BASE) --build-arg VERSION=$(VERSION) -t $(IMAGE) -t $(DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
|
|
||||||
@@ -38,7 +48,8 @@ BUILDER_IMAGE ?= mesh-builder:$(VERSION)
|
|||||||
BUILDER_DEV_TAG ?= mesh-builder:development
|
BUILDER_DEV_TAG ?= mesh-builder:development
|
||||||
|
|
||||||
builder-image:
|
builder-image:
|
||||||
docker build -f cmd/mesh-builder/Dockerfile -t $(BUILDER_IMAGE) -t $(BUILDER_DEV_TAG) .
|
@test -n "$(GO_BASE)" || { echo "module.json declares no GO_BASE; pass GO_BASE=<image> or fix the manifest"; exit 1; }
|
||||||
|
docker build --build-arg GO_BASE=$(GO_BASE) -f cmd/mesh-builder/Dockerfile -t $(BUILDER_IMAGE) -t $(BUILDER_DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(BUILDER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(BUILDER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
@@ -12,7 +13,10 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The bus's own certificate, made by the mesh rather than borrowed from an image.
|
// The bus's own certificate, made by the mesh rather than borrowed from an image.
|
||||||
@@ -169,3 +173,86 @@ func writeBusCertificate(crt, key string) error {
|
|||||||
busCertificateNames, template.NotAfter.Format(time.RFC3339), crt, key)
|
busCertificateNames, template.NotAfter.Format(time.RFC3339), crt, key)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// busAccounts writes the mesh's composed user list to a file.
|
||||||
|
//
|
||||||
|
// **For genesis, where no declaration can deliver it** (novox/hq 04-ISSUES/146). Everywhere else
|
||||||
|
// the list reaches the machine running the bus as a resource of the module that holds it — which
|
||||||
|
// requires that machine to be an enrolled node, and at genesis it is not: the first node cannot
|
||||||
|
// enrol because the account it would enrol with cannot be composed onto a bus it has no declaration
|
||||||
|
// for. The installer breaks that circle by placing the file itself, once, and the module takes the
|
||||||
|
// file over from its first push.
|
||||||
|
//
|
||||||
|
// The same composition, not a second one: this asks the store for the same records and renders them
|
||||||
|
// with the same composer the declaration uses. A genesis that hand-wrote an account would be a
|
||||||
|
// second statement of who may say what, able to disagree with the first.
|
||||||
|
//
|
||||||
|
// **It writes to standard output unless told a file**, and that is the point: the control plane
|
||||||
|
// composes and says what it composed, and whoever is raising the machine puts it where that
|
||||||
|
// machine's bus reads it. A control plane that wrote into the bus's own directory would have to
|
||||||
|
// know where that is and how to make the server re-read it — which is the module's knowledge, and
|
||||||
|
// the module is what takes this over on the first push.
|
||||||
|
//
|
||||||
|
// broker accounts > /var/lib/mesh-bus-conf/accounts.conf
|
||||||
|
func busAccounts(ctx context.Context, args []string) error {
|
||||||
|
into := ""
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--into":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
return errors.New("--into needs a file")
|
||||||
|
}
|
||||||
|
into = args[i+1]
|
||||||
|
i++
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("broker accounts --into <file>: %q", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open, err := openStores(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer open.Close()
|
||||||
|
|
||||||
|
records, err := open.inventory.BusRecords(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
users, err := broker.Users(records)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
kept, err := open.inventory.BusUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hashes := make(map[string]string, len(kept))
|
||||||
|
for name, u := range kept {
|
||||||
|
hashes[name] = u.PasswordHash
|
||||||
|
}
|
||||||
|
filled, missing := broker.WithPasswords(users, hashes)
|
||||||
|
if len(missing) > 0 {
|
||||||
|
// To standard error, always: the composed file may be going to standard output, and a
|
||||||
|
// remark in the middle of it is a configuration the server refuses to parse.
|
||||||
|
fmt.Fprintf(os.Stderr, "leaving out %d user(s) the mesh has minted no credential for: %s\n",
|
||||||
|
len(missing), strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
if len(filled) == 0 {
|
||||||
|
return errors.New("not one user has a credential, so this list would refuse every " +
|
||||||
|
"connection in the mesh")
|
||||||
|
}
|
||||||
|
accounts, err := broker.ComposeAccounts(filled)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if into == "" {
|
||||||
|
fmt.Print(accounts)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(into, []byte(accounts), 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("wrote %d user(s) to %s\n", len(filled), into)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func run() error {
|
|||||||
case "identity":
|
case "identity":
|
||||||
return identityCommand(ctx, args[1:])
|
return identityCommand(ctx, args[1:])
|
||||||
case "broker":
|
case "broker":
|
||||||
return brokerCommand(args[1:])
|
return brokerCommand(ctx, args[1:])
|
||||||
case "serve":
|
case "serve":
|
||||||
return serve(ctx)
|
return serve(ctx)
|
||||||
case "upgrade":
|
case "upgrade":
|
||||||
|
|||||||
@@ -363,12 +363,15 @@ func identityCommand(ctx context.Context, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func brokerCommand(args []string) error {
|
func brokerCommand(ctx context.Context, args []string) error {
|
||||||
if len(args) > 0 && args[0] == "certificate" {
|
if len(args) > 0 && args[0] == "certificate" {
|
||||||
return busCertificate(args[1:])
|
return busCertificate(args[1:])
|
||||||
}
|
}
|
||||||
|
if len(args) > 0 && args[0] == "accounts" {
|
||||||
|
return busAccounts(ctx, args[1:])
|
||||||
|
}
|
||||||
if len(args) == 0 || args[0] != "show" {
|
if len(args) == 0 || args[0] != "show" {
|
||||||
return errors.New("broker show | broker certificate [--check] --into <directory>")
|
return errors.New("broker show | broker certificate [--check] --into <directory> | broker accounts --into <file>")
|
||||||
}
|
}
|
||||||
known, err := broker.FromEnvironment()
|
known, err := broker.FromEnvironment()
|
||||||
if errors.Is(err, broker.ErrNotConfigured) {
|
if errors.Is(err, broker.ErrNotConfigured) {
|
||||||
|
|||||||
@@ -62,6 +62,22 @@ func TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose(t *testing.T)
|
|||||||
|
|
||||||
// theCarriedAccounts is the accounts file the installer's template writes at genesis.
|
// theCarriedAccounts is the accounts file the installer's template writes at genesis.
|
||||||
func theCarriedAccounts(t *testing.T) string {
|
func theCarriedAccounts(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, r := range theTemplate(t) {
|
||||||
|
if r["id"] == "bus-accounts" {
|
||||||
|
content, _ := r["content"].(string)
|
||||||
|
if content == "" {
|
||||||
|
t.Fatal("the template's accounts file is empty, so the bus would refuse every connection")
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("the template carries no accounts file, so a mesh raised from it has a bus nobody may use")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// theTemplate is the installer's bundle, as resources.
|
||||||
|
func theTemplate(t *testing.T) []map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join("..", "..", "..", "mesh-host", "examples", "foundation-first-node-nats.lock")
|
path := filepath.Join("..", "..", "..", "mesh-host", "examples", "foundation-first-node-nats.lock")
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
@@ -81,17 +97,7 @@ func theCarriedAccounts(t *testing.T) string {
|
|||||||
if err := json.Unmarshal([]byte(strings.Join(lines, "\n")), &bundle); err != nil {
|
if err := json.Unmarshal([]byte(strings.Join(lines, "\n")), &bundle); err != nil {
|
||||||
t.Fatalf("the template is not readable: %v", err)
|
t.Fatalf("the template is not readable: %v", err)
|
||||||
}
|
}
|
||||||
for _, r := range bundle.Resources {
|
return bundle.Resources
|
||||||
if r["id"] == "bus-accounts" {
|
|
||||||
content, _ := r["content"].(string)
|
|
||||||
if content == "" {
|
|
||||||
t.Fatal("the template's accounts file is empty, so the bus would refuse every connection")
|
|
||||||
}
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Fatal("the template carries no accounts file, so a mesh raised from it has a bus nobody may use")
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// subjectsIn reads one allow-list out of a composed accounts file.
|
// subjectsIn reads one allow-list out of a composed accounts file.
|
||||||
|
|||||||
@@ -184,7 +184,20 @@ func (j *JetStream) EnsureConsumer(c Consumer) error {
|
|||||||
// without the other is refused by the server with a message that does not say which half is
|
// without the other is refused by the server with a message that does not say which half is
|
||||||
// missing.
|
// missing.
|
||||||
if c.Queue != "" || c.Push {
|
if c.Queue != "" || c.Push {
|
||||||
want.DeliverSubject = "_DELIVER." + c.Name
|
// **Per consumer, which means per stream as well as per name** (novox/hq 04-ISSUES/146).
|
||||||
|
// A push consumer delivers onto an ordinary subject, and everything subscribed to that
|
||||||
|
// subject gets a copy. The controller holds a consumer called `controller` on CONTROL and
|
||||||
|
// another called `controller` on EVENTS, and both were given `_DELIVER.controller` — so the
|
||||||
|
// one process, holding both subscriptions, acted on every message twice. It enrolled a
|
||||||
|
// joining machine twice from one request, minting a second credential that replaced the one
|
||||||
|
// the machine had just been given; the same doubling applied to every report and every
|
||||||
|
// event the controller follows.
|
||||||
|
//
|
||||||
|
// The stream is in the name because the pair is what identifies a consumer — the server
|
||||||
|
// scopes a durable's name to its stream, and this subject is the only place that scoping
|
||||||
|
// was dropped. Already within what the controller may subscribe (`_DELIVER.controller.>`),
|
||||||
|
// so no permission moves.
|
||||||
|
want.DeliverSubject = DeliverSubjectFor(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch _, err := j.js.ConsumerInfo(c.Stream, c.Name); {
|
switch _, err := j.js.ConsumerInfo(c.Stream, c.Name); {
|
||||||
|
|||||||
@@ -269,7 +269,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
"mesh.control." + p.Node + ".>",
|
"mesh.control." + p.Node + ".>",
|
||||||
"$JS.API.CONSUMER.INFO.NODES." + p.Node,
|
"$JS.API.CONSUMER.INFO.NODES." + p.Node,
|
||||||
}
|
}
|
||||||
sub = []string{"mesh.node." + p.Node + ".declare", "_DELIVER." + p.Node}
|
// The deliver subject carries the stream as well as the consumer's name, so what a
|
||||||
|
// subscriber is permitted has to carry it too (novox/hq 04-ISSUES/146). The bare name
|
||||||
|
// stays: an existing consumer keeps delivering where it always did until the controller's
|
||||||
|
// next assertion moves it, and a permission that only allowed the new shape would refuse
|
||||||
|
// every node in the mesh for exactly as long as that took.
|
||||||
|
sub = []string{"mesh.node." + p.Node + ".declare",
|
||||||
|
"_DELIVER." + p.Node, "_DELIVER." + p.Node + ".>"}
|
||||||
|
|
||||||
case KindModule:
|
case KindModule:
|
||||||
// 1. Its own namespace: it publishes its events there and serves its tools there. Nothing
|
// 1. Its own namespace: it publishes its events there and serves its tools there. Nothing
|
||||||
@@ -323,7 +329,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
// take work over the new bus was refused the asking (2026-09-28).
|
// take work over the new bus was refused the asking (2026-09-28).
|
||||||
worker := "SEAT_" + upperSnake(s.Name) + "_worker"
|
worker := "SEAT_" + upperSnake(s.Name) + "_worker"
|
||||||
stream := seatStreamName(s.Name)
|
stream := seatStreamName(s.Name)
|
||||||
sub = append(sub, "_DELIVER."+worker)
|
sub = append(sub, "_DELIVER."+worker, "_DELIVER."+worker+".>")
|
||||||
pub = append(pub, "$JS.API.CONSUMER.INFO."+stream+"."+worker, "$JS.ACK."+stream+"."+worker+".>")
|
pub = append(pub, "$JS.API.CONSUMER.INFO."+stream+"."+worker, "$JS.ACK."+stream+"."+worker+".>")
|
||||||
for _, a := range s.Accepts {
|
for _, a := range s.Accepts {
|
||||||
sub = append(sub, seatSubject(s, "accept", a))
|
sub = append(sub, seatSubject(s, "accept", a))
|
||||||
|
|||||||
@@ -94,6 +94,24 @@ func MeshStreams() []Stream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeliverSubjectFor is where a push consumer's messages land.
|
||||||
|
//
|
||||||
|
// **Per consumer, which means per stream as well as per name** (novox/hq 04-ISSUES/146). A push
|
||||||
|
// consumer delivers onto an ordinary subject, and everything subscribed to that subject gets a
|
||||||
|
// copy. The controller holds a consumer called `controller` on CONTROL and another called
|
||||||
|
// `controller` on EVENTS; while both were given `_DELIVER.controller`, the one process holding
|
||||||
|
// both subscriptions acted on every message twice — a joining machine was enrolled twice from one
|
||||||
|
// request, and the second enrolment minted a credential that replaced the one the machine had just
|
||||||
|
// been handed. Every report and every followed event doubled the same way, silently: nothing is
|
||||||
|
// redelivered, no count is wrong, the work simply happens twice.
|
||||||
|
//
|
||||||
|
// The stream belongs in it because the pair is what identifies a consumer — the server scopes a
|
||||||
|
// durable's name to its stream, and this subject was the one place that scoping was dropped. It
|
||||||
|
// stays inside what a controller may already subscribe (`_DELIVER.controller.>`).
|
||||||
|
func DeliverSubjectFor(c Consumer) string {
|
||||||
|
return "_DELIVER." + c.Name + "." + c.Stream
|
||||||
|
}
|
||||||
|
|
||||||
// An Asserter is the part of a JetStream connection stream assertion needs. Narrow on purpose: it
|
// An Asserter is the part of a JetStream connection stream assertion needs. Narrow on purpose: it
|
||||||
// keeps this testable without a server, and keeps the client library out of everything that only
|
// keeps this testable without a server, and keeps the client library out of everything that only
|
||||||
// wants to know what the streams are.
|
// wants to know what the streams are.
|
||||||
|
|||||||
@@ -233,3 +233,30 @@ func containsStep(steps []string, want string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **Two consumers may share a name, and must not share a delivery subject** (novox/hq
|
||||||
|
// 04-ISSUES/146).
|
||||||
|
//
|
||||||
|
// A push consumer delivers onto an ordinary subject and everything subscribed to it gets a copy.
|
||||||
|
// The controller holds a consumer called `controller` on CONTROL and another called `controller` on
|
||||||
|
// EVENTS; while both were given `_DELIVER.controller`, the one process holding both subscriptions
|
||||||
|
// acted on every message twice — a joining machine enrolled twice from one request, with the second
|
||||||
|
// enrolment minting a credential that replaced the one the machine had just been handed.
|
||||||
|
//
|
||||||
|
// Checked here rather than against a server because it is a property of what the mesh asks for, and
|
||||||
|
// because the failure it produces is silent: every count is right, nothing is redelivered, and the
|
||||||
|
// work simply happens twice.
|
||||||
|
func TestNoTwoConsumersDeliverOntoTheSameSubject(t *testing.T) {
|
||||||
|
seen := map[string]string{}
|
||||||
|
for _, c := range MeshConsumers() {
|
||||||
|
if !c.Push && c.Queue == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
subject := DeliverSubjectFor(c)
|
||||||
|
if other, taken := seen[subject]; taken {
|
||||||
|
t.Errorf("%s on %s and %s deliver onto %s, so whoever holds both acts on every "+
|
||||||
|
"message twice", c.Name, c.Stream, other, subject)
|
||||||
|
}
|
||||||
|
seen[subject] = c.Name + " on " + c.Stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -34,11 +34,11 @@ accounts {
|
|||||||
} }
|
} }
|
||||||
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
|
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.INFO.NODES.one", "mesh.control.one.>"] }
|
publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.INFO.NODES.one", "mesh.control.one.>"] }
|
||||||
subscribe: { allow: ["_DELIVER.one", "_INBOX.node.one.>", "mesh.node.one.declare"] }
|
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.declare"] }
|
||||||
} }
|
} }
|
||||||
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
|
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
|
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
|
||||||
subscribe: { allow: ["_DELIVER.SEAT_TELEGRAM_SENDER_worker", "_INBOX.one.telegram.>", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
subscribe: { allow: ["_DELIVER.SEAT_TELEGRAM_SENDER_worker", "_DELIVER.SEAT_TELEGRAM_SENDER_worker.>", "_INBOX.one.telegram.>", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
|
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reading the bus's user list out of the mesh's records, against a real store.
|
// Reading the bus's user list out of the mesh's records, against a real store.
|
||||||
@@ -164,3 +165,63 @@ func granted(all []string, one string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **A token is an account on the bus, or it is a string nothing accepts** (novox/hq 04-ISSUES/146).
|
||||||
|
//
|
||||||
|
// The composed list names an enrolment user for every machine with a live token, and nothing minted
|
||||||
|
// a credential for it — so the composer left it out as a user with no password, and every enrolment
|
||||||
|
// since the mesh moved to this bus was refused by the server before the mesh heard of it. Nothing
|
||||||
|
// caught it because nothing had enrolled since.
|
||||||
|
//
|
||||||
|
// The password cannot be minted, because it is the token's own secret: the machine will present
|
||||||
|
// exactly that string. So this checks the two halves that make the account usable — that a row
|
||||||
|
// exists under the name the composer asks for, and that the secret handed out is what that row
|
||||||
|
// accepts.
|
||||||
|
func TestIssuingATokenRecordsTheAccountItIsThePasswordOf(t *testing.T) {
|
||||||
|
inv, ctx := aMeshWith(t)
|
||||||
|
if _, err := inv.AddNode(ctx, "joiner"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
issued, err := inv.IssueToken(ctx, "joiner", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := broker.Principal{Kind: broker.KindEnrolment, Node: "joiner"}.Username()
|
||||||
|
users, err := inv.BusUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
user, has := users[name]
|
||||||
|
if !has {
|
||||||
|
t.Fatalf("no bus account for %q; the composer would leave the enrolment out and the "+
|
||||||
|
"machine would be refused before the mesh heard of it: %v", name, users)
|
||||||
|
}
|
||||||
|
if user.Kind != BusEnrolment || user.Node != "joiner" {
|
||||||
|
t.Errorf("the account is %+v, not this node's enrolment", user)
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(issued.Secret)); err != nil {
|
||||||
|
t.Error("the account does not accept the secret the token carries, so presenting the " +
|
||||||
|
"token would be refused by the server")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the composition contains it, which is the thing the server reads.
|
||||||
|
records, err := inv.BusRecords(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
derived, err := broker.Users(records)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hashes := map[string]string{}
|
||||||
|
for n, u := range users {
|
||||||
|
hashes[n] = u.PasswordHash
|
||||||
|
}
|
||||||
|
_, missing := broker.WithPasswords(derived, hashes)
|
||||||
|
for _, m := range missing {
|
||||||
|
if m == name {
|
||||||
|
t.Fatal("the enrolment user is composed without a password, which is a user nobody can be")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,40 @@ const (
|
|||||||
// reply, into a module's sealed environment — and the mesh keeps only the hash, so a credential is
|
// reply, into a module's sealed environment — and the mesh keeps only the hash, so a credential is
|
||||||
// never recoverable from the store. A caller that loses it must mint again, which is a rotation and
|
// never recoverable from the store. A caller that loses it must mint again, which is a rotation and
|
||||||
// is meant to feel like one.
|
// is meant to feel like one.
|
||||||
|
// RecordBusPassword records a hash for a password the caller already holds.
|
||||||
|
//
|
||||||
|
// **For the one credential the mesh does not choose**: an enrolment token's secret is the password
|
||||||
|
// of the user that presents it (novox/hq ADR 0004, design 25 §6), so the token cannot be given a
|
||||||
|
// minted password — it already has one, and the machine will connect with exactly that string.
|
||||||
|
// Everything else goes through Mint, which chooses and returns the plaintext once.
|
||||||
|
func (i *Inventory) RecordBusPassword(ctx context.Context, u BusUser, password string) error {
|
||||||
|
if u.Username == "" || u.Kind == "" {
|
||||||
|
return errors.New("a bus user needs a username and a kind")
|
||||||
|
}
|
||||||
|
if password == "" {
|
||||||
|
return errors.New("a bus user needs a password")
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot hash a bus password: %w", err)
|
||||||
|
}
|
||||||
|
return i.writeBusUser(ctx, u, string(hash))
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeBusUser is the row, whoever chose the password.
|
||||||
|
func (i *Inventory) writeBusUser(ctx context.Context, u BusUser, hash string) error {
|
||||||
|
if _, err := i.store.Pool().Exec(ctx,
|
||||||
|
`insert into bus_user (username, kind, node, module, password_hash)
|
||||||
|
values ($1, $2, $3, $4, $5)
|
||||||
|
on conflict (username) do update
|
||||||
|
set kind = excluded.kind, node = excluded.node, module = excluded.module,
|
||||||
|
password_hash = excluded.password_hash, minted_at = now()`,
|
||||||
|
u.Username, u.Kind, u.Node, u.Module, hash); err != nil {
|
||||||
|
return fmt.Errorf("cannot record the bus user %s: %w", u.Username, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (i *Inventory) MintBusPassword(ctx context.Context, u BusUser) (string, error) {
|
func (i *Inventory) MintBusPassword(ctx context.Context, u BusUser) (string, error) {
|
||||||
if u.Username == "" || u.Kind == "" {
|
if u.Username == "" || u.Kind == "" {
|
||||||
return "", errors.New("a bus user needs a username and a kind")
|
return "", errors.New("a bus user needs a username and a kind")
|
||||||
@@ -69,14 +103,8 @@ func (i *Inventory) MintBusPassword(ctx context.Context, u BusUser) (string, err
|
|||||||
return "", fmt.Errorf("cannot hash a bus password: %w", err)
|
return "", fmt.Errorf("cannot hash a bus password: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := i.store.Pool().Exec(ctx,
|
if err := i.writeBusUser(ctx, u, string(hash)); err != nil {
|
||||||
`insert into bus_user (username, kind, node, module, password_hash)
|
return "", err
|
||||||
values ($1, $2, $3, $4, $5)
|
|
||||||
on conflict (username) do update
|
|
||||||
set kind = excluded.kind, node = excluded.node, module = excluded.module,
|
|
||||||
password_hash = excluded.password_hash, minted_at = now()`,
|
|
||||||
u.Username, u.Kind, u.Node, u.Module, string(hash)); err != nil {
|
|
||||||
return "", fmt.Errorf("cannot record the bus user %s: %w", u.Username, err)
|
|
||||||
}
|
}
|
||||||
return password, nil
|
return password, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
"github.com/novox/mesh-controller/internal/store"
|
"github.com/novox/mesh-controller/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -263,6 +264,29 @@ func (i *Inventory) IssueToken(ctx context.Context, nodeName string, validFor ti
|
|||||||
return Issued{}, err
|
return Issued{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **And the account that secret is the password of** (novox/hq 04-ISSUES/146). The composed
|
||||||
|
// user list names an enrolment user for every node with a live token, and nothing minted a
|
||||||
|
// credential for it — so the composer left it out as a user with no password and every
|
||||||
|
// enrolment was refused by the server before the mesh heard of it.
|
||||||
|
//
|
||||||
|
// Recorded rather than minted: the token's secret IS the password, which is what lets a
|
||||||
|
// machine's first connection be authenticated by the thing it is enrolling with. It cannot be
|
||||||
|
// chosen here, because it has already been handed to whoever will present it.
|
||||||
|
//
|
||||||
|
// Outside the transaction on purpose. The token is what the mesh promised; a credential that
|
||||||
|
// the next composition rewrites anyway is not worth failing an issue over, and a token with no
|
||||||
|
// account is recoverable by issuing another, while an account with no token is a user nobody
|
||||||
|
// can be.
|
||||||
|
if err := i.RecordBusPassword(ctx, BusUser{
|
||||||
|
Username: broker.Principal{Kind: broker.KindEnrolment, Node: node.Name}.Username(),
|
||||||
|
Kind: BusEnrolment,
|
||||||
|
Node: node.Name,
|
||||||
|
}, secret); err != nil {
|
||||||
|
return Issued{}, fmt.Errorf(
|
||||||
|
"the token for %s was issued and the bus account it is the password of was not "+
|
||||||
|
"recorded, so this token cannot connect: %w", node.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
return Issued{Node: node, Secret: secret, Expires: expires}, nil
|
return Issued{Node: node, Secret: secret, Expires: expires}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user