Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5b4b1fba6 | ||
|
|
ec78fafc82 | ||
|
|
689dd060b0 | ||
|
|
99c4c4ef04 | ||
|
|
e5e1666f91 | ||
|
|
bd58d1c3ef | ||
|
|
d134c89a7c | ||
|
|
a9307d9f33 | ||
|
|
5eb1c9c2b7 | ||
|
|
37b8edeec6 | ||
|
|
424406b2d6 | ||
|
|
90455c61f6 | ||
|
|
1f6793cf0c | ||
|
|
6ef522fbda | ||
|
|
46f65c12a0 | ||
|
|
8f7c02d77a | ||
|
|
a637df01ea | ||
|
|
252eb786a7 |
@@ -59,7 +59,7 @@ PROVISIONER_IMAGE ?= mesh-provision-postgres:$(VERSION)
|
|||||||
PROVISIONER_DEV_TAG ?= mesh-provision-postgres:development
|
PROVISIONER_DEV_TAG ?= mesh-provision-postgres:development
|
||||||
|
|
||||||
provisioner-image:
|
provisioner-image:
|
||||||
docker build -f examples/postgres-provisioner/Dockerfile \
|
docker build --build-arg GO_BASE=$(GO_BASE) -f examples/postgres-provisioner/Dockerfile \
|
||||||
-t $(PROVISIONER_IMAGE) -t $(PROVISIONER_DEV_TAG) .
|
-t $(PROVISIONER_IMAGE) -t $(PROVISIONER_DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
@@ -70,7 +70,7 @@ OBJECTSTORE_IMAGE ?= mesh-provision-objectstore:$(VERSION)
|
|||||||
OBJECTSTORE_DEV_TAG ?= mesh-provision-objectstore:development
|
OBJECTSTORE_DEV_TAG ?= mesh-provision-objectstore:development
|
||||||
|
|
||||||
objectstore-image:
|
objectstore-image:
|
||||||
docker build -f examples/objectstore-provisioner/Dockerfile \
|
docker build --build-arg GO_BASE=$(GO_BASE) -f examples/objectstore-provisioner/Dockerfile \
|
||||||
-t $(OBJECTSTORE_IMAGE) -t $(OBJECTSTORE_DEV_TAG) .
|
-t $(OBJECTSTORE_IMAGE) -t $(OBJECTSTORE_DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(OBJECTSTORE_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(OBJECTSTORE_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
@@ -81,7 +81,7 @@ REDIS_PROVISIONER_IMAGE ?= mesh-provision-redis:$(VERSION)
|
|||||||
REDIS_PROVISIONER_DEV_TAG ?= mesh-provision-redis:development
|
REDIS_PROVISIONER_DEV_TAG ?= mesh-provision-redis:development
|
||||||
|
|
||||||
redis-provisioner-image:
|
redis-provisioner-image:
|
||||||
docker build -f examples/redis-provisioner/Dockerfile \
|
docker build --build-arg GO_BASE=$(GO_BASE) -f examples/redis-provisioner/Dockerfile \
|
||||||
-t $(REDIS_PROVISIONER_IMAGE) -t $(REDIS_PROVISIONER_DEV_TAG) .
|
-t $(REDIS_PROVISIONER_IMAGE) -t $(REDIS_PROVISIONER_DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(REDIS_PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(REDIS_PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
@@ -91,7 +91,7 @@ PROXY_IMAGE ?= mesh-route-proxy:$(VERSION)
|
|||||||
PROXY_DEV_TAG ?= mesh-route-proxy:development
|
PROXY_DEV_TAG ?= mesh-route-proxy:development
|
||||||
|
|
||||||
proxy-image:
|
proxy-image:
|
||||||
docker build -f examples/route-proxy/Dockerfile -t $(PROXY_IMAGE) -t $(PROXY_DEV_TAG) .
|
docker build --build-arg GO_BASE=$(GO_BASE) -f examples/route-proxy/Dockerfile -t $(PROXY_IMAGE) -t $(PROXY_DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(PROXY_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(PROXY_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ func showFiltering(f inventory.Filtering, adopted bool) {
|
|||||||
switch {
|
switch {
|
||||||
case fw.Active:
|
case fw.Active:
|
||||||
fmt.Printf(" found firewall %s is ACTIVE on this converged machine; the next apply retires it again\n", fw.Kind)
|
fmt.Printf(" found firewall %s is ACTIVE on this converged machine; the next apply retires it again\n", fw.Kind)
|
||||||
|
case fw.RetiredBy == "removed":
|
||||||
|
fmt.Printf(" found firewall %s, removed: the mesh's filter is what filters this machine (novox/hq ADR 0175)\n", fw.Kind)
|
||||||
case fw.RetiredBy == inventory.FilterMesh || fw.RetiredBy == "mesh":
|
case fw.RetiredBy == inventory.FilterMesh || fw.RetiredBy == "mesh":
|
||||||
fmt.Printf(" found firewall %s, retired by the mesh; its configuration stays on disk\n", fw.Kind)
|
fmt.Printf(" found firewall %s, retired by the mesh; its configuration stays on disk\n", fw.Kind)
|
||||||
case fw.RetiredBy != "":
|
case fw.RetiredBy != "":
|
||||||
|
|||||||
@@ -232,9 +232,22 @@ func network(ctx context.Context, inv *inventory.Inventory, on map[string]bool,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// **A machine joining is on the network before it is anything else** (novox/hq ADR 0169). Its
|
||||||
|
// token was issued for its tunnel key and gave it an address, so while that token can still be
|
||||||
|
// used the hub carries it as a peer: it brings its tunnel up from the token and enrols over it.
|
||||||
|
// When the token is spent the machine is on the network by what it runs, as every other is; when
|
||||||
|
// it expires unused, the peer goes with it at the hub's next composition.
|
||||||
|
joining, err := inv.NodesWithALiveToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
isJoining := map[string]bool{}
|
||||||
|
for _, name := range joining {
|
||||||
|
isJoining[name] = true
|
||||||
|
}
|
||||||
nodes := make([]overlay.Node, 0, len(places))
|
nodes := make([]overlay.Node, 0, len(places))
|
||||||
for _, p := range places {
|
for _, p := range places {
|
||||||
if !on[p.Name] {
|
if !on[p.Name] && !(isJoining[p.Name] && p.Key != "" && p.Address != "") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
n := overlay.Node{
|
n := overlay.Node{
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -230,6 +232,8 @@ func tokenCommand(ctx context.Context, args []string) error {
|
|||||||
validFor := set.Duration("for", time.Hour, "how long the token may be used")
|
validFor := set.Duration("for", time.Hour, "how long the token may be used")
|
||||||
adopted := set.Bool("adopted", false,
|
adopted := set.Bool("adopted", false,
|
||||||
"the machine joining is in use: it is adopted, and keeps what is found on it")
|
"the machine joining is in use: it is adopted, and keeps what is found on it")
|
||||||
|
tunnelKey := set.String("overlay-key", "",
|
||||||
|
"the public half of the tunnel key the machine made (`nox-mesh-host key`): it joins through the tunnel")
|
||||||
if err := set.Parse(args[1:]); err != nil {
|
if err := set.Parse(args[1:]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -283,6 +287,14 @@ func tokenCommand(ctx context.Context, args []string) error {
|
|||||||
default:
|
default:
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// **Through the tunnel** (novox/hq ADR 0169): the machine's key recorded, its address given, the
|
||||||
|
// hub sent it as a peer — all before the token is shown, so the tunnel answers the first time the
|
||||||
|
// machine knocks. The bus is then reached at its address on the private network.
|
||||||
|
if *tunnelKey != "" {
|
||||||
|
if made.Tunnel, made.Broker, err = throughTheTunnel(ctx, open, issued.Node, *tunnelKey, made.Broker); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
encoded, err := made.Encode()
|
encoded, err := made.Encode()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -307,6 +319,66 @@ func tokenCommand(ctx context.Context, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// throughTheTunnel makes a machine a peer of the hub for its token, and says what the token carries
|
||||||
|
// for it: its first tunnel, and the bus at its address on the private network (novox/hq ADR 0169).
|
||||||
|
//
|
||||||
|
// The hub is pushed here, before the token is shown. A token shown before the hub knew the key is a
|
||||||
|
// tunnel that does not answer, and a machine that cannot tell that from a bus that is down.
|
||||||
|
func throughTheTunnel(ctx context.Context, open *stores, node inventory.Node, key, busAt string) (
|
||||||
|
*token.Tunnel, string, error) {
|
||||||
|
inv := open.inventory
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if raw, err := base64.StdEncoding.DecodeString(key); err != nil || len(raw) != 32 {
|
||||||
|
return nil, "", fmt.Errorf("%q is not a tunnel public key: it is 32 bytes in base64, as "+
|
||||||
|
"`nox-mesh-host key` prints it", key)
|
||||||
|
}
|
||||||
|
// The bus on the private network is the hub's address at the bus's own port, so the port must be
|
||||||
|
// known before anything is recorded.
|
||||||
|
_, port, err := net.SplitHostPort(busAt)
|
||||||
|
if err != nil || port == "" {
|
||||||
|
return nil, "", fmt.Errorf("the bus's address %q has no port to reach it on", busAt)
|
||||||
|
}
|
||||||
|
places, err := inv.Overlays(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
var hub *inventory.Overlay
|
||||||
|
for i := range places {
|
||||||
|
if places[i].Hub {
|
||||||
|
hub = &places[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hub == nil || hub.Key == "" || hub.Endpoint == "" || hub.Address == "" {
|
||||||
|
return nil, "", errors.New("this mesh has no hub with a key, an address and an endpoint to " +
|
||||||
|
"dial, so there is no tunnel to join through: place one (`overlay place <node> --hub " +
|
||||||
|
"--endpoint <host>:<port>`), or issue the token without --overlay-key")
|
||||||
|
}
|
||||||
|
if err := inv.RecordOverlayKey(ctx, node.ID, key); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if err := inv.BindTokenToKey(ctx, node.ID, key); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
cidr, err := overlayRange(ctx, inv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
address, err := inv.AssignAddress(ctx, node.ID, cidr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if err := sendTo(ctx, open, []string{hub.Name}); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("%s was made a peer of the hub, and the hub could not be sent "+
|
||||||
|
"it, so the tunnel would not answer — the token is not shown; issue it again once %s "+
|
||||||
|
"can be pushed: %w", node.Name, hub.Name, err)
|
||||||
|
}
|
||||||
|
// An address, not a name — nothing resolves before the machine has joined (novox/hq ADR 0004).
|
||||||
|
return &token.Tunnel{
|
||||||
|
Key: key, Address: address + "/32", Range: cidr,
|
||||||
|
HubKey: hub.Key, HubEndpoint: hub.Endpoint,
|
||||||
|
}, net.JoinHostPort(hub.Address, port), nil
|
||||||
|
}
|
||||||
|
|
||||||
// issueFor is the inventory's half of issuing a token: the record, made when it is new, adopted
|
// issueFor is the inventory's half of issuing a token: the record, made when it is new, adopted
|
||||||
// when the operator says so, and the one-time secret for it. The node in what it returns carries
|
// when the operator says so, and the one-time secret for it. The node in what it returns carries
|
||||||
// its mode, which is what the token says.
|
// its mode, which is what the token says.
|
||||||
|
|||||||
@@ -48,6 +48,21 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
switch verb {
|
switch verb {
|
||||||
|
case "command":
|
||||||
|
// The generic verb: the command line as given, split as a shell would split it, with
|
||||||
|
// nothing added — the named verbs add flags a caller cannot reach; this one is the whole
|
||||||
|
// binary and says so in its description (novox/hq ADR 0154, 0175).
|
||||||
|
if err := need("command"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
argv, err := splitCommandLine(str("command"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(argv) == 0 {
|
||||||
|
return nil, errors.New("command names no command")
|
||||||
|
}
|
||||||
|
return argv, nil
|
||||||
case "status":
|
case "status":
|
||||||
return []string{"status", "--json"}, nil
|
return []string{"status", "--json"}, nil
|
||||||
case "nodes":
|
case "nodes":
|
||||||
@@ -129,6 +144,26 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
|||||||
// Half of either shape: the command says its usage, which names both shapes, and that is
|
// Half of either shape: the command says its usage, which names both shapes, and that is
|
||||||
// the answer the caller needs.
|
// the answer the caller needs.
|
||||||
return []string{"rotate"}, nil
|
return []string{"rotate"}, nil
|
||||||
|
case "token":
|
||||||
|
// `token issue` at a shell (novox/hq ADR 0169). Exactly one of node or new; the command
|
||||||
|
// refuses both or neither in its own words.
|
||||||
|
argv := []string{"token", "issue"}
|
||||||
|
if n := str("node"); n != "" {
|
||||||
|
argv = append(argv, "--node", n)
|
||||||
|
}
|
||||||
|
if n := str("new"); n != "" {
|
||||||
|
argv = append(argv, "--new", n)
|
||||||
|
}
|
||||||
|
if k := str("overlay_key"); k != "" {
|
||||||
|
argv = append(argv, "--overlay-key", k)
|
||||||
|
}
|
||||||
|
if d := str("for"); d != "" {
|
||||||
|
argv = append(argv, "--for", d)
|
||||||
|
}
|
||||||
|
if str("adopted") == "true" {
|
||||||
|
argv = append(argv, "--adopted")
|
||||||
|
}
|
||||||
|
return argv, nil
|
||||||
case "settings":
|
case "settings":
|
||||||
// `settings set|clear` at a shell (novox/hq issue 198). The values travel as an argument
|
// `settings set|clear` at a shell (novox/hq issue 198). The values travel as an argument
|
||||||
// because a tool has no file to hand the command; the command reads either.
|
// because a tool has no file to hand the command; the command reads either.
|
||||||
@@ -289,3 +324,57 @@ func sampleArguments(v catalogue.Verb) map[string]any {
|
|||||||
}
|
}
|
||||||
return sample
|
return sample
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// splitCommandLine splits a command line into words the way a POSIX shell does for the simple
|
||||||
|
// cases a controller command needs: spaces separate, single or double quotes group, a backslash
|
||||||
|
// escapes the next character inside double quotes or outside any. No expansion of anything.
|
||||||
|
func splitCommandLine(line string) ([]string, error) {
|
||||||
|
var words []string
|
||||||
|
var cur strings.Builder
|
||||||
|
inWord := false
|
||||||
|
quote := rune(0)
|
||||||
|
runes := []rune(line)
|
||||||
|
for i := 0; i < len(runes); i++ {
|
||||||
|
r := runes[i]
|
||||||
|
switch {
|
||||||
|
case quote == '\'':
|
||||||
|
if r == '\'' {
|
||||||
|
quote = 0
|
||||||
|
} else {
|
||||||
|
cur.WriteRune(r)
|
||||||
|
}
|
||||||
|
case quote == '"':
|
||||||
|
if r == '"' {
|
||||||
|
quote = 0
|
||||||
|
} else if r == '\\' && i+1 < len(runes) {
|
||||||
|
i++
|
||||||
|
cur.WriteRune(runes[i])
|
||||||
|
} else {
|
||||||
|
cur.WriteRune(r)
|
||||||
|
}
|
||||||
|
case r == '\'' || r == '"':
|
||||||
|
quote = r
|
||||||
|
inWord = true
|
||||||
|
case r == '\\' && i+1 < len(runes):
|
||||||
|
i++
|
||||||
|
cur.WriteRune(runes[i])
|
||||||
|
inWord = true
|
||||||
|
case r == ' ' || r == '\t' || r == '\n':
|
||||||
|
if inWord {
|
||||||
|
words = append(words, cur.String())
|
||||||
|
cur.Reset()
|
||||||
|
inWord = false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
cur.WriteRune(r)
|
||||||
|
inWord = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if quote != 0 {
|
||||||
|
return nil, fmt.Errorf("command has an unclosed %c quote", quote)
|
||||||
|
}
|
||||||
|
if inWord {
|
||||||
|
words = append(words, cur.String())
|
||||||
|
}
|
||||||
|
return words, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `token` is `token issue` at a shell, with the machine's tunnel key (novox/hq ADR 0169).
|
||||||
|
func TestTokenIssuesForAMachineAndItsTunnelKey(t *testing.T) {
|
||||||
|
argv, err := argvFor("token", map[string]any{"new": "laptop", "overlay_key": "k", "for": "2h"})
|
||||||
|
if err != nil || strings.Join(argv, " ") != "token issue --new laptop --overlay-key k --for 2h" {
|
||||||
|
t.Fatalf("token: %v %v", argv, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// `settings` is `settings set|clear` at a shell, with the values passed inline (novox/hq issue 198).
|
// `settings` is `settings set|clear` at a shell, with the values passed inline (novox/hq issue 198).
|
||||||
func TestSettingsSetsOrClearsALayer(t *testing.T) {
|
func TestSettingsSetsOrClearsALayer(t *testing.T) {
|
||||||
argv, err := argvFor("settings", map[string]any{"module": "dnsmasq", "values": `{"a":1}`, "node": "ace"})
|
argv, err := argvFor("settings", map[string]any{"module": "dnsmasq", "values": `{"a":1}`, "node": "ace"})
|
||||||
@@ -171,3 +179,27 @@ func TestAJSONVerbsAnswerIsItsStandardOutput(t *testing.T) {
|
|||||||
t.Fatalf("stderr and stdout are both what the command said: %s", answer.Output)
|
t.Fatalf("stderr and stdout are both what the command said: %s", answer.Output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `command` is the generic verb: the command line as given, split as a shell would, nothing added —
|
||||||
|
// so an operator's `node account g14 jochen` is one call through the console rather than a shell on
|
||||||
|
// the control node (novox/hq ADR 0154, ADR 0175).
|
||||||
|
func TestCommandRunsTheLineAsGiven(t *testing.T) {
|
||||||
|
argv, err := argvFor("command", map[string]any{"command": "node account g14 jochen"})
|
||||||
|
if err != nil || strings.Join(argv, " ") != "node account g14 jochen" {
|
||||||
|
t.Fatalf("a plain line: %v %v", argv, err)
|
||||||
|
}
|
||||||
|
argv, err = argvFor("command", map[string]any{"command": `settings set dnsmasq '{"a": "b c"}' --node ace`})
|
||||||
|
if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` {
|
||||||
|
t.Fatalf("a quoted word stays one word: %q %v", argv, err)
|
||||||
|
}
|
||||||
|
argv, err = argvFor("command", map[string]any{"command": `node add "the box" --adopted`})
|
||||||
|
if err != nil || len(argv) != 4 || argv[2] != "the box" {
|
||||||
|
t.Fatalf("double quotes group: %q %v", argv, err)
|
||||||
|
}
|
||||||
|
if _, err := argvFor("command", map[string]any{"command": " "}); err == nil {
|
||||||
|
t.Fatal("an empty line was accepted")
|
||||||
|
}
|
||||||
|
if _, err := argvFor("command", map[string]any{"command": `node "unclosed`}); err == nil {
|
||||||
|
t.Fatal("an unclosed quote was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@
|
|||||||
# The client is copied from the vendor's own image rather than installed from a distribution:
|
# The client is copied from the vendor's own image rather than installed from a distribution:
|
||||||
# `apk add mc` on Alpine installs Midnight Commander, which is a different program with the same
|
# `apk add mc` on Alpine installs Midnight Commander, which is a different program with the same
|
||||||
# name, and the failure would be a provisioner that starts cleanly and cannot do anything.
|
# name, and the failure would be a provisioner that starts cleanly and cannot do anything.
|
||||||
FROM golang:1.25-alpine AS build
|
# The Go it builds with is the one the manifest pins (build.on GO_BASE), passed by the Makefile and the
|
||||||
|
# build machine alike; the default only serves a hand build, and matches go.mod.
|
||||||
|
ARG GO_BASE=golang:1.26-alpine
|
||||||
|
FROM ${GO_BASE} AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
# Built here so a machine can be given it by the mesh rather than by somebody putting a binary on
|
# Built here so a machine can be given it by the mesh rather than by somebody putting a binary on
|
||||||
# it. Static and FROM scratch for the same reason the control plane's image is: it is fetched by
|
# it. Static and FROM scratch for the same reason the control plane's image is: it is fetched by
|
||||||
# digest and run on a machine, and everything in it is something a person would have to audit.
|
# digest and run on a machine, and everything in it is something a person would have to audit.
|
||||||
FROM golang:1.25-alpine AS build
|
# The Go it builds with is the one the manifest pins (build.on GO_BASE), passed by the Makefile and the
|
||||||
|
# build machine alike; the default only serves a hand build, and matches go.mod.
|
||||||
|
ARG GO_BASE=golang:1.26-alpine
|
||||||
|
FROM ${GO_BASE} AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
#
|
#
|
||||||
# FROM scratch, like the postgres one and unlike the bucket one: it speaks the store's own wire
|
# FROM scratch, like the postgres one and unlike the bucket one: it speaks the store's own wire
|
||||||
# protocol directly and needs no client in the image.
|
# protocol directly and needs no client in the image.
|
||||||
FROM golang:1.25-alpine AS build
|
# The Go it builds with is the one the manifest pins (build.on GO_BASE), passed by the Makefile and the
|
||||||
|
# build machine alike; the default only serves a hand build, and matches go.mod.
|
||||||
|
ARG GO_BASE=golang:1.26-alpine
|
||||||
|
FROM ${GO_BASE} AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
#
|
#
|
||||||
# Static and FROM scratch like the control plane's image, and for the same reason: it is fetched
|
# Static and FROM scratch like the control plane's image, and for the same reason: it is fetched
|
||||||
# by digest and run on a machine, so everything in it is something a person would have to audit.
|
# by digest and run on a machine, so everything in it is something a person would have to audit.
|
||||||
FROM golang:1.25-alpine AS build
|
# The Go it builds with is the one the manifest pins (build.on GO_BASE), passed by the Makefile and the
|
||||||
|
# build machine alike; the default only serves a hand build, and matches go.mod.
|
||||||
|
ARG GO_BASE=golang:1.26-alpine
|
||||||
|
FROM ${GO_BASE} AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -732,6 +732,11 @@ func handler(held *table) http.Handler {
|
|||||||
// And since a host may now be routed only on some paths, those are a third thing:
|
// And since a host may now be routed only on some paths, those are a third thing:
|
||||||
// saying "no route for this name" while listing that very name as served is a
|
// saying "no route for this name" while listing that very name as served is a
|
||||||
// contradiction an operator would have to disbelieve the proxy to get past.
|
// contradiction an operator would have to disbelieve the proxy to get past.
|
||||||
|
// **Said in the log as well as to the client.** A name this mesh does not serve, asked
|
||||||
|
// for from outside, is what a scanner does, and the machine's intrusion prevention reads
|
||||||
|
// this proxy's log for exactly that line (novox/hq ADR 0179): the address last, as the
|
||||||
|
// jail's filter expects it.
|
||||||
|
log.Printf("refused: no route for %q, asked from %s", r.Host, r.RemoteAddr)
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
if !hidden && held.routed(r.Host) {
|
if !hidden && held.routed(r.Host) {
|
||||||
|
|||||||
@@ -102,8 +102,11 @@ func accountHomeOf(account, home string) string {
|
|||||||
func machineInto(resource map[string]any, facts map[string]string, module string) error {
|
func machineInto(resource map[string]any, facts map[string]string, module string) error {
|
||||||
// Content, and now the path and owner too: a module that writes into a person's home names it
|
// Content, and now the path and owner too: a module that writes into a person's home names it
|
||||||
// with ${machine:account-home} and ${machine:account}, which it cannot know until assigned
|
// with ${machine:account-home} and ${machine:account}, which it cannot know until assigned
|
||||||
// (novox/hq to-be 29), the same reason its content names ${machine:address}.
|
// (novox/hq to-be 29), the same reason its content names ${machine:address}. And the name a
|
||||||
for _, field := range []string{"path", "owner", "content"} {
|
// `user` shape sets the login shell of, and the user a user-scoped unit or a process runs as:
|
||||||
|
// the shell module makes the operator's account its holder's login shell, and the desktop's
|
||||||
|
// watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person.
|
||||||
|
for _, field := range []string{"path", "owner", "content", "name", "user"} {
|
||||||
s, ok := resource[field].(string)
|
s, ok := resource[field].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1667,6 +1667,7 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
problems = append(problems, m.undeclaredMounts()...)
|
problems = append(problems, m.undeclaredMounts()...)
|
||||||
problems = append(problems, m.unknownDirRefs()...)
|
problems = append(problems, m.unknownDirRefs()...)
|
||||||
problems = append(problems, m.unknownAccessRefs()...)
|
problems = append(problems, m.unknownAccessRefs()...)
|
||||||
|
problems = append(problems, m.jailProblems()...)
|
||||||
|
|
||||||
for i, r := range m.Resources {
|
for i, r := range m.Resources {
|
||||||
id, _ := r["id"].(string)
|
id, _ := r["id"].(string)
|
||||||
@@ -1769,6 +1770,46 @@ func (m Manifest) MachineSide(port int) (at int, mayAssign bool) {
|
|||||||
var facilitiesOf = map[string][]string{
|
var facilitiesOf = map[string][]string{
|
||||||
// Both spellings: /var/run is a link to /run on every machine the mesh runs on.
|
// Both spellings: /var/run is a link to /run on every machine the mesh runs on.
|
||||||
"container-runtime": {"/var/run/docker.sock", "/run/docker.sock"},
|
"container-runtime": {"/var/run/docker.sock", "/run/docker.sock"},
|
||||||
|
// The virtualisation daemon's socket, for the lab (novox/hq ADR 0172): it raises machines there.
|
||||||
|
"virtualisation": {"/var/lib/incus/unix.socket"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// jailProblems is every jail this module declares that the machine's intrusion prevention would
|
||||||
|
// refuse (novox/hq ADR 0179).
|
||||||
|
//
|
||||||
|
// **Because one bad pattern stops every jail, not its own.** fail2ban expands `<HOST>` into a named
|
||||||
|
// capture group, so a pattern naming it twice is a duplicate group name, and the daemon refuses the
|
||||||
|
// whole configuration and exits — the machine keeps no bans at all, for any jail, including the one
|
||||||
|
// watching its ssh. Caught live on the control node the day this was built, where a proxy's pattern
|
||||||
|
// matched two shapes of refusal in one line. A pattern matches one shape; several shapes are several
|
||||||
|
// patterns, one per line, as fail2ban's own filters are written.
|
||||||
|
func (m Manifest) jailProblems() []string {
|
||||||
|
var problems []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, j := range m.Jails {
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(j.Name) == "":
|
||||||
|
problems = append(problems, m.Module+" declares a jail with no name")
|
||||||
|
case seen[j.Name]:
|
||||||
|
problems = append(problems, m.Module+" declares two jails called "+strconv.Quote(j.Name))
|
||||||
|
}
|
||||||
|
seen[j.Name] = true
|
||||||
|
if strings.TrimSpace(j.Failregex) == "" {
|
||||||
|
problems = append(problems, m.Module+"'s jail "+strconv.Quote(j.Name)+" says nothing a failed attempt looks like")
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(j.Failregex, "\n") {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n := strings.Count(line, "<HOST>"); n > 1 {
|
||||||
|
problems = append(problems, fmt.Sprintf("%s's jail %s names <HOST> %d times in one pattern; "+
|
||||||
|
"fail2ban reads it as one capture group and refuses the whole configuration, so the machine "+
|
||||||
|
"keeps no bans at all — write one pattern per shape, each naming <HOST> once",
|
||||||
|
m.Module, strconv.Quote(j.Name), n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
// undeclaredMounts is every bind-mount source no declaration covers — see the check above.
|
// undeclaredMounts is every bind-mount source no declaration covers — see the check above.
|
||||||
@@ -1812,7 +1853,7 @@ func (m Manifest) undeclaredMounts() []string {
|
|||||||
}
|
}
|
||||||
// The file a filter module's rule set is written to is declared by `filtering.into`: the mesh
|
// The file a filter module's rule set is written to is declared by `filtering.into`: the mesh
|
||||||
// writes it, the module loads it, and the module's runtime may read it back to reload the
|
// writes it, the module loads it, and the module's runtime may read it back to reload the
|
||||||
// mesh's own table (novox/hq ADR 0169).
|
// mesh's own table (novox/hq ADR 0170).
|
||||||
if m.Filtering != nil {
|
if m.Filtering != nil {
|
||||||
claim(m.Filtering.Into)
|
claim(m.Filtering.Into)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A `user` shape and a user-scoped unit name the operator account the way a home file does
|
||||||
|
// (novox/hq ADR 0176, ADR 0177): with ${machine:account}, resolved when the module is assigned.
|
||||||
|
func TestAUserShapeAndAUserScopedUnitNameTheAccount(t *testing.T) {
|
||||||
|
facts := map[string]string{"account": "ops", "account-home": "/home/ops"}
|
||||||
|
login := map[string]any{"type": "user", "id": "login", "name": "${machine:account}", "shell": "/usr/bin/zsh"}
|
||||||
|
if err := machineInto(login, facts, "zsh"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if login["name"] != "ops" {
|
||||||
|
t.Fatalf("the user shape did not learn the account: %v", login["name"])
|
||||||
|
}
|
||||||
|
watcher := map[string]any{"type": "service", "id": "watcher", "unit": "i3-reload-watcher.service",
|
||||||
|
"scope": "user", "user": "${machine:account}"}
|
||||||
|
if err := machineInto(watcher, facts, "i3"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if watcher["user"] != "ops" {
|
||||||
|
t.Fatalf("the user-scoped unit did not learn the account: %v", watcher["user"])
|
||||||
|
}
|
||||||
|
// A machine with no operator account refuses rather than writing the literal.
|
||||||
|
err := machineInto(map[string]any{"type": "user", "id": "login", "name": "${machine:account}"},
|
||||||
|
map[string]string{"address": "10.0.0.1"}, "zsh")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "${machine:account}") {
|
||||||
|
t.Fatalf("a user shape on a machine with no account was not refused by name: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The service manager is a seat of the mesh's own with the unit verbs as its contract (novox/hq
|
||||||
|
// ADR 0177): every verb described, with a schema, taking a scope.
|
||||||
|
func TestTheServiceManagerSeatServesTheUnitVerbs(t *testing.T) {
|
||||||
|
seat, ok := SeatNamed("node-service-manager")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("node-service-manager is not a seat the mesh defines")
|
||||||
|
}
|
||||||
|
if seat.Scope != ScopeNode {
|
||||||
|
t.Fatalf("the service manager is a role each machine has once, and the seat is %s-scoped", seat.Scope)
|
||||||
|
}
|
||||||
|
want := []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal"}
|
||||||
|
var got []string
|
||||||
|
for _, v := range seat.Serves {
|
||||||
|
got = append(got, v.Name)
|
||||||
|
if v.Description == "" || v.Input == nil {
|
||||||
|
t.Fatalf("%s is promised without a description or a schema", v.Name)
|
||||||
|
}
|
||||||
|
props, _ := v.Input["properties"].(map[string]any)
|
||||||
|
if _, has := props["scope"]; !has {
|
||||||
|
t.Fatalf("%s takes no scope, and a user unit could not be asked for", v.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
|
t.Fatalf("the seat serves %v, not %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,9 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
|||||||
}
|
}
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n",
|
"\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n",
|
||||||
"\nlisten-address=127.0.0.1\n", "\nlisten-address=${machine:address}\n", "\nbind-dynamic\n",
|
// Loopback is the mesh-wide setting's default; a machine answering its own LAN adds its
|
||||||
|
// address there (novox/hq issue 198).
|
||||||
|
"\nlisten-address=${setting:listen-addresses}\n", "\nlisten-address=${machine:address}\n", "\nbind-dynamic\n",
|
||||||
"\ndomain-needed\n", "\nbogus-priv\n",
|
"\ndomain-needed\n", "\nbogus-priv\n",
|
||||||
"\nconf-file=" + m.Facts["node-zones"].Path + "\n",
|
"\nconf-file=" + m.Facts["node-zones"].Path + "\n",
|
||||||
} {
|
} {
|
||||||
@@ -114,6 +116,7 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
|||||||
// happen to be the same map, since nothing routed is part of it.
|
// happen to be the same map, since nothing routed is part of it.
|
||||||
Names: twoMachines, Machines: twoMachines, Suffix: "internal",
|
Names: twoMachines, Machines: twoMachines, Suffix: "internal",
|
||||||
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
||||||
|
Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -207,9 +210,16 @@ func TestTheResolverOnAMachineOffTheNetworkIsRefused(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err = got.Declaration(Rendering{Names: twoMachines, Suffix: "internal",
|
// Left out of the declaration and said, rather than composed listening nowhere: a module that
|
||||||
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}})
|
// cannot compose on a machine is kept as it is there, with the reason (hq ADR 0163).
|
||||||
if err == nil || !strings.Contains(err.Error(), "${machine:address}") {
|
composed, err := got.Compose(Rendering{Names: twoMachines, Suffix: "internal",
|
||||||
t.Fatalf("a machine off the network was composed a resolver, or refused for another reason: %v", err)
|
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
||||||
|
Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}}})
|
||||||
|
if err == nil && !strings.Contains(composed.LeftOut["dnsmasq"], "${machine:address}") {
|
||||||
|
t.Fatalf("a machine off the network was composed a resolver, or left out for another reason: %v",
|
||||||
|
composed.LeftOut)
|
||||||
|
}
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "${machine:address}") {
|
||||||
|
t.Fatalf("a machine off the network was refused for another reason: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,8 +99,24 @@ var defaultSeats = []Seat{
|
|||||||
{Name: "mesh-build-machine", Scope: ScopeMesh,
|
{Name: "mesh-build-machine", Scope: ScopeMesh,
|
||||||
Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0121"},
|
Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0121"},
|
||||||
{Name: "node-dns-resolver", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
{Name: "node-dns-resolver", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
||||||
{Name: "node-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
// The intrusion prevention's verbs (novox/hq ADR 0179): what a person asks a machine's ban list
|
||||||
// The packet filter's verbs (novox/hq ADR 0169): what a person asks a machine's filter whatever
|
// whatever keeps it — who is banned and why, ban one address, let one go. Every holder serves all
|
||||||
|
// four; the jails themselves are composed from the modules the machine runs (to-be 31).
|
||||||
|
{Name: "node-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0121",
|
||||||
|
Serves: []Verb{
|
||||||
|
{Name: "status", Description: "Every jail on this machine with how many it is watching and " +
|
||||||
|
"holding now, and the totals since the jail started; one jail's detail when named.",
|
||||||
|
Input: schema(map[string]string{"jail": "one jail (optional)"}, nil)},
|
||||||
|
{Name: "banned", Description: "Every address banned on this machine right now, with the jail " +
|
||||||
|
"that holds it and when the ban ends.",
|
||||||
|
Input: schema(map[string]string{"jail": "one jail (optional)"}, nil)},
|
||||||
|
{Name: "ban", Description: "Ban one address in one jail now, for the jail's ban time — an " +
|
||||||
|
"operator's act on the live ban list, which the mesh never writes itself.",
|
||||||
|
Input: schema(map[string]string{"ip": "the address", "jail": "the jail to hold it"}, []string{"ip", "jail"})},
|
||||||
|
{Name: "unban", Description: "Let one address go, from one jail or from every jail when none is named.",
|
||||||
|
Input: schema(map[string]string{"ip": "the address", "jail": "one jail (optional)"}, []string{"ip"})},
|
||||||
|
}},
|
||||||
|
// The packet filter's verbs (novox/hq ADR 0170): what a person asks a machine's filter whatever
|
||||||
// filter answers — the rules as enforced, reload the mesh's own, remove one thing the mesh did
|
// filter answers — the rules as enforced, reload the mesh's own, remove one thing the mesh did
|
||||||
// not write. Every holder serves all three; what differs by filter is the holder's own tools.
|
// not write. Every holder serves all three; what differs by filter is the holder's own tools.
|
||||||
{Name: "node-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0121",
|
{Name: "node-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0121",
|
||||||
@@ -119,6 +135,12 @@ var defaultSeats = []Seat{
|
|||||||
"active found firewall's chains. An operator's act, by name, never a flush.",
|
"active found firewall's chains. An operator's act, by name, never a flush.",
|
||||||
Input: schema(map[string]string{"where": "the rule set, as `node show` lists it"}, []string{"where"})},
|
Input: schema(map[string]string{"where": "the rule set, as `node show` lists it"}, []string{"where"})},
|
||||||
}},
|
}},
|
||||||
|
// The machine's service manager (novox/hq ADR 0177). The host applies every declared unit,
|
||||||
|
// system or user scope; the holder answers questions and operator acts about them, each verb
|
||||||
|
// taking the unit and an optional scope. The holder runs nothing of its own: its verbs are
|
||||||
|
// served by the node tools runtime (ADR 0175).
|
||||||
|
{Name: "node-service-manager", Scope: ScopeNode, Decision: "novox/hq ADR 0177",
|
||||||
|
Serves: serviceManagerVerbs()},
|
||||||
// Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client
|
// Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client
|
||||||
// model change, not a rename, so it stays until that is built.
|
// model change, not a rename, so it stays until that is built.
|
||||||
{Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
{Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
||||||
@@ -392,3 +414,36 @@ func SeatsWithAProtocol() []Seat {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serviceManagerVerbs is the contract every holder of node-service-manager serves (novox/hq ADR
|
||||||
|
// 0177): the units on the machine in both scopes, read and acted on by name. Every verb takes an
|
||||||
|
// optional scope — "system" when absent, "user" for the operator account's own manager — so a
|
||||||
|
// caller asks for a user unit the way it asks for a system one.
|
||||||
|
func serviceManagerVerbs() []Verb {
|
||||||
|
scoped := func(more map[string]string, required []string) map[string]any {
|
||||||
|
props := map[string]string{"scope": "\"system\" (the default) or \"user\": the operator account's own manager"}
|
||||||
|
for k, v := range more {
|
||||||
|
props[k] = v
|
||||||
|
}
|
||||||
|
return schema(props, required)
|
||||||
|
}
|
||||||
|
unit := map[string]string{"unit": "the unit's name, as the service manager knows it"}
|
||||||
|
return []Verb{
|
||||||
|
{Name: "units", Description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.",
|
||||||
|
Input: scoped(map[string]string{"pattern": "a glob the unit's name must match (optional)"}, nil)},
|
||||||
|
{Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.",
|
||||||
|
Input: scoped(unit, []string{"unit"})},
|
||||||
|
{Name: "start", Description: "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at the next apply.",
|
||||||
|
Input: scoped(unit, []string{"unit"})},
|
||||||
|
{Name: "stop", Description: "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state.",
|
||||||
|
Input: scoped(unit, []string{"unit"})},
|
||||||
|
{Name: "restart", Description: "Restart one unit.",
|
||||||
|
Input: scoped(unit, []string{"unit"})},
|
||||||
|
{Name: "enable", Description: "Make one unit start at boot (or at the account's login, in user scope).",
|
||||||
|
Input: scoped(unit, []string{"unit"})},
|
||||||
|
{Name: "disable", Description: "Stop one unit starting at boot (or at login, in user scope).",
|
||||||
|
Input: scoped(unit, []string{"unit"})},
|
||||||
|
{Name: "journal", Description: "The last lines of one unit's journal.",
|
||||||
|
Input: scoped(map[string]string{"unit": unit["unit"], "lines": "how many lines from the end (default 100)"}, []string{"unit"})},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
|||||||
delivered[s.Delivers] = s.Name
|
delivered[s.Delivers] = s.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(Seats()) != 15 {
|
// Sixteen since node-service-manager (novox/hq ADR 0177).
|
||||||
t.Errorf("the mesh defines %d seats rather than 14; the set is closed, so a change here is "+
|
if len(Seats()) != 16 {
|
||||||
|
t.Errorf("the mesh defines %d seats rather than 16; the set is closed, so a change here is "+
|
||||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,16 @@ var ControllerVerbs = []Verb{
|
|||||||
"node": "the machine that runs the module",
|
"node": "the machine that runs the module",
|
||||||
"module": "the module's name",
|
"module": "the module's name",
|
||||||
}, []string{"node", "module"})},
|
}, []string{"node", "module"})},
|
||||||
|
{Name: "token", Description: "Issue a one-time token for a machine to join with. Give the public half of the " +
|
||||||
|
"tunnel key the machine made (`nox-mesh-host key`): the machine is given its address and made a peer of " +
|
||||||
|
"the hub, and joins through the tunnel. The token is shown once, in the answer.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"node": "a machine the mesh already has a record for",
|
||||||
|
"new": "or the name of a machine to create the record for",
|
||||||
|
"overlay_key": "the public half of the machine's tunnel key",
|
||||||
|
"for": "how long it may be used, as a duration (default 1h)",
|
||||||
|
"adopted": "\"true\" when the machine is in use and joins adopted",
|
||||||
|
}, nil)},
|
||||||
{Name: "settings", Description: "Set what an assignment is configured with: a module's settings for the whole mesh, " +
|
{Name: "settings", Description: "Set what an assignment is configured with: a module's settings for the whole mesh, " +
|
||||||
"or for one machine. Replaces that layer whole — what it does not name, it no longer sets — and takes effect " +
|
"or for one machine. Replaces that layer whole — what it does not name, it no longer sets — and takes effect " +
|
||||||
"at the next push. With clear, removes the layer and the module is back to what its definition says.",
|
"at the next push. With clear, removes the layer and the module is back to what its definition says.",
|
||||||
@@ -145,6 +155,13 @@ var ControllerVerbs = []Verb{
|
|||||||
"node": "one machine; the whole mesh when absent",
|
"node": "one machine; the whole mesh when absent",
|
||||||
"clear": "\"true\" to remove the layer instead of setting it",
|
"clear": "\"true\" to remove the layer instead of setting it",
|
||||||
}, []string{"module"})},
|
}, []string{"module"})},
|
||||||
|
{Name: "command", Description: "Run one command line of the controller's own, as you would type it at its " +
|
||||||
|
"shell — `node account g14 jochen`, `node show ace`, `module list` — and answer what it printed. The " +
|
||||||
|
"generic verb beside the named ones (novox/hq ADR 0154): everything the binary can do, without a verb " +
|
||||||
|
"per command. Any node may call any tool (ADR 0175), so nothing is held back here.",
|
||||||
|
Input: schema(map[string]string{
|
||||||
|
"command": "the command line, as the controller's binary takes it; quotes group a word with spaces",
|
||||||
|
}, []string{"command"})},
|
||||||
{Name: "build", Description: "Have the build machine build a repository. Answers at once with the build's id: " +
|
{Name: "build", Description: "Have the build machine build a repository. Answers at once with the build's id: " +
|
||||||
"`builds` with that id follows it line by line, and the module is registered when the outcome comes.",
|
"`builds` with that id follows it line by line, and the module is registered when the outcome comes.",
|
||||||
Input: schema(map[string]string{
|
Input: schema(map[string]string{
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- A token issued for a tunnel key (novox/hq ADR 0169).
|
||||||
|
--
|
||||||
|
-- A machine that joins through the tunnel makes its key first, and the token is issued for it: the
|
||||||
|
-- hub is told the key before the token is shown. So enrolment must take that key and no other — a
|
||||||
|
-- different one is a machine the hub does not know, offering a tunnel that would never answer. Null
|
||||||
|
-- for a token issued without one, which enrols as before.
|
||||||
|
alter table enrolment_token add column overlay_key text;
|
||||||
@@ -356,6 +356,33 @@ func (i *Inventory) Claim(ctx context.Context, secret, by string, again bool) (N
|
|||||||
return scanNode(i.store.Pool().QueryRow(ctx, `select `+nodeColumns+` from node where id = $1`, id))
|
return scanNode(i.store.Pool().QueryRow(ctx, `select `+nodeColumns+` from node where id = $1`, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BindTokenToKey records the tunnel key a node's live token was issued for (novox/hq ADR 0169), so
|
||||||
|
// enrolment takes that key and no other. Refused when the node has no live token to bind: a key
|
||||||
|
// recorded against nothing would be a promise nothing keeps.
|
||||||
|
func (i *Inventory) BindTokenToKey(ctx context.Context, node, key string) error {
|
||||||
|
tag, err := i.store.Pool().Exec(ctx,
|
||||||
|
`update enrolment_token set overlay_key = $2
|
||||||
|
where node = $1 and redeemed is null and expires > now()`, node, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return fmt.Errorf("no live token to issue for the tunnel key")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenKey is the tunnel key a token was issued for, or empty when it was issued without one.
|
||||||
|
func (i *Inventory) TokenKey(ctx context.Context, secret string) (string, error) {
|
||||||
|
var key *string
|
||||||
|
err := i.store.Pool().QueryRow(ctx,
|
||||||
|
`select overlay_key from enrolment_token where secret = $1`, hashSecret(secret)).Scan(&key)
|
||||||
|
if err != nil || key == nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return *key, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Spend makes a claimed token used, only for the presenter holding the claim. The last write to the
|
// Spend makes a claimed token used, only for the presenter holding the claim. The last write to the
|
||||||
// store in an enrolment, so a token is spent exactly when the node it enrolled is complete. Spent
|
// store in an enrolment, so a token is spent exactly when the node it enrolled is complete. Spent
|
||||||
// again by the same presenter is not an error: an answer lost after the first spend.
|
// again by the same presenter is not an error: an answer lost after the first spend.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ func ConnectNats(js *broker.JetStream, enroller Enroller, listener Listener) *Se
|
|||||||
inbound: Nats(js),
|
inbound: Nats(js),
|
||||||
bus: OverNATS{JS: js.Context(), Conn: js.Conn()},
|
bus: OverNATS{JS: js.Context(), Conn: js.Conn()},
|
||||||
js: js,
|
js: js,
|
||||||
|
consumers: js,
|
||||||
enroller: enroller,
|
enroller: enroller,
|
||||||
listener: listener,
|
listener: listener,
|
||||||
log: newLog(),
|
log: newLog(),
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/broker"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ensured struct{ consumers []broker.Consumer }
|
||||||
|
|
||||||
|
func (e *ensured) EnsureConsumer(c broker.Consumer) error {
|
||||||
|
e.consumers = append(e.consumers, c)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type acceptsAs string
|
||||||
|
|
||||||
|
func (n acceptsAs) Enrol(context.Context, EnrolRequest) (EnrolReply, error) {
|
||||||
|
return EnrolReply{Accepted: true, Node: string(n)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type anEnrolment struct{ body []byte }
|
||||||
|
|
||||||
|
func (m anEnrolment) Kind() string { return "enrol" }
|
||||||
|
func (m anEnrolment) Body() []byte { return m.body }
|
||||||
|
func (m anEnrolment) Redelivered() bool { return false }
|
||||||
|
func (m anEnrolment) HeldFor() time.Duration { return 0 }
|
||||||
|
func (m anEnrolment) Answer(context.Context, []byte) error { return nil }
|
||||||
|
func (m anEnrolment) Took() error { return nil }
|
||||||
|
func (m anEnrolment) Hold(time.Duration) error { return nil }
|
||||||
|
func (m anEnrolment) Drop() error { return nil }
|
||||||
|
|
||||||
|
// **A node that enrols can hear its declarations at once** (novox/hq 04-ISSUES/146): its consumer is
|
||||||
|
// made as it enrols, not only when the control plane next starts — the first machine of a mesh
|
||||||
|
// enrols after the control plane is up, and heard nothing.
|
||||||
|
func TestAnEnrolledNodeIsGivenHowItHearsItsDeclarations(t *testing.T) {
|
||||||
|
made := &ensured{}
|
||||||
|
s := &Server{enroller: acceptsAs("anchor"), consumers: made, log: log.New(io.Discard, "", 0)}
|
||||||
|
body, _ := json.Marshal(EnrolRequest{Node: "anchor"})
|
||||||
|
s.enrolling(context.Background(), anEnrolment{body: body})
|
||||||
|
|
||||||
|
want := broker.NodeConsumer("anchor")
|
||||||
|
if len(made.consumers) != 1 || made.consumers[0].Name != want.Name || made.consumers[0].Stream != want.Stream {
|
||||||
|
t.Fatalf("the enrolled node was given %v, want its own declaration consumer %v", made.consumers, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package link_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/link"
|
||||||
|
)
|
||||||
|
|
||||||
|
// **A token issued for a tunnel key takes that key and no other** (novox/hq ADR 0169). The hub was
|
||||||
|
// sent the key before the token was shown, so another key is a machine it does not know.
|
||||||
|
func TestATokenIssuedForATunnelKeyTakesThatKeyAndNoOther(t *testing.T) {
|
||||||
|
inv, ident := aMeshReadyToEnrol(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
secret, public := aTokenFor(t, inv, "joiner")
|
||||||
|
node, err := inv.NodeByName(ctx, "joiner")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const issuedFor = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||||
|
if err := inv.BindTokenToKey(ctx, node.ID, issuedFor); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e := link.Enrolment{Inventory: inv, Identity: ident}
|
||||||
|
|
||||||
|
_, err = e.Enrol(ctx, link.EnrolRequest{Node: "joiner", Secret: secret, PublicKey: public,
|
||||||
|
OverlayKey: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "issued for the tunnel key") {
|
||||||
|
t.Fatalf("a token issued for one key took another: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := e.Enrol(ctx, link.EnrolRequest{Node: "joiner", Secret: secret, PublicKey: public,
|
||||||
|
OverlayKey: issuedFor}); err != nil {
|
||||||
|
t.Fatalf("the key the token was issued for was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,18 @@ func (e Enrolment) Enrol(ctx context.Context, request EnrolRequest) (reply Enrol
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return EnrolReply{}, err
|
return EnrolReply{}, err
|
||||||
}
|
}
|
||||||
|
// **A token issued for a tunnel key takes that key and no other** (novox/hq ADR 0169). The hub
|
||||||
|
// was told it before the token was shown; another key is a machine the hub does not know.
|
||||||
|
// Checked before anything is recorded, so a refusal changes nothing.
|
||||||
|
bound, err := e.Inventory.TokenKey(ctx, secret)
|
||||||
|
if err != nil {
|
||||||
|
return EnrolReply{}, err
|
||||||
|
}
|
||||||
|
if bound != "" && request.OverlayKey != bound {
|
||||||
|
return EnrolReply{}, fmt.Errorf("%s's token was issued for the tunnel key %s and the machine "+
|
||||||
|
"offered %q — the key it made with `nox-mesh-host key` is the one to issue for",
|
||||||
|
node.Name, bound, request.OverlayKey)
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := e.Identity.RecordNodeKey(ctx, node.ID, public); err != nil {
|
if _, err := e.Identity.RecordNodeKey(ctx, node.ID, public); err != nil {
|
||||||
return EnrolReply{}, fmt.Errorf("%s's key could not be recorded: %w", node.Name, err)
|
return EnrolReply{}, fmt.Errorf("%s's key could not be recorded: %w", node.Name, err)
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ type Server struct {
|
|||||||
inbound Inbound
|
inbound Inbound
|
||||||
bus Bus
|
bus Bus
|
||||||
js *broker.JetStream
|
js *broker.JetStream
|
||||||
|
// consumers makes a node's declaration consumer as it enrols; the bus connection, or a stand-in.
|
||||||
|
consumers interface{ EnsureConsumer(broker.Consumer) error }
|
||||||
|
|
||||||
enroller Enroller
|
enroller Enroller
|
||||||
listener Listener
|
listener Listener
|
||||||
@@ -383,6 +385,7 @@ func (s *Server) enrolling(ctx context.Context, m Control) {
|
|||||||
default:
|
default:
|
||||||
reply = accepted
|
reply = accepted
|
||||||
s.log.Printf("enrolled %s", accepted.Node)
|
s.log.Printf("enrolled %s", accepted.Node)
|
||||||
|
s.hearsItsDeclarations(accepted.Node)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,6 +405,24 @@ func (s *Server) enrolling(ctx context.Context, m Control) {
|
|||||||
_ = m.Took()
|
_ = m.Took()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hearsItsDeclarations makes the consumer a node reads its declarations through, as it enrols and
|
||||||
|
// before it is answered.
|
||||||
|
//
|
||||||
|
// **Created at enrolment, as the consumer's own doc has always said** (novox/hq 04-ISSUES/146). It
|
||||||
|
// was asserted only when the control plane started, so the first machine of a mesh — which enrols
|
||||||
|
// after the control plane is already up — joined and then heard nothing, its host retrying "consumer
|
||||||
|
// not found" for ever. Failing here is said and does not unspend the token: the next start of the
|
||||||
|
// control plane asserts it again.
|
||||||
|
func (s *Server) hearsItsDeclarations(node string) {
|
||||||
|
if s.consumers == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.consumers.EnsureConsumer(broker.NodeConsumer(node)); err != nil {
|
||||||
|
s.log.Printf("%s enrolled, and how it hears its declarations could not be made — it will hear "+
|
||||||
|
"nothing until the control plane next starts: %v", node, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// wasBuilt keeps what a builder said, whichever way it went.
|
// wasBuilt keeps what a builder said, whichever way it went.
|
||||||
//
|
//
|
||||||
// This is for results nobody was waiting for. A build asked for with `build` is answered directly
|
// This is for results nobody was waiting for. A build asked for with `build` is answered directly
|
||||||
|
|||||||
@@ -55,6 +55,26 @@ type Token struct {
|
|||||||
// that it speaks the firewall found on the machine, because an adopted node keeps that firewall
|
// that it speaks the firewall found on the machine, because an adopted node keeps that firewall
|
||||||
// in force. Absent for a converged node, so a converged token is byte for byte what it was.
|
// in force. Absent for a converged node, so a converged token is byte for byte what it was.
|
||||||
Adopted bool `json:"adopted,omitempty"`
|
Adopted bool `json:"adopted,omitempty"`
|
||||||
|
|
||||||
|
// Tunnel is the one peer a joining machine needs, when the token was issued for its tunnel key
|
||||||
|
// (novox/hq ADR 0169). The machine brings its tunnel up from this alone and reaches the bus over
|
||||||
|
// it, at an address on the private network — so the bus is never open to the internet. Absent
|
||||||
|
// on a token issued without a key, which then reads byte for byte as before.
|
||||||
|
Tunnel *Tunnel `json:"tunnel,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tunnel is the joining machine's side of its first tunnel: its own address and the hub to reach.
|
||||||
|
type Tunnel struct {
|
||||||
|
// Key is the public half of the key the machine made itself, which this token was issued for.
|
||||||
|
// The private half never left the machine (novox/hq ADR 0004).
|
||||||
|
Key string `json:"key"`
|
||||||
|
// Address is the machine's own address on the private network, with its prefix.
|
||||||
|
Address string `json:"address"`
|
||||||
|
// Range is the private network, routed through the hub until the machine is told more.
|
||||||
|
Range string `json:"range"`
|
||||||
|
// HubKey and HubEndpoint are the hub's tunnel key and where it is dialled.
|
||||||
|
HubKey string `json:"hub_key"`
|
||||||
|
HubEndpoint string `json:"hub_endpoint"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Missing names the parts that are not filled in.
|
// Missing names the parts that are not filled in.
|
||||||
@@ -83,6 +103,19 @@ func (t Token) Missing() []string {
|
|||||||
if strings.TrimSpace(t.Secret) == "" {
|
if strings.TrimSpace(t.Secret) == "" {
|
||||||
missing = append(missing, "the one-time secret — nothing to present")
|
missing = append(missing, "the one-time secret — nothing to present")
|
||||||
}
|
}
|
||||||
|
if t.Tunnel != nil {
|
||||||
|
for _, part := range []struct{ value, says string }{
|
||||||
|
{t.Tunnel.Key, "the machine's own tunnel key — the hub would not know it"},
|
||||||
|
{t.Tunnel.Address, "the machine's address on the private network"},
|
||||||
|
{t.Tunnel.Range, "the private network's range — nothing to route through the hub"},
|
||||||
|
{t.Tunnel.HubKey, "the hub's tunnel key — nothing to dial"},
|
||||||
|
{t.Tunnel.HubEndpoint, "where the hub's tunnel is dialled"},
|
||||||
|
} {
|
||||||
|
if strings.TrimSpace(part.value) == "" {
|
||||||
|
missing = append(missing, part.says)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return missing
|
return missing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,3 +172,38 @@ func TestATokenWithNoNameIsRefused(t *testing.T) {
|
|||||||
t.Fatalf("the refusal does not say what is missing: %v", without.Missing())
|
t.Fatalf("the refusal does not say what is missing: %v", without.Missing())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A token issued for a tunnel key carries the one peer a joining machine needs (novox/hq ADR 0169),
|
||||||
|
// and says which part is missing rather than producing a tunnel that never answers.
|
||||||
|
func TestATokenThroughTheTunnelCarriesThePeerOrSaysWhatIsMissing(t *testing.T) {
|
||||||
|
whole := Token{Node: "n", Broker: "10.42.0.1:4222", Fingerprint: "sha256:x", Signer: make([]byte, 32), Secret: "s",
|
||||||
|
Tunnel: &Tunnel{Key: "k", Address: "10.42.0.9/32", Range: "10.42.0.0/16", HubKey: "h", HubEndpoint: "198.51.100.1:51820"}}
|
||||||
|
if !whole.Complete() {
|
||||||
|
t.Fatalf("a whole token through the tunnel reads as missing %v", whole.Missing())
|
||||||
|
}
|
||||||
|
encoded, err := whole.Encode()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
back, err := Decode(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if back.Tunnel == nil || *back.Tunnel != *whole.Tunnel {
|
||||||
|
t.Fatalf("the tunnel did not survive the round trip: %+v", back.Tunnel)
|
||||||
|
}
|
||||||
|
|
||||||
|
part := whole
|
||||||
|
part.Tunnel = &Tunnel{Key: "k", Address: "10.42.0.9/32"}
|
||||||
|
if len(part.Missing()) != 3 {
|
||||||
|
t.Errorf("a tunnel without the hub and the range should name three missing parts: %v", part.Missing())
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a token issued without a key carries no tunnel at all, byte for byte as before.
|
||||||
|
plain := whole
|
||||||
|
plain.Tunnel = nil
|
||||||
|
raw, _ := plain.Encode()
|
||||||
|
if strings.Contains(raw, "tunnel") {
|
||||||
|
t.Error("a token without a key mentions a tunnel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user