Compare commits
117
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
feb74e2069 | ||
|
|
a31cce0fd0 | ||
|
|
5b73e04192 | ||
|
|
f57386cdea | ||
|
|
f92dd3e286 | ||
|
|
cdbe3ab0a4 | ||
|
|
a8f1cdb445 | ||
|
|
ecb3003ba4 | ||
|
|
d3861f82d4 | ||
|
|
b6dbe0a7b9 | ||
|
|
07bdad9e94 | ||
|
|
627ac97d4f | ||
|
|
ee2359f29f | ||
|
|
cbdbf6b7d3 | ||
|
|
e12ca3f4dd | ||
|
|
c47aa5d9b3 | ||
|
|
3b9692b3cb | ||
|
|
fb9c9c3ee8 | ||
|
|
d53e626366 | ||
|
|
83b3d20e68 | ||
|
|
e030aa2387 | ||
|
|
c670bef4e1 | ||
|
|
45529bfec2 | ||
|
|
b1e9ccff6d | ||
|
|
cbcf0bcc93 | ||
|
|
6910f07d75 | ||
|
|
88037b33b7 | ||
|
|
422ad516d5 | ||
|
|
8431ecfb48 | ||
|
|
f107d68b2d | ||
|
|
1028193c8a | ||
|
|
f576287b51 | ||
|
|
6c6495f6d9 | ||
|
|
e6d48cf537 | ||
|
|
b8a766f234 | ||
|
|
9caea5bc32 | ||
|
|
df27cee7b7 | ||
|
|
d275e64ed3 | ||
|
|
1a628a4d22 | ||
|
|
b5196e974c | ||
|
|
5162c3b05f | ||
|
|
98fe8edf35 | ||
|
|
cbf50185d0 | ||
|
|
197258c88c | ||
|
|
a3b810f1f0 | ||
|
|
971a6d6d03 | ||
|
|
04a27caa43 | ||
|
|
6e90c2692d | ||
|
|
ced54d489f | ||
|
|
94a35a39eb | ||
|
|
ec06369101 | ||
|
|
bb85af1821 | ||
|
|
0c3928ad19 | ||
|
|
fbf0fb7d63 | ||
|
|
b005d4ef17 | ||
|
|
b0d11c2439 | ||
|
|
155689672c | ||
|
|
e82789a322 | ||
|
|
99562947f3 | ||
|
|
c171c64d3a | ||
|
|
0dc5515099 | ||
|
|
58c0e715c7 | ||
|
|
c5b229f95d | ||
|
|
a9c49724b5 | ||
|
|
296ec5ece6 | ||
|
|
45b9a507a1 | ||
|
|
5c410aaf54 | ||
|
|
2478b5f127 | ||
|
|
c82e933268 | ||
|
|
e212da6bd2 | ||
|
|
673912ccdc | ||
|
|
68a193d6f0 | ||
|
|
4fba884d47 | ||
|
|
15f0dabf32 | ||
|
|
4ef41ad5a0 | ||
|
|
d749de989c | ||
|
|
14e64844df | ||
|
|
d82fc9a121 | ||
|
|
9fd3762e93 | ||
|
|
587b8aa220 | ||
|
|
48e2ff2de5 | ||
|
|
6a3435629e | ||
|
|
9072f60a30 | ||
|
|
6e208f7b3e | ||
|
|
54f6eb661a | ||
|
|
646be4fdf4 | ||
|
|
e688b3b816 | ||
|
|
50776b8613 | ||
|
|
b462f461c6 | ||
|
|
23a4436499 | ||
|
|
08c0f40ff0 | ||
|
|
3112c881e4 | ||
|
|
06aaac0820 | ||
|
|
fdc768c476 | ||
|
|
25449a31c3 | ||
|
|
1cb895346d | ||
|
|
3ae999497f | ||
|
|
32d7234637 | ||
|
|
2722e7b36e | ||
|
|
808e93a477 | ||
|
|
8e2a1e762d | ||
|
|
5dbc8e5c3c | ||
|
|
260bf0b752 | ||
|
|
93caf26aed | ||
|
|
3ac765db65 | ||
|
|
7e245dae92 | ||
|
|
5dd439df50 | ||
|
|
f68139c9d1 | ||
|
|
fc593b9dfd | ||
|
|
83306b2dba | ||
|
|
4840e21405 | ||
|
|
982b84310e | ||
|
|
c60228e719 | ||
|
|
977df39e0d | ||
|
|
f08a8ea3f7 | ||
|
|
7283924a35 | ||
|
|
27c4b765b2 |
@@ -55,12 +55,13 @@ connects to nothing and listens on nothing — what it applies comes from a file
|
||||
mesh-host profile what this machine can be asked to do
|
||||
mesh-host inventory what this machine is, and what it holds
|
||||
mesh-host apply FILE make this machine match a declaration from a file
|
||||
mesh-host reconcile make this machine match the declaration this host carries
|
||||
mesh-host reconcile make this machine match what the mesh last told it — or, before
|
||||
any mesh has, the bundle this host carries
|
||||
mesh-host bundle show what this host carries
|
||||
mesh-host owned what this host has applied and still owns
|
||||
--json machine-readable
|
||||
--state where this node keeps what it knows
|
||||
--dry-run read and check the declaration, change nothing
|
||||
--dry-run say what applying would change, and change nothing
|
||||
```
|
||||
|
||||
```
|
||||
@@ -114,8 +115,16 @@ A host built for a machine carries its declaration **inside the binary**:
|
||||
make host BUNDLE=path/to/foundation.lock
|
||||
```
|
||||
|
||||
`mesh-host reconcile` then applies it. That is the first node's path — no mesh present, nothing
|
||||
fetched, nothing else copied onto the machine. `copy it and run it` stops being true the moment
|
||||
`mesh-host reconcile` then applies it, on a machine the mesh has told nothing yet. That is the
|
||||
first node's path — no mesh present, nothing fetched, nothing else copied onto the machine. Once
|
||||
the mesh has spoken, `reconcile` holds the machine to what it last said and never to the bundle,
|
||||
which genesis consumed; a bundle or a file is refused when it says the other mode than the node
|
||||
is in; and `apply FILE` is refused altogether once the mesh has spoken — a file is applied as the
|
||||
bundle is, its resources recorded as the machine's own, so on an enrolled node it would plan to
|
||||
remove the foundation. `apply FILE` is for a machine the mesh has not spoken to. (hq's to-be
|
||||
node lifecycle describes `apply repair.json` as a rescue on an enrolled node; that line is being
|
||||
amended in hq, and no rescue path exists here yet.) Both commands say what they would change
|
||||
before changing anything, and `--dry-run` is that alone. `copy it and run it` stops being true the moment
|
||||
a second file has to arrive with it, which is why the bundle is embedded rather than beside it.
|
||||
|
||||
**A default build carries nothing and refuses to reconcile**, saying so. A host that applied
|
||||
|
||||
@@ -140,6 +140,10 @@ const usage = `mesh-bootstrap — make a bare machine into a mesh
|
||||
firewall stay as they are, the foundation's filter is not loaded and
|
||||
the mesh guards its own ports instead, and each module is taken on it
|
||||
one at a time. Without it, a machine in use is refused
|
||||
--tunnel adopted: the interface of the tunnel the private network takes over
|
||||
(its key, port, range and peers); found by itself when one is up, and
|
||||
needed only when several are. --hub-port and --overlay-range then
|
||||
follow the tunnel
|
||||
|
||||
The installer carries a builder, not a control plane. What raises a mesh is therefore
|
||||
the same thing that will maintain it, and the control plane a mesh ends up running is
|
||||
@@ -334,6 +338,8 @@ func newFlagSet(opts *bootstrap.Options, jsonOut *bool) *flag.FlagSet {
|
||||
"raise this machine adopted: keep what it runs and its firewall until each module is taken")
|
||||
set.StringVar(&opts.OverlayRange, "overlay-range", opts.OverlayRange,
|
||||
"the private network's address range; must not overlap a tunnel the machine already runs")
|
||||
set.StringVar(&opts.Tunnel, "tunnel", "",
|
||||
"adopted: the found tunnel's interface the private network takes over; found by itself when one is up")
|
||||
if opts.Answers == nil {
|
||||
opts.Answers = map[string]string{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
)
|
||||
|
||||
// Joining through the tunnel (novox/hq ADR 0169).
|
||||
//
|
||||
// **The bus is never open to the internet, so a joining machine reaches it over the tunnel.** It
|
||||
// makes its tunnel key first and prints the public half; the token is issued for that key, and the
|
||||
// hub is told the key before the token is shown; the token carries the one peer this machine needs.
|
||||
// So the tunnel can come up before the mesh has said anything else — the circle ADR 0004 broke by
|
||||
// carrying the bus's address in the token is broken here by carrying the hub's.
|
||||
|
||||
// tunnelConfigPath and tunnelUnit are where the mesh's own declaration puts the private network, so
|
||||
// the first tunnel is the same interface and unit the mesh takes over, not a second one beside it.
|
||||
var (
|
||||
tunnelConfigPath = "/etc/wireguard/mesh0.conf"
|
||||
tunnelUnit = "wg-quick@mesh0"
|
||||
// lookPath finds WireGuard's tools; a variable so a test needs none installed.
|
||||
lookPath = exec.LookPath
|
||||
)
|
||||
|
||||
// keyCommand makes this machine's tunnel key, or reads the one it already made, and prints the
|
||||
// public half: what the token is issued for. Making it twice would be a token issued for a key the
|
||||
// machine no longer has, so an existing key is kept.
|
||||
func keyCommand(opts options) error {
|
||||
path := identity.OverlayKeyPath(opts.state)
|
||||
if key, err := identity.LoadOverlayKey(path); err == nil {
|
||||
fmt.Println(key.Public)
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(identity.Path(opts.state)); err == nil {
|
||||
return fmt.Errorf("this machine has joined already (%s), and its tunnel key is its own; "+
|
||||
"there is no key to make", identity.Path(opts.state))
|
||||
}
|
||||
key, err := identity.GenerateOverlayKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(key.Private+"\n"), 0o600); err != nil {
|
||||
return fmt.Errorf("cannot write this machine's tunnel key: %w", err)
|
||||
}
|
||||
fmt.Println(key.Public)
|
||||
fmt.Fprintln(os.Stderr, "\nthis machine's tunnel key, made here; the private half stays in "+path+".\n"+
|
||||
"Issue the token for it — `token issue --new <name> --overlay-key <the line above>` — and enrol with that token.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// tunnelKeyFor is the key a token through the tunnel was issued for, read from where `key` left it.
|
||||
// Refused when there is none, or it is another: the hub knows only the key the token names.
|
||||
func tunnelKeyFor(t *identity.TokenTunnel, state string) (identity.OverlayKey, error) {
|
||||
path := identity.OverlayKeyPath(state)
|
||||
key, err := identity.LoadOverlayKey(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return identity.OverlayKey{}, fmt.Errorf("this token was issued for a tunnel key, and this " +
|
||||
"machine has none: run `nox-mesh-host key` here first and issue the token for the key it prints")
|
||||
}
|
||||
if err != nil {
|
||||
return identity.OverlayKey{}, err
|
||||
}
|
||||
if key.Public != t.Key {
|
||||
return identity.OverlayKey{}, fmt.Errorf("this token was issued for the tunnel key %s, and this "+
|
||||
"machine's is %s — it is another machine's token, or the key was made again; issue a new "+
|
||||
"token for %s", t.Key, key.Public, key.Public)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// tunnelConfig is the first tunnel: this machine's address, and the hub as its one peer, reaching the
|
||||
// whole private network through it. The private key is set from its file, as the mesh's own
|
||||
// declaration does it, so the file holds no secret.
|
||||
func tunnelConfig(t *identity.TokenTunnel, keyPath string) string {
|
||||
return fmt.Sprintf(`# Written by nox-mesh-host enrol: the one peer a joining machine needs (novox/hq ADR 0169).
|
||||
# The mesh's own declaration replaces this once the machine has joined.
|
||||
[Interface]
|
||||
Address = %s
|
||||
PostUp = wg set %%i private-key %s
|
||||
|
||||
[Peer]
|
||||
PublicKey = %s
|
||||
Endpoint = %s
|
||||
AllowedIPs = %s
|
||||
PersistentKeepalive = 25
|
||||
`, t.Address, keyPath, t.HubKey, t.HubEndpoint, t.Range)
|
||||
}
|
||||
|
||||
// bringTheTunnelUp writes the first tunnel and starts it, so the bus the token names can be reached.
|
||||
func bringTheTunnelUp(ctx context.Context, t *identity.TokenTunnel, keyPath string, run apply.Runner) error {
|
||||
if _, err := lookPath("wg-quick"); err != nil {
|
||||
return errors.New("joining through the tunnel needs WireGuard's tools on this machine " +
|
||||
"(wireguard-tools), and wg-quick is not here")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(tunnelConfigPath), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(tunnelConfigPath, []byte(tunnelConfig(t, keyPath)), 0o600); err != nil {
|
||||
return fmt.Errorf("cannot write the first tunnel: %w", err)
|
||||
}
|
||||
if out, err := run(ctx, "systemctl", "restart", tunnelUnit); err != nil {
|
||||
return fmt.Errorf("the first tunnel would not start (%s): %v %s", tunnelUnit, err, strings.TrimSpace(out))
|
||||
}
|
||||
fmt.Printf("the tunnel to the hub is up: %s, through %s\n", t.Address, t.HubEndpoint)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
)
|
||||
|
||||
// `key` makes the tunnel key once and prints its public half; asked again it prints the same one,
|
||||
// because a token may already have been issued for it (novox/hq ADR 0169).
|
||||
func TestKeyMakesTheTunnelKeyOnceAndKeepsIt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
opts := options{state: filepath.Join(dir, "state.json")}
|
||||
first := captureStdout(t, func() {
|
||||
if err := keyCommand(opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
second := captureStdout(t, func() {
|
||||
if err := keyCommand(opts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if strings.TrimSpace(first) == "" || strings.TrimSpace(first) != strings.TrimSpace(second) {
|
||||
t.Fatalf("the key changed between two asks: %q then %q", first, second)
|
||||
}
|
||||
info, err := os.Stat(identity.OverlayKeyPath(opts.state))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("the private half is readable beyond root: %v", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// A token through the tunnel takes the key it was issued for, and says so when this machine has none
|
||||
// or another.
|
||||
func TestATokenThroughTheTunnelTakesItsOwnKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
state := filepath.Join(dir, "state.json")
|
||||
tt := &identity.TokenTunnel{Key: "x", Address: "10.42.0.9/32", Range: "10.42.0.0/16", HubKey: "h", HubEndpoint: "198.51.100.1:51820"}
|
||||
if _, err := tunnelKeyFor(tt, state); err == nil || !strings.Contains(err.Error(), "nox-mesh-host key") {
|
||||
t.Fatalf("a machine with no key was not told to make one: %v", err)
|
||||
}
|
||||
captureStdout(t, func() { _ = keyCommand(options{state: state}) })
|
||||
if _, err := tunnelKeyFor(tt, state); err == nil || !strings.Contains(err.Error(), "issued for the tunnel key x") {
|
||||
t.Fatalf("another machine's token was taken: %v", err)
|
||||
}
|
||||
mine, _ := identity.LoadOverlayKey(identity.OverlayKeyPath(state))
|
||||
tt.Key = mine.Public
|
||||
if got, err := tunnelKeyFor(tt, state); err != nil || got.Public != mine.Public {
|
||||
t.Fatalf("this machine's own token was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The first tunnel is the mesh's interface and unit, with the hub as its one peer and no secret in
|
||||
// the file — the same shape the mesh's declaration replaces it with.
|
||||
func TestTheFirstTunnelIsTheMeshsInterfaceWithTheHubAsItsPeer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tunnelConfigPath = filepath.Join(dir, "wireguard", "mesh0.conf")
|
||||
lookPath = func(string) (string, error) { return "/usr/bin/wg-quick", nil }
|
||||
t.Cleanup(func() { tunnelConfigPath = "/etc/wireguard/mesh0.conf" })
|
||||
var ran []string
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
ran = append(ran, name+" "+strings.Join(args, " "))
|
||||
return "", nil
|
||||
}
|
||||
tt := &identity.TokenTunnel{Key: "k", Address: "10.42.0.9/32", Range: "10.42.0.0/16", HubKey: "HUBKEY", HubEndpoint: "198.51.100.1:51820"}
|
||||
captureStdout(t, func() {
|
||||
if err := bringTheTunnelUp(context.Background(), tt, "/var/lib/mesh-host/overlay.key", run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
raw, err := os.ReadFile(tunnelConfigPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := string(raw)
|
||||
for _, want := range []string{"Address = 10.42.0.9/32", "PostUp = wg set %i private-key /var/lib/mesh-host/overlay.key",
|
||||
"PublicKey = HUBKEY", "Endpoint = 198.51.100.1:51820", "AllowedIPs = 10.42.0.0/16", "PersistentKeepalive = 25"} {
|
||||
if !strings.Contains(conf, want) {
|
||||
t.Errorf("the first tunnel lacks %q:\n%s", want, conf)
|
||||
}
|
||||
}
|
||||
if strings.Contains(conf, "PrivateKey") {
|
||||
t.Error("the first tunnel's file holds the private key")
|
||||
}
|
||||
if len(ran) != 1 || ran[0] != "systemctl restart wg-quick@mesh0" {
|
||||
t.Errorf("the tunnel was started as %v", ran)
|
||||
}
|
||||
}
|
||||
|
||||
// captureStdout is what fn printed to standard output.
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
was := os.Stdout
|
||||
os.Stdout = w
|
||||
fn()
|
||||
os.Stdout = was
|
||||
w.Close()
|
||||
out, _ := io.ReadAll(r)
|
||||
return string(out)
|
||||
}
|
||||
+766
-66
File diff suppressed because it is too large
Load Diff
+348
-1
@@ -1,14 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/bundle"
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/system"
|
||||
@@ -164,6 +171,22 @@ func TestAReconcileSpeaksOnlyWhenWhatIsHeldChanged(t *testing.T) {
|
||||
if !w.changed(link.Report{Firewall: "none", Held: rewritten.Held}) {
|
||||
t.Error("a changed firewall was not said")
|
||||
}
|
||||
// What filters the machine is part of it (novox/hq ADR 0168): a predecessor's chain removed by
|
||||
// hand, or the found firewall enabled again, is said without being asked.
|
||||
filtered := link.Report{Firewall: "none", Held: rewritten.Held,
|
||||
Filters: []link.Filter{{Where: "chain HAL-MESH-ONLY (iptables-legacy)", Owner: "other", Refuses: "-j DROP"}}}
|
||||
if !w.changed(filtered) {
|
||||
t.Error("a filter appearing was not said")
|
||||
}
|
||||
if !w.changed(link.Report{Firewall: "none", Held: rewritten.Held}) {
|
||||
t.Error("a filter removed was not said")
|
||||
}
|
||||
if !w.changed(link.Report{Firewall: "none", Held: rewritten.Held, FoundFirewall: &link.FoundFirewall{Kind: "ufw", Active: true}}) {
|
||||
t.Error("the found firewall coming back was not said")
|
||||
}
|
||||
if !worthSaying(link.Report{Filters: filtered.Filters}) {
|
||||
t.Error("a report carrying only what filters the machine is not worth saying")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatTheLinkPublishedCountsAsSaid(t *testing.T) {
|
||||
@@ -218,7 +241,7 @@ func TestOnlyOneApplyRunsAtATime(t *testing.T) {
|
||||
// Whatever else is applying — the link, while this is the reconcile — this waits for it.
|
||||
applying.Lock()
|
||||
done := make(chan link.Report, 1)
|
||||
go func() { done <- applyAndKeep(context.Background(), opts, raw, nil, nil) }()
|
||||
go func() { done <- applyAndKeep(context.Background(), opts, raw, nil, nil, nil) }()
|
||||
select {
|
||||
case report := <-done:
|
||||
applying.Unlock()
|
||||
@@ -262,3 +285,327 @@ func TestAChangeThatNeverReachedTheMeshIsSaidAgain(t *testing.T) {
|
||||
t.Error("a change the mesh was told was said again")
|
||||
}
|
||||
}
|
||||
|
||||
// Defends novox/hq issue 104: a declaration for the other mode than this node is in is refused at
|
||||
// the point of application, whichever command delivered it, naming both — an adopted control-node
|
||||
// once applied its converged genesis bundle and closed itself for forty-five minutes.
|
||||
|
||||
func stateWithMode(t *testing.T, mode string) options {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
opts := options{state: filepath.Join(dir, "state.json"), out: &bytes.Buffer{}}
|
||||
if err := store.Save(opts.state, store.State{Mode: mode}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func TestAConvergedDeclarationIsRefusedOnAnAdoptedNode(t *testing.T) {
|
||||
opts := stateWithMode(t, store.ModeAdopted)
|
||||
path := filepath.Join(filepath.Dir(opts.state), "filter.conf")
|
||||
raw := []byte(`{"declaration":1,"resources":[{"id":"filter","type":"file","path":"` + path +
|
||||
`","content":"table inet filter { chain input { policy drop; } }\n"}]}`)
|
||||
d, err := declaration.ParseFileTrusted(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, from := range []provenance{fromFile, fromBundle} {
|
||||
err := runApply(context.Background(), opts, d, raw, from)
|
||||
if err == nil {
|
||||
t.Fatalf("a converged declaration was applied to an adopted node (from %d)", from)
|
||||
}
|
||||
want := "this node is adopted; the declaration says converged"
|
||||
if !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "`converge`") {
|
||||
t.Errorf("the refusal does not name both modes and the act that changes it: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Error("the refused declaration touched the machine")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheKeptDeclarationRepairsARecordThatDisagrees(t *testing.T) {
|
||||
// What a node kept is signed by the mesh and verified on load; the state's note of the mode is
|
||||
// the host's own. A genesis re-run after `converge`, or a state saved when the kept declaration
|
||||
// could not be, leaves them apart — and a loop that refused every five minutes would hold the
|
||||
// node off what the mesh said until a delivery that comes only when something changes. The
|
||||
// kept declaration wins, and the repair is said.
|
||||
opts := stateWithMode(t, store.ModeConverged)
|
||||
raw := []byte(`{"declaration":1,"adoption":{"taken":[]},"resources":[{"id":"a","type":"file","path":"` +
|
||||
filepath.Join(filepath.Dir(opts.state), "a.conf") + `","content":"x\n"}]}`)
|
||||
var said []string
|
||||
report := applyAndKeep(context.Background(), opts, raw, nil, nil, func(line string) { said = append(said, line) })
|
||||
if strings.Contains(report.Refused, "the declaration says") {
|
||||
t.Fatalf("what the node kept was refused against its own note: %s", report.Refused)
|
||||
}
|
||||
if len(said) != 1 || !strings.Contains(said[0], "record said converged") ||
|
||||
!strings.Contains(said[0], "says adopted") || !strings.Contains(said[0], "repaired") {
|
||||
t.Errorf("the repair was not said: %q", said)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMeshItselfMayChangeTheMode(t *testing.T) {
|
||||
// The flip is a declaration: `converge` on the controller records the mode and sends the
|
||||
// first converged declaration. Delivered by the link, signed, it is not held to the record —
|
||||
// it becomes it. (With no system linked in, the apply is refused later for that; what this
|
||||
// checks is that the refusal is not the mode's.)
|
||||
opts := stateWithMode(t, store.ModeAdopted)
|
||||
raw := []byte(`{"declaration":1,"resources":[{"id":"a","type":"file","path":"` +
|
||||
filepath.Join(filepath.Dir(opts.state), "a.conf") + `","content":"x\n"}]}`)
|
||||
report := applyAndKeep(context.Background(), opts, raw, &store.Declared{Declaration: raw}, nil, nil)
|
||||
if strings.Contains(report.Refused, "the declaration says") {
|
||||
t.Errorf("the mesh's own flip was refused for its mode: %s", report.Refused)
|
||||
}
|
||||
}
|
||||
|
||||
// Defends novox/hq issue 104: a declaration older than what the mesh last said is refused, naming
|
||||
// both — and `apply FILE` is refused altogether once the mesh has spoken, the last declaration
|
||||
// itself included: from a file it is applied as the bundle is, which would record the mesh's
|
||||
// resources as this machine's own and remove the foundation as undeclared.
|
||||
func TestAnOlderDeclarationIsRefused(t *testing.T) {
|
||||
opts := stateWithMode(t, "")
|
||||
genesis := []byte(`{"declaration":1,"resources":[{"id":"g","type":"file","path":"/tmp/g","content":"genesis\n"}]}`)
|
||||
since := []byte(`{"declaration":1,"resources":[{"id":"g","type":"file","path":"/tmp/g","content":"since\n"}]}`)
|
||||
other := []byte(`{"declaration":1,"resources":[{"id":"g","type":"file","path":"/tmp/g","content":"other\n"}]}`)
|
||||
if err := store.Save(opts.state, store.State{Genesis: &store.Genesis{Digest: apply.DigestOf(genesis),
|
||||
At: time.Now(), Rewritten: true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.SaveDeclared(store.DeclaredPath(opts.state), store.Declared{Declaration: since,
|
||||
Signature: []byte("unverified here")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed := func(raw []byte) *declaration.Declaration {
|
||||
d, err := declaration.ParseFileTrusted(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
err := runApply(context.Background(), opts, parsed(genesis), genesis, fromFile)
|
||||
if err == nil {
|
||||
t.Fatal("the bundle genesis consumed was applied over what the mesh said since")
|
||||
}
|
||||
for _, want := range []string{"older", short(apply.DigestOf(genesis)), short(apply.DigestOf(since))} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not say %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
|
||||
err = runApply(context.Background(), opts, parsed(other), other, fromFile)
|
||||
if err == nil {
|
||||
t.Fatal("a declaration that is not what the mesh last said was applied")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "for a machine the mesh has not spoken to") ||
|
||||
!strings.Contains(err.Error(), short(apply.DigestOf(since))) {
|
||||
t.Errorf("the refusal does not say what a file is for and what was last said: %v", err)
|
||||
}
|
||||
|
||||
// The very declaration the mesh last sent, from a file: still a file.
|
||||
err = runApply(context.Background(), opts, parsed(since), since, fromFile)
|
||||
if err == nil || !strings.Contains(err.Error(), "applied as the bundle is") {
|
||||
t.Errorf("the last declaration, from a file, was not refused as a file: %v", err)
|
||||
}
|
||||
if known, _ := store.Load(opts.state); len(known.Resources) != 0 {
|
||||
t.Errorf("a refused file recorded %d resource(s)", len(known.Resources))
|
||||
}
|
||||
|
||||
// A bundle other than the one genesis consumed: what genesis applied was rewritten for this
|
||||
// machine, so the carried bytes never are.
|
||||
err = runApply(context.Background(), opts, parsed(other), other, fromBundle)
|
||||
if err == nil || !strings.Contains(err.Error(), "consumed") ||
|
||||
!strings.Contains(err.Error(), short(apply.DigestOf(genesis))) {
|
||||
t.Errorf("a bundle other than the consumed one was not refused naming it: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Defends novox/hq issue 104: what an apply would change is said before anything is, and
|
||||
// `--dry-run` is that and nothing else — an action listed as the action it is.
|
||||
func TestADryRunChangesNothingAndListsTheActions(t *testing.T) {
|
||||
opts := stateWithMode(t, "")
|
||||
opts.dryRun = true
|
||||
out := &bytes.Buffer{}
|
||||
opts.out = out
|
||||
path := filepath.Join(filepath.Dir(opts.state), "a.conf")
|
||||
raw := []byte(`{"declaration":1,"resources":[
|
||||
{"id":"a","type":"file","path":"` + path + `","content":"x\n"},
|
||||
{"id":"init","type":"action","command":["createdb","mesh"],"verify":["psql","-c","select 1"]}]}`)
|
||||
d, err := declaration.ParseFileTrusted(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runApply(context.Background(), opts, d, raw, fromFile); err != nil {
|
||||
t.Fatalf("a dry run failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Error("a dry run wrote the file")
|
||||
}
|
||||
for _, want := range []string{"would change", "create file a", "run action init",
|
||||
"`createdb mesh`", "--dry-run: nothing applied"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Errorf("the preview does not say %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(out.String(), "applying:") {
|
||||
t.Errorf("a dry run went on to apply:\n%s", out.String())
|
||||
}
|
||||
|
||||
// Machine-readable, the same shape as an apply's: the plan, and no report.
|
||||
out.Reset()
|
||||
opts.json = true
|
||||
if err := runApply(context.Background(), opts, d, raw, fromFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var shape struct {
|
||||
Plan []apply.Step `json:"plan"`
|
||||
Report *json.RawMessage `json:"report"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &shape); err != nil || len(shape.Plan) != 2 || shape.Report != nil {
|
||||
t.Errorf("a json dry run is not {plan} alone: %v\n%s", err, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Defends novox/hq issue 104: once the mesh has told this node anything, `reconcile` holds it to
|
||||
// that — never to the bundle the host carries, which genesis consumed.
|
||||
func TestReconcileAfterAControllerDeclarationDoesNotReapplyTheBundle(t *testing.T) {
|
||||
was := builtFor
|
||||
builtFor = "arch"
|
||||
t.Cleanup(func() { builtFor = was })
|
||||
opts := stateWithMode(t, store.ModeAdopted)
|
||||
|
||||
controllerPublic, controllerPrivate, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
said := []byte(`{"declaration":1,"adoption":{"taken":[]},"resources":[{"id":"a","type":"file","path":"/tmp/a","content":"x\n"}]}`)
|
||||
if err := store.SaveDeclared(store.DeclaredPath(opts.state), store.Declared{Declaration: said,
|
||||
Signature: ed25519.Sign(controllerPrivate, said)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Told, and unable to prove by whom: refused, and the bundle is not applied in its place.
|
||||
_, _, _, err = reconcileSource(opts)
|
||||
if err == nil || !strings.Contains(err.Error(), "not applied in its place") {
|
||||
t.Errorf("a node that cannot prove what it was told fell back to something: %v", err)
|
||||
}
|
||||
|
||||
mine, err := identity.Generate("workstation")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mine.Membership = identity.Membership{Broker: "198.51.100.10:5671", Fingerprint: "sha256:0",
|
||||
Signer: controllerPublic, Password: "issued"}
|
||||
if err := identity.Save(identity.Path(opts.state), mine); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, raw, from, err := reconcileSource(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if from != fromDeclared || !bytes.Equal(raw, said) || d.Adoption == nil {
|
||||
t.Errorf("reconcile chose %d with %d bytes, not what the mesh last said", from, len(raw))
|
||||
}
|
||||
|
||||
// And before the mesh has said anything: the bundle, as it always was. A test binary carries
|
||||
// only the placeholder, and asking for it is what proves the path.
|
||||
if err := os.Remove(store.DeclaredPath(opts.state)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, _, err = reconcileSource(opts)
|
||||
if !errors.Is(err, bundle.ErrEmpty) {
|
||||
t.Errorf("with nothing said, reconcile did not reach for the carried bundle: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// **A machine says which links face outside without being asked.**
|
||||
//
|
||||
// The mesh composes no filter for a machine that has not said (novox/hq ADR 0140), and a machine only
|
||||
// speaks unasked when this fingerprint changes. Left out of it, a machine that has just learnt to say
|
||||
// could speak only when a declaration arrived — and a declaration cannot be composed until it has
|
||||
// spoken. A machine waiting for a push that is waiting for the machine.
|
||||
func TestANewOutwardLinkIsSaidUnasked(t *testing.T) {
|
||||
w := &adoptionWatch{}
|
||||
first := link.Report{Firewall: "none"}
|
||||
if !w.differs(first) {
|
||||
t.Fatal("the first report should differ from nothing")
|
||||
}
|
||||
w.said(first)
|
||||
|
||||
// Only the links changed, and nothing about adoption.
|
||||
learnt := link.Report{Firewall: "none", Outward: []string{"eth0"}}
|
||||
if !w.differs(learnt) {
|
||||
t.Fatal("a machine that has just learnt which links face outside would never say so, " +
|
||||
"and could then never be sent a filter")
|
||||
}
|
||||
w.said(learnt)
|
||||
if w.differs(link.Report{Firewall: "none", Outward: []string{"eth0"}}) {
|
||||
t.Fatal("the same links are reported as a change, so the machine would speak on every reconcile")
|
||||
}
|
||||
|
||||
// And a link that changes — a laptop moving from a cable to a radio — is said too, because the
|
||||
// filter is written around the old one until it is.
|
||||
if !w.differs(link.Report{Firewall: "none", Outward: []string{"wlan0"}}) {
|
||||
t.Fatal("a changed outward link is not said, so the filter stays written around the old one")
|
||||
}
|
||||
}
|
||||
|
||||
// **A converged machine can say which links face outside, unasked.**
|
||||
//
|
||||
// A reconcile is otherwise silent, and the condition deciding when it speaks asked only what an
|
||||
// adopted node reports — what it holds, and the firewall it found. A converged node has neither, so
|
||||
// it could speak only in reply to a declaration, and the mesh composes no declaration for a node
|
||||
// that has not said which links face outside (novox/hq ADR 0140). Measured: three converged machines
|
||||
// sat silent while the control plane refused to send them a filter.
|
||||
func TestAConvergedMachineSaysItsOutwardLinksUnasked(t *testing.T) {
|
||||
// A converged node's reconcile: nothing held, no found firewall, and the links it can see.
|
||||
converged := link.Report{Outward: []string{"eth0"}}
|
||||
if !worthSaying(converged) {
|
||||
t.Fatal("a converged machine cannot say which links face outside, so it can never be " +
|
||||
"sent a filter — a machine waiting for a push that is waiting for the machine")
|
||||
}
|
||||
|
||||
// An adopted node's reasons still hold, because that is how a predecessor still writing is caught.
|
||||
if !worthSaying(link.Report{Firewall: "ufw"}) {
|
||||
t.Fatal("an adopted machine no longer says which firewall it found")
|
||||
}
|
||||
if !worthSaying(link.Report{Held: []link.Held{{ID: "a-file"}}}) {
|
||||
t.Fatal("an adopted machine no longer says what it holds")
|
||||
}
|
||||
|
||||
// And a reconcile with nothing to say stays silent, or every machine speaks every five minutes
|
||||
// about nothing.
|
||||
if worthSaying(link.Report{}) {
|
||||
t.Fatal("a reconcile with nothing to say speaks anyway")
|
||||
}
|
||||
|
||||
// A refused report says nothing about the machine; the refusal is for the console.
|
||||
if worthSaying(link.Report{Outward: []string{"eth0"}, Refused: "not for this node"}) {
|
||||
t.Fatal("a refused report is offered as news about the machine")
|
||||
}
|
||||
}
|
||||
|
||||
// **A host running as a service says what its apply did.**
|
||||
//
|
||||
// The serving path passed nil where the apply writes its detail, and nil is silence. The one-shot path
|
||||
// has always passed a real function, so everything the apply says was visible when a person ran it by
|
||||
// hand and discarded in the way the host actually runs. Measured before this was written: a machine was
|
||||
// converged, its found firewall was not retired, and what the host decided was unrecoverable because it
|
||||
// had been said to nobody (novox/hq 04-ISSUES/143).
|
||||
//
|
||||
// This asserts only that the apply's log is never nil and that a line reaches what the caller gave.
|
||||
// **It cannot catch the fault it was written for** — a call site passing nil directly — because that is
|
||||
// wiring, and wiring is only proved by running the thing. That proof is a deployed host whose journal
|
||||
// carries the apply's detail, which is how this fix was verified.
|
||||
func TestTheApplysLogIsNeverNil(t *testing.T) {
|
||||
if announceOr(nil) == nil {
|
||||
t.Fatal("a host with nowhere to say things got a nil log, which the apply will call")
|
||||
}
|
||||
announceOr(nil)("this goes nowhere and must not panic")
|
||||
|
||||
var said []string
|
||||
announceOr(func(line string) { said = append(said, line) })(" disabled ufw")
|
||||
if len(said) != 1 || !strings.Contains(said[0], "disabled ufw") {
|
||||
t.Fatalf("the apply's detail did not reach the caller's announce: %v", said)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// A declaration carries no order, so a host cannot tell an older one from a newer (novox/hq
|
||||
// 04-ISSUES/107). Its only identity was the digest of its bytes: "not the last" could be said,
|
||||
// "older" could not.
|
||||
|
||||
func keptWith(t *testing.T, sequence int64) store.Declared {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"declaration": 1, "resources": []any{}, "owns_nothing": true, "sequence": sequence,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return store.Declared{Declaration: body, Signature: []byte("x")}
|
||||
}
|
||||
|
||||
func TestAnOlderDeclarationFromTheMeshIsRefused(t *testing.T) {
|
||||
err := refuseOlder(keptWith(t, 7), nil, 5)
|
||||
if err == nil {
|
||||
t.Fatal("sequence 5 was accepted over a kept 7")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "older") || !strings.Contains(err.Error(), "nothing was applied") {
|
||||
t.Fatalf("the refusal does not say what it is: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewerOrEqualDeclarationIsNot(t *testing.T) {
|
||||
if err := refuseOlder(keptWith(t, 7), nil, 8); err != nil {
|
||||
t.Fatalf("sequence 8 was refused over a kept 7: %v", err)
|
||||
}
|
||||
// Equal is the same declaration again, which reconciling is for.
|
||||
if err := refuseOlder(keptWith(t, 7), nil, 7); err != nil {
|
||||
t.Fatalf("the same sequence was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoOrderClaimedMeansNoOrderCompared(t *testing.T) {
|
||||
// An older controller sends none; a host that received before it understood them kept none.
|
||||
// Refusing on a guess would strand a node the moment the controller is older than the host.
|
||||
if err := refuseOlder(keptWith(t, 7), nil, 0); err != nil {
|
||||
t.Fatalf("a declaration claiming no order was refused: %v", err)
|
||||
}
|
||||
if err := refuseOlder(keptWith(t, 0), nil, 3); err != nil {
|
||||
t.Fatalf("a declaration was refused against a kept one that claimed no order: %v", err)
|
||||
}
|
||||
if err := refuseOlder(store.Declared{}, store.ErrNothingDeclared, 3); err != nil {
|
||||
t.Fatalf("a first declaration was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/tunnel"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0105: `overlay take` moves this node's overlay key onto the found tunnel's and
|
||||
// nothing else — identity, sealing and serving keys stay as they were — and tells the mesh with a
|
||||
// proof signed by the identity key, over the previous key, the new one and the tunnel.
|
||||
|
||||
func anEnrolledNode(t *testing.T) identity.Identity {
|
||||
t.Helper()
|
||||
mine, err := identity.Generate("anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mine.Overlay, err = identity.GenerateOverlayKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mine.Membership = identity.Membership{Broker: "198.51.100.1:5671", Fingerprint: "sha256:aa",
|
||||
Signer: make([]byte, ed25519.PublicKeySize), Password: "p"}
|
||||
return mine
|
||||
}
|
||||
|
||||
func aFoundTunnel(t *testing.T) tunnel.Found {
|
||||
t.Helper()
|
||||
private, err := identity.GenerateOverlayKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found, err := tunnel.Parse([]byte("[Interface]\nPrivateKey = " + private.Private + "\nListenPort = 51900\n" +
|
||||
"Address = 192.0.2.1/24\n[Peer]\nPublicKey = PEER-A=\nAllowedIPs = 192.0.2.2/32\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found.Interface, found.Unit, found.Config = "wg0", "wg-quick@wg0", "/etc/wireguard/wg0.conf"
|
||||
return found
|
||||
}
|
||||
|
||||
func TestTakingATunnelMovesOnlyTheOverlayKeyAndSignsForIt(t *testing.T) {
|
||||
mine := anEnrolledNode(t)
|
||||
found := aFoundTunnel(t)
|
||||
before := mine.Overlay.Public
|
||||
|
||||
taken, rekey, err := rekeyOnto(mine, found)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taken.Overlay.Public != found.PublicKey || taken.Overlay.Private != found.PrivateKey() {
|
||||
t.Fatal("the overlay key is not the tunnel's")
|
||||
}
|
||||
if string(taken.Public) != string(mine.Public) || string(taken.Private) != string(mine.Private) ||
|
||||
taken.Node != mine.Node || taken.Membership.Password != mine.Membership.Password ||
|
||||
taken.Membership.Broker != mine.Membership.Broker {
|
||||
t.Fatal("something other than the overlay key moved")
|
||||
}
|
||||
if taken.OverlayBefore != before {
|
||||
t.Errorf("the key before the take was not kept: %q", taken.OverlayBefore)
|
||||
}
|
||||
if rekey.Previous != before || rekey.OverlayKey != found.PublicKey || rekey.Tunnel == nil ||
|
||||
rekey.Tunnel.PublicKey != found.PublicKey || len(rekey.Tunnel.Peers) != 1 {
|
||||
t.Fatalf("the rekey does not say what moved: %+v", rekey)
|
||||
}
|
||||
if !ed25519.Verify(ed25519.PublicKey(mine.Public),
|
||||
link.RekeyProof("anchor", before, found.PublicKey, rekey.Tunnel), rekey.Proof) {
|
||||
t.Fatal("the rekey is not signed by this node's identity key over what it says")
|
||||
}
|
||||
if ed25519.Verify(ed25519.PublicKey(mine.Public),
|
||||
link.RekeyProof("laptop", before, found.PublicKey, rekey.Tunnel), rekey.Proof) {
|
||||
t.Fatal("the proof is not bound to the node")
|
||||
}
|
||||
for _, said := range []string{rekey.Previous, rekey.OverlayKey, rekey.Tunnel.Interface} {
|
||||
if strings.Contains(said, found.PrivateKey()) {
|
||||
t.Fatal("the private key travels")
|
||||
}
|
||||
}
|
||||
|
||||
// Run again after the take — the mesh not yet told, or told and refused — the previous key it
|
||||
// names is still the one before the take, so the mesh can tell a repeat from a replay.
|
||||
again, second, err := rekeyOnto(taken, found)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.Previous != before || again.OverlayBefore != before || again.Overlay.Public != found.PublicKey {
|
||||
t.Fatalf("a take run again does not name the key before the first: %+v", second)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A component's version comes from where it sits, not from its linker (novox/hq ADR 0142). The mesh's
|
||||
// toolchain stamps no version — a build does not know what it will be called — so a delivered host
|
||||
// read as "development build" and the mesh could not tell which host a machine ran (04-ISSUES/161).
|
||||
|
||||
func TestADeliveredHostReadsItsVersionFromItsPath(t *testing.T) {
|
||||
// A delivered host lives at <libexec>/versions/<version>/<binary>.
|
||||
dir := t.TempDir()
|
||||
versioned := filepath.Join(dir, "versions", "637f65559d16")
|
||||
if err := os.MkdirAll(versioned, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
self := filepath.Join(versioned, "nox-mesh-host")
|
||||
if err := os.WriteFile(self, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versionAt(self, "development build"); got != "637f65559d16" {
|
||||
t.Fatalf("a delivered host read its version as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHostPlacedByHandKeepsItsStamp(t *testing.T) {
|
||||
// The honest answer for one the mesh did not deliver — and every machine is in that state until
|
||||
// a delivery reaches it.
|
||||
if got := versionAt("/usr/bin/nox-mesh-host", "04a27ca"); got != "04a27ca" {
|
||||
t.Fatalf("a hand-placed host read its version as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADirectoryThatIsNotAVersionIsNotReadAsOne(t *testing.T) {
|
||||
// A binary sitting anywhere else must not have its parent directory's name read as a version.
|
||||
for _, path := range []string{
|
||||
"/opt/somewhere/nox-mesh-host",
|
||||
"/usr/lib/nox-mesh-host/launch",
|
||||
"/home/someone/build/nox-mesh-host",
|
||||
} {
|
||||
if got := versionAt(path, "the stamp"); got != "the stamp" {
|
||||
t.Fatalf("%s read its version as %q", path, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// foundation-first-node-nats.lock — what a machine must be before a mesh exists, on the bus being
|
||||
// built (novox/hq ADR 0106, design 25).
|
||||
//
|
||||
// The same twelve steps as foundation-first-node.lock, with one difference that matters: **the mesh
|
||||
// writes its own user list, and at genesis there is no mesh yet to write it.** So this carries the
|
||||
// first one — the controller's own account, at a well-known bootstrap password, exactly as the store
|
||||
// is reached at `postgres:bootstrap` and the old bus at `guest:guest`. It is rotated with those, and
|
||||
// from the controller's first composition onward the file is the controller's to write.
|
||||
//
|
||||
// The accounts file is its own file beside the server's configuration, because the server's own
|
||||
// settings belong to whoever raises it and the users belong to the mesh (design 25 §4). Both live in
|
||||
// one directory, of necessity: an include path is resolved relative to the including file's own
|
||||
// directory, so a server given an absolute one looks for it underneath that directory and refuses to
|
||||
// start.
|
||||
//
|
||||
// No `verify` on the TLS block, deliberately — that setting makes the server demand a *client*
|
||||
// certificate, and nothing in the mesh presents one: a host pins this server's exact certificate and
|
||||
// authenticates with a password (ADR 0004, design 25 §4).
|
||||
|
||||
{
|
||||
"declaration": 1,
|
||||
"resources": [
|
||||
{
|
||||
"id": "container-runtime",
|
||||
"type": "package",
|
||||
"package": "docker"
|
||||
},
|
||||
{
|
||||
"id": "container-runtime-running",
|
||||
"type": "service",
|
||||
"unit": "docker.service",
|
||||
"state": "running",
|
||||
"boot": "enabled"
|
||||
},
|
||||
// **A filter before anything listens** (novox/hq issue 054, ADR 0088). The store and the
|
||||
// broker are adopted as modules later and so bind to every interface from the moment they
|
||||
// start; the packet filter that governs who may reach them is a module too, installed a
|
||||
// dozen steps later. Between the two, a control-node facing the network had its store and
|
||||
// its bus open to anyone who could reach the machine. So the foundation carries a filter of
|
||||
// its own — the same table the filter module will replace wholesale once it can derive one:
|
||||
// drop by default, keep loopback, replies, ssh and the mesh's own ports (the bus a node
|
||||
// enrols over, the registry a node pulls from), and let the container runtime's own
|
||||
// networks through the forward chain so containers keep working. A published container port
|
||||
// is forwarded, never input (issue 047), which is why the forward chain is where the store's
|
||||
// and broker's ports are refused from outside — and a container on this machine dialling a
|
||||
// port this machine publishes reaches it through the runtime's proxy, which IS input, which
|
||||
// is why the bus and the registry are opened in both chains, exactly as the derived ruleset
|
||||
// does.
|
||||
{
|
||||
"id": "base-filter-package",
|
||||
"type": "package",
|
||||
"package": "nftables"
|
||||
},
|
||||
{
|
||||
"id": "base-filter",
|
||||
"type": "file",
|
||||
"path": "/etc/nftables.conf",
|
||||
"mode": "0644",
|
||||
"content": "#!/usr/sbin/nft -f\n# the foundation's own filter, until the mesh derives one (novox/hq issue 054)\ntable inet mesh {}\ndelete table inet mesh\n\ntable inet mesh {\n\tchain input {\n\t\ttype filter hook input priority filter; policy drop;\n\t\tct state established,related accept\n\t\tct state invalid drop\n\t\tiif lo accept\n\t\ticmp type echo-request accept\n\t\ticmpv6 type { echo-request, nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept\n\t\t# ssh, from anywhere — never closed\n\t\ttcp dport 22 accept\n\t\t# the mesh's own, from anywhere: the bus a node enrols over and a container on this machine reaches through the proxy, the registry a node pulls from\n\t\ttcp dport 5671 accept\n\t\ttcp dport 5000 accept\n\t}\n\tchain output {\n\t\ttype filter hook output priority filter; policy accept;\n\t}\n\tchain forward {\n\t\ttype filter hook forward priority filter; policy drop;\n\t\tct state established,related accept\n\t\tct state invalid drop\n\t\t# the container runtime's bridge networks, and the networks its compose files are given\n\t\tip saddr 172.16.0.0/12 accept\n\t\tip saddr 192.168.128.0/17 accept\n\t\t# the mesh's own: the bus a node enrols over, the registry a node pulls from\n\t\tct original proto-dst 5671 accept\n\t\tct original proto-dst 5000 accept\n\t}\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "base-filter-loaded",
|
||||
"type": "service",
|
||||
"unit": "nftables.service",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"restart-on": ["base-filter"]
|
||||
},
|
||||
{
|
||||
"id": "store",
|
||||
"type": "container",
|
||||
"name": "mesh-store",
|
||||
"image": "192.0.2.250:5000/postgres@sha256:7abf537131b66ed5af448d90653abf1679b0c7e9a1f07efdd4c3108a401b259a",
|
||||
"env": {
|
||||
"POSTGRES_PASSWORD": "bootstrap",
|
||||
"PGDATA": "/var/lib/postgresql/data/pgdata"
|
||||
},
|
||||
"ports": ["5432:5432"],
|
||||
"volumes": ["mesh-store-data:/var/lib/postgresql/data"]
|
||||
},
|
||||
// Over TCP, not the socket. While the store initialises it runs a temporary server on the
|
||||
// socket ONLY, then stops it and starts the real one — so a socket check passes, the action
|
||||
// exits happy, and the verify a moment later lands in the gap and fails. The action and its
|
||||
// verify must ask the same question, or the action can succeed into a state verify rejects.
|
||||
{
|
||||
"id": "store-ready",
|
||||
"type": "action",
|
||||
"in": "mesh-store",
|
||||
"command": ["sh", "-c", "for i in $(seq 1 180); do pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1 && exit 0; sleep 1; done; echo 'the store did not answer within 180s; its own last words follow'; pg_isready -h 127.0.0.1 -U postgres; tail -n 20 /var/lib/postgresql/data/log/*.log 2>/dev/null; exit 1"],
|
||||
"verify": ["pg_isready", "-h", "127.0.0.1", "-U", "postgres"]
|
||||
},
|
||||
{
|
||||
"id": "inventory-database",
|
||||
"type": "action",
|
||||
"in": "mesh-store",
|
||||
"command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE inventory'"],
|
||||
"verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw inventory"]
|
||||
},
|
||||
{
|
||||
"id": "identity-database",
|
||||
"type": "action",
|
||||
"in": "mesh-store",
|
||||
"command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE identity'"],
|
||||
"verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw identity"]
|
||||
},
|
||||
// Each context owns its own database (novox/hq ADR 0008). A third one is a third database,
|
||||
// created the same way and named the same way — which is the whole of adding a context to the
|
||||
// bootstrap, and is why the count is not something the foundation has an opinion about.
|
||||
{
|
||||
"id": "licences-database",
|
||||
"type": "action",
|
||||
"in": "mesh-store",
|
||||
"command": ["sh", "-c", "psql -U postgres -c 'CREATE DATABASE licences'"],
|
||||
"verify": ["sh", "-c", "psql -U postgres -lqt | cut -d'|' -f1 | grep -qw licences"]
|
||||
},
|
||||
{
|
||||
"id": "context-schemas",
|
||||
"type": "action",
|
||||
"command": ["docker", "run", "--rm", "--network", "container:mesh-store",
|
||||
"-e", "MESH_STORE_INVENTORY=postgres://postgres:bootstrap@127.0.0.1:5432/inventory?sslmode=disable",
|
||||
"-e", "MESH_STORE_IDENTITY=postgres://postgres:bootstrap@127.0.0.1:5432/identity?sslmode=disable",
|
||||
"-e", "MESH_STORE_LICENCES=postgres://postgres:bootstrap@127.0.0.1:5432/licences?sslmode=disable",
|
||||
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
|
||||
"migrate"],
|
||||
"verify": ["sh", "-c", "docker exec mesh-store psql -U postgres -d inventory -tAc \"select to_regclass('public.node')\" | grep -qx node && docker exec mesh-store psql -U postgres -d identity -tAc \"select to_regclass('public.signing_key')\" | grep -qx signing_key && docker exec mesh-store psql -U postgres -d licences -tAc \"select to_regclass('public.licence')\" | grep -qx licence"]
|
||||
},
|
||||
{
|
||||
"id": "bus-certificate",
|
||||
"type": "action",
|
||||
// **The mesh makes its own** (novox/hq 04-ISSUES/146). This ran `openssl` inside the
|
||||
// broker's image while the broker was one that carried it; the bus that replaced it has a
|
||||
// shell and no openssl, and no other image the bundle names has one either. So the program
|
||||
// that needs the certificate writes it — already on this machine, since the schema step ran
|
||||
// it, and asking nothing of the image it writes into. Self-signed on purpose: a host pins
|
||||
// this server's exact certificate (novox/hq ADR 0004), and at this moment there is no mesh
|
||||
// to ask an authority of.
|
||||
// `--user 0:0` because the volume is root's and this image runs as nobody, which is right
|
||||
// for the long-running control plane and wrong for a one-shot writing into a fresh volume.
|
||||
"command": ["docker", "run", "--rm", "--user", "0:0", "-v", "mesh-broker-tls:/tls",
|
||||
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
|
||||
"broker", "certificate", "--into", "/tls"],
|
||||
"verify": ["docker", "run", "--rm", "--user", "0:0", "-v", "mesh-broker-tls:/tls",
|
||||
"192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
|
||||
"broker", "certificate", "--check", "--into", "/tls"]
|
||||
},
|
||||
{
|
||||
"id": "bus-conf-dir",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh-bus-conf",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "bus-conf",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh-bus-conf/nats.conf",
|
||||
"mode": "0644",
|
||||
"content": "port: 4222\nhttp: 127.0.0.1:8222\n\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n}\n\njetstream {\n store_dir: \"/data\"\n}\n\ninclude accounts.conf\n"
|
||||
},
|
||||
{
|
||||
"id": "bus-accounts",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh-bus-conf/accounts.conf",
|
||||
"mode": "0600",
|
||||
"content": "// The first user list, carried by the installer because at genesis there is no mesh to\n// compose one. A bootstrap credential, rotated with the store's and replaced by the\n// controller's own composition from its first start onward.\naccounts {\n MESH {\n jetstream: enabled\n users = [\n { user: \"controller\", password: \"$2a$10$AHqJgOifIVbU41KmATiMhuXFs8xa7Wl2HuN4UVBCXdN2jIQzjqApy\", permissions: {\n publish: { allow: [\"$JS.ACK.CONTROL.controller.>\", \"$JS.ACK.EVENTS.controller.>\", \"$JS.API.>\", \"_INBOX.enrol.>\", \"mesh.assignment.>\", \"mesh.control.>\", \"mesh.mod.*.tool.>\", \"mesh.node.>\", \"mesh.seat.mesh-build-machine.accept.>\", \"mesh.seat.mesh-controller.event.applied\", \"mesh.seat.mesh-controller.event.built-before\", \"mesh.seat.mesh-controller.event.refused\"] }\n subscribe: { allow: [\"$JS.API.>\", \"_DELIVER.controller\", \"_DELIVER.controller.>\", \"_INBOX.controller.>\", \"mesh.control.>\", \"mesh.mod.gitea.event.pull.merged\", \"mesh.mod.mesh-catalog.event.catching-up\", \"mesh.mod.mesh-catalog.event.upgraded\", \"mesh.seat.mesh-build-machine.event.built\", \"mesh.seat.mesh-controller.tool.>\"] }\n allow_responses: { max: 1, ttl: \"1m\" }\n } }\n ]\n }\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "broker",
|
||||
"type": "container",
|
||||
"name": "mesh-broker",
|
||||
"image": "192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927",
|
||||
"ports": ["5671:4222", "127.0.0.1:8222:8222"],
|
||||
"volumes": ["mesh-broker-data:/data", "mesh-broker-tls:/tls:ro", "/var/lib/mesh-bus-conf:/etc/nats:ro"],
|
||||
"args": ["-c", "/etc/nats/nats.conf", "-js"]
|
||||
},
|
||||
{
|
||||
"id": "broker-ready",
|
||||
"type": "action",
|
||||
"command": ["sh", "-c", "for i in $(seq 1 60); do docker run --rm --network host --entrypoint sh 192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927 -c 'nc -z 127.0.0.1 5671' >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1"],
|
||||
"verify": ["sh", "-c", "docker run --rm --network host --entrypoint sh 192.0.2.250:5000/nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927 -c 'nc -z 127.0.0.1 5671'"]
|
||||
},
|
||||
{
|
||||
"id": "control-plane",
|
||||
"type": "container",
|
||||
"name": "mesh-controller",
|
||||
"image": "192.0.2.250:5000/mesh-controller@sha256:c67db38439ff0aee242b467486765467bb95801f52175fc5727cc4e437338ace",
|
||||
"network": "host",
|
||||
"args": ["serve"],
|
||||
"volumes": ["mesh-broker-tls:/broker-tls:ro"],
|
||||
"env": {
|
||||
"MESH_STORE_INVENTORY": "postgres://postgres:bootstrap@127.0.0.1:5432/inventory?sslmode=disable",
|
||||
"MESH_STORE_IDENTITY": "postgres://postgres:bootstrap@127.0.0.1:5432/identity?sslmode=disable",
|
||||
"MESH_STORE_LICENCES": "postgres://postgres:bootstrap@127.0.0.1:5432/licences?sslmode=disable",
|
||||
"MESH_BUS_NATS": "nats://controller:bootstrap@127.0.0.1:5671",
|
||||
"MESH_BROKER_ADDRESS": "192.0.2.10:5671",
|
||||
"MESH_BROKER_CERTIFICATE": "/broker-tls/tls.crt"
|
||||
}
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
module github.com/novox/mesh-host
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/rabbitmq/amqp091-go v1.14.0 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
github.com/rabbitmq/amqp091-go v1.14.0 h1:RSaT7aOKt/OrkVUyswPDW29lnRz9psuGmfZFBmLqLek=
|
||||
github.com/rabbitmq/amqp091-go v1.14.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
|
||||
+794
-45
File diff suppressed because it is too large
Load Diff
+110
-32
@@ -348,33 +348,111 @@ func TestAnUnknownServiceStateIsRefusedNotGuessed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestADroppedServiceIsStoppedNotDeleted(t *testing.T) {
|
||||
// The host did not install the unit and does not own the unit file — only the state it put
|
||||
// the unit into.
|
||||
var commands []string
|
||||
func TestADroppedServiceIsGivenBackTheStateItWasFoundIn(t *testing.T) {
|
||||
// novox/hq ADR 0118: undeclaring removes what the mesh made, gives back what it changed, and
|
||||
// leaves what was the machine's. A service resource never installs a unit — so undeclaring it
|
||||
// undoes what the mesh did to the unit, and nothing more. Stopping every undeclared unit is
|
||||
// how unassigning the private network stopped the container runtime (novox/hq issue 130).
|
||||
cases := []struct {
|
||||
name string
|
||||
found *store.FoundUnit
|
||||
action string
|
||||
stop bool
|
||||
disable bool
|
||||
}{
|
||||
{"recorded before the host kept what it found", nil, "forgotten", false, false},
|
||||
{"running before the mesh", &store.FoundUnit{State: "running"}, "forgotten", false, false},
|
||||
{"running and enabled before the mesh", &store.FoundUnit{State: "running", Boot: "enabled"}, "forgotten", false, false},
|
||||
{"started by the mesh", &store.FoundUnit{State: "stopped"}, "restored", true, false},
|
||||
{"started and enabled by the mesh", &store.FoundUnit{State: "stopped", Boot: "disabled"}, "restored", true, true},
|
||||
{"enabled by the mesh, running before it", &store.FoundUnit{State: "running", Boot: "disabled"}, "restored", false, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var commands []string
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
commands = append(commands, strings.Join(args, " "))
|
||||
if args[0] == "show" {
|
||||
return "LoadState=loaded\nActiveState=active\n", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
state := store.State{Resources: []store.Applied{
|
||||
{ID: "s", Type: "service", Target: "unit.service", Found: c.found},
|
||||
}}
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"other","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
|
||||
]}`)
|
||||
report, after, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := strings.Join(commands, "; ")
|
||||
if stopped := strings.Contains(joined, "stop unit.service"); stopped != c.stop {
|
||||
t.Errorf("stopped %v, want %v: %s", stopped, c.stop, joined)
|
||||
}
|
||||
if disabled := strings.Contains(joined, "disable unit.service"); disabled != c.disable {
|
||||
t.Errorf("disabled %v, want %v: %s", disabled, c.disable, joined)
|
||||
}
|
||||
if strings.Contains(joined, "start unit.service") || strings.Contains(joined, "mask") {
|
||||
t.Errorf("the host started or masked a unit on its way out: %s", joined)
|
||||
}
|
||||
if o := outcomeOf(report, "s"); o.Action != c.action {
|
||||
t.Errorf("outcome %+v, want %s", o, c.action)
|
||||
}
|
||||
if _, still := after.Find("s"); still {
|
||||
t.Error("the host still believes it owns the undeclared service")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAServiceRecordsTheStateItWasFoundInOnceAndCarriesIt(t *testing.T) {
|
||||
// Read the first time the host applies the unit — before it starts or enables anything —
|
||||
// and never again: by the next apply, the unit's state is the mesh's doing.
|
||||
active := "inactive"
|
||||
enabled := "disabled"
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
commands = append(commands, strings.Join(args, " "))
|
||||
if args[0] == "show" {
|
||||
return "LoadState=loaded\nActiveState=active\n", nil
|
||||
switch args[0] {
|
||||
case "show":
|
||||
return "LoadState=loaded\nActiveState=" + active + "\n", nil
|
||||
case "is-enabled":
|
||||
return enabled, nil
|
||||
case "start":
|
||||
active = "active"
|
||||
case "enable":
|
||||
enabled = "enabled"
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
state := store.State{Resources: []store.Applied{
|
||||
{ID: "s", Type: "service", Target: "gone.service"},
|
||||
}}
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"other","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
|
||||
{"id":"s","type":"service","unit":"filter.service","state":"running","boot":"enabled"}
|
||||
]}`)
|
||||
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, run, nil, nil); err != nil {
|
||||
_, first, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := strings.Join(commands, "; ")
|
||||
if !strings.Contains(joined, "stop gone.service") {
|
||||
t.Errorf("the dropped service was not stopped: %s", joined)
|
||||
rec, _ := first.Find("s")
|
||||
if rec.Found == nil || rec.Found.State != "stopped" || rec.Found.Boot != "disabled" {
|
||||
t.Fatalf("first apply recorded %+v, want stopped and disabled", rec.Found)
|
||||
}
|
||||
if strings.Contains(joined, "disable") || strings.Contains(joined, "mask") {
|
||||
t.Errorf("the host did more than stop a unit it does not own: %s", joined)
|
||||
_, second, err := Apply(context.Background(), archHost(t), d, first, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, _ = second.Find("s")
|
||||
if rec.Found == nil || rec.Found.State != "stopped" {
|
||||
t.Errorf("a later apply replaced what was found with what the mesh made: %+v", rec.Found)
|
||||
}
|
||||
|
||||
// A record from before the host kept what it found is not given one later.
|
||||
old := store.State{Resources: []store.Applied{{ID: "s", Type: "service", Target: "filter.service"}}}
|
||||
_, third, err := Apply(context.Background(), archHost(t), d, old, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec, _ = third.Find("s"); rec.Found != nil {
|
||||
t.Errorf("a record that predates the field was given one after the mesh had acted: %+v", rec.Found)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -635,7 +713,7 @@ func TestAContainerThatExitsImmediatelyFailsTheApply(t *testing.T) {
|
||||
switch {
|
||||
case args[0] == "info":
|
||||
return "27.0\n", nil
|
||||
case args[0] == "inspect":
|
||||
case args[0] == "container":
|
||||
return "false\t" + "", nil // exists, not running
|
||||
case args[0] == "run":
|
||||
return "deadbeef\n", nil
|
||||
@@ -666,14 +744,14 @@ func TestAContainerWhoseDeclarationChangedIsReplaced(t *testing.T) {
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}}
|
||||
]}`)
|
||||
want := containerSpec(d.Resources[0].(*declaration.Container), nil)
|
||||
want := containerSpec(d.Resources[0].(*declaration.Container), inputs{})
|
||||
|
||||
var removed, created bool
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "27.0\n", nil
|
||||
case "inspect":
|
||||
case "container":
|
||||
if created {
|
||||
return "true\t" + want, nil
|
||||
}
|
||||
@@ -704,14 +782,14 @@ func TestAContainerThatMatchesIsLeftAlone(t *testing.T) {
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}}
|
||||
]}`)
|
||||
spec := containerSpec(d.Resources[0].(*declaration.Container), nil)
|
||||
spec := containerSpec(d.Resources[0].(*declaration.Container), inputs{})
|
||||
|
||||
var touched bool
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "27.0\n", nil
|
||||
case "inspect":
|
||||
case "container":
|
||||
return "true\t" + spec, nil
|
||||
}
|
||||
touched = true
|
||||
@@ -748,15 +826,15 @@ func TestAContainerIsRecreatedWhenARestartOnResourceChanged(t *testing.T) {
|
||||
// what a container reads is part of what it is, so the old content yields a different spec.
|
||||
was := map[string]string{"config": declaredDigest(&declaration.File{Content: "{\"token\":\"old\"}\n"})}
|
||||
now := map[string]string{"config": declaredDigest(d.Resources[0].(*declaration.File))}
|
||||
stale := containerSpec(d.Resources[1].(*declaration.Container), was)
|
||||
fresh := containerSpec(d.Resources[1].(*declaration.Container), now)
|
||||
stale := containerSpec(d.Resources[1].(*declaration.Container), inputs{declares: was})
|
||||
fresh := containerSpec(d.Resources[1].(*declaration.Container), inputs{declares: now})
|
||||
|
||||
var removed, created bool
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "27.0\n", nil
|
||||
case "inspect":
|
||||
case "container":
|
||||
// Already there and running, created against the file as it was. After the host
|
||||
// recreates it, the runtime holds the one it just made — as a real one would.
|
||||
if created {
|
||||
@@ -1000,7 +1078,7 @@ func TestAContainerUsesTheRuntimeTheMachineHas(t *testing.T) {
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "6.1.0\n", nil
|
||||
case "inspect":
|
||||
case "container":
|
||||
return "false\t\n", errors.New("no such container")
|
||||
case "run":
|
||||
return "deadbeef\n", nil
|
||||
@@ -1326,7 +1404,7 @@ func TestAContainerIsGivenTheMeshsNames(t *testing.T) {
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "29.0.0\n", nil
|
||||
case "inspect":
|
||||
case "container":
|
||||
return "false\t\n", errors.New("no such container")
|
||||
case "run":
|
||||
ran = args
|
||||
@@ -1361,7 +1439,7 @@ func TestAContainerGivenNoNamesIsRunAsBefore(t *testing.T) {
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "29.0.0\n", nil
|
||||
case "inspect":
|
||||
case "container":
|
||||
return "false\t\n", errors.New("no such container")
|
||||
case "run":
|
||||
ran = args
|
||||
@@ -1538,15 +1616,15 @@ func TestAContainerStaleFromAnEarlierApplyIsReplaced(t *testing.T) {
|
||||
// The container was created when the file said something else.
|
||||
was := map[string]string{"env": declaredDigest(&declaration.File{Content: "PASSWORD=old\n"})}
|
||||
now := map[string]string{"env": declaredDigest(d.Resources[0].(*declaration.File))}
|
||||
stale := containerSpec(d.Resources[1].(*declaration.Container), was)
|
||||
fresh := containerSpec(d.Resources[1].(*declaration.Container), now)
|
||||
stale := containerSpec(d.Resources[1].(*declaration.Container), inputs{declares: was})
|
||||
fresh := containerSpec(d.Resources[1].(*declaration.Container), inputs{declares: now})
|
||||
|
||||
var removed, created bool
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "27.0\n", nil
|
||||
case "inspect":
|
||||
case "container":
|
||||
if created {
|
||||
return "true\t" + fresh, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// A file written into a marked block, never over (novox/hq issue 128, ADR 0102).
|
||||
//
|
||||
// **The file is the machine's; the mesh owns lines in it.** The machine's hosts file is the case
|
||||
// that needed it. The mesh wrote it whole — its own header, localhost, the machine's name and every
|
||||
// name in the mesh — and on a workstation that file is shared: the distribution's lines, a local
|
||||
// development tool's own marked blocks rewritten whenever its projects change, the operator's
|
||||
// hand-added names. Written whole, all of those went at the next change to the mesh's names, with
|
||||
// no failure anywhere: the tool believed it had written its block, and the mesh believed it owned
|
||||
// the file. It is ADR 0102's failure exactly, in a file ADR 0102's JSON verb cannot speak.
|
||||
//
|
||||
// So the host finds the lines between `# BEGIN mesh <id>` and `# END mesh <id>`, rewrites those and
|
||||
// nothing else, and records what they held before. Every line outside the markers is kept byte for
|
||||
// byte — including another tool's `# BEGIN …` blocks, which are that tool's. Undeclared, the region
|
||||
// is given back what it held, or taken out with its markers when it held nothing, and a file the
|
||||
// mesh created goes only if nothing but whitespace is left.
|
||||
|
||||
// applyBlock writes a file's declared lines into its region of the file already at its path.
|
||||
//
|
||||
// **A link stays a link.** Where the path is a symbolic link — a hosts file some distributions keep
|
||||
// elsewhere and link into /etc — the file read, written and renamed over is the one it points to,
|
||||
// so the link and whatever manages it are left as they were. A file written whole, or into JSON,
|
||||
// still replaces a link with a file; that is unchanged here.
|
||||
func applyBlock(r *declaration.File, previous store.Applied) (Outcome, error) {
|
||||
out := begin(r)
|
||||
opening, closing := declaration.BlockMarkers(r.ID)
|
||||
want := blockBody(r.Content)
|
||||
|
||||
real, err := realPath(r.Path)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
raw, err := os.ReadFile(real)
|
||||
existed := err == nil
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return out, err
|
||||
}
|
||||
// What the file is, taken once with what it holds: its mode and owner are the machine's and
|
||||
// go back onto what is written. A file read and then not there to stat is a failure, never a
|
||||
// file with no owner.
|
||||
var info os.FileInfo
|
||||
if existed {
|
||||
if info, err = os.Stat(real); err != nil {
|
||||
return out, fmt.Errorf("read %s and cannot see it: %w", r.Path, err)
|
||||
}
|
||||
}
|
||||
existing := string(raw)
|
||||
|
||||
rec := store.Into{Format: declaration.IntoBlock}
|
||||
var note string
|
||||
rebuilt := false
|
||||
|
||||
// **A file the mesh once wrote whole** (novox/hq issue 128). The resource keeps its id when its
|
||||
// module moves from writing the file whole to writing into it, and the file on the machine is
|
||||
// then the mesh's own old write — its header, its loopback lines, its names. Adding the region
|
||||
// after that would leave the old names above the new ones, and a resolver takes the first
|
||||
// line that answers: the region would be shadowed by what it replaced. So the file is rebuilt:
|
||||
// the original the mesh kept before its first write, with the region in it; or, where the mesh
|
||||
// made the file itself, the loopback lines every machine needs, kept as the machine's, with the
|
||||
// region beside them. Changed since the mesh wrote it, the file is somebody's again and is
|
||||
// written into as it stands, and the outcome says so.
|
||||
if existed && previous.Into == nil && previous.Wrote != "" {
|
||||
if digestOf(existing) == previous.Wrote {
|
||||
if previous.Kept != "" {
|
||||
original, err := os.ReadFile(previous.Kept)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("%s was written whole by the mesh over an original kept at %s, "+
|
||||
"which cannot be read to give it back: %w; it was left as it is", r.Path, previous.Kept, err)
|
||||
}
|
||||
existing = string(original)
|
||||
note = "the mesh's old whole file replaced by the original kept at " + previous.Kept + ", with the region in it"
|
||||
} else {
|
||||
existing = loopbackOf(existing)
|
||||
rec.Created = true
|
||||
note = "the mesh's old whole file replaced by its loopback lines and the region"
|
||||
}
|
||||
// Not what was read: the whole of it was the mesh's, and the file is written afresh.
|
||||
rebuilt = true
|
||||
} else {
|
||||
note = "a file the mesh once wrote whole, changed since; its old lines were kept"
|
||||
}
|
||||
}
|
||||
|
||||
lines := linesOf(existing)
|
||||
at, found, err := regionIn(lines, opening, closing)
|
||||
if err != nil {
|
||||
// Refused, never guessed at: markers the host cannot pair are markers it cannot write
|
||||
// between without risking lines that are not the mesh's.
|
||||
return out, fmt.Errorf("%s: %w; it was left as it is", r.Path, err)
|
||||
}
|
||||
|
||||
// A record of a block is carried; anything else — no record, a file once written whole, one
|
||||
// once written into as JSON — is a file the host is seeing for the first time as a block.
|
||||
recorded := previous.Into != nil && previous.Into.Format == declaration.IntoBlock
|
||||
if recorded && existed {
|
||||
rec.Created = previous.Into.Created
|
||||
rec.Region = previous.Into.Region
|
||||
rec.Separated = previous.Into.Separated
|
||||
rec.At = previous.Into.At
|
||||
rec.Ended = previous.Into.Ended
|
||||
} else if !rebuilt {
|
||||
// A file gone since the last apply is made again, and made by the mesh: what it held
|
||||
// before went with it, so there is nothing to give back but the file's absence.
|
||||
rec.Created = !existed
|
||||
if found {
|
||||
// **What the host may have written itself is not the machine's** — the same reasoning
|
||||
// as a key in a JSON file (novox/hq ADR 0102). With no record, a region already holding
|
||||
// exactly the declared lines cannot be told from one this host wrote a moment ago and
|
||||
// died before saving; remembered as the machine's, it would be put back on undeclare
|
||||
// for ever. So it is the mesh's, and undeclaring takes it out.
|
||||
if held := at.body(lines); held != want {
|
||||
rec.Region = &held
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drift: the machine no longer holds, between the mesh's markers, what this host last put
|
||||
// there. Judged only against a record of a block: a digest of a whole file says nothing about
|
||||
// a region of it.
|
||||
drifted := recorded && previous.Wrote != "" && existed &&
|
||||
(!found || digestOf(at.body(lines)) != previous.Wrote)
|
||||
|
||||
var next string
|
||||
switch {
|
||||
case !existed:
|
||||
next = regionOf(opening, closing, want)
|
||||
case found:
|
||||
// Where it is, whatever At says: the region is never moved, because moving it moves the
|
||||
// machine's lines around it.
|
||||
next = strings.Join(lines[:at.begin+1], "") + want + strings.Join(lines[at.end:], "")
|
||||
case r.At == declaration.AtStart:
|
||||
// Above everything, and one blank line between the region and the machine's first line
|
||||
// unless there is one already — a line in some files means what the lines above it say.
|
||||
rec.At, rec.Separated, rec.Ended = declaration.AtStart, false, false
|
||||
next = regionOf(opening, closing, want)
|
||||
if existing != "" && !strings.HasPrefix(existing, "\n") {
|
||||
next += "\n"
|
||||
rec.Separated = true
|
||||
}
|
||||
next += existing
|
||||
default:
|
||||
// At the end, apart from whatever is there: the file's last line is ended if it was not,
|
||||
// and one blank line separates the region from the machine's lines unless there is one.
|
||||
rec.At, rec.Separated, rec.Ended = "", false, false
|
||||
next = existing
|
||||
if next != "" && !strings.HasSuffix(next, "\n") {
|
||||
next += "\n"
|
||||
rec.Ended = true
|
||||
}
|
||||
if next != "" && next != "\n" && !strings.HasSuffix(next, "\n\n") {
|
||||
next += "\n"
|
||||
rec.Separated = true
|
||||
}
|
||||
next += regionOf(opening, closing, want)
|
||||
}
|
||||
// What was not the mesh's is what it was. By construction — and checked, because a slip in
|
||||
// splicing lines is exactly the fault this mode exists to prevent, and it must never be written.
|
||||
if found {
|
||||
after := linesOf(next)
|
||||
if where, ok, err := regionIn(after, opening, closing); err != nil || !ok || outside(after, where) != outside(lines, at) {
|
||||
return out, fmt.Errorf("%s: writing the region would change lines outside it; it was left as it is", r.Path)
|
||||
}
|
||||
}
|
||||
|
||||
same := existed && next == string(raw)
|
||||
if !same {
|
||||
mode := os.FileMode(0o644)
|
||||
if info != nil {
|
||||
mode = info.Mode().Perm() // the machine's file keeps the machine's mode
|
||||
} else if mode, err = modeOf(r.Mode, mode); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(real), 0o755); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := writeAtomically(real, []byte(next), mode); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if info != nil {
|
||||
// The write is a new file renamed over the old, so it belongs to whoever wrote it. The
|
||||
// machine's file keeps the machine's owner, as it keeps its mode.
|
||||
if err := keepOwner(real, info); err != nil {
|
||||
return out, err
|
||||
}
|
||||
} else if err := own(real, r.Owner); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
|
||||
// Read back: the region holds what was declared. Only the region — another tool writing its
|
||||
// own lines in the moment after the rename is not a failed write. What remains is the moment
|
||||
// between reading the file and renaming over it: a line another tool writes there is lost, and
|
||||
// found again at its next write. Nothing short of a lock every writer honours closes that, and
|
||||
// the other writers of a hosts file honour none.
|
||||
written, err := os.ReadFile(real)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("wrote into %s and cannot read it back: %w", r.Path, err)
|
||||
}
|
||||
back := linesOf(string(written))
|
||||
if where, ok, err := regionIn(back, opening, closing); err != nil || !ok || where.body(back) != want {
|
||||
return out, fmt.Errorf("%s does not hold the mesh's region after writing into it", r.Path)
|
||||
}
|
||||
|
||||
out.into = &rec
|
||||
out.wrote = digestOf(want)
|
||||
switch {
|
||||
case note != "" && !same:
|
||||
out.Action = "updated"
|
||||
out.Detail = note
|
||||
case !existed:
|
||||
out.Action = "created"
|
||||
out.Detail = "written into; the file was not there"
|
||||
case same:
|
||||
out.Action = "unchanged"
|
||||
case drifted:
|
||||
out.Action = "corrected"
|
||||
out.Detail = "the mesh's region had been changed on the machine; every line outside it was kept"
|
||||
case !found:
|
||||
out.Action = "updated"
|
||||
where := "end"
|
||||
if rec.At == declaration.AtStart {
|
||||
where = "start"
|
||||
}
|
||||
out.Detail = "the mesh's region added at the " + where + "; every other line kept as it was"
|
||||
default:
|
||||
out.Action = "updated"
|
||||
out.Detail = "the mesh's region rewritten; every line outside it kept as it was"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// removeBlock gives back what a file written into a block held before the mesh's region.
|
||||
func removeBlock(a store.Applied) (string, string, error) {
|
||||
real, err := realPath(a.Target)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
raw, err := os.ReadFile(real)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "forgotten", "no longer there", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
info, err := os.Stat(real)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("read %s and cannot see it: %w", a.Target, err)
|
||||
}
|
||||
opening, closing := declaration.BlockMarkers(a.ID)
|
||||
lines := linesOf(string(raw))
|
||||
at, found, err := regionIn(lines, opening, closing)
|
||||
if err != nil {
|
||||
return "kept", err.Error() + ", so nothing was taken out of it; remove the mesh's region by hand", nil
|
||||
}
|
||||
|
||||
next, action, detail := string(raw), "forgotten", "the mesh's region was no longer in it"
|
||||
switch {
|
||||
case found && a.Into.Region != nil:
|
||||
next = strings.Join(lines[:at.begin+1], "") + *a.Into.Region + strings.Join(lines[at.end:], "")
|
||||
action, detail = "restored", "no longer declared; the region was given back what it held"
|
||||
case found:
|
||||
from, to := at.begin, at.end+1
|
||||
// The blank line the host added beside the region, when a blank line still stands there.
|
||||
// Whether it is the same one the host added cannot be known from the file; a blank line
|
||||
// is the one line whose going changes nothing any program reads, so it is taken. A line
|
||||
// that is not blank is never taken, whoever put it there.
|
||||
if a.Into.Separated {
|
||||
if a.Into.At == declaration.AtStart {
|
||||
if to < len(lines) && lines[to] == "\n" {
|
||||
to++
|
||||
}
|
||||
} else if from > 0 && lines[from-1] == "\n" {
|
||||
from--
|
||||
}
|
||||
}
|
||||
next = strings.Join(lines[:from], "") + strings.Join(lines[to:], "")
|
||||
// And the line end the host gave the machine's last line, if that line is still last.
|
||||
if a.Into.Ended && strings.Join(lines[to:], "") == "" {
|
||||
next = strings.TrimSuffix(next, "\n")
|
||||
}
|
||||
action, detail = "restored", "no longer declared; the mesh's region was taken out and every other line kept"
|
||||
}
|
||||
|
||||
if a.Into.Created && strings.TrimSpace(next) == "" && real == a.Target {
|
||||
if err := os.Remove(real); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return "removed", "no longer declared; the mesh had created it and nothing else was in it", nil
|
||||
}
|
||||
if next == string(raw) {
|
||||
return action, detail, nil
|
||||
}
|
||||
if err := writeAtomically(real, []byte(next), info.Mode().Perm()); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := keepOwner(real, info); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return action, detail, nil
|
||||
}
|
||||
|
||||
// realPath is the file a path names, through any links; a path that is not there yet is itself.
|
||||
// A link to nothing is refused: writing through it would replace the link with a file.
|
||||
func realPath(path string) (string, error) {
|
||||
real, err := filepath.EvalSymlinks(path)
|
||||
if err == nil {
|
||||
return real, nil
|
||||
}
|
||||
if _, lerr := os.Lstat(path); errors.Is(lerr, os.ErrNotExist) {
|
||||
return path, nil
|
||||
}
|
||||
return "", fmt.Errorf("%s is a link the host cannot follow to a file: %w; it was left as it is", path, err)
|
||||
}
|
||||
|
||||
// loopbackOf is the lines of a file that answer for the machine itself — localhost, its own name on
|
||||
// 127.0.1.1, ::1 — and nothing else: what the mesh's old whole hosts file carried that the machine
|
||||
// needs, without the mesh's header or its names.
|
||||
func loopbackOf(text string) string {
|
||||
var b strings.Builder
|
||||
for _, line := range linesOf(text) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(fields[0]); ip != nil && ip.IsLoopback() {
|
||||
b.WriteString(strings.TrimSuffix(line, "\n") + "\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// outside is every line of a file but the mesh's region, markers included, as one string.
|
||||
func outside(lines []string, at region) string {
|
||||
end := at.end + 1
|
||||
if end > len(lines) {
|
||||
end = len(lines)
|
||||
}
|
||||
return strings.Join(lines[:at.begin], "") + "\x00" + strings.Join(lines[end:], "")
|
||||
}
|
||||
|
||||
// blockBody is the declared lines as they stand in the region: ending in exactly one line end, or
|
||||
// nothing at all when there are no lines.
|
||||
func blockBody(content string) string {
|
||||
trimmed := strings.TrimRight(content, "\n")
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
return trimmed + "\n"
|
||||
}
|
||||
|
||||
func regionOf(begin, end, body string) string {
|
||||
return begin + "\n" + body + end + "\n"
|
||||
}
|
||||
|
||||
// linesOf splits text into lines that keep their line ends, so joining them again gives back
|
||||
// exactly the bytes that were read — a last line without one included.
|
||||
func linesOf(text string) []string {
|
||||
return strings.SplitAfter(text, "\n")
|
||||
}
|
||||
|
||||
// region is where the mesh's markers stand, as indices into the lines of a file.
|
||||
type region struct{ begin, end int }
|
||||
|
||||
// body is what stands between the markers.
|
||||
func (r region) body(lines []string) string {
|
||||
return strings.Join(lines[r.begin+1:r.end], "")
|
||||
}
|
||||
|
||||
// regionIn finds the mesh's markers for one resource. A line is a marker only if it is exactly the
|
||||
// marker, so another tool's block and another resource's region are never it. Markers that do not
|
||||
// form one pair — a begin with no end, an end before its begin, either twice — are an error rather
|
||||
// than a best guess, because a guess is how the host would rewrite lines that are not its own.
|
||||
func regionIn(lines []string, begin, end string) (region, bool, error) {
|
||||
at := region{begin: -1, end: -1}
|
||||
for i, line := range lines {
|
||||
switch strings.TrimSuffix(line, "\n") {
|
||||
case begin:
|
||||
if at.begin >= 0 {
|
||||
return at, false, fmt.Errorf("%q is in it more than once", begin)
|
||||
}
|
||||
at.begin = i
|
||||
case end:
|
||||
if at.end >= 0 {
|
||||
return at, false, fmt.Errorf("%q is in it more than once", end)
|
||||
}
|
||||
at.end = i
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case at.begin < 0 && at.end < 0:
|
||||
return at, false, nil
|
||||
case at.begin < 0:
|
||||
return at, false, fmt.Errorf("%q is in it with no %q before it", end, begin)
|
||||
case at.end < 0:
|
||||
return at, false, fmt.Errorf("%q is in it with no %q after it", begin, end)
|
||||
case at.end < at.begin:
|
||||
return at, false, fmt.Errorf("%q stands before %q", end, begin)
|
||||
}
|
||||
return at, true, nil
|
||||
}
|
||||
|
||||
// keepOwner gives a file rewritten through a new one back to whoever owned what it replaced.
|
||||
// Changed only where it differs, so a host that is not root can still write a file it owns.
|
||||
func keepOwner(path string, was os.FileInfo) error {
|
||||
uid, gid, ok := ownerOf(was)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
now, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u, g, ok := ownerOf(now); ok && u == uid && g == gid {
|
||||
return nil
|
||||
}
|
||||
if err := os.Chown(path, uid, gid); err != nil {
|
||||
return fmt.Errorf("cannot give %s back to its owner %d:%d: %w", path, uid, gid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// Defends novox/hq issue 128 and ADR 0102: a text file the mesh shares with software it did not
|
||||
// install is written into a marked block, never over — every line outside the mesh's markers is
|
||||
// the machine's and is kept byte for byte, and undeclaring gives the file back.
|
||||
|
||||
const namesID = "mesh-wireguard.fact-node-names"
|
||||
|
||||
func blockDecl(t *testing.T, path, content string, extra ...string) string {
|
||||
t.Helper()
|
||||
more := ""
|
||||
for _, e := range extra {
|
||||
more += "," + e
|
||||
}
|
||||
return fmt.Sprintf(`{"declaration":1,"resources":[
|
||||
{"id":%q,"type":"file","path":%q,"into":"block","content":%q%s}
|
||||
]}`, namesID, path, content, more)
|
||||
}
|
||||
|
||||
func applyBlockDecl(t *testing.T, raw string, known store.State) (Report, store.State) {
|
||||
t.Helper()
|
||||
report, state, err := Apply(context.Background(), archHost(t), parse(t, raw), known, store.OriginDeclared, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return report, state
|
||||
}
|
||||
|
||||
func undeclare(t *testing.T, known store.State) (Report, store.State) {
|
||||
t.Helper()
|
||||
report, state, err := Apply(context.Background(), archHost(t), somethingElse(t), known, store.OriginDeclared, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return report, state
|
||||
}
|
||||
|
||||
func readText(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func marked(id, body string) string {
|
||||
return "# BEGIN mesh " + id + "\n" + body + "# END mesh " + id + "\n"
|
||||
}
|
||||
|
||||
// A workstation's hosts file, the way issue 128 found it: the distribution's lines, a development
|
||||
// tool's own marked blocks, and the operator's hand-added names.
|
||||
const workstationHosts = "127.0.0.1\tlocalhost\n" +
|
||||
"127.0.1.1\tg14.localdomain g14\n" +
|
||||
"\n" +
|
||||
"# BEGIN devtool project-a\n" +
|
||||
"127.0.0.1 a.test api.a.test\n" +
|
||||
"# END devtool project-a\n" +
|
||||
"# BEGIN devtool project-b\n" +
|
||||
"127.0.0.1 b.test\n" +
|
||||
"# END devtool project-b\n" +
|
||||
"192.168.1.20 printer # the operator's\n"
|
||||
|
||||
const meshNames = "10.42.0.1 ace\n10.42.0.2 novox\n"
|
||||
|
||||
func TestABlockKeepsEveryLineOutsideItsMarkers(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hosts")
|
||||
if err := os.WriteFile(path, []byte(workstationHosts), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, state := applyBlockDecl(t, blockDecl(t, path, meshNames), store.State{})
|
||||
want := workstationHosts + "\n" + marked(namesID, meshNames)
|
||||
if got := readText(t, path); got != want {
|
||||
t.Fatalf("the file after writing into it:\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
if got := report.Outcomes[0].Action; got != "updated" {
|
||||
t.Errorf("adding the region was %q", got)
|
||||
}
|
||||
if info, _ := os.Stat(path); info.Mode().Perm() != 0o640 {
|
||||
t.Errorf("the machine's file mode was changed to %o", info.Mode().Perm())
|
||||
}
|
||||
rec, _ := state.Find(namesID)
|
||||
if rec.Into == nil || rec.Into.Format != "block" || rec.Into.Region != nil || rec.Into.Created {
|
||||
t.Fatalf("recorded as %+v", rec.Into)
|
||||
}
|
||||
|
||||
// Again, with nothing changed: nothing written.
|
||||
report, state = applyBlockDecl(t, blockDecl(t, path, meshNames), state)
|
||||
if got := report.Outcomes[0].Action; got != "unchanged" {
|
||||
t.Errorf("a second apply was %q", got)
|
||||
}
|
||||
|
||||
// The development tool rewrites its block, and the operator adds a line after the mesh's
|
||||
// region; the mesh's names change. Only the region moves.
|
||||
edited := strings.Replace(readText(t, path), "127.0.0.1 b.test\n", "127.0.0.1 b.test c.test\n", 1) +
|
||||
"10.0.0.5 nas # added after\n"
|
||||
_ = os.WriteFile(path, []byte(edited), 0o640)
|
||||
changed := meshNames + "10.42.0.3 shanks\n"
|
||||
report, state = applyBlockDecl(t, blockDecl(t, path, changed), state)
|
||||
want = strings.Replace(edited, marked(namesID, meshNames), marked(namesID, changed), 1)
|
||||
if got := readText(t, path); got != want {
|
||||
t.Fatalf("rewriting the region moved something else:\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
if got := report.Outcomes[0].Action; got != "updated" {
|
||||
t.Errorf("rewriting the region was %q", got)
|
||||
}
|
||||
|
||||
// Undeclared: the region, its markers and the blank line the host put before it go; every
|
||||
// other line is where it was.
|
||||
report, _ = undeclare(t, state)
|
||||
want = strings.Replace(edited, "\n"+marked(namesID, meshNames), "", 1)
|
||||
if got := readText(t, path); got != want {
|
||||
t.Fatalf("undeclaring left:\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
if got := report.Outcomes[0].Action; got != "restored" {
|
||||
t.Errorf("undeclaring was %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndeclaringABlockAddedAtTheEndGivesTheFileBackExactly(t *testing.T) {
|
||||
for name, original := range map[string]string{
|
||||
"ending in a line": "127.0.0.1 localhost\n",
|
||||
"ending in a blank line": "127.0.0.1 localhost\n\n",
|
||||
"empty": "",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hosts")
|
||||
_ = os.WriteFile(path, []byte(original), 0o644)
|
||||
_, state := applyBlockDecl(t, blockDecl(t, path, meshNames), store.State{})
|
||||
undeclare(t, state)
|
||||
if got := readText(t, path); got != original {
|
||||
t.Errorf("undeclaring left %q, the machine had %q", got, original)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestABlockAddedAtTheEndIsSetApartFromTheMachinesLines(t *testing.T) {
|
||||
for name, c := range map[string]struct{ before, after string }{
|
||||
"no line end": {"127.0.0.1 localhost", "127.0.0.1 localhost\n\n" + marked(namesID, meshNames)},
|
||||
"a line end": {"127.0.0.1 localhost\n", "127.0.0.1 localhost\n\n" + marked(namesID, meshNames)},
|
||||
"a blank line already": {"127.0.0.1 localhost\n\n", "127.0.0.1 localhost\n\n" + marked(namesID, meshNames)},
|
||||
"empty": {"", marked(namesID, meshNames)},
|
||||
"only a blank line": {"\n", "\n" + marked(namesID, meshNames)},
|
||||
"content without an end": {"x\n\n", "x\n\n" + marked(namesID, meshNames)},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hosts")
|
||||
_ = os.WriteFile(path, []byte(c.before), 0o644)
|
||||
applyBlockDecl(t, blockDecl(t, path, meshNames), store.State{})
|
||||
if got := readText(t, path); got != c.after {
|
||||
t.Errorf("got %q, want %q", got, c.after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRegionEndsInExactlyOneLineEnd(t *testing.T) {
|
||||
for content, body := range map[string]string{
|
||||
"10.42.0.1 ace": "10.42.0.1 ace\n",
|
||||
"10.42.0.1 ace\n": "10.42.0.1 ace\n",
|
||||
"10.42.0.1 ace\n\n\n": "10.42.0.1 ace\n",
|
||||
"": "",
|
||||
"\n\n": "",
|
||||
} {
|
||||
path := filepath.Join(t.TempDir(), "hosts")
|
||||
_, state := applyBlockDecl(t, blockDecl(t, path, content), store.State{})
|
||||
if got := readText(t, path); got != marked(namesID, body) {
|
||||
t.Errorf("content %q was written as %q", content, got)
|
||||
}
|
||||
// And the same content again is not a change.
|
||||
report, _ := applyBlockDecl(t, blockDecl(t, path, content), state)
|
||||
if got := report.Outcomes[0].Action; got != "unchanged" {
|
||||
t.Errorf("content %q applied twice was %q", content, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestARegionAlreadyThereIsRewrittenInPlaceAndGivenBack(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hosts")
|
||||
before := "127.0.0.1 localhost\n"
|
||||
after := "# BEGIN devtool x\n127.0.0.1 x.test\n# END devtool x\n192.168.1.20 printer\n"
|
||||
found := "10.42.0.9 old-name\n"
|
||||
original := before + marked(namesID, found) + after
|
||||
_ = os.WriteFile(path, []byte(original), 0o644)
|
||||
|
||||
report, state := applyBlockDecl(t, blockDecl(t, path, meshNames), store.State{})
|
||||
if got := readText(t, path); got != before+marked(namesID, meshNames)+after {
|
||||
t.Fatalf("the region was not rewritten in place: %q", got)
|
||||
}
|
||||
if got := report.Outcomes[0].Action; got != "updated" {
|
||||
t.Errorf("rewriting a found region was %q", got)
|
||||
}
|
||||
rec, _ := state.Find(namesID)
|
||||
if rec.Into.Region == nil || *rec.Into.Region != found {
|
||||
t.Fatalf("what the region held before was recorded as %v", rec.Into.Region)
|
||||
}
|
||||
|
||||
// Undeclared: what the region held goes back, where it was.
|
||||
report, _ = undeclare(t, state)
|
||||
if got := readText(t, path); got != original {
|
||||
t.Errorf("undeclaring left %q, the machine had %q", got, original)
|
||||
}
|
||||
if got := report.Outcomes[0].Action; got != "restored" {
|
||||
t.Errorf("undeclaring was %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARegionWithNoRecordHoldingExactlyTheDeclaredLinesIsTheMeshs(t *testing.T) {
|
||||
// A host that wrote the region and died before saving its state: what is between the markers
|
||||
// is exactly what the mesh declares, and remembered as the machine's it would never go.
|
||||
path := filepath.Join(t.TempDir(), "hosts")
|
||||
original := "127.0.0.1 localhost\n"
|
||||
_ = os.WriteFile(path, []byte(original+"\n"+marked(namesID, meshNames)), 0o644)
|
||||
_, state := applyBlockDecl(t, blockDecl(t, path, meshNames), store.State{})
|
||||
if rec, _ := state.Find(namesID); rec.Into.Region != nil {
|
||||
t.Fatalf("the mesh's own lines were recorded as the machine's: %q", *rec.Into.Region)
|
||||
}
|
||||
undeclare(t, state)
|
||||
if got := readText(t, path); !strings.HasPrefix(got, original) || strings.Contains(got, "BEGIN mesh") {
|
||||
t.Errorf("undeclaring left the mesh's region behind: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADriftedRegionIsCorrected(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hosts")
|
||||
_ = os.WriteFile(path, []byte(workstationHosts), 0o644)
|
||||
_, state := applyBlockDecl(t, blockDecl(t, path, meshNames), store.State{})
|
||||
written := readText(t, path)
|
||||
|
||||
_ = os.WriteFile(path, []byte(strings.Replace(written, "10.42.0.2 novox\n", "10.42.0.2 novox\n6.6.6.6 evil\n", 1)), 0o644)
|
||||
report, _ := applyBlockDecl(t, blockDecl(t, path, meshNames), state)
|
||||
if got := report.Outcomes[0].Action; got != "corrected" {
|
||||
t.Errorf("a region edited on the machine was %q", got)
|
||||
}
|
||||
if got := readText(t, path); got != written {
|
||||
t.Errorf("the region was not put back: %q", got)
|
||||
}
|
||||
|
||||
// The region taken out by hand is drift too, and it is put back.
|
||||
_ = os.WriteFile(path, []byte(workstationHosts), 0o644)
|
||||
report, _ = applyBlockDecl(t, blockDecl(t, path, meshNames), state)
|
||||
if got := report.Outcomes[0].Action; got != "corrected" {
|
||||
t.Errorf("a region removed on the machine was %q", got)
|
||||
}
|
||||
if got := readText(t, path); got != written {
|
||||
t.Errorf("the region was not put back: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoRegionsInOneFileAreEachTheirOwn(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hosts")
|
||||
_ = os.WriteFile(path, []byte(workstationHosts), 0o644)
|
||||
raw := fmt.Sprintf(`{"declaration":1,"resources":[
|
||||
{"id":"a.names","type":"file","path":%q,"into":"block","content":"10.42.0.1 ace\n"},
|
||||
{"id":"b.names","type":"file","path":%q,"into":"block","content":"10.43.0.1 lab\n"}
|
||||
]}`, path, path)
|
||||
_, state := applyBlockDecl(t, raw, store.State{})
|
||||
want := workstationHosts + "\n" + marked("a.names", "10.42.0.1 ace\n") + "\n" + marked("b.names", "10.43.0.1 lab\n")
|
||||
if got := readText(t, path); got != want {
|
||||
t.Fatalf("two regions:\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
report, state := applyBlockDecl(t, raw, state)
|
||||
for _, o := range report.Outcomes {
|
||||
if o.Action != "unchanged" {
|
||||
t.Errorf("%s applied twice was %q", o.ID, o.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// One undeclared: only its region goes.
|
||||
only := fmt.Sprintf(`{"declaration":1,"resources":[
|
||||
{"id":"b.names","type":"file","path":%q,"into":"block","content":"10.43.0.1 lab\n"}
|
||||
]}`, path)
|
||||
applyBlockDecl(t, only, state)
|
||||
want = workstationHosts + "\n" + marked("b.names", "10.43.0.1 lab\n")
|
||||
if got := readText(t, path); got != want {
|
||||
t.Errorf("undeclaring one region:\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABlockInAFileThatWasNotThereIsCreatedAndRemovedWithIt(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "conf.d", "mesh.conf")
|
||||
report, state := applyBlockDecl(t, blockDecl(t, path, meshNames, `"mode":"0600"`), store.State{})
|
||||
if got := readText(t, path); got != marked(namesID, meshNames) {
|
||||
t.Fatalf("a created file holds %q", got)
|
||||
}
|
||||
if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("a created file is mode %o, declared 0600", info.Mode().Perm())
|
||||
}
|
||||
if got := report.Outcomes[0].Action; got != "created" {
|
||||
t.Errorf("creating was %q", got)
|
||||
}
|
||||
if rec, _ := state.Find(namesID); !rec.Into.Created {
|
||||
t.Error("the mesh creating the file was not recorded")
|
||||
}
|
||||
report, _ = undeclare(t, state)
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("a file the mesh created, holding only its region, was left behind")
|
||||
}
|
||||
if got := report.Outcomes[0].Action; got != "removed" {
|
||||
t.Errorf("undeclaring was %q", got)
|
||||
}
|
||||
|
||||
// Somebody else wrote into it meanwhile: it is no longer only the mesh's, and it stays.
|
||||
_, state = applyBlockDecl(t, blockDecl(t, path, meshNames), store.State{})
|
||||
_ = os.WriteFile(path, []byte(readText(t, path)+"their = line\n"), 0o600)
|
||||
undeclare(t, state)
|
||||
if got := readText(t, path); got != "their = line\n" {
|
||||
t.Errorf("undeclaring a created file somebody wrote into left %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkersThatDoNotPairAreRefusedAndLeftAlone(t *testing.T) {
|
||||
for name, text := range map[string]string{
|
||||
"a begin with no end": "a\n# BEGIN mesh " + namesID + "\nb\n",
|
||||
"an end with no begin": "a\n# END mesh " + namesID + "\n",
|
||||
"an end before a begin": "# END mesh " + namesID + "\n# BEGIN mesh " + namesID + "\n",
|
||||
"a begin twice": marked(namesID, "x\n") + "# BEGIN mesh " + namesID + "\n",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hosts")
|
||||
_ = os.WriteFile(path, []byte(text), 0o644)
|
||||
if _, _, err := Apply(context.Background(), archHost(t), parse(t, blockDecl(t, path, meshNames)),
|
||||
store.State{}, store.OriginDeclared, nil, nil, nil); err == nil {
|
||||
t.Fatal("markers that do not pair were written between")
|
||||
}
|
||||
if got := readText(t, path); got != text {
|
||||
t.Errorf("the file was changed: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMarkerOfAnotherIDIsNotThisRegion(t *testing.T) {
|
||||
// The id is part of the marker: a region whose id merely starts with this one is not it.
|
||||
path := filepath.Join(t.TempDir(), "hosts")
|
||||
other := marked(namesID+"-extra", "10.9.9.9 other\n")
|
||||
_ = os.WriteFile(path, []byte(other), 0o644)
|
||||
_, state := applyBlockDecl(t, blockDecl(t, path, meshNames), store.State{})
|
||||
if got := readText(t, path); got != other+"\n"+marked(namesID, meshNames) {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
undeclare(t, state)
|
||||
if got := readText(t, path); got != other {
|
||||
t.Errorf("undeclaring touched the other region: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABlockAtTheStartStandsAboveEverything(t *testing.T) {
|
||||
// dhcpcd scopes every line after `interface X` to that interface, so the mesh's global options
|
||||
// go above all of it.
|
||||
dhcpcd := "hostname\nduid\n\ninterface enp6s0\nstatic ip_address=192.168.1.5/24\n"
|
||||
opts := "nohook resolv.conf\ndenyinterfaces mesh0\n"
|
||||
for name, c := range map[string]struct{ before, after string }{
|
||||
"a file with content": {dhcpcd, marked(namesID, opts) + "\n" + dhcpcd},
|
||||
"an empty file": {"", marked(namesID, opts)},
|
||||
"a file opening blank": {"\n" + dhcpcd, marked(namesID, opts) + "\n" + dhcpcd},
|
||||
"a last line with no end": {"interface enp6s0", marked(namesID, opts) + "\ninterface enp6s0"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "dhcpcd.conf")
|
||||
_ = os.WriteFile(path, []byte(c.before), 0o644)
|
||||
report, state := applyBlockDecl(t, blockDecl(t, path, opts, `"at":"start"`), store.State{})
|
||||
if got := readText(t, path); got != c.after {
|
||||
t.Fatalf("got %q, want %q", got, c.after)
|
||||
}
|
||||
if got := report.Outcomes[0].Action; got != "updated" {
|
||||
t.Errorf("adding the region was %q", got)
|
||||
}
|
||||
report, state = applyBlockDecl(t, blockDecl(t, path, opts, `"at":"start"`), state)
|
||||
if got := report.Outcomes[0].Action; got != "unchanged" {
|
||||
t.Errorf("a second apply was %q", got)
|
||||
}
|
||||
undeclare(t, state)
|
||||
if got := readText(t, path); got != c.before {
|
||||
t.Errorf("undeclaring left %q, the machine had %q", got, c.before)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a file that was not there", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "dhcpcd.conf")
|
||||
applyBlockDecl(t, blockDecl(t, path, opts, `"at":"start"`), store.State{})
|
||||
if got := readText(t, path); got != marked(namesID, opts) {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestARegionAlreadyThereIsNotMovedWhereverAtSaysItGoes(t *testing.T) {
|
||||
for _, at := range []string{`"at":"start"`, `"at":"end"`} {
|
||||
path := filepath.Join(t.TempDir(), "dhcpcd.conf")
|
||||
original := "hostname\n" + marked(namesID, "old\n") + "interface enp6s0\n"
|
||||
_ = os.WriteFile(path, []byte(original), 0o644)
|
||||
applyBlockDecl(t, blockDecl(t, path, "nohook resolv.conf\n", at), store.State{})
|
||||
want := "hostname\n" + marked(namesID, "nohook resolv.conf\n") + "interface enp6s0\n"
|
||||
if got := readText(t, path); got != want {
|
||||
t.Errorf("%s: a found region was moved: %q", at, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFileWrittenIntoABlockIsNeverHeldOnAnAdoptedNode(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hosts")
|
||||
_ = os.WriteFile(path, []byte(workstationHosts), 0o644)
|
||||
resource := fmt.Sprintf(`{"id":%q,"type":"file","path":%q,"into":"block","content":%q}`, namesID, path, meshNames)
|
||||
d := adopted(t, `{"taken":[],"untaken":{"mesh-wireguard":["`+namesID+`"]}}`, resource)
|
||||
report, state := applyAdopted(t, d, store.State{}, &machine{}, t.TempDir())
|
||||
if got := outcomeOf(report, namesID).Action; got == "held" {
|
||||
t.Fatal("a file written into a block was held, though it replaces nothing that was found")
|
||||
}
|
||||
if len(state.Held) != 0 {
|
||||
t.Errorf("something was held: %+v", state.Held)
|
||||
}
|
||||
if got := readText(t, path); got != workstationHosts+"\n"+marked(namesID, meshNames) {
|
||||
t.Errorf("the adopted node's file was not written into: %q", got)
|
||||
}
|
||||
|
||||
// Held from when it was declared whole, the hold does not keep the region out.
|
||||
path2 := filepath.Join(t.TempDir(), "hosts")
|
||||
_ = os.WriteFile(path2, []byte(workstationHosts), 0o644)
|
||||
known := store.State{Held: []store.Held{{ID: namesID, Module: "mesh-wireguard", Kind: "file", Target: path2}}}
|
||||
resource2 := fmt.Sprintf(`{"id":%q,"type":"file","path":%q,"into":"block","content":%q}`, namesID, path2, meshNames)
|
||||
d2 := adopted(t, `{"taken":[],"untaken":{"mesh-wireguard":["`+namesID+`"]}}`, resource2)
|
||||
report, state = applyAdopted(t, d2, known, &machine{}, t.TempDir())
|
||||
if got := outcomeOf(report, namesID).Action; got != "updated" {
|
||||
t.Errorf("the file was %q, not written into", got)
|
||||
}
|
||||
if len(state.Held) != 0 {
|
||||
t.Errorf("the old hold outlived the block declaration: %+v", state.Held)
|
||||
}
|
||||
|
||||
// And the preview says the same: written into, not held.
|
||||
for _, s := range Plan(d2, known, store.OriginDeclared) {
|
||||
if s.ID == namesID && s.Verb == "hold" {
|
||||
t.Errorf("the preview holds a file written into a block: %+v", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRecordOfABlockSurvivesTheStateFile(t *testing.T) {
|
||||
// What undeclaring needs is in the state a host saves, not only in memory.
|
||||
path := filepath.Join(t.TempDir(), "hosts")
|
||||
original := "a\n" + marked(namesID, "old\n")
|
||||
_ = os.WriteFile(path, []byte(original), 0o644)
|
||||
_, state := applyBlockDecl(t, blockDecl(t, path, meshNames, `"at":"start"`), store.State{})
|
||||
raw, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back store.State
|
||||
if err := json.Unmarshal(raw, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
undeclare(t, back)
|
||||
if got := readText(t, path); got != original {
|
||||
t.Errorf("undeclaring from a saved state left %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The mesh's old whole hosts file, as the controller composed it before issue 128.
|
||||
const oldWholeHosts = "# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n" +
|
||||
"# joins or leaves, and an edit would survive until then and vanish.\n\n" +
|
||||
"127.0.0.1\tlocalhost\n" +
|
||||
"::1\t\tlocalhost ip6-localhost ip6-loopback\n" +
|
||||
"127.0.1.1\tg14\n" +
|
||||
"\n" +
|
||||
"10.42.0.1\tace.internal\tace\n" +
|
||||
"10.42.0.9\tg14.internal\tg14\t# this machine\n"
|
||||
|
||||
func wholeDecl(path, content string) string {
|
||||
return fmt.Sprintf(`{"declaration":1,"resources":[
|
||||
{"id":%q,"type":"file","path":%q,"content":%q}
|
||||
]}`, namesID, path, content)
|
||||
}
|
||||
|
||||
func applyKeepingIn(t *testing.T, raw string, known store.State, keepDir string) (Report, store.State) {
|
||||
t.Helper()
|
||||
report, state, err := ApplyKeeping(context.Background(), archHost(t), parse(t, raw), known,
|
||||
store.OriginDeclared, (&machine{}).run, nil, nil, KeepIn(keepDir))
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
return report, state
|
||||
}
|
||||
|
||||
func TestAFileTheMeshWroteWholeAndMadeItselfKeepsOnlyItsLoopbackLines(t *testing.T) {
|
||||
// Written whole into a file that was not there, then declared as a block under the same id:
|
||||
// the old names must not stay above the region, where a resolver would answer from them first.
|
||||
path := filepath.Join(t.TempDir(), "hosts")
|
||||
_, state := applyKeepingIn(t, wholeDecl(path, oldWholeHosts), store.State{}, t.TempDir())
|
||||
report, state := applyKeepingIn(t, blockDecl(t, path, meshNames), state, t.TempDir())
|
||||
floor := "127.0.0.1\tlocalhost\n::1\t\tlocalhost ip6-localhost ip6-loopback\n127.0.1.1\tg14\n"
|
||||
if got := readText(t, path); got != floor+"\n"+marked(namesID, meshNames) {
|
||||
t.Fatalf("the old whole file became:\n%q", got)
|
||||
}
|
||||
o := outcomeOf(report, namesID)
|
||||
if o.Action != "updated" || !strings.Contains(o.Detail, "loopback lines") {
|
||||
t.Errorf("the rebuild was reported as %q: %s", o.Action, o.Detail)
|
||||
}
|
||||
if rec, _ := state.Find(namesID); !rec.Into.Created {
|
||||
t.Error("a file the mesh made itself was not recorded as the mesh's")
|
||||
}
|
||||
report, _ = applyKeepingIn(t, blockDecl(t, path, meshNames), state, t.TempDir())
|
||||
if got := outcomeOf(report, namesID).Action; got != "unchanged" {
|
||||
t.Errorf("applied again, the rebuilt file was %q", got)
|
||||
}
|
||||
undeclare(t, state)
|
||||
if got := readText(t, path); got != floor {
|
||||
t.Errorf("undeclared, the file holds %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFileTheMeshWroteWholeOverAnOriginalGetsTheOriginalBack(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hosts")
|
||||
_ = os.WriteFile(path, []byte(workstationHosts), 0o644)
|
||||
keep := t.TempDir()
|
||||
_, state := applyKeepingIn(t, wholeDecl(path, oldWholeHosts), store.State{}, keep)
|
||||
if rec, _ := state.Find(namesID); rec.Kept == "" {
|
||||
t.Fatal("where the original was kept was not recorded")
|
||||
}
|
||||
report, state := applyKeepingIn(t, blockDecl(t, path, meshNames), state, keep)
|
||||
if got := readText(t, path); got != workstationHosts+"\n"+marked(namesID, meshNames) {
|
||||
t.Fatalf("the old whole file became:\n%q", got)
|
||||
}
|
||||
if d := outcomeOf(report, namesID).Detail; !strings.Contains(d, "original kept at") {
|
||||
t.Errorf("the rebuild was reported as: %s", d)
|
||||
}
|
||||
undeclare(t, state)
|
||||
if got := readText(t, path); got != workstationHosts {
|
||||
t.Errorf("undeclared, the machine did not get its original back: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFileTheMeshWroteWholeAndSomebodyChangedIsWrittenIntoAsItStands(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hosts")
|
||||
_, state := applyKeepingIn(t, wholeDecl(path, oldWholeHosts), store.State{}, t.TempDir())
|
||||
edited := oldWholeHosts + "192.168.1.20 printer\n"
|
||||
_ = os.WriteFile(path, []byte(edited), 0o644)
|
||||
report, _ := applyKeepingIn(t, blockDecl(t, path, meshNames), state, t.TempDir())
|
||||
if got := readText(t, path); got != edited+"\n"+marked(namesID, meshNames) {
|
||||
t.Fatalf("an edited whole file became:\n%q", got)
|
||||
}
|
||||
if d := outcomeOf(report, namesID).Detail; !strings.Contains(d, "changed since; its old lines were kept") {
|
||||
t.Errorf("the outcome does not say so: %s", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALinkedFileStaysALink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
real := filepath.Join(dir, "static", "hosts")
|
||||
_ = os.MkdirAll(filepath.Dir(real), 0o755)
|
||||
_ = os.WriteFile(real, []byte(workstationHosts), 0o644)
|
||||
link := filepath.Join(dir, "hosts")
|
||||
if err := os.Symlink(real, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, state := applyBlockDecl(t, blockDecl(t, link, meshNames), store.State{})
|
||||
if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatalf("the link was replaced by a file")
|
||||
}
|
||||
if got := readText(t, real); got != workstationHosts+"\n"+marked(namesID, meshNames) {
|
||||
t.Errorf("the file the link names holds %q", got)
|
||||
}
|
||||
undeclare(t, state)
|
||||
if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatalf("undeclaring replaced the link with a file")
|
||||
}
|
||||
if got := readText(t, real); got != workstationHosts {
|
||||
t.Errorf("undeclared, the file the link names holds %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALastLineWithNoEndIsGivenBackWithNone(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "hosts")
|
||||
_ = os.WriteFile(path, []byte("x"), 0o644)
|
||||
_, state := applyBlockDecl(t, blockDecl(t, path, meshNames), store.State{})
|
||||
if got := readText(t, path); got != "x\n\n"+marked(namesID, meshNames) {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
undeclare(t, state)
|
||||
if got := readText(t, path); got != "x" {
|
||||
t.Errorf("undeclaring left %q, the machine had %q", got, "x")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedBlockWriteKeepsItsHold(t *testing.T) {
|
||||
// Held from when it was declared whole, then declared as a block into a file whose markers do
|
||||
// not pair: the write is refused, and the hold — with where its original is — stays.
|
||||
path := filepath.Join(t.TempDir(), "hosts")
|
||||
broken := "a\n# BEGIN mesh " + namesID + "\n"
|
||||
_ = os.WriteFile(path, []byte(broken), 0o644)
|
||||
known := store.State{Held: []store.Held{{ID: namesID, Module: "mesh-wireguard", Kind: "file",
|
||||
Target: path, Kept: "/var/lib/mesh/kept/hosts"}}}
|
||||
resource := fmt.Sprintf(`{"id":%q,"type":"file","path":%q,"into":"block","content":%q}`, namesID, path, meshNames)
|
||||
d := adopted(t, `{"taken":[],"untaken":{"mesh-wireguard":["`+namesID+`"]}}`, resource)
|
||||
_, state, err := ApplyKeeping(context.Background(), archHost(t), d, known,
|
||||
store.OriginDeclared, (&machine{}).run, nil, nil, KeepIn(t.TempDir()))
|
||||
if err == nil {
|
||||
t.Fatal("a write into unpaired markers was not refused")
|
||||
}
|
||||
h, held := state.HeldAt(namesID)
|
||||
if !held || h.Kept != "/var/lib/mesh/kept/hosts" {
|
||||
t.Errorf("a failed write released the hold: %+v", state.Held)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// A take is a comparison (novox/hq ADR 0163): while a module's container is held, the host reports
|
||||
// the found image and its age beside the declared one, the networks and who else is on them, the
|
||||
// mounts and the ports — and says when the declared image is the older.
|
||||
func TestAHeldContainerCarriesTheFactsATakeCompares(t *testing.T) {
|
||||
dir, page, m := predecessor(t)
|
||||
m.containers["hello-web"].image = "web:1.27"
|
||||
m.containers["hello-web"].imageID = "sha256:found"
|
||||
m.containers["hello-web"].networks = []string{"predecessor_default"}
|
||||
m.containers["hello-web"].mounts = []string{"/srv/web:/data"}
|
||||
m.containers["hello-web"].ports = []string{"80/tcp>0.0.0.0:8080"}
|
||||
m.images = map[string]string{"sha256:found": "2026-09-17T10:00:00Z", pinned: "2026-08-20T10:00:00Z"}
|
||||
m.members = map[string][]string{"predecessor_default": {"hello-web", "office", "db"}}
|
||||
|
||||
_, state := applyAdopted(t, adopted(t, untakenWeb, webResources(page)), store.State{}, m, dir)
|
||||
h, ok := state.HeldAt("hello-web.server")
|
||||
if !ok || h.Facts == nil {
|
||||
t.Fatalf("a held container carries no facts: %+v", h)
|
||||
}
|
||||
f := h.Facts
|
||||
if f.Image != "web:1.27" || f.ImageCreated != "2026-09-17T10:00:00Z" {
|
||||
t.Errorf("the found image and its age: %+v", f)
|
||||
}
|
||||
if f.DeclaredImage != pinned || f.DeclaredImageCreated != "2026-08-20T10:00:00Z" || !f.Downgrade {
|
||||
t.Errorf("the declared image, its age, and that it is a downgrade: %+v", f)
|
||||
}
|
||||
if got := f.Networks["predecessor_default"]; len(got) != 2 || got[0] != "db" || got[1] != "office" {
|
||||
t.Errorf("the neighbours on the found network, without the container itself: %v", f.Networks)
|
||||
}
|
||||
if len(f.Mounts) != 1 || f.Mounts[0] != "/srv/web:/data" || len(f.Ports) != 1 || f.Ports[0] != "80/tcp>0.0.0.0:8080" {
|
||||
t.Errorf("mounts and ports as found: %+v", f)
|
||||
}
|
||||
// And the held file carries how the declared content differs from what was found.
|
||||
p, ok := state.HeldAt("hello-web.page")
|
||||
if !ok || p.Facts == nil || !p.Facts.Differs {
|
||||
t.Fatalf("a held file that differs from the declared content does not say so: %+v", p)
|
||||
}
|
||||
joined := strings.Join(p.Facts.Difference, "\n")
|
||||
if !strings.Contains(joined, "- the predecessor's page") || !strings.Contains(joined, "+ the mesh's page") {
|
||||
t.Errorf("the difference does not show what is lost and what is new: %q", joined)
|
||||
}
|
||||
_ = os.Remove(filepath.Join(dir, "unused"))
|
||||
}
|
||||
|
||||
// A declared image not yet on the machine leaves its age unknown and the comparison undecided.
|
||||
func TestAnImageNotYetPulledLeavesTheDowngradeUndecided(t *testing.T) {
|
||||
dir, page, m := predecessor(t)
|
||||
m.containers["hello-web"].image = "web:1.27"
|
||||
m.containers["hello-web"].imageID = "sha256:found"
|
||||
m.images = map[string]string{"sha256:found": "2026-09-17T10:00:00Z"}
|
||||
_, state := applyAdopted(t, adopted(t, untakenWeb, webResources(page)), store.State{}, m, dir)
|
||||
h, _ := state.HeldAt("hello-web.server")
|
||||
if h.Facts == nil || h.Facts.DeclaredImageCreated != "" || h.Facts.Downgrade {
|
||||
t.Fatalf("an unknown declared age decided a downgrade: %+v", h.Facts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDifferenceIsWhatIsLostAndWhatIsNew(t *testing.T) {
|
||||
differs, lines := differenceOf("a\nprivate scope: local\nb\n", "a\nb\nupstream: public\n")
|
||||
if !differs || len(lines) != 2 || lines[0] != "- private scope: local" || lines[1] != "+ upstream: public" {
|
||||
t.Fatalf("got %v %v", differs, lines)
|
||||
}
|
||||
if differs, lines := differenceOf("same\n", "same\n"); differs || lines != nil {
|
||||
t.Fatalf("identical content differs: %v %v", differs, lines)
|
||||
}
|
||||
}
|
||||
|
||||
// What runs on the machine that the mesh neither wrote nor holds is reported (ADR 0163).
|
||||
func TestStraysAreWhatRunsHereThatNobodyAsked(t *testing.T) {
|
||||
m := &machine{containers: map[string]*fakeContainer{
|
||||
"hello-web": {id: "ours", running: true, image: "web:1"},
|
||||
"gitea-old": {id: "left-behind", running: true, image: "gitea:1.22"},
|
||||
"held-thing": {id: "found", running: true, image: "x:1"},
|
||||
}}
|
||||
known := store.State{
|
||||
Resources: []store.Applied{{ID: "hello-web.server", Type: "container", Target: "hello-web"}},
|
||||
Held: []store.Held{{ID: "other.server", Kind: "container", Target: "held-thing"}},
|
||||
}
|
||||
strays, err := Strays(context.Background(), m.run, known)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(strays) != 1 || strays[0].Name != "gitea-old" || !strings.Contains(strays[0].Detail, "gitea:1.22") {
|
||||
t.Fatalf("strays: %+v", strays)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// The host's own former archive stops nothing (novox/hq issue 194). A new host's first apply finds
|
||||
// the version before it as a former target of the archive that delivered it; an archive has no
|
||||
// removal (issue 162), and the refusal stopped every machine applying anything. A former target of
|
||||
// such a kind is left in place, said, and forgotten. An archive the declaration dropped still fails,
|
||||
// as 162 has it.
|
||||
func TestTheHostsOwnFormerArchiveIsLeftInPlaceNotFatal(t *testing.T) {
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
if name == "docker" && args[0] == "info" {
|
||||
return "29.0.0\n", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
dir := t.TempDir()
|
||||
former := store.FormerID("mesh-host.next", dir+"/versions/old")
|
||||
known := store.State{Resources: []store.Applied{
|
||||
{ID: "mesh-host.next", Type: "archive", Target: dir + "/versions/new", Origin: store.OriginDeclared},
|
||||
{ID: former, Type: "archive", Target: dir + "/versions/old", Origin: store.OriginDeclared},
|
||||
}}
|
||||
body, digest := anArchive(t, map[string]string{"nox-mesh-host": "#!/bin/sh\n"})
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"mesh-host.next","type":"archive","path":"`+dir+`/versions/new","source":"`+serving(t, body)+`","digest":"`+digest+`"},
|
||||
{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"x"}
|
||||
]}`)
|
||||
report, state, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("the apply failed: %v", err)
|
||||
}
|
||||
if _, still := state.HeldAt(former); still {
|
||||
t.Fatal("held?")
|
||||
}
|
||||
for _, r := range state.Resources {
|
||||
if r.ID == former {
|
||||
t.Fatal("the former archive is still on record")
|
||||
}
|
||||
}
|
||||
said := false
|
||||
for _, o := range report.Outcomes {
|
||||
if o.ID == former && o.Action == "forgotten" && strings.Contains(o.Detail, "left in place") {
|
||||
said = true
|
||||
}
|
||||
}
|
||||
if !said {
|
||||
t.Fatalf("leaving the former archive was not said: %+v", report.Outcomes)
|
||||
}
|
||||
applied := false
|
||||
for _, o := range report.Outcomes {
|
||||
if o.ID == "notes.conf" && o.Action == "created" {
|
||||
applied = true
|
||||
}
|
||||
}
|
||||
if !applied {
|
||||
t.Fatalf("the rest of the declaration was not applied: %+v", report.Outcomes)
|
||||
}
|
||||
|
||||
// An archive the declaration dropped is a different matter: nothing can undo it, and saying
|
||||
// it was would report an effect the host declined to have (issue 162).
|
||||
dropped := store.State{Resources: []store.Applied{
|
||||
{ID: "tool.next", Type: "archive", Target: "/usr/lib/tool/versions/old", Origin: store.OriginDeclared},
|
||||
}}
|
||||
only := parse(t, `{"declaration":1,"resources":[{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"x"}]}`)
|
||||
if _, _, err := Apply(context.Background(), archHost(t), only, dropped, store.OriginDeclared, run, nil, nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "no way to remove") {
|
||||
t.Fatalf("a dropped archive was passed over: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0118: undeclaring removes what the mesh made, gives back what it changed,
|
||||
// and leaves what was the machine's — which needs what was found kept exactly, and a unit the mesh
|
||||
// made known for the mesh's whatever its record says.
|
||||
|
||||
func unitsMachine(units map[string]*fakeUnit) *machine {
|
||||
return &machine{containers: map[string]*fakeContainer{}, units: units}
|
||||
}
|
||||
|
||||
// nothingButA is a declaration of one unrelated file, so everything recorded is undeclared.
|
||||
func nothingButA(t *testing.T) string {
|
||||
return `{"declaration":1,"resources":[
|
||||
{"id":"other","type":"file","path":"` + filepath.Join(t.TempDir(), "a") + `","content":"a\n"}]}`
|
||||
}
|
||||
|
||||
// copyOf is a state as a later load of it would be: sharing nothing with the one it came from.
|
||||
func copyOf(t *testing.T, s store.State) store.State {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out store.State
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyOn(t *testing.T, raw string, known store.State, m *machine) (Report, store.State, error) {
|
||||
t.Helper()
|
||||
return Apply(context.Background(), archHost(t), parse(t, raw), known, store.OriginDeclared, m.run, nil, nil)
|
||||
}
|
||||
|
||||
func TestAUnitWhoseFileTheMeshWroteIsStoppedWhateverItsRecordSays(t *testing.T) {
|
||||
// The adoption guard's unit file is the mesh's own file resource, written where there was none.
|
||||
// Its service recorded before the host kept what it found has no Found, and forgetting it left
|
||||
// the guard's table loaded on a converged node and its unit file deleted from under it.
|
||||
units := t.TempDir()
|
||||
was := unitDir
|
||||
unitDir = units
|
||||
t.Cleanup(func() { unitDir = was })
|
||||
unitFile := filepath.Join(units, "mesh-guard.service")
|
||||
if err := os.WriteFile(unitFile, []byte("[Unit]\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := unitsMachine(map[string]*fakeUnit{"mesh-guard.service": {active: "active", enabled: "enabled"}})
|
||||
fileThereAtStop := false
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
if name == "systemctl" && args[0] == "stop" {
|
||||
_, err := os.Stat(unitFile)
|
||||
fileThereAtStop = err == nil
|
||||
}
|
||||
return m.run(ctx, name, args...)
|
||||
}
|
||||
// As a host before this change recorded them: the unit file first, then the service it
|
||||
// starts, and no Found on the service.
|
||||
known := store.State{Resources: []store.Applied{
|
||||
{ID: "adoption.guard-unit", Type: "file", Target: unitFile, Origin: store.OriginDeclared},
|
||||
{ID: "adoption.guard-running", Type: "service", Target: "mesh-guard.service", Origin: store.OriginDeclared},
|
||||
}}
|
||||
report, after, err := applyWith(t, parse(t, nothingButA(t)), known, run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u := m.units["mesh-guard.service"]; u.active != "inactive" || u.enabled != "disabled" {
|
||||
t.Errorf("the mesh's own unit was left %s and %s: %v", u.active, u.enabled, m.asked)
|
||||
}
|
||||
if !fileThereAtStop {
|
||||
t.Error("the unit was stopped after its file was deleted, or not at all")
|
||||
}
|
||||
if o := outcomeOf(report, "adoption.guard-running"); o.Action != "removed" || !strings.Contains(o.Detail, "unit file") {
|
||||
t.Errorf("outcome %+v", o)
|
||||
}
|
||||
if _, err := os.Stat(unitFile); !os.IsNotExist(err) {
|
||||
t.Error("the unit file outlived its record")
|
||||
}
|
||||
if len(after.Resources) != 1 {
|
||||
t.Errorf("still recorded: %v", after.IDs())
|
||||
}
|
||||
|
||||
// A unit file the host wrote OVER — its original kept — is the machine's unit, and a record
|
||||
// with no Found leaves it as it is.
|
||||
if err := os.WriteFile(unitFile, []byte("[Unit]\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m = unitsMachine(map[string]*fakeUnit{"mesh-guard.service": {active: "active", enabled: "enabled"}})
|
||||
known.Resources[0].Kept = filepath.Join(t.TempDir(), "original")
|
||||
if err := os.WriteFile(known.Resources[0].Kept, []byte("[Unit]\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := applyWith(t, parse(t, nothingButA(t)), known, m.run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.did("systemctl stop") || m.did("systemctl disable") {
|
||||
t.Errorf("a unit whose file the mesh only wrote over was stopped: %v", m.asked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyAUnitsOwnFileTheMeshCreatedMakesItTheMeshs(t *testing.T) {
|
||||
known := store.State{Resources: []store.Applied{
|
||||
{ID: "a", Type: "file", Target: "/etc/systemd/system/made.service"},
|
||||
{ID: "b", Type: "file", Target: "/run/systemd/system/runtime.service"},
|
||||
{ID: "c", Type: "file", Target: "/etc/systemd/system/kept.service", Kept: "/var/lib/mesh-host/kept/x"},
|
||||
{ID: "d", Type: "file", Target: "/etc/systemd/system/into.service", Into: &store.Into{Format: "block"}},
|
||||
{ID: "e", Type: "file", Target: "/etc/systemd/system/docker.service.d/mesh.conf"},
|
||||
{ID: "f", Type: "file", Target: "/etc/mesh/elsewhere.service"},
|
||||
{ID: "g", Type: "directory", Target: "/etc/systemd/system/dir.service"},
|
||||
}}
|
||||
made := meshMadeUnits(known)
|
||||
for unit, want := range map[string]bool{"made.service": true, "runtime.service": true, "kept.service": false,
|
||||
"into.service": false, "mesh.conf": false, "docker.service.d": false, "elsewhere.service": false, "dir.service": false} {
|
||||
if made[unit] != want {
|
||||
t.Errorf("%s: made %v, want %v", unit, made[unit], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatWasFoundOutlivesAFirstApplyThatFailed(t *testing.T) {
|
||||
// Enabled, then it would not start: no record. The next apply must not read the enable as
|
||||
// the machine's — or undeclaring leaves enabled a unit the mesh enabled.
|
||||
m := unitsMachine(map[string]*fakeUnit{"filter.service": {active: "inactive", enabled: "disabled", wontStart: true}})
|
||||
declared := `{"declaration":1,"resources":[
|
||||
{"id":"s","type":"service","unit":"filter.service","state":"running","boot":"enabled"}]}`
|
||||
_, first, err := applyOn(t, declared, store.State{}, m)
|
||||
if err == nil || !m.did("systemctl enable filter.service") {
|
||||
t.Fatalf("the fixture did not enable and then fail: %v, %v", err, m.asked)
|
||||
}
|
||||
if _, ok := first.Find("s"); ok {
|
||||
t.Fatal("a failed apply was recorded")
|
||||
}
|
||||
if p := first.FoundFirst["s"]; p.State != "stopped" || p.Boot != "disabled" || p.Unit != "filter.service" {
|
||||
t.Fatalf("what was found was not kept through the failure: %+v", first.FoundFirst)
|
||||
}
|
||||
|
||||
failed := copyOf(t, first)
|
||||
|
||||
m.units["filter.service"].wontStart = false
|
||||
_, second, err := applyOn(t, declared, first, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec, _ := second.Find("s"); rec.Found == nil || rec.Found.State != "stopped" || rec.Found.Boot != "disabled" {
|
||||
t.Errorf("the record carries the mesh's own effect as found: %+v", rec.Found)
|
||||
}
|
||||
if second.FoundFirst != nil {
|
||||
t.Errorf("kept apart after the record carried it: %+v", second.FoundFirst)
|
||||
}
|
||||
|
||||
if _, _, err := applyOn(t, nothingButA(t), second, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u := m.units["filter.service"]; u.active != "inactive" || u.enabled != "disabled" {
|
||||
t.Errorf("undeclared, the unit was left %s and %s", u.active, u.enabled)
|
||||
}
|
||||
|
||||
// Undeclared before it was ever recorded, what was found goes with it — only for its origin.
|
||||
if _, kept, _ := Apply(context.Background(), archHost(t), parse(t, nothingButA(t)), copyOf(t, failed),
|
||||
store.OriginCarried, m.run, nil, nil); kept.FoundFirst["s"].State == "" {
|
||||
t.Error("a carried apply dropped what the mesh's declaration found")
|
||||
}
|
||||
if _, dropped, err := applyOn(t, nothingButA(t), copyOf(t, failed), m); err != nil || dropped.FoundFirst != nil {
|
||||
t.Errorf("kept for a service no longer declared: %+v, %v", dropped.FoundFirst, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootIsFoundTheFirstTimeTheMeshSetsIt(t *testing.T) {
|
||||
// The declaration said nothing about boot at first, so the mesh never touched it: what is
|
||||
// there when a declaration first does is still the machine's.
|
||||
m := unitsMachine(map[string]*fakeUnit{"web.service": {active: "active", enabled: "disabled"}})
|
||||
known := store.State{Resources: []store.Applied{{ID: "s", Type: "service", Target: "web.service",
|
||||
Origin: store.OriginDeclared, Found: &store.FoundUnit{Unit: "web.service", State: "running"}}}}
|
||||
_, after, err := applyOn(t, `{"declaration":1,"resources":[
|
||||
{"id":"s","type":"service","unit":"web.service","state":"running","boot":"enabled"}]}`, known, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, _ := after.Find("s")
|
||||
if rec.Found == nil || rec.Found.State != "running" || rec.Found.Boot != "disabled" {
|
||||
t.Fatalf("found %+v, want running and disabled", rec.Found)
|
||||
}
|
||||
report, _, err := applyOn(t, nothingButA(t), after, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u := m.units["web.service"]; u.active != "active" || u.enabled != "disabled" {
|
||||
t.Errorf("undeclared, the unit is %s and %s; want running, and disabled again", u.active, u.enabled)
|
||||
}
|
||||
if o := outcomeOf(report, "s"); o.Action != "restored" || o.Detail != "disabled at boot, as the host found it" {
|
||||
t.Errorf("outcome %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAServiceOnceDeclaredWithNoStateIsFoundWhenFirstGivenOne(t *testing.T) {
|
||||
// Declared with no state (novox/hq ADR 0117), the mesh never started or stopped it — so what
|
||||
// is there when a declaration first gives it one is what the machine had.
|
||||
m := unitsMachine(map[string]*fakeUnit{"net.service": {active: "inactive", enabled: "disabled"}})
|
||||
known := store.State{Resources: []store.Applied{{ID: "s", Type: "service", Target: "net.service",
|
||||
Origin: store.OriginDeclared, Stateless: true}}}
|
||||
_, after, err := applyOn(t, `{"declaration":1,"resources":[
|
||||
{"id":"s","type":"service","unit":"net.service","state":"running"}]}`, known, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec, _ := after.Find("s"); rec.Found == nil || rec.Found.State != "stopped" {
|
||||
t.Fatalf("found %+v, want stopped", rec.Found)
|
||||
}
|
||||
if _, _, err := applyOn(t, nothingButA(t), after, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.units["net.service"].active != "inactive" {
|
||||
t.Error("the unit the mesh started outlived its declaration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAUnitWrittenInTheSameApplyIsLoadedBeforeItIsRead(t *testing.T) {
|
||||
// Read before the service manager is told about its new file, the unit is not there to find.
|
||||
dir := t.TempDir()
|
||||
m := unitsMachine(map[string]*fakeUnit{"fresh.service": {active: "inactive", enabled: "disabled"}})
|
||||
_, _, err := applyOn(t, `{"declaration":1,"resources":[
|
||||
{"id":"unit","type":"file","path":"`+filepath.Join(dir, "fresh.service")+`","content":"[Unit]\n"},
|
||||
{"id":"s","type":"service","unit":"fresh.service","state":"running","restart-on":["unit"]}]}`,
|
||||
store.State{}, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reload, show := -1, -1
|
||||
for i, a := range m.asked {
|
||||
if a == "systemctl daemon-reload" && reload < 0 {
|
||||
reload = i
|
||||
}
|
||||
if strings.HasPrefix(a, "systemctl show fresh.service") && show < 0 {
|
||||
show = i
|
||||
}
|
||||
}
|
||||
if reload < 0 || show < 0 || reload > show {
|
||||
t.Errorf("the unit was read before its file was loaded: %v", m.asked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAServiceMovedToAnotherUnitGivesTheOldOneBackAndFindsTheNewOne(t *testing.T) {
|
||||
m := unitsMachine(map[string]*fakeUnit{
|
||||
"old.service": {active: "active", enabled: "enabled"},
|
||||
"new.service": {active: "inactive", enabled: "disabled"},
|
||||
})
|
||||
known := store.State{Resources: []store.Applied{{ID: "s", Type: "service", Target: "old.service",
|
||||
Origin: store.OriginDeclared, Found: &store.FoundUnit{Unit: "old.service", State: "stopped"}}}}
|
||||
report, after, err := applyOn(t, `{"declaration":1,"resources":[
|
||||
{"id":"s","type":"service","unit":"new.service","state":"running"}]}`, known, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.units["old.service"].active != "inactive" {
|
||||
t.Error("the unit the mesh started is still running though nothing declares it")
|
||||
}
|
||||
if m.units["new.service"].active != "active" {
|
||||
t.Error("the unit now declared was not started")
|
||||
}
|
||||
rec, _ := after.Find("s")
|
||||
if rec.Found == nil || rec.Found.Unit != "new.service" || rec.Found.State != "stopped" {
|
||||
t.Errorf("what was found about the old unit was carried to the new one: %+v", rec.Found)
|
||||
}
|
||||
if o := outcomeOf(report, "s"); !strings.Contains(o.Detail, "old.service stopped, as the host found it") {
|
||||
t.Errorf("giving the old unit back went unsaid: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARemovalSaysWhatItDid(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
found *store.FoundUnit
|
||||
unit *fakeUnit
|
||||
action, detail string
|
||||
}{
|
||||
{"found stopped and still stopped", &store.FoundUnit{State: "stopped"},
|
||||
&fakeUnit{active: "inactive", enabled: "disabled"}, "forgotten", "already as the host found it (stopped)"},
|
||||
{"started by the mesh", &store.FoundUnit{State: "stopped"},
|
||||
&fakeUnit{active: "active", enabled: "disabled"}, "restored", "stopped, as the host found it"},
|
||||
{"started and enabled by the mesh", &store.FoundUnit{State: "stopped", Boot: "disabled"},
|
||||
&fakeUnit{active: "active", enabled: "enabled"}, "restored", "stopped, disabled at boot, as the host found it"},
|
||||
{"found running, stopped by the mesh", &store.FoundUnit{State: "running"},
|
||||
&fakeUnit{active: "inactive", enabled: "disabled"}, "forgotten",
|
||||
"left as it is; the mesh stopped it and does not start anything on the way out"},
|
||||
{"found running and enabled, disabled by the mesh", &store.FoundUnit{State: "running", Boot: "enabled"},
|
||||
&fakeUnit{active: "active", enabled: "disabled"}, "forgotten",
|
||||
"left as it is; the mesh disabled it at boot and does not start anything on the way out"},
|
||||
{"found running, still running", &store.FoundUnit{State: "running"},
|
||||
&fakeUnit{active: "active", enabled: "enabled"}, "forgotten", "it was running before the mesh; left as it is"},
|
||||
// Found says to stop it, so the machine is asked about it — and it is gone.
|
||||
{"started by the mesh, since uninstalled", &store.FoundUnit{State: "stopped"},
|
||||
nil, "forgotten", "the unit no longer exists"},
|
||||
{"recorded before the host kept what it found", nil,
|
||||
&fakeUnit{active: "active", enabled: "enabled"}, "forgotten", "recorded before the host kept what it found; left as it is"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
units := map[string]*fakeUnit{}
|
||||
if c.unit != nil {
|
||||
units["unit.service"] = c.unit
|
||||
}
|
||||
m := unitsMachine(units)
|
||||
known := store.State{Resources: []store.Applied{{ID: "s", Type: "service", Target: "unit.service",
|
||||
Origin: store.OriginDeclared, Found: c.found}}}
|
||||
report, after, err := applyOn(t, nothingButA(t), known, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o := outcomeOf(report, "s"); o.Action != c.action || o.Detail != c.detail {
|
||||
t.Errorf("said %s: %s; want %s: %s", o.Action, o.Detail, c.action, c.detail)
|
||||
}
|
||||
if c.unit == nil && !m.did("systemctl show unit.service") {
|
||||
t.Errorf("the machine was never asked whether the unit is there: %v", m.asked)
|
||||
}
|
||||
if m.did("systemctl start") || m.did("systemctl enable") {
|
||||
t.Errorf("something was started on the way out: %v", m.asked)
|
||||
}
|
||||
if _, still := after.Find("s"); still {
|
||||
t.Error("still recorded")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAUnitTheMeshStartedIsStoppedWhenUndeclaredAndOneFoundRunningIsNot(t *testing.T) {
|
||||
// End to end: found by the first apply, carried, given back.
|
||||
m := unitsMachine(map[string]*fakeUnit{
|
||||
"filter.service": {active: "inactive", enabled: "disabled"},
|
||||
"runtime.service": {active: "active", enabled: "enabled"},
|
||||
})
|
||||
_, state, err := applyOn(t, `{"declaration":1,"resources":[
|
||||
{"id":"filter","type":"service","unit":"filter.service","state":"running","boot":"enabled"},
|
||||
{"id":"runtime","type":"service","unit":"runtime.service","state":"running","boot":"enabled"}]}`,
|
||||
store.State{}, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.units["filter.service"].active != "active" {
|
||||
t.Fatal("the fixture did not start the filter")
|
||||
}
|
||||
if _, _, err := applyOn(t, nothingButA(t), state, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u := m.units["filter.service"]; u.active != "inactive" || u.enabled != "disabled" {
|
||||
t.Errorf("the filter the mesh started and enabled is %s and %s", u.active, u.enabled)
|
||||
}
|
||||
if u := m.units["runtime.service"]; u.active != "active" || u.enabled != "enabled" {
|
||||
t.Errorf("the runtime that was running before the mesh is %s and %s", u.active, u.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAUnitHeldAndThenTakenIsFoundAsThePredecessorLeftIt(t *testing.T) {
|
||||
// Held while its module was untaken, nothing was applied and nothing found; taken, the first
|
||||
// apply finds the predecessor's unit — stopped, but started at boot — before starting it.
|
||||
dir := t.TempDir()
|
||||
m := unitsMachine(map[string]*fakeUnit{"hello.service": {active: "inactive", enabled: "enabled"}})
|
||||
service := `{"id":"hello-web.unit","type":"service","unit":"hello.service","state":"running","boot":"enabled"}`
|
||||
_, held := applyAdopted(t, adopted(t, untaken("hello-web.unit"), service), store.State{}, m, dir)
|
||||
if _, ok := held.HeldAt("hello-web.unit"); !ok {
|
||||
t.Fatal("the fixture's unit was not held")
|
||||
}
|
||||
_, taken := applyAdopted(t, adopted(t, `{"taken":["hello-web"]}`, service), held, m, dir)
|
||||
rec, _ := taken.Find("hello-web.unit")
|
||||
if rec.Found == nil || rec.Found.State != "stopped" || rec.Found.Boot != "enabled" {
|
||||
t.Fatalf("found %+v, want the predecessor's stopped and enabled", rec.Found)
|
||||
}
|
||||
if m.units["hello.service"].active != "active" {
|
||||
t.Fatal("the taken unit was not started")
|
||||
}
|
||||
if _, _, err := applyOn(t, nothingButA(t), taken, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u := m.units["hello.service"]; u.active != "inactive" || u.enabled != "enabled" {
|
||||
t.Errorf("given back as %s and %s; the predecessor left it stopped and enabled", u.active, u.enabled)
|
||||
}
|
||||
}
|
||||
+153
-10
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -108,7 +109,7 @@ func lookBefore(ctx context.Context, sys system.System, d *declaration.Declarati
|
||||
}
|
||||
}
|
||||
case *declaration.Service:
|
||||
if known.Recorded(string(declaration.TypeService), res.Unit) {
|
||||
if res.Stateless() || known.Recorded(string(declaration.TypeService), res.Unit) {
|
||||
continue
|
||||
}
|
||||
// **Found is a unit somebody put on this machine, or one the machine uses.**
|
||||
@@ -261,6 +262,19 @@ func heldContainer(known store.State, name string) (store.Held, bool) {
|
||||
return store.Held{}, false
|
||||
}
|
||||
|
||||
// replacesNothing is a resource that takes nothing found on the machine from it, so on an adopted
|
||||
// node it is never held and never previewed as replacing what was found: a file written into
|
||||
// (novox/hq ADR 0102), and a service whose unit's lifecycle is the machine's (novox/hq ADR 0117).
|
||||
func replacesNothing(r declaration.Resource) bool {
|
||||
switch res := r.(type) {
|
||||
case *declaration.File:
|
||||
return res.Into != ""
|
||||
case *declaration.Service:
|
||||
return res.Stateless()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// holdOnAdopted decides whether a resource of an adopted node is held rather than applied, and
|
||||
// holds it (novox/hq ADR 0100, ADR 0103). For a module not yet taken, what is present with no
|
||||
// record is kept as it is: a file or a container under its name, a directory, a service's unit,
|
||||
@@ -295,11 +309,11 @@ func holdOnAdopted(ctx context.Context, sys system.System, r declaration.Resourc
|
||||
}
|
||||
|
||||
// A file written into replaces nothing that was found, so it is never held (novox/hq ADR
|
||||
// 0102) — and a hold from when it was declared whole must not keep the mesh's keys out.
|
||||
if f, ok := r.(*declaration.File); ok && f.Into != "" {
|
||||
if already {
|
||||
known.Release(r.Identity())
|
||||
}
|
||||
// 0102) — and a hold from when it was declared whole must not keep the mesh's keys out. That
|
||||
// hold is released by the apply once the write has worked, not here: a write that fails keeps
|
||||
// it, and with it where the original was kept. A service whose lifecycle is the machine's
|
||||
// replaces nothing either (novox/hq ADR 0117).
|
||||
if replacesNothing(r) {
|
||||
return false, false, out, nil
|
||||
}
|
||||
|
||||
@@ -420,17 +434,51 @@ type foundContainer struct {
|
||||
id string
|
||||
running bool
|
||||
spec string
|
||||
// What a take compares (novox/hq ADR 0163): the image and its id, the networks the container
|
||||
// is on, its mounts and its published ports — empty from a runtime (or a test's fake) that
|
||||
// answers the short form.
|
||||
image string
|
||||
imageID string
|
||||
networks []string
|
||||
mounts []string
|
||||
ports []string
|
||||
}
|
||||
|
||||
// foundFormat is what inspectFound asks the runtime for, tab-separated: the three a hold has
|
||||
// always needed, then the facts a take compares.
|
||||
const foundFormat = "{{.Id}}\t{{.State.Running}}\t{{index .Config.Labels \"" + specLabel + "\"}}" +
|
||||
"\t{{.Config.Image}}\t{{.Image}}" +
|
||||
"\t{{range $k, $v := .NetworkSettings.Networks}}{{$k}},{{end}}" +
|
||||
"\t{{range .Mounts}}{{.Source}}:{{.Destination}},{{end}}" +
|
||||
"\t{{range $p, $b := .NetworkSettings.Ports}}{{$p}}{{range $b}}>{{.HostIp}}:{{.HostPort}}{{end}},{{end}}"
|
||||
|
||||
func splitList(s string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// inspectFound reads a container by name the way a hold needs it: its id, whether it runs, and
|
||||
// whether a host made it.
|
||||
//
|
||||
// **`container inspect`, not the bare form.** A name is not unique across object kinds — a
|
||||
// module regularly names a network the same as the container that joins it (`keycloak` names
|
||||
// both, and it is ordinary). The bare form resolves across every kind and returns whichever it
|
||||
// finds, so a container that does not exist yet but a same-named network does answers with the
|
||||
// network's JSON — no `.State` field at all — and the template below fails to execute rather
|
||||
// than failing to find anything. That reads as "the runtime could not say", which this function's
|
||||
// caller correctly refuses to build on (novox/hq ADR 0100) — but there was something to say, a
|
||||
// question of kind, not of ambiguity that should have stopped anything.
|
||||
func inspectFound(ctx context.Context, name string, run Runner) (foundContainer, bool, error) {
|
||||
cri, err := containerRuntime(ctx, run)
|
||||
if err != nil {
|
||||
return foundContainer{}, false, fmt.Errorf("%w, so nothing can be said about %q", err, name)
|
||||
}
|
||||
out, err := run(ctx, cri, "inspect", "--format",
|
||||
"{{.Id}}\t{{.State.Running}}\t{{index .Config.Labels \""+specLabel+"\"}}", name)
|
||||
out, err := run(ctx, cri, "container", "inspect", "--format", foundFormat, name)
|
||||
if err != nil {
|
||||
if absent(err) {
|
||||
return foundContainer{}, false, nil
|
||||
@@ -444,14 +492,100 @@ func inspectFound(ctx context.Context, name string, run Runner) (foundContainer,
|
||||
name, err)
|
||||
}
|
||||
parts := strings.Split(strings.TrimSpace(out), "\t")
|
||||
for len(parts) < 3 {
|
||||
for len(parts) < 8 {
|
||||
parts = append(parts, "")
|
||||
}
|
||||
spec := strings.TrimSpace(parts[2])
|
||||
if spec == "<no value>" {
|
||||
spec = ""
|
||||
}
|
||||
return foundContainer{id: strings.TrimSpace(parts[0]), running: parts[1] == "true", spec: spec}, true, nil
|
||||
return foundContainer{id: strings.TrimSpace(parts[0]), running: parts[1] == "true", spec: spec,
|
||||
image: strings.TrimSpace(parts[3]), imageID: strings.TrimSpace(parts[4]),
|
||||
networks: splitList(parts[5]), mounts: splitList(parts[6]), ports: splitList(parts[7])}, true, nil
|
||||
}
|
||||
|
||||
// factsOf is what a take would compare for a found container (novox/hq ADR 0163): the found
|
||||
// image and when it was made, the networks and who else is on them, mounts and ports — beside
|
||||
// what the module declares, and the declared image's date when that image is on the machine.
|
||||
// Every question the runtime cannot answer leaves its fact empty; a preview says so rather than
|
||||
// guesses.
|
||||
func factsOf(ctx context.Context, seen foundContainer, res *declaration.Container, run Runner) *Facts {
|
||||
cri, err := containerRuntime(ctx, run)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
f := &store.Facts{Image: seen.image, Mounts: seen.mounts, Ports: seen.ports,
|
||||
DeclaredImage: res.Image, DeclaredPorts: res.Ports, DeclaredVolumes: res.Volumes}
|
||||
if seen.imageID != "" {
|
||||
if out, err := run(ctx, cri, "image", "inspect", "--format", "{{.Created}}", seen.imageID); err == nil {
|
||||
f.ImageCreated = strings.TrimSpace(out)
|
||||
}
|
||||
}
|
||||
if res.Image != "" {
|
||||
if out, err := run(ctx, cri, "image", "inspect", "--format", "{{.Created}}", res.Image); err == nil {
|
||||
f.DeclaredImageCreated = strings.TrimSpace(out)
|
||||
}
|
||||
}
|
||||
if found, err := time.Parse(time.RFC3339Nano, f.ImageCreated); err == nil {
|
||||
if declared, err := time.Parse(time.RFC3339Nano, f.DeclaredImageCreated); err == nil {
|
||||
f.Downgrade = declared.Before(found)
|
||||
}
|
||||
}
|
||||
for _, network := range seen.networks {
|
||||
if f.Networks == nil {
|
||||
f.Networks = map[string][]string{}
|
||||
}
|
||||
var members []string
|
||||
if out, err := run(ctx, cri, "network", "inspect", "--format",
|
||||
"{{range .Containers}}{{.Name}},{{end}}", network); err == nil {
|
||||
for _, m := range splitList(out) {
|
||||
if m != res.Name {
|
||||
members = append(members, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(members)
|
||||
f.Networks[network] = members
|
||||
}
|
||||
return (*Facts)(f)
|
||||
}
|
||||
|
||||
// Facts is store.Facts, named here so hold's callers read as one vocabulary.
|
||||
type Facts = store.Facts
|
||||
|
||||
// differenceOf is how a found file differs from the declared content: the lines only the found
|
||||
// file has, marked -, then the lines only the declared content has, marked +, in their own order,
|
||||
// bounded so a report stays a report. Not a diff tool's output: the question a take answers is
|
||||
// "what would be lost and what would be new", and that is these two lists.
|
||||
func differenceOf(found, declared string) (bool, []string) {
|
||||
if found == declared {
|
||||
return false, nil
|
||||
}
|
||||
const bound = 40
|
||||
count := func(s string) map[string]int {
|
||||
out := map[string]int{}
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
out[line]++
|
||||
}
|
||||
return out
|
||||
}
|
||||
inFound, inDeclared := count(found), count(declared)
|
||||
var out []string
|
||||
add := func(mark, s string, other map[string]int) {
|
||||
seen := map[string]int{}
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
seen[line]++
|
||||
if seen[line] > other[line] && len(out) < bound {
|
||||
out = append(out, mark+" "+line)
|
||||
}
|
||||
}
|
||||
}
|
||||
add("-", found, inDeclared)
|
||||
add("+", declared, inFound)
|
||||
if len(out) >= bound {
|
||||
out = append(out, "… and more")
|
||||
}
|
||||
return true, out
|
||||
}
|
||||
|
||||
// absent is whether a runtime said the thing is not there, rather than failing to answer. Its own
|
||||
@@ -518,6 +652,12 @@ func hold(ctx context.Context, sys system.System, r declaration.Resource, module
|
||||
} else if digestOf(string(content)) != h.Digest {
|
||||
changed = "rewritten"
|
||||
}
|
||||
// What a take would replace it with, and how that differs (novox/hq ADR 0163): a
|
||||
// file declared whole is compared whole; one written into is not replaced at all.
|
||||
if res.Into == "" {
|
||||
differs, lines := differenceOf(string(content), res.Content)
|
||||
h.Facts = &Facts{Differs: differs, Difference: lines}
|
||||
}
|
||||
}
|
||||
case *declaration.Directory:
|
||||
info, err := os.Lstat(res.Path)
|
||||
@@ -606,6 +746,9 @@ func hold(ctx context.Context, sys system.System, r declaration.Resource, module
|
||||
case h.Running && !seen.running:
|
||||
changed = "stopped"
|
||||
}
|
||||
if exists {
|
||||
h.Facts = factsOf(ctx, seen, res, run)
|
||||
}
|
||||
default:
|
||||
return out, h, fmt.Errorf("a %s cannot be held", r.Kind())
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -18,7 +19,17 @@ import (
|
||||
// label when a host made it. Every command it is asked is written down.
|
||||
type machine struct {
|
||||
containers map[string]*fakeContainer
|
||||
asked []string
|
||||
// images is what `image inspect --format {{.Created}}` answers per image or id; members is
|
||||
// what `network inspect` lists per network (ADR 0163).
|
||||
images map[string]string
|
||||
members map[string][]string
|
||||
asked []string
|
||||
// wgUp is what `wg show interfaces` answers: the tunnels up on the machine.
|
||||
wgUp string
|
||||
// handshakes is what `wg show <interface> latest-handshakes` answers, and handshakesFail the
|
||||
// error it fails with instead — a machine with no `wg`, say (novox/hq ADR 0119).
|
||||
handshakes string
|
||||
handshakesFail error
|
||||
|
||||
// units are service units by name, as systemd would report them; volumes are the runtime's
|
||||
// named volumes.
|
||||
@@ -29,6 +40,8 @@ type machine struct {
|
||||
|
||||
type fakeUnit struct {
|
||||
active, enabled string
|
||||
// wontStart is a unit that accepts `start` and stays inactive — one that starts and dies.
|
||||
wontStart bool
|
||||
// fragment is where systemd loads the unit from; empty means /etc/systemd/system, where an
|
||||
// administrator installs one.
|
||||
fragment string
|
||||
@@ -63,7 +76,9 @@ func (m *machine) systemctl(args []string) (string, error) {
|
||||
}
|
||||
return u.enabled + "\n", nil
|
||||
case "start":
|
||||
u.active = "active"
|
||||
if !u.wontStart {
|
||||
u.active = "active"
|
||||
}
|
||||
case "stop":
|
||||
u.active = "inactive"
|
||||
case "enable":
|
||||
@@ -87,6 +102,9 @@ type fakeContainer struct {
|
||||
id string
|
||||
running bool
|
||||
spec string
|
||||
// What a take compares (ADR 0163), answered in the long inspect form when set.
|
||||
image, imageID string
|
||||
networks, mounts, ports []string
|
||||
}
|
||||
|
||||
func (m *machine) run(_ context.Context, name string, args ...string) (string, error) {
|
||||
@@ -94,6 +112,12 @@ func (m *machine) run(_ context.Context, name string, args ...string) (string, e
|
||||
if name == "systemctl" {
|
||||
return m.systemctl(args)
|
||||
}
|
||||
if name == "wg" {
|
||||
if len(args) > 0 && args[len(args)-1] == "latest-handshakes" {
|
||||
return m.handshakes, m.handshakesFail
|
||||
}
|
||||
return m.wgUp, nil
|
||||
}
|
||||
if name == "getent" {
|
||||
if m.users[args[len(args)-1]] {
|
||||
return args[len(args)-1] + ":x:1500:1500::/home/" + args[len(args)-1] + ":/bin/bash\n", nil
|
||||
@@ -111,7 +135,10 @@ func (m *machine) run(_ context.Context, name string, args ...string) (string, e
|
||||
return "", errors.New("no such volume")
|
||||
case "info":
|
||||
return "27.0\n", nil
|
||||
case "inspect":
|
||||
case "container":
|
||||
if args[1] != "inspect" {
|
||||
return "", errors.New("unexpected docker container command")
|
||||
}
|
||||
c, ok := m.containers[args[len(args)-1]]
|
||||
if !ok {
|
||||
return "", errors.New("no such container")
|
||||
@@ -120,10 +147,39 @@ func (m *machine) run(_ context.Context, name string, args ...string) (string, e
|
||||
if c.running {
|
||||
running = "true"
|
||||
}
|
||||
if strings.HasPrefix(args[2], "{{.Id}}") {
|
||||
return c.id + "\t" + running + "\t" + c.spec + "\n", nil
|
||||
if strings.HasPrefix(args[3], "{{.Id}}") {
|
||||
line := c.id + "\t" + running + "\t" + c.spec
|
||||
if c.image != "" {
|
||||
line += "\t" + c.image + "\t" + c.imageID + "\t" + strings.Join(c.networks, ",") + "," +
|
||||
"\t" + strings.Join(c.mounts, ",") + "," + "\t" + strings.Join(c.ports, ",") + ","
|
||||
}
|
||||
return line + "\n", nil
|
||||
}
|
||||
return running + "\t" + c.spec + "\n", nil
|
||||
case "image":
|
||||
if len(args) > 1 && args[1] == "inspect" {
|
||||
if created, ok := m.images[args[len(args)-1]]; ok {
|
||||
return created + "\n", nil
|
||||
}
|
||||
return "", errors.New("no such image")
|
||||
}
|
||||
return "", nil
|
||||
case "network":
|
||||
if len(args) > 1 && args[1] == "inspect" {
|
||||
return strings.Join(m.members[args[len(args)-1]], ",") + ",\n", nil
|
||||
}
|
||||
return "", nil
|
||||
case "ps":
|
||||
var lines []string
|
||||
for name, c := range m.containers {
|
||||
state := "exited"
|
||||
if c.running {
|
||||
state = "running"
|
||||
}
|
||||
lines = append(lines, name+"\t"+c.image+"\t"+state)
|
||||
}
|
||||
sort.Strings(lines)
|
||||
return strings.Join(lines, "\n") + "\n", nil
|
||||
case "rm":
|
||||
delete(m.containers, args[len(args)-1])
|
||||
return "", nil
|
||||
@@ -907,7 +963,7 @@ func TestAVolumeTheRuntimeCannotBeAskedAboutStopsTheContainer(t *testing.T) {
|
||||
return "27.0\n", nil
|
||||
case name == "docker" && args[0] == "volume":
|
||||
return "", errors.New("docker exited 1: Cannot connect to the Docker daemon")
|
||||
case name == "docker" && args[0] == "inspect":
|
||||
case name == "docker" && args[0] == "container":
|
||||
return "", errors.New("Error: No such object: hello-web")
|
||||
case name == "docker":
|
||||
return "", errors.New("docker run must not happen")
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A name is not unique across object kinds: a module regularly names a network the same as the
|
||||
// container that joins it (keycloak does this today, ordinarily). `docker inspect <name>`, unlike
|
||||
// `docker container inspect <name>`, resolves across every kind — so when the container does not
|
||||
// exist yet but a same-named network does, the bare form answers with the network's JSON instead
|
||||
// of reporting the container absent. containerState and inspectFound must ask by kind, or a
|
||||
// same-named network makes them unable to tell "not here yet" from "the runtime is broken"
|
||||
// (novox/hq ADR 0100's refusal, tripped by nothing wrong).
|
||||
//
|
||||
// dockerLikeByKind is Docker's real behaviour, not the bug: `container inspect` only ever
|
||||
// answers from the container namespace. A fake that also answered the bare, unscoped form would
|
||||
// not catch a regression back to it — this one refuses to, on purpose.
|
||||
func dockerLikeByKind(containers map[string]bool) func(context.Context, string, ...string) (string, error) {
|
||||
return func(_ context.Context, name string, args ...string) (string, error) {
|
||||
if name != "docker" {
|
||||
return "", errors.New("unexpected program: " + name)
|
||||
}
|
||||
if len(args) > 0 && args[0] == "info" {
|
||||
// containerRuntime's probe, answered so inspectFound gets past it to the check under
|
||||
// test.
|
||||
return "27.0\n", nil
|
||||
}
|
||||
if len(args) < 2 || args[0] != "container" || args[1] != "inspect" {
|
||||
return "", errors.New("unexpected command: only `docker container inspect` is modelled here")
|
||||
}
|
||||
target := args[len(args)-1]
|
||||
if !containers[target] {
|
||||
return "", errors.New("Error: No such container: " + target)
|
||||
}
|
||||
return "false\t\n", nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerStateAsksTheContainerNamespaceNotTheBareForm(t *testing.T) {
|
||||
// "minio" exists only as a network in this scenario — never in `containers` — matching the
|
||||
// live failure this guards: a module's network and its container share a name, and the
|
||||
// container does not exist yet.
|
||||
run := dockerLikeByKind(map[string]bool{"keycloak": true})
|
||||
|
||||
if _, err := containerState(context.Background(), "minio", run); err == nil {
|
||||
t.Fatal("a container that does not exist should report absent, not be mistaken for found")
|
||||
}
|
||||
if state, err := containerState(context.Background(), "keycloak", run); err != nil {
|
||||
t.Fatalf("a container that does exist should be found: %v", err)
|
||||
} else if state.Running {
|
||||
t.Errorf("the fake said not running; containerState disagreed: %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectFoundAsksTheContainerNamespaceNotTheBareForm(t *testing.T) {
|
||||
run := dockerLikeByKind(map[string]bool{"keycloak": true})
|
||||
|
||||
if _, exists, err := inspectFound(context.Background(), "minio", run); err != nil || exists {
|
||||
t.Fatalf("a container that does not exist should be reported absent cleanly, not refused: exists=%v err=%v", exists, err)
|
||||
}
|
||||
if _, exists, err := inspectFound(context.Background(), "keycloak", run); err != nil || !exists {
|
||||
t.Fatalf("a container that does exist should be found: exists=%v err=%v", exists, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// A taken container keeps a found network by a per-machine setting (novox/hq ADR 0163, rule 4):
|
||||
// joined once it runs, part of its spec, and refused when it cannot be joined.
|
||||
func TestAContainerJoinsTheNetworksItKeeps(t *testing.T) {
|
||||
var ran []string
|
||||
connectFails := false
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
if name != "docker" {
|
||||
return "", errors.New("not installed")
|
||||
}
|
||||
ran = append(ran, strings.Join(args, " "))
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "29.0.0\n", nil
|
||||
case "container":
|
||||
if len(ran) > 2 {
|
||||
return "true\t" + specOfLast, nil
|
||||
}
|
||||
return "false\t\n", errors.New("no such container")
|
||||
case "run":
|
||||
return "deadbeef\n", nil
|
||||
case "network":
|
||||
if connectFails {
|
||||
return "", errors.New("network predecessor_default not found")
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"app","type":"container","name":"app","image":"`+pinned+`",
|
||||
"networks":["predecessor_default"]}
|
||||
]}`)
|
||||
specOfLast = containerSpec(d.Resources[0].(*declaration.Container), inputs{})
|
||||
alone := *d.Resources[0].(*declaration.Container)
|
||||
alone.Networks = nil
|
||||
if specOfLast == containerSpec(&alone, inputs{}) {
|
||||
t.Fatal("the kept network is not part of the container's spec: kept or let go, the container would be left alone")
|
||||
}
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := false
|
||||
for i, line := range ran {
|
||||
if line == "network connect predecessor_default app" {
|
||||
joined = true
|
||||
if ran[i-1] != "container inspect --format {{.State.Running}}\t{{index .Config.Labels \""+specLabel+"\"}} app" &&
|
||||
!strings.HasPrefix(ran[i-1], "container inspect") {
|
||||
t.Errorf("joined before the container was read back as running: %v", ran)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !joined || report.Outcomes[0].Action != "created" {
|
||||
t.Fatalf("the container did not join the kept network: %v\n%+v", ran, report.Outcomes)
|
||||
}
|
||||
|
||||
connectFails, ran = true, nil
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "could not join the kept network predecessor_default") {
|
||||
t.Fatalf("a network that cannot be joined was passed over: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var specOfLast string
|
||||
|
||||
// A module the mesh left out of a declaration is not a module the mesh removed (novox/hq ADR 0163,
|
||||
// rule 6): what the host wrote for it stays, recorded and said; what it holds for it stays held.
|
||||
// A module simply absent is removed as it always was.
|
||||
func TestALeftOutModuleIsNeitherRemovedNorForgotten(t *testing.T) {
|
||||
var removed []string
|
||||
gone := map[string]bool{}
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
if name != "docker" {
|
||||
return "", nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "29.0.0\n", nil
|
||||
case "rm":
|
||||
removed = append(removed, args[len(args)-1])
|
||||
gone[args[len(args)-1]] = true
|
||||
case "container":
|
||||
if gone[args[len(args)-1]] {
|
||||
return "", errors.New("no such container")
|
||||
}
|
||||
return "true\tspec", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
known := store.State{
|
||||
Resources: []store.Applied{
|
||||
{ID: "web.server", Type: "container", Target: "web", Origin: store.OriginDeclared},
|
||||
{ID: "old.server", Type: "container", Target: "old", Origin: store.OriginDeclared},
|
||||
},
|
||||
Held: []store.Held{{ID: "web.page", Module: "web", Kind: "file", Target: "/srv/web/index.html"}},
|
||||
}
|
||||
d := parse(t, `{"declaration":1,"left_out":["web"],"resources":[
|
||||
{"id":"notes.conf","type":"file","path":"`+t.TempDir()+`/notes.conf","content":"x"}
|
||||
]}`)
|
||||
report, state, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(removed) != 1 || removed[0] != "old" {
|
||||
t.Fatalf("removed %v; only the module that is absent goes", removed)
|
||||
}
|
||||
if _, kept := state.At("container", "web"); !kept {
|
||||
t.Fatal("the left-out module's record was forgotten")
|
||||
}
|
||||
if _, held := state.HeldAt("web.page"); !held {
|
||||
t.Fatal("the left-out module's hold was released")
|
||||
}
|
||||
said := false
|
||||
for _, o := range report.Outcomes {
|
||||
if o.ID == "web.server" && o.Action == "unchanged" && strings.Contains(o.Detail, "web was left out of this declaration by the mesh") {
|
||||
said = true
|
||||
}
|
||||
if o.ID == "web.server" && o.Action != "unchanged" {
|
||||
t.Errorf("the left-out module's container was %s", o.Action)
|
||||
}
|
||||
}
|
||||
if !said {
|
||||
t.Fatalf("keeping the left-out module's container was not said: %+v", report.Outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
// A container's capabilities reach the runtime and are part of its spec (novox/hq ADR 0170).
|
||||
func TestACapabilityReachesTheRuntimeAndTheSpec(t *testing.T) {
|
||||
var ran []string
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
if name != "docker" {
|
||||
return "", errors.New("not installed")
|
||||
}
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "29.0.0\n", nil
|
||||
case "container":
|
||||
return "false\t\n", errors.New("no such container")
|
||||
case "run":
|
||||
ran = args
|
||||
return "deadbeef\n", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"fw","type":"container","name":"fw","image":"`+pinned+`","network":"host","capabilities":["NET_ADMIN"]}
|
||||
]}`)
|
||||
_, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
granted := false
|
||||
for i, a := range ran {
|
||||
if a == "--cap-add" && i+1 < len(ran) && ran[i+1] == "NET_ADMIN" {
|
||||
granted = true
|
||||
}
|
||||
}
|
||||
if !granted {
|
||||
t.Fatalf("the capability was not granted: %v", ran)
|
||||
}
|
||||
with := d.Resources[0].(*declaration.Container)
|
||||
without := *with
|
||||
without.Capabilities = nil
|
||||
if containerSpec(with, inputs{}) == containerSpec(&without, inputs{}) {
|
||||
t.Fatal("a capability is not part of the container's spec")
|
||||
}
|
||||
}
|
||||
|
||||
// A package may be declared absent (novox/hq ADR 0175): removed when it is installed, read back,
|
||||
// left alone when it is not.
|
||||
func TestAPackageDeclaredAbsentIsRemovedWhenPresentAndLeftWhenNot(t *testing.T) {
|
||||
installed := true
|
||||
var ran []string
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
ran = append(ran, name+" "+strings.Join(args, " "))
|
||||
if name != "pacman" {
|
||||
return "", nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "-Q":
|
||||
if args[1] == "pacman" || installed {
|
||||
return args[1] + " 1.0\n", nil
|
||||
}
|
||||
return "", errors.New("package not found")
|
||||
case "-R":
|
||||
installed = false
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[{"id":"front-end","type":"package","package":"ufw","absent":true}]}`)
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Outcomes[0].Action != "removed" || !strings.Contains(strings.Join(ran, "\n"), "pacman -R --noconfirm ufw") {
|
||||
t.Fatalf("an installed package declared absent was not removed: %+v\n%v", report.Outcomes[0], ran)
|
||||
}
|
||||
ran = nil
|
||||
report, _, err = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Outcomes[0].Action != "unchanged" || strings.Contains(strings.Join(ran, "\n"), "-R") {
|
||||
t.Fatalf("a package already absent was touched: %+v\n%v", report.Outcomes[0], ran)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
)
|
||||
|
||||
// **A container's mesh names are part of what it is** (novox/hq 04-ISSUES/135).
|
||||
//
|
||||
// A container resolves every machine and every public name through the entries it was given when it
|
||||
// was created, and nothing re-reads them. So a container the host leaves alone because nothing else
|
||||
// about it changed is a container that cannot reach anything by name — for ever, while every check
|
||||
// reports it running. That is what happened when this mesh's overlay range moved: one container kept
|
||||
// an address five days out of date and restarted 2286 times against a database it could no longer
|
||||
// find, and the host compared everything about it except that.
|
||||
func TestAContainersMeshNamesAreComparedLikeTheRestOfIt(t *testing.T) {
|
||||
was := &declaration.Container{
|
||||
Name: "umami", Image: "ghcr.io/example/umami@sha256:" + zeros(64),
|
||||
Hosts: []string{"novox.internal:10.42.0.1", "umami.novox.be:10.42.0.1"},
|
||||
}
|
||||
moved := &declaration.Container{
|
||||
Name: was.Name, Image: was.Image,
|
||||
Hosts: []string{"novox.internal:10.10.0.1", "umami.novox.be:10.10.0.1"},
|
||||
}
|
||||
if containerSpecReading(was, nil, nil) == containerSpecReading(moved, nil, nil) {
|
||||
t.Fatal("a container whose mesh names moved compares equal, so it is never recreated")
|
||||
}
|
||||
|
||||
// And the order they arrive in is not a change: the digest must not move for a reordering
|
||||
// nobody made.
|
||||
reordered := &declaration.Container{
|
||||
Name: moved.Name, Image: moved.Image,
|
||||
Hosts: []string{moved.Hosts[1], moved.Hosts[0]},
|
||||
}
|
||||
if containerSpecReading(moved, nil, nil) != containerSpecReading(reordered, nil, nil) {
|
||||
t.Fatal("the same names in another order read as a different container")
|
||||
}
|
||||
|
||||
// A container the mesh gives no names is unaffected, so nothing is recreated for a field it
|
||||
// does not set.
|
||||
plain := &declaration.Container{Name: "plex", Image: was.Image}
|
||||
if containerSpecReading(plain, nil, nil) == containerSpecReading(was, nil, nil) {
|
||||
return // different for other reasons, which is fine
|
||||
}
|
||||
}
|
||||
|
||||
func zeros(n int) string {
|
||||
out := make([]byte, n)
|
||||
for i := range out {
|
||||
out[i] = '0'
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// A node is adopted or converged, and a declaration says which it is for (novox/hq ADR 0100).
|
||||
//
|
||||
// **The two are not interchangeable, and the host knows which it is.** A converged declaration
|
||||
// carries the mesh's drop-by-default filter and retires the firewall the node was found with; on
|
||||
// an adopted node that closes the machine to everything the predecessor still serves — which is
|
||||
// what happened when an operator ran `reconcile` on an adopted control-node and it applied the
|
||||
// converged genesis bundle (novox/hq issue 104). The host reported "adopted" in every report and
|
||||
// compared nothing. Now it compares, at the point of application, whichever command delivered
|
||||
// the declaration.
|
||||
//
|
||||
// Only the mesh changes a node's mode, and it does so by sending a declaration: the flip arrives
|
||||
// over the link as the first converged declaration after adopted ones (`converge` on the
|
||||
// controller), and the way back as the first adopted one (`adopt`). So a declaration the link
|
||||
// delivers, signed and verified, is the mode's authority and is not checked against the record —
|
||||
// it becomes the record. Everything else — the carried bundle, a file, the kept declaration a
|
||||
// disconnected node re-applies — is held to the mode already recorded.
|
||||
|
||||
// ModeOf says which mode a declaration is for.
|
||||
func ModeOf(d *declaration.Declaration) string {
|
||||
if d.Adoption != nil {
|
||||
return store.ModeAdopted
|
||||
}
|
||||
return store.ModeConverged
|
||||
}
|
||||
|
||||
// CheckMode refuses a declaration whose mode is not the one this node has recorded. A node with
|
||||
// no recorded mode — a machine nothing has said a mode to yet — takes either.
|
||||
func CheckMode(known store.State, d *declaration.Declaration) error {
|
||||
if known.Mode == "" || known.Mode == ModeOf(d) {
|
||||
return nil
|
||||
}
|
||||
act := "`converge`"
|
||||
if ModeOf(d) == store.ModeAdopted {
|
||||
act = "`adopt`"
|
||||
}
|
||||
return fmt.Errorf("this node is %s; the declaration says %s — %s declaration is not applied "+
|
||||
"to %s node; %s on the controller is the act that changes it, and it sends the "+
|
||||
"declaration that does", known.Mode, ModeOf(d), article(ModeOf(d)), article(known.Mode), act)
|
||||
}
|
||||
|
||||
func article(mode string) string {
|
||||
if mode == store.ModeAdopted {
|
||||
return "an adopted"
|
||||
}
|
||||
return "a converged"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// Defends novox/hq issue 104: a declaration is refused for the other mode than the node is in,
|
||||
// naming both and the act that changes it; a node no mode has been said to takes either.
|
||||
func TestADeclarationForTheOtherModeIsRefused(t *testing.T) {
|
||||
converged := parse(t, `{"declaration":1,"resources":[{"id":"a","type":"file","path":"/tmp/a","content":"x\n"}]}`)
|
||||
adopted := parse(t, `{"declaration":1,"adoption":{"taken":[]},"resources":[{"id":"a","type":"file","path":"/tmp/a","content":"x\n"}]}`)
|
||||
|
||||
err := CheckMode(store.State{Mode: store.ModeAdopted}, converged)
|
||||
if err == nil || !strings.Contains(err.Error(), "this node is adopted; the declaration says converged") ||
|
||||
!strings.Contains(err.Error(), "`converge`") {
|
||||
t.Errorf("a converged declaration on an adopted node: %v", err)
|
||||
}
|
||||
err = CheckMode(store.State{Mode: store.ModeConverged}, adopted)
|
||||
if err == nil || !strings.Contains(err.Error(), "this node is converged; the declaration says adopted") ||
|
||||
!strings.Contains(err.Error(), "`adopt`") {
|
||||
t.Errorf("an adopted declaration on a converged node: %v", err)
|
||||
}
|
||||
if err := CheckMode(store.State{Mode: store.ModeAdopted}, adopted); err != nil {
|
||||
t.Errorf("the node's own mode was refused: %v", err)
|
||||
}
|
||||
if err := CheckMode(store.State{}, converged); err != nil {
|
||||
t.Errorf("a node in no mode yet refused a declaration: %v", err)
|
||||
}
|
||||
}
|
||||
+51
-12
@@ -54,20 +54,53 @@ func foundFirewall(ctx context.Context, d *declaration.Declaration, known *store
|
||||
return kind, nil
|
||||
}
|
||||
|
||||
// retireFirewall disables the found firewall once a converged declaration has applied cleanly,
|
||||
// which is when the mesh's derived filter has taken its place. Disabled, never flushed: its
|
||||
// configuration stays on disk for a return to adopted, and the container runtime's rules are not
|
||||
// its to take.
|
||||
// retireFirewall keeps the found firewall retired on a converged machine (novox/hq ADR 0100, ADR
|
||||
// 0168): disabled, never flushed, its configuration left on disk for a return to adopted, and the
|
||||
// container runtime's rules not its to take.
|
||||
//
|
||||
// **Convergence is a state the host keeps, not a step it takes once.** Every converged apply reads
|
||||
// whether the front end is in force; enabled again by a package, a boot or a hand, it is retired
|
||||
// again and said. The record says how it came to be inactive — the mesh disabled it, or a reconcile
|
||||
// found it so — and the two are never confused: a flip that did not take, followed by a hand that
|
||||
// did, used to be recorded as the mesh's doing (issue 143).
|
||||
//
|
||||
// Only a declaration from the mesh converges a node. A carried bundle never says a node is adopted
|
||||
// — it cannot — so its silence is not the controller's word that the node was converged, and an
|
||||
// adopted node re-applying its bundle keeps the firewall it was found with.
|
||||
//
|
||||
// Returned is what this apply did about the found firewall, for the report; empty when the machine
|
||||
// has none or is not converged.
|
||||
func retireFirewall(ctx context.Context, d *declaration.Declaration, origin string, known *store.State,
|
||||
run Runner, log func(string)) error {
|
||||
run Runner, log func(string)) (string, error) {
|
||||
rec := known.Firewall
|
||||
if origin != store.OriginDeclared || d.Adoption != nil || rec == nil || rec.Kind != string(firewall.UFW) || !rec.WasActive ||
|
||||
rec.DisabledByMesh {
|
||||
return nil
|
||||
if origin != store.OriginDeclared || d.Adoption != nil || rec == nil || rec.Kind != string(firewall.UFW) || !rec.WasActive {
|
||||
return "", nil
|
||||
}
|
||||
if !firewall.Installed(ctx, run) {
|
||||
// Uninstalled (novox/hq ADR 0175): retired for good, by the module that replaced it. Said
|
||||
// once, and nothing is asked of a command that is not there.
|
||||
if rec.RetiredBy != firewall.RetiredRemoved {
|
||||
rec.RetiredBy = firewall.RetiredRemoved
|
||||
log(" the found firewall (ufw) is no longer installed; the mesh's filter is what filters this machine")
|
||||
return "removed: ufw is no longer installed; the mesh's filter is what filters this machine", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
active := firewall.Active(ctx, run)
|
||||
if !active && !(rec.Forward != nil && !rec.DisabledByMesh) {
|
||||
// Inactive, and either the mesh's doing already or nobody's recorded here: said as found,
|
||||
// never as done (issue 143's second fault). A retirement the mesh began and did not finish —
|
||||
// the forward policy recorded, ufw down, the restore failed — is the one inactive state that
|
||||
// is still the mesh's to complete, below.
|
||||
if rec.RetiredBy == "" {
|
||||
if rec.DisabledByMesh {
|
||||
rec.RetiredBy = firewall.RetiredByMesh
|
||||
} else {
|
||||
rec.RetiredBy = firewall.RetiredFoundSo
|
||||
log(" ufw is inactive on this converged node, and not by the mesh; recorded as found so")
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
// **Nothing is retired until what replaces it is in force** (novox/hq ADR 0100). The flip
|
||||
// loads the mesh's derived filter in ufw's place; disabling ufw before that table is actually
|
||||
@@ -75,10 +108,10 @@ func retireFirewall(ctx context.Context, d *declaration.Declaration, origin stri
|
||||
// no filter at all.
|
||||
loaded, err := firewall.MeshTableLoaded(ctx, run)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
if !loaded {
|
||||
return fmt.Errorf("this node is converged and the mesh's own filter (table %s) is not loaded on "+
|
||||
return "", fmt.Errorf("this node is converged and the mesh's own filter (table %s) is not loaded on "+
|
||||
"this machine, so ufw was left in force: retiring it would leave the machine filtering "+
|
||||
"nothing. Assign a filter module to this node, or return it to adopted", firewall.MeshTable)
|
||||
}
|
||||
@@ -88,11 +121,17 @@ func retireFirewall(ctx context.Context, d *declaration.Declaration, origin stri
|
||||
rec.Forward = firewall.ForwardPolicies(ctx, run)
|
||||
}
|
||||
if err := firewall.Disable(ctx, run, rec.Forward); err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
again := rec.DisabledByMesh || rec.RetiredBy != ""
|
||||
rec.DisabledByMesh = true
|
||||
rec.RetiredBy = firewall.RetiredByMesh
|
||||
if again {
|
||||
log(" disabled ufw again: it had been enabled since the mesh retired it; this node is converged and filtered by the mesh")
|
||||
return "disabled again: ufw had been enabled since the mesh retired it", nil
|
||||
}
|
||||
log(" disabled ufw: this node is converged and filtered by the mesh; ufw's configuration is left on disk")
|
||||
return nil
|
||||
return "disabled: this node is converged and filtered by the mesh; ufw's configuration is left on disk", nil
|
||||
}
|
||||
|
||||
// applyOpening makes one opening true through the firewall found here.
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
@@ -189,13 +190,33 @@ func TestConvergingRetiresTheFoundFirewallAndReturningRestoresIt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Converged again: nothing more to retire.
|
||||
// Converged again: nothing more to retire — the node asks ufw whether it is in force, which is
|
||||
// what keeps convergence a state rather than a step taken once (novox/hq ADR 0168), and touches
|
||||
// nothing else.
|
||||
u.asked = nil
|
||||
if _, state, err = applyWith(t, converged, state, u.run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.index("ufw") >= 0 {
|
||||
t.Errorf("a converged node kept talking to a retired ufw: %v", u.asked)
|
||||
for _, a := range u.asked {
|
||||
if strings.HasPrefix(a, "ufw") && a != "ufw status" {
|
||||
t.Errorf("a converged node kept talking to a retired ufw: %v", u.asked)
|
||||
}
|
||||
}
|
||||
if state.Firewall.RetiredBy != "mesh" {
|
||||
t.Errorf("the record does not say the mesh retired it: %+v", state.Firewall)
|
||||
}
|
||||
// Enabled again by a hand: retired again, and said.
|
||||
u.active = true
|
||||
u.asked = nil
|
||||
report, state, err := applyWith(t, converged, state, u.run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.active || u.index("ufw disable") < 0 {
|
||||
t.Fatalf("ufw enabled again on a converged node was not retired again: %v", u.asked)
|
||||
}
|
||||
if !strings.Contains(report.Firewall, "disabled again") {
|
||||
t.Errorf("retiring it again was not said: %q", report.Firewall)
|
||||
}
|
||||
|
||||
// Returned to adopted: ufw is enabled before the opening is converged through it.
|
||||
@@ -339,8 +360,18 @@ func TestReturningToAdoptedLoadsTheGuardBeforeRemovingTheFilter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
guard := filepath.Join(dir, "guard.nft")
|
||||
guardFile := `{"id":"adoption.guard","type":"file","path":"` + guard + `","content":"table inet mesh_guard {}\n"}`
|
||||
// The filter's unit file is the mesh's own, written where there was none — which is what makes
|
||||
// its unit the mesh's to stop, with or without a record of what was found (novox/hq ADR 0118).
|
||||
units := t.TempDir()
|
||||
was := unitDir
|
||||
unitDir = units
|
||||
t.Cleanup(func() { unitDir = was })
|
||||
filterUnit := filepath.Join(units, "mesh-filter.service")
|
||||
for _, stopFails := range []bool{false, true} {
|
||||
_ = os.Remove(guard)
|
||||
if err := os.WriteFile(filterUnit, []byte("[Unit]\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guardUpAtStop := false
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
if name != "systemctl" {
|
||||
@@ -358,7 +389,9 @@ func TestReturningToAdoptedLoadsTheGuardBeforeRemovingTheFilter(t *testing.T) {
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
// As a host recorded them before it kept what it found: no Found on the service.
|
||||
converged := store.State{Resources: []store.Applied{
|
||||
{ID: "nftables.unit", Type: "file", Target: filterUnit, Origin: store.OriginDeclared},
|
||||
{ID: "nftables.load", Type: "service", Target: "mesh-filter.service", Origin: store.OriginDeclared}}}
|
||||
_, state, err := applyWith(t, adopted(t, `{"taken":[]}`, withConf(dir)+","+guardFile), converged, run)
|
||||
if !guardUpAtStop {
|
||||
@@ -490,3 +523,33 @@ func TestUfwIsNotRetiredUntilTheMeshsOwnFilterIsLoaded(t *testing.T) {
|
||||
t.Errorf("ufw was not retired once the mesh's filter was loaded: active %v, %+v", u.active, state.Firewall)
|
||||
}
|
||||
}
|
||||
|
||||
// A front end that is no longer installed is recorded as removed, said once, and asked nothing of
|
||||
// (novox/hq ADR 0175).
|
||||
func TestAnUninstalledFrontEndIsRetiredForGood(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
u := &ufwMachine{installed: false, ruleset: "table inet mesh\n"}
|
||||
known := store.State{Firewall: &store.FoundFirewall{Kind: "ufw", WasActive: true, DisabledByMesh: true,
|
||||
RetiredBy: "mesh", FoundAt: time.Now()}}
|
||||
converged := parse(t, `{"declaration":1,"resources":[`+withConf(dir)+`]}`)
|
||||
report, state, err := applyWith(t, converged, known, u.run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.Firewall.RetiredBy != "removed" || !strings.Contains(report.Firewall, "no longer installed") {
|
||||
t.Fatalf("record %+v, said %q", state.Firewall, report.Firewall)
|
||||
}
|
||||
u.asked = nil
|
||||
report, _, err = applyWith(t, converged, state, u.run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Firewall != "" {
|
||||
t.Errorf("said again: %q", report.Firewall)
|
||||
}
|
||||
for _, a := range u.asked {
|
||||
if strings.HasPrefix(a, "ufw") && a != "ufw status" {
|
||||
t.Errorf("asked something of a front end that is not there: %v", u.asked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/firewall"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// A declaration is said before it is done.
|
||||
//
|
||||
// An apply that prints what it did after it did it is a report; what an operator reaching for
|
||||
// `reconcile` under pressure needs is a preview — the base filter that closed an adopted
|
||||
// control-node for forty-five minutes was listed nowhere until it was on disk (novox/hq issue
|
||||
// 104). Converging already previews on the controller; the host's own commands now do too, and
|
||||
// `--dry-run` is the preview and nothing else.
|
||||
|
||||
// Step is one thing an apply would do to this machine.
|
||||
type Step struct {
|
||||
// Verb is create · update · check · hold · run · remove · forget · restore · disable · enable.
|
||||
Verb string `json:"verb"`
|
||||
Type string `json:"type,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
}
|
||||
|
||||
func (s Step) String() string {
|
||||
line := fmt.Sprintf("%-8s %-10s %s", s.Verb, s.Type, s.ID)
|
||||
if s.Target != "" && s.Target != s.ID {
|
||||
line += " (" + s.Target + ")"
|
||||
}
|
||||
if s.Why != "" {
|
||||
line += " — " + s.Why
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// Plan says what applying a declaration would change, in the order ApplyKeeping would do it,
|
||||
// before anything on the machine is touched.
|
||||
//
|
||||
// **Read from the declaration and the node's own record, not from the machine.** What the
|
||||
// record cannot settle — whether a file recorded here has since drifted, whether a container
|
||||
// runs the spec it was made from — is said as a check, because that is what the apply does: it
|
||||
// reads the machine and corrects it. What the record does settle is said as it is: a resource
|
||||
// with no record is created; a plain file whose declared content differs from what this host
|
||||
// last wrote is updated; a hold is kept; an action is run — an action is a command, and a
|
||||
// preview that folded it into "check" would hide the one kind of step that is not read back
|
||||
// from state.
|
||||
func Plan(d *declaration.Declaration, known store.State, origin string) []Step {
|
||||
var steps []Step
|
||||
|
||||
declared := map[string]bool{}
|
||||
for _, r := range d.Resources {
|
||||
declared[r.Identity()] = true
|
||||
}
|
||||
// The found tunnel's configuration is held under an id of its own, declared for as long as the
|
||||
// service taking it over is — as ApplyKeeping counts it, or a plan would forget a hold the
|
||||
// apply keeps (novox/hq ADR 0105).
|
||||
if svc := takesOver(d); svc != nil {
|
||||
declared[takeOverID(svc)] = true
|
||||
}
|
||||
rec := known.Firewall
|
||||
ufw := rec != nil && rec.Kind == string(firewall.UFW)
|
||||
|
||||
if d.Adoption != nil && ufw && rec.DisabledByMesh {
|
||||
steps = append(steps, Step{Verb: "enable", Type: "firewall", ID: "ufw",
|
||||
Why: "this node is adopted again, so the firewall found on it is put back in force"})
|
||||
}
|
||||
|
||||
// What is held and no longer declared is let go of on paper only (ApplyKeeping does this
|
||||
// before the resources); the file or container itself is left as found.
|
||||
if origin == store.OriginDeclared {
|
||||
for _, h := range known.Held {
|
||||
if declared[h.ID] {
|
||||
continue
|
||||
}
|
||||
steps = append(steps, Step{Verb: "forget", Type: h.Kind, ID: h.ID, Target: h.Target,
|
||||
Why: "held for " + h.Module + " and no longer declared; left as found"})
|
||||
}
|
||||
}
|
||||
|
||||
var protecting, orphans []Step
|
||||
made := meshMadeUnits(known)
|
||||
for _, orphan := range known.Orphans(declared, origin) {
|
||||
step := Step{Verb: "remove", Type: orphan.Type, ID: orphan.ID, Target: orphan.Target,
|
||||
Why: "recorded here and no longer declared"}
|
||||
switch {
|
||||
case orphan.Stateless:
|
||||
step.Verb, step.Why = "forget", "no longer declared; its unit's state was never the mesh's and is left as it is"
|
||||
case orphan.Type == string(declaration.TypeService):
|
||||
// What removal will do, said before it does it (novox/hq ADR 0118), in removeService's
|
||||
// words. "restore" only where it may stop or disable something — the record cannot say
|
||||
// whether the unit is still as the mesh left it, so "may" is as far as a preview goes —
|
||||
// and a unit whose file the mesh wrote is named as the mesh's, since that one is
|
||||
// stopped whatever was found.
|
||||
f := orphan.Found
|
||||
switch {
|
||||
case made[orphan.Target]:
|
||||
step.Verb, step.Why = "remove", "no longer declared; the mesh wrote its unit file, so it is "+
|
||||
"stopped and disabled at boot before that file goes"
|
||||
case f == nil:
|
||||
step.Verb, step.Why = "forget", "no longer declared; recorded before the host kept what it "+
|
||||
"found, so it is left as it is"
|
||||
case f.State == "stopped" || f.Boot == "disabled":
|
||||
var back []string
|
||||
if f.State == "stopped" {
|
||||
back = append(back, "stopped")
|
||||
}
|
||||
if f.Boot == "disabled" {
|
||||
back = append(back, "disabled at boot")
|
||||
}
|
||||
step.Verb, step.Why = "restore", "no longer declared; the host found it "+
|
||||
strings.Join(back, " and ")+", and it goes back to that if the mesh changed it"
|
||||
default:
|
||||
step.Verb, step.Why = "forget", "no longer declared; it was running before the mesh and is "+
|
||||
"left as it is — nothing is started or stopped on the way out"
|
||||
}
|
||||
}
|
||||
if d.Adoption == nil && strings.HasPrefix(orphan.ID, declaration.AdoptionPrefix) {
|
||||
step.Why = "what protected this node while adopted; removed last, once everything else applied"
|
||||
protecting = append(protecting, step)
|
||||
continue
|
||||
}
|
||||
orphans = append(orphans, step)
|
||||
}
|
||||
|
||||
// The guard goes up before anything is removed on an adopted node; on a converged one the
|
||||
// removals go first (novox/hq ADR 0103).
|
||||
var guard, rest []declaration.Resource
|
||||
for _, r := range d.Resources {
|
||||
if d.Adoption != nil && strings.HasPrefix(r.Identity(), guardPrefix) {
|
||||
guard = append(guard, r)
|
||||
continue
|
||||
}
|
||||
rest = append(rest, r)
|
||||
}
|
||||
for _, r := range guard {
|
||||
steps = append(steps, planned(r, d, known))
|
||||
}
|
||||
steps = append(steps, orphans...)
|
||||
for _, r := range rest {
|
||||
step := planned(r, d, known)
|
||||
if svc, ok := r.(*declaration.Service); ok && svc.TakesOver != nil && d.Adoption != nil && step.Verb != "hold" {
|
||||
// The take comes before the service that replaces the tunnel, as it does in the apply.
|
||||
steps = append(steps, plannedTake(svc, known))
|
||||
}
|
||||
steps = append(steps, step)
|
||||
}
|
||||
|
||||
// Only a declaration from the mesh converges a node; a bundle or a file never retires the
|
||||
// firewall found here, and neither says so in a plan.
|
||||
if d.Adoption == nil && origin == store.OriginDeclared && ufw && rec.WasActive && !rec.DisabledByMesh {
|
||||
steps = append(steps, Step{Verb: "disable", Type: "firewall", ID: "ufw",
|
||||
Why: "this node converges: retired once the mesh's own filter is loaded, never before; " +
|
||||
"its configuration stays on disk"})
|
||||
}
|
||||
steps = append(steps, protecting...)
|
||||
return steps
|
||||
}
|
||||
|
||||
// planned is what one declared resource would come to.
|
||||
//
|
||||
// **In the order holdOnAdopted decides it**, because the one cutover ADR 0100 says must be
|
||||
// previewed is the one a plan gets backwards if it looks at the record first: a resource this
|
||||
// node holds for a module the declaration now says is taken is not held any longer — it is
|
||||
// applied, and what was found is replaced. The declaration's word on which modules are untaken
|
||||
// comes first; the record of what is held only says what that replacement replaces.
|
||||
func planned(r declaration.Resource, d *declaration.Declaration, known store.State) Step {
|
||||
step := Step{Type: string(r.Kind()), ID: r.Identity(), Target: r.Target()}
|
||||
|
||||
if d.Adoption != nil {
|
||||
// Something run inside a held container is held with it, while that container's module
|
||||
// is untaken; once the module is taken the container is replaced before this runs.
|
||||
if in := runsIn(r); in != "" {
|
||||
if container, isHeld := heldContainer(known, in); isHeld {
|
||||
if _, untaken := d.Adoption.Untaken[container.Module]; untaken {
|
||||
step.Verb = "hold"
|
||||
step.Why = "runs in " + in + ", which is held as found; not run until " + container.Module + " is taken"
|
||||
return step
|
||||
}
|
||||
}
|
||||
}
|
||||
// A file written into, or a service whose lifecycle is the machine's, replaces nothing that
|
||||
// was found, so it is never held (ADR 0102, ADR 0117).
|
||||
into := replacesNothing(r)
|
||||
h, held := known.HeldAt(r.Identity())
|
||||
module, untaken := d.Adoption.UntakenModuleOf(r.Identity())
|
||||
switch {
|
||||
case into:
|
||||
case untaken && held:
|
||||
step.Verb, step.Why = "hold", "found on this machine and kept as it is until "+module+" is taken"
|
||||
return step
|
||||
case untaken:
|
||||
step.Verb = "create"
|
||||
step.Why = "unless it is found on this machine — then held as it is until " + module + " is taken"
|
||||
return step
|
||||
case held:
|
||||
// The cutover: the module is taken, and what was held for it is replaced.
|
||||
step.Verb = "create"
|
||||
if _, recorded := known.Find(r.Identity()); recorded {
|
||||
step.Verb = "update"
|
||||
}
|
||||
step.Why = h.Module + " is taken: replaces what was found and held"
|
||||
if h.Kept != "" {
|
||||
step.Why += "; the original stays at " + h.Kept
|
||||
}
|
||||
return step
|
||||
}
|
||||
}
|
||||
|
||||
if a, ok := r.(*declaration.Action); ok {
|
||||
// Named as what it is. Its verify decides whether it runs, and that is read from the
|
||||
// machine, not the record.
|
||||
step.Verb = "run"
|
||||
step.Target = ""
|
||||
step.Why = fmt.Sprintf("an action: `%s`, unless its verify `%s` already passes; if it fails, "+
|
||||
"nothing after it is attempted", strings.Join(a.Command, " "), strings.Join(a.Verify, " "))
|
||||
if a.In != "" {
|
||||
step.Why = "in " + a.In + ", " + step.Why
|
||||
}
|
||||
return step
|
||||
}
|
||||
|
||||
if svc, ok := r.(*declaration.Service); ok && svc.Stateless() {
|
||||
// Nothing is created: the unit and whether it runs are the machine's (novox/hq ADR 0117).
|
||||
step.Verb, step.Why = "check", "its lifecycle is the machine's; reloaded or restarted only if "+
|
||||
"running when what it reflects changes"
|
||||
return step
|
||||
}
|
||||
was, recorded := known.Find(r.Identity())
|
||||
if !recorded {
|
||||
step.Verb, step.Why = "create", "no record of it on this node"
|
||||
return step
|
||||
}
|
||||
if want := wouldWrite(r); want != "" && was.Wrote != "" && want != was.Wrote {
|
||||
step.Verb, step.Why = "update", "the declaration changed since this host applied it"
|
||||
return step
|
||||
}
|
||||
if c, ok := r.(*declaration.Container); ok {
|
||||
if changed := readsChanged(c, d, known, was.Reads); len(changed) > 0 {
|
||||
step.Verb = "update"
|
||||
step.Why = "recreated: " + strings.Join(changed, ", ") + " changed since it was created"
|
||||
return step
|
||||
}
|
||||
}
|
||||
step.Verb, step.Why = "check", "recorded here; corrected if this machine drifted from it"
|
||||
return step
|
||||
}
|
||||
|
||||
// plannedTake is what the take of a found tunnel would do to its configuration (novox/hq ADR 0105,
|
||||
// ADR 0119): kept as found while the take is not proven, and retired — removed from where its unit
|
||||
// reads it, its original staying kept — by the first apply that finds the mesh's interface up in
|
||||
// its place with a peer handshaken. Whether that is this apply is read from the machine, which a
|
||||
// plan does not do, so it says when rather than whether. One the mesh retired already is said as
|
||||
// retired: nothing brings it back.
|
||||
func plannedTake(svc *declaration.Service, known store.State) Step {
|
||||
t := svc.TakesOver
|
||||
step := Step{Verb: "hold", Type: string(declaration.TypeFile), ID: takeOverID(svc), Target: t.Config}
|
||||
if r, ok := known.RetiredAt(t.Config); ok {
|
||||
step.Verb = "check"
|
||||
step.Why = "retired once the take of " + t.Interface + " was proven; its original stays at " + r.Kept +
|
||||
" and the mesh never brings it back"
|
||||
return step
|
||||
}
|
||||
step.Why = "the configuration of the tunnel " + t.Interface + ", kept as found while " + svc.Unit +
|
||||
" takes it over (" + t.Unit + " stopped and disabled, never flushed); retired — removed from " +
|
||||
t.Config + ", its original staying kept — once the take is proven by a peer handshaking on " +
|
||||
strings.TrimPrefix(svc.Unit, "wg-quick@")
|
||||
if h, ok := known.HeldAt(takeOverID(svc)); ok && h.Kept != "" {
|
||||
step.Why += "; the original is at " + h.Kept
|
||||
}
|
||||
return step
|
||||
}
|
||||
|
||||
// readsChanged is which of the files a container was created reading the apply will hand it
|
||||
// changed — the same comparison applyContainer makes (novox/hq 04-ISSUES/103), settled from the
|
||||
// declaration and the record alone.
|
||||
//
|
||||
// A file's digest is what this apply will record for it: a plain file declared here, by its
|
||||
// declared content; otherwise what this host last wrote there, under any id. A file neither
|
||||
// declares nor records — an env-file a predecessor left — is read by the apply from the machine,
|
||||
// which a plan does not do, so it stays a check. A container with no record of what it read was
|
||||
// labelled before the host kept that record and is accepted as it is, so it is a check too.
|
||||
func readsChanged(c *declaration.Container, d *declaration.Declaration, known store.State,
|
||||
wasReading map[string]string) []string {
|
||||
if len(wasReading) == 0 {
|
||||
return nil
|
||||
}
|
||||
willWrite := map[string]string{}
|
||||
for _, r := range d.Resources {
|
||||
if f, ok := r.(*declaration.File); ok {
|
||||
if want := wouldWrite(f); want != "" {
|
||||
willWrite[f.Path] = want
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only what it still reads: a file it was created reading and no longer names is a changed
|
||||
// declaration, not a changed file.
|
||||
stillReads := map[string]bool{}
|
||||
for _, path := range c.EnvFile {
|
||||
stillReads[path] = true
|
||||
}
|
||||
for _, v := range c.Volumes {
|
||||
if src := mountSource(v); strings.HasPrefix(src, "/") {
|
||||
stillReads[src] = true
|
||||
}
|
||||
}
|
||||
var changed []string
|
||||
for _, path := range sortedKeys(wasReading) {
|
||||
if !stillReads[path] {
|
||||
continue
|
||||
}
|
||||
now, settled := willWrite[path]
|
||||
if !settled {
|
||||
if f, recorded := known.At(string(declaration.TypeFile), path); recorded {
|
||||
now, settled = f.Wrote, true
|
||||
}
|
||||
}
|
||||
if settled && now != wasReading[path] {
|
||||
changed = append(changed, path)
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// wouldWrite is the digest a plain file would be recorded under, or empty where only the apply
|
||||
// can know: a sealed file, one with secrets in it, one written into, one carrying bytes.
|
||||
func wouldWrite(r declaration.Resource) string {
|
||||
f, ok := r.(*declaration.File)
|
||||
if !ok || f.Into != "" || f.Bytes != "" || f.Secret() || len(f.Secrets) > 0 {
|
||||
return ""
|
||||
}
|
||||
return digestOf(f.Content)
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// Defends novox/hq issue 104: what an apply would change is said before anything is, from the
|
||||
// declaration and the node's record — and an action is named as the action it is.
|
||||
|
||||
func trusted(t *testing.T, raw string) *declaration.Declaration {
|
||||
t.Helper()
|
||||
d, err := declaration.ParseFileTrusted([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("fixture is not a valid declaration: %v", err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func verbs(steps []Step) string {
|
||||
var out []string
|
||||
for _, s := range steps {
|
||||
out = append(out, s.Verb+" "+s.ID)
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
func TestAPlanNamesAnActionAsAnAction(t *testing.T) {
|
||||
d := trusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"init","type":"action","command":["createdb","mesh"],"verify":["psql","-c","select 1"]}]}`)
|
||||
steps := Plan(d, store.State{}, store.OriginCarried)
|
||||
if len(steps) != 1 || steps[0].Verb != "run" {
|
||||
t.Fatalf("an action was planned as %s", verbs(steps))
|
||||
}
|
||||
if !strings.Contains(steps[0].Why, "createdb mesh") || !strings.Contains(steps[0].Why, "nothing after it") {
|
||||
t.Errorf("the plan does not say what the action runs and what failing it means: %q", steps[0].Why)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPlanSaysWhatIsRecordedAndWhatIsNot(t *testing.T) {
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"new","type":"file","path":"/tmp/new","content":"a\n"},
|
||||
{"id":"same","type":"file","path":"/tmp/same","content":"b\n"},
|
||||
{"id":"moved","type":"file","path":"/tmp/moved","content":"c\n"},
|
||||
{"id":"sealed","type":"file","path":"/tmp/sealed","sealed":"AAAA","mode":"0600"}]}`)
|
||||
known := store.State{}
|
||||
known.Record(store.Applied{ID: "same", Type: "file", Target: "/tmp/same", Wrote: digestOf("b\n")})
|
||||
known.Record(store.Applied{ID: "moved", Type: "file", Target: "/tmp/moved", Wrote: digestOf("old\n")})
|
||||
known.Record(store.Applied{ID: "sealed", Type: "file", Target: "/tmp/sealed", Wrote: digestOf("secret")})
|
||||
known.Record(store.Applied{ID: "gone", Type: "file", Target: "/tmp/gone"})
|
||||
|
||||
got := verbs(Plan(d, known, store.OriginCarried))
|
||||
want := "remove gone, create new, check same, update moved, check sealed"
|
||||
if got != want {
|
||||
t.Errorf("planned %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPlanSaysTheFirewallAConvergingNodeRetires(t *testing.T) {
|
||||
d := parse(t, `{"declaration":1,"resources":[{"id":"a","type":"file","path":"/tmp/a","content":"x\n"}]}`)
|
||||
known := store.State{Firewall: &store.FoundFirewall{Kind: "ufw", WasActive: true, FoundAt: time.Now()}}
|
||||
known.Record(store.Applied{ID: "adoption.guard.table", Type: "file", Target: "/etc/guard", Origin: store.OriginDeclared})
|
||||
|
||||
got := verbs(Plan(d, known, store.OriginDeclared))
|
||||
// The firewall goes last but for what protected the node, which goes after it.
|
||||
if got != "create a, disable ufw, remove adoption.guard.table" {
|
||||
t.Errorf("a converging node planned %q", got)
|
||||
}
|
||||
// A file or the bundle never retires the firewall found here, and says nothing about it.
|
||||
if got := verbs(Plan(d, known, store.OriginCarried)); strings.Contains(got, "ufw") {
|
||||
t.Errorf("a carried declaration planned to touch the firewall: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPlanHoldsWhatAnAdoptedNodeFound(t *testing.T) {
|
||||
d := parse(t, `{"declaration":1,"adoption":{"taken":[],"untaken":{"hello-web":["hello-web.page","hello-web.server"]}},
|
||||
"resources":[
|
||||
{"id":"hello-web.page","type":"file","path":"/srv/index.html","content":"x\n"},
|
||||
{"id":"hello-web.server","type":"container","name":"hello-web","image":"example/web@sha256:0000000000000000000000000000000000000000000000000000000000000000"}]}`)
|
||||
known := store.State{}
|
||||
known.RecordHeld(store.Held{ID: "hello-web.page", Module: "hello-web", Kind: "file", Target: "/srv/index.html"})
|
||||
|
||||
steps := Plan(d, known, store.OriginDeclared)
|
||||
if len(steps) != 2 || steps[0].Verb != "hold" || steps[1].Verb != "create" {
|
||||
t.Fatalf("an adopted node planned %s", verbs(steps))
|
||||
}
|
||||
if !strings.Contains(steps[1].Why, "held as it is until hello-web is taken") {
|
||||
t.Errorf("the plan does not say an untaken module's resource is held if found: %q", steps[1].Why)
|
||||
}
|
||||
}
|
||||
|
||||
// both is a machine with a container runtime and ufw on it at once.
|
||||
type both struct {
|
||||
m *machine
|
||||
u *ufwMachine
|
||||
}
|
||||
|
||||
func (b *both) run(ctx context.Context, name string, args ...string) (string, error) {
|
||||
switch name {
|
||||
case "nft", "ufw", "iptables", "ip6tables", "firewall-cmd":
|
||||
return b.u.run(ctx, name, args...)
|
||||
}
|
||||
return b.m.run(ctx, name, args...)
|
||||
}
|
||||
|
||||
func ids(steps []Step) []string {
|
||||
var out []string
|
||||
for _, s := range steps {
|
||||
if s.Type == "firewall" {
|
||||
continue // said, not an outcome
|
||||
}
|
||||
out = append(out, s.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func outcomeIDs(r Report) []string {
|
||||
var out []string
|
||||
for _, o := range r.Outcomes {
|
||||
out = append(out, o.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func write(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Defends novox/hq issue 104: the plan is the apply, said first — the same resources in the same
|
||||
// order, and the one cutover ADR 0100 says must be previewed said as a cutover, not a hold.
|
||||
func TestThePlanIsTheApplyInOrder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
guard, page, keep, old := filepath.Join(dir, "guard.nft"), filepath.Join(dir, "index.html"),
|
||||
filepath.Join(dir, "keep.html"), filepath.Join(dir, "old.conf")
|
||||
for _, p := range []string{page, keep, old} {
|
||||
write(t, p, "the predecessor's\n")
|
||||
}
|
||||
|
||||
// Returning to adopted, with a guard to raise, an orphan, a hold that stays, a hold whose
|
||||
// module is now taken, and a hold no longer declared.
|
||||
back := adopted(t, `{"taken":["hello-web"],"untaken":{"keep":["keep.page"]}}`,
|
||||
`{"id":"adoption.guard.table","type":"file","path":"`+guard+`","content":"table inet mesh-guard {}\n"},
|
||||
{"id":"hello-web.page","type":"file","path":"`+page+`","content":"the mesh's page\n"},
|
||||
{"id":"keep.page","type":"file","path":"`+keep+`","content":"the mesh's keep\n"}`)
|
||||
known := store.State{Firewall: &store.FoundFirewall{Kind: "ufw", WasActive: true, DisabledByMesh: true, FoundAt: time.Now()}}
|
||||
known.Record(store.Applied{ID: "old.conf", Type: "file", Target: old, Origin: store.OriginDeclared})
|
||||
for id, module := range map[string]string{"hello-web.page": "hello-web", "keep.page": "keep", "gone.page": "gone"} {
|
||||
known.RecordHeld(store.Held{ID: id, Module: module, Kind: "file", Target: filepath.Join(dir, id), Since: time.Now()})
|
||||
}
|
||||
fake := &both{m: &machine{containers: map[string]*fakeContainer{}}, u: &ufwMachine{installed: true}}
|
||||
|
||||
plan := Plan(back, known, store.OriginDeclared)
|
||||
report, state, err := ApplyKeeping(context.Background(), archHost(t), back, known, store.OriginDeclared,
|
||||
fake.run, nil, nil, KeepIn(dir))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := verbs(plan), "enable ufw, forget gone.page, create adoption.guard.table, remove old.conf, "+
|
||||
"create hello-web.page, hold keep.page"; got != want {
|
||||
t.Errorf("planned %q, want %q", got, want)
|
||||
}
|
||||
if got, want := strings.Join(ids(plan), " "), strings.Join(outcomeIDs(report), " "); got != want {
|
||||
t.Errorf("the plan said %q and the apply did %q", got, want)
|
||||
}
|
||||
taken := outcomeOf(report, "hello-web.page")
|
||||
if !strings.Contains(taken.Detail, "taken") || !strings.Contains(plan[4].Why, "hello-web is taken: replaces what was found") {
|
||||
t.Errorf("the cutover was applied as %q and planned as %q", taken.Detail, plan[4].Why)
|
||||
}
|
||||
if !fake.u.active || state.Firewall.DisabledByMesh {
|
||||
t.Error("returning to adopted did not enable ufw again")
|
||||
}
|
||||
|
||||
// Converged, from the mesh: an orphan, a new file, ufw retired, and what protected the node
|
||||
// removed last.
|
||||
flip := parse(t, `{"declaration":1,"resources":[{"id":"a","type":"file","path":"`+filepath.Join(dir, "a.conf")+`","content":"a\n"}]}`)
|
||||
write(t, old, "again\n")
|
||||
known = store.State{Firewall: &store.FoundFirewall{Kind: "ufw", WasActive: true, FoundAt: time.Now()}}
|
||||
known.Record(store.Applied{ID: "adoption.guard.table", Type: "file", Target: guard, Origin: store.OriginDeclared})
|
||||
known.Record(store.Applied{ID: "old.conf", Type: "file", Target: old, Origin: store.OriginDeclared})
|
||||
fake = &both{m: &machine{containers: map[string]*fakeContainer{}}, u: &ufwMachine{installed: true, active: true,
|
||||
ruleset: "table inet mesh {\n}\n"}}
|
||||
|
||||
plan = Plan(flip, known, store.OriginDeclared)
|
||||
report, state, err = ApplyKeeping(context.Background(), archHost(t), flip, known, store.OriginDeclared,
|
||||
fake.run, nil, nil, KeepIn(dir))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := verbs(plan), "remove old.conf, create a, disable ufw, remove adoption.guard.table"; got != want {
|
||||
t.Errorf("planned %q, want %q", got, want)
|
||||
}
|
||||
if got, want := strings.Join(ids(plan), " "), strings.Join(outcomeIDs(report), " "); got != want {
|
||||
t.Errorf("the plan said %q and the apply did %q", got, want)
|
||||
}
|
||||
if fake.u.active || !state.Firewall.DisabledByMesh {
|
||||
t.Error("converging did not retire ufw")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAResourceRunInAHeldContainerIsPlannedAsItIsApplied(t *testing.T) {
|
||||
// A run-once step sharing a container's namespace runs in it, as an action's `in` does.
|
||||
img := "example/x@sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
||||
d := parse(t, `{"declaration":1,"adoption":{"taken":["web"],"untaken":{"db":["db.server"]}},"resources":[
|
||||
{"id":"web.server","type":"container","name":"web","image":"`+img+`"},
|
||||
{"id":"db.server","type":"container","name":"db","image":"`+img+`"},
|
||||
{"id":"db.init","type":"container","name":"db-init","image":"`+img+`","run-once":true,"network":"container:db"},
|
||||
{"id":"web.warm","type":"container","name":"web-warm","image":"`+img+`","run-once":true,"network":"container:web"}]}`)
|
||||
known := store.State{}
|
||||
known.RecordHeld(store.Held{ID: "db.server", Module: "db", Kind: "container", Target: "db", Container: "predecessor"})
|
||||
known.RecordHeld(store.Held{ID: "web.server", Module: "web", Kind: "container", Target: "web", Container: "predecessor"})
|
||||
got := verbs(Plan(d, known, store.OriginDeclared))
|
||||
// db is untaken, so what runs in it waits; web is taken, so its held container is replaced (the
|
||||
// cutover) and what runs in it runs.
|
||||
if got != "create web.server, hold db.server, hold db.init, create web.warm" {
|
||||
t.Errorf("planned %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPlanSaysAContainerIsRecreatedWhenAFileItReadsChanged(t *testing.T) {
|
||||
// The apply recreates a container when the content of a file it reads at creation changed
|
||||
// (novox/hq 04-ISSUES/103); the plan says so from the record alone — what the container was
|
||||
// created reading, against what this apply will write. And a container recorded before the
|
||||
// host kept that record is accepted, so it is a check, not an update.
|
||||
dir := t.TempDir()
|
||||
env := filepath.Join(dir, "forge.env")
|
||||
declare := func(port string) *declaration.Declaration {
|
||||
return trusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"forge.env","type":"file","path":"`+env+`","content":"DATABASE_PORT=`+port+`\n"},
|
||||
{"id":"forge.server","type":"container","name":"forge","image":"`+pinned+`","env-file":["`+env+`"]}]}`)
|
||||
}
|
||||
created := digestOf("DATABASE_PORT=5432\n")
|
||||
known := store.State{}
|
||||
known.Record(store.Applied{ID: "forge.env", Type: "file", Target: env, Wrote: created})
|
||||
known.Record(store.Applied{ID: "forge.server", Type: "container", Target: "forge",
|
||||
Reads: map[string]string{env: created}})
|
||||
|
||||
if got := verbs(Plan(declare("5432"), known, store.OriginCarried)); got != "check forge.env, check forge.server" {
|
||||
t.Errorf("nothing changed and the plan says %q", got)
|
||||
}
|
||||
steps := Plan(declare("5433"), known, store.OriginCarried)
|
||||
if got := verbs(steps); got != "update forge.env, update forge.server" {
|
||||
t.Fatalf("the env-file changes and the plan says %q", got)
|
||||
}
|
||||
if !strings.Contains(steps[1].Why, env+" changed") {
|
||||
t.Errorf("the plan does not say which file: %+v", steps[1])
|
||||
}
|
||||
|
||||
// No record of what it read: labelled by an earlier host, accepted as it is.
|
||||
known.Record(store.Applied{ID: "forge.server", Type: "container", Target: "forge"})
|
||||
if got := verbs(Plan(declare("5433"), known, store.OriginCarried)); got != "update forge.env, check forge.server" {
|
||||
t.Errorf("a container with no record of what it read is planned as %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPlanSaysWhichUnitsAnUndeclareGivesBackAndWhichItLeaves(t *testing.T) {
|
||||
// novox/hq ADR 0118, said before it is done: "restore" only where removal may stop or disable
|
||||
// something, and a unit whose file the mesh wrote named as the mesh's.
|
||||
known := store.State{}
|
||||
known.Record(store.Applied{ID: "guard-unit", Type: "file", Target: "/etc/systemd/system/mesh-guard.service"})
|
||||
known.Record(store.Applied{ID: "guard", Type: "service", Target: "mesh-guard.service"})
|
||||
known.Record(store.Applied{ID: "filter", Type: "service", Target: "filter.service",
|
||||
Found: &store.FoundUnit{State: "stopped"}})
|
||||
known.Record(store.Applied{ID: "boot", Type: "service", Target: "boot.service",
|
||||
Found: &store.FoundUnit{State: "running", Boot: "disabled"}})
|
||||
known.Record(store.Applied{ID: "runtime", Type: "service", Target: "docker.service",
|
||||
Found: &store.FoundUnit{State: "running", Boot: "enabled"}})
|
||||
known.Record(store.Applied{ID: "old", Type: "service", Target: "sshd.service"})
|
||||
known.Record(store.Applied{ID: "nm", Type: "service", Target: "NetworkManager.service", Stateless: true})
|
||||
|
||||
steps := Plan(parse(t, nothingButA(t)), known, store.OriginCarried)
|
||||
got := verbs(steps)
|
||||
want := "forget nm, forget old, forget runtime, restore boot, restore filter, remove guard, remove guard-unit, create other"
|
||||
if got != want {
|
||||
t.Fatalf("planned %s\nwant %s", got, want)
|
||||
}
|
||||
for _, s := range steps {
|
||||
switch s.ID {
|
||||
case "guard":
|
||||
if !strings.Contains(s.Why, "unit file") {
|
||||
t.Errorf("the mesh's own unit was not named as the mesh's: %q", s.Why)
|
||||
}
|
||||
case "filter":
|
||||
if !strings.Contains(s.Why, "found it stopped") {
|
||||
t.Errorf("what the unit goes back to went unsaid: %q", s.Why)
|
||||
}
|
||||
case "old":
|
||||
if !strings.Contains(s.Why, "left as it is") {
|
||||
t.Errorf("a unit with nothing found was not said to be left: %q", s.Why)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ import (
|
||||
// Under the mesh's own directory rather than somewhere a distribution owns: these are files the
|
||||
// mesh puts there and replaces, and putting them where a package manager also writes is how two
|
||||
// owners end up disagreeing about one path.
|
||||
const daemonRoot = "/var/lib/mesh/daemons"
|
||||
var daemonRoot = "/var/lib/mesh/daemons"
|
||||
|
||||
// unitDir is where the mesh writes the units it owns. A variable only so a test can point it at a
|
||||
// directory of its own.
|
||||
@@ -290,3 +290,54 @@ func unitValue(key, value string) string {
|
||||
).Replace(value)
|
||||
return `"` + key + "=" + escaped + `"`
|
||||
}
|
||||
|
||||
// removeProcess takes away a process the mesh no longer declares: its timer and unit stopped and
|
||||
// disabled, their files removed, the supervisor told, and the unpacked bundle deleted.
|
||||
//
|
||||
// **All of it is the host's**, which is why all of it goes (novox/hq ADR 0118). Before this there
|
||||
// was no way to remove a process at all, and one left undeclared failed every apply on its node
|
||||
// until someone edited the host's state by hand — unassigning any module that ran its own code
|
||||
// stranded the machine.
|
||||
//
|
||||
// Idempotent, like every removal: a unit already gone is not an error, and a record whose files
|
||||
// have all vanished is forgotten rather than reported as removed.
|
||||
func removeProcess(ctx context.Context, a store.Applied, run Runner) (string, string, error) {
|
||||
name := a.Target
|
||||
if problem := declaration.ProcessNameProblem(name); problem != "" {
|
||||
// The name is a unit name and a directory under the mesh's own, and what goes is that
|
||||
// directory, whole. One that could climb out of either — ".." is the mesh's own directory's
|
||||
// parent — is refused rather than acted on, whatever wrote it into the record.
|
||||
return "", "", fmt.Errorf("a process recorded under %q cannot be removed by name: %s", name, problem)
|
||||
}
|
||||
found := false
|
||||
for _, unit := range []string{name + ".timer", name + ".service"} {
|
||||
path := filepath.Join(unitDir, unit)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
// The timer first, so a scheduled run cannot start the service between the two.
|
||||
if _, err := run(ctx, "systemctl", "disable", "--now", unit); err != nil {
|
||||
return "", "", fmt.Errorf("stopping %s: %w", unit, err)
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
if found {
|
||||
if _, err := run(ctx, "systemctl", "daemon-reload"); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
bundle := filepath.Join(daemonRoot, name)
|
||||
if _, err := os.Stat(bundle); err == nil {
|
||||
found = true
|
||||
if err := os.RemoveAll(bundle); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return "forgotten", "no longer there", nil
|
||||
}
|
||||
return "removed", "stopped; its unit and its bundle removed — the mesh's own code", nil
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
func aProcess() *declaration.Process {
|
||||
@@ -163,3 +167,92 @@ func TestAnEnvironmentValueMeansWhatTheDeclarationSaid(t *testing.T) {
|
||||
t.Fatalf("a quote was not escaped, so the value ends early: %s", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUndeclaredProcessIsRemovedWithItsUnitAndBundle(t *testing.T) {
|
||||
// novox/hq ADR 0118: a process's unit and bundle are the host's own, so they go with the
|
||||
// declaration. Before, there was no way to remove a process at all, and one left undeclared
|
||||
// failed every apply on its node.
|
||||
units, bundles := t.TempDir(), t.TempDir()
|
||||
wasUnits, wasBundles := unitDir, daemonRoot
|
||||
unitDir, daemonRoot = units, bundles
|
||||
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
|
||||
|
||||
for _, f := range []string{"mesh-job.service", "mesh-job.timer"} {
|
||||
if err := os.WriteFile(filepath.Join(units, f), []byte("[Unit]\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(bundles, "mesh-job", "bin"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var commands []string
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
commands = append(commands, strings.Join(args, " "))
|
||||
return "", nil
|
||||
}
|
||||
known := store.State{Resources: []store.Applied{{ID: "p", Type: "process", Target: "mesh-job"}}}
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"f","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
|
||||
]}`)
|
||||
report, after, err := Apply(context.Background(), archHost(t), d, known, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("an undeclared process failed the apply: %v", err)
|
||||
}
|
||||
joined := strings.Join(commands, "; ")
|
||||
timer, service := strings.Index(joined, "disable --now mesh-job.timer"), strings.Index(joined, "disable --now mesh-job.service")
|
||||
if timer < 0 || service < 0 || timer > service {
|
||||
t.Errorf("the timer and the unit were not stopped, timer first: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "daemon-reload") {
|
||||
t.Errorf("the service manager was not told its units changed: %s", joined)
|
||||
}
|
||||
for _, gone := range []string{filepath.Join(units, "mesh-job.service"), filepath.Join(units, "mesh-job.timer"), filepath.Join(bundles, "mesh-job")} {
|
||||
if _, err := os.Stat(gone); !os.IsNotExist(err) {
|
||||
t.Errorf("%s is still there", gone)
|
||||
}
|
||||
}
|
||||
if o := outcomeOf(report, "p"); o.Action != "removed" {
|
||||
t.Errorf("outcome %+v, want removed", o)
|
||||
}
|
||||
if _, still := after.Find("p"); still {
|
||||
t.Error("the host still believes it owns the process")
|
||||
}
|
||||
|
||||
// Again, with everything already gone: forgotten, not an error.
|
||||
_, _, err = Apply(context.Background(), archHost(t), d, known, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Errorf("removing a process that is already gone failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProcessRecordedUnderAPathlikeNameIsRefusedNotRemoved(t *testing.T) {
|
||||
// filepath.Join(daemonRoot, "..") is the mesh's own directory, and removal deletes what that
|
||||
// names, whole. A record is what some host wrote, perhaps under looser rules than today's, so
|
||||
// the removal holds the name to the declaration's rule again (novox/hq ADR 0118).
|
||||
root := t.TempDir()
|
||||
bundles := filepath.Join(root, "daemons")
|
||||
wasUnits, wasBundles := unitDir, daemonRoot
|
||||
unitDir, daemonRoot = t.TempDir(), bundles
|
||||
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
|
||||
precious := filepath.Join(root, "state.json")
|
||||
if err := os.MkdirAll(filepath.Join(bundles, "other"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(precious, []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"..", ".", "", "-x"} {
|
||||
known := store.State{Resources: []store.Applied{{ID: "p", Type: "process", Target: name}}}
|
||||
d := parse(t, `{"declaration":1,"resources":[
|
||||
{"id":"f","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
|
||||
]}`)
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, known, store.OriginCarried, noServices, nil, nil); err == nil {
|
||||
t.Errorf("a process recorded as %q was removed by name", name)
|
||||
}
|
||||
for _, still := range []string{precious, filepath.Join(bundles, "other")} {
|
||||
if _, err := os.Stat(still); err != nil {
|
||||
t.Fatalf("removing a process recorded as %q took %s with it", name, still)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// Defends novox/hq 04-ISSUES/103: a container is recreated when the CONTENT of a file it reads at
|
||||
// creation changes, not only when its path does. A container takes its env-file and its mounted
|
||||
// files in once, when it is created; `docker restart` hands it the same environment again, so
|
||||
// only a recreate carries a rewritten file into the process.
|
||||
//
|
||||
// The runtime here is the `machine` fake: it keeps the spec label the host gave a container and
|
||||
// hands it back on inspect, so the comparison under test is the one the host really makes,
|
||||
// against what it really wrote — not against a spec a test imagined.
|
||||
|
||||
func applyCarried(t *testing.T, d *declaration.Declaration, known store.State, m *machine,
|
||||
log func(string)) (Report, store.State) {
|
||||
t.Helper()
|
||||
report, state, err := Apply(context.Background(), archHost(t), d, known, store.OriginCarried, m.run, log, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
return report, state
|
||||
}
|
||||
|
||||
func TestAContainerIsRecreatedWhenItsEnvFileChanged(t *testing.T) {
|
||||
// The night of the issue: the store was given a new port, the host rewrote the forge's
|
||||
// environment file with it — and left the forge running with the old one.
|
||||
dir := t.TempDir()
|
||||
env := filepath.Join(dir, "forge.env")
|
||||
declare := func(port string) *declaration.Declaration {
|
||||
return parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"forge.env","type":"file","path":"`+env+`","content":"DATABASE_PORT=`+port+`\n","mode":"0600"},
|
||||
{"id":"forge.server","type":"container","name":"forge","image":"`+pinned+`","env-file":["`+env+`"]}
|
||||
]}`)
|
||||
}
|
||||
m := &machine{containers: map[string]*fakeContainer{}}
|
||||
var logged []string
|
||||
log := func(line string) { logged = append(logged, line) }
|
||||
|
||||
report, state := applyCarried(t, declare("5432"), store.State{}, m, log)
|
||||
if o := outcomeOf(report, "forge.server"); o.Action != "created" {
|
||||
t.Fatalf("the container was not created: %+v", report.Outcomes)
|
||||
}
|
||||
|
||||
// The store moved. The file is rewritten in this apply, before the container is reached, and
|
||||
// the container must follow it in the same pass.
|
||||
m.asked, logged = nil, nil
|
||||
report, state = applyCarried(t, declare("5433"), state, m, log)
|
||||
if !m.removed("forge") || !m.did("docker run") {
|
||||
t.Fatalf("the container kept running with the old environment after its env-file changed: %v", m.asked)
|
||||
}
|
||||
o := outcomeOf(report, "forge.server")
|
||||
if o.Action != "updated" || o.Detail != "recreated: "+env+" changed" {
|
||||
t.Errorf("the recreate did not say which file changed: %+v", o)
|
||||
}
|
||||
var said bool
|
||||
for _, line := range logged {
|
||||
if strings.Contains(line, "updated forge.server") && strings.Contains(line, "recreated: "+env+" changed") {
|
||||
said = true
|
||||
}
|
||||
}
|
||||
if !said {
|
||||
t.Errorf("the log did not say which file made the container recreate: %q", logged)
|
||||
}
|
||||
|
||||
// And with nothing moved, it is left alone: content is part of the identity, not a tripwire.
|
||||
m.asked = nil
|
||||
report, _ = applyCarried(t, declare("5433"), state, m, log)
|
||||
if m.did("docker rm") || m.did("docker run") || report.Changed() {
|
||||
t.Errorf("a container whose env-file did not change was recreated: %v %+v", m.asked, report.Outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEnvFileTheHostDidNotWriteIsStillReadForWhatItHolds(t *testing.T) {
|
||||
// The host has no record of this file — a predecessor left it, or something else on the
|
||||
// machine maintains it — and the container still reads it once. Its content is read from the
|
||||
// disk, so a change is a recreate exactly as for a file the host wrote.
|
||||
dir := t.TempDir()
|
||||
env := filepath.Join(dir, "app.env")
|
||||
if err := os.WriteFile(env, []byte("TOKEN=old\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"app.server","type":"container","name":"app","image":"`+pinned+`","env-file":["`+env+`"]}
|
||||
]}`)
|
||||
m := &machine{containers: map[string]*fakeContainer{}}
|
||||
_, state := applyCarried(t, d, store.State{}, m, nil)
|
||||
|
||||
if err := os.WriteFile(env, []byte("TOKEN=new\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.asked = nil
|
||||
report, _ := applyCarried(t, d, state, m, nil)
|
||||
if !m.removed("app") || !m.did("docker run") {
|
||||
t.Fatalf("a container reading an env-file the host did not write was not recreated when it changed: %v", m.asked)
|
||||
}
|
||||
if o := outcomeOf(report, "app.server"); o.Detail != "recreated: "+env+" changed" {
|
||||
t.Errorf("the recreate did not name the file: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContainerIsRecreatedWhenAMountedSecretChanged(t *testing.T) {
|
||||
// A rotated credential has the same shape as a moved port: the host writes the file the
|
||||
// container mounts, and the process holds the value it was created with.
|
||||
dir := t.TempDir()
|
||||
secret := filepath.Join(dir, "db.secret")
|
||||
declare := func(value string) *declaration.Declaration {
|
||||
return parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"app.secret","type":"file","path":"`+secret+`","content":"`+value+`","mode":"0600"},
|
||||
{"id":"app.server","type":"container","name":"app","image":"`+pinned+`",
|
||||
"volumes":["`+secret+`:/run/secrets/db:ro"]}
|
||||
]}`)
|
||||
}
|
||||
m := &machine{containers: map[string]*fakeContainer{}}
|
||||
_, state := applyCarried(t, declare("hunter2"), store.State{}, m, nil)
|
||||
|
||||
m.asked = nil
|
||||
report, _ := applyCarried(t, declare("correct-horse-battery-staple"), state, m, nil)
|
||||
if !m.removed("app") || !m.did("docker run") {
|
||||
t.Fatalf("the container kept the secret it was created with after the mounted file changed: %v", m.asked)
|
||||
}
|
||||
if o := outcomeOf(report, "app.server"); o.Action != "updated" || o.Detail != "recreated: "+secret+" changed" {
|
||||
t.Errorf("the recreate did not say which file changed: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMountedDirectoryIsNotLookedInside(t *testing.T) {
|
||||
// A bind-mounted directory is not part of what a container is — not the data the service
|
||||
// grows in it, and not the files the host itself writes there either. Whether a service reads
|
||||
// a file under its directory once at start or watches it live is the service's business: the
|
||||
// route proxy re-reads its routes live, a provisioner sidecar polls what it receives every few
|
||||
// seconds, and recreating either for a file the host rewrote would kill them for nothing. A
|
||||
// module whose container does read such a file once says so with restart-on, which stays the
|
||||
// opt-in.
|
||||
dir := t.TempDir()
|
||||
state := filepath.Join(dir, "state")
|
||||
config := filepath.Join(state, "config.toml")
|
||||
declare := func(level, restartOn string) *declaration.Declaration {
|
||||
return parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"app.state","type":"directory","path":"`+state+`"},
|
||||
{"id":"app.config","type":"file","path":"`+config+`","content":"level = \"`+level+`\"\n"},
|
||||
{"id":"app.server","type":"container","name":"app","image":"`+pinned+`",
|
||||
"volumes":["`+state+`:/var/lib/app"]`+restartOn+`}
|
||||
]}`)
|
||||
}
|
||||
m := &machine{containers: map[string]*fakeContainer{}}
|
||||
_, known := applyCarried(t, declare("info", ""), store.State{}, m, nil)
|
||||
|
||||
// The service grows its data in the directory it was given.
|
||||
if err := os.WriteFile(filepath.Join(state, "app.db"), []byte("rows"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(state, "cache"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(state, "cache", "index"), []byte("entries"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.asked = nil
|
||||
report, known := applyCarried(t, declare("info", ""), known, m, nil)
|
||||
if m.did("docker rm") || m.did("docker run") || report.Changed() {
|
||||
t.Errorf("a container was recreated for data its service wrote in a mounted directory: %v %+v",
|
||||
m.asked, report.Outcomes)
|
||||
}
|
||||
|
||||
// The host rewrites its own file under the same directory: still not a reason. The container
|
||||
// did not name it.
|
||||
m.asked = nil
|
||||
report, known = applyCarried(t, declare("debug", ""), known, m, nil)
|
||||
if m.did("docker rm") || m.did("docker run") {
|
||||
t.Errorf("a container was recreated for a file under a mounted directory it did not name: %v", m.asked)
|
||||
}
|
||||
if o := outcomeOf(report, "app.config"); o.Action != "updated" {
|
||||
t.Fatalf("the config was not rewritten: %+v", o)
|
||||
}
|
||||
|
||||
// Naming it is what makes it a reason, as before this change.
|
||||
m.asked = nil
|
||||
report, _ = applyCarried(t, declare("trace", `,"restart-on":["app.config"]`), known, m, nil)
|
||||
if !m.removed("app") || !m.did("docker run") {
|
||||
t.Fatalf("a container naming a rewritten file under its mount was not recreated: %v", m.asked)
|
||||
}
|
||||
if o := outcomeOf(report, "app.server"); !strings.Contains(o.Detail, "app.config") {
|
||||
t.Errorf("the recreate did not name why: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContainerLabelledBeforeTheHostReadItsFilesIsAcceptedNotRecreated(t *testing.T) {
|
||||
// The first apply after the host upgrades finds every container carrying a label computed
|
||||
// without the file lines. Recreating them all would be a restart storm across the mesh in
|
||||
// declaration order, the store first. A label that matches the spec as it used to be computed
|
||||
// is accepted: what the container reads is recorded now, and from then on a change is caught.
|
||||
dir := t.TempDir()
|
||||
env := filepath.Join(dir, "forge.env")
|
||||
declare := func(port string) *declaration.Declaration {
|
||||
return parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"forge.env","type":"file","path":"`+env+`","content":"DATABASE_PORT=`+port+`\n","mode":"0600"},
|
||||
{"id":"forge.server","type":"container","name":"forge","image":"`+pinned+`","env-file":["`+env+`"]}
|
||||
]}`)
|
||||
}
|
||||
// The machine as the previous host left it: the file written and recorded, the container up
|
||||
// under the label that host computed — the spec with nothing about the file's content.
|
||||
if err := os.WriteFile(env, []byte("DATABASE_PORT=5432\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := declare("5432")
|
||||
legacy := containerSpecReading(d.Resources[1].(*declaration.Container), nil, nil)
|
||||
known := store.State{}
|
||||
known.Record(store.Applied{ID: "forge.env", Type: "file", Origin: store.OriginCarried, Target: env,
|
||||
Wrote: digestOf("DATABASE_PORT=5432\n")})
|
||||
known.Record(store.Applied{ID: "forge.server", Type: "container", Origin: store.OriginCarried, Target: "forge"})
|
||||
m := &machine{containers: map[string]*fakeContainer{"forge": {id: "made-by-host", running: true, spec: legacy}}}
|
||||
|
||||
report, known := applyCarried(t, d, known, m, nil)
|
||||
if m.did("docker rm") || m.did("docker run") || report.Changed() {
|
||||
t.Fatalf("a container labelled by the previous host was recreated on upgrade: %v %+v", m.asked, report.Outcomes)
|
||||
}
|
||||
if got, _ := known.Find("forge.server"); got.Reads[env] != digestOf("DATABASE_PORT=5432\n") {
|
||||
t.Fatalf("what the accepted container reads was not recorded: %+v", got)
|
||||
}
|
||||
// Accepted stays accepted: the next pass with nothing moved is quiet too.
|
||||
m.asked = nil
|
||||
report, known = applyCarried(t, d, known, m, nil)
|
||||
if m.did("docker rm") || m.did("docker run") || report.Changed() {
|
||||
t.Fatalf("an accepted container was recreated on the pass after: %v", m.asked)
|
||||
}
|
||||
|
||||
// And a change to the file is caught from the record, and the label is renewed.
|
||||
m.asked = nil
|
||||
report, _ = applyCarried(t, declare("5433"), known, m, nil)
|
||||
if !m.removed("forge") || !m.did("docker run") {
|
||||
t.Fatalf("an accepted container was not recreated when its env-file changed: %v", m.asked)
|
||||
}
|
||||
if o := outcomeOf(report, "forge.server"); o.Detail != "recreated: "+env+" changed" {
|
||||
t.Errorf("the recreate did not name the file: %+v", o)
|
||||
}
|
||||
if m.containers["forge"].spec == legacy {
|
||||
t.Error("the recreated container still carries the legacy label")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContainerAdoptedUnderANewIdStillSaysWhichFileChanged(t *testing.T) {
|
||||
// The bundle's `store` becomes the postgres module's `postgres.server`: the same container by
|
||||
// name, under a new id with no record of its own. A file change on that day is a real change,
|
||||
// and the record of what it read is under the old id — by name, it is found.
|
||||
dir := t.TempDir()
|
||||
env := filepath.Join(dir, "store.env")
|
||||
m := &machine{containers: map[string]*fakeContainer{}}
|
||||
raised := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"env","type":"file","path":"`+env+`","content":"PORT=5432\n","mode":"0600"},
|
||||
{"id":"store","type":"container","name":"mesh-store","image":"`+pinned+`","env-file":["`+env+`"]}
|
||||
]}`)
|
||||
_, known := applyCarried(t, raised, store.State{}, m, nil)
|
||||
|
||||
adopted := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"postgres.env","type":"file","path":"`+env+`","content":"PORT=5433\n","mode":"0600"},
|
||||
{"id":"postgres.server","type":"container","name":"mesh-store","image":"`+pinned+`","env-file":["`+env+`"]}
|
||||
]}`)
|
||||
m.asked = nil
|
||||
report, _, err := Apply(context.Background(), archHost(t), adopted, known, store.OriginDeclared, m.run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o := outcomeOf(report, "postgres.server"); o.Action != "updated" || o.Detail != "recreated: "+env+" changed" {
|
||||
t.Errorf("a container adopted under a new id did not say which file changed: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHeldContainerIsNotRecreatedByAChangedHeldFile(t *testing.T) {
|
||||
// On an adopted node the predecessor's container and the file it reads are both held as
|
||||
// found (novox/hq ADR 0100). The predecessor rewriting its own file is reported on the file
|
||||
// — and is nothing to recreate the container for: it is not the host's to recreate.
|
||||
dir := t.TempDir()
|
||||
env := filepath.Join(dir, "hello.env")
|
||||
if err := os.WriteFile(env, []byte("PORT=5432\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := &machine{containers: map[string]*fakeContainer{
|
||||
"hello-web": {id: "predecessor-id", running: true},
|
||||
}}
|
||||
d := adopted(t, untaken("hello-web.env", "hello-web.server"),
|
||||
`{"id":"hello-web.env","type":"file","path":"`+env+`","content":"PORT=5433\n","mode":"0600"},
|
||||
{"id":"hello-web.server","type":"container","name":"hello-web","image":"`+pinned+`","env-file":["`+env+`"]}`)
|
||||
report, state := applyAdopted(t, d, store.State{}, m, dir)
|
||||
if outcomeOf(report, "hello-web.env").Action != "held" || outcomeOf(report, "hello-web.server").Action != "held" {
|
||||
t.Fatalf("the predecessor's file and container were not held: %+v", report.Outcomes)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(env, []byte("PORT=5434\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.asked = nil
|
||||
report, state = applyAdopted(t, d, state, m, dir)
|
||||
for _, a := range m.asked {
|
||||
if strings.HasPrefix(a, "docker run") || strings.HasPrefix(a, "docker rm") {
|
||||
t.Fatalf("a held container was acted on because a held file changed: %s", a)
|
||||
}
|
||||
}
|
||||
if o := outcomeOf(report, "hello-web.server"); o.Action != "held" {
|
||||
t.Errorf("the container is no longer held: %+v", o)
|
||||
}
|
||||
if h, _ := state.HeldAt("hello-web.env"); h.Changed != "rewritten" {
|
||||
t.Errorf("the predecessor's rewrite was not reported on the file: %+v", h)
|
||||
}
|
||||
if h, _ := state.HeldAt("hello-web.server"); h.Changed != "" {
|
||||
t.Errorf("a file change was charged to the container: %+v", h)
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func TestARunOnceStepIsRunToCompletionNotLeftRunning(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("a completed run-once step was not recorded")
|
||||
}
|
||||
if applied.Wrote != containerSpec(d.Resources[0].(*declaration.Container), nil) {
|
||||
if applied.Wrote != containerSpec(d.Resources[0].(*declaration.Container), inputs{}) {
|
||||
t.Errorf("the run-once record is not the declaration's digest: %q", applied.Wrote)
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func TestAFailedRunOnceStepGatesWhatFollows(t *testing.T) {
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "27.0\n", nil
|
||||
case "inspect":
|
||||
case "container":
|
||||
return "false\t\n", errors.New("no such container")
|
||||
case "run":
|
||||
startedNames = append(startedNames, nameOf(args))
|
||||
@@ -155,7 +155,7 @@ func TestARunOnceStepAlreadyCompletedIsNotReRun(t *testing.T) {
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"seed","type":"container","name":"seed","image":"`+pinned+`","run-once":true}
|
||||
]}`)
|
||||
want := containerSpec(d.Resources[0].(*declaration.Container), nil)
|
||||
want := containerSpec(d.Resources[0].(*declaration.Container), inputs{})
|
||||
|
||||
var ran bool
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
@@ -232,7 +232,7 @@ func TestARunOnceStepRunsAgainWhenWhatItReadsChanged(t *testing.T) {
|
||||
was := map[string]string{"env": declaredDigest(&declaration.File{Content: "ACME_ROOTS=https://10.0.0.1/roots.pem\n"})}
|
||||
known := store.State{}
|
||||
known.Record(store.Applied{ID: "trust", Type: "container", Origin: store.OriginCarried, Target: "trust",
|
||||
Wrote: containerSpec(d.Resources[1].(*declaration.Container), was)})
|
||||
Wrote: containerSpec(d.Resources[1].(*declaration.Container), inputs{declares: was})})
|
||||
|
||||
var ran bool
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
@@ -262,7 +262,7 @@ func TestARunOnceStepRunsAgainWhenWhatItReadsChanged(t *testing.T) {
|
||||
settled := store.State{}
|
||||
settled.Record(store.Applied{ID: "env", Type: "file", Origin: store.OriginCarried, Target: env, Wrote: now["env"]})
|
||||
settled.Record(store.Applied{ID: "trust", Type: "container", Origin: store.OriginCarried, Target: "trust",
|
||||
Wrote: containerSpec(d.Resources[1].(*declaration.Container), now)})
|
||||
Wrote: containerSpec(d.Resources[1].(*declaration.Container), inputs{declares: now})})
|
||||
if _, _, err := Apply(context.Background(), archHost(t), d, settled, store.OriginCarried, run, nil, nil); err != nil {
|
||||
t.Fatalf("re-apply failed: %v", err)
|
||||
}
|
||||
@@ -280,14 +280,14 @@ func TestAContainerNamingARunOnceStepIsRecreatedWhenItRan(t *testing.T) {
|
||||
{"id":"server","type":"container","name":"server","image":"`+pinned+`","restart-on":["trust"]}
|
||||
]}`)
|
||||
declares := map[string]string{"trust": declaredDigest(d.Resources[0].(*declaration.Container))}
|
||||
spec := containerSpec(d.Resources[1].(*declaration.Container), declares)
|
||||
spec := containerSpec(d.Resources[1].(*declaration.Container), inputs{declares: declares})
|
||||
|
||||
var removed, created bool
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "27.0\n", nil
|
||||
case "inspect":
|
||||
case "container":
|
||||
// The server is up, made from exactly this spec — nothing but the step's run says
|
||||
// it must be replaced.
|
||||
return "true\t" + spec, nil
|
||||
@@ -316,3 +316,85 @@ func TestAContainerNamingARunOnceStepIsRecreatedWhenItRan(t *testing.T) {
|
||||
t.Errorf("the recreation did not name the step as its reason: %+v", server)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedStepGatesItsModuleAndNotTheMachine(t *testing.T) {
|
||||
// **The blast radius of a step is its module** (novox/hq ADR 0136). A step exists to make
|
||||
// something true before the next thing in its own module needs it — a store seeded before the
|
||||
// broker starts, a schema prepared before the version that needs it runs. Stopping the whole
|
||||
// apply is what this host's own loop calls holding a machine hostage, and it was already
|
||||
// rejected for every other shape (04-ISSUES/011): a module whose database is briefly
|
||||
// unreachable must not stop every module declared after it.
|
||||
var startedNames []string
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "27.0\n", nil
|
||||
case "container":
|
||||
return "false\t\n", errors.New("no such container")
|
||||
case "run":
|
||||
startedNames = append(startedNames, nameOf(args))
|
||||
if nameOf(args) == "catalogue-prepare" {
|
||||
return "", errors.New("exit status 1") // the schema could not be reached
|
||||
}
|
||||
return "deadbeef\n", nil
|
||||
case "rm":
|
||||
return "", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"mesh-catalog.runtime-prepare","type":"container","name":"catalogue-prepare","image":"`+pinned+`","run-once":true},
|
||||
{"id":"mesh-catalog.runtime","type":"container","name":"catalogue","image":"`+pinned+`"},
|
||||
{"id":"gitea.server","type":"container","name":"forge","image":"`+pinned+`"}
|
||||
]}`)
|
||||
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("a failed step was not reported as a failure")
|
||||
}
|
||||
started := map[string]bool{}
|
||||
for _, n := range startedNames {
|
||||
started[n] = true
|
||||
}
|
||||
if started["catalogue"] {
|
||||
t.Error("the module's own workload ran although its step did not complete")
|
||||
}
|
||||
if !started["forge"] {
|
||||
t.Error("another module was not attempted, so one module's step held the machine hostage")
|
||||
}
|
||||
// And the machine's own account says which was not attempted, rather than leaving it to be
|
||||
// inferred from silence.
|
||||
var skipped string
|
||||
for _, o := range report.Outcomes {
|
||||
if o.Action == "skipped" {
|
||||
skipped = o.ID
|
||||
}
|
||||
}
|
||||
if skipped != "mesh-catalog.runtime" {
|
||||
t.Errorf("the report does not say what was not attempted: %q", skipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAResourcesOwnerIsReadToTheLastDot(t *testing.T) {
|
||||
// **A module's name may contain a dot.** `novox.be` is one on this mesh, so reading a resource's
|
||||
// owner to the first dot would make its resources belong to something called "novox" — and a gate
|
||||
// would skip whatever else happened to start that way. A resource's own id never contains one,
|
||||
// which is what makes the last dot the boundary.
|
||||
for identity, want := range map[string]string{
|
||||
"novox.be.server": "novox.be",
|
||||
"mesh-catalog.runtime-prepare": "mesh-catalog",
|
||||
"gitea.admin-bootstrap": "gitea",
|
||||
} {
|
||||
got, ours := moduleOf(identity)
|
||||
if !ours || got != want {
|
||||
t.Errorf("%q belongs to %q (%v), want %q", identity, got, ours, want)
|
||||
}
|
||||
}
|
||||
// What the mesh declares in its own right belongs to no module: the foundation's resources carry
|
||||
// no dot, and the adoption's are the mesh's.
|
||||
for _, identity := range []string{"container-runtime", "store-ready", "adoption.guard", ".server"} {
|
||||
if _, ours := moduleOf(identity); ours {
|
||||
t.Errorf("%q was read as a module's", identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ func (s *Scheduler) Sync(d *declaration.Declaration, held map[string]bool) {
|
||||
// Nothing to read: a scheduled container may not declare restart-on — it runs to completion
|
||||
// on its cadence rather than staying running to be restarted — so its identity cannot
|
||||
// depend on another resource's content and there is nothing to pass.
|
||||
spec := containerSpec(c, nil)
|
||||
spec := containerSpec(c, inputs{})
|
||||
if existing := s.jobs[c.Identity()]; existing != nil && existing.spec == spec {
|
||||
// Unchanged: keep where it is in its cadence, refresh the declaration pointer only.
|
||||
existing.container = c
|
||||
|
||||
@@ -208,7 +208,7 @@ func TestAScheduledStepDoesNotGateWhatFollows(t *testing.T) {
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "27.0\n", nil
|
||||
case "inspect":
|
||||
case "container":
|
||||
// The container name is the last argument to `docker inspect --format ... <name>`.
|
||||
target := args[len(args)-1]
|
||||
if spec, up := specs[target]; up {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0117: a service that omits its state leaves the unit's lifecycle to the
|
||||
// machine. The mesh reflects its triggers on a unit already running and does nothing else to it —
|
||||
// never starts, stops, enables or disables it, and forgets it when undeclared.
|
||||
|
||||
// unitIn is a service manager whose one unit is active or not, recording what it is asked.
|
||||
func unitIn(active bool, commands *[]string) Runner {
|
||||
return func(_ context.Context, name string, args ...string) (string, error) {
|
||||
line := name + " " + strings.Join(args, " ")
|
||||
*commands = append(*commands, line)
|
||||
switch {
|
||||
case strings.Contains(line, "is-enabled"):
|
||||
return "enabled", nil
|
||||
case strings.Contains(line, "show") && strings.Contains(line, "ActiveState"):
|
||||
if active {
|
||||
return "LoadState=loaded\nActiveState=active\nSubState=running", nil
|
||||
}
|
||||
return "LoadState=loaded\nActiveState=inactive\nSubState=dead", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func statelessDecl(path, content, triggers string) string {
|
||||
return fmt.Sprintf(`{"declaration":1,"resources":[
|
||||
{"id":"uplink.conf","type":"file","path":%q,"into":"block","content":%q},
|
||||
{"id":"uplink.manager","type":"service","unit":"NetworkManager.service",%s}
|
||||
]}`, path, content, triggers)
|
||||
}
|
||||
|
||||
// touched is whether any command would change the unit's lifecycle.
|
||||
func touched(commands []string) []string {
|
||||
var changing []string
|
||||
for _, c := range commands {
|
||||
for _, verb := range []string{" start ", " stop ", " restart ", " enable ", " disable ", " reload "} {
|
||||
if strings.Contains(c+" ", verb) {
|
||||
changing = append(changing, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
return changing
|
||||
}
|
||||
|
||||
func TestAStatelessServiceRunningIsReloadedForItsTrigger(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mesh.conf")
|
||||
var commands []string
|
||||
report, _, err := Apply(context.Background(), archHost(t),
|
||||
parse(t, statelessDecl(path, "[main]\ndns=none\n", `"reload-on":["uplink.conf"]`)),
|
||||
store.State{}, store.OriginDeclared, unitIn(true, &commands), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := strings.Join(commands, "\n")
|
||||
if !strings.Contains(joined, "systemctl reload NetworkManager.service") {
|
||||
t.Errorf("the running manager was not reloaded; commands were %v", commands)
|
||||
}
|
||||
for _, c := range touched(commands) {
|
||||
if !strings.Contains(c, "reload") {
|
||||
t.Errorf("the manager's lifecycle was touched: %s", c)
|
||||
}
|
||||
}
|
||||
if o := outcomeOf(report, "uplink.manager"); o.Action != "updated" || !strings.Contains(o.Detail, "reloaded for uplink.conf") {
|
||||
t.Errorf("reported as %q: %s", o.Action, o.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStatelessServiceNotRunningIsLeftSo(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mesh.conf")
|
||||
for _, triggers := range []string{`"reload-on":["uplink.conf"]`, `"restart-on":["uplink.conf"]`} {
|
||||
var commands []string
|
||||
report, _, err := Apply(context.Background(), archHost(t),
|
||||
parse(t, statelessDecl(path, fmt.Sprintf("# %s\n", triggers), triggers)),
|
||||
store.State{}, store.OriginDeclared, unitIn(false, &commands), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changing := touched(commands); len(changing) > 0 {
|
||||
t.Errorf("%s: an inactive unit was acted on: %v", triggers, changing)
|
||||
}
|
||||
if o := outcomeOf(report, "uplink.manager"); o.Action != "unchanged" ||
|
||||
o.Detail != "not running; the change applies at its next start" {
|
||||
t.Errorf("%s: reported as %q: %s", triggers, o.Action, o.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStatelessServiceIsRestartedOnlyForItsRestartTrigger(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mesh.conf")
|
||||
var commands []string
|
||||
d := parse(t, statelessDecl(path, "x\n", `"restart-on":["uplink.conf"]`))
|
||||
_, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared,
|
||||
unitIn(true, &commands), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(commands, "\n"), "stop NetworkManager.service") {
|
||||
t.Errorf("not restarted for its restart trigger; commands were %v", commands)
|
||||
}
|
||||
// Nothing it reflects changed: nothing is asked of the unit at all.
|
||||
commands = nil
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared,
|
||||
unitIn(true, &commands), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changing := touched(commands); len(changing) > 0 {
|
||||
t.Errorf("with nothing changed the unit was acted on: %v", changing)
|
||||
}
|
||||
if got := outcomeOf(report, "uplink.manager").Action; got != "unchanged" {
|
||||
t.Errorf("with nothing changed it was %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStatelessServiceUndeclaredIsForgottenNotStopped(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mesh.conf")
|
||||
var commands []string
|
||||
_, state, err := Apply(context.Background(), archHost(t),
|
||||
parse(t, statelessDecl(path, "x\n", `"reload-on":["uplink.conf"]`)),
|
||||
store.State{}, store.OriginDeclared, unitIn(true, &commands), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec, _ := state.Find("uplink.manager"); !rec.Stateless {
|
||||
t.Fatal("the record does not say the service was stateless")
|
||||
}
|
||||
if steps := Plan(somethingElse(t), state, store.OriginDeclared); !hasStep(steps, "forget", "uplink.manager") {
|
||||
t.Errorf("the preview does not forget it: %v", steps)
|
||||
}
|
||||
commands = nil
|
||||
report, _, err := Apply(context.Background(), archHost(t), somethingElse(t), state, store.OriginDeclared,
|
||||
unitIn(true, &commands), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changing := touched(commands); len(changing) > 0 {
|
||||
t.Errorf("undeclaring acted on the unit: %v", changing)
|
||||
}
|
||||
o := outcomeOf(report, "uplink.manager")
|
||||
if o.Action != "forgotten" || o.Detail != "its state was never the mesh's" {
|
||||
t.Errorf("undeclaring was %q: %s", o.Action, o.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStatelessServiceIsNeverHeldOnAnAdoptedNode(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "mesh.conf")
|
||||
resources := fmt.Sprintf(`{"id":"uplink.conf","type":"file","path":%q,"into":"block","content":"x\n"},
|
||||
{"id":"uplink.manager","type":"service","unit":"NetworkManager.service","reload-on":["uplink.conf"]}`, path)
|
||||
d := adopted(t, `{"taken":[],"untaken":{"uplink":["uplink.conf","uplink.manager"]}}`, resources)
|
||||
for _, s := range Plan(d, store.State{}, store.OriginDeclared) {
|
||||
if s.ID == "uplink.manager" && (s.Verb == "hold" || s.Verb == "create" || strings.Contains(s.Why, "replaces")) {
|
||||
t.Errorf("the preview holds or replaces a stateless service: %+v", s)
|
||||
}
|
||||
}
|
||||
var commands []string
|
||||
report, state, err := ApplyKeeping(context.Background(), archHost(t), d, store.State{},
|
||||
store.OriginDeclared, unitIn(true, &commands), nil, nil, KeepIn(t.TempDir()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := outcomeOf(report, "uplink.manager").Action; got == "held" {
|
||||
t.Fatal("a stateless service was held, though it replaces nothing that was found")
|
||||
}
|
||||
if len(state.Held) != 0 {
|
||||
t.Errorf("something was held: %+v", state.Held)
|
||||
}
|
||||
for _, c := range touched(commands) {
|
||||
if !strings.Contains(c, "reload") {
|
||||
t.Errorf("the adopted node's manager lifecycle was touched: %s", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasStep(steps []Step, verb, id string) bool {
|
||||
for _, s := range steps {
|
||||
if s.Verb == verb && s.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// Strays is what runs on the machine that the mesh neither wrote nor holds (novox/hq ADR 0163):
|
||||
// every container the runtime has that no record names and no hold names. The question nothing
|
||||
// answered on 2026-09-23, when a renamed resource left its old container running for a day; asked
|
||||
// on every apply now, and reported, so a thing left behind is seen the day it is left.
|
||||
//
|
||||
// Containers only, today. A listener nobody declared is harder to attribute to a thing, and the
|
||||
// machine's own services are not strays; that account is issue 160's.
|
||||
func Strays(ctx context.Context, run Runner, known store.State) ([]store.Stray, error) {
|
||||
cri, err := containerRuntime(ctx, run)
|
||||
if err != nil {
|
||||
return nil, nil // a machine with no runtime has no containers to stray
|
||||
}
|
||||
out, err := run(ctx, cri, "ps", "-a", "--format", "{{.Names}}\t{{.Image}}\t{{.State}}")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ours := map[string]bool{}
|
||||
for _, r := range known.Resources {
|
||||
if declaration.Type(r.Type) == declaration.TypeContainer {
|
||||
ours[r.Target] = true
|
||||
}
|
||||
}
|
||||
for _, h := range known.Held {
|
||||
if h.Kind == string(declaration.TypeContainer) {
|
||||
ours[h.Target] = true
|
||||
}
|
||||
}
|
||||
var strays []store.Stray
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
parts := strings.Split(line, "\t")
|
||||
name := strings.TrimSpace(parts[0])
|
||||
if name == "" || ours[name] {
|
||||
continue
|
||||
}
|
||||
detail := ""
|
||||
if len(parts) > 2 {
|
||||
detail = strings.TrimSpace(parts[1]) + ", " + strings.TrimSpace(parts[2])
|
||||
}
|
||||
strays = append(strays, store.Stray{Kind: string(declaration.TypeContainer), Name: name, Detail: detail})
|
||||
}
|
||||
sort.Slice(strays, func(i, j int) bool { return strays[i].Name < strays[j].Name })
|
||||
return strays, nil
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/system"
|
||||
"github.com/novox/mesh-host/internal/tunnel"
|
||||
)
|
||||
|
||||
// The private network takes over the tunnel it found (novox/hq ADR 0105).
|
||||
//
|
||||
// The controller says so on the interface's service: `takes-over` names the found interface, the
|
||||
// unit that raised it and its configuration file. Before the mesh's unit is started, the host keeps
|
||||
// that file like any held file — the original recorded before anything else happens to it — and
|
||||
// stops and disables the found unit. Never a flush: `wg set … peer … remove` is never run, the
|
||||
// file is never written, and the found interface goes down the way its own unit takes it down.
|
||||
// Then the mesh's interface comes up, with the found key the node took at enrolment, on the found
|
||||
// port, with the found peers in its list — and a peer of the tunnel cannot tell it changed hands.
|
||||
//
|
||||
// Every apply, not once: a found unit somebody starts again would take the port back from the
|
||||
// mesh's interface, so it is stopped again and said so. That is the one place an adopted node
|
||||
// undoes something done by hand, and it is because the tunnel is the mesh's now.
|
||||
//
|
||||
// **Until the take is proven, and then the found configuration is retired** (novox/hq ADR 0119).
|
||||
// Keeping it on disk was the caution the take needed: if the mesh's interface does not come up,
|
||||
// the found unit is started again and the peers never notice. That caution is spent once the
|
||||
// tunnel is taken — the found unit down and disabled, the mesh's interface up — and a peer has
|
||||
// handshaken with the mesh's interface. From then on a configuration nothing maintains, one
|
||||
// command away from raising a second way onto the network, is not a rollback path but a door
|
||||
// nobody watches. So it is removed from where its unit reads it; its original, kept before
|
||||
// anything happened to it (ADR 0100), stays kept; and the hold on it ends. A take never proven
|
||||
// keeps it, and says so — a broken take is visible, not silently retired.
|
||||
|
||||
// TakenTunnel is what an apply says about a tunnel it took over, for the node's report.
|
||||
type TakenTunnel struct {
|
||||
Interface string
|
||||
Port int
|
||||
Range string
|
||||
Peers int
|
||||
// State is "not-taken" (the found interface still up, the mesh's not), "taken" (the found one
|
||||
// down and disabled, the mesh's up with its key) or "down" (the found one down and the mesh's
|
||||
// not up: the peers reach nothing). Note is what this apply did about it — and, for a taken
|
||||
// tunnel, whether the take is proven and its found configuration retired (novox/hq ADR 0119).
|
||||
// Kept is where the found configuration's original is, retired or not.
|
||||
State string
|
||||
Note string
|
||||
Kept string
|
||||
}
|
||||
|
||||
// The states, as the link says them.
|
||||
const (
|
||||
NotTaken = "not-taken"
|
||||
Taken = "taken"
|
||||
TunnelDown = "down"
|
||||
)
|
||||
|
||||
// takeoverRecheck is how often, and takeoverRechecks how many times, a found interface still up
|
||||
// after its unit stopped is looked at again before the takeover is refused: `wg-quick down` by a
|
||||
// person takes a moment. Variables so a test need not wait.
|
||||
var (
|
||||
takeoverRecheck = 2 * time.Second
|
||||
takeoverRechecks = 3
|
||||
)
|
||||
|
||||
// takeOverID is the held record's id for the found configuration: the service's own with a suffix,
|
||||
// so it is declared for as long as the service is and never mistaken for the service itself.
|
||||
func takeOverID(svc *declaration.Service) string { return svc.ID + ".takes-over" }
|
||||
|
||||
// takeOver keeps the found tunnel's configuration and stops its unit, ahead of the service that
|
||||
// replaces it. Returned is the hold's outcome, and what was found for the report.
|
||||
//
|
||||
// **Nothing is stopped until the mesh's interface is known to be able to replace it** (the record's
|
||||
// option 2 is exactly this going wrong): the declared configuration must listen on the found port
|
||||
// at the found address, and the key file it points at must hold the found key. Only then is the
|
||||
// found unit stopped — and `stopped` says whether this apply did, so a mesh interface that then
|
||||
// fails to start can have the found unit started again.
|
||||
func takeOver(ctx context.Context, sys system.System, svc *declaration.Service, d *declaration.Declaration,
|
||||
known *store.State, run Runner, keep Keep, now time.Time) (out Outcome, facts TakenTunnel, stopped bool, err error) {
|
||||
t := svc.TakesOver
|
||||
id := takeOverID(svc)
|
||||
module, _ := d.Adoption.UntakenModuleOf(svc.ID)
|
||||
if module == "" {
|
||||
module = "the private network"
|
||||
}
|
||||
facts = TakenTunnel{Interface: t.Interface, State: NotTaken}
|
||||
|
||||
// 0. What the found configuration says, before anything: the checks below are against it.
|
||||
found, ferr := readFoundTunnel(t.Config)
|
||||
|
||||
// 1. The configuration, kept like any held file. A synthetic file resource stands for it, so
|
||||
// the same code keeps its original, digests it and notices it changing.
|
||||
//
|
||||
// **Unless it was retired** (novox/hq ADR 0119): the take was proven and the mesh removed
|
||||
// it, so there is nothing to hold and nothing missing — only where its original is, which
|
||||
// the retirement recorded. A hold still standing is let go: that is what retiring it meant.
|
||||
//
|
||||
// **One that comes back is held again on its FIRST original** — the one kept before anything
|
||||
// happened to it (ADR 0100), never whatever was put back — and retired again by the first
|
||||
// apply that finds the take still proven, keeping what came back only if it differs from
|
||||
// what is already kept. Put back by hand while the private network is assigned, it is not a
|
||||
// rollback: that means unassigning the private network first, and the note says so.
|
||||
file := &declaration.File{ID: id, Type: declaration.TypeFile, Path: t.Config}
|
||||
var held store.Held
|
||||
retired, wasRetired := known.RetiredAt(t.Config)
|
||||
cameBack := wasRetired && present(t.Config)
|
||||
switch {
|
||||
case wasRetired && !cameBack:
|
||||
known.Release(id)
|
||||
held = store.Held{Kept: retired.Kept}
|
||||
out = begin(file)
|
||||
out.Action = "unchanged"
|
||||
facts.Note = "the found configuration " + t.Config + " was retired once the take was proven; " +
|
||||
"its original is kept at " + retired.Kept + " and the mesh never brings it back"
|
||||
default:
|
||||
was, already := known.HeldAt(id)
|
||||
why := "the configuration of the tunnel " + t.Interface + ", taken over by " + svc.Unit
|
||||
if cameBack && !already {
|
||||
digest := retired.Digest
|
||||
if digest == "" {
|
||||
if raw, err := os.ReadFile(retired.Kept); err == nil {
|
||||
digest = digestOf(string(raw))
|
||||
}
|
||||
}
|
||||
was = store.Held{ID: id, Module: module, Kind: string(declaration.TypeFile), Target: t.Config,
|
||||
Since: now, Why: why, Kept: retired.Kept, Digest: digest}
|
||||
already = true
|
||||
}
|
||||
out, held, err = hold(ctx, sys, file, module, was, already, why, run, keep, now)
|
||||
if err != nil {
|
||||
return begin(file), facts, false, fmt.Errorf("keeping the found tunnel's configuration: %w", err)
|
||||
}
|
||||
known.RecordHeld(held)
|
||||
if cameBack {
|
||||
facts.Note = "the found configuration " + t.Config + " came back after it was retired; while the " +
|
||||
"private network is assigned the mesh retires it again, so rolling back to the found tunnel " +
|
||||
"means unassigning the private network first"
|
||||
}
|
||||
}
|
||||
facts.Kept = held.Kept
|
||||
// What the file says, for the report: from the machine, or from the kept original when the
|
||||
// machine's copy is gone. The private key stays in the file; nothing here keeps it.
|
||||
unread := ""
|
||||
if ferr != nil && held.Kept != "" {
|
||||
found, ferr = readFoundTunnel(held.Kept)
|
||||
}
|
||||
if ferr == nil {
|
||||
facts.Port, facts.Range, facts.Peers = found.Port, found.Range, len(found.Peers)
|
||||
} else {
|
||||
unread = ferr.Error()
|
||||
}
|
||||
|
||||
// 2. Where things stand: the found unit, and the mesh's.
|
||||
foundState, unitErr := sys.ServiceState(ctx, run, t.Unit)
|
||||
meshState, _ := sys.ServiceState(ctx, run, svc.Unit)
|
||||
if foundState == "running" && meshState == "running" {
|
||||
// Both up. On the hub this cannot last — the found unit cannot bind the port the mesh's
|
||||
// holds — and on a spoke two interfaces with one key flap between them. Not stopped again
|
||||
// by the mesh: what is found on an adopted node is reported, and the first takeover was
|
||||
// the one act (the PR note says why). Said, so a person sees it.
|
||||
facts.Note = t.Unit + " is running again beside the mesh's interface; not stopped by the mesh — " +
|
||||
"`systemctl stop " + t.Unit + "` on the machine"
|
||||
}
|
||||
|
||||
// 3. Before the found unit is stopped: can the mesh's interface replace it? Its declared
|
||||
// configuration must listen on the found port at the found address, and the key file it
|
||||
// points at must hold the found key, or the peers would be dropped the moment it came up.
|
||||
if foundState == "running" && meshState != "running" {
|
||||
if ferr != nil {
|
||||
return out, facts, false, fmt.Errorf("the found tunnel's configuration at %s cannot be read as a "+
|
||||
"tunnel's (%v), so nothing says what the mesh's interface must match; %s is left running",
|
||||
t.Config, ferr, t.Unit)
|
||||
}
|
||||
if err := replaces(d, svc, found); err != nil {
|
||||
return out, facts, false, fmt.Errorf("%w; %s is left running", err, t.Unit)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. The found unit: stopped if it runs and the mesh's does not, disabled if it starts at
|
||||
// boot. A unit that is not there is not an error — the interface may have been raised
|
||||
// another way, which the check below catches — and neither is one already down.
|
||||
var did []string
|
||||
switch {
|
||||
case unitErr != nil:
|
||||
did = append(did, t.Unit+" is not a unit here")
|
||||
case foundState == "running" && meshState != "running":
|
||||
if err := sys.SetServiceState(ctx, run, t.Unit, "stopped"); err != nil {
|
||||
return out, facts, false, fmt.Errorf("stopping the found %s: %w", t.Unit, err)
|
||||
}
|
||||
after, err := sys.ServiceState(ctx, run, t.Unit)
|
||||
if err != nil {
|
||||
return out, facts, true, err
|
||||
}
|
||||
if after != "stopped" {
|
||||
return out, facts, true, fmt.Errorf("%s was asked to stop and is %s", t.Unit, after)
|
||||
}
|
||||
stopped = true
|
||||
did = append(did, "stopped "+t.Unit)
|
||||
}
|
||||
if unitErr == nil {
|
||||
if boot, err := sys.ServiceBoot(ctx, run, t.Unit); err == nil && boot == "enabled" {
|
||||
if err := sys.SetServiceBoot(ctx, run, t.Unit, "disabled"); err != nil {
|
||||
return out, facts, stopped, fmt.Errorf("disabling the found %s at boot: %w", t.Unit, err)
|
||||
}
|
||||
did = append(did, "disabled it at boot")
|
||||
}
|
||||
}
|
||||
|
||||
// 5. The interface is gone. If it is still up, something other than its unit raised it —
|
||||
// the predecessor brings its up by hand — and the mesh's interface cannot take its port
|
||||
// and address while it is. Looked at again for a moment, since a person taking it down
|
||||
// takes a moment; then refused, naming what to do.
|
||||
if meshState != "running" {
|
||||
for try := 0; ; try++ {
|
||||
if !interfaceUp(ctx, run, t.Interface) {
|
||||
break
|
||||
}
|
||||
if try >= takeoverRechecks {
|
||||
return out, facts, stopped, fmt.Errorf("%s is still up although its unit %s is not running: it was "+
|
||||
"raised by hand, not by its unit, and the mesh's interface cannot take its port and "+
|
||||
"address while it is. On the machine: `wg-quick down %s` — the next reconcile takes it "+
|
||||
"over. Nothing was flushed", t.Interface, t.Unit, t.Interface)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return out, facts, stopped, ctx.Err()
|
||||
case <-time.After(takeoverRecheck):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.Detail = "the tunnel " + t.Interface + "'s configuration, kept as found"
|
||||
switch {
|
||||
case cameBack:
|
||||
out.Detail = "the tunnel " + t.Interface + "'s configuration, back after it was retired; held until " +
|
||||
"it is retired again"
|
||||
case wasRetired:
|
||||
out.Detail = "the tunnel " + t.Interface + "'s configuration, retired once the take was proven"
|
||||
}
|
||||
if held.Kept != "" {
|
||||
out.Detail += " (original at " + held.Kept + ")"
|
||||
}
|
||||
if len(did) > 0 {
|
||||
out.Detail += "; " + strings.Join(did, ", ") + " — never flushed"
|
||||
}
|
||||
if held.Changed != "" {
|
||||
out.Detail += "; " + held.Changed + " by something other than the mesh since it was found"
|
||||
}
|
||||
if unread != "" {
|
||||
// Said, not swallowed: the report would otherwise say a tunnel with no port and no
|
||||
// peers was carried, which reads as a tunnel that was not one.
|
||||
out.Detail += "; what it says could not be read as a tunnel's: " + unread
|
||||
}
|
||||
return out, facts, stopped, nil
|
||||
}
|
||||
|
||||
// readFoundTunnel is the found configuration as a tunnel.
|
||||
func readFoundTunnel(path string) (tunnel.Found, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return tunnel.Found{}, err
|
||||
}
|
||||
return tunnel.Parse(raw)
|
||||
}
|
||||
|
||||
// interfaceUp is whether a WireGuard interface is up on the machine.
|
||||
func interfaceUp(ctx context.Context, run Runner, iface string) bool {
|
||||
up, err := run(ctx, "wg", "show", "interfaces")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, name := range strings.Fields(up) {
|
||||
if name == iface {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// replaces holds the mesh's declared interface configuration against the found tunnel it is to
|
||||
// replace: same port, same address, and a key file holding the found key. The configuration is
|
||||
// the file the service restarts on; its `PostUp = wg set %i private-key <path>` names the key.
|
||||
func replaces(d *declaration.Declaration, svc *declaration.Service, found tunnel.Found) error {
|
||||
var conf *declaration.File
|
||||
for _, r := range d.Resources {
|
||||
f, ok := r.(*declaration.File)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, id := range svc.RestartOn {
|
||||
if f.ID == id {
|
||||
conf = f
|
||||
}
|
||||
}
|
||||
}
|
||||
if conf == nil {
|
||||
return fmt.Errorf("%s takes over %s and restarts on no declared file, so the interface it would "+
|
||||
"raise cannot be checked against the found one", svc.Unit, found.Interface)
|
||||
}
|
||||
port, address, keyPath := "", "", ""
|
||||
for _, line := range strings.Split(conf.Content, "\n") {
|
||||
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, value = strings.ToLower(strings.TrimSpace(key)), strings.TrimSpace(value)
|
||||
switch key {
|
||||
case "listenport":
|
||||
port = value
|
||||
case "address":
|
||||
address = strings.TrimSpace(strings.Split(value, ",")[0])
|
||||
case "postup":
|
||||
if _, after, ok := strings.Cut(value, "private-key "); ok {
|
||||
keyPath = strings.Fields(after)[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
var wrong []string
|
||||
if port != fmt.Sprint(found.Port) {
|
||||
wrong = append(wrong, fmt.Sprintf("it listens on port %q and the tunnel on %d", port, found.Port))
|
||||
}
|
||||
if host(address) != host(found.Address) {
|
||||
wrong = append(wrong, fmt.Sprintf("its address is %q and the tunnel's %s", address, found.Address))
|
||||
}
|
||||
switch raw, err := os.ReadFile(keyPath); {
|
||||
case keyPath == "":
|
||||
wrong = append(wrong, "it names no key file")
|
||||
case err != nil:
|
||||
wrong = append(wrong, fmt.Sprintf("its key file %s cannot be read (%v)", keyPath, err))
|
||||
default:
|
||||
public, perr := tunnel.PublicKeyOf(strings.TrimSpace(string(raw)))
|
||||
if perr != nil || public != found.PublicKey {
|
||||
wrong = append(wrong, fmt.Sprintf("the key at %s is not the tunnel's — `mesh-host overlay take "+
|
||||
"--tunnel %s` on this machine takes it, then push again", keyPath, found.Interface))
|
||||
}
|
||||
}
|
||||
if len(wrong) > 0 {
|
||||
return fmt.Errorf("the mesh's interface would not replace the tunnel on %s: %s — the peers would be "+
|
||||
"dropped the moment it came up. Re-place the hub on the tunnel's address and port and push again",
|
||||
found.Interface, strings.Join(wrong, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// host is an address without its prefix length.
|
||||
func host(address string) string {
|
||||
if i := strings.Index(address, "/"); i >= 0 {
|
||||
return address[:i]
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
// tunnelState is where the tunnel stands, read from the machine: the found unit or interface up
|
||||
// and the mesh's not is not taken; the mesh's up and the found one down is taken; neither up is
|
||||
// down — the peers reach nothing.
|
||||
func tunnelState(ctx context.Context, sys system.System, foundUnit, meshUnit string, run Runner) string {
|
||||
foundState, _ := sys.ServiceState(ctx, run, foundUnit)
|
||||
meshState, _ := sys.ServiceState(ctx, run, meshUnit)
|
||||
foundUp := foundState == "running" || interfaceUp(ctx, run, strings.TrimPrefix(foundUnit, "wg-quick@"))
|
||||
switch {
|
||||
case meshState == "running" && !foundUp:
|
||||
return Taken
|
||||
case meshState == "running":
|
||||
// Both up: not a takeover that holds, and said as not taken so nobody reads it as one.
|
||||
return NotTaken
|
||||
case foundUp:
|
||||
return NotTaken
|
||||
default:
|
||||
return TunnelDown
|
||||
}
|
||||
}
|
||||
|
||||
// restoreFound starts the found unit again after the mesh's interface failed to replace it, so the
|
||||
// machine has the tunnel it had rather than none, and says so in the account.
|
||||
func restoreFound(ctx context.Context, sys system.System, unit string, run Runner, facts *TakenTunnel) {
|
||||
if err := sys.SetServiceState(ctx, run, unit, "running"); err != nil {
|
||||
facts.State = TunnelDown
|
||||
facts.Note += "; " + unit + " could not be started again (" + err.Error() + ") — on the machine: systemctl start " + unit
|
||||
return
|
||||
}
|
||||
if state, err := sys.ServiceState(ctx, run, unit); err != nil || state != "running" {
|
||||
facts.State = TunnelDown
|
||||
facts.Note += "; " + unit + " was started again and is not running — on the machine: systemctl start " + unit
|
||||
return
|
||||
}
|
||||
facts.State = NotTaken
|
||||
facts.Note += "; " + unit + " was started again, so the machine has the tunnel it had"
|
||||
}
|
||||
|
||||
// wireguardDir is where a found tunnel's configuration may be retired from: wg-quick's own, and
|
||||
// nowhere else. A variable so a test can hand in a directory.
|
||||
var wireguardDir = tunnel.ConfigDir
|
||||
|
||||
// retireFound removes the found tunnel's configuration from where its unit reads it, once the take
|
||||
// is proven, and ends the hold on it (novox/hq ADR 0119). Asked after the mesh's service applied
|
||||
// and the tunnel reads as taken; retired says whether this apply retired it, and out is then what
|
||||
// replaces the take's outcome for the configuration.
|
||||
//
|
||||
// **Proven is taken and a handshake.** Taken alone — the found unit down and disabled, the mesh's
|
||||
// interface up — says the mesh's interface exists, not that any peer reaches it: an interface up
|
||||
// with the wrong key is taken and carries nothing. A peer that has completed a handshake with it
|
||||
// has checked its key, so that is the proof, asked of the kernel through `wg`. Any handshake counts,
|
||||
// however old: a change to the mesh's configuration restarts its unit, which recreates the
|
||||
// interface and resets its counters, so a time that is there at all was made by this interface.
|
||||
// Anything short of one — no peer yet, every time zero, `wg` missing or failing — keeps the file,
|
||||
// and the account says which: a take that never proves itself is visible rather than silently
|
||||
// retired.
|
||||
//
|
||||
// **Only what the take names, and only wg-quick's own file.** Nothing is removed unless the path
|
||||
// is exactly `<wireguard dir>/<found interface>.conf`, is not a path the mesh itself writes, and is
|
||||
// a file rather than a link: removing a link would leave the key-bearing file it points at where it
|
||||
// is, a retirement in name only, so that one is said and left to a person.
|
||||
//
|
||||
// **The original must still be kept.** It is the record of what the predecessor was and a
|
||||
// person's only way back (ADR 0100); a kept copy that has gone missing is said, and the file is
|
||||
// not removed, since removing it then would lose the only copy. What is on disk now, if it differs
|
||||
// from the first original and from what was kept at the last retirement, is kept too before it
|
||||
// goes — by content, so the first original is never overwritten and a file that keeps coming back
|
||||
// the same keeps nothing more.
|
||||
//
|
||||
// The found unit is left disabled; without its configuration it cannot raise the interface, so
|
||||
// every later apply's check of it finds nothing to do. Nothing here ever writes the file back.
|
||||
func retireFound(ctx context.Context, svc *declaration.Service, d *declaration.Declaration, known *store.State,
|
||||
run Runner, keep Keep, facts *TakenTunnel, now time.Time) (out Outcome, retired bool) {
|
||||
t := svc.TakesOver
|
||||
id := takeOverID(svc)
|
||||
if facts.State != Taken {
|
||||
return out, false
|
||||
}
|
||||
held, isHeld := known.HeldAt(id)
|
||||
if !isHeld {
|
||||
// Retired already (takeOver let any hold go and said so), or never held: nothing to do.
|
||||
return out, false
|
||||
}
|
||||
say := func(note string) {
|
||||
if facts.Note != "" {
|
||||
facts.Note += "; "
|
||||
}
|
||||
facts.Note += note
|
||||
}
|
||||
notRetired := func(why string) (Outcome, bool) {
|
||||
say("the found configuration " + t.Config + " is not retired: " + why)
|
||||
return Outcome{}, false
|
||||
}
|
||||
mesh := strings.TrimPrefix(svc.Unit, "wg-quick@")
|
||||
peers, err := tunnel.Handshaken(ctx, tunnel.Runner(run), mesh)
|
||||
if err != nil {
|
||||
say("taken, not yet proven: " + err.Error() + "; the found configuration " + t.Config + " is kept")
|
||||
return out, false
|
||||
}
|
||||
if peers == 0 {
|
||||
say("taken, not yet proven: no peer has handshaken on " + mesh + "; the found configuration " +
|
||||
t.Config + " is kept")
|
||||
return out, false
|
||||
}
|
||||
proven := fmt.Sprintf("proven: %d peer(s) handshaken on %s", peers, mesh)
|
||||
say(proven)
|
||||
|
||||
// What may be removed at all.
|
||||
if want := filepath.Join(wireguardDir, t.Interface+".conf"); t.Config != want {
|
||||
return notRetired("only " + want + ", the found interface's own wg-quick configuration, is ever " +
|
||||
"retired by the mesh, and the take names " + t.Config)
|
||||
}
|
||||
if known.Recorded(string(declaration.TypeFile), t.Config) || declaresFile(d, t.Config) {
|
||||
return notRetired("it is a path the mesh itself writes")
|
||||
}
|
||||
if info, err := os.Lstat(t.Config); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
target, _ := os.Readlink(t.Config)
|
||||
return notRetired("it is a link to " + target + "; removing the link would leave the key-bearing file " +
|
||||
"it points at, so it must be retired by hand — both are kept")
|
||||
}
|
||||
|
||||
// The kept original, read back — not just named in a record.
|
||||
if held.Kept == "" {
|
||||
return notRetired("no original of it was kept, so removing it would leave no record of what the " +
|
||||
"predecessor was")
|
||||
}
|
||||
original, err := os.ReadFile(held.Kept)
|
||||
if err != nil || (held.Digest != "" && digestOf(string(original)) != held.Digest) {
|
||||
why := "is missing"
|
||||
if err == nil {
|
||||
why = "no longer holds what was found"
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
why = "cannot be read (" + err.Error() + ")"
|
||||
}
|
||||
return notRetired("its kept original " + held.Kept + " " + why + ", so removing it would lose the only copy")
|
||||
}
|
||||
|
||||
before, cameBack := known.RetiredAt(t.Config)
|
||||
record := store.Retired{ID: id, Path: t.Config, Kept: held.Kept, Digest: digestOf(string(original)), At: now}
|
||||
if cameBack {
|
||||
// The first original stays the record's, and so does what the last retirement kept.
|
||||
record.Extra, record.ExtraDigest, record.Again = before.Extra, before.ExtraDigest, before.Again+1
|
||||
}
|
||||
newCopy := ""
|
||||
gone := !present(t.Config)
|
||||
if !gone {
|
||||
current, err := os.ReadFile(t.Config)
|
||||
if err != nil {
|
||||
return notRetired("it cannot be read (" + err.Error() + ")")
|
||||
}
|
||||
if sum := digestOf(string(current)); sum != record.Digest && sum != record.ExtraDigest {
|
||||
if keep == nil {
|
||||
return notRetired("it holds something other than its kept original and this host has nowhere " +
|
||||
"to keep it")
|
||||
}
|
||||
where, err := keep(t.Config, current, 0o600)
|
||||
if err != nil {
|
||||
return notRetired("keeping what it holds now failed (" + err.Error() + ")")
|
||||
}
|
||||
record.Extra, record.ExtraDigest, newCopy = where, sum, where
|
||||
}
|
||||
if err := os.Remove(t.Config); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return notRetired("removing it failed (" + err.Error() + ")")
|
||||
}
|
||||
if present(t.Config) {
|
||||
return notRetired("it is still there after it was removed")
|
||||
}
|
||||
}
|
||||
|
||||
known.RecordRetired(record)
|
||||
known.Release(id)
|
||||
facts.Kept = held.Kept
|
||||
copied := ""
|
||||
if newCopy != "" {
|
||||
copied = "; what it held, which differed from the original, is kept at " + newCopy
|
||||
}
|
||||
out = Outcome{ID: id, Type: string(declaration.TypeFile), Target: t.Config, Action: "removed"}
|
||||
switch {
|
||||
case cameBack:
|
||||
say("the found configuration came back and was retired again — its original still kept at " +
|
||||
held.Kept + copied + "; rolling back to the found tunnel means unassigning the private network first")
|
||||
out.Detail = "the found configuration came back and was retired again; original kept at " + held.Kept + copied
|
||||
default:
|
||||
say("the found configuration " + t.Config + " is retired — its original kept at " + held.Kept + copied +
|
||||
", " + t.Unit + " left disabled, and the mesh never brings it back")
|
||||
out.Detail = "retired: the take of " + t.Interface + " is " + proven + "; original kept at " + held.Kept + copied
|
||||
}
|
||||
if gone {
|
||||
// Already gone — removed by something other than the mesh, or by an apply whose record was
|
||||
// never saved. Nothing removed here; the hold ends all the same.
|
||||
out.Action = "unchanged"
|
||||
out.Detail = "retired: the take of " + t.Interface + " is " + proven + " and " + t.Config +
|
||||
" was already gone; original kept at " + held.Kept
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// declaresFile is whether a declaration writes a file at a path.
|
||||
func declaresFile(d *declaration.Declaration, path string) bool {
|
||||
for _, r := range d.Resources {
|
||||
if f, ok := r.(*declaration.File); ok && filepath.Clean(f.Path) == filepath.Clean(path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// takesOver is the service in a declaration that takes over a tunnel, if any: one per node, since
|
||||
// a machine has one private network.
|
||||
func takesOver(d *declaration.Declaration) *declaration.Service {
|
||||
for _, r := range d.Resources {
|
||||
if svc, ok := r.(*declaration.Service); ok && svc.TakesOver != nil {
|
||||
return svc
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// errNotAdopted is a takeover on a declaration that does not say the node is adopted, which the
|
||||
// parser refuses already; kept as a second line of defence at the point of acting.
|
||||
var errNotAdopted = errors.New("a tunnel is taken over on an adopted node only")
|
||||
@@ -0,0 +1,715 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0105: the host raises the mesh's interface with the found key and peers, stops the
|
||||
// found interface without flushing it, and keeps its configuration — and stops nothing until the
|
||||
// mesh's interface is known to be able to replace it.
|
||||
|
||||
// foundKey is the predecessor's private key, a real one made once per run: the key is what the
|
||||
// takeover must never print or copy, so it had better be one.
|
||||
var foundKey = func() string {
|
||||
k, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k.Bytes())
|
||||
}()
|
||||
|
||||
var foundConf = "[Interface]\nPrivateKey = " + foundKey + "\n" +
|
||||
"ListenPort = 51900\nAddress = 192.0.2.1/24\n\n[Peer]\nPublicKey = PEER-A=\nAllowedIPs = 192.0.2.2/32\n" +
|
||||
"\n[Peer]\nPublicKey = PEER-B=\nAllowedIPs = 192.0.2.3/32\n"
|
||||
|
||||
// aTakeover is the private network's declaration for an adopted hub whose interface takes over
|
||||
// the found tunnel: the mesh's configuration — on the found port and address, its key set from the
|
||||
// node's own key file, the found peers in its list — and the interface's service naming what it
|
||||
// replaces. Port and address are parameters so a test can declare a wrong one.
|
||||
func aTakeover(t *testing.T, config, mesh, keyFile, port, address string) *declaration.Declaration {
|
||||
t.Helper()
|
||||
return adopted(t,
|
||||
`{"taken":[],"untaken":{"mesh-wireguard":["mesh-wireguard.overlay-config","mesh-wireguard.overlay-up"]}}`,
|
||||
`{"id":"mesh-wireguard.overlay-config","type":"file","path":"`+mesh+`","mode":"0600",
|
||||
"content":"[Interface]\nAddress = `+address+`/32\nListenPort = `+port+`\nPostUp = wg set %i private-key `+keyFile+`\n\n[Peer]\nPublicKey = PEER-A=\nAllowedIPs = 192.0.2.2/32\n"},
|
||||
{"id":"mesh-wireguard.overlay-up","type":"service","unit":"wg-quick@mesh0","state":"running","boot":"enabled",
|
||||
"restart-on":["mesh-wireguard.overlay-config"],
|
||||
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"`+config+`"}}`)
|
||||
}
|
||||
|
||||
// aHubInUse is a machine with the predecessor's tunnel up and the mesh's not yet: the found
|
||||
// configuration on disk, and the node's key file holding the found key, as enrolment left it.
|
||||
func aHubInUse(t *testing.T) (dir, config, mesh, keyFile string, m *machine) {
|
||||
t.Helper()
|
||||
dir = t.TempDir()
|
||||
config = filepath.Join(dir, "wg0.conf")
|
||||
mesh = filepath.Join(dir, "mesh0.conf")
|
||||
keyFile = filepath.Join(dir, "overlay.key")
|
||||
if err := os.WriteFile(config, []byte(foundConf), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keyFile, []byte(foundKey+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m = &machine{containers: map[string]*fakeContainer{}, units: map[string]*fakeUnit{
|
||||
"wg-quick@wg0": {active: "active", enabled: "enabled"},
|
||||
"wg-quick@mesh0": {active: "inactive", enabled: "disabled", fragment: "/usr/lib/systemd/system/wg-quick@.service"},
|
||||
}}
|
||||
takeoverRecheck = 0
|
||||
// The found configuration lives in this test's own wireguard directory (novox/hq ADR 0119).
|
||||
was := wireguardDir
|
||||
wireguardDir = dir
|
||||
t.Cleanup(func() { wireguardDir = was })
|
||||
return dir, config, mesh, keyFile, m
|
||||
}
|
||||
|
||||
func TestTheFoundTunnelIsStoppedNeverFlushedAndItsConfigurationKept(t *testing.T) {
|
||||
dir, config, mesh, keyFile, m := aHubInUse(t)
|
||||
report, state := applyAdopted(t, aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1"), store.State{}, m, dir)
|
||||
|
||||
// The found interface: its unit stopped and disabled, and nothing else done to it.
|
||||
if u := m.units["wg-quick@wg0"]; u.active != "inactive" || u.enabled != "disabled" {
|
||||
t.Fatalf("the found unit was not stopped and disabled: %+v", u)
|
||||
}
|
||||
for _, asked := range m.asked {
|
||||
// Only ever asked about: which interfaces are up, and whether a peer has handshaken with
|
||||
// the mesh's own (novox/hq ADR 0119).
|
||||
if strings.HasPrefix(asked, "wg ") && !strings.HasPrefix(asked, "wg show interfaces") &&
|
||||
asked != "wg show mesh0 latest-handshakes" {
|
||||
t.Errorf("the found interface was touched with %q; it is stopped, never flushed", asked)
|
||||
}
|
||||
if strings.HasPrefix(asked, "wg-quick") || strings.Contains(asked, "peer remove") {
|
||||
t.Errorf("the found interface was flushed: %q", asked)
|
||||
}
|
||||
}
|
||||
// Its configuration: on disk as it was, its original kept, held for the module.
|
||||
if got, _ := os.ReadFile(config); string(got) != foundConf {
|
||||
t.Fatalf("the found configuration was changed:\n%s", got)
|
||||
}
|
||||
held, ok := state.HeldAt("mesh-wireguard.overlay-up.takes-over")
|
||||
if !ok || held.Kind != "file" || held.Target != config || held.Kept == "" || held.Module != "mesh-wireguard" {
|
||||
t.Fatalf("the found configuration is not held: %+v", held)
|
||||
}
|
||||
if kept, _ := os.ReadFile(held.Kept); string(kept) != foundConf {
|
||||
t.Fatalf("the original was not kept as found: %q", kept)
|
||||
}
|
||||
// The mesh's interface: up, enabled, with the found peers in the file the mesh wrote.
|
||||
if u := m.units["wg-quick@mesh0"]; u.active != "active" || u.enabled != "enabled" {
|
||||
t.Fatalf("the mesh's interface was not raised: %+v", u)
|
||||
}
|
||||
if got, _ := os.ReadFile(mesh); !strings.Contains(string(got), "PEER-A=") || strings.Contains(string(got), "PrivateKey") {
|
||||
t.Fatalf("the mesh's configuration does not carry the found peer, or carries a key:\n%s", got)
|
||||
}
|
||||
// And the report says so, with what was found — port, range, peers — and never the key.
|
||||
if report.Tunnel == nil || report.Tunnel.State != Taken || report.Tunnel.Port != 51900 ||
|
||||
report.Tunnel.Range != "192.0.2.0/24" || report.Tunnel.Peers != 2 || report.Tunnel.Kept != held.Kept {
|
||||
t.Fatalf("the report does not say what was carried: %+v", report.Tunnel)
|
||||
}
|
||||
for _, o := range report.Outcomes {
|
||||
if strings.Contains(o.Detail, foundKey) {
|
||||
t.Errorf("the found key was printed in an outcome: %+v", o)
|
||||
}
|
||||
}
|
||||
if strings.Contains(report.Tunnel.Note, foundKey) {
|
||||
t.Error("the found key was printed in the account")
|
||||
}
|
||||
if o := outcomeOf(report, "mesh-wireguard.overlay-up.takes-over"); o.Action != "held" ||
|
||||
!strings.Contains(o.Detail, "stopped wg-quick@wg0") || !strings.Contains(o.Detail, "never flushed") {
|
||||
t.Errorf("the takeover was not reported as a hold that stopped the found unit: %+v", o)
|
||||
}
|
||||
if _, recorded := state.Find("mesh-wireguard.overlay-up.takes-over"); recorded {
|
||||
t.Error("the found configuration was recorded as applied, so it would be removed as an orphan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingIsStoppedUntilTheMeshsInterfaceCanReplaceTheFoundOne(t *testing.T) {
|
||||
dir, config, mesh, keyFile, m := aHubInUse(t)
|
||||
otherKey := filepath.Join(dir, "other.key")
|
||||
k, _ := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err := os.WriteFile(otherKey, []byte(base64.StdEncoding.EncodeToString(k.Bytes())+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := map[string]*declaration.Declaration{
|
||||
"another port": aTakeover(t, config, mesh, keyFile, "51821", "192.0.2.1"),
|
||||
"another address": aTakeover(t, config, mesh, keyFile, "51900", "10.42.0.1"),
|
||||
"another key": aTakeover(t, config, mesh, otherKey, "51900", "192.0.2.1"),
|
||||
"no key file": aTakeover(t, config, mesh, filepath.Join(dir, "missing.key"), "51900", "192.0.2.1"),
|
||||
}
|
||||
for name, d := range cases {
|
||||
m.asked = nil
|
||||
report, state, err := ApplyKeeping(t.Context(), archHost(t), d, store.State{},
|
||||
store.OriginDeclared, m.run, nil, nil, KeepIn(dir))
|
||||
if err == nil || !strings.Contains(err.Error(), "would not replace the tunnel") {
|
||||
t.Fatalf("%s: the takeover was not refused: %v", name, err)
|
||||
}
|
||||
if name == "another key" && !strings.Contains(err.Error(), "overlay take") {
|
||||
t.Errorf("%s: the refusal does not name the remedy: %v", name, err)
|
||||
}
|
||||
if m.units["wg-quick@wg0"].active != "active" || m.did("systemctl stop wg-quick@wg0") {
|
||||
t.Fatalf("%s: the found unit was stopped although the mesh's interface could not replace it", name)
|
||||
}
|
||||
if m.units["wg-quick@mesh0"].active == "active" {
|
||||
t.Fatalf("%s: the mesh's interface was started on top of the found one", name)
|
||||
}
|
||||
if report.Tunnel == nil || report.Tunnel.State != NotTaken || !strings.Contains(report.Tunnel.Note, "would not replace") {
|
||||
t.Fatalf("%s: the account does not say the tunnel is not taken and why: %+v", name, report.Tunnel)
|
||||
}
|
||||
if _, held := state.HeldAt("mesh-wireguard.overlay-up.takes-over"); !held {
|
||||
t.Errorf("%s: the found configuration was not kept before the refusal", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMeshInterfaceThatFailsToStartGivesTheFoundOneBack(t *testing.T) {
|
||||
dir, config, mesh, keyFile, m := aHubInUse(t)
|
||||
m.units["wg-quick@mesh0"].wontStart = true
|
||||
report, _, err := ApplyKeeping(t.Context(), archHost(t), aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1"),
|
||||
store.State{}, store.OriginDeclared, m.run, nil, nil, KeepIn(dir))
|
||||
if err == nil {
|
||||
t.Fatal("a mesh interface that did not come up was reported as applied")
|
||||
}
|
||||
if !m.did("systemctl stop wg-quick@wg0") || !m.did("systemctl start wg-quick@wg0") {
|
||||
t.Fatalf("the found unit was not stopped and then started again: %v", m.asked)
|
||||
}
|
||||
if m.units["wg-quick@wg0"].active != "active" {
|
||||
t.Fatal("the machine was left with no tunnel at all")
|
||||
}
|
||||
if report.Tunnel == nil || report.Tunnel.State != NotTaken ||
|
||||
!strings.Contains(report.Tunnel.Note, "did not come up") || !strings.Contains(report.Tunnel.Note, "started again") {
|
||||
t.Fatalf("the account does not say the mesh's interface failed and the found one was given back: %+v", report.Tunnel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATakeoverIsSteadyAndAFoundUnitUpAgainIsSaidNotStopped(t *testing.T) {
|
||||
dir, config, mesh, keyFile, m := aHubInUse(t)
|
||||
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
|
||||
_, state := applyAdopted(t, d, store.State{}, m, dir)
|
||||
m.asked = nil
|
||||
|
||||
report, again := applyAdopted(t, d, state, m, dir)
|
||||
if report.Changed() {
|
||||
t.Errorf("a second apply moved the machine: %+v", report.Outcomes)
|
||||
}
|
||||
if _, still := again.HeldAt("mesh-wireguard.overlay-up.takes-over"); !still {
|
||||
t.Error("the hold on the found configuration was forgotten while the service still declares it")
|
||||
}
|
||||
if m.did("systemctl stop wg-quick@wg0") {
|
||||
t.Error("a found unit already down was stopped again")
|
||||
}
|
||||
if report.Tunnel == nil || report.Tunnel.State != Taken {
|
||||
t.Errorf("a steady takeover does not read as taken: %+v", report.Tunnel)
|
||||
}
|
||||
|
||||
// Somebody starts the found unit again beside the mesh's interface. Not stopped by the mesh —
|
||||
// on the hub it cannot hold the port, on a spoke stopping it would be a fight — but said.
|
||||
m.units["wg-quick@wg0"].active = "active"
|
||||
m.asked = nil
|
||||
report, _ = applyAdopted(t, d, again, m, dir)
|
||||
if m.did("systemctl stop wg-quick@wg0") {
|
||||
t.Error("a found unit started again by hand was stopped by the mesh")
|
||||
}
|
||||
if report.Tunnel == nil || report.Tunnel.State != NotTaken || !strings.Contains(report.Tunnel.Note, "running again beside") {
|
||||
t.Errorf("the account does not say the found unit is up again: %+v", report.Tunnel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFoundInterfaceRaisedByHandIsRefusedNamingTheRemedy(t *testing.T) {
|
||||
dir, config, mesh, keyFile, m := aHubInUse(t)
|
||||
// The unit is not running, yet the interface is up: the predecessor raised it by hand.
|
||||
m.units["wg-quick@wg0"].active = "inactive"
|
||||
m.wgUp = "wg0 mesh0\n"
|
||||
report, state, err := ApplyKeeping(t.Context(), archHost(t), aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1"),
|
||||
store.State{}, store.OriginDeclared, m.run, nil, nil, KeepIn(dir))
|
||||
if err == nil || !strings.Contains(err.Error(), "wg-quick down wg0") || !strings.Contains(err.Error(), "Nothing was flushed") {
|
||||
t.Fatalf("an interface raised by hand was not refused naming the remedy: %v", err)
|
||||
}
|
||||
if m.units["wg-quick@mesh0"].active == "active" {
|
||||
t.Error("the mesh's interface was started on a port the found one still holds")
|
||||
}
|
||||
// Looked at more than once before giving up: a person taking it down takes a moment.
|
||||
shows := 0
|
||||
for _, a := range m.asked {
|
||||
if a == "wg show interfaces" {
|
||||
shows++
|
||||
}
|
||||
}
|
||||
if shows < takeoverRechecks+1 {
|
||||
t.Errorf("the interface was looked at %d time(s) before the refusal; a person needs a moment", shows)
|
||||
}
|
||||
if report.Tunnel == nil || report.Tunnel.State != NotTaken {
|
||||
t.Errorf("the account does not say the tunnel is not taken: %+v", report.Tunnel)
|
||||
}
|
||||
if _, held := state.HeldAt("mesh-wireguard.overlay-up.takes-over"); !held {
|
||||
t.Error("the found configuration was not kept before the refusal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestATakeoverIsRefusedOnAConvergedDeclaration(t *testing.T) {
|
||||
_, err := declaration.Parse([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"up","type":"service","unit":"wg-quick@mesh0","state":"running",
|
||||
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"/etc/wireguard/wg0.conf"}}]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "adopted") {
|
||||
t.Fatalf("a takeover on a converged node was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0119: once the take is proven — taken, and a peer handshaken on the mesh's
|
||||
// interface — the found configuration is removed from where its unit reads it, its original stays
|
||||
// kept and the hold on it ends. Never before, and never brought back.
|
||||
|
||||
const takesOverID = "mesh-wireguard.overlay-up.takes-over"
|
||||
|
||||
// handshaken is `wg show mesh0 latest-handshakes` with one of the two peers through.
|
||||
const handshaken = "PEER-A=\t1790000000\nPEER-B=\t0\n"
|
||||
|
||||
func TestAProvenTakeRetiresTheFoundConfiguration(t *testing.T) {
|
||||
dir, config, mesh, keyFile, m := aHubInUse(t)
|
||||
m.handshakes = handshaken
|
||||
report, state := applyAdopted(t, aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1"), store.State{}, m, dir)
|
||||
|
||||
if _, err := os.Lstat(config); !os.IsNotExist(err) {
|
||||
t.Fatalf("a proven take left the found configuration where its unit reads it: %v", err)
|
||||
}
|
||||
retired, ok := state.RetiredAt(config)
|
||||
if !ok || retired.Kept == "" || retired.ID != takesOverID {
|
||||
t.Fatalf("the retirement was not recorded: %+v", state.Retired)
|
||||
}
|
||||
if kept, _ := os.ReadFile(retired.Kept); string(kept) != foundConf {
|
||||
t.Fatalf("the kept original did not survive the retirement: %q", kept)
|
||||
}
|
||||
if _, held := state.HeldAt(takesOverID); held {
|
||||
t.Error("the hold on the found configuration did not end with its retirement")
|
||||
}
|
||||
if u := m.units["wg-quick@wg0"]; u.active != "inactive" || u.enabled != "disabled" {
|
||||
t.Errorf("the found unit is not left down and disabled: %+v", u)
|
||||
}
|
||||
if report.Tunnel == nil || report.Tunnel.State != Taken || report.Tunnel.Kept != retired.Kept ||
|
||||
!strings.Contains(report.Tunnel.Note, "proven: 1 peer(s) handshaken on mesh0") ||
|
||||
!strings.Contains(report.Tunnel.Note, "is retired") {
|
||||
t.Fatalf("the account does not say the take is proven and the configuration retired: %+v", report.Tunnel)
|
||||
}
|
||||
if o := outcomeOf(report, takesOverID); o.Action != "removed" || !strings.Contains(o.Detail, "retired") {
|
||||
t.Errorf("the retirement is not what the apply says it did to the file: %+v", o)
|
||||
}
|
||||
// And the account still carries what was found, read from the kept original.
|
||||
if report.Tunnel.Port != 51900 || report.Tunnel.Peers != 2 {
|
||||
t.Errorf("the account lost what the tunnel was: %+v", report.Tunnel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATakeNotProvenKeepsTheFoundConfigurationAndSaysSo(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
handshakes string
|
||||
fail error
|
||||
says string
|
||||
}{
|
||||
"no peer at all": {"", nil, "no peer has handshaken on mesh0"},
|
||||
"every handshake at zero": {"PEER-A=\t0\nPEER-B=\t0\n", nil, "no peer has handshaken on mesh0"},
|
||||
"wg is not there": {"", errors.New(`exec: "wg": executable file not found in $PATH`), "executable file not found"},
|
||||
"the answer is nonsense": {"unable to access interface\n", nil, "not a peer and a time"},
|
||||
}
|
||||
for name, c := range cases {
|
||||
dir, config, mesh, keyFile, m := aHubInUse(t)
|
||||
m.handshakes, m.handshakesFail = c.handshakes, c.fail
|
||||
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
|
||||
report, state := applyAdopted(t, d, store.State{}, m, dir)
|
||||
|
||||
if got, _ := os.ReadFile(config); string(got) != foundConf {
|
||||
t.Fatalf("%s: a take not proven lost the found configuration", name)
|
||||
}
|
||||
if _, held := state.HeldAt(takesOverID); !held {
|
||||
t.Errorf("%s: the hold ended although the take is not proven", name)
|
||||
}
|
||||
if _, retired := state.RetiredAt(config); retired {
|
||||
t.Errorf("%s: recorded as retired", name)
|
||||
}
|
||||
if report.Tunnel == nil || report.Tunnel.State != Taken ||
|
||||
!strings.Contains(report.Tunnel.Note, "taken, not yet proven") ||
|
||||
!strings.Contains(report.Tunnel.Note, c.says) || !strings.Contains(report.Tunnel.Note, "is kept") {
|
||||
t.Errorf("%s: the account does not say the take is not proven and why: %+v", name, report.Tunnel)
|
||||
}
|
||||
|
||||
// A later apply that finds a peer through retires it: the take itself need not be the one.
|
||||
m.handshakes, m.handshakesFail = handshaken, nil
|
||||
_, state = applyAdopted(t, d, state, m, dir)
|
||||
if _, err := os.Lstat(config); !os.IsNotExist(err) {
|
||||
t.Errorf("%s: the apply after the take was proven kept the found configuration", name)
|
||||
}
|
||||
if _, retired := state.RetiredAt(config); !retired {
|
||||
t.Errorf("%s: the later retirement was not recorded", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFoundConfigurationWhoseKeptOriginalIsMissingIsNotRetired(t *testing.T) {
|
||||
dir, config, mesh, keyFile, m := aHubInUse(t)
|
||||
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
|
||||
_, state := applyAdopted(t, d, store.State{}, m, dir)
|
||||
held, _ := state.HeldAt(takesOverID)
|
||||
if err := os.Remove(held.Kept); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m.handshakes = handshaken
|
||||
report, state := applyAdopted(t, d, state, m, dir)
|
||||
if got, _ := os.ReadFile(config); string(got) != foundConf {
|
||||
t.Fatal("the found configuration was removed with no kept original left of it")
|
||||
}
|
||||
if _, still := state.HeldAt(takesOverID); !still {
|
||||
t.Error("the hold ended although nothing was retired")
|
||||
}
|
||||
if _, retired := state.RetiredAt(config); retired {
|
||||
t.Error("recorded as retired")
|
||||
}
|
||||
if report.Tunnel == nil || !strings.Contains(report.Tunnel.Note, "is not retired") ||
|
||||
!strings.Contains(report.Tunnel.Note, held.Kept+" is missing") {
|
||||
t.Errorf("the account does not say the kept original is missing: %+v", report.Tunnel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFoundConfigurationRewrittenSinceItWasFoundIsKeptAgainBeforeItGoes(t *testing.T) {
|
||||
dir, config, mesh, keyFile, m := aHubInUse(t)
|
||||
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
|
||||
_, state := applyAdopted(t, d, store.State{}, m, dir)
|
||||
rewritten := foundConf + "\n[Peer]\nPublicKey = PEER-C=\nAllowedIPs = 192.0.2.4/32\n"
|
||||
if err := os.WriteFile(config, []byte(rewritten), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m.handshakes = handshaken
|
||||
_, state = applyAdopted(t, d, state, m, dir)
|
||||
if _, err := os.Lstat(config); !os.IsNotExist(err) {
|
||||
t.Fatal("a proven take kept a rewritten configuration")
|
||||
}
|
||||
retired, _ := state.RetiredAt(config)
|
||||
if first, _ := os.ReadFile(retired.Kept); string(first) != foundConf {
|
||||
t.Errorf("the first original was overwritten: %q", first)
|
||||
}
|
||||
kept, _ := filepath.Glob(filepath.Join(dir, "kept", "*-wg0.conf"))
|
||||
var found bool
|
||||
for _, k := range kept {
|
||||
if got, _ := os.ReadFile(k); string(got) == rewritten {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("what the file held when it was retired was not kept: %v", kept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARetiredTakeIsSteadyAndItsFoundUnitFindsNothingToDo(t *testing.T) {
|
||||
dir, config, mesh, keyFile, m := aHubInUse(t)
|
||||
m.handshakes = handshaken
|
||||
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
|
||||
_, state := applyAdopted(t, d, store.State{}, m, dir)
|
||||
retired, _ := state.RetiredAt(config)
|
||||
|
||||
// wg-quick@wg0 with no configuration: inactive, and disabled — the check of it every apply
|
||||
// makes must find nothing to do and fail on nothing.
|
||||
m.asked = nil
|
||||
report, again := applyAdopted(t, d, state, m, dir)
|
||||
if report.Changed() {
|
||||
t.Errorf("an apply after the retirement moved the machine: %+v", report.Outcomes)
|
||||
}
|
||||
if m.did("systemctl stop wg-quick@wg0") || m.did("systemctl start wg-quick@wg0") {
|
||||
t.Errorf("the retired tunnel's unit was acted on: %v", m.asked)
|
||||
}
|
||||
if _, err := os.Lstat(config); !os.IsNotExist(err) {
|
||||
t.Error("the found configuration came back")
|
||||
}
|
||||
if _, held := again.HeldAt(takesOverID); held {
|
||||
t.Error("a retired configuration is held again")
|
||||
}
|
||||
if r, ok := again.RetiredAt(config); !ok || r != retired {
|
||||
t.Errorf("the retirement was not kept as it was: %+v", again.Retired)
|
||||
}
|
||||
if o := outcomeOf(report, takesOverID); o.Action != "unchanged" || !strings.Contains(o.Detail, "retired") {
|
||||
t.Errorf("the retired configuration is not said as retired: %+v", o)
|
||||
}
|
||||
if report.Tunnel == nil || report.Tunnel.State != Taken || report.Tunnel.Kept != retired.Kept ||
|
||||
!strings.Contains(report.Tunnel.Note, "retired") || report.Tunnel.Port != 51900 {
|
||||
t.Errorf("the account of a retired take does not say so: %+v", report.Tunnel)
|
||||
}
|
||||
|
||||
// Enabled at boot again by a person: disabled again, as any take does, and still no error.
|
||||
m.units["wg-quick@wg0"].enabled = "enabled"
|
||||
_, _ = applyAdopted(t, d, again, m, dir)
|
||||
if m.units["wg-quick@wg0"].enabled != "disabled" {
|
||||
t.Error("the found unit enabled again by hand was left to start at boot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndeclaringThePrivateNetworkAfterRetirementBringsNothingBack(t *testing.T) {
|
||||
dir, config, mesh, keyFile, m := aHubInUse(t)
|
||||
m.handshakes = handshaken
|
||||
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
|
||||
_, state := applyAdopted(t, d, store.State{}, m, dir)
|
||||
|
||||
// The private network unassigned: only something else is declared.
|
||||
other := adopted(t, `{"taken":[],"untaken":{}}`,
|
||||
`{"id":"other.file","type":"file","path":"`+filepath.Join(dir, "other.conf")+`","content":"x\n"}`)
|
||||
m.asked = nil
|
||||
_, after := applyAdopted(t, other, state, m, dir)
|
||||
if _, err := os.Lstat(config); !os.IsNotExist(err) {
|
||||
t.Fatal("undeclaring the private network brought the found configuration back")
|
||||
}
|
||||
if m.did("systemctl start wg-quick@wg0") || m.did("systemctl enable wg-quick@wg0") {
|
||||
t.Errorf("undeclaring the private network started the found tunnel: %v", m.asked)
|
||||
}
|
||||
if _, ok := after.RetiredAt(config); !ok {
|
||||
t.Error("the retirement was forgotten with the private network")
|
||||
}
|
||||
|
||||
// Assigned again, it finds the configuration retired rather than missing, and raises the
|
||||
// mesh's interface.
|
||||
report, _ := applyAdopted(t, d, after, m, dir)
|
||||
if report.Tunnel == nil || report.Tunnel.State != Taken {
|
||||
t.Errorf("the private network assigned again did not take the tunnel: %+v", report.Tunnel)
|
||||
}
|
||||
if _, err := os.Lstat(config); !os.IsNotExist(err) {
|
||||
t.Error("assigning the private network again brought the found configuration back")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPlanSaysTheFoundConfigurationIsRetiredWhenTheTakeIsProven(t *testing.T) {
|
||||
dir, config, mesh, keyFile, m := aHubInUse(t)
|
||||
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
|
||||
|
||||
plan := Plan(d, store.State{}, store.OriginDeclared)
|
||||
report, state := applyAdopted(t, d, store.State{}, m, dir)
|
||||
if got, want := strings.Join(ids(plan), " "), strings.Join(outcomeIDs(report), " "); got != want {
|
||||
t.Errorf("the plan said %q and the apply did %q", got, want)
|
||||
}
|
||||
var take Step
|
||||
for _, s := range plan {
|
||||
if s.ID == takesOverID {
|
||||
take = s
|
||||
}
|
||||
}
|
||||
if take.Verb != "hold" || take.Target != config || !strings.Contains(take.Why, "retired — removed from "+config) ||
|
||||
!strings.Contains(take.Why, "handshaking on mesh0") {
|
||||
t.Errorf("the plan does not say the found configuration is retired once proven: %+v", take)
|
||||
}
|
||||
// Held and still declared: never planned as forgotten.
|
||||
if strings.Contains(verbs(Plan(d, state, store.OriginDeclared)), "forget "+takesOverID) {
|
||||
t.Error("the plan forgets a hold the apply keeps")
|
||||
}
|
||||
|
||||
m.handshakes = handshaken
|
||||
_, state = applyAdopted(t, d, state, m, dir)
|
||||
for _, s := range Plan(d, state, store.OriginDeclared) {
|
||||
if s.ID == takesOverID && (s.Verb != "check" || !strings.Contains(s.Why, "retired once the take")) {
|
||||
t.Errorf("a retired configuration is planned as %+v", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// keptCopies is every copy kept of the found configuration.
|
||||
func keptCopies(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
kept, err := filepath.Glob(filepath.Join(dir, "kept", "*-wg0.conf"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
func TestAConfigurationPutBackIsRetiredAgainOnItsFirstOriginalAndSettles(t *testing.T) {
|
||||
dir, config, mesh, keyFile, m := aHubInUse(t)
|
||||
m.handshakes = handshaken
|
||||
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
|
||||
_, state := applyAdopted(t, d, store.State{}, m, dir)
|
||||
first, _ := state.RetiredAt(config)
|
||||
|
||||
// Put back by hand with the original, while no peer is through yet: held on the first
|
||||
// original, and the account says what a rollback takes.
|
||||
write(t, config, foundConf)
|
||||
m.handshakes = ""
|
||||
report, state := applyAdopted(t, d, state, m, dir)
|
||||
if held, ok := state.HeldAt(takesOverID); !ok || held.Kept != first.Kept {
|
||||
t.Fatalf("what came back is not held on the first original: %+v", held)
|
||||
}
|
||||
if report.Tunnel == nil || !strings.Contains(report.Tunnel.Note, "came back after it was retired") ||
|
||||
!strings.Contains(report.Tunnel.Note, "unassigning the private network first") {
|
||||
t.Errorf("the account does not say a rollback means unassigning the private network: %+v", report.Tunnel)
|
||||
}
|
||||
|
||||
// Proven: retired again, nothing more kept, said once.
|
||||
m.handshakes = handshaken
|
||||
report, state = applyAdopted(t, d, state, m, dir)
|
||||
again, _ := state.RetiredAt(config)
|
||||
if _, err := os.Lstat(config); !os.IsNotExist(err) || again.Kept != first.Kept || again.Extra != "" {
|
||||
t.Fatalf("put back as it was, it was not retired again on the first original: %+v", again)
|
||||
}
|
||||
if o := outcomeOf(report, takesOverID); o.Action != "removed" ||
|
||||
!strings.HasPrefix(o.Detail, "the found configuration came back and was retired again") ||
|
||||
strings.Contains(o.Detail, "differed") {
|
||||
t.Errorf("the second retirement is not said as one: %+v", o)
|
||||
}
|
||||
if !strings.Contains(report.Tunnel.Note, "unassigning the private network first") {
|
||||
t.Errorf("the account does not say what a rollback takes: %q", report.Tunnel.Note)
|
||||
}
|
||||
if n := len(keptCopies(t, dir)); n != 1 {
|
||||
t.Errorf("%d copies kept of one content", n)
|
||||
}
|
||||
if report, _ := applyAdopted(t, d, state, m, dir); report.Changed() {
|
||||
t.Errorf("a steady machine moved after the second retirement: %+v", report.Outcomes)
|
||||
}
|
||||
|
||||
// Put back with something else: that is kept beside the first original, which stays the record's.
|
||||
other := strings.Replace(foundConf, "PEER-B=", "PEER-Z=", 1)
|
||||
write(t, config, other)
|
||||
report, state = applyAdopted(t, d, state, m, dir)
|
||||
third, _ := state.RetiredAt(config)
|
||||
if third.Kept != first.Kept || third.Extra == "" || third.Extra == first.Kept {
|
||||
t.Fatalf("other content was not kept apart from the first original: %+v", third)
|
||||
}
|
||||
if got, _ := os.ReadFile(third.Extra); string(got) != other {
|
||||
t.Errorf("the extra copy does not hold what was put back: %q", got)
|
||||
}
|
||||
if o := outcomeOf(report, takesOverID); !strings.Contains(o.Detail, third.Extra) ||
|
||||
!strings.Contains(report.Tunnel.Note, third.Extra) {
|
||||
t.Errorf("where the extra copy is was not said: %+v / %q", o, report.Tunnel.Note)
|
||||
}
|
||||
|
||||
// And the same other content again: nothing more kept, the record as it was.
|
||||
write(t, config, other)
|
||||
report, state = applyAdopted(t, d, state, m, dir)
|
||||
fourth, _ := state.RetiredAt(config)
|
||||
if fourth.Kept != first.Kept || fourth.Extra != third.Extra || len(keptCopies(t, dir)) != 2 {
|
||||
t.Errorf("the same content put back again grew the copies: %+v, %v", fourth, keptCopies(t, dir))
|
||||
}
|
||||
if o := outcomeOf(report, takesOverID); strings.Contains(o.Detail, "differed") {
|
||||
t.Errorf("a copy already kept was said as new: %+v", o)
|
||||
}
|
||||
if report, _ := applyAdopted(t, d, state, m, dir); report.Changed() {
|
||||
t.Errorf("a steady machine moved: %+v", report.Outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyWgQuicksOwnConfigurationIsRetired(t *testing.T) {
|
||||
// Not under the wireguard directory.
|
||||
dir, config, mesh, keyFile, m := aHubInUse(t)
|
||||
wireguardDir = filepath.Join(dir, "elsewhere")
|
||||
m.handshakes = handshaken
|
||||
report, state := applyAdopted(t, aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1"), store.State{}, m, dir)
|
||||
if got, _ := os.ReadFile(config); string(got) != foundConf {
|
||||
t.Fatal("a configuration outside wg-quick's directory was removed")
|
||||
}
|
||||
if _, held := state.HeldAt(takesOverID); !held || !strings.Contains(report.Tunnel.Note, "is not retired: only ") {
|
||||
t.Errorf("the refusal is not said, or the hold ended: %+v", report.Tunnel)
|
||||
}
|
||||
|
||||
// A path the mesh itself writes.
|
||||
dir, config, mesh, keyFile, m = aHubInUse(t)
|
||||
m.handshakes = handshaken
|
||||
known := store.State{}
|
||||
known.Record(store.Applied{ID: "bundle.wg0", Type: "file", Target: config, Origin: store.OriginCarried})
|
||||
report, _ = applyAdopted(t, aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1"), known, m, dir)
|
||||
if got, _ := os.ReadFile(config); string(got) != foundConf {
|
||||
t.Fatal("a path the mesh writes was retired")
|
||||
}
|
||||
if !strings.Contains(report.Tunnel.Note, "a path the mesh itself writes") {
|
||||
t.Errorf("the refusal is not said: %+v", report.Tunnel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFoundConfigurationThatIsALinkIsKeptAndLeftToAPerson(t *testing.T) {
|
||||
dir, config, mesh, keyFile, m := aHubInUse(t)
|
||||
target := filepath.Join(dir, "predecessor", "hub.conf")
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write(t, target, foundConf)
|
||||
if err := os.Remove(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.handshakes = handshaken
|
||||
report, state := applyAdopted(t, aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1"), store.State{}, m, dir)
|
||||
if _, err := os.Lstat(config); err != nil {
|
||||
t.Fatal("the link was removed, leaving the key-bearing file it points at")
|
||||
}
|
||||
if got, _ := os.ReadFile(target); string(got) != foundConf {
|
||||
t.Fatal("the file the link points at was touched")
|
||||
}
|
||||
held, ok := state.HeldAt(takesOverID)
|
||||
if !ok {
|
||||
t.Fatal("the hold ended")
|
||||
}
|
||||
if kept, _ := os.ReadFile(held.Kept); string(kept) != foundConf {
|
||||
t.Errorf("what was kept is not what the link points at: %q", kept)
|
||||
}
|
||||
if !strings.Contains(report.Tunnel.Note, "is a link to "+target) || !strings.Contains(report.Tunnel.Note, "by hand") {
|
||||
t.Errorf("the account does not say the link must be retired by hand: %q", report.Tunnel.Note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARetirementWhoseRecordWasNeverSavedIsRecordedByTheNextApply(t *testing.T) {
|
||||
dir, config, mesh, keyFile, m := aHubInUse(t)
|
||||
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
|
||||
_, state := applyAdopted(t, d, store.State{}, m, dir)
|
||||
held, _ := state.HeldAt(takesOverID)
|
||||
// An apply removed the file and stopped before its state was saved.
|
||||
if err := os.Remove(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.handshakes = handshaken
|
||||
report, state := applyAdopted(t, d, state, m, dir)
|
||||
if r, ok := state.RetiredAt(config); !ok || r.Kept != held.Kept {
|
||||
t.Fatalf("the retirement was not recorded: %+v", state.Retired)
|
||||
}
|
||||
if _, still := state.HeldAt(takesOverID); still {
|
||||
t.Error("the hold did not end")
|
||||
}
|
||||
if o := outcomeOf(report, takesOverID); o.Action != "unchanged" || !strings.Contains(o.Detail, "already gone") {
|
||||
t.Errorf("a file already gone is not said as such: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARemovalThatFailsKeepsTheFileAndTheHold(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root removes from a directory it may not write to")
|
||||
}
|
||||
dir, _, mesh, keyFile, m := aHubInUse(t)
|
||||
wg := filepath.Join(dir, "wireguard")
|
||||
if err := os.MkdirAll(wg, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := filepath.Join(wg, "wg0.conf")
|
||||
write(t, config, foundConf)
|
||||
wireguardDir = wg
|
||||
d := aTakeover(t, config, mesh, keyFile, "51900", "192.0.2.1")
|
||||
_, state := applyAdopted(t, d, store.State{}, m, dir)
|
||||
|
||||
if err := os.Chmod(wg, 0o500); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(wg, 0o700) })
|
||||
m.handshakes = handshaken
|
||||
report, state := applyAdopted(t, d, state, m, dir)
|
||||
if got, _ := os.ReadFile(config); string(got) != foundConf {
|
||||
t.Fatal("the found configuration is gone although it could not be removed")
|
||||
}
|
||||
if _, held := state.HeldAt(takesOverID); !held {
|
||||
t.Error("the hold ended although nothing was retired")
|
||||
}
|
||||
if _, retired := state.RetiredAt(config); retired {
|
||||
t.Error("recorded as retired")
|
||||
}
|
||||
if !strings.Contains(report.Tunnel.Note, "removing it failed") || !strings.Contains(report.Tunnel.Note, "permission denied") {
|
||||
t.Errorf("the failed removal is not said: %q", report.Tunnel.Note)
|
||||
}
|
||||
}
|
||||
@@ -376,7 +376,7 @@ func TestAContainerIsGivenItsEnvironmentFiles(t *testing.T) {
|
||||
var ran []string
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
ran = append(ran, name+" "+strings.Join(args, " "))
|
||||
if len(args) > 0 && args[0] == "inspect" {
|
||||
if len(args) > 0 && args[0] == "container" {
|
||||
return "", fmt.Errorf("no such container")
|
||||
}
|
||||
return "", nil
|
||||
@@ -403,3 +403,51 @@ func TestAContainerIsGivenItsEnvironmentFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A container may name its resolvers and its own address — the shape a module shipping its own
|
||||
// validating DNS needs: the resolver pinned where its siblings can find it, the siblings pointed
|
||||
// at it. Both flags take addresses, so both reach the runtime verbatim.
|
||||
func TestAContainerIsGivenItsResolverAndItsAddress(t *testing.T) {
|
||||
var ran []string
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
ran = append(ran, name+" "+strings.Join(args, " "))
|
||||
if len(args) > 0 && args[0] == "container" {
|
||||
return "", fmt.Errorf("no such container")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
d := declare(t, `{"id":"imap","type":"container","name":"mailu-imap",`+
|
||||
`"image":"dovecot@sha256:0000000000000000000000000000000000000000000000000000000000000000",`+
|
||||
`"network":"mailu","dns":["192.168.203.254"],"ip":"192.168.203.7"}`)
|
||||
|
||||
_, _, _ = Apply(context.Background(), archHost(t), d, store.State{},
|
||||
store.OriginDeclared, run, nil, nil)
|
||||
|
||||
var started string
|
||||
for _, line := range ran {
|
||||
if strings.Contains(line, "run ") {
|
||||
started = line
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"--dns 192.168.203.254", "--ip 192.168.203.7"} {
|
||||
if !strings.Contains(started, want) {
|
||||
t.Errorf("the container was started without %q:\n%s", want, started)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The resolver and address are part of the spec — a container whose dns or ip moved is a
|
||||
// different container, or the fields can never reach one that already runs. That is not
|
||||
// hypothetical: their first deployment compared equal and changed nothing.
|
||||
func TestAChangedResolverOrAddressIsAChangedContainer(t *testing.T) {
|
||||
base := &declaration.Container{ID: "c", Name: "x", Image: "a@sha256:00"}
|
||||
withDns := &declaration.Container{ID: "c", Name: "x", Image: "a@sha256:00", Dns: []string{"192.168.203.254"}}
|
||||
withIP := &declaration.Container{ID: "c", Name: "x", Image: "a@sha256:00", IP: "192.168.203.254"}
|
||||
plain := containerSpecReading(base, nil, nil)
|
||||
if containerSpecReading(withDns, nil, nil) == plain {
|
||||
t.Error("adding a resolver did not change the spec, so it can never reach a running container")
|
||||
}
|
||||
if containerSpecReading(withIP, nil, nil) == plain {
|
||||
t.Error("adding an address did not change the spec, so it can never reach a running container")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// Defends phase three's premise (phase3.go): the store genesis raised is adopted by the postgres
|
||||
// module IN PLACE — same name, same image, same spec — so the applier reconciles it and never
|
||||
// recreates the mesh's memory with the temporary control plane connected to it.
|
||||
//
|
||||
// The host folds a mounted file's content into the container's spec (novox/hq 04-ISSUES/103), so
|
||||
// this now depends on a byte: the superuser file genesis writes and mounts must be the same bytes
|
||||
// the module later declares. The module's value is what `secret accept` took — the operator's
|
||||
// file with its line ending removed and nothing else (mesh-control, asSupplied). Reproduced before
|
||||
// it was fixed: genesis wrote `value\n`, the module wrote `value`, and the store was recreated
|
||||
// during install.
|
||||
|
||||
// labelled is a runtime that keeps the spec label the host gives a container and hands it back.
|
||||
type labelled struct {
|
||||
spec map[string]string
|
||||
created []string
|
||||
removed []string
|
||||
}
|
||||
|
||||
func (l *labelled) run(_ context.Context, _ string, args ...string) (string, error) {
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "27.0\n", nil
|
||||
case "container":
|
||||
spec, ok := l.spec[args[len(args)-1]]
|
||||
if !ok {
|
||||
return "", errors.New("no such container")
|
||||
}
|
||||
return "true\t" + spec + "\n", nil
|
||||
case "rm":
|
||||
l.removed = append(l.removed, args[len(args)-1])
|
||||
delete(l.spec, args[len(args)-1])
|
||||
case "run":
|
||||
var name, spec string
|
||||
for i, a := range args {
|
||||
if a == "--name" {
|
||||
name = args[i+1]
|
||||
}
|
||||
if a == "--label" && strings.HasPrefix(args[i+1], "mesh-host.spec=") {
|
||||
spec = strings.TrimPrefix(args[i+1], "mesh-host.spec=")
|
||||
}
|
||||
}
|
||||
l.spec[name] = spec
|
||||
l.created = append(l.created, name)
|
||||
return "made\n", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func TestTheStoreGenesisRaisedIsAdoptedInPlaceNotRecreated(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
secret := filepath.Join(dir, "superuser.secret")
|
||||
|
||||
// The bytes genesis really writes — the code path, not a fixture that agrees with it.
|
||||
if _, made, err := keptOrMade(secret, false); err != nil || !made {
|
||||
t.Fatalf("genesis did not make the superuser secret: made=%v err=%v", made, err)
|
||||
}
|
||||
onDisk, err := os.ReadFile(secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
image := "docker.io/library/postgres@sha256:" + strings.Repeat("ab", 32)
|
||||
mounts := `"volumes":["mesh-store-data:/var/lib/postgresql/data","` + secret + `:` + storeSuperuserMount + `:ro"]`
|
||||
|
||||
// The foundation's store, as the produced bundle raises it (RewriteRoot): the file mounted,
|
||||
// declared by nothing — genesis wrote it before there was a declaration to name it.
|
||||
raise, err := declaration.ParseTrusted([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"` + StoreID + `","type":"container","name":"mesh-store","image":"` + image + `",
|
||||
"env":{"POSTGRES_PASSWORD_FILE":"` + storeSuperuserMount + `"},` + mounts + `}
|
||||
]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := &labelled{spec: map[string]string{}}
|
||||
_, known, err := apply.Apply(context.Background(), arch(t), raise, store.State{}, store.OriginCarried,
|
||||
runtime.run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("raising the foundation's store: %v", err)
|
||||
}
|
||||
if len(runtime.created) != 1 {
|
||||
t.Fatalf("the store was not raised once: %v", runtime.created)
|
||||
}
|
||||
|
||||
// The postgres module's declaration of the same store: the superuser file as `secret accept`
|
||||
// took it in — its line ending removed and nothing else — then the same container.
|
||||
accepted := strings.TrimRight(string(onDisk), "\r\n")
|
||||
adopt, err := declaration.ParseTrusted([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"postgres.superuser","type":"file","path":"` + secret + `","content":"` + accepted + `","mode":"0600"},
|
||||
{"id":"postgres.server","type":"container","name":"mesh-store","image":"` + image + `",
|
||||
"env":{"POSTGRES_PASSWORD_FILE":"` + storeSuperuserMount + `"},` + mounts + `}
|
||||
]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime.created, runtime.removed = nil, nil
|
||||
report, _, err := apply.Apply(context.Background(), arch(t), adopt, known, store.OriginDeclared,
|
||||
runtime.run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("adopting the store: %v", err)
|
||||
}
|
||||
|
||||
if len(runtime.removed) > 0 || len(runtime.created) > 0 {
|
||||
t.Fatalf("the module's declaration recreated the store genesis raised (removed %v, created %v): "+
|
||||
"the file genesis mounted and the file the module declares are not the same bytes",
|
||||
runtime.removed, runtime.created)
|
||||
}
|
||||
for _, o := range report.Outcomes {
|
||||
if o.ID == "postgres.server" && o.Action != "unchanged" {
|
||||
t.Errorf("the store was not adopted in place: %+v", o)
|
||||
}
|
||||
if o.ID == "postgres.superuser" && o.Action != "unchanged" {
|
||||
t.Errorf("the module rewrote the superuser file genesis wrote: %+v", o)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
@@ -33,8 +34,16 @@ type Runner = apply.Runner
|
||||
// What it does not do is the host's own lifecycle bookkeeping — recording a known-good version,
|
||||
// clearing the launcher's start counter. Those are facts about a running `mesh-host`, and this is
|
||||
// not one.
|
||||
//
|
||||
// What it does record is that the bundle was consumed, and in which mode the operator raised
|
||||
// the machine. `raw` is the exact bytes of what is applied — the bundle as rewritten for this
|
||||
// machine — and its digest is what `mesh-host reconcile` later holds the carried bundle against,
|
||||
// by digest: a host built from the carried template does not match it, since genesis rewrote the
|
||||
// ports, root credentials and adoption; a host built from the lock genesis wrote out does.
|
||||
// Applying the unrewritten template on a raised node recreated the store and loaded the converged
|
||||
// filter on an adopted one (novox/hq issue 104).
|
||||
func ApplyBundle(ctx context.Context, o Options, sys system.System, d *declaration.Declaration,
|
||||
run Runner, say func(string)) (apply.Report, error) {
|
||||
raw []byte, run Runner, say func(string)) (apply.Report, error) {
|
||||
|
||||
// Refuse a shape this host cannot apply before anything is applied, exactly as `mesh-host`
|
||||
// does: finding out half way through is the half-configured machine tier 0 exists to prevent.
|
||||
@@ -42,6 +51,12 @@ func ApplyBundle(ctx context.Context, o Options, sys system.System, d *declarati
|
||||
return apply.Report{}, err
|
||||
}
|
||||
|
||||
// One apply at a time on this machine: a host already running here applies too.
|
||||
unlock, err := store.Lock(o.State, func() { say(" waiting another apply holds this node's state") })
|
||||
if err != nil {
|
||||
return apply.Report{}, err
|
||||
}
|
||||
defer unlock()
|
||||
known, err := store.Load(o.State)
|
||||
if err != nil {
|
||||
return apply.Report{}, err
|
||||
@@ -55,6 +70,20 @@ func ApplyBundle(ctx context.Context, o Options, sys system.System, d *declarati
|
||||
func(line string) { say(" " + strings.TrimPrefix(line, " ")) }, refuseSealed,
|
||||
apply.KeepIn(filepath.Dir(o.State)))
|
||||
|
||||
// The bundle is consumed, whichever way the apply went: what is on the machine came from these
|
||||
// bytes, and the carried ones must not be applied over it. The mode is the operator's word at
|
||||
// genesis, and only at genesis: the controller records the same and says it in every
|
||||
// declaration from then on (novox/hq ADR 0100), so a node the mesh has spoken to — this
|
||||
// installer re-run on it, or finishing a pivot — keeps the mode it has, which may since have
|
||||
// been flipped.
|
||||
updated.Genesis = &store.Genesis{Digest: apply.DigestOf(raw), At: time.Now().UTC(), Rewritten: true}
|
||||
if updated.Mode == "" {
|
||||
updated.Mode = store.ModeConverged
|
||||
if o.Adopted {
|
||||
updated.Mode = store.ModeAdopted
|
||||
}
|
||||
}
|
||||
|
||||
// Saved whichever way it went, for the reason `mesh-host` gives: what was applied before a
|
||||
// failure is on the machine either way, and a host that did not record it would believe it
|
||||
// owns less than it does and leave that behind for ever.
|
||||
|
||||
@@ -3,12 +3,15 @@ package bootstrap
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/system"
|
||||
)
|
||||
|
||||
@@ -113,7 +116,7 @@ func TestTheBundleKeepsTheOriginalOfWhatItWritesOver(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o := Options{State: filepath.Join(dir, "state.json")}
|
||||
report, err := ApplyBundle(context.Background(), o, sys, d, nil, quietly)
|
||||
report, err := ApplyBundle(context.Background(), o, sys, d, nil, nil, quietly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -127,3 +130,53 @@ func TestTheBundleKeepsTheOriginalOfWhatItWritesOver(t *testing.T) {
|
||||
t.Errorf("the kept original is %q (%v)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Defends novox/hq issue 104: genesis consumes the bundle, recording the digest of what it applied
|
||||
// and the mode the operator raised the machine in, so the host's own `reconcile` never applies the
|
||||
// carried bytes over it.
|
||||
func TestGenesisConsumesTheBundleAndRecordsTheMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
raw := []byte(`{"declaration":1,"resources":[
|
||||
{"id":"a","type":"file","path":"` + filepath.Join(dir, "a.conf") + `","content":"x\n"}]}`)
|
||||
d, err := declaration.ParseFileTrusted(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sys, err := system.For("arch")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, adopted := range []bool{false, true} {
|
||||
o := Options{State: filepath.Join(dir, fmt.Sprintf("state-%v.json", adopted)), Adopted: adopted}
|
||||
if _, err := ApplyBundle(context.Background(), o, sys, d, raw, nil, quietly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
known, err := store.Load(o.State)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if known.Genesis == nil || known.Genesis.Digest != apply.DigestOf(raw) || !known.Genesis.Rewritten {
|
||||
t.Errorf("adopted=%v: genesis did not record the bundle it consumed: %+v", adopted, known.Genesis)
|
||||
}
|
||||
want := store.ModeConverged
|
||||
if adopted {
|
||||
want = store.ModeAdopted
|
||||
}
|
||||
if known.Mode != want {
|
||||
t.Errorf("adopted=%v: genesis recorded the mode as %q, want %q", adopted, known.Mode, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-run on a node the mesh has spoken to since — and converged — genesis leaves the mode
|
||||
// alone: it is the operator's word at genesis, and the controller's from then on.
|
||||
o := Options{State: filepath.Join(dir, "state-flipped.json"), Adopted: true}
|
||||
if err := store.Save(o.State, store.State{Mode: store.ModeConverged}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ApplyBundle(context.Background(), o, sys, d, raw, nil, quietly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if known, _ := store.Load(o.State); known.Mode != store.ModeConverged {
|
||||
t.Errorf("a genesis re-run set the mode back to %q over the mesh's converged", known.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/firewall"
|
||||
"github.com/novox/mesh-host/internal/tunnel"
|
||||
)
|
||||
|
||||
// Step names one stage. A failure says which one, because "the bootstrap failed" is a sentence
|
||||
@@ -201,6 +202,11 @@ type Options struct {
|
||||
// until each module is taken, its firewall stays in force, and the mesh guards its own ports
|
||||
// in a table that only refuses. Without it, a machine in use is refused.
|
||||
Adopted bool
|
||||
// Tunnel names the found tunnel's interface an adopted hub takes over (novox/hq ADR 0105), when
|
||||
// more than one is up and the machine cannot say which. Empty finds the one that is up. Once
|
||||
// found, the tunnel's port is the hub's and its range the private network's; --hub-port and
|
||||
// --overlay-range may agree with it or be left unsaid.
|
||||
Tunnel string
|
||||
}
|
||||
|
||||
// pivots reports whether this run goes past the foundation.
|
||||
@@ -311,6 +317,9 @@ type Result struct {
|
||||
// Filter is the packet filter chosen for when the node converges; an adopted genesis loads
|
||||
// none, and the flip assigns this one.
|
||||
Filter string `json:"filter-on-converge,omitempty"`
|
||||
// Tunnel is the found tunnel an adopted genesis takes over (novox/hq ADR 0105): what was read
|
||||
// from it, never its key.
|
||||
Tunnel *tunnel.Found `json:"tunnel,omitempty"`
|
||||
}
|
||||
|
||||
// Run performs the bootstrap, saying what it is doing as it goes.
|
||||
@@ -394,6 +403,23 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro
|
||||
}
|
||||
result.Firewall = string(kind)
|
||||
say(" adopted what is on this machine is kept; its firewall (" + string(kind) + ") stays in force")
|
||||
|
||||
// The tunnel the predecessor left, which the private network takes over (novox/hq ADR
|
||||
// 0105): its port is the hub's and its range is the mesh's from here on, so both are
|
||||
// settled before the ports are checked free and the bundle rewritten.
|
||||
found, err := TakeTheTunnel(&o, d.Run)
|
||||
if err != nil {
|
||||
return result, failed(StepPreflight, err)
|
||||
}
|
||||
if found != nil {
|
||||
result.Tunnel = found
|
||||
result.Ports = o.Ports
|
||||
say(fmt.Sprintf(" tunnel %s — the private network takes it over: its port %d is "+
|
||||
"the hub's, its range %s the mesh's, and its %d peer(s) are carried until they enrol",
|
||||
found.Interface, found.Port, found.Range, len(found.Peers)))
|
||||
} else {
|
||||
say(" tunnel none up on this machine; the private network is raised on its own port and range")
|
||||
}
|
||||
}
|
||||
|
||||
sys, err := WorkOutSystem(ctx, d.Run, o.System)
|
||||
@@ -566,7 +592,7 @@ func Run(ctx context.Context, o Options, d Deps, say func(string)) (Result, erro
|
||||
|
||||
// ---- 4. apply -----------------------------------------------------------------------
|
||||
say("apply — raising the foundation")
|
||||
report, err := ApplyBundle(ctx, o, sys, rewritten.Declaration, d.Run, say)
|
||||
report, err := ApplyBundle(ctx, o, sys, rewritten.Declaration, rewritten.Bundle, d.Run, say)
|
||||
result.Applied, result.Changed = len(report.Outcomes), report.Changed()
|
||||
if err != nil {
|
||||
return result, failed(StepApply, err)
|
||||
|
||||
@@ -254,7 +254,7 @@ func TestAManifestWantingNoStoresIsRefusedWithTheShapeItShouldHave(t *testing.T)
|
||||
// a reply proves the sealed connections it was given are the ones the foundation made.
|
||||
func TestThePermanentControlPlaneIsAskedTheSameQuestion(t *testing.T) {
|
||||
runtime := &asked{answer: aMeshThatAgrees(map[string]string{
|
||||
"module list": "",
|
||||
"module list": "",
|
||||
"exec mesh-controller /mesh-controller": "1 node, 0 waiting\n",
|
||||
})}
|
||||
control := controlPlane{container: "temp-mesh-controller", run: runtime.run, timeout: time.Second}
|
||||
|
||||
@@ -112,7 +112,19 @@ func Enrol(ctx context.Context, o Options, sys system.System, control controlPla
|
||||
return out, err
|
||||
}
|
||||
joining, cancel := context.WithTimeout(ctx, o.Wait)
|
||||
joined, err := control.run(joining, o.Host, "enrol", "--token", token, "--state", o.State)
|
||||
args := []string{"enrol", "--token", token, "--state", o.State}
|
||||
if o.Tunnel != "" {
|
||||
// The found tunnel's key becomes this node's overlay key, and the tunnel travels with
|
||||
// the enrolment (novox/hq ADR 0105). Named, so the host takes the one genesis settled
|
||||
// its ports and range on and not another that came up since.
|
||||
args = append(args, "--tunnel", o.Tunnel)
|
||||
}
|
||||
// The enrolment account the token's secret is the password of exists in the mesh's records
|
||||
// and nowhere on the bus yet (novox/hq 04-ISSUES/146): placed before the machine presents it.
|
||||
if err := placeTheBusUsers(ctx, control, say); err != nil {
|
||||
return out, err
|
||||
}
|
||||
joined, err := control.run(joining, o.Host, args...)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return out, fmt.Errorf(
|
||||
@@ -130,6 +142,10 @@ func Enrol(ctx context.Context, o Options, sys system.System, control controlPla
|
||||
}
|
||||
out.Joined = true
|
||||
say(" enrolled as " + o.Node)
|
||||
// And the node's own account, minted as it enrolled, before its agent connects as it.
|
||||
if err := placeTheBusUsers(ctx, control, say); err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
|
||||
// 4. The agent.
|
||||
@@ -141,6 +157,40 @@ func Enrol(ctx context.Context, o Options, sys system.System, control controlPla
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// busAccounts and busContainer are where the installer's bundle raises the bus: the file its
|
||||
// configuration includes, and the container that reads it. The installer raised them, so it is the
|
||||
// one that knows them (examples/foundation-first-node-nats.lock).
|
||||
const (
|
||||
busAccounts = "/var/lib/mesh-bus-conf/accounts.conf"
|
||||
busContainer = "mesh-broker"
|
||||
)
|
||||
|
||||
// placeTheBusUsers writes the mesh's composed user list beside the bus the installer raised, and makes
|
||||
// the bus re-read it.
|
||||
//
|
||||
// **Genesis's own step** (novox/hq 04-ISSUES/146). Every account on the bus reaches it in the
|
||||
// declaration of the machine that runs it — which needs that machine to be an enrolled node, and at
|
||||
// genesis it is not. The control plane composes the list and says it; whoever raised the bus places
|
||||
// it. That is this installer: it carried the bus in its bundle, so it knows where the bus reads it,
|
||||
// and the control plane never has to.
|
||||
func placeTheBusUsers(ctx context.Context, control controlPlane, say func(string)) error {
|
||||
users, err := control.tell(ctx, "broker", "accounts")
|
||||
if err != nil {
|
||||
return fmt.Errorf("the control plane would not say the bus's users, so no machine could "+
|
||||
"join it: %w", err)
|
||||
}
|
||||
if !strings.Contains(users, "accounts") {
|
||||
return fmt.Errorf("the control plane's account of the bus's users is not one:\n%s", indent(users))
|
||||
}
|
||||
script := "umask 077 && cat > " + busAccounts + ".next <<'MESHBUSUSERS'\n" + users + "\nMESHBUSUSERS\n" +
|
||||
"mv " + busAccounts + ".next " + busAccounts + " && docker kill -s HUP " + busContainer + " >/dev/null"
|
||||
if out, err := control.run(ctx, "sh", "-c", script); err != nil {
|
||||
return fmt.Errorf("the bus's users could not be placed at %s: %w\n%s", busAccounts, err, indent(out))
|
||||
}
|
||||
say(" bus users placed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// runTheHost makes sure something on this machine is listening to the mesh, and proves it.
|
||||
//
|
||||
// **The installer does not install the service, and says so.** A unit file is a packaging decision
|
||||
|
||||
@@ -261,3 +261,56 @@ func TestAListingIsMatchedByNameAndNotBySubstring(t *testing.T) {
|
||||
t.Error("registry-mirror was not found")
|
||||
}
|
||||
}
|
||||
|
||||
// **Genesis places the bus's users, before the machine enrols and again after** (novox/hq
|
||||
// 04-ISSUES/146). The enrolment account exists only in the mesh's records until somebody writes it
|
||||
// beside the bus; so does the node's own, minted as it enrols. Without the first, the machine is
|
||||
// refused by the bus it just raised; without the second, its agent is.
|
||||
func TestAFirstNodeIsLetOntoTheBusItRaised(t *testing.T) {
|
||||
enrolled := false
|
||||
runtime := &asked{answer: func(name string, args []string) (string, error) {
|
||||
joined := strings.Join(args, " ")
|
||||
switch {
|
||||
case strings.Contains(joined, "node list"):
|
||||
if enrolled {
|
||||
return "anchor here 01J0\n", nil
|
||||
}
|
||||
return "", nil
|
||||
case strings.Contains(joined, "node add"):
|
||||
return "added anchor\n", nil
|
||||
case strings.Contains(joined, "token issue"):
|
||||
return "a token for anchor, good once:\n\n " + strings.Repeat("t", 240) + "\n\n", nil
|
||||
case strings.Contains(joined, "broker accounts"):
|
||||
return "accounts {\n MESH { users = [] }\n}\n", nil
|
||||
case name == "/usr/local/bin/mesh-host":
|
||||
enrolled = true
|
||||
return "enrolled as anchor\n", nil
|
||||
case name == "pgrep", name == "sh":
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("unexpected: %s %v", name, args)
|
||||
}}
|
||||
|
||||
if _, err := Enrol(context.Background(), Options{
|
||||
Node: "anchor", State: filepath.Join(t.TempDir(), "state.json"), Timeout: time.Second,
|
||||
Host: "/usr/local/bin/mesh-host", HostInBackground: true,
|
||||
}, arch(t), controlPlane{container: "temp-mesh-controller", run: runtime.run, timeout: time.Second},
|
||||
func(string) {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
placed, enrol := []int{}, -1
|
||||
for i, c := range runtime.commands {
|
||||
if strings.HasPrefix(c, "sh -c") && strings.Contains(c, "kill -s HUP mesh-broker") &&
|
||||
strings.Contains(c, "/var/lib/mesh-bus-conf/accounts.conf") {
|
||||
placed = append(placed, i)
|
||||
}
|
||||
if strings.HasPrefix(c, "/usr/local/bin/mesh-host enrol") {
|
||||
enrol = i
|
||||
}
|
||||
}
|
||||
if enrol < 0 || len(placed) != 2 || placed[0] > enrol || placed[1] < enrol {
|
||||
t.Fatalf("the bus's users were not placed before the machine enrolled and again after "+
|
||||
"(placed at %v, enrolled at %d):\n%s", placed, enrol, strings.Join(runtime.commands, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What genesis raises, it raises as the module that succeeds it declares — name, data directory
|
||||
// and image — so the module adopts it by the found rule that already exists (novox/hq ADR 0163,
|
||||
// rule 7; issue 090). The network is the one difference left: the bootstrap forge runs on the
|
||||
// machine's network to reach the store on its loopback, and a take says so.
|
||||
func TestGenesisRaisesTheForgeAsTheModuleDeclaresIt(t *testing.T) {
|
||||
var ran [][]string
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
if name == "docker" && args[0] == "container" {
|
||||
return "", nil // not raised yet
|
||||
}
|
||||
ran = append(ran, append([]string{name}, args...))
|
||||
return "", nil
|
||||
}
|
||||
if err := raiseGiteaServer(context.Background(), run, time.Second, "pw", DefaultPorts(), quietly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raised []string
|
||||
for _, r := range ran {
|
||||
if r[0] == "docker" && r[1] == "run" {
|
||||
raised = r
|
||||
}
|
||||
}
|
||||
line := strings.Join(raised, " ")
|
||||
for _, want := range []string{"--name gitea ", "--volume " + giteaDataDir + ":/data", " " + giteaImage} {
|
||||
if !strings.Contains(line+" ", want) {
|
||||
t.Errorf("the forge is not raised with %q: %s", want, line)
|
||||
}
|
||||
}
|
||||
if giteaBootstrap != ForgeModule {
|
||||
t.Errorf("the bootstrap forge is %q and the module names its container %q", giteaBootstrap, ForgeModule)
|
||||
}
|
||||
|
||||
// Against the module's own manifest, where the catalogue is checked out beside this repository.
|
||||
var manifest []byte
|
||||
for _, candidate := range []string{"../../../mesh-catalog/modules/gitea/module.json", "../../../../../mesh-catalog/modules/gitea/module.json"} {
|
||||
if raw, err := os.ReadFile(filepath.Clean(candidate)); err == nil {
|
||||
manifest = raw
|
||||
break
|
||||
}
|
||||
}
|
||||
if manifest == nil {
|
||||
t.Skip("the catalogue is not beside this checkout; the module's pin is not compared")
|
||||
}
|
||||
var m struct {
|
||||
Resources []struct {
|
||||
ID, Type, Name, Image string
|
||||
Volumes []string
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(manifest, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r.Type != "container" || r.ID != "server" {
|
||||
continue
|
||||
}
|
||||
if r.Name != giteaBootstrap {
|
||||
t.Errorf("the module names its container %q; genesis raises %q", r.Name, giteaBootstrap)
|
||||
}
|
||||
if r.Image != giteaImage {
|
||||
t.Errorf("the module pins %s; genesis raises %s — the two must move together", r.Image, giteaImage)
|
||||
}
|
||||
if len(r.Volumes) != 1 || !strings.HasSuffix(r.Volumes[0], ":/data") {
|
||||
t.Errorf("the module mounts %v; genesis mounts %s:/data", r.Volumes, giteaDataDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func TestARerunOfGenesisIsNotAMachineInUse(t *testing.T) {
|
||||
if err := store.Save(o.State, store.State{Resources: []store.Applied{{ID: "store", Type: "container", Target: "mesh-store"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := inUseRunner{ps: "mesh-gitea-server\t\n", ss: servingSockets}
|
||||
m := inUseRunner{ps: giteaBootstrap + "\t\n", ss: servingSockets}
|
||||
if err := RefuseAMachineInUse(context.Background(), o, m.run, quietly); err != nil {
|
||||
t.Errorf("what an earlier genesis raised was counted as a machine in use: %v", err)
|
||||
}
|
||||
|
||||
@@ -25,11 +25,24 @@ const (
|
||||
// foundationStore is the foundation's postgres container — the mesh's own memory, raised from the
|
||||
// bundle. gitea's bootstrap database lives here too, so a mesh runs one postgres (issue 051).
|
||||
foundationStore = "mesh-store"
|
||||
// giteaBootstrap is the gitea server raised directly at genesis, before gitea is a module.
|
||||
giteaBootstrap = "mesh-gitea-server"
|
||||
// giteaImage is the same upstream image the gitea module runs, pinned identically so the module
|
||||
// adopts the running server rather than replacing it.
|
||||
giteaImage = "gitea/gitea@sha256:dfc61e347c8b582df918f4556401bf2cecdfbdb56c5282ae9488dd76fca3e41c"
|
||||
// giteaBootstrap is the gitea server raised directly at genesis, before gitea is a module —
|
||||
// under the name the gitea MODULE declares for its container, so the module finds it and holds
|
||||
// it rather than raising a second forge beside it (novox/hq ADR 0163, rule 7; issue 090).
|
||||
giteaBootstrap = "gitea"
|
||||
// giteaImage is the image the gitea module declares for that container, pinned to the same
|
||||
// digest, so taking the module over is not a downgrade and not an upgrade. **Moves with the
|
||||
// module's pin**: the two are compared by a take, and a difference is said there — but a
|
||||
// genesis that raised an older image than the module declares would be taken over as an
|
||||
// upgrade on first push, which a forge holding the mesh's packages must not have done to it
|
||||
// unannounced. Checked in TestGenesisRaisesTheForgeAsTheModuleDeclaresIt against the module's
|
||||
// manifest where the catalogue is beside this checkout.
|
||||
giteaImage = "gitea/gitea@sha256:87a67ee09d3ae0d1df5fda5dcda3e2a1f9236a45b0a59025d6e00e46adc43bef"
|
||||
// giteaDataDir is where the module's `data` directory resolves on a machine with the default
|
||||
// layout (<data root>/<module>/<id>, novox/hq ADR 0112): mounted at /data as the module mounts
|
||||
// it, so the repositories, attachments and indexes the bootstrap forge accumulates are the
|
||||
// module's the day it is taken — before this, the forge had no volume and its data was the
|
||||
// container's, lost with it.
|
||||
giteaDataDir = "/var/lib/gitea/data"
|
||||
// packagesOrg is the npm owner: every module consumes `@novox/*` from this gitea org.
|
||||
packagesOrg = "novox"
|
||||
// packagesTeam is the org team whose members may read and write the org's packages.
|
||||
@@ -230,8 +243,9 @@ func raiseGiteaServer(ctx context.Context, run Runner, timeout time.Duration, db
|
||||
defer cancel()
|
||||
|
||||
// Already there: a re-run does not raise a second one. `docker start` is a no-op on a running
|
||||
// container and revives a stopped one.
|
||||
if out, _ := run(asking, "docker", "inspect", "--format", "{{.Id}}", giteaBootstrap); strings.TrimSpace(out) != "" {
|
||||
// container and revives a stopped one. `container inspect`, not the bare form: a name is not
|
||||
// unique across object kinds, and `.Id` resolves on a network or volume too.
|
||||
if out, _ := run(asking, "docker", "container", "inspect", "--format", "{{.Id}}", giteaBootstrap); strings.TrimSpace(out) != "" {
|
||||
_, _ = run(asking, "docker", "start", giteaBootstrap)
|
||||
return nil
|
||||
}
|
||||
@@ -261,9 +275,13 @@ func raiseGiteaServer(ctx context.Context, run Runner, timeout time.Duration, db
|
||||
"run", "-d", "--name", giteaBootstrap,
|
||||
// Host network, like the control plane: it reaches the foundation store on the machine's
|
||||
// loopback (where the store publishes 5432) and answers on the machine's own 3000, which is
|
||||
// where mesh-bootstrap and the builder's build containers look for it.
|
||||
// where mesh-bootstrap and the builder's build containers look for it. The module runs
|
||||
// bridged and publishes its ports; that is the one difference a take still has to say
|
||||
// (ADR 0163, rule 7) — the data, the name and the image are the module's already.
|
||||
"--network", "host",
|
||||
"--restart", "unless-stopped",
|
||||
// The module's data directory, so what the forge accumulates is the module's when taken.
|
||||
"--volume", giteaDataDir + ":/data",
|
||||
}, env...)
|
||||
args = append(args, giteaImage)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/reachable"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/tunnel"
|
||||
)
|
||||
|
||||
// FoundationPorts are the machine's ports the foundation binds (novox/hq ADR 0100).
|
||||
@@ -321,6 +323,36 @@ func PortsFree(ctx context.Context, run Runner, p FoundationPorts, ours func(rea
|
||||
return nil
|
||||
}
|
||||
|
||||
// TakeTheTunnel finds the tunnel an adopted machine's private network takes over (novox/hq ADR
|
||||
// 0105) and settles the options on it: the hub's port is the tunnel's, the mesh's range is the
|
||||
// tunnel's, and the interface is named for the enrolment that takes its key. Nil when no tunnel is
|
||||
// up, which is an ordinary machine. A --hub-port or --overlay-range that disagrees with the
|
||||
// tunnel is refused: the peers dial the tunnel's port and live in its range, and a mesh raised
|
||||
// beside them on other numbers is the two-tunnel shape the record rejects.
|
||||
func TakeTheTunnel(o *Options, run Runner) (*tunnel.Found, error) {
|
||||
found, err := tunnel.Find(context.Background(), tunnel.Runner(run), o.Tunnel)
|
||||
if errors.Is(err, tunnel.ErrNone) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w. An adopted hub takes over the tunnel it finds; nothing was changed", err)
|
||||
}
|
||||
if o.Ports.Hub != 0 && o.Ports.Hub != DefaultPorts().Hub && o.Ports.Hub != found.Port {
|
||||
return nil, fmt.Errorf("--hub-port %d disagrees with the tunnel %s, which listens on %d: the "+
|
||||
"private network takes over that tunnel on its own port, so leave --hub-port unsaid or "+
|
||||
"say %d", o.Ports.Hub, found.Interface, found.Port, found.Port)
|
||||
}
|
||||
if o.OverlayRange != "" && o.OverlayRange != DefaultOverlayRange && o.OverlayRange != found.Range {
|
||||
return nil, fmt.Errorf("--overlay-range %s disagrees with the tunnel %s, whose range is %s: the "+
|
||||
"private network takes over that tunnel with its range, so leave --overlay-range unsaid "+
|
||||
"or say %s", o.OverlayRange, found.Interface, found.Range, found.Range)
|
||||
}
|
||||
o.Tunnel = found.Interface
|
||||
o.Ports.Hub = found.Port
|
||||
o.OverlayRange = found.Range
|
||||
return &found, nil
|
||||
}
|
||||
|
||||
// OverlayClear refuses a private-network range that overlaps an address or a route the machine
|
||||
// already has — a predecessor's tunnel still running — naming the interface. The mesh's own
|
||||
// interface is not counted.
|
||||
@@ -399,7 +431,9 @@ func NamesFree(ctx context.Context, run Runner, names []string, known store.Stat
|
||||
sorted := append([]string{}, names...)
|
||||
sort.Strings(sorted)
|
||||
for _, name := range sorted {
|
||||
out, err := run(ctx, "docker", "inspect", "--format",
|
||||
// `container inspect`: a name is not unique across object kinds, and the bare form can
|
||||
// resolve to a same-named network or volume instead of reporting the container absent.
|
||||
out, err := run(ctx, "docker", "container", "inspect", "--format",
|
||||
"{{index .Config.Labels \"mesh-host.spec\"}}", name)
|
||||
if err != nil {
|
||||
continue // no such container
|
||||
@@ -459,12 +493,26 @@ func CheckTheMachine(ctx context.Context, o Options, run Runner, bundle *declara
|
||||
}
|
||||
return false
|
||||
}
|
||||
if o.Tunnel != "" {
|
||||
// The hub's port is the found tunnel's, held by that tunnel until the mesh's interface
|
||||
// takes it over (novox/hq ADR 0105): held by design, not by something else.
|
||||
inner := ours
|
||||
ours = func(r reachable.Reach) bool {
|
||||
return inner(r) || (r.Protocol == "udp" && r.Port == p.Hub)
|
||||
}
|
||||
}
|
||||
if err := PortsFree(ctx, run, p, ours); err != nil {
|
||||
return err
|
||||
}
|
||||
say(fmt.Sprintf(" ports free store %d, bus %d, amqp %d, management %d, registry %d, packages %d, hub %d/udp",
|
||||
p.Store, p.Bus, p.AMQP, p.Management, p.Registry, p.Packages, p.Hub))
|
||||
if err := OverlayClear(ctx, run, o.OverlayRange); err != nil {
|
||||
if o.Tunnel != "" {
|
||||
// One tunnel and one range: the mesh's range IS the found tunnel's, so the rule that the
|
||||
// two must not overlap applies only where a found tunnel is left running beside the mesh's
|
||||
// (ADR 0100, narrowed by ADR 0105).
|
||||
say(fmt.Sprintf(" range %s is the tunnel %s's, taken over; not checked against it",
|
||||
o.OverlayRange, o.Tunnel))
|
||||
} else if err := OverlayClear(ctx, run, o.OverlayRange); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := NamesFree(ctx, run, names, known); err != nil {
|
||||
|
||||
@@ -2,6 +2,9 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -12,6 +15,7 @@ import (
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/reachable"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/tunnel"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0100: the foundation's ports are the node's — inputs to genesis, checked free,
|
||||
@@ -130,9 +134,9 @@ func TestTwoThingsOnOnePortAreRefused(t *testing.T) {
|
||||
|
||||
// machineRunner answers ss, docker ps, docker inspect and ip from fixtures.
|
||||
type machineRunner struct {
|
||||
ss, ps, addrs, routes string
|
||||
unlabelled map[string]bool
|
||||
labelled map[string]bool
|
||||
ss, ps, addrs, routes, wg string
|
||||
unlabelled map[string]bool
|
||||
labelled map[string]bool
|
||||
}
|
||||
|
||||
func (m machineRunner) run(_ context.Context, name string, args ...string) (string, error) {
|
||||
@@ -141,7 +145,7 @@ func (m machineRunner) run(_ context.Context, name string, args ...string) (stri
|
||||
return m.ss, nil
|
||||
case name == "docker" && args[0] == "ps":
|
||||
return m.ps, nil
|
||||
case name == "docker" && args[0] == "inspect":
|
||||
case name == "docker" && args[0] == "container":
|
||||
n := args[len(args)-1]
|
||||
if m.labelled[n] {
|
||||
return "abc\n", nil
|
||||
@@ -154,6 +158,8 @@ func (m machineRunner) run(_ context.Context, name string, args ...string) (stri
|
||||
return m.addrs, nil
|
||||
case name == "ip" && args[1] == "route":
|
||||
return m.routes, nil
|
||||
case name == "wg":
|
||||
return m.wg, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
@@ -295,3 +301,76 @@ func TestARerunOfGenesisFindsItsOwnPackageRegistry(t *testing.T) {
|
||||
t.Error("a container under the package registry's name on a fresh machine was not refused")
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0105: an adopted genesis takes over the tunnel it finds — its port is the hub's,
|
||||
// its range the mesh's, and neither is refused for being held by it.
|
||||
func TestAnAdoptedGenesisSettlesOnTheTunnelItFinds(t *testing.T) {
|
||||
private, err := aFoundKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := "[Interface]\nPrivateKey = " + private + "\nListenPort = 51900\nAddress = 192.0.2.1/24\n" +
|
||||
"[Peer]\nPublicKey = PEER=\nAllowedIPs = 192.0.2.2/32\n"
|
||||
tunnel.ReadFile = func(path string) ([]byte, error) {
|
||||
if path == tunnel.ConfigDir+"/wg0.conf" {
|
||||
return []byte(conf), nil
|
||||
}
|
||||
return nil, errors.New("no such file")
|
||||
}
|
||||
t.Cleanup(func() { tunnel.ReadFile = os.ReadFile })
|
||||
m := machineRunner{
|
||||
wg: "wg0\n",
|
||||
ss: "udp UNCONN 0 0 0.0.0.0:51900 0.0.0.0:*\n",
|
||||
addrs: "5: wg0 inet 192.0.2.1/24 scope global wg0\n",
|
||||
routes: "192.0.2.0/24 dev wg0 proto kernel scope link src 192.0.2.1\n",
|
||||
}
|
||||
|
||||
o := Options{Adopted: true, Ports: DefaultPorts(), OverlayRange: DefaultOverlayRange, State: filepath.Join(t.TempDir(), "state.json")}
|
||||
found, err := TakeTheTunnel(&o, m.run)
|
||||
if err != nil || found == nil {
|
||||
t.Fatalf("the tunnel was not found and taken: %+v %v", found, err)
|
||||
}
|
||||
if o.Tunnel != "wg0" || o.Ports.Hub != 51900 || o.OverlayRange != "192.0.2.0/24" {
|
||||
t.Fatalf("genesis did not settle on the tunnel's port and range: %+v", o)
|
||||
}
|
||||
// Its port is held by the tunnel and its range overlaps the tunnel's — by design, not refused.
|
||||
if err := CheckTheMachine(context.Background(), o, m.run, producedBundle(t).Declaration, func(string) {}); err != nil {
|
||||
t.Fatalf("the machine was refused for the tunnel it takes over: %v", err)
|
||||
}
|
||||
// Whereas the same machine not taking it over is refused on both counts (ADR 0100).
|
||||
plain := o
|
||||
plain.Tunnel = ""
|
||||
if err := CheckTheMachine(context.Background(), plain, m.run, producedBundle(t).Declaration, func(string) {}); err == nil ||
|
||||
!strings.Contains(err.Error(), "51900") {
|
||||
t.Fatalf("a tunnel not taken over stopped being refused for holding the hub's port: %v", err)
|
||||
}
|
||||
plain.Ports.Hub = 51821
|
||||
if err := CheckTheMachine(context.Background(), plain, m.run, producedBundle(t).Declaration, func(string) {}); err == nil ||
|
||||
!strings.Contains(err.Error(), "wg0") {
|
||||
t.Fatalf("a tunnel not taken over stopped being refused for overlapping the range: %v", err)
|
||||
}
|
||||
|
||||
// Numbers that disagree with the tunnel are refused, naming the tunnel's.
|
||||
for name, given := range map[string]Options{
|
||||
"--hub-port": {Adopted: true, Ports: FoundationPorts{Hub: 51821}, OverlayRange: DefaultOverlayRange},
|
||||
"--overlay-range": {Adopted: true, Ports: DefaultPorts(), OverlayRange: "10.77.0.0/16"},
|
||||
} {
|
||||
if _, err := TakeTheTunnel(&given, m.run); err == nil || !strings.Contains(err.Error(), name) {
|
||||
t.Errorf("a %s disagreeing with the tunnel was accepted: %v", name, err)
|
||||
}
|
||||
}
|
||||
// And no tunnel up is an ordinary machine.
|
||||
m.wg = "mesh0\n"
|
||||
none := Options{Adopted: true, Ports: DefaultPorts(), OverlayRange: DefaultOverlayRange}
|
||||
if found, err := TakeTheTunnel(&none, m.run); err != nil || found != nil || none.Ports.Hub != DefaultPorts().Hub {
|
||||
t.Errorf("a machine with no tunnel was not left as it was: %+v %v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func aFoundKey() (string, error) {
|
||||
k, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k.Bytes()), nil
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ func digestOf(ctx context.Context, o Options, d Deps, remote string) (string, er
|
||||
|
||||
// The registry has it. What digest, according to the runtime that pushed it.
|
||||
reading, cancel := context.WithTimeout(ctx, o.Timeout)
|
||||
out, err := d.Run(reading, "docker", "inspect", "--format", "{{json .RepoDigests}}",
|
||||
out, err := d.Run(reading, "docker", "image", "inspect", "--format", "{{json .RepoDigests}}",
|
||||
remote+":"+genesisTag)
|
||||
cancel()
|
||||
if err != nil {
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestAnImageNoRegistryHasEverHeldIsPushed(t *testing.T) {
|
||||
case "push":
|
||||
pushed = true
|
||||
return "", nil
|
||||
case "inspect":
|
||||
case "image":
|
||||
return `["127.0.0.1:5000/mesh-controller@sha256:` + strings.Repeat("a", 64) + `"]`, nil
|
||||
}
|
||||
return "", fmt.Errorf("unexpected: %v", args)
|
||||
@@ -80,7 +80,7 @@ func TestAnImageTheRegistryAlreadyServesIsNotPushedAgain(t *testing.T) {
|
||||
return http.StatusOK, `{"name":"mesh-controller","tags":["genesis"]}`, nil
|
||||
},
|
||||
func(_ string, args []string) (string, error) {
|
||||
if args[0] == "inspect" {
|
||||
if args[0] == "image" {
|
||||
return `["127.0.0.1:5000/mesh-controller@sha256:` + strings.Repeat("b", 64) + `"]`, nil
|
||||
}
|
||||
return "", fmt.Errorf("unexpected: %v", args)
|
||||
@@ -111,7 +111,7 @@ func TestTheDigestComesFromThisMeshsOwnRegistry(t *testing.T) {
|
||||
return http.StatusOK, `{"tags":["genesis"]}`, nil
|
||||
},
|
||||
func(_ string, args []string) (string, error) {
|
||||
if args[0] == "inspect" {
|
||||
if args[0] == "image" {
|
||||
return `["` + elsewhere + `","` + ours + `"]`, nil
|
||||
}
|
||||
return "", fmt.Errorf("unexpected: %v", args)
|
||||
@@ -166,7 +166,7 @@ func TestATagIsNotAPin(t *testing.T) {
|
||||
return http.StatusOK, `{"tags":["genesis"]}`, nil
|
||||
},
|
||||
func(_ string, args []string) (string, error) {
|
||||
if args[0] == "inspect" {
|
||||
if args[0] == "image" {
|
||||
return `["127.0.0.1:5000/mesh-controller:genesis"]`, nil
|
||||
}
|
||||
return "", nil
|
||||
|
||||
@@ -62,7 +62,7 @@ func aMeshThatAgrees(answers map[string]string) func(string, []string) (string,
|
||||
return "", fmt.Errorf("unexpected program %q", name)
|
||||
case args[0] == "cp":
|
||||
return "", nil
|
||||
case args[0] == "inspect":
|
||||
case args[0] == "container":
|
||||
return "true running\n", nil
|
||||
case args[0] == "exec":
|
||||
return "", nil
|
||||
|
||||
@@ -89,7 +89,7 @@ func RetireTheTemporaryControlPlane(ctx context.Context, o Options, sys system.S
|
||||
// same state file. What makes this a removal rather than a no-op is that the state file
|
||||
// records the container as something this installer applied, and the declaration no longer
|
||||
// asks for it.
|
||||
report, err := ApplyBundle(ctx, o, sys, without, run, say)
|
||||
report, err := ApplyBundle(ctx, o, sys, without, bundle, run, say)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf(
|
||||
"%w\n\nThe permanent control plane is running and the temporary one is still here. "+
|
||||
|
||||
@@ -102,8 +102,16 @@ func keptOrMade(path string, dryRun bool) (value string, made bool, err error) {
|
||||
}
|
||||
// Written whole and renamed into place, at 0600, owned by whoever runs the installer — root,
|
||||
// which is also who the host runs as when it later writes the sealed copy here.
|
||||
//
|
||||
// **The value alone, no line ending.** The module that adopts the store declares this same
|
||||
// file, and what it declares is the value as `secret accept` took it — its line ending gone,
|
||||
// by design. The host folds a mounted file's content into the container's spec (novox/hq
|
||||
// 04-ISSUES/103), so a genesis that wrote `value\n` here would raise a store whose label
|
||||
// digests one byte more than the module's file, and phase three would RECREATE the store it
|
||||
// meant to adopt in place, with the temporary control plane connected to it. readCredentialFile
|
||||
// tolerates either ending, so a file an earlier genesis wrote still reads.
|
||||
tmp := path + ".genesis"
|
||||
if err := os.WriteFile(tmp, []byte(value+"\n"), 0o600); err != nil {
|
||||
if err := os.WriteFile(tmp, []byte(value), 0o600); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
|
||||
@@ -129,8 +129,9 @@ func containerRunning(ctx context.Context, run Runner, probe time.Duration, name
|
||||
defer cancel()
|
||||
|
||||
// Both facts in one answer, so a container that is not running is reported with what it IS
|
||||
// rather than with the absence of what it should be.
|
||||
out, err := run(asking, "docker", "inspect", "--format", "{{.State.Running}} {{.State.Status}}", name)
|
||||
// rather than with the absence of what it should be. `container inspect`, not the bare form:
|
||||
// a same-named network or volume would otherwise answer in the container's place.
|
||||
out, err := run(asking, "docker", "container", "inspect", "--format", "{{.State.Running}} {{.State.Status}}", name)
|
||||
if err != nil {
|
||||
return containerState{}, fmt.Errorf(
|
||||
"the container %q is not there at all, and the apply reported it applied: %w", name, err)
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestAContainerThatIsUpIsNotAControlPlaneThatReplies(t *testing.T) {
|
||||
|
||||
runtime := &asked{answer: func(_ string, args []string) (string, error) {
|
||||
switch args[0] {
|
||||
case "inspect":
|
||||
case "container":
|
||||
return "true running\n", nil
|
||||
case "exec":
|
||||
// Up, and saying nothing. The program inside is not answering.
|
||||
@@ -59,7 +59,7 @@ func TestAControlPlaneThatSaysNothingHasNotAnswered(t *testing.T) {
|
||||
defer func() { answerEvery = previous }()
|
||||
|
||||
runtime := &asked{answer: func(_ string, args []string) (string, error) {
|
||||
if args[0] == "inspect" {
|
||||
if args[0] == "container" {
|
||||
return "true running\n", nil
|
||||
}
|
||||
return " \n", nil
|
||||
@@ -74,7 +74,7 @@ func TestAControlPlaneThatSaysNothingHasNotAnswered(t *testing.T) {
|
||||
// The foundation answering is the whole point, and what it said is reported rather than asserted.
|
||||
func TestAFoundationThatIsUpAndAnsweringIsAccepted(t *testing.T) {
|
||||
runtime := &asked{answer: func(_ string, args []string) (string, error) {
|
||||
if args[0] == "inspect" {
|
||||
if args[0] == "container" {
|
||||
return "true running\n", nil
|
||||
}
|
||||
return "1 node, 0 waiting\n", nil
|
||||
@@ -112,7 +112,7 @@ func TestAControlPlaneThatIsStillStartingIsWaitedFor(t *testing.T) {
|
||||
|
||||
attempts := 0
|
||||
runtime := &asked{answer: func(_ string, args []string) (string, error) {
|
||||
if args[0] == "inspect" {
|
||||
if args[0] == "container" {
|
||||
return "true running\n", nil
|
||||
}
|
||||
attempts++
|
||||
@@ -132,10 +132,10 @@ func TestAControlPlaneThatIsStillStartingIsWaitedFor(t *testing.T) {
|
||||
// than being told only that something is not what it should be.
|
||||
func TestAContainerThatExitedIsNamedWithItsState(t *testing.T) {
|
||||
runtime := &asked{answer: func(_ string, args []string) (string, error) {
|
||||
if args[0] == "inspect" && args[len(args)-1] == "mesh-broker" {
|
||||
if args[0] == "container" && args[len(args)-1] == "mesh-broker" {
|
||||
return "false exited\n", nil
|
||||
}
|
||||
if args[0] == "inspect" {
|
||||
if args[0] == "container" {
|
||||
return "true running\n", nil
|
||||
}
|
||||
return "", fmt.Errorf("unexpected command: %v", args)
|
||||
@@ -155,7 +155,7 @@ func TestAContainerThatExitedIsNamedWithItsState(t *testing.T) {
|
||||
// `FROM scratch` and has no shell for a command line to be interpreted by.
|
||||
func TestTheControlPlaneIsAskedByRunningItsOwnBinary(t *testing.T) {
|
||||
runtime := &asked{answer: func(_ string, args []string) (string, error) {
|
||||
if args[0] == "inspect" {
|
||||
if args[0] == "container" {
|
||||
return "true running\n", nil
|
||||
}
|
||||
return "1 node\n", nil
|
||||
|
||||
@@ -105,3 +105,28 @@ func TestACarriedBundleCannotSayTheNodeIsAdopted(t *testing.T) {
|
||||
t.Fatalf("a bundle claiming adoption was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0105: a service may take over a found tunnel, said whole and on an adopted node.
|
||||
func TestTakingOverATunnelIsSaidWholeAndForARunningService(t *testing.T) {
|
||||
adoptedWith := func(service string) error {
|
||||
_, err := Parse([]byte(`{"declaration":1,"adoption":{"taken":[]},"resources":[` + service + `]}`))
|
||||
return err
|
||||
}
|
||||
good := `{"id":"up","type":"service","unit":"wg-quick@mesh0","state":"running",
|
||||
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"/etc/wireguard/wg0.conf"}}`
|
||||
if err := adoptedWith(good); err != nil {
|
||||
t.Fatalf("a whole takeover on an adopted node was refused: %v", err)
|
||||
}
|
||||
for name, bad := range map[string]string{
|
||||
"its own unit": `{"id":"up","type":"service","unit":"wg-quick@wg0","state":"running",
|
||||
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"/etc/wireguard/wg0.conf"}}`,
|
||||
"no config": `{"id":"up","type":"service","unit":"wg-quick@mesh0","state":"running",
|
||||
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0"}}`,
|
||||
"a stopped service": `{"id":"up","type":"service","unit":"wg-quick@mesh0","state":"stopped",
|
||||
"takes-over":{"interface":"wg0","unit":"wg-quick@wg0","config":"/etc/wireguard/wg0.conf"}}`,
|
||||
} {
|
||||
if err := adoptedWith(bad); err == nil {
|
||||
t.Errorf("a takeover naming %s was accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package declaration
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq issue 128: a file written into a block carries only its lines, in content,
|
||||
// never a line the host keeps as its own marker, and says where a new region goes only as a block.
|
||||
|
||||
func TestAFileWrittenIntoABlockIsRefusedUnlessItIsOnlyItsLines(t *testing.T) {
|
||||
for name, c := range map[string]struct{ resource, refusal string }{
|
||||
"a begin marker in content": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a\n# BEGIN mesh f\nb\n"}`, "# BEGIN mesh f"},
|
||||
"an end marker in content": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a\n# END mesh other\n"}`, "# END mesh other"},
|
||||
"a marker with a CR": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"# BEGIN mesh x\r\n"}`, "marker"},
|
||||
"sealed": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","sealed":"abc"}`, "not sealed"},
|
||||
"bytes": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","bytes":"YQ=="}`, "not sealed, bytes"},
|
||||
"secrets": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"${secret:s}","secrets":{"s":"abc"}}`, "secrets"},
|
||||
"create-once": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a","create-once":true}`, "create-once"},
|
||||
"at on a whole file": {`{"id":"f","type":"file","path":"/etc/hosts","content":"a","at":"start"}`, `at "start"`},
|
||||
"at on a JSON file": {`{"id":"f","type":"file","path":"/etc/x.json","into":"json","content":"{}","at":"end"}`, `at "end"`},
|
||||
"at somewhere else": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a","at":"middle"}`, `"start" or "end"`},
|
||||
"an id ending in a space": {`{"id":"f ","type":"file","path":"/etc/hosts","into":"block","content":"a"}`, "whitespace"},
|
||||
"an unknown format": {`{"id":"f","type":"file","path":"/etc/hosts","into":"lines","content":"a"}`, `"json" or "block"`},
|
||||
} {
|
||||
_, err := Parse([]byte(`{"declaration":1,"resources":[` + c.resource + `]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), c.refusal) {
|
||||
t.Errorf("%s: want a refusal naming %q, got %v", name, c.refusal, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFileWrittenIntoABlockIsRead(t *testing.T) {
|
||||
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"mesh-wireguard.fact-node-names","type":"file","path":"/etc/hosts","into":"block","content":"10.42.0.1 ace\n# BEGIN devtool x\n"},
|
||||
{"id":"dhcpcd.options","type":"file","path":"/etc/dhcpcd.conf","into":"block","content":"nohook resolv.conf\n","at":"start"},
|
||||
{"id":"empty","type":"file","path":"/etc/x","into":"block","content":"","at":"end"}
|
||||
]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f := d.Resources[0].(*File); f.Into != IntoBlock || f.At != "" {
|
||||
t.Errorf("read as into %q at %q", f.Into, f.At)
|
||||
}
|
||||
if f := d.Resources[1].(*File); f.At != AtStart {
|
||||
t.Errorf("at was read as %q", f.At)
|
||||
}
|
||||
if begin, end := BlockMarkers("mesh-wireguard.fact-node-names"); begin != "# BEGIN mesh mesh-wireguard.fact-node-names" ||
|
||||
end != "# END mesh mesh-wireguard.fact-node-names" {
|
||||
t.Errorf("the markers are %q and %q", begin, end)
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,12 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -159,8 +161,30 @@ type File struct {
|
||||
// "json" is spoken — the content is a JSON object whose keys the host sets in the file's
|
||||
// object, keeping every other key as it found it and recording what each of its keys held
|
||||
// before, so undeclaring the file gives those back.
|
||||
//
|
||||
// "block" is the same idea for a file that is not structured (novox/hq issue 128): the
|
||||
// content is the mesh's lines, and the host owns only the region between `# BEGIN mesh <id>`
|
||||
// and `# END mesh <id>`, keeping every line outside it byte for byte. The machine's hosts file
|
||||
// is the case that needed it — on a workstation the distribution, a local development tool and
|
||||
// the operator all write into it, and the mesh writing it whole took their lines away at the
|
||||
// next change to the mesh's names, silently. Marked blocks are the shape the other tools in
|
||||
// that file already use, and `#` is the comment character of every file this serves.
|
||||
//
|
||||
// Written into, in either format, the file's mode and owner are the machine's: a declared mode
|
||||
// and owner apply only to a file the host creates, and a file that was there keeps its own.
|
||||
Into string `json:"into,omitempty"`
|
||||
|
||||
// At is where a file written into a block has its region added when the file does not hold
|
||||
// one yet: "end", the default, or "start". A region already there stays where it is, whatever
|
||||
// this says — moving it would move the lines around it, and those are the machine's.
|
||||
//
|
||||
// **Some files give a line its meaning by what stands above it.** dhcpcd's configuration scopes
|
||||
// every line after `interface X` to that interface, and a real one ends with exactly that — an
|
||||
// interface and its static address. A region added at the end would make the mesh's global
|
||||
// options (`nohook resolv.conf`, `denyinterfaces mesh0`) options of one interface, and dhcpcd
|
||||
// would read them without complaint. At the start, nothing stands above them.
|
||||
At string `json:"at,omitempty"`
|
||||
|
||||
// Sealed is content encrypted to this node's sealing key, for a file the mesh must deliver
|
||||
// without being able to read.
|
||||
//
|
||||
@@ -239,16 +263,50 @@ func (f *File) validate(where string, _ bool) []string {
|
||||
problems = append(problems, where+
|
||||
": a file written into JSON carries a JSON object of the keys it sets")
|
||||
}
|
||||
if f.Sealed != "" || f.Bytes != "" || len(f.Secrets) > 0 || f.CreateOnce {
|
||||
case IntoBlock:
|
||||
// The markers are how the host finds its region again. A marker in the content would be
|
||||
// a second region, or the end of this one, the next time the file is read — and the host
|
||||
// would then rewrite, or on undeclare take out, lines that were never the mesh's.
|
||||
for _, line := range strings.Split(f.Content, "\n") {
|
||||
if strings.HasPrefix(line, BlockBegin) || strings.HasPrefix(line, BlockEnd) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: a file written into a block carries the mesh's lines, and %q is a marker the "+
|
||||
"host keeps for itself", where, strings.TrimRight(line, "\r")))
|
||||
break
|
||||
}
|
||||
}
|
||||
// The id is written into the markers, so it has to stay on one line — and whitespace at
|
||||
// either end of it is whitespace the host would have to match exactly in a line some
|
||||
// editor may trim.
|
||||
if strings.ContainsAny(f.ID, "\r\n") {
|
||||
problems = append(problems, where+
|
||||
": a file written into says only its keys, in content — not sealed, bytes, "+
|
||||
"secrets or create-once")
|
||||
": a file written into a block names its region by its id, and this id spans lines")
|
||||
} else if strings.TrimSpace(f.ID) != f.ID {
|
||||
problems = append(problems, where+
|
||||
": a file written into a block names its region by its id, and this id begins or ends in whitespace")
|
||||
}
|
||||
default:
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: into %q; a file is written into \"json\", or omits it to be written whole",
|
||||
"%s: into %q; a file is written into \"json\" or \"block\", or omits it to be written whole",
|
||||
where, f.Into))
|
||||
}
|
||||
switch {
|
||||
case f.At == "":
|
||||
case f.Into != IntoBlock:
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: at %q; only a file written into a block has a place its region is added", where, f.At))
|
||||
case f.At != AtStart && f.At != AtEnd:
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: at %q; a block is added at \"start\" or \"end\", or omits it to be added at the end",
|
||||
where, f.At))
|
||||
}
|
||||
if f.Into == IntoJSON || f.Into == IntoBlock {
|
||||
if f.Sealed != "" || f.Bytes != "" || len(f.Secrets) > 0 || f.CreateOnce {
|
||||
problems = append(problems, where+
|
||||
": a file written into says only its part, in content — not sealed, bytes, "+
|
||||
"secrets or create-once")
|
||||
}
|
||||
}
|
||||
var said []string
|
||||
for name, value := range map[string]string{
|
||||
"content": f.Content, "sealed": f.Sealed, "bytes": f.Bytes,
|
||||
@@ -517,16 +575,34 @@ func (d *Process) Identity() string { return d.ID }
|
||||
func (d *Process) Kind() Type { return TypeProcess }
|
||||
func (d *Process) Target() string { return d.Name }
|
||||
|
||||
// ProcessNameProblem says what is wrong with a process name, or nothing.
|
||||
//
|
||||
// The name becomes a unit name, a file under the unit directory and a directory under the mesh's
|
||||
// own — the one removing the process deletes, whole (novox/hq ADR 0118). So a name that is not one
|
||||
// plain path element is refused: with a separator it writes somewhere nobody meant, and "." or
|
||||
// ".." IS the mesh's directory or its parent — removing a process named ".." would delete every
|
||||
// bundle the mesh has, and more. One with a leading dash is read by the service manager as an
|
||||
// option, not a unit. Exported because the removal checks the recorded name again: a record is
|
||||
// what the host wrote, and a host of an older version wrote it under looser rules.
|
||||
func ProcessNameProblem(name string) string {
|
||||
switch {
|
||||
case name == "":
|
||||
return "a process needs a name, which is what its unit is called"
|
||||
case strings.ContainsAny(name, "/ \t"):
|
||||
return "a process name becomes a unit name, so it cannot contain a path separator or a space"
|
||||
case name == "." || name == "..":
|
||||
return fmt.Sprintf("a process name becomes a directory under the mesh's own, and %q would be "+
|
||||
"that directory or its parent", name)
|
||||
case strings.HasPrefix(name, "-"):
|
||||
return "a process name cannot begin with a dash: the service manager would read it as an option"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (d *Process) validate(where string, _ bool) []string {
|
||||
var problems []string
|
||||
if d.Name == "" {
|
||||
problems = append(problems, where+": a process needs a name, which is what its unit is called")
|
||||
}
|
||||
if strings.ContainsAny(d.Name, "/ \t") {
|
||||
// It becomes a unit name and a file on disk. A name with a separator in it would write
|
||||
// somewhere nobody meant.
|
||||
problems = append(problems, where+": a process name becomes a unit name, so it cannot "+
|
||||
"contain a path separator or a space")
|
||||
if problem := ProcessNameProblem(d.Name); problem != "" {
|
||||
problems = append(problems, where+": "+problem)
|
||||
}
|
||||
if d.Source == "" {
|
||||
problems = append(problems, where+": a process needs somewhere to fetch its bundle from")
|
||||
@@ -587,10 +663,20 @@ func (d *Process) validate(where string, _ bool) []string {
|
||||
// (it will come back at boot), or disabled and running (started by hand, gone after a reboot).
|
||||
// Folding them into one field would make the second expressible only by accident.
|
||||
type Service struct {
|
||||
ID string `json:"id"`
|
||||
Type Type `json:"type"`
|
||||
Unit string `json:"unit"`
|
||||
State string `json:"state"`
|
||||
ID string `json:"id"`
|
||||
Type Type `json:"type"`
|
||||
Unit string `json:"unit"`
|
||||
// State is "running" or "stopped" — or absent, and then **the unit's lifecycle is the
|
||||
// machine's; the mesh only reflects its triggers** (novox/hq ADR 0117). The uplink modules
|
||||
// declare the machine's own network manager this way: the mesh writes into its configuration
|
||||
// and needs it to read that again, and nothing more. Stated, the host would start the manager
|
||||
// on a machine that uses another one — two managers fighting over the same links — and, when
|
||||
// the module was unassigned, stop it: the machine's network, the channel the mesh itself
|
||||
// arrives on, gone at the moment of a routine change. So a service without a state is never
|
||||
// started, stopped, enabled or disabled, is reloaded or restarted only when a trigger changed
|
||||
// and it is already running, and undeclared is simply forgotten. It says nothing unless it
|
||||
// names a trigger, and it may not say boot or takes-over, which are both lifecycle.
|
||||
State string `json:"state,omitempty"`
|
||||
// Boot is "enabled" or "disabled" — whether the unit starts at boot. Optional: absent means
|
||||
// the host asserts nothing about it and leaves whatever is there.
|
||||
//
|
||||
@@ -617,8 +703,28 @@ type Service struct {
|
||||
// container runtime, whose restart stops every container on the machine (novox/hq ADR 0102).
|
||||
// A change that is also in RestartOn restarts it, which covers a reload.
|
||||
ReloadOn []string `json:"reload-on,omitempty"`
|
||||
|
||||
// TakesOver names the found tunnel this service replaces (novox/hq ADR 0105): before this unit
|
||||
// is started, the named unit is stopped and disabled — never flushed — and its configuration
|
||||
// file is kept like any held file — until the take is proven by a peer's handshake, and then
|
||||
// retired, its original staying kept (novox/hq ADR 0119). Only on an adopted node, and only
|
||||
// said by the controller, which knows the found tunnel's key is this node's own: without that,
|
||||
// starting this unit on the found one's port would drop every peer's packets.
|
||||
TakesOver *TakeOver `json:"takes-over,omitempty"`
|
||||
}
|
||||
|
||||
// TakeOver is a found tunnel a service replaces: its interface, the unit that raised it, and its
|
||||
// configuration file.
|
||||
type TakeOver struct {
|
||||
Interface string `json:"interface"`
|
||||
Unit string `json:"unit"`
|
||||
Config string `json:"config"`
|
||||
}
|
||||
|
||||
// Stateless reports whether the unit's lifecycle is the machine's, and the mesh only reflects the
|
||||
// service's triggers (novox/hq ADR 0117).
|
||||
func (s *Service) Stateless() bool { return s.State == "" }
|
||||
|
||||
func (s *Service) Identity() string { return s.ID }
|
||||
func (s *Service) Kind() Type { return TypeService }
|
||||
func (s *Service) Target() string { return s.Unit }
|
||||
@@ -628,20 +734,65 @@ func (s *Service) validate(where string, _ bool) []string {
|
||||
if s.Unit == "" {
|
||||
problems = append(problems, where+": a service needs a unit")
|
||||
}
|
||||
if s.State != "running" && s.State != "stopped" {
|
||||
switch {
|
||||
case s.State == "running" || s.State == "stopped":
|
||||
case s.State != "":
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: state %q; a service is \"running\" or \"stopped\"", where, s.State))
|
||||
"%s: state %q; a service is \"running\" or \"stopped\", or omits state to leave the "+
|
||||
"unit's lifecycle to the machine", where, s.State))
|
||||
case s.Boot != "" || s.TakesOver != nil:
|
||||
problems = append(problems, where+": a service that omits state leaves the unit's lifecycle "+
|
||||
"to the machine, and boot and takes-over are both its lifecycle")
|
||||
case len(s.RestartOn) == 0 && len(s.ReloadOn) == 0:
|
||||
problems = append(problems, where+": a service that omits state leaves the unit's lifecycle "+
|
||||
"to the machine, and names no restart-on or reload-on — it declares nothing")
|
||||
}
|
||||
if s.Boot != "" && s.Boot != "enabled" && s.Boot != "disabled" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: boot %q; a service is \"enabled\" or \"disabled\" at boot, or omits it to "+
|
||||
"leave the machine's own setting alone", where, s.Boot))
|
||||
}
|
||||
if t := s.TakesOver; t != nil {
|
||||
switch {
|
||||
case t.Unit == "" || t.Config == "" || t.Interface == "":
|
||||
problems = append(problems, where+": takes-over names the found tunnel's interface, unit "+
|
||||
"and config, and this leaves one out")
|
||||
case t.Unit == s.Unit:
|
||||
problems = append(problems, fmt.Sprintf("%s: takes-over names %s, which is this service's own unit",
|
||||
where, t.Unit))
|
||||
case s.State != "running" && s.State != "":
|
||||
problems = append(problems, where+": a service that takes over a tunnel is running — stopping "+
|
||||
"the found one for a service that will not run would leave the peers with nothing")
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// IntoJSON is the one structured format a file is written into.
|
||||
const IntoJSON = "json"
|
||||
// What a file is written into (novox/hq ADR 0102): a JSON object whose keys the mesh sets, or a
|
||||
// text file in which the mesh owns one marked block of lines (novox/hq issue 128).
|
||||
const (
|
||||
IntoJSON = "json"
|
||||
IntoBlock = "block"
|
||||
)
|
||||
|
||||
// The lines that delimit the mesh's region in a file written into a block, each followed by the
|
||||
// resource's id. Exact lines, never patterns: another tool's `# BEGIN …` block in the same file is
|
||||
// that tool's, and a marker that merely resembled the mesh's must not be taken for it.
|
||||
const (
|
||||
BlockBegin = "# BEGIN mesh "
|
||||
BlockEnd = "# END mesh "
|
||||
)
|
||||
|
||||
// Where a file written into a block has its region added, when it has none yet.
|
||||
const (
|
||||
AtStart = "start"
|
||||
AtEnd = "end"
|
||||
)
|
||||
|
||||
// BlockMarkers are the two lines, without their line ends, that delimit a resource's region.
|
||||
func BlockMarkers(id string) (begin, end string) {
|
||||
return BlockBegin + id, BlockEnd + id
|
||||
}
|
||||
|
||||
// Opening is a port reachable on an adopted node, from where, and on which path.
|
||||
//
|
||||
@@ -719,6 +870,12 @@ type Package struct {
|
||||
ID string `json:"id"`
|
||||
Type Type `json:"type"`
|
||||
Package string `json:"package"`
|
||||
// Absent declares that the package is NOT installed (novox/hq ADR 0175): the host removes it
|
||||
// when it is, and leaves a machine that never had it alone. For the one case a module replaces
|
||||
// software the machine was found with and the operator has decided it does not come back — the
|
||||
// firewall front end a converged machine's filter module retired. Nothing to undo when the
|
||||
// declaration drops it: the host does not install what a declaration stopped saying is absent.
|
||||
Absent bool `json:"absent,omitempty"`
|
||||
}
|
||||
|
||||
func (p *Package) Identity() string { return p.ID }
|
||||
@@ -779,15 +936,50 @@ type Container struct {
|
||||
// worse failure mode.
|
||||
Network string `json:"network,omitempty"`
|
||||
|
||||
// Dns is the resolvers this container asks, passed to the runtime unchanged.
|
||||
//
|
||||
// **Because some software refuses to run behind the runtime's forwarding resolver.** A mail
|
||||
// server's admin demands a DNSSEC-validating resolver, and the runtime's own (127.0.0.11)
|
||||
// forwards to whatever the machine has — so a module that ships its own validating resolver
|
||||
// must be able to point its other containers at it. Addresses, not names: the runtime's flag
|
||||
// takes only addresses, which is also why IP below exists — the resolver has to be somewhere
|
||||
// its siblings can name before any of them can resolve anything.
|
||||
Dns []string `json:"dns,omitempty"`
|
||||
|
||||
// Capabilities are the Linux capabilities this container is granted beyond the runtime's
|
||||
// default set, by name (novox/hq ADR 0170): a holder's runtime that changes the machine's packet
|
||||
// filter asks for NET_ADMIN. Exactly these, named in the spec so a change recreates the
|
||||
// container; a privileged container stays undeclarable.
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
|
||||
// Networks are networks this container also joins once created, by name — a found network a
|
||||
// per-machine setting keeps for a taken container (novox/hq ADR 0163, rule 4), so a
|
||||
// neighbour that resolves it there keeps resolving it until the neighbour is taken too.
|
||||
// Joined after creation, because a runtime starts a container on one network; part of the
|
||||
// container's spec, so a network kept or let go recreates it.
|
||||
Networks []string `json:"networks,omitempty"`
|
||||
|
||||
// IP is this container's address on its network, passed to the runtime unchanged.
|
||||
//
|
||||
// Only meaningful on a user-defined network, and refused by the runtime elsewhere. Exists for
|
||||
// exactly one shape: a container others must reach *before* name resolution works — a
|
||||
// module's own DNS resolver being the case that forced it (see Dns).
|
||||
IP string `json:"ip,omitempty"`
|
||||
|
||||
// RestartOn names resources whose change means this container must be recreated — the same
|
||||
// field a service has, for the same reason (novox/hq 04-ISSUES/009). A container reads a
|
||||
// mounted file once at start; a changed file leaves the running process holding the old value,
|
||||
// while every check passes because the file on disk is right. The container's spec — image,
|
||||
// env, volumes — does not include a mounted file's *content*, so a settings change that
|
||||
// re-renders that file is invisible to the ordinary spec diff. This closes that: the host
|
||||
// recreates the container when one of these resources changed this pass, even if the spec
|
||||
// matches. On a run-once step it means *run again*: a step that fetches a fact from a provider
|
||||
// names the binding it reads, and is run again when the provider moved (novox/hq ADR 0099).
|
||||
// while every check passes because the file on disk is right. The host recreates the container
|
||||
// when one of these resources changed this pass, even if the spec matches.
|
||||
//
|
||||
// What a running container reads at creation — its env-files, and a file mounted into it
|
||||
// directly — is part of its spec by content since novox/hq 04-ISSUES/103, and needs no naming
|
||||
// here. A directory mounted into it is NOT looked inside, not even for files the host wrote
|
||||
// there: whether a service reads such a file once or watches it live is the service's, and
|
||||
// RestartOn is how a module says "once, at start" — a config the host renders under the
|
||||
// module's state directory, a step whose result it consumes. On a run-once step it means *run
|
||||
// again*: a step that fetches a fact from a provider names the binding it reads, and is run
|
||||
// again when the provider moved (novox/hq ADR 0099).
|
||||
RestartOn []string `json:"restart-on,omitempty"`
|
||||
|
||||
// RunOnce marks a container the host runs to completion rather than leaves running: a step,
|
||||
@@ -842,6 +1034,39 @@ func (c *Container) validate(where string, _ bool) []string {
|
||||
problems = append(problems, where+": "+err.Error())
|
||||
}
|
||||
}
|
||||
// The runtime's flags take addresses, and a name here would be handed to it verbatim and
|
||||
// refused at create — after the old container was already removed. Refused on arrival instead.
|
||||
for _, d := range c.Dns {
|
||||
if net.ParseIP(d) == nil {
|
||||
problems = append(problems, where+": dns "+strconv.Quote(d)+" is not an address; "+
|
||||
"the runtime's resolver flag takes only addresses")
|
||||
}
|
||||
}
|
||||
if c.IP != "" {
|
||||
if net.ParseIP(c.IP) == nil {
|
||||
problems = append(problems, where+": ip "+strconv.Quote(c.IP)+" is not an address")
|
||||
}
|
||||
if c.Network == "" {
|
||||
problems = append(problems, where+": an ip needs a network; the runtime refuses a "+
|
||||
"static address anywhere but a user-defined one")
|
||||
}
|
||||
}
|
||||
for _, cap := range c.Capabilities {
|
||||
if !capabilityName.MatchString(cap) {
|
||||
problems = append(problems, where+": capabilities names "+strconv.Quote(cap)+", which is not a "+
|
||||
"capability's name (CAP_NET_ADMIN or NET_ADMIN)")
|
||||
}
|
||||
}
|
||||
for _, n := range c.Networks {
|
||||
problems = append(problems, (&Network{Name: n}).validate(where+": networks", false)...)
|
||||
if n == c.Network {
|
||||
problems = append(problems, where+": networks names "+n+", which is already the container's network")
|
||||
}
|
||||
}
|
||||
if len(c.Networks) > 0 && (c.RunOnce || c.Schedule != "") {
|
||||
problems = append(problems, where+": networks is for a container that keeps running; a step "+
|
||||
"runs and exits, and joins nothing afterwards")
|
||||
}
|
||||
return append(problems, checkImage(where, c.Image)...)
|
||||
}
|
||||
|
||||
@@ -947,6 +1172,41 @@ type Declaration struct {
|
||||
// converged node — which is every node the mesh raised before adoption existed, and so the
|
||||
// only form an older controller ever sends (novox/hq ADR 0100).
|
||||
Adoption *Adoption
|
||||
|
||||
// Sequence orders this declaration against every other the mesh has sent this node: each
|
||||
// send is one higher than the last, assigned under the control plane's hold on the node
|
||||
// (novox/hq 04-ISSUES/107). Zero is a declaration that carries no order — every one an older
|
||||
// controller sent, and the bundle genesis applies — and a host makes no ordering claim about
|
||||
// one of those.
|
||||
//
|
||||
// **The one property a declaration needs that its signature does not give it.** A signature
|
||||
// says the mesh sent this; it cannot say the mesh sent it AFTER the one the host is holding.
|
||||
// Before this, "older" was inferred from arrival within a batch and a 750ms window, and a
|
||||
// backlog longer than the batch, or a slow broker, applied a declaration the mesh had already
|
||||
// superseded.
|
||||
Sequence int64
|
||||
|
||||
// LeftOut names the modules of this machine's set the mesh left out of this declaration,
|
||||
// because a setting stored for one cannot compose with its definition (novox/hq ADR 0163,
|
||||
// rule 6). A machine is told everything or nothing about what it IS told; this is what it is
|
||||
// not told, said. The host keeps what it holds for a left-out module and touches none of
|
||||
// what it wrote for it — its resources are absent from the declaration, and absence would
|
||||
// otherwise read as removal.
|
||||
LeftOut []string
|
||||
}
|
||||
|
||||
// LeftOutModuleOf says which left-out module a recorded resource belongs to, if any: its id is the
|
||||
// module's name, a dot, and the module's own id for it. A module's name may contain a dot, so the
|
||||
// longest left-out name that prefixes the id wins; a false match keeps a thing an apply would
|
||||
// otherwise remove, which is the conservative mistake.
|
||||
func (d *Declaration) LeftOutModuleOf(id string) (string, bool) {
|
||||
best := ""
|
||||
for _, m := range d.LeftOut {
|
||||
if strings.HasPrefix(id, m+".") && len(m) > len(best) {
|
||||
best = m
|
||||
}
|
||||
}
|
||||
return best, best != ""
|
||||
}
|
||||
|
||||
// Adoption is a node's mode, as the controller records it: the node is adopted, and these are
|
||||
@@ -967,6 +1227,10 @@ type Adoption struct {
|
||||
Untaken map[string][]string `json:"untaken,omitempty"`
|
||||
}
|
||||
|
||||
// capabilityName is what a Linux capability is called: upper case, underscores, an optional CAP_
|
||||
// prefix. The runtime accepts either spelling.
|
||||
var capabilityName = regexp.MustCompile(`^(CAP_)?[A-Z][A-Z0-9_]*$`)
|
||||
|
||||
// AdoptionPrefix is the id prefix of what the mesh itself declares because a node is adopted —
|
||||
// its openings and its guard. Nothing under it belongs to a module, so none of it is ever held.
|
||||
const AdoptionPrefix = "adoption."
|
||||
@@ -1073,10 +1337,22 @@ func ParseTrusted(raw []byte) (*Declaration, error) { return parse(raw, true) }
|
||||
// first pass takes the envelope and each resource's bytes; the second decodes each one into
|
||||
// the struct for its kind, strictly.
|
||||
type envelope struct {
|
||||
Version int `json:"declaration"`
|
||||
For string `json:"for,omitempty"`
|
||||
Adoption *Adoption `json:"adoption,omitempty"`
|
||||
Resources []json.RawMessage `json:"resources"`
|
||||
Version int `json:"declaration"`
|
||||
For string `json:"for,omitempty"`
|
||||
Adoption *Adoption `json:"adoption,omitempty"`
|
||||
// OwnsNothing is the control plane saying, explicitly, that this node's declaration is empty
|
||||
// on purpose — it owns nothing the mesh put there (novox/hq issue 127). Without it an empty
|
||||
// resources list is refused as a likely mistake; with it the node applies the empty
|
||||
// declaration and drops what it last held. The two are distinguished because a truncated or
|
||||
// mis-composed body arrives as empty too, and a host that could not tell them apart would let
|
||||
// a bug quietly strip a machine.
|
||||
OwnsNothing bool `json:"owns_nothing,omitempty"`
|
||||
Resources []json.RawMessage `json:"resources"`
|
||||
// Sequence is optional on the wire, so a controller that does not send one is still
|
||||
// understood: absent reads as zero, which is "no ordering claimed" rather than "first".
|
||||
Sequence int64 `json:"sequence,omitempty"`
|
||||
// LeftOut is optional on the wire too, and absent when nothing was left out (ADR 0163).
|
||||
LeftOut []string `json:"left_out,omitempty"`
|
||||
}
|
||||
|
||||
func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
||||
@@ -1093,10 +1369,22 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
||||
env.Version, Version)}}
|
||||
}
|
||||
|
||||
d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption}
|
||||
d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption, Sequence: env.Sequence,
|
||||
LeftOut: env.LeftOut}
|
||||
var problems []string
|
||||
if len(env.LeftOut) > 0 && allowActions {
|
||||
// The bundle is carried with the binary and leaves nothing out: which module a setting
|
||||
// stopped composing for is the mesh's record (ADR 0163).
|
||||
problems = append(problems, "a carried bundle says modules were left out, and only the "+
|
||||
"mesh can say that")
|
||||
}
|
||||
for _, m := range env.LeftOut {
|
||||
if strings.TrimSpace(m) == "" {
|
||||
problems = append(problems, "left_out names a module with no name")
|
||||
}
|
||||
}
|
||||
|
||||
if len(env.Resources) == 0 {
|
||||
if len(env.Resources) == 0 && !env.OwnsNothing {
|
||||
problems = append(problems, "no resources. An empty declaration is a mistake, not a "+
|
||||
"machine with nothing on it — say so with an explicit empty list if that is meant")
|
||||
}
|
||||
@@ -1167,6 +1455,14 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
||||
"resource %q: an opening is for an adopted node, and this declaration does not "+
|
||||
"say the node is adopted", r.Identity()))
|
||||
}
|
||||
if svc, ok := r.(*Service); ok && svc.TakesOver != nil {
|
||||
// A tunnel is taken over on an adopted node, where what is found is kept: on a
|
||||
// converged one there is nothing found to take over, and stopping a unit the
|
||||
// mesh did not declare would be the host deciding (novox/hq ADR 0105).
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"resource %q: taking over a tunnel is for an adopted node, and this declaration "+
|
||||
"does not say the node is adopted", r.Identity()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -427,3 +427,100 @@ func TestASecretTheContentNeverUsesIsRefused(t *testing.T) {
|
||||
t.Fatal("a secret the content never mentions was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// The runtime's resolver and address flags take only addresses; a name would be refused at
|
||||
// create, after the old container was already gone. Refused on arrival instead — and an address
|
||||
// without a user-defined network is refused for the same reason.
|
||||
func TestAContainersResolverAndAddressAreAddressesOrRefused(t *testing.T) {
|
||||
refused := func(body string) []string {
|
||||
_, err := Parse([]byte(`{"declaration":1,"resources":[` + body + `]}`))
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return []string{err.Error()}
|
||||
}
|
||||
base := `"id":"c","type":"container","name":"x",` +
|
||||
`"image":"a@sha256:0000000000000000000000000000000000000000000000000000000000000000"`
|
||||
if p := refused(`{` + base + `,"network":"m","dns":["resolver.local"]}`); len(p) == 0 {
|
||||
t.Error("a resolver named by name was accepted; the runtime takes only addresses")
|
||||
}
|
||||
if p := refused(`{` + base + `,"ip":"192.168.203.7"}`); len(p) == 0 {
|
||||
t.Error("a static address with no network was accepted; the runtime refuses it")
|
||||
}
|
||||
if p := refused(`{` + base + `,"network":"m","dns":["192.168.203.254"],"ip":"192.168.203.7"}`); len(p) != 0 {
|
||||
t.Errorf("a well-formed resolver and address were refused: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnExplicitlyEmptyDeclarationIsAccepted(t *testing.T) {
|
||||
// A deliberately-empty declaration (novox/hq issue 127) says owns_nothing, and is applied so
|
||||
// the node drops what it last held — distinct from an accidental empty body, which is refused.
|
||||
if _, err := Parse([]byte(`{"declaration":1,"owns_nothing":true,"resources":[]}`)); err != nil {
|
||||
t.Fatalf("an explicitly-empty declaration must be accepted: %v", err)
|
||||
}
|
||||
// Without the marker, an empty declaration is still refused as a likely mistake.
|
||||
_, err := Parse([]byte(`{"declaration":1,"resources":[]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "no resources") {
|
||||
t.Fatalf("an unmarked empty declaration must still be refused; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A container's kept networks are names, not its own network, and not for a step (novox/hq ADR
|
||||
// 0163, rule 4); and the mesh may say which modules it left out, which a carried bundle may not.
|
||||
func TestKeptNetworksAndLeftOutModulesAreReadStrictly(t *testing.T) {
|
||||
pinnedImage := "postgres@sha256:" + strings.Repeat("a", 64)
|
||||
d, err := Parse([]byte(`{"declaration":1,"left_out":["web"],"resources":[
|
||||
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","networks":["predecessor_default"]}
|
||||
]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := d.Resources[0].(*Container).Networks; len(got) != 1 || got[0] != "predecessor_default" {
|
||||
t.Fatalf("the kept network was not read: %v", got)
|
||||
}
|
||||
if m, left := d.LeftOutModuleOf("web.server"); !left || m != "web" {
|
||||
t.Fatalf("web.server is not web's: %q %v", m, left)
|
||||
}
|
||||
if _, left := d.LeftOutModuleOf("webapp.server"); left {
|
||||
t.Fatal("webapp.server was taken for web's")
|
||||
}
|
||||
for name, raw := range map[string]string{
|
||||
"a bad network name": `{"declaration":1,"resources":[
|
||||
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","networks":["a/b"]}]}`,
|
||||
"its own network": `{"declaration":1,"resources":[
|
||||
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","network":"own","networks":["own"]}]}`,
|
||||
"a step": `{"declaration":1,"resources":[
|
||||
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","run-once":true,"networks":["x"]}]}`,
|
||||
"a nameless module": `{"declaration":1,"left_out":[""],"resources":[
|
||||
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `"}]}`,
|
||||
} {
|
||||
if _, err := Parse([]byte(raw)); err == nil {
|
||||
t.Errorf("%s was accepted", name)
|
||||
}
|
||||
}
|
||||
if _, err := ParseTrusted([]byte(`{"declaration":1,"left_out":["web"],"resources":[
|
||||
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `"}]}`)); err == nil ||
|
||||
!strings.Contains(err.Error(), "only the mesh can say that") {
|
||||
t.Fatalf("a carried bundle leaving modules out was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A container may ask for a capability by name, and nothing else (novox/hq ADR 0170).
|
||||
func TestACapabilityIsNamedOrRefused(t *testing.T) {
|
||||
image := "postgres@sha256:" + strings.Repeat("a", 64)
|
||||
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"fw","type":"container","name":"fw","image":"` + image + `","network":"host","capabilities":["NET_ADMIN","CAP_NET_RAW"]}
|
||||
]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := d.Resources[0].(*Container).Capabilities; len(got) != 2 || got[0] != "NET_ADMIN" {
|
||||
t.Fatalf("capabilities read as %v", got)
|
||||
}
|
||||
for _, bad := range []string{`"net_admin"`, `"ALL;rm -rf /"`, `"privileged"`} {
|
||||
if _, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"fw","type":"container","name":"fw","image":"` + image + `","capabilities":[` + bad + `]}]}`)); err == nil {
|
||||
t.Errorf("%s was accepted as a capability", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,10 @@ func TestAProcessMustSayWhatToRun(t *testing.T) {
|
||||
|
||||
// Its name becomes a unit name and a path, so a separator in it would write somewhere nobody meant.
|
||||
func TestAProcesssNameCannotEscapeItsUnit(t *testing.T) {
|
||||
for _, bad := range []string{"", "../escape", "two words", "a/b"} {
|
||||
// "." and ".." are one path element each, and the mesh's own bundle directory and its parent:
|
||||
// removing a process named ".." would delete every bundle the mesh has, and more (novox/hq ADR
|
||||
// 0118). A leading dash is an option to the service manager, not a unit.
|
||||
for _, bad := range []string{"", "../escape", "two words", "a/b", ".", "..", "-", "--now"} {
|
||||
d := aProcess()
|
||||
d.Name = bad
|
||||
if problems := d.validate("a process", false); len(problems) == 0 {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package declaration
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0117: a service may leave its unit's lifecycle to the machine, and then
|
||||
// says nothing but its triggers.
|
||||
func TestAServiceWithoutAStateSaysOnlyItsTriggers(t *testing.T) {
|
||||
for name, c := range map[string]struct{ resource, refusal string }{
|
||||
"no trigger": {`{"id":"s","type":"service","unit":"NetworkManager.service"}`, "declares nothing"},
|
||||
"with boot": {`{"id":"s","type":"service","unit":"NetworkManager.service","boot":"enabled","reload-on":["f"]}`, "boot and takes-over"},
|
||||
"with takes-over": {`{"id":"s","type":"service","unit":"a.service","reload-on":["f"],"takes-over":{"interface":"wg0","unit":"b.service","config":"/etc/x"}}`, "boot and takes-over"},
|
||||
"an unknown state": {`{"id":"s","type":"service","unit":"a.service","state":"paused"}`, "omits state to leave the unit's lifecycle to the machine"},
|
||||
} {
|
||||
_, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"f","type":"file","path":"/etc/x","content":"x"},` + c.resource + `]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), c.refusal) {
|
||||
t.Errorf("%s: want a refusal naming %q, got %v", name, c.refusal, err)
|
||||
}
|
||||
}
|
||||
d, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"f","type":"file","path":"/etc/x","content":"x"},
|
||||
{"id":"s","type":"service","unit":"NetworkManager.service","restart-on":["f"]}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := d.Resources[1].(*Service); !s.Stateless() {
|
||||
t.Error("a service without a state was not read as stateless")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What filters a machine, said with an owner (novox/hq ADR 0168).
|
||||
//
|
||||
// "The firewall found" names one front end, and a machine carries rules from several sources: the
|
||||
// front end's own, the container runtime's plumbing, a ban list, the mesh's own tables, and whatever
|
||||
// a predecessor installed directly — on both machines of the first mesh, in the user chain the
|
||||
// runtime leaves for an administrator, where the mesh's reader of rules counted it as the runtime's.
|
||||
// So the host reports every table and chain that refuses traffic, each with whose it is, and the
|
||||
// mesh says truthfully what filters a converged machine. It removes none of it.
|
||||
|
||||
// Owners of a refusal.
|
||||
const (
|
||||
// OwnerMesh is the mesh's own tables: the derived filter and the guard.
|
||||
OwnerMesh = "mesh"
|
||||
// OwnerFoundFirewall is the front end found on the machine — ufw's chains.
|
||||
OwnerFoundFirewall = "found-firewall"
|
||||
// OwnerRuntime is the container runtime's own plumbing: its chains, the forward policy it sets
|
||||
// when it turns forwarding on, its guard against reaching a container's address from off its
|
||||
// bridge. Not the user chain it leaves for an administrator.
|
||||
OwnerRuntime = "runtime"
|
||||
// OwnerBan is a refusal that names the sources it refuses, in a chain that accepts nothing — a
|
||||
// ban list, which is not a firewall.
|
||||
OwnerBan = "ban"
|
||||
// OwnerOther is everything else: rules the mesh did not write and cannot attribute. Where a
|
||||
// predecessor's rules live.
|
||||
OwnerOther = "other"
|
||||
)
|
||||
|
||||
// A Filter is one place on the machine that refuses traffic: a chain of a table, or a chain of the
|
||||
// legacy filter, with its owner and what it refuses in one line.
|
||||
type Filter struct {
|
||||
// Where names the chain: "table ip filter, chain DOCKER-USER", or "chain HAL-MESH-ONLY
|
||||
// (iptables-legacy)".
|
||||
Where string `json:"where"`
|
||||
// Owner is one of the owners above.
|
||||
Owner string `json:"owner"`
|
||||
// Refuses is the first refusing line, counters stripped, and how many more there are.
|
||||
Refuses string `json:"refuses"`
|
||||
|
||||
table, chain string
|
||||
}
|
||||
|
||||
// userChain is the chain the container runtime creates empty and leaves for an administrator's
|
||||
// rules, consulted before its own forwarding. Nothing in it is the runtime's.
|
||||
const userChain = "DOCKER-USER"
|
||||
|
||||
// Filters classifies every refusing chain of an `nft list ruleset` and of the legacy filter's `-S`
|
||||
// listings (by tool: iptables-legacy, ip6tables-legacy), in the order they appear.
|
||||
func Filters(ruleset string, legacy map[string]string, ufwActive bool) []Filter {
|
||||
var out []Filter
|
||||
r := parseNft(ruleset)
|
||||
refusing := map[string][]nftRule{} // by "table\x00chain"
|
||||
for _, rule := range r.refusals {
|
||||
k := rule.table + "\x00" + rule.chain
|
||||
refusing[k] = append(refusing[k], rule)
|
||||
}
|
||||
for _, k := range r.chainOrder {
|
||||
c := r.chains[k]
|
||||
table, chain, _ := strings.Cut(k, "\x00")
|
||||
rules := refusing[k]
|
||||
if !c.dropping && len(rules) == 0 {
|
||||
continue
|
||||
}
|
||||
f := Filter{table: table, chain: chain, Where: "table " + table + ", chain " + chain}
|
||||
switch {
|
||||
case table == MeshTable || table == "inet mesh_guard":
|
||||
f.Owner = OwnerMesh
|
||||
case strings.HasPrefix(chain, "ufw"):
|
||||
f.Owner = OwnerFoundFirewall
|
||||
if !ufwActive {
|
||||
// Left behind by a retired front end, and still refusing: not ufw's any more in
|
||||
// any sense that matters, since nothing maintains it.
|
||||
f.Owner = OwnerOther
|
||||
}
|
||||
case chain == userChain:
|
||||
f.Owner = OwnerOther
|
||||
case c.dropping && (r.managed[table] || iptablesTable(table)) && runtimes(table, chain, c.policyLine):
|
||||
f.Owner = OwnerRuntime
|
||||
case len(rules) > 0 && (r.managed[table] || iptablesTable(table)) && allRuntimes(table, chain, rules):
|
||||
f.Owner = OwnerRuntime
|
||||
case len(rules) > 0 && allBans(r, rules):
|
||||
f.Owner = OwnerBan
|
||||
case c.dropping && !iptablesTable(table) && !r.managed[table] && len(rules) == 0:
|
||||
// A table of its own whose base chain drops by policy: a firewall nobody declared.
|
||||
f.Owner = OwnerOther
|
||||
default:
|
||||
f.Owner = OwnerOther
|
||||
}
|
||||
if ufwActive && (r.managed[table] || iptablesTable(table)) && f.Owner == OwnerOther && len(rules) == 0 && c.dropping {
|
||||
// A base chain ufw set to drop while it is in force is ufw's.
|
||||
f.Owner = OwnerFoundFirewall
|
||||
}
|
||||
f.Refuses = refusesLine(c, rules)
|
||||
out = append(out, f)
|
||||
}
|
||||
tools := make([]string, 0, len(legacy))
|
||||
for tool := range legacy {
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
sort.Strings(tools)
|
||||
for _, tool := range tools {
|
||||
out = append(out, legacyFilters(legacy[tool], tool, ufwActive)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// allRuntimes is whether every refusal in a chain is the runtime's own.
|
||||
func allRuntimes(table, chain string, rules []nftRule) bool {
|
||||
for _, rule := range rules {
|
||||
if !runtimes(table, chain, rule.line) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// allBans is whether every refusal in a chain only bans the sources it names.
|
||||
func allBans(r *nftRuleset, rules []nftRule) bool {
|
||||
for _, rule := range rules {
|
||||
if !r.onlyBans(rule) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var counters = regexp.MustCompile(`\s*counter packets \d+ bytes \d+`)
|
||||
|
||||
// refusesLine is one line a person reads: the policy when the chain drops by policy, else the first
|
||||
// refusing rule with its counters stripped, and how many more there are.
|
||||
func refusesLine(c *nftChain, rules []nftRule) string {
|
||||
var parts []string
|
||||
if c.dropping {
|
||||
parts = append(parts, "policy drop")
|
||||
}
|
||||
if len(rules) > 0 {
|
||||
line := strings.TrimSpace(counters.ReplaceAllString(rules[0].line, ""))
|
||||
if len(rules) > 1 {
|
||||
line += fmt.Sprintf(" (and %d more)", len(rules)-1)
|
||||
}
|
||||
parts = append(parts, line)
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// legacyFilters classifies the chains of an `iptables-legacy -S` listing that refuse.
|
||||
func legacyFilters(rules, tool string, ufwActive bool) []Filter {
|
||||
policy := map[string]string{}
|
||||
accepting := map[string]bool{}
|
||||
jumpedFrom := map[string][]string{}
|
||||
for _, line := range strings.Split(rules, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
switch fields[0] {
|
||||
case "-P":
|
||||
policy[fields[1]] = fields[2]
|
||||
case "-A":
|
||||
for i, f := range fields {
|
||||
if (f == "-j" || f == "-g") && i+1 < len(fields) {
|
||||
switch fields[i+1] {
|
||||
case "ACCEPT":
|
||||
accepting[fields[1]] = true
|
||||
case "DROP", "REJECT", "RETURN", "LOG":
|
||||
default:
|
||||
jumpedFrom[fields[i+1]] = append(jumpedFrom[fields[i+1]], fields[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var entered func(chain string, seen map[string]bool) bool
|
||||
entered = func(chain string, seen map[string]bool) bool {
|
||||
if seen[chain] || accepting[chain] || len(jumpedFrom[chain]) == 0 {
|
||||
return false
|
||||
}
|
||||
seen[chain] = true
|
||||
for _, from := range jumpedFrom[chain] {
|
||||
if p, builtIn := policy[from]; builtIn {
|
||||
if p != "ACCEPT" {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !entered(from, seen) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
ban := func(chain, line string) bool {
|
||||
return bansSources(line) && entered(chain, map[string]bool{})
|
||||
}
|
||||
type seen struct {
|
||||
owner string
|
||||
lines []string
|
||||
}
|
||||
chains := map[string]*seen{}
|
||||
var order []string
|
||||
note := func(chain, owner, line string) {
|
||||
s := chains[chain]
|
||||
if s == nil {
|
||||
s = &seen{owner: owner}
|
||||
chains[chain] = s
|
||||
order = append(order, chain)
|
||||
}
|
||||
if owner == OwnerOther || s.owner == "" {
|
||||
s.owner = owner
|
||||
}
|
||||
s.lines = append(s.lines, line)
|
||||
}
|
||||
for _, line := range strings.Split(rules, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
chain := fields[1]
|
||||
switch fields[0] {
|
||||
case "-P":
|
||||
if fields[2] != "DROP" {
|
||||
continue
|
||||
}
|
||||
owner := OwnerOther
|
||||
if chain == "FORWARD" {
|
||||
owner = OwnerRuntime
|
||||
}
|
||||
if ufwActive {
|
||||
owner = OwnerFoundFirewall
|
||||
}
|
||||
note(chain, owner, "policy DROP")
|
||||
case "-A":
|
||||
refuses := false
|
||||
for i, f := range fields {
|
||||
if f == "-j" && i+1 < len(fields) && (fields[i+1] == "DROP" || fields[i+1] == "REJECT") {
|
||||
refuses = true
|
||||
}
|
||||
}
|
||||
if !refuses {
|
||||
continue
|
||||
}
|
||||
owner := OwnerOther
|
||||
switch {
|
||||
case strings.HasPrefix(chain, "ufw"):
|
||||
owner = OwnerFoundFirewall
|
||||
if !ufwActive {
|
||||
owner = OwnerOther
|
||||
}
|
||||
case chain != userChain && strings.HasPrefix(chain, "DOCKER"):
|
||||
owner = OwnerRuntime
|
||||
case ban(chain, line):
|
||||
owner = OwnerBan
|
||||
}
|
||||
note(chain, owner, strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
var out []Filter
|
||||
for _, chain := range order {
|
||||
s := chains[chain]
|
||||
refuses := s.lines[0]
|
||||
if len(s.lines) > 1 {
|
||||
refuses += fmt.Sprintf(" (and %d more)", len(s.lines)-1)
|
||||
}
|
||||
out = append(out, Filter{Where: "chain " + chain + " (" + tool + ")", Owner: s.owner, Refuses: refuses})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Collect reads what filters this machine now: its nftables ruleset and, where the legacy tools
|
||||
// exist, their listings. A machine without nft is read through iptables, as Detect reads it.
|
||||
func Collect(ctx context.Context, run Runner, ufwActive bool) ([]Filter, error) {
|
||||
ruleset := ""
|
||||
noNft := false
|
||||
out, err := run(ctx, "nft", "list", "ruleset")
|
||||
switch {
|
||||
case err == nil:
|
||||
ruleset = out
|
||||
case missing(err):
|
||||
noNft = true
|
||||
default:
|
||||
return nil, fmt.Errorf("cannot read this machine's packet filter: %w", err)
|
||||
}
|
||||
legacy := map[string]string{}
|
||||
tools := []string{"iptables-legacy", "ip6tables-legacy"}
|
||||
if noNft {
|
||||
tools = append(tools, "iptables", "ip6tables")
|
||||
}
|
||||
for _, tool := range tools {
|
||||
if out, err := run(ctx, tool, "-S"); err == nil && strings.TrimSpace(out) != "" {
|
||||
legacy[tool] = out
|
||||
}
|
||||
}
|
||||
return Filters(ruleset, legacy, ufwActive), nil
|
||||
}
|
||||
|
||||
// Alone is whether a machine is filtered by the mesh alone: nothing in the list but the mesh's
|
||||
// own tables, the runtime's plumbing and bans (novox/hq ADR 0168).
|
||||
func Alone(filters []Filter) bool {
|
||||
for _, f := range filters {
|
||||
if f.Owner == OwnerOther || f.Owner == OwnerFoundFirewall {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Active says whether ufw is in force on this machine now. A machine without ufw is not.
|
||||
func Active(ctx context.Context, run Runner) bool {
|
||||
out, err := run(ctx, "ufw", "status")
|
||||
return err == nil && statusActive(out)
|
||||
}
|
||||
|
||||
// Installed says whether ufw is on this machine at all: a command that is not there is a front end
|
||||
// that was uninstalled (novox/hq ADR 0175), not one that is silent.
|
||||
func Installed(ctx context.Context, run Runner) bool {
|
||||
_, err := run(ctx, "ufw", "status")
|
||||
return !missing(err)
|
||||
}
|
||||
|
||||
// Retirements of a found firewall, as the host records them.
|
||||
const (
|
||||
RetiredByMesh = "mesh"
|
||||
RetiredFoundSo = "found-inactive"
|
||||
// RetiredRemoved is a front end uninstalled by the module that replaced it (ADR 0175).
|
||||
RetiredRemoved = "removed"
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fixture(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("testdata/" + name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func ownerOf(filters []Filter, where string) string {
|
||||
for _, f := range filters {
|
||||
if f.Where == where {
|
||||
return f.Owner
|
||||
}
|
||||
}
|
||||
return "(not reported)"
|
||||
}
|
||||
|
||||
// Every refusing table and chain is classified with an owner (novox/hq ADR 0168), over rulesets
|
||||
// captured from three machines of the first mesh. The control node: a ban list reached through the
|
||||
// runtime's user chain is a ban; a refusal left in that chain, and a chain a retired front end left
|
||||
// behind, are *other*; the runtime's own and the mesh's own are theirs.
|
||||
func TestTheControlNodesRefusalsAreClassified(t *testing.T) {
|
||||
got := Filters(fixture(t, "control-node.nft"), nil, false)
|
||||
for where, want := range map[string]string{
|
||||
"table ip filter, chain f2b-recidive": OwnerBan,
|
||||
"table ip filter, chain DOCKER": OwnerRuntime,
|
||||
"table ip raw, chain PREROUTING": OwnerRuntime,
|
||||
"table inet mesh, chain input": OwnerMesh,
|
||||
"table inet mesh, chain forward": OwnerMesh,
|
||||
"table ip6 filter, chain DOCKER-USER": OwnerOther,
|
||||
"table ip6 filter, chain ufw6-docker-logging-deny": OwnerOther,
|
||||
} {
|
||||
if o := ownerOf(got, where); o != want {
|
||||
t.Errorf("%s: %s, want %s", where, o, want)
|
||||
}
|
||||
}
|
||||
if Alone(got) {
|
||||
t.Error("a machine with a refusal in the runtime's user chain reads as filtered by the mesh alone")
|
||||
}
|
||||
// What refuses adoption does not move (rule 4): the user chain's refusals are reported, not
|
||||
// refused. The chain a retired front end left behind, still dropping, is what it always was
|
||||
// to Detect — a refusal nobody speaks for, in one table.
|
||||
if refusing := Refusing(fixture(t, "control-node.nft"), false); len(refusing) != 1 || refusing[0] != "table ip6 filter" {
|
||||
t.Errorf("adoption's threshold moved: %v", refusing)
|
||||
}
|
||||
// The counters are stripped from what a person reads.
|
||||
for _, f := range got {
|
||||
if strings.Contains(f.Refuses, "counter packets") {
|
||||
t.Errorf("counters in the line: %s", f.Refuses)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The laptop: the runtime's forward policy and bridge guards, a virtualisation host and an endpoint
|
||||
// agent that refuse nothing, and the mesh — filtered by the mesh alone.
|
||||
func TestTheLaptopIsFilteredByTheMeshAlone(t *testing.T) {
|
||||
got := Filters(fixture(t, "laptop.nft"), nil, false)
|
||||
for where, want := range map[string]string{
|
||||
"table ip filter, chain FORWARD": OwnerRuntime,
|
||||
"table ip filter, chain DOCKER": OwnerRuntime,
|
||||
"table ip raw, chain PREROUTING": OwnerRuntime,
|
||||
"table inet mesh, chain input": OwnerMesh,
|
||||
} {
|
||||
if o := ownerOf(got, where); o != want {
|
||||
t.Errorf("%s: %s, want %s", where, o, want)
|
||||
}
|
||||
}
|
||||
for _, f := range got {
|
||||
if strings.Contains(f.Where, "incus") || strings.Contains(f.Where, "fct_") {
|
||||
t.Errorf("a table that refuses nothing is reported: %+v", f)
|
||||
}
|
||||
}
|
||||
if !Alone(got) {
|
||||
t.Errorf("the laptop is not read as filtered by the mesh alone: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The home server: its rules are in the legacy filter, where a predecessor's chain still drops what
|
||||
// arrives on the outward link for the forwarded path — invisible to the mesh until now (issue 144).
|
||||
func TestThePredecessorsChainInTheLegacyFilterIsOther(t *testing.T) {
|
||||
mesh := "table inet mesh {\n\tchain forward {\n\t\ttype filter hook forward priority filter; policy drop;\n\t}\n}\n"
|
||||
got := Filters(mesh, map[string]string{"iptables-legacy": fixture(t, "home-server-legacy-S.txt")}, false)
|
||||
for where, want := range map[string]string{
|
||||
"table inet mesh, chain forward": OwnerMesh,
|
||||
"chain FORWARD (iptables-legacy)": OwnerRuntime,
|
||||
"chain DOCKER (iptables-legacy)": OwnerRuntime,
|
||||
"chain HAL-MESH-ONLY (iptables-legacy)": OwnerOther,
|
||||
} {
|
||||
if o := ownerOf(got, where); o != want {
|
||||
t.Errorf("%s: %s, want %s", where, o, want)
|
||||
}
|
||||
}
|
||||
var other Filter
|
||||
for _, f := range got {
|
||||
if f.Owner == OwnerOther {
|
||||
other = f
|
||||
}
|
||||
}
|
||||
if !strings.Contains(other.Refuses, "-j DROP") {
|
||||
t.Errorf("what the predecessor's chain refuses is not said: %+v", other)
|
||||
}
|
||||
if Alone(got) {
|
||||
t.Error("a machine with a predecessor's chain reads as filtered by the mesh alone")
|
||||
}
|
||||
}
|
||||
|
||||
// With the front end in force, its chains are its own; retired, a chain it left behind that still
|
||||
// refuses is nobody's and said so.
|
||||
func TestAFrontEndsChainsAreItsWhileItIsInForce(t *testing.T) {
|
||||
ruleset := dockerOnly(t) + ufwChains
|
||||
for _, f := range Filters(ruleset, nil, true) {
|
||||
if strings.Contains(f.Where, "ufw") && f.Owner != OwnerFoundFirewall {
|
||||
t.Errorf("active: %+v", f)
|
||||
}
|
||||
}
|
||||
for _, f := range Filters(ruleset, nil, false) {
|
||||
if strings.Contains(f.Where, "ufw") && f.Owner != OwnerOther {
|
||||
t.Errorf("retired: %+v", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
-81
@@ -128,42 +128,82 @@ func statusActive(out string) bool {
|
||||
// mesh needs, so a refusal that names the sources it refuses, in a table or a chain that accepts
|
||||
// nothing and is entered only from chains whose policy accepts, is not counted.
|
||||
func Refusing(ruleset string, ufwActive bool) []string {
|
||||
type rule struct{ table, chain, line string }
|
||||
type chainOf struct {
|
||||
base, dropping, accepts bool
|
||||
policyLine string
|
||||
jumpedFrom []string
|
||||
}
|
||||
chains := map[string]*chainOf{} // by "table\x00chain"
|
||||
tableAccepts := map[string]bool{}
|
||||
var tables []string
|
||||
var refusals []rule
|
||||
managed := map[string]bool{}
|
||||
var table, chain string
|
||||
get := func(t, c string) *chainOf {
|
||||
k := t + "\x00" + c
|
||||
if chains[k] == nil {
|
||||
chains[k] = &chainOf{}
|
||||
var refusing []string
|
||||
for _, f := range Filters(ruleset, nil, ufwActive) {
|
||||
if f.Owner != OwnerOther || f.chain == userChain {
|
||||
// A refusal in the runtime's user chain is reported as *other* and does not refuse
|
||||
// adoption (novox/hq ADR 0168, rule 4): both predecessors kept their rules there.
|
||||
continue
|
||||
}
|
||||
name := "table " + f.table
|
||||
if len(refusing) == 0 || refusing[len(refusing)-1] != name {
|
||||
if !contains(refusing, name) {
|
||||
refusing = append(refusing, name)
|
||||
}
|
||||
}
|
||||
return chains[k]
|
||||
}
|
||||
return refusing
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// nftRule is one line of a ruleset that refuses, with where it is.
|
||||
type nftRule struct{ table, chain, line string }
|
||||
|
||||
// nftChain is what a parse knows about one chain.
|
||||
type nftChain struct {
|
||||
base, dropping, accepts bool
|
||||
policyLine string
|
||||
jumpedFrom []string
|
||||
}
|
||||
|
||||
// nftRuleset is `nft list ruleset`, read: its tables in order, its chains, every refusing line,
|
||||
// and which tables iptables-nft manages.
|
||||
type nftRuleset struct {
|
||||
tables []string
|
||||
chains map[string]*nftChain // by "table\x00chain"
|
||||
chainOrder []string
|
||||
tableAccepts map[string]bool
|
||||
refusals []nftRule
|
||||
managed map[string]bool
|
||||
}
|
||||
|
||||
func (r *nftRuleset) get(t, c string) *nftChain {
|
||||
k := t + "\x00" + c
|
||||
if r.chains[k] == nil {
|
||||
r.chains[k] = &nftChain{}
|
||||
r.chainOrder = append(r.chainOrder, k)
|
||||
}
|
||||
return r.chains[k]
|
||||
}
|
||||
|
||||
func parseNft(ruleset string) *nftRuleset {
|
||||
r := &nftRuleset{chains: map[string]*nftChain{}, tableAccepts: map[string]bool{}, managed: map[string]bool{}}
|
||||
var table, chain string
|
||||
for _, raw := range strings.Split(ruleset, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "# Warning: table ") && strings.Contains(line, "managed by iptables-nft"):
|
||||
name := strings.TrimPrefix(line, "# Warning: table ")
|
||||
name, _, _ = strings.Cut(name, " is managed")
|
||||
managed[name] = true
|
||||
r.managed[name] = true
|
||||
continue
|
||||
case strings.HasPrefix(line, "table "):
|
||||
table = strings.TrimSuffix(strings.TrimSpace(strings.TrimPrefix(line, "table ")), "{")
|
||||
table = strings.TrimSpace(table)
|
||||
tables = append(tables, table)
|
||||
r.tables = append(r.tables, table)
|
||||
chain = ""
|
||||
continue
|
||||
case strings.HasPrefix(line, "chain "):
|
||||
chain = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "chain "), "{"))
|
||||
get(table, chain)
|
||||
r.get(table, chain)
|
||||
continue
|
||||
case strings.HasPrefix(line, "set ") || strings.HasPrefix(line, "map ") ||
|
||||
strings.HasPrefix(line, "flowtable "):
|
||||
@@ -172,7 +212,7 @@ func Refusing(ruleset string, ufwActive bool) []string {
|
||||
case line == "" || line == "}" || strings.HasPrefix(line, "#") || chain == "":
|
||||
continue
|
||||
}
|
||||
c := get(table, chain)
|
||||
c := r.get(table, chain)
|
||||
if strings.HasPrefix(line, "type ") {
|
||||
c.base = true
|
||||
c.policyLine = line
|
||||
@@ -183,85 +223,66 @@ func Refusing(ruleset string, ufwActive bool) []string {
|
||||
if i := strings.Index(line, verb); i >= 0 {
|
||||
target := strings.Fields(line[i+len(verb):])
|
||||
if len(target) > 0 {
|
||||
get(table, target[0]).jumpedFrom = append(get(table, target[0]).jumpedFrom, chain)
|
||||
r.get(table, target[0]).jumpedFrom = append(r.get(table, target[0]).jumpedFrom, chain)
|
||||
}
|
||||
}
|
||||
}
|
||||
if accepts(line) {
|
||||
c.accepts = true
|
||||
tableAccepts[table] = true
|
||||
r.tableAccepts[table] = true
|
||||
}
|
||||
if verdictRefuses(line) {
|
||||
refusals = append(refusals, rule{table, chain, line})
|
||||
r.refusals = append(r.refusals, nftRule{table, chain, line})
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
skipped := func(table string) bool {
|
||||
if table == "inet mesh" || table == "inet mesh_guard" {
|
||||
return true
|
||||
}
|
||||
return (managed[table] || iptablesTable(table)) && ufwActive
|
||||
// onlyBans is whether a refusal only refuses the sources it names: in a table that accepts nothing
|
||||
// and whose base chains all accept by default, or in a chain that accepts nothing and is entered
|
||||
// only from base chains that accept by default.
|
||||
func (r *nftRuleset) onlyBans(rule nftRule) bool {
|
||||
if !bansSources(rule.line) {
|
||||
return false
|
||||
}
|
||||
// onlyBans is whether a refusal only refuses the sources it names: in a table that accepts
|
||||
// nothing and whose base chains all accept by default, or in a chain that accepts nothing and
|
||||
// is entered only from base chains that accept by default.
|
||||
onlyBans := func(r rule) bool {
|
||||
if !bansSources(r.line) {
|
||||
return false
|
||||
}
|
||||
allAccepting := true
|
||||
for k, c := range chains {
|
||||
if strings.HasPrefix(k, r.table+"\x00") && c.base && !strings.Contains(c.policyLine, "policy accept") {
|
||||
allAccepting = false
|
||||
}
|
||||
}
|
||||
if !tableAccepts[r.table] && allAccepting {
|
||||
return true
|
||||
}
|
||||
c := get(r.table, r.chain)
|
||||
if c.base || c.accepts || len(c.jumpedFrom) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, from := range c.jumpedFrom {
|
||||
caller := get(r.table, from)
|
||||
if !caller.base || !strings.Contains(caller.policyLine, "policy accept") {
|
||||
return false
|
||||
}
|
||||
allAccepting := true
|
||||
for k, c := range r.chains {
|
||||
if strings.HasPrefix(k, rule.table+"\x00") && c.base && !strings.Contains(c.policyLine, "policy accept") {
|
||||
allAccepting = false
|
||||
}
|
||||
}
|
||||
if !r.tableAccepts[rule.table] && allAccepting {
|
||||
return true
|
||||
}
|
||||
return r.enteredAccepting(rule.table, rule.chain, map[string]bool{})
|
||||
}
|
||||
|
||||
counted := map[string]bool{}
|
||||
for k, c := range chains {
|
||||
t, name, _ := strings.Cut(k, "\x00")
|
||||
if skipped(t) || !c.dropping {
|
||||
continue
|
||||
}
|
||||
if (managed[t] || iptablesTable(t)) && runtimes(t, name, c.policyLine) {
|
||||
continue
|
||||
}
|
||||
counted[t] = true
|
||||
// enteredAccepting is whether a chain accepts nothing and is entered only through chains that
|
||||
// accept by default — base chains whose policy accepts, or chains that are themselves entered that
|
||||
// way and accept nothing. A ban list jumped to from the runtime's user chain, which the forward
|
||||
// chain enters with an accepting policy, is still a ban list.
|
||||
func (r *nftRuleset) enteredAccepting(table, chain string, seen map[string]bool) bool {
|
||||
if seen[chain] {
|
||||
return false
|
||||
}
|
||||
for _, r := range refusals {
|
||||
if skipped(r.table) || counted[r.table] {
|
||||
continue
|
||||
}
|
||||
if (managed[r.table] || iptablesTable(r.table)) && runtimes(r.table, r.chain, r.line) {
|
||||
continue
|
||||
}
|
||||
if onlyBans(r) {
|
||||
continue
|
||||
}
|
||||
counted[r.table] = true
|
||||
seen[chain] = true
|
||||
c := r.get(table, chain)
|
||||
if c.base || c.accepts || len(c.jumpedFrom) == 0 {
|
||||
return false
|
||||
}
|
||||
var refusing []string
|
||||
for _, t := range tables {
|
||||
if counted[t] {
|
||||
counted[t] = false
|
||||
refusing = append(refusing, "table "+t)
|
||||
for _, from := range c.jumpedFrom {
|
||||
caller := r.get(table, from)
|
||||
if caller.base {
|
||||
if !strings.Contains(caller.policyLine, "policy accept") {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if caller.accepts || !r.enteredAccepting(table, from, seen) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return refusing
|
||||
return true
|
||||
}
|
||||
|
||||
// iptablesTable is whether a table is one iptables-nft writes. Named rather than read from the
|
||||
|
||||
+588
@@ -0,0 +1,588 @@
|
||||
# Warning: table ip filter is managed by iptables-nft, do not touch!
|
||||
table ip filter {
|
||||
chain INPUT {
|
||||
type filter hook input priority filter; policy accept;
|
||||
ip protocol tcp counter packets 945757787 bytes 1737008792038 jump f2b-sshd
|
||||
ip protocol tcp counter packets 945756610 bytes 1737008898620 jump f2b-recidive
|
||||
counter packets 2862213204 bytes 3144751431654 jump ufw-before-logging-input
|
||||
counter packets 2862213204 bytes 3144751431654 jump ufw-before-input
|
||||
counter packets 989333889 bytes 1776344988272 jump ufw-after-input
|
||||
counter packets 989303248 bytes 1776343408920 jump ufw-after-logging-input
|
||||
counter packets 989303248 bytes 1776343408920 jump ufw-reject-input
|
||||
counter packets 989303248 bytes 1776343408920 jump ufw-track-input
|
||||
}
|
||||
|
||||
chain FORWARD {
|
||||
type filter hook forward priority filter; policy accept;
|
||||
oifname "mesh0" counter packets 1613103 bytes 2577614868 accept
|
||||
iifname "mesh0" counter packets 995195 bytes 84526284 accept
|
||||
counter packets 20454697 bytes 11504107676 jump DOCKER-USER
|
||||
counter packets 20442192 bytes 11503368404 jump DOCKER-FORWARD
|
||||
counter packets 12438285 bytes 10907281833 jump ufw-before-logging-forward
|
||||
counter packets 12438285 bytes 10907281833 jump ufw-before-forward
|
||||
counter packets 384 bytes 39643 jump ufw-after-forward
|
||||
counter packets 384 bytes 39643 jump ufw-after-logging-forward
|
||||
counter packets 384 bytes 39643 jump ufw-reject-forward
|
||||
counter packets 384 bytes 39643 jump ufw-track-forward
|
||||
}
|
||||
|
||||
chain OUTPUT {
|
||||
type filter hook output priority filter; policy accept;
|
||||
counter packets 3195070897 bytes 3951725261199 jump ufw-before-logging-output
|
||||
counter packets 3195070897 bytes 3951725261199 jump ufw-before-output
|
||||
counter packets 945745931 bytes 1778546547406 jump ufw-after-output
|
||||
counter packets 945745931 bytes 1778546547406 jump ufw-after-logging-output
|
||||
counter packets 945745931 bytes 1778546547406 jump ufw-reject-output
|
||||
counter packets 945745931 bytes 1778546547406 jump ufw-track-output
|
||||
}
|
||||
|
||||
chain DOCKER-FORWARD {
|
||||
counter packets 20442192 bytes 11503368404 jump DOCKER-CT
|
||||
counter packets 8079126 bytes 1230017985 jump DOCKER-INTERNAL
|
||||
counter packets 8079126 bytes 1230017985 jump DOCKER-BRIDGE
|
||||
iifname "br-cadedce55fe9" counter packets 0 bytes 0 accept
|
||||
iifname "br-dd007c7e67bc" counter packets 0 bytes 0 accept
|
||||
iifname "br-a5fbc29c2c2a" counter packets 0 bytes 0 accept
|
||||
iifname "br-6eb1e7f7f847" counter packets 0 bytes 0 accept
|
||||
iifname "br-8ce143481a5b" counter packets 14700 bytes 2493600 accept
|
||||
iifname "br-84e7d0cfeada" counter packets 0 bytes 0 accept
|
||||
iifname "br-f8b083119d99" counter packets 264 bytes 57438 accept
|
||||
iifname "br-0d1490cc67c9" counter packets 732468 bytes 351624109 accept
|
||||
iifname "br-3b338a381229" counter packets 137 bytes 11876 accept
|
||||
iifname "br-3008d408e73a" counter packets 25380 bytes 1564417 accept
|
||||
iifname "br-ca07a9577a7f" counter packets 0 bytes 0 accept
|
||||
iifname "docker0" counter packets 6695791 bytes 795364128 accept
|
||||
iifname "br-a63fa64a9e18" counter packets 0 bytes 0 accept
|
||||
iifname "br-1ccb887b3344" counter packets 237174 bytes 36804979 accept
|
||||
iifname "br-9fd22324ec08" counter packets 0 bytes 0 accept
|
||||
iifname "br-73641cceafc3" counter packets 36 bytes 6614 accept
|
||||
iifname "br-77eb8a9e2ba1" counter packets 0 bytes 0 accept
|
||||
iifname "br-e99ce5248c84" counter packets 0 bytes 0 accept
|
||||
iifname "br-e5d78502832d" counter packets 0 bytes 0 accept
|
||||
iifname "br-2e4a76a7cd2e" counter packets 20339 bytes 1799711 accept
|
||||
iifname "br-72fd626a8ff7" counter packets 0 bytes 0 accept
|
||||
}
|
||||
|
||||
chain DOCKER-USER {
|
||||
ip protocol tcp counter packets 3054221 bytes 3329963005 jump f2b-sshd
|
||||
ip protocol tcp counter packets 3054221 bytes 3329963005 jump f2b-recidive
|
||||
counter packets 1421378091 bytes 2045004412819 return
|
||||
}
|
||||
|
||||
chain ufw-before-logging-input {
|
||||
}
|
||||
|
||||
chain ufw-before-logging-output {
|
||||
}
|
||||
|
||||
chain ufw-before-logging-forward {
|
||||
}
|
||||
|
||||
chain ufw-before-input {
|
||||
}
|
||||
|
||||
chain ufw-before-output {
|
||||
}
|
||||
|
||||
chain ufw-before-forward {
|
||||
}
|
||||
|
||||
chain ufw-after-input {
|
||||
}
|
||||
|
||||
chain ufw-after-output {
|
||||
}
|
||||
|
||||
chain ufw-after-forward {
|
||||
}
|
||||
|
||||
chain ufw-after-logging-input {
|
||||
}
|
||||
|
||||
chain ufw-after-logging-output {
|
||||
}
|
||||
|
||||
chain ufw-after-logging-forward {
|
||||
}
|
||||
|
||||
chain ufw-reject-input {
|
||||
}
|
||||
|
||||
chain ufw-reject-output {
|
||||
}
|
||||
|
||||
chain ufw-reject-forward {
|
||||
}
|
||||
|
||||
chain ufw-track-input {
|
||||
}
|
||||
|
||||
chain ufw-track-output {
|
||||
}
|
||||
|
||||
chain ufw-track-forward {
|
||||
}
|
||||
|
||||
chain DOCKER {
|
||||
ip daddr 172.17.0.7 iifname != "docker0" oifname "docker0" tcp dport 5432 counter packets 8 bytes 480 accept
|
||||
ip daddr 172.19.0.2 iifname != "br-72fd626a8ff7" oifname "br-72fd626a8ff7" tcp dport 8080 counter packets 0 bytes 0 accept
|
||||
ip daddr 172.17.0.6 iifname != "docker0" oifname "docker0" tcp dport 9443 counter packets 0 bytes 0 accept
|
||||
ip daddr 172.17.0.6 iifname != "docker0" oifname "docker0" tcp dport 9000 counter packets 0 bytes 0 accept
|
||||
ip daddr 192.168.176.2 iifname != "br-f8b083119d99" oifname "br-f8b083119d99" tcp dport 9001 counter packets 0 bytes 0 accept
|
||||
ip daddr 192.168.176.2 iifname != "br-f8b083119d99" oifname "br-f8b083119d99" tcp dport 9000 counter packets 47769 bytes 2866140 accept
|
||||
ip daddr 172.20.0.2 iifname != "br-6eb1e7f7f847" oifname "br-6eb1e7f7f847" tcp dport 8080 counter packets 0 bytes 0 accept
|
||||
ip daddr 172.27.0.2 iifname != "br-3008d408e73a" oifname "br-3008d408e73a" tcp dport 3000 counter packets 0 bytes 0 accept
|
||||
ip daddr 192.168.48.5 iifname != "br-77eb8a9e2ba1" oifname "br-77eb8a9e2ba1" tcp dport 80 counter packets 0 bytes 0 accept
|
||||
ip daddr 192.168.48.4 iifname != "br-77eb8a9e2ba1" oifname "br-77eb8a9e2ba1" tcp dport 80 counter packets 0 bytes 0 accept
|
||||
ip daddr 192.168.48.3 iifname != "br-77eb8a9e2ba1" oifname "br-77eb8a9e2ba1" tcp dport 80 counter packets 0 bytes 0 accept
|
||||
ip daddr 192.168.48.2 iifname != "br-77eb8a9e2ba1" oifname "br-77eb8a9e2ba1" tcp dport 9000 counter packets 0 bytes 0 accept
|
||||
ip daddr 172.18.0.2 iifname != "br-2e4a76a7cd2e" oifname "br-2e4a76a7cd2e" tcp dport 80 counter packets 0 bytes 0 accept
|
||||
ip daddr 172.17.0.5 iifname != "docker0" oifname "docker0" tcp dport 80 counter packets 0 bytes 0 accept
|
||||
ip daddr 172.17.0.3 iifname != "docker0" oifname "docker0" tcp dport 8222 counter packets 0 bytes 0 accept
|
||||
ip daddr 172.17.0.3 iifname != "docker0" oifname "docker0" tcp dport 4222 counter packets 97 bytes 5744 accept
|
||||
ip daddr 172.28.0.2 iifname != "br-8ce143481a5b" oifname "br-8ce143481a5b" tcp dport 1433 counter packets 0 bytes 0 accept
|
||||
ip daddr 192.168.80.2 iifname != "br-e99ce5248c84" oifname "br-e99ce5248c84" tcp dport 8080 counter packets 0 bytes 0 accept
|
||||
ip daddr 192.168.112.3 iifname != "br-e5d78502832d" oifname "br-e5d78502832d" tcp dport 9000 counter packets 0 bytes 0 accept
|
||||
ip daddr 192.168.112.2 iifname != "br-e5d78502832d" oifname "br-e5d78502832d" tcp dport 80 counter packets 0 bytes 0 accept
|
||||
ip daddr 192.168.128.2 iifname != "br-73641cceafc3" oifname "br-73641cceafc3" tcp dport 27017 counter packets 14 bytes 840 accept
|
||||
ip daddr 192.168.208.2 iifname != "br-9fd22324ec08" oifname "br-9fd22324ec08" tcp dport 35621 counter packets 0 bytes 0 accept
|
||||
ip daddr 192.168.203.13 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 4243 counter packets 0 bytes 0 accept
|
||||
ip daddr 192.168.203.12 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 995 counter packets 194 bytes 11000 accept
|
||||
ip daddr 192.168.203.12 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 993 counter packets 188 bytes 9394 accept
|
||||
ip daddr 192.168.203.12 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 587 counter packets 444 bytes 23312 accept
|
||||
ip daddr 192.168.203.12 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 465 counter packets 104 bytes 5852 accept
|
||||
ip daddr 192.168.203.12 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 443 counter packets 0 bytes 0 accept
|
||||
ip daddr 192.168.203.12 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 143 counter packets 443 bytes 25280 accept
|
||||
ip daddr 192.168.203.12 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 110 counter packets 192 bytes 9561 accept
|
||||
ip daddr 192.168.203.12 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 80 counter packets 0 bytes 0 accept
|
||||
ip daddr 192.168.203.12 iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" tcp dport 25 counter packets 430 bytes 22919 accept
|
||||
ip daddr 172.17.0.2 iifname != "docker0" oifname "docker0" tcp dport 3000 counter packets 0 bytes 0 accept
|
||||
ip daddr 172.17.0.2 iifname != "docker0" oifname "docker0" tcp dport 22 counter packets 1578 bytes 93884 accept
|
||||
ip daddr 172.17.0.4 iifname != "docker0" oifname "docker0" tcp dport 5000 counter packets 25 bytes 1492 accept
|
||||
iifname != "br-cadedce55fe9" oifname "br-cadedce55fe9" counter packets 0 bytes 0 drop
|
||||
iifname != "br-dd007c7e67bc" oifname "br-dd007c7e67bc" counter packets 0 bytes 0 drop
|
||||
iifname != "br-a5fbc29c2c2a" oifname "br-a5fbc29c2c2a" counter packets 0 bytes 0 drop
|
||||
iifname != "br-6eb1e7f7f847" oifname "br-6eb1e7f7f847" counter packets 0 bytes 0 drop
|
||||
iifname != "br-8ce143481a5b" oifname "br-8ce143481a5b" counter packets 0 bytes 0 drop
|
||||
iifname != "br-84e7d0cfeada" oifname "br-84e7d0cfeada" counter packets 0 bytes 0 drop
|
||||
iifname != "br-f8b083119d99" oifname "br-f8b083119d99" counter packets 0 bytes 0 drop
|
||||
iifname != "br-0d1490cc67c9" oifname "br-0d1490cc67c9" counter packets 0 bytes 0 drop
|
||||
iifname != "br-3b338a381229" oifname "br-3b338a381229" counter packets 0 bytes 0 drop
|
||||
iifname != "br-3008d408e73a" oifname "br-3008d408e73a" counter packets 0 bytes 0 drop
|
||||
iifname != "br-ca07a9577a7f" oifname "br-ca07a9577a7f" counter packets 0 bytes 0 drop
|
||||
iifname != "docker0" oifname "docker0" counter packets 0 bytes 0 drop
|
||||
iifname != "br-a63fa64a9e18" oifname "br-a63fa64a9e18" counter packets 0 bytes 0 drop
|
||||
iifname != "br-1ccb887b3344" oifname "br-1ccb887b3344" counter packets 0 bytes 0 drop
|
||||
iifname != "br-9fd22324ec08" oifname "br-9fd22324ec08" counter packets 0 bytes 0 drop
|
||||
iifname != "br-73641cceafc3" oifname "br-73641cceafc3" counter packets 0 bytes 0 drop
|
||||
iifname != "br-77eb8a9e2ba1" oifname "br-77eb8a9e2ba1" counter packets 0 bytes 0 drop
|
||||
iifname != "br-e99ce5248c84" oifname "br-e99ce5248c84" counter packets 0 bytes 0 drop
|
||||
iifname != "br-e5d78502832d" oifname "br-e5d78502832d" counter packets 0 bytes 0 drop
|
||||
iifname != "br-2e4a76a7cd2e" oifname "br-2e4a76a7cd2e" counter packets 0 bytes 0 drop
|
||||
iifname != "br-72fd626a8ff7" oifname "br-72fd626a8ff7" counter packets 0 bytes 0 drop
|
||||
}
|
||||
|
||||
chain DOCKER-BRIDGE {
|
||||
oifname "br-cadedce55fe9" counter packets 0 bytes 0 jump DOCKER
|
||||
oifname "br-dd007c7e67bc" counter packets 0 bytes 0 jump DOCKER
|
||||
oifname "br-a5fbc29c2c2a" counter packets 0 bytes 0 jump DOCKER
|
||||
oifname "br-6eb1e7f7f847" counter packets 799 bytes 47940 jump DOCKER
|
||||
oifname "br-8ce143481a5b" counter packets 0 bytes 0 jump DOCKER
|
||||
oifname "br-84e7d0cfeada" counter packets 0 bytes 0 jump DOCKER
|
||||
oifname "br-f8b083119d99" counter packets 98911 bytes 5934660 jump DOCKER
|
||||
oifname "br-0d1490cc67c9" counter packets 69740 bytes 4118476 jump DOCKER
|
||||
oifname "br-3b338a381229" counter packets 32 bytes 1920 jump DOCKER
|
||||
oifname "br-3008d408e73a" counter packets 458 bytes 27480 jump DOCKER
|
||||
oifname "br-ca07a9577a7f" counter packets 0 bytes 0 jump DOCKER
|
||||
oifname "docker0" counter packets 87073 bytes 5223529 jump DOCKER
|
||||
oifname "br-a63fa64a9e18" counter packets 1353 bytes 81180 jump DOCKER
|
||||
oifname "br-1ccb887b3344" counter packets 7662 bytes 419862 jump DOCKER
|
||||
oifname "br-9fd22324ec08" counter packets 173 bytes 10380 jump DOCKER
|
||||
oifname "br-73641cceafc3" counter packets 162 bytes 9720 jump DOCKER
|
||||
oifname "br-77eb8a9e2ba1" counter packets 94 bytes 5640 jump DOCKER
|
||||
oifname "br-e99ce5248c84" counter packets 8 bytes 480 jump DOCKER
|
||||
oifname "br-e5d78502832d" counter packets 26 bytes 1560 jump DOCKER
|
||||
oifname "br-2e4a76a7cd2e" counter packets 7 bytes 420 jump DOCKER
|
||||
oifname "br-72fd626a8ff7" counter packets 0 bytes 0 jump DOCKER
|
||||
}
|
||||
|
||||
chain DOCKER-CT {
|
||||
oifname "br-cadedce55fe9" xt match "conntrack" counter packets 0 bytes 0 accept
|
||||
oifname "br-dd007c7e67bc" xt match "conntrack" counter packets 0 bytes 0 accept
|
||||
oifname "br-a5fbc29c2c2a" xt match "conntrack" counter packets 0 bytes 0 accept
|
||||
oifname "br-6eb1e7f7f847" xt match "conntrack" counter packets 38458 bytes 6234236 accept
|
||||
oifname "br-8ce143481a5b" xt match "conntrack" counter packets 60403 bytes 20478794 accept
|
||||
oifname "br-84e7d0cfeada" xt match "conntrack" counter packets 0 bytes 0 accept
|
||||
oifname "br-f8b083119d99" xt match "conntrack" counter packets 1008024 bytes 206364436 accept
|
||||
oifname "br-0d1490cc67c9" xt match "conntrack" counter packets 871134 bytes 1416174426 accept
|
||||
oifname "br-3b338a381229" xt match "conntrack" counter packets 4649 bytes 2311375 accept
|
||||
oifname "br-3008d408e73a" xt match "conntrack" counter packets 13415 bytes 1974731 accept
|
||||
oifname "br-ca07a9577a7f" xt match "conntrack" counter packets 0 bytes 0 accept
|
||||
oifname "docker0" xt match "conntrack" counter packets 8909862 bytes 7892163419 accept
|
||||
oifname "br-a63fa64a9e18" xt match "conntrack" counter packets 16688 bytes 6822829 accept
|
||||
oifname "br-1ccb887b3344" xt match "conntrack" counter packets 461453 bytes 141913887 accept
|
||||
oifname "br-9fd22324ec08" xt match "conntrack" counter packets 1677 bytes 427538 accept
|
||||
oifname "br-73641cceafc3" xt match "conntrack" counter packets 417619 bytes 35343624 accept
|
||||
oifname "br-77eb8a9e2ba1" xt match "conntrack" counter packets 125437 bytes 94155193 accept
|
||||
oifname "br-e99ce5248c84" xt match "conntrack" counter packets 91 bytes 19173 accept
|
||||
oifname "br-e5d78502832d" xt match "conntrack" counter packets 128653 bytes 40539569 accept
|
||||
oifname "br-2e4a76a7cd2e" xt match "conntrack" counter packets 20257 bytes 158631592 accept
|
||||
oifname "br-72fd626a8ff7" xt match "conntrack" counter packets 0 bytes 0 accept
|
||||
}
|
||||
|
||||
chain DOCKER-INTERNAL {
|
||||
}
|
||||
|
||||
chain f2b-recidive {
|
||||
ip saddr 2.57.122.209 counter packets 0 bytes 0 xt target "REJECT"
|
||||
ip saddr 2.57.122.76 counter packets 127 bytes 7600 xt target "REJECT"
|
||||
ip saddr 195.178.110.228 counter packets 17 bytes 1000 xt target "REJECT"
|
||||
ip saddr 2.57.122.74 counter packets 11 bytes 620 xt target "REJECT"
|
||||
ip saddr 195.178.110.26 counter packets 56 bytes 3360 xt target "REJECT"
|
||||
ip saddr 92.118.39.77 counter packets 2 bytes 80 xt target "REJECT"
|
||||
ip saddr 92.118.39.71 counter packets 1 bytes 40 xt target "REJECT"
|
||||
ip saddr 45.148.10.240 counter packets 0 bytes 0 xt target "REJECT"
|
||||
ip saddr 195.178.110.30 counter packets 8 bytes 320 xt target "REJECT"
|
||||
counter packets 948810608 bytes 1740338848565 return
|
||||
}
|
||||
|
||||
chain f2b-sshd {
|
||||
counter packets 948810709 bytes 1740338655735 return
|
||||
}
|
||||
}
|
||||
# Warning: table ip6 filter is managed by iptables-nft, do not touch!
|
||||
table ip6 filter {
|
||||
chain INPUT {
|
||||
type filter hook input priority filter; policy accept;
|
||||
counter packets 5426360 bytes 34419159588 jump ufw6-before-logging-input
|
||||
counter packets 5426360 bytes 34419159588 jump ufw6-before-input
|
||||
counter packets 367982 bytes 3415126642 jump ufw6-after-input
|
||||
counter packets 367982 bytes 3415126642 jump ufw6-after-logging-input
|
||||
counter packets 367982 bytes 3415126642 jump ufw6-reject-input
|
||||
counter packets 367982 bytes 3415126642 jump ufw6-track-input
|
||||
}
|
||||
|
||||
chain FORWARD {
|
||||
type filter hook forward priority filter; policy accept;
|
||||
counter packets 0 bytes 0 jump DOCKER-USER
|
||||
counter packets 0 bytes 0 jump DOCKER-FORWARD
|
||||
counter packets 0 bytes 0 jump ufw6-before-logging-forward
|
||||
counter packets 0 bytes 0 jump ufw6-before-forward
|
||||
counter packets 0 bytes 0 jump ufw6-after-forward
|
||||
counter packets 0 bytes 0 jump ufw6-after-logging-forward
|
||||
counter packets 0 bytes 0 jump ufw6-reject-forward
|
||||
counter packets 0 bytes 0 jump ufw6-track-forward
|
||||
}
|
||||
|
||||
chain OUTPUT {
|
||||
type filter hook output priority filter; policy accept;
|
||||
counter packets 6004354 bytes 1866587952 jump ufw6-before-logging-output
|
||||
counter packets 6004354 bytes 1866587952 jump ufw6-before-output
|
||||
counter packets 2241898 bytes 639173314 jump ufw6-after-output
|
||||
counter packets 2241898 bytes 639173314 jump ufw6-after-logging-output
|
||||
counter packets 2241898 bytes 639173314 jump ufw6-reject-output
|
||||
counter packets 2241898 bytes 639173314 jump ufw6-track-output
|
||||
}
|
||||
|
||||
chain DOCKER-FORWARD {
|
||||
counter packets 0 bytes 0 jump DOCKER-CT
|
||||
counter packets 0 bytes 0 jump DOCKER-INTERNAL
|
||||
counter packets 0 bytes 0 jump DOCKER-BRIDGE
|
||||
}
|
||||
|
||||
chain DOCKER-USER {
|
||||
counter packets 0 bytes 0 jump ufw6-user-forward
|
||||
xt match "conntrack" counter packets 0 bytes 0 return
|
||||
xt match "conntrack" counter packets 0 bytes 0 drop
|
||||
iifname "docker0" oifname "docker0" counter packets 0 bytes 0 accept
|
||||
ip6 saddr fd00::/8 counter packets 0 bytes 0 return
|
||||
ip6 daddr fd00::/8 xt match "conntrack" counter packets 0 bytes 0 jump ufw6-docker-logging-deny
|
||||
counter packets 0 bytes 0 return
|
||||
}
|
||||
|
||||
chain ufw6-before-logging-input {
|
||||
}
|
||||
|
||||
chain ufw6-before-logging-output {
|
||||
}
|
||||
|
||||
chain ufw6-before-logging-forward {
|
||||
}
|
||||
|
||||
chain ufw6-before-input {
|
||||
}
|
||||
|
||||
chain ufw6-before-output {
|
||||
}
|
||||
|
||||
chain ufw6-before-forward {
|
||||
}
|
||||
|
||||
chain ufw6-after-input {
|
||||
}
|
||||
|
||||
chain ufw6-after-output {
|
||||
}
|
||||
|
||||
chain ufw6-after-forward {
|
||||
}
|
||||
|
||||
chain ufw6-after-logging-input {
|
||||
}
|
||||
|
||||
chain ufw6-after-logging-output {
|
||||
}
|
||||
|
||||
chain ufw6-after-logging-forward {
|
||||
}
|
||||
|
||||
chain ufw6-reject-input {
|
||||
}
|
||||
|
||||
chain ufw6-reject-output {
|
||||
}
|
||||
|
||||
chain ufw6-reject-forward {
|
||||
}
|
||||
|
||||
chain ufw6-track-input {
|
||||
}
|
||||
|
||||
chain ufw6-track-output {
|
||||
}
|
||||
|
||||
chain ufw6-track-forward {
|
||||
}
|
||||
|
||||
chain ufw6-user-forward {
|
||||
}
|
||||
|
||||
chain ufw6-docker-logging-deny {
|
||||
limit rate 3/minute burst 10 packets counter packets 0 bytes 0 xt target "LOG"
|
||||
counter packets 0 bytes 0 drop
|
||||
}
|
||||
|
||||
chain DOCKER {
|
||||
}
|
||||
|
||||
chain DOCKER-BRIDGE {
|
||||
}
|
||||
|
||||
chain DOCKER-CT {
|
||||
}
|
||||
|
||||
chain DOCKER-INTERNAL {
|
||||
}
|
||||
}
|
||||
# Warning: table ip nat is managed by iptables-nft, do not touch!
|
||||
table ip nat {
|
||||
chain PREROUTING {
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
xt match "addrtype" counter packets 10757093 bytes 647829087 jump DOCKER
|
||||
}
|
||||
|
||||
chain OUTPUT {
|
||||
type nat hook output priority dstnat; policy accept;
|
||||
ip daddr != 127.0.0.0/8 xt match "addrtype" counter packets 128611 bytes 7705178 jump DOCKER
|
||||
}
|
||||
|
||||
chain POSTROUTING {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
ip saddr 172.19.0.0/16 oifname != "br-72fd626a8ff7" counter packets 0 bytes 0 xt target "MASQUERADE"
|
||||
ip saddr 172.18.0.0/16 oifname != "br-2e4a76a7cd2e" counter packets 825 bytes 49500 xt target "MASQUERADE"
|
||||
ip saddr 192.168.112.0/20 oifname != "br-e5d78502832d" counter packets 126 bytes 7560 xt target "MASQUERADE"
|
||||
ip saddr 192.168.80.0/20 oifname != "br-e99ce5248c84" counter packets 0 bytes 0 xt target "MASQUERADE"
|
||||
ip saddr 192.168.48.0/20 oifname != "br-77eb8a9e2ba1" counter packets 99 bytes 5940 xt target "MASQUERADE"
|
||||
ip saddr 192.168.128.0/20 oifname != "br-73641cceafc3" counter packets 536 bytes 32160 xt target "MASQUERADE"
|
||||
ip saddr 192.168.208.0/20 oifname != "br-9fd22324ec08" counter packets 2 bytes 120 xt target "MASQUERADE"
|
||||
ip saddr 192.168.203.0/24 oifname != "br-1ccb887b3344" counter packets 37248 bytes 2854476 xt target "MASQUERADE"
|
||||
ip saddr 192.168.64.0/20 oifname != "br-a63fa64a9e18" counter packets 353 bytes 21180 xt target "MASQUERADE"
|
||||
ip saddr 172.17.0.0/16 oifname != "docker0" counter packets 99933 bytes 6001436 xt target "MASQUERADE"
|
||||
ip saddr 172.21.0.0/16 oifname != "br-84e7d0cfeada" counter packets 2 bytes 128 xt target "MASQUERADE"
|
||||
ip saddr 192.168.176.0/20 oifname != "br-f8b083119d99" counter packets 209 bytes 12644 xt target "MASQUERADE"
|
||||
ip saddr 172.20.0.0/16 oifname != "br-6eb1e7f7f847" counter packets 699 bytes 42516 xt target "MASQUERADE"
|
||||
ip saddr 172.28.0.0/16 oifname != "br-8ce143481a5b" counter packets 1934 bytes 116040 xt target "MASQUERADE"
|
||||
ip saddr 172.27.0.0/16 oifname != "br-3008d408e73a" counter packets 2904 bytes 174240 xt target "MASQUERADE"
|
||||
ip saddr 172.25.0.0/16 oifname != "br-cadedce55fe9" counter packets 0 bytes 0 xt target "MASQUERADE"
|
||||
ip saddr 172.24.0.0/16 oifname != "br-3b338a381229" counter packets 385 bytes 23164 xt target "MASQUERADE"
|
||||
ip saddr 192.168.224.0/20 oifname != "br-ca07a9577a7f" counter packets 0 bytes 0 xt target "MASQUERADE"
|
||||
ip saddr 192.168.0.0/20 oifname != "br-a5fbc29c2c2a" counter packets 10 bytes 600 xt target "MASQUERADE"
|
||||
ip saddr 172.31.0.0/16 oifname != "br-dd007c7e67bc" counter packets 0 bytes 0 xt target "MASQUERADE"
|
||||
ip saddr 192.168.240.0/20 oifname != "br-0d1490cc67c9" counter packets 587045 bytes 35224163 xt target "MASQUERADE"
|
||||
}
|
||||
|
||||
chain DOCKER {
|
||||
iifname != "docker0" tcp dport 5100 counter packets 8247 bytes 494812 xt target "DNAT"
|
||||
iifname != "docker0" tcp dport 222 counter packets 2304 bytes 137444 xt target "DNAT"
|
||||
iifname != "docker0" tcp dport 20000 counter packets 1532 bytes 91584 xt target "DNAT"
|
||||
iifname != "br-1ccb887b3344" tcp dport 25 counter packets 433 bytes 23099 xt target "DNAT"
|
||||
iifname != "br-1ccb887b3344" tcp dport 7080 counter packets 35 bytes 1864 xt target "DNAT"
|
||||
iifname != "br-1ccb887b3344" tcp dport 110 counter packets 195 bytes 9741 xt target "DNAT"
|
||||
iifname != "br-1ccb887b3344" tcp dport 143 counter packets 448 bytes 25580 xt target "DNAT"
|
||||
iifname != "br-1ccb887b3344" tcp dport 7443 counter packets 58 bytes 2868 xt target "DNAT"
|
||||
iifname != "br-1ccb887b3344" tcp dport 465 counter packets 107 bytes 6032 xt target "DNAT"
|
||||
iifname != "br-1ccb887b3344" tcp dport 587 counter packets 447 bytes 23492 xt target "DNAT"
|
||||
iifname != "br-1ccb887b3344" tcp dport 993 counter packets 201 bytes 10174 xt target "DNAT"
|
||||
iifname != "br-1ccb887b3344" tcp dport 995 counter packets 197 bytes 11180 xt target "DNAT"
|
||||
iifname != "br-1ccb887b3344" tcp dport 20004 counter packets 5 bytes 300 xt target "DNAT"
|
||||
iifname != "br-9fd22324ec08" tcp dport 20005 counter packets 5 bytes 300 xt target "DNAT"
|
||||
iifname != "br-73641cceafc3" tcp dport 20006 counter packets 19 bytes 1140 xt target "DNAT"
|
||||
iifname != "br-e5d78502832d" tcp dport 20007 counter packets 5 bytes 284 xt target "DNAT"
|
||||
iifname != "br-e5d78502832d" tcp dport 20008 counter packets 4 bytes 240 xt target "DNAT"
|
||||
iifname != "br-e99ce5248c84" tcp dport 1842 counter packets 12 bytes 720 xt target "DNAT"
|
||||
iifname != "br-8ce143481a5b" tcp dport 4848 counter packets 40 bytes 1960 xt target "DNAT"
|
||||
iifname != "docker0" tcp dport 4222 counter packets 3846 bytes 231012 xt target "DNAT"
|
||||
ip daddr 127.0.0.1 iifname != "docker0" tcp dport 8222 counter packets 0 bytes 0 xt target "DNAT"
|
||||
iifname != "docker0" tcp dport 20003 counter packets 18942 bytes 1136512 xt target "DNAT"
|
||||
iifname != "br-2e4a76a7cd2e" tcp dport 9070 counter packets 195 bytes 11676 xt target "DNAT"
|
||||
iifname != "br-77eb8a9e2ba1" tcp dport 9102 counter packets 13 bytes 772 xt target "DNAT"
|
||||
iifname != "br-77eb8a9e2ba1" tcp dport 8102 counter packets 17 bytes 944 xt target "DNAT"
|
||||
iifname != "br-77eb8a9e2ba1" tcp dport 8104 counter packets 16 bytes 916 xt target "DNAT"
|
||||
iifname != "br-77eb8a9e2ba1" tcp dport 8103 counter packets 13 bytes 756 xt target "DNAT"
|
||||
iifname != "br-3008d408e73a" tcp dport 1212 counter packets 189 bytes 11188 xt target "DNAT"
|
||||
iifname != "br-6eb1e7f7f847" tcp dport 20009 counter packets 138 bytes 8280 xt target "DNAT"
|
||||
iifname != "br-f8b083119d99" tcp dport 20001 counter packets 47780 bytes 2866736 xt target "DNAT"
|
||||
iifname != "br-f8b083119d99" tcp dport 20002 counter packets 7 bytes 404 xt target "DNAT"
|
||||
iifname != "docker0" tcp dport 20010 counter packets 74 bytes 4424 xt target "DNAT"
|
||||
iifname != "docker0" tcp dport 20011 counter packets 4 bytes 240 xt target "DNAT"
|
||||
iifname != "br-72fd626a8ff7" tcp dport 20012 counter packets 237 bytes 14220 xt target "DNAT"
|
||||
iifname != "docker0" tcp dport 6852 counter packets 16489 bytes 989324 xt target "DNAT"
|
||||
}
|
||||
}
|
||||
# Warning: table ip6 nat is managed by iptables-nft, do not touch!
|
||||
table ip6 nat {
|
||||
chain PREROUTING {
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
xt match "addrtype" counter packets 399 bytes 22104 jump DOCKER
|
||||
}
|
||||
|
||||
chain OUTPUT {
|
||||
type nat hook output priority dstnat; policy accept;
|
||||
ip6 daddr != ::1 xt match "addrtype" counter packets 0 bytes 0 jump DOCKER
|
||||
}
|
||||
|
||||
chain DOCKER {
|
||||
}
|
||||
}
|
||||
table ip raw {
|
||||
chain PREROUTING {
|
||||
type filter hook prerouting priority raw; policy accept;
|
||||
ip daddr 127.0.0.1 iifname != "lo" tcp dport 8222 counter packets 0 bytes 0 drop
|
||||
}
|
||||
}
|
||||
table ip mangle {
|
||||
chain FORWARD {
|
||||
type filter hook forward priority mangle; policy accept;
|
||||
}
|
||||
}
|
||||
table inet mesh {
|
||||
chain input {
|
||||
type filter hook input priority filter; policy drop;
|
||||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
iif "lo" accept
|
||||
iifname != { "mesh0", "enp9s0" } accept
|
||||
icmp type echo-request accept
|
||||
icmpv6 type { echo-request, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept
|
||||
iifname != { "mesh0", "enp9s0" } udp dport { 53, 67 } accept
|
||||
iifname != { "mesh0", "enp9s0" } tcp dport 53 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 22 accept
|
||||
tcp dport 22 accept
|
||||
tcp dport 4222 accept
|
||||
tcp dport 22 accept
|
||||
tcp dport 25 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 53 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } udp dport 53 accept
|
||||
tcp dport 80 accept
|
||||
tcp dport 110 accept
|
||||
tcp dport 143 accept
|
||||
tcp dport 222 accept
|
||||
tcp dport 443 accept
|
||||
tcp dport 465 accept
|
||||
tcp dport 587 accept
|
||||
tcp dport 993 accept
|
||||
tcp dport 995 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 1212 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 1842 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 4222 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 4848 accept
|
||||
tcp dport 5100 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 6852 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 7080 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 7443 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 8102 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 8103 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 8104 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 9000 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 9070 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 9102 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20000 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20001 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20002 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20003 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20004 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20005 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20006 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20007 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20008 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20009 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20010 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20011 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 20012 accept
|
||||
udp dport 51820 accept
|
||||
}
|
||||
|
||||
chain output {
|
||||
type filter hook output priority filter; policy accept;
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy drop;
|
||||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
iifname != { "mesh0", "enp9s0" } accept
|
||||
iifname "mesh0" oifname "mesh0" accept
|
||||
ct original proto-dst 22 accept
|
||||
ct original proto-dst 25 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 53 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 53 accept
|
||||
ct original proto-dst 80 accept
|
||||
ct original proto-dst 110 accept
|
||||
ct original proto-dst 143 accept
|
||||
ct original proto-dst 222 accept
|
||||
ct original proto-dst 443 accept
|
||||
ct original proto-dst 465 accept
|
||||
ct original proto-dst 587 accept
|
||||
ct original proto-dst 993 accept
|
||||
ct original proto-dst 995 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 1212 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 1842 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 4222 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 4848 accept
|
||||
ct original proto-dst 5100 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 6852 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 7080 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 7443 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 8102 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 8103 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 8104 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 9000 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 9070 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 9102 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20000 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20001 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20002 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20003 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20004 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20005 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20006 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20007 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20008 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20009 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20010 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20011 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 20012 accept
|
||||
ct original proto-dst 51820 accept
|
||||
ct original proto-dst 4222 accept
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
-P INPUT ACCEPT
|
||||
-P FORWARD DROP
|
||||
-P OUTPUT ACCEPT
|
||||
-N DOCKER
|
||||
-N DOCKER-BRIDGE
|
||||
-N DOCKER-CT
|
||||
-N DOCKER-FORWARD
|
||||
-N DOCKER-INTERNAL
|
||||
-N DOCKER-USER
|
||||
-N HAL-MESH-ONLY
|
||||
-N ufw-after-forward
|
||||
-N ufw-after-input
|
||||
-N ufw-after-logging-forward
|
||||
-N ufw-after-logging-input
|
||||
-N ufw-after-logging-output
|
||||
-N ufw-after-output
|
||||
-N ufw-before-forward
|
||||
-N ufw-before-input
|
||||
-N ufw-before-logging-forward
|
||||
-N ufw-before-logging-input
|
||||
-N ufw-before-logging-output
|
||||
-N ufw-before-output
|
||||
-N ufw-reject-forward
|
||||
-N ufw-reject-input
|
||||
-N ufw-reject-output
|
||||
-N ufw-track-forward
|
||||
-N ufw-track-input
|
||||
-N ufw-track-output
|
||||
-A INPUT -j ufw-before-logging-input
|
||||
-A INPUT -j ufw-before-input
|
||||
-A INPUT -j ufw-after-input
|
||||
-A INPUT -j ufw-after-logging-input
|
||||
-A INPUT -j ufw-reject-input
|
||||
-A INPUT -j ufw-track-input
|
||||
-A FORWARD -j DOCKER-USER
|
||||
-A FORWARD -j DOCKER-FORWARD
|
||||
-A FORWARD -j ufw-before-logging-forward
|
||||
-A FORWARD -j ufw-before-forward
|
||||
-A FORWARD -j ufw-after-forward
|
||||
-A FORWARD -j ufw-after-logging-forward
|
||||
-A FORWARD -j ufw-reject-forward
|
||||
-A FORWARD -j ufw-track-forward
|
||||
-A OUTPUT -j ufw-before-logging-output
|
||||
-A OUTPUT -j ufw-before-output
|
||||
-A OUTPUT -j ufw-after-output
|
||||
-A OUTPUT -j ufw-after-logging-output
|
||||
-A OUTPUT -j ufw-reject-output
|
||||
-A OUTPUT -j ufw-track-output
|
||||
-A DOCKER -d 172.17.0.18/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8686 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.14/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8989 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.15/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 7878 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.5/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 9117 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.13/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 6789 -j ACCEPT
|
||||
-A DOCKER -d 172.19.0.2/32 ! -i br-32062158f584 -o br-32062158f584 -p tcp -m tcp --dport 8080 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 5432 -j ACCEPT
|
||||
-A DOCKER -d 172.27.0.2/32 ! -i br-0910a98c6158 -o br-0910a98c6158 -p tcp -m tcp --dport 5678 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.21/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 3579 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.19/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8181 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.17/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8787 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.16/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 6767 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.12/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 3000 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.11/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 80 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.10/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 9443 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.10/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 9000 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.9/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 3000 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.7/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 1880 -j ACCEPT
|
||||
-A DOCKER -d 172.28.0.2/32 ! -i br-b11461b5b028 -o br-b11461b5b028 -p tcp -m tcp --dport 80 -j ACCEPT
|
||||
-A DOCKER -d 172.23.0.14/32 ! -i br-66ffa5c1cba5 -o br-66ffa5c1cba5 -p tcp -m tcp --dport 6543 -j ACCEPT
|
||||
-A DOCKER -d 172.23.0.14/32 ! -i br-66ffa5c1cba5 -o br-66ffa5c1cba5 -p tcp -m tcp --dport 5432 -j ACCEPT
|
||||
-A DOCKER -d 172.23.0.5/32 ! -i br-66ffa5c1cba5 -o br-66ffa5c1cba5 -p tcp -m tcp --dport 8000 -j ACCEPT
|
||||
-A DOCKER -d 172.26.0.3/32 ! -i br-b0fec361ccaa -o br-b0fec361ccaa -p tcp -m tcp --dport 6167 -j ACCEPT
|
||||
-A DOCKER -d 172.26.0.2/32 ! -i br-b0fec361ccaa -o br-b0fec361ccaa -p tcp -m tcp --dport 80 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.8/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8000 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p udp -m udp --dport 10001 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8880 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8843 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8443 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8080 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 6789 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p udp -m udp --dport 5514 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p udp -m udp --dport 3478 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.6/32 ! -i docker0 -o docker0 -p udp -m udp --dport 1900 -j ACCEPT
|
||||
-A DOCKER -d 172.25.0.3/32 ! -i br-b98821f7dc38 -o br-b98821f7dc38 -p tcp -m tcp --dport 8000 -j ACCEPT
|
||||
-A DOCKER -d 172.18.0.3/32 ! -i br-442a0bfc65f8 -o br-442a0bfc65f8 -p tcp -m tcp --dport 1433 -j ACCEPT
|
||||
-A DOCKER -d 172.20.0.3/32 ! -i br-afa37ac8b33d -o br-afa37ac8b33d -p tcp -m tcp --dport 8081 -j ACCEPT
|
||||
-A DOCKER -d 172.20.0.3/32 ! -i br-afa37ac8b33d -o br-afa37ac8b33d -p tcp -m tcp --dport 1883 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.4/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8086 -j ACCEPT
|
||||
-A DOCKER -d 172.21.0.2/32 ! -i br-df15d8e19ec7 -o br-df15d8e19ec7 -p tcp -m tcp --dport 6379 -j ACCEPT
|
||||
-A DOCKER -d 172.30.0.3/32 ! -i br-521eab9a3a5e -o br-521eab9a3a5e -p tcp -m tcp --dport 8283 -j ACCEPT
|
||||
-A DOCKER -d 172.17.0.3/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 3000 -j ACCEPT
|
||||
-A DOCKER ! -i br-32062158f584 -o br-32062158f584 -j DROP
|
||||
-A DOCKER ! -i docker0 -o docker0 -j DROP
|
||||
-A DOCKER ! -i br-521eab9a3a5e -o br-521eab9a3a5e -j DROP
|
||||
-A DOCKER ! -i br-df15d8e19ec7 -o br-df15d8e19ec7 -j DROP
|
||||
-A DOCKER ! -i br-afa37ac8b33d -o br-afa37ac8b33d -j DROP
|
||||
-A DOCKER ! -i br-442a0bfc65f8 -o br-442a0bfc65f8 -j DROP
|
||||
-A DOCKER ! -i br-b98821f7dc38 -o br-b98821f7dc38 -j DROP
|
||||
-A DOCKER ! -i br-b0fec361ccaa -o br-b0fec361ccaa -j DROP
|
||||
-A DOCKER ! -i br-66ffa5c1cba5 -o br-66ffa5c1cba5 -j DROP
|
||||
-A DOCKER ! -i br-b11461b5b028 -o br-b11461b5b028 -j DROP
|
||||
-A DOCKER ! -i br-2df4e541b877 -o br-2df4e541b877 -j DROP
|
||||
-A DOCKER ! -i br-0910a98c6158 -o br-0910a98c6158 -j DROP
|
||||
-A DOCKER-BRIDGE -o br-32062158f584 -j DOCKER
|
||||
-A DOCKER-BRIDGE -o docker0 -j DOCKER
|
||||
-A DOCKER-BRIDGE -o br-521eab9a3a5e -j DOCKER
|
||||
-A DOCKER-BRIDGE -o br-df15d8e19ec7 -j DOCKER
|
||||
-A DOCKER-BRIDGE -o br-afa37ac8b33d -j DOCKER
|
||||
-A DOCKER-BRIDGE -o br-442a0bfc65f8 -j DOCKER
|
||||
-A DOCKER-BRIDGE -o br-b98821f7dc38 -j DOCKER
|
||||
-A DOCKER-BRIDGE -o br-b0fec361ccaa -j DOCKER
|
||||
-A DOCKER-BRIDGE -o br-66ffa5c1cba5 -j DOCKER
|
||||
-A DOCKER-BRIDGE -o br-b11461b5b028 -j DOCKER
|
||||
-A DOCKER-BRIDGE -o br-2df4e541b877 -j DOCKER
|
||||
-A DOCKER-BRIDGE -o br-0910a98c6158 -j DOCKER
|
||||
-A DOCKER-CT -o br-32062158f584 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||
-A DOCKER-CT -o docker0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||
-A DOCKER-CT -o br-521eab9a3a5e -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||
-A DOCKER-CT -o br-df15d8e19ec7 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||
-A DOCKER-CT -o br-afa37ac8b33d -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||
-A DOCKER-CT -o br-442a0bfc65f8 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||
-A DOCKER-CT -o br-b98821f7dc38 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||
-A DOCKER-CT -o br-b0fec361ccaa -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||
-A DOCKER-CT -o br-66ffa5c1cba5 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||
-A DOCKER-CT -o br-b11461b5b028 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||
-A DOCKER-CT -o br-2df4e541b877 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||
-A DOCKER-CT -o br-0910a98c6158 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||
-A DOCKER-FORWARD -j DOCKER-CT
|
||||
-A DOCKER-FORWARD -j DOCKER-INTERNAL
|
||||
-A DOCKER-FORWARD -j DOCKER-BRIDGE
|
||||
-A DOCKER-FORWARD -i br-32062158f584 -j ACCEPT
|
||||
-A DOCKER-FORWARD -i docker0 -j ACCEPT
|
||||
-A DOCKER-FORWARD -i br-521eab9a3a5e -j ACCEPT
|
||||
-A DOCKER-FORWARD -i br-df15d8e19ec7 -j ACCEPT
|
||||
-A DOCKER-FORWARD -i br-afa37ac8b33d -j ACCEPT
|
||||
-A DOCKER-FORWARD -i br-442a0bfc65f8 -j ACCEPT
|
||||
-A DOCKER-FORWARD -i br-b98821f7dc38 -j ACCEPT
|
||||
-A DOCKER-FORWARD -i br-b0fec361ccaa -j ACCEPT
|
||||
-A DOCKER-FORWARD -i br-66ffa5c1cba5 -j ACCEPT
|
||||
-A DOCKER-FORWARD -i br-b11461b5b028 -j ACCEPT
|
||||
-A DOCKER-FORWARD -i br-2df4e541b877 -j ACCEPT
|
||||
-A DOCKER-FORWARD -i br-0910a98c6158 -j ACCEPT
|
||||
-A DOCKER-USER -i enp6s0 -p tcp -m conntrack --ctstate NEW -j HAL-MESH-ONLY
|
||||
-A HAL-MESH-ONLY -m conntrack --ctorigdstport 6881 -j RETURN
|
||||
-A HAL-MESH-ONLY -m conntrack --ctorigdstport 80 -j RETURN
|
||||
-A HAL-MESH-ONLY -m conntrack --ctorigdstport 443 -j RETURN
|
||||
-A HAL-MESH-ONLY -s 10.0.0.0/8 -j RETURN
|
||||
-A HAL-MESH-ONLY -s 172.16.0.0/12 -j RETURN
|
||||
-A HAL-MESH-ONLY -s 192.168.0.0/16 -j RETURN
|
||||
-A HAL-MESH-ONLY -m comment --comment "HAL: not public -> mesh only" -j DROP
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
table ip mangle {
|
||||
chain FORWARD {
|
||||
type filter hook forward priority mangle; policy accept;
|
||||
}
|
||||
}
|
||||
# Warning: table ip nat is managed by iptables-nft, do not touch!
|
||||
table ip nat {
|
||||
chain PREROUTING {
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
xt match "addrtype" counter packets 12072 bytes 4564241 jump DOCKER
|
||||
}
|
||||
|
||||
chain OUTPUT {
|
||||
type nat hook output priority dstnat; policy accept;
|
||||
ip daddr != 127.0.0.0/8 xt match "addrtype" counter packets 1854 bytes 111240 jump DOCKER
|
||||
}
|
||||
|
||||
chain POSTROUTING {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
ip saddr 172.17.0.0/16 oifname != "docker0" counter packets 903 bytes 61577 xt target "MASQUERADE"
|
||||
ip saddr 172.21.0.0/16 oifname != "br-86a5d6b30e2b" counter packets 344 bytes 27744 xt target "MASQUERADE"
|
||||
ip saddr 172.25.0.0/16 oifname != "br-61495e14a004" counter packets 374 bytes 33016 xt target "MASQUERADE"
|
||||
ip saddr 172.30.0.0/16 oifname != "br-5107796ee9b4" counter packets 352 bytes 28224 xt target "MASQUERADE"
|
||||
ip saddr 172.18.0.0/16 oifname != "br-cfd337ac4e58" counter packets 339 bytes 27444 xt target "MASQUERADE"
|
||||
ip saddr 172.19.0.0/16 oifname != "br-8f0c6ee01425" counter packets 351 bytes 28164 xt target "MASQUERADE"
|
||||
ip saddr 172.22.0.0/16 oifname != "br-75ac3c36e87f" counter packets 333 bytes 27084 xt target "MASQUERADE"
|
||||
ip saddr 172.20.0.0/16 oifname != "br-0529801521bc" counter packets 343 bytes 27404 xt target "MASQUERADE"
|
||||
}
|
||||
|
||||
chain DOCKER {
|
||||
iifname != "br-61495e14a004" tcp dport 5680 counter packets 2 bytes 120 xt target "DNAT"
|
||||
ip daddr 127.0.0.1 iifname != "br-61495e14a004" tcp dport 15673 counter packets 0 bytes 0 xt target "DNAT"
|
||||
ip daddr 127.0.0.1 iifname != "docker0" tcp dport 55432 counter packets 0 bytes 0 xt target "DNAT"
|
||||
}
|
||||
}
|
||||
# Warning: table ip filter is managed by iptables-nft, do not touch!
|
||||
table ip filter {
|
||||
chain DOCKER-FORWARD {
|
||||
counter packets 702328 bytes 1801910999 jump DOCKER-CT
|
||||
counter packets 337315 bytes 23928864 jump DOCKER-INTERNAL
|
||||
counter packets 337315 bytes 23928864 jump DOCKER-BRIDGE
|
||||
iifname "br-75ac3c36e87f" counter packets 0 bytes 0 accept
|
||||
iifname "br-86a5d6b30e2b" counter packets 0 bytes 0 accept
|
||||
iifname "br-8f0c6ee01425" counter packets 0 bytes 0 accept
|
||||
iifname "br-cfd337ac4e58" counter packets 0 bytes 0 accept
|
||||
iifname "br-0529801521bc" counter packets 0 bytes 0 accept
|
||||
iifname "br-5107796ee9b4" counter packets 0 bytes 0 accept
|
||||
iifname "br-61495e14a004" counter packets 0 bytes 0 accept
|
||||
iifname "docker0" counter packets 337315 bytes 23928864 accept
|
||||
}
|
||||
|
||||
chain FORWARD {
|
||||
type filter hook forward priority filter; policy drop;
|
||||
counter packets 702328 bytes 1801910999 jump DOCKER-USER
|
||||
counter packets 702328 bytes 1801910999 jump DOCKER-FORWARD
|
||||
}
|
||||
|
||||
chain DOCKER-USER {
|
||||
ip protocol tcp counter packets 702510 bytes 1801965868 jump f2b-sshd
|
||||
oifname "incusbr0" counter packets 0 bytes 0 accept
|
||||
iifname "incusbr0" counter packets 0 bytes 0 accept
|
||||
}
|
||||
|
||||
chain f2b-sshd {
|
||||
counter packets 10423854 bytes 13891318049 return
|
||||
}
|
||||
|
||||
chain INPUT {
|
||||
type filter hook input priority filter; policy accept;
|
||||
ip protocol tcp counter packets 9721344 bytes 12089352181 jump f2b-sshd
|
||||
}
|
||||
|
||||
chain DOCKER {
|
||||
ip daddr 172.17.0.2 iifname != "docker0" oifname "docker0" tcp dport 5432 counter packets 0 bytes 0 accept
|
||||
ip daddr 172.25.0.2 iifname != "br-61495e14a004" oifname "br-61495e14a004" tcp dport 15672 counter packets 0 bytes 0 accept
|
||||
ip daddr 172.25.0.2 iifname != "br-61495e14a004" oifname "br-61495e14a004" tcp dport 5672 counter packets 0 bytes 0 accept
|
||||
iifname != "br-75ac3c36e87f" oifname "br-75ac3c36e87f" counter packets 0 bytes 0 drop
|
||||
iifname != "br-86a5d6b30e2b" oifname "br-86a5d6b30e2b" counter packets 0 bytes 0 drop
|
||||
iifname != "br-8f0c6ee01425" oifname "br-8f0c6ee01425" counter packets 0 bytes 0 drop
|
||||
iifname != "br-cfd337ac4e58" oifname "br-cfd337ac4e58" counter packets 0 bytes 0 drop
|
||||
iifname != "br-0529801521bc" oifname "br-0529801521bc" counter packets 0 bytes 0 drop
|
||||
iifname != "br-5107796ee9b4" oifname "br-5107796ee9b4" counter packets 0 bytes 0 drop
|
||||
iifname != "br-61495e14a004" oifname "br-61495e14a004" counter packets 0 bytes 0 drop
|
||||
iifname != "docker0" oifname "docker0" counter packets 0 bytes 0 drop
|
||||
}
|
||||
|
||||
chain DOCKER-BRIDGE {
|
||||
oifname "br-75ac3c36e87f" counter packets 0 bytes 0 jump DOCKER
|
||||
oifname "br-86a5d6b30e2b" counter packets 0 bytes 0 jump DOCKER
|
||||
oifname "br-8f0c6ee01425" counter packets 0 bytes 0 jump DOCKER
|
||||
oifname "br-cfd337ac4e58" counter packets 0 bytes 0 jump DOCKER
|
||||
oifname "br-0529801521bc" counter packets 0 bytes 0 jump DOCKER
|
||||
oifname "br-5107796ee9b4" counter packets 0 bytes 0 jump DOCKER
|
||||
oifname "br-61495e14a004" counter packets 0 bytes 0 jump DOCKER
|
||||
oifname "docker0" counter packets 0 bytes 0 jump DOCKER
|
||||
}
|
||||
|
||||
chain DOCKER-CT {
|
||||
oifname "br-75ac3c36e87f" xt match "conntrack" counter packets 0 bytes 0 accept
|
||||
oifname "br-86a5d6b30e2b" xt match "conntrack" counter packets 0 bytes 0 accept
|
||||
oifname "br-8f0c6ee01425" xt match "conntrack" counter packets 0 bytes 0 accept
|
||||
oifname "br-cfd337ac4e58" xt match "conntrack" counter packets 0 bytes 0 accept
|
||||
oifname "br-0529801521bc" xt match "conntrack" counter packets 0 bytes 0 accept
|
||||
oifname "br-5107796ee9b4" xt match "conntrack" counter packets 0 bytes 0 accept
|
||||
oifname "br-61495e14a004" xt match "conntrack" counter packets 0 bytes 0 accept
|
||||
oifname "docker0" xt match "conntrack" counter packets 365013 bytes 1777982135 accept
|
||||
}
|
||||
|
||||
chain DOCKER-INTERNAL {
|
||||
}
|
||||
}
|
||||
# Warning: table ip6 nat is managed by iptables-nft, do not touch!
|
||||
table ip6 nat {
|
||||
chain PREROUTING {
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
xt match "addrtype" counter packets 363 bytes 67927 jump DOCKER
|
||||
}
|
||||
|
||||
chain OUTPUT {
|
||||
type nat hook output priority dstnat; policy accept;
|
||||
ip6 daddr != ::1 xt match "addrtype" counter packets 0 bytes 0 jump DOCKER
|
||||
}
|
||||
|
||||
chain DOCKER {
|
||||
}
|
||||
}
|
||||
table ip6 filter {
|
||||
chain DOCKER-FORWARD {
|
||||
counter packets 0 bytes 0 jump DOCKER-CT
|
||||
counter packets 0 bytes 0 jump DOCKER-INTERNAL
|
||||
counter packets 0 bytes 0 jump DOCKER-BRIDGE
|
||||
}
|
||||
|
||||
chain FORWARD {
|
||||
type filter hook forward priority filter; policy accept;
|
||||
counter packets 0 bytes 0 jump DOCKER-USER
|
||||
counter packets 0 bytes 0 jump DOCKER-FORWARD
|
||||
}
|
||||
|
||||
chain DOCKER-USER {
|
||||
}
|
||||
|
||||
chain DOCKER {
|
||||
}
|
||||
|
||||
chain DOCKER-BRIDGE {
|
||||
}
|
||||
|
||||
chain DOCKER-CT {
|
||||
}
|
||||
|
||||
chain DOCKER-INTERNAL {
|
||||
}
|
||||
}
|
||||
table ip raw {
|
||||
chain PREROUTING {
|
||||
type filter hook prerouting priority raw; policy accept;
|
||||
ip daddr 172.25.0.2 iifname != "br-61495e14a004" counter packets 0 bytes 0 drop
|
||||
ip daddr 127.0.0.1 iifname != "lo" tcp dport 15673 counter packets 0 bytes 0 drop
|
||||
ip daddr 172.17.0.2 iifname != "docker0" counter packets 0 bytes 0 drop
|
||||
ip daddr 127.0.0.1 iifname != "lo" tcp dport 55432 counter packets 0 bytes 0 drop
|
||||
}
|
||||
}
|
||||
table inet incus {
|
||||
set bridges {
|
||||
type ifname
|
||||
elements = { "incusbr0" }
|
||||
}
|
||||
|
||||
chain pstrt.incusbr0 {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
ip saddr 10.7.169.0/24 oifname @bridges accept
|
||||
ip saddr 10.7.169.0/24 ip daddr != 10.7.169.0/24 masquerade
|
||||
ip6 saddr fd42:cbc4:e123:f6::/64 oifname @bridges accept
|
||||
ip6 saddr fd42:cbc4:e123:f6::/64 ip6 daddr != fd42:cbc4:e123:f6::/64 masquerade
|
||||
}
|
||||
|
||||
chain fwd.incusbr0 {
|
||||
type filter hook forward priority filter; policy accept;
|
||||
ip version 4 oifname "incusbr0" accept
|
||||
ip version 4 iifname "incusbr0" accept
|
||||
ip6 version 6 oifname "incusbr0" accept
|
||||
ip6 version 6 iifname "incusbr0" accept
|
||||
}
|
||||
|
||||
chain in.incusbr0 {
|
||||
type filter hook input priority filter; policy accept;
|
||||
iifname "incusbr0" tcp dport 53 accept
|
||||
iifname "incusbr0" udp dport 53 accept
|
||||
iifname "incusbr0" icmp type { destination-unreachable, time-exceeded, parameter-problem } accept
|
||||
iifname "incusbr0" udp dport 67 accept
|
||||
iifname "incusbr0" ip protocol udp udp checksum set 0
|
||||
iifname "incusbr0" icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-router-solicit, nd-neighbor-solicit, nd-neighbor-advert, mld2-listener-report } accept
|
||||
iifname "incusbr0" udp dport 547 accept
|
||||
}
|
||||
|
||||
chain out.incusbr0 {
|
||||
type filter hook output priority filter; policy accept;
|
||||
oifname "incusbr0" tcp sport 53 accept
|
||||
oifname "incusbr0" udp sport 53 accept
|
||||
oifname "incusbr0" icmp type { destination-unreachable, time-exceeded, parameter-problem } accept
|
||||
oifname "incusbr0" udp sport 67 accept
|
||||
oifname "incusbr0" ip protocol udp udp checksum set 0
|
||||
oifname "incusbr0" icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, echo-request, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, mld2-listener-report } accept
|
||||
oifname "incusbr0" udp sport 547 accept
|
||||
}
|
||||
}
|
||||
table ip fct_filter {
|
||||
chain OUTPUT {
|
||||
type filter hook output priority filter; policy accept;
|
||||
}
|
||||
|
||||
chain FCT-QUARANTINE-EMS {
|
||||
}
|
||||
|
||||
chain FCT-QUARANTINE-FAZ {
|
||||
}
|
||||
|
||||
chain FORWARD {
|
||||
type filter hook forward priority filter; policy accept;
|
||||
}
|
||||
|
||||
chain FCT-WEBFILTER-QUIC-CHAIN {
|
||||
}
|
||||
|
||||
chain INPUT {
|
||||
type filter hook input priority filter; policy accept;
|
||||
}
|
||||
|
||||
chain FCT-QUARANTINE {
|
||||
}
|
||||
|
||||
chain FCT-DNS-QUIC-FILTER {
|
||||
}
|
||||
|
||||
chain FCT-VPN-CHAIN {
|
||||
}
|
||||
}
|
||||
table ip fct_nat {
|
||||
chain OUTPUT {
|
||||
type nat hook output priority dstnat; policy accept;
|
||||
}
|
||||
|
||||
chain FCT-DNS-UDP-CHAIN-STAGE-2 {
|
||||
}
|
||||
|
||||
chain FCT-DNS-UDP-CHAIN-STAGE-1 {
|
||||
}
|
||||
|
||||
chain FCT-TCP-CHAIN {
|
||||
}
|
||||
|
||||
chain FCT-DNS-DOH-CHAIN-STAGE-1 {
|
||||
}
|
||||
|
||||
chain FCT-WEBFILTER-CHAIN {
|
||||
}
|
||||
|
||||
chain FCT-DNS-DOH-CHAIN-STAGE-2 {
|
||||
}
|
||||
}
|
||||
table ip6 fct_filter {
|
||||
chain FCT-QUARANTINE {
|
||||
}
|
||||
|
||||
chain INPUT {
|
||||
type filter hook input priority filter; policy accept;
|
||||
}
|
||||
|
||||
chain FORWARD {
|
||||
type filter hook forward priority filter; policy accept;
|
||||
}
|
||||
|
||||
chain OUTPUT {
|
||||
type filter hook output priority filter; policy accept;
|
||||
}
|
||||
}
|
||||
table ip fct_mangle {
|
||||
chain PREROUTING {
|
||||
type filter hook prerouting priority mangle; policy accept;
|
||||
}
|
||||
|
||||
chain FCT-UDP-STAGE-1 {
|
||||
}
|
||||
|
||||
chain OUTPUT {
|
||||
type route hook output priority mangle; policy accept;
|
||||
}
|
||||
|
||||
chain FCT-UDP-STAGE-2 {
|
||||
}
|
||||
|
||||
chain FCT-UDP-OUTPUT {
|
||||
}
|
||||
}
|
||||
table inet mesh {
|
||||
chain input {
|
||||
type filter hook input priority filter; policy drop;
|
||||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
iif "lo" accept
|
||||
iifname != { "mesh0", "wlp3s0" } accept
|
||||
icmp type echo-request accept
|
||||
icmpv6 type { echo-request, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept
|
||||
iifname != { "mesh0", "wlp3s0" } udp dport { 53, 67 } accept
|
||||
iifname != { "mesh0", "wlp3s0" } tcp dport 53 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 22 accept
|
||||
tcp dport 22 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } tcp dport 53 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } udp dport 53 accept
|
||||
}
|
||||
|
||||
chain output {
|
||||
type filter hook output priority filter; policy accept;
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy drop;
|
||||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
iifname != { "mesh0", "wlp3s0" } accept
|
||||
iifname "mesh0" oifname "mesh0" accept
|
||||
ct original proto-dst 22 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 53 accept
|
||||
ip saddr { 10.10.0.1, 10.10.0.2, 10.10.0.3, 10.10.0.4 } ct original proto-dst 53 accept
|
||||
}
|
||||
}
|
||||
@@ -49,8 +49,13 @@ type Identity struct {
|
||||
Membership Membership `json:"membership"`
|
||||
|
||||
// Overlay is this node's key on the private network. Generated here, like the identity above,
|
||||
// and for the same reason: the mesh computes a graph it cannot impersonate.
|
||||
// and for the same reason: the mesh computes a graph it cannot impersonate — or, on an adopted
|
||||
// node that took a found tunnel over, that tunnel's key (novox/hq ADR 0105).
|
||||
Overlay OverlayKey `json:"overlay"`
|
||||
// OverlayBefore is the public half of the overlay key this node held before it took a found
|
||||
// tunnel's, so a take run again names the key the mesh still records. Empty on a node that
|
||||
// never took one.
|
||||
OverlayBefore string `json:"overlay_before,omitempty"`
|
||||
}
|
||||
|
||||
// Membership is how this node reaches the mesh it belongs to, and who it believes.
|
||||
@@ -71,6 +76,11 @@ type Membership struct {
|
||||
// Not the token's secret: that is spent, and a credential that lives for ever should not be
|
||||
// the same string as one that was meant to be used once.
|
||||
Password string `json:"password"`
|
||||
|
||||
// Transport is which bus this membership is for. Empty is the bus the mesh ran on before
|
||||
// the move — so every membership written before this field existed reads as correct, not as
|
||||
// unset — and "nats" is the one being moved to (novox/hq design 28, task 5.2).
|
||||
Transport string `json:"transport,omitempty"`
|
||||
}
|
||||
|
||||
// Queue is where this node listens. Its account may read this and nothing else.
|
||||
|
||||
@@ -409,3 +409,26 @@ func TestATokenSaysWhatTheMeshCallsThisMachine(t *testing.T) {
|
||||
t.Fatalf("the name did not survive the token: %q", token.Node)
|
||||
}
|
||||
}
|
||||
|
||||
// A token through the tunnel carries the one peer, in the field names the control plane writes
|
||||
// (novox/hq ADR 0169), and an incomplete tunnel is refused naming what is missing.
|
||||
func TestATokenThroughTheTunnelParsesAndAPartOneIsRefused(t *testing.T) {
|
||||
whole := map[string]any{"v": 1, "node": "n", "broker": "10.42.0.1:4222", "fingerprint": "sha256:x",
|
||||
"signer": make([]byte, 32), "secret": "s",
|
||||
"tunnel": map[string]any{"key": "k", "address": "10.42.0.9/32", "range": "10.42.0.0/16",
|
||||
"hub_key": "h", "hub_endpoint": "198.51.100.1:51820"}}
|
||||
raw, _ := json.Marshal(whole)
|
||||
got, err := ParseToken(base64.RawURLEncoding.EncodeToString(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Tunnel == nil || got.Tunnel.HubEndpoint != "198.51.100.1:51820" || got.Tunnel.Range != "10.42.0.0/16" {
|
||||
t.Fatalf("the tunnel was not read: %+v", got.Tunnel)
|
||||
}
|
||||
whole["tunnel"] = map[string]any{"key": "k"}
|
||||
raw, _ = json.Marshal(whole)
|
||||
if _, err := ParseToken(base64.RawURLEncoding.EncodeToString(raw)); err == nil ||
|
||||
!strings.Contains(err.Error(), "the hub's tunnel key") {
|
||||
t.Fatalf("a token with half a tunnel was taken: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The node's key on the private network, which is a different key from the one that says who it
|
||||
@@ -43,6 +45,40 @@ func GenerateOverlayKey() (OverlayKey, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OverlayKeyFrom makes this node's overlay key from a private key it did not generate: the found
|
||||
// tunnel's, on an adopted node whose private network takes that tunnel over (novox/hq ADR 0105).
|
||||
// The one case where the mesh takes a credential it did not mint. From here on it is stored and
|
||||
// sealed exactly as a generated one — in the identity file and the key file, readable by root
|
||||
// alone — and the mesh receives only the public half, derived here from the private one so the
|
||||
// two cannot disagree.
|
||||
func OverlayKeyFrom(privateBase64 string) (OverlayKey, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(privateBase64)
|
||||
if err != nil {
|
||||
return OverlayKey{}, fmt.Errorf("the found tunnel's private key is not base64: %w", err)
|
||||
}
|
||||
private, err := ecdh.X25519().NewPrivateKey(raw)
|
||||
if err != nil {
|
||||
return OverlayKey{}, fmt.Errorf("the found tunnel's private key is not a Curve25519 key: %w", err)
|
||||
}
|
||||
return OverlayKey{
|
||||
Public: base64.StdEncoding.EncodeToString(private.PublicKey().Bytes()),
|
||||
Private: base64.StdEncoding.EncodeToString(private.Bytes()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LoadOverlayKey reads the key `key` made and left in its file (novox/hq ADR 0169).
|
||||
func LoadOverlayKey(path string) (OverlayKey, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return OverlayKey{}, err
|
||||
}
|
||||
key, err := OverlayKeyFrom(strings.TrimSpace(string(raw)))
|
||||
if err != nil {
|
||||
return OverlayKey{}, fmt.Errorf("%s does not hold a tunnel key: %w", path, err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// OverlayKeyPath is where the private half lives: a file of its own, referenced by the interface
|
||||
// configuration rather than embedded in it.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0105: the found tunnel's private key becomes the node's overlay key, stored as a
|
||||
// generated one is, and the public half the mesh records is derived from it — so the peers that
|
||||
// know the tunnel by that key keep reaching it.
|
||||
func TestAnOverlayKeyTakenFromAFoundTunnelIsTheSameKey(t *testing.T) {
|
||||
generated, err := GenerateOverlayKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
taken, err := OverlayKeyFrom(generated.Private)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taken.Public != generated.Public || taken.Private != generated.Private {
|
||||
t.Fatalf("a key taken from a private half is not that key: %+v vs %+v", taken, generated)
|
||||
}
|
||||
for _, bad := range []string{"", "not base64!", "c2hvcnQ="} {
|
||||
if _, err := OverlayKeyFrom(bad); err == nil || !strings.Contains(err.Error(), "found tunnel") {
|
||||
t.Errorf("%q was taken as a key: %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,21 @@ type Token struct {
|
||||
// firewall found here before enrolling, because an adopted node keeps that firewall in force.
|
||||
// Absent for a converged node.
|
||||
Adopted bool `json:"adopted,omitempty"`
|
||||
|
||||
// Tunnel is this machine's first tunnel, when the token was issued for the key it made with
|
||||
// `key` (novox/hq ADR 0169): its own address and the hub to reach. It brings the tunnel up from
|
||||
// this alone and reaches the bus over it, so the bus never has to face the internet.
|
||||
Tunnel *TokenTunnel `json:"tunnel,omitempty"`
|
||||
}
|
||||
|
||||
// TokenTunnel is the joining machine's side of its first tunnel. Field names are the wire format
|
||||
// the control plane writes.
|
||||
type TokenTunnel struct {
|
||||
Key string `json:"key"`
|
||||
Address string `json:"address"`
|
||||
Range string `json:"range"`
|
||||
HubKey string `json:"hub_key"`
|
||||
HubEndpoint string `json:"hub_endpoint"`
|
||||
}
|
||||
|
||||
// ParseToken reads a token a person pasted.
|
||||
@@ -70,6 +85,17 @@ func ParseToken(encoded string) (Token, error) {
|
||||
if strings.TrimSpace(t.Secret) == "" {
|
||||
missing = append(missing, "the one-time secret")
|
||||
}
|
||||
if tt := t.Tunnel; tt != nil {
|
||||
for _, part := range []struct{ value, says string }{
|
||||
{tt.Key, "the tunnel key it was issued for"}, {tt.Address, "this machine's address"},
|
||||
{tt.Range, "the private network's range"}, {tt.HubKey, "the hub's tunnel key"},
|
||||
{tt.HubEndpoint, "where the hub's tunnel is dialled"},
|
||||
} {
|
||||
if strings.TrimSpace(part.value) == "" {
|
||||
missing = append(missing, part.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
// Refused whole rather than used partially. A token missing the fingerprint would have
|
||||
// this node connect to whatever answers at that address, and one missing the signing key
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The enrolment conversation, as the host's own words for it.
|
||||
//
|
||||
// **Its own seam rather than part of Link**, because almost nothing about it is the same. The
|
||||
// credential is a one-time secret rather than this node's own; there is no declaration to hear; the
|
||||
// whole exchange is a single question asked and possibly asked again. And the stakes differ: a node
|
||||
// that fails here is not in the mesh at all, where a node that fails in Link has merely lost touch
|
||||
// with one it belongs to.
|
||||
|
||||
// Approach is how a node reaches a mesh it does not yet belong to.
|
||||
//
|
||||
// The three things a token carries about where to go, and nothing about who is asking: the address,
|
||||
// the certificate that address must present, and which bus is at the other end — the mesh's own
|
||||
// (hearing.go), and a token naming any other is refused before anything is sent.
|
||||
type Approach struct {
|
||||
Address string
|
||||
Fingerprint string
|
||||
Transport string
|
||||
}
|
||||
|
||||
// Asking is one open enrolment conversation.
|
||||
type Asking interface {
|
||||
// Ask puts the request to the mesh and waits for one answer, or says why none came.
|
||||
//
|
||||
// Called again, with the same bytes, while the mesh says "try again": the keys this node
|
||||
// generated are the ones it keeps, so the same request is the same enrolment and the mesh holds
|
||||
// the token for it (novox/hq issue 083).
|
||||
Ask(ctx context.Context, request []byte, wait time.Duration) ([]byte, error)
|
||||
|
||||
// Close lets go of the connection made with the token.
|
||||
Close()
|
||||
}
|
||||
|
||||
// Present opens an enrolment conversation with the mesh.
|
||||
//
|
||||
// The connection is made before anything is sent, and the certificate is checked while it is being
|
||||
// made — so a node pointed at the wrong bus finds out before its token has left the machine (ADR
|
||||
// 0004).
|
||||
func Present(ctx context.Context, to Approach, node, secret string,
|
||||
timeout time.Duration) (Asking, error) {
|
||||
|
||||
if to.Transport != OnNATS {
|
||||
return nil, fmt.Errorf("this token is for the %q bus, and the mesh's bus is %s", to.Transport, OnNATS)
|
||||
}
|
||||
return presentNats(ctx, to, node, secret, timeout)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// The enrolment conversation on the bus being built.
|
||||
//
|
||||
// **The reply address is the whole of what changes**, and it changes for a reason the transport
|
||||
// forces rather than a preference. Core NATS request/reply puts the caller's inbox in the message's
|
||||
// reply field and a plain responder answers it — but this request goes into a stream, and a message
|
||||
// a JetStream consumer delivers has had that field claimed for the consumer's own ack address. So by
|
||||
// the time the controller reads the request, the transport's reply field names where the
|
||||
// *controller* must acknowledge. Verified against a running server (design 25 §2).
|
||||
//
|
||||
// The address therefore travels as a field of the request, and this subscribes it before publishing:
|
||||
// a node that published first could miss an answer to a question nobody was listening for.
|
||||
|
||||
// enrolInbox is where a node enrolling waits.
|
||||
//
|
||||
// Under `_INBOX.enrol.<node>.`, which is exactly what its enrolment user may subscribe and no
|
||||
// wider — so an answer sealed to one machine cannot be read by another enrolling beside it. The
|
||||
// random tail is this attempt's own: a reply left over from an attempt that timed out is not the
|
||||
// answer to this question, which is what the correlation id does on the other transport.
|
||||
func enrolInbox(node string) (string, error) {
|
||||
tail := make([]byte, 8)
|
||||
if _, err := rand.Read(tail); err != nil {
|
||||
return "", fmt.Errorf("cannot make a reply address: %w", err)
|
||||
}
|
||||
return "_INBOX.enrol." + node + "." + hex.EncodeToString(tail), nil
|
||||
}
|
||||
|
||||
type natsAsking struct {
|
||||
conn *nats.Conn
|
||||
js nats.JetStreamContext
|
||||
inbox string
|
||||
answers *nats.Subscription
|
||||
lost chan error
|
||||
}
|
||||
|
||||
func presentNats(_ context.Context, to Approach, node, secret string,
|
||||
timeout time.Duration) (Asking, error) {
|
||||
|
||||
config, err := PinnedConfig(to.Fingerprint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
inbox, err := enrolInbox(node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lost := make(chan error, 1)
|
||||
// The user is this token's own — `enrol.<node>`, which may publish the enrolment subject and
|
||||
// subscribe its own inbox and nothing else (design 25 §6). The secret is its password, the same
|
||||
// string the request claims, so the server proves somebody holds the token and the request
|
||||
// proves the same thing to the controller without it having to ask the server who connected.
|
||||
// **Its own inbox space, because that is the only one it may listen in** (novox/hq
|
||||
// 04-ISSUES/146). A JetStream publish waits for the stream's acknowledgement on an inbox the
|
||||
// client picks, and the client's default is `_INBOX.<random>` — which this user may not
|
||||
// subscribe to, so the enrolment failed with a permissions violation on a subject nobody had
|
||||
// chosen. The permission is `_INBOX.enrol.<node>.>` (design 25 §6), so the client is told to
|
||||
// pick its inboxes there; the reply address below is in the same space for the same reason.
|
||||
conn, err := nats.Connect(natsURL(to.Address),
|
||||
nats.Secure(config),
|
||||
nats.CustomInboxPrefix("_INBOX.enrol."+node),
|
||||
nats.UserInfo("enrol."+node, secret),
|
||||
nats.Name("mesh-host/enrol/"+node),
|
||||
nats.Timeout(timeout),
|
||||
nats.NoReconnect(),
|
||||
nats.DisconnectErrHandler(func(_ *nats.Conn, err error) {
|
||||
select {
|
||||
case lost <- fmt.Errorf("the bus closed the connection: %w", err):
|
||||
default:
|
||||
}
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrWrongCertificate) {
|
||||
return nil, err
|
||||
}
|
||||
// Not quoted back with the credential: the secret is one-time and still a secret.
|
||||
return nil, fmt.Errorf("cannot reach the bus at %s as %s: %w", to.Address, node, err)
|
||||
}
|
||||
js, err := conn.JetStream()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("the bus at %s has no JetStream: %w", to.Address, err)
|
||||
}
|
||||
|
||||
// Subscribed before anything is published, so an answer cannot arrive before there is anywhere
|
||||
// for it to land.
|
||||
answers, err := conn.SubscribeSync(inbox)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("this node cannot listen for the mesh's answer: %w", err)
|
||||
}
|
||||
if err := conn.Flush(); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("this node's reply address did not reach the bus: %w", err)
|
||||
}
|
||||
|
||||
return &natsAsking{conn: conn, js: js, inbox: inbox, answers: answers, lost: lost}, nil
|
||||
}
|
||||
|
||||
func (a *natsAsking) Close() {
|
||||
if a.answers != nil {
|
||||
_ = a.answers.Unsubscribe()
|
||||
}
|
||||
if a.conn != nil {
|
||||
a.conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Ask publishes the request with this attempt's reply address written into it, and waits there.
|
||||
func (a *natsAsking) Ask(ctx context.Context, request []byte, wait time.Duration) ([]byte, error) {
|
||||
addressed, err := withReplyTo(request, a.inbox)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
publish, cancel := context.WithTimeout(ctx, wait)
|
||||
defer cancel()
|
||||
// Into the stream and awaited: an enrolment the bus never accepted must fail here rather than be
|
||||
// assumed, because the node has nothing else to go on.
|
||||
//
|
||||
// **Once, however many times it is sent** (novox/hq 04-ISSUES/146). The client re-publishes when
|
||||
// an acknowledgement is slow, and the mesh enrolled the machine on each copy — minting a second
|
||||
// credential, which replaced the first, which is the one the node had already been given. The
|
||||
// machine then reconnected for ever as a user whose password the mesh had rotated out from under
|
||||
// it, and the controller's log said "enrolled anchor" twice in the same second.
|
||||
//
|
||||
// The id is the message: the same bytes carry the same id, so the stream discards the client's
|
||||
// own retry, and a genuine second attempt — which carries a new reply address — is a different
|
||||
// message and is let through.
|
||||
sum := sha256.Sum256(addressed)
|
||||
if _, err := a.js.Publish(EnrolSubject, addressed,
|
||||
nats.MsgId(hex.EncodeToString(sum[:])), nats.Context(publish)); err != nil {
|
||||
return nil, fmt.Errorf("cannot ask the mesh to enrol this node: %w", err)
|
||||
}
|
||||
|
||||
// Waited for rather than assumed. A published message that nothing answers means the controller
|
||||
// is not running, and a node that carried on regardless would believe it had joined a mesh that
|
||||
// has never heard of it.
|
||||
//
|
||||
// **The wait may legitimately be several store-window cycles long**: the controller naks the
|
||||
// request with a delay while its store is restarting, and the node is waiting on the other side
|
||||
// of that — which is exactly the combination that would have delivered the answer to a caller
|
||||
// who had given up, had the address travelled in the transport's field.
|
||||
answered, cancelAnswer := context.WithTimeout(ctx, wait)
|
||||
defer cancelAnswer()
|
||||
for {
|
||||
msg, err := a.answers.NextMsgWithContext(answered)
|
||||
switch {
|
||||
case err == nil:
|
||||
return msg.Data, nil
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
select {
|
||||
case reason := <-a.lost:
|
||||
return nil, reason
|
||||
default:
|
||||
}
|
||||
return nil, fmt.Errorf(
|
||||
"the bus accepted this node's connection and nothing answered within %s. The mesh's "+
|
||||
"bus is running and its controller is not", wait)
|
||||
case errors.Is(err, context.Canceled):
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
select {
|
||||
case reason := <-a.lost:
|
||||
return nil, reason
|
||||
default:
|
||||
}
|
||||
return nil, fmt.Errorf("waiting for the mesh's answer: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// The enrolment round trip against a real server.
|
||||
//
|
||||
// **This is the test that keeps a reason from becoming folklore.** The reply address travels in the
|
||||
// request's payload because a JetStream consumer's delivery has had the transport's reply field
|
||||
// claimed for its own ack address — which is a fact about a server, not a rule anybody can check by
|
||||
// reading. Both halves are asserted here: that the field really is eaten, and that the answer
|
||||
// reaches the node anyway.
|
||||
//
|
||||
// docker run -d --rm --name t -p 14223:4222 nats:2.10-alpine -js
|
||||
// MESH_TEST_NATS=nats://127.0.0.1:14223 go test ./internal/link/ -run TestNatsAnEnrolment
|
||||
|
||||
// asking is a conversation on a bus with no TLS. Built directly rather than through Present because
|
||||
// the pin is what Present adds and PinnedConfig's own tests cover it; what is under test here is the
|
||||
// address the answer comes back on.
|
||||
func asking(t *testing.T, conn *nats.Conn, js nats.JetStreamContext, node string) *natsAsking {
|
||||
t.Helper()
|
||||
inbox, err := enrolInbox(node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answers, err := conn.SubscribeSync(inbox)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := conn.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := &natsAsking{conn: conn, js: js, inbox: inbox, answers: answers, lost: make(chan error, 1)}
|
||||
t.Cleanup(a.Close)
|
||||
return a
|
||||
}
|
||||
|
||||
// theMeshAnswers stands in for the controller: it consumes the enrolment off the stream, reads the
|
||||
// reply address out of the payload — never from the transport field — and answers there. It reports
|
||||
// what the transport field actually held, which is the claim design 25 §2 rests on.
|
||||
func theMeshAnswers(t *testing.T, conn *nats.Conn, js nats.JetStreamContext,
|
||||
reply EnrolReply) <-chan string {
|
||||
t.Helper()
|
||||
sawReplyField := make(chan string, 1)
|
||||
sub, err := js.Subscribe(EnrolSubject, func(msg *nats.Msg) {
|
||||
select {
|
||||
case sawReplyField <- msg.Reply:
|
||||
default:
|
||||
}
|
||||
var addressed struct {
|
||||
ReplyTo string `json:"reply_to"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Data, &addressed); err != nil || addressed.ReplyTo == "" {
|
||||
_ = msg.Ack()
|
||||
return
|
||||
}
|
||||
body, _ := json.Marshal(reply)
|
||||
// Published explicitly to the address the payload named, never msg.Respond — which would
|
||||
// send it to whatever the transport's reply field holds, and that is the point.
|
||||
_ = conn.Publish(addressed.ReplyTo, body)
|
||||
_ = msg.Ack()
|
||||
}, nats.Durable("controller-standin"), nats.ManualAck())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
return sawReplyField
|
||||
}
|
||||
|
||||
// An enrolment is answered on the address the request carried, and the transport's own reply field
|
||||
// held something else entirely.
|
||||
func TestNatsAnEnrolmentIsAnsweredOnTheAddressInItsPayload(t *testing.T) {
|
||||
conn, js := aBus(t)
|
||||
const node = "joining"
|
||||
|
||||
sawReplyField := theMeshAnswers(t, conn, js, EnrolReply{Accepted: true, Node: node, Password: "p"})
|
||||
a := asking(t, conn, js, node)
|
||||
|
||||
request, _ := json.Marshal(EnrolRequest{Node: node, Secret: "t"})
|
||||
answer, err := a.Ask(context.Background(), request, 8*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("no answer reached the node: %v", err)
|
||||
}
|
||||
var reply EnrolReply
|
||||
if err := json.Unmarshal(answer, &reply); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reply.Accepted || reply.Node != node {
|
||||
t.Fatalf("the answer was not the mesh's: %+v", reply)
|
||||
}
|
||||
|
||||
// And the field the answer would have gone to, had it used the transport's: the consumer's own
|
||||
// ack address. If a future server stopped doing this, the payload-borne address would still
|
||||
// work and this line is what would say the reason had changed.
|
||||
select {
|
||||
case field := <-sawReplyField:
|
||||
if field == a.inbox {
|
||||
t.Fatalf("the transport's reply field held this node's inbox (%s), so the payload "+
|
||||
"address is no longer load-bearing — check design 25 §2 before relying on it", field)
|
||||
}
|
||||
if field == "" {
|
||||
t.Fatal("the transport's reply field was empty rather than claimed, which is a third " +
|
||||
"behaviour from the two design 25 §2 describes")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the stand-in never saw the request")
|
||||
}
|
||||
}
|
||||
|
||||
// A request that names no reply address is not answered, and the node says so as a mesh that is not
|
||||
// running rather than hanging. The controller has nowhere to send an answer, which is the failure
|
||||
// the payload field exists to make impossible — asserted so that a request built without it fails
|
||||
// loudly here rather than quietly on a machine.
|
||||
func TestNatsAnEnrolmentWithNoReplyAddressIsNotAnswered(t *testing.T) {
|
||||
conn, js := aBus(t)
|
||||
const node = "silent"
|
||||
|
||||
theMeshAnswers(t, conn, js, EnrolReply{Accepted: true, Node: node})
|
||||
a := asking(t, conn, js, node)
|
||||
|
||||
// Published without going through Ask, so the reply address is genuinely absent.
|
||||
request, _ := json.Marshal(EnrolRequest{Node: node, Secret: "t"})
|
||||
if _, err := js.Publish(EnrolSubject, request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.Ask(context.Background(), []byte(`{"node":"`+node+`"}`), 0); err == nil {
|
||||
t.Fatal("a node with no answer coming was told it had one")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// Bus is what a host needs of the mesh's bus, in the mesh's own words.
|
||||
//
|
||||
// A host says exactly two things unprompted: what it applied, and that it is here. They are not
|
||||
// the same kind of statement and the difference is the whole of this interface — one must arrive
|
||||
// and one must not be insisted on.
|
||||
//
|
||||
// **The host still imports nothing of the mesh's own** (novox/hq ADR 0005): this is its own
|
||||
// interface over its own client libraries, not a contract shared with the controller. The two
|
||||
// agree because a conformance fixture holds them to one envelope, which is the only kind of
|
||||
// agreement that survives being in different repositories.
|
||||
type Bus interface {
|
||||
// Report says what this node applied. **It must arrive.** A report that fails leaves the
|
||||
// mesh believing the node never answered while the node believes it did, and the two go on
|
||||
// disagreeing with nothing anywhere saying so — the shape of fault this project keeps
|
||||
// finding. Returns false when it could not be delivered, so the caller can say so.
|
||||
Report(ctx context.Context, node string, body []byte) error
|
||||
|
||||
// Alive says this node is here, and nothing else. **Losing one is nothing**: the next is a
|
||||
// minute away and the mesh reads a gap rather than counting arrivals. Insisting on delivery
|
||||
// would turn a harmless miss into a logged failure every minute.
|
||||
Alive(ctx context.Context, node string, body []byte) error
|
||||
}
|
||||
|
||||
// --- The bus the mesh runs on today -----------------------------------------------------------
|
||||
|
||||
// OverNATS is the bus as a connection. A report goes through JetStream because it must survive
|
||||
// the controller's store restarting; a heartbeat does not, because it must not.
|
||||
type OverNATS struct {
|
||||
Conn *nats.Conn
|
||||
JS nats.JetStreamContext
|
||||
}
|
||||
|
||||
// ReportSubject and AliveSubject are this node's own, and no other node's: a host's account may
|
||||
// publish `mesh.control.<its own node>.>` and nothing wider, so the subject is the authority on
|
||||
// which node a report is about.
|
||||
func ReportSubject(node string) string { return "mesh.control." + node + ".report" }
|
||||
func AliveSubject(node string) string { return "mesh.control." + node + ".alive" }
|
||||
|
||||
func (b OverNATS) Report(ctx context.Context, node string, body []byte) error {
|
||||
// Into the CONTROL stream and awaited: this is the message the store-window guarantee is
|
||||
// about (novox/hq ADR 0083). The controller naks with a delay while its store is away and
|
||||
// the message is redelivered; a publish the bus never accepted must fail here rather than
|
||||
// be assumed.
|
||||
if _, err := b.JS.Publish(ReportSubject(node), body, nats.Context(ctx)); err != nil {
|
||||
return fmt.Errorf("reporting: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b OverNATS) Alive(ctx context.Context, node string, body []byte) error {
|
||||
// Core, deliberately: a heartbeat in a stream is the mesh's least valuable message competing
|
||||
// for retention with its most valuable, and a lost one is the next one.
|
||||
if err := b.Conn.Publish(AliveSubject(node), body); err != nil {
|
||||
return err
|
||||
}
|
||||
// Flushed rather than fired and forgotten, so "could not tell the mesh" means the write
|
||||
// failed rather than that nobody has looked yet.
|
||||
flush, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
return b.Conn.FlushWithContext(flush)
|
||||
}
|
||||
+76
-115
@@ -6,10 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
// The wire format shared with the control plane, which defines it separately because this binary
|
||||
@@ -52,6 +49,41 @@ type EnrolRequest struct {
|
||||
// holds the private half of PublicKey. The mesh asks for it before letting an enrolment finish
|
||||
// on a token this key already spent (novox/hq issue 083).
|
||||
Proof []byte `json:"proof,omitempty"`
|
||||
|
||||
// ReplyTo is where the mesh's answer goes, as a field of the request rather than the
|
||||
// transport's own reply address (design 25 §2). Written by the transport that needs it —
|
||||
// withReplyTo, once per attempt — because a request going into a stream has had the transport's
|
||||
// reply field claimed for the consumer's ack address before the controller ever reads it.
|
||||
//
|
||||
// Empty on the bus the mesh runs on today, where the delivery carries the reply queue and the
|
||||
// field means what it has always meant. Named here so both sides of the wire hold the same
|
||||
// field name, which is what the shape test on each side is for.
|
||||
ReplyTo string `json:"reply_to,omitempty"`
|
||||
|
||||
// Tunnel is the tunnel this node found and whose key it took as its overlay key (novox/hq ADR
|
||||
// 0105): everything about it but that key. Sent with the keys because it is one of them —
|
||||
// OverlayKey above IS this tunnel's public key when this is set — and the mesh composes the
|
||||
// hub's address, the range and the carried peers from it before the first declaration.
|
||||
Tunnel *Tunnel `json:"tunnel,omitempty"`
|
||||
}
|
||||
|
||||
// Tunnel is a found tunnel as it travels: no private key.
|
||||
type Tunnel struct {
|
||||
Interface string `json:"interface"`
|
||||
Unit string `json:"unit"`
|
||||
Config string `json:"config"`
|
||||
Port int `json:"port"`
|
||||
Address string `json:"address"`
|
||||
Range string `json:"range"`
|
||||
MTU int `json:"mtu,omitempty"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Peers []TunnelPeer `json:"peers,omitempty"`
|
||||
}
|
||||
|
||||
// TunnelPeer is one peer of a found tunnel: its key, and the address the tunnel routes to it.
|
||||
type TunnelPeer struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
// EnrolReply is what the mesh says back.
|
||||
@@ -123,136 +155,65 @@ func answered(reply EnrolReply, asking time.Duration) (again bool, err error) {
|
||||
// was issued and the secret is its password. So this is not how the node gets in — it is what it
|
||||
// says once it is in, and the secret travels again because the control plane must not have to ask
|
||||
// the broker who connected.
|
||||
func Enrol(ctx context.Context, address, pin, node, secret string, public []byte,
|
||||
func Enrol(ctx context.Context, to Approach, node, secret string, public []byte,
|
||||
overlayKey, sealingKey, servingKey string, profile map[string]any, proof []byte,
|
||||
timeout time.Duration) (EnrolReply, error) {
|
||||
tunnel *Tunnel, timeout time.Duration) (EnrolReply, error) {
|
||||
|
||||
config, err := PinnedConfig(pin)
|
||||
if err != nil {
|
||||
return EnrolReply{}, err
|
||||
}
|
||||
|
||||
// The account name is the node's, and the password is the token's secret. Escaped because a
|
||||
// name or secret containing a colon or an at-sign would otherwise change which host this
|
||||
// connects to — a credential silently redirecting a connection is the worst shape this could
|
||||
// take.
|
||||
dsn := fmt.Sprintf("amqps://%s:%s@%s/",
|
||||
url.QueryEscape(node), url.QueryEscape(secret), address)
|
||||
|
||||
conn, err := amqp.DialConfig(dsn, amqp.Config{
|
||||
TLSClientConfig: config,
|
||||
Dial: amqp.DefaultDial(timeout),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrWrongCertificate) {
|
||||
return EnrolReply{}, err
|
||||
}
|
||||
// Not quoted back: the DSN carries the one-time secret.
|
||||
return EnrolReply{}, fmt.Errorf("cannot reach the broker at %s as %s: %w", address, node, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
channel, err := conn.Channel()
|
||||
if err != nil {
|
||||
return EnrolReply{}, err
|
||||
}
|
||||
defer channel.Close()
|
||||
|
||||
// This node's own queue, which its account is scoped to and nothing else may read.
|
||||
queue, err := channel.QueueDeclare(QueueFor(node), true, false, false, false, nil)
|
||||
if err != nil {
|
||||
return EnrolReply{}, fmt.Errorf(
|
||||
"cannot declare this node's queue %s: %w", QueueFor(node), err)
|
||||
}
|
||||
|
||||
replies, err := channel.Consume(queue.Name, "", true, false, false, false, nil)
|
||||
asking, err := Present(ctx, to, node, secret, timeout)
|
||||
if err != nil {
|
||||
return EnrolReply{}, err
|
||||
}
|
||||
defer asking.Close()
|
||||
|
||||
request := EnrolRequest{Node: node, Secret: secret, PublicKey: public,
|
||||
OverlayKey: overlayKey, SealingKey: sealingKey, ServingKey: servingKey, Profile: profile,
|
||||
Proof: proof}
|
||||
Proof: proof, Tunnel: tunnel}
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return EnrolReply{}, err
|
||||
}
|
||||
|
||||
// Asked, and asked again with the same request while the mesh says "try again": the keys
|
||||
// this node generated are the ones it keeps, so the same request is the same enrolment, and
|
||||
// the mesh holds the token for it (novox/hq issue 083).
|
||||
ask := func() (string, error) {
|
||||
correlation := fmt.Sprintf("%s-%d", node, time.Now().UnixNano())
|
||||
publish, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
if err := channel.PublishWithContext(publish, Exchange, KeyEnrol, false, false,
|
||||
amqp.Publishing{
|
||||
ContentType: "application/json",
|
||||
CorrelationId: correlation,
|
||||
ReplyTo: queue.Name,
|
||||
Body: body,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("cannot publish to the %s exchange: %w", Exchange, err)
|
||||
}
|
||||
return correlation, nil
|
||||
}
|
||||
correlation, err := ask()
|
||||
if err != nil {
|
||||
return EnrolReply{}, err
|
||||
}
|
||||
// Asked, and asked again with the same request while the mesh says "try again": the keys this
|
||||
// node generated are the ones it keeps, so the same request is the same enrolment, and the mesh
|
||||
// holds the token for it (novox/hq issue 083).
|
||||
began := time.Now()
|
||||
|
||||
// Waited for rather than assumed. A published message that nothing answers means the control
|
||||
// plane is not running, and a node that carried on regardless would believe it had joined a
|
||||
// mesh that has never heard of it.
|
||||
deadline := time.NewTimer(timeout)
|
||||
defer deadline.Stop()
|
||||
closed := conn.NotifyClose(make(chan *amqp.Error, 1))
|
||||
|
||||
for {
|
||||
answer, err := asking.Ask(ctx, body, timeout)
|
||||
if err != nil {
|
||||
return EnrolReply{}, err
|
||||
}
|
||||
var reply EnrolReply
|
||||
if err := json.Unmarshal(answer, &reply); err != nil {
|
||||
return EnrolReply{}, fmt.Errorf("the mesh's answer could not be read: %w", err)
|
||||
}
|
||||
again, err := answered(reply, time.Since(began))
|
||||
if err != nil {
|
||||
return reply, err
|
||||
}
|
||||
if !again {
|
||||
return reply, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return EnrolReply{}, ctx.Err()
|
||||
case reason := <-closed:
|
||||
return EnrolReply{}, fmt.Errorf("the broker closed the connection: %v", reason)
|
||||
case <-deadline.C:
|
||||
return EnrolReply{}, fmt.Errorf(
|
||||
"the broker accepted this node's connection and nothing answered within %s. The "+
|
||||
"mesh's broker is running and its control plane is not", timeout)
|
||||
case delivery, ok := <-replies:
|
||||
if !ok {
|
||||
return EnrolReply{}, errors.New("the broker stopped delivering")
|
||||
}
|
||||
// Anything else on this queue is not the answer to this question.
|
||||
if delivery.CorrelationId != correlation {
|
||||
continue
|
||||
}
|
||||
var reply EnrolReply
|
||||
if err := json.Unmarshal(delivery.Body, &reply); err != nil {
|
||||
return EnrolReply{}, fmt.Errorf("the mesh's answer could not be read: %w", err)
|
||||
}
|
||||
again, err := answered(reply, time.Since(began))
|
||||
if err != nil {
|
||||
return reply, err
|
||||
}
|
||||
if !again {
|
||||
return reply, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return EnrolReply{}, ctx.Err()
|
||||
case <-time.After(AskAgainAfter):
|
||||
}
|
||||
if correlation, err = ask(); err != nil {
|
||||
return EnrolReply{}, err
|
||||
}
|
||||
if !deadline.Stop() {
|
||||
select {
|
||||
case <-deadline.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
deadline.Reset(timeout)
|
||||
case <-time.After(AskAgainAfter):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withReplyTo writes this attempt's reply address into the request, as a field of its own.
|
||||
//
|
||||
// **Written into the bytes rather than carried beside them**, because the whole point is that the
|
||||
// address survives a stream: a JetStream consumer's delivery has had the transport's reply field
|
||||
// claimed for its own ack address, so a reply address that is not in the payload is one the
|
||||
// controller cannot read (design 25 §2). Done by decoding and re-encoding rather than by setting the
|
||||
// field before marshalling, so one request can be asked again with a fresh address each time without
|
||||
// the caller knowing that is what happens.
|
||||
func withReplyTo(request []byte, inbox string) ([]byte, error) {
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal(request, &fields); err != nil {
|
||||
return nil, fmt.Errorf("this node's own enrolment request cannot be read back: %w", err)
|
||||
}
|
||||
fields["reply_to"] = inbox
|
||||
return json.Marshal(fields)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// What a host hears, as the host's own words for it.
|
||||
//
|
||||
// The outbound half is behind `Bus` (bus.go); this is the other half — dialling, and the
|
||||
// declarations that arrive. The run loop reads its own words for a declaration rather than the
|
||||
// client library's delivery type, so nothing past this file knows what carried it.
|
||||
//
|
||||
// **The host still imports nothing of the mesh's own** (novox/hq ADR 0005). This is its own
|
||||
// interface over its own libraries, and it agrees with the controller only because a conformance
|
||||
// fixture holds both to one envelope.
|
||||
|
||||
// Link is this node's live connection to its mesh: what it hears, and what it says.
|
||||
//
|
||||
// One interface rather than two, because dialling once is what keeps both halves of a node on the
|
||||
// same connection.
|
||||
type Link interface {
|
||||
// Bus is what this node says: what it applied, and that it is here.
|
||||
Bus
|
||||
|
||||
// Declarations is what the mesh tells this node to be.
|
||||
Declarations() <-chan Declaration
|
||||
|
||||
// Lost says the link ended, and why.
|
||||
//
|
||||
// **Read rather than discovered.** A node that finds out by noticing silence is a node that
|
||||
// believed it was in the mesh for as long as the silence lasted, which is the one state ADR
|
||||
// 0004 says must never look like being connected.
|
||||
Lost() <-chan error
|
||||
|
||||
// Close lets go of whatever was dialled.
|
||||
Close()
|
||||
}
|
||||
|
||||
// Declaration is one thing the mesh told this node to be.
|
||||
//
|
||||
// **Handled, once — after the report is published.** A node that dies between applying and
|
||||
// reporting leaves the declaration with the mesh and applies it again on return, which is safe
|
||||
// because applying is reconciliation: it converges rather than repeating.
|
||||
//
|
||||
// There is one way of being done rather than two. A declaration set aside because a newer arrived
|
||||
// with it is settled exactly as an applied one is, and the difference between them is a fact the
|
||||
// *report* carries — a second method here would be a distinction the bus does not make.
|
||||
type Declaration interface {
|
||||
// Body is the signed declaration as it arrived, bytes unchanged: a node verifies what it
|
||||
// received rather than what it re-encoded.
|
||||
Body() []byte
|
||||
|
||||
// Handled settles it. Called after the report for it has been published, either way.
|
||||
Handled() error
|
||||
}
|
||||
|
||||
// Open opens this node's link to its mesh.
|
||||
//
|
||||
// Named Open rather than Dial because Dial is this package's raw TLS dial, which the enrolment path
|
||||
// uses to see a certificate before it trusts anything.
|
||||
//
|
||||
// **The mesh has one bus** (novox/hq ADR 0131): the one the broker seat delivers. A membership
|
||||
// still records which transport it was minted for, so a host can say what it is dialling, and a
|
||||
// membership recorded for anything else is a membership this host cannot use.
|
||||
func Open(ctx context.Context, m Membership, timeout time.Duration) (Link, error) {
|
||||
if m.Transport != OnNATS {
|
||||
return nil, fmt.Errorf("this membership is for %q, and the mesh's bus is %s", m.Transport, OnNATS)
|
||||
}
|
||||
return dialNats(ctx, m, timeout)
|
||||
}
|
||||
|
||||
// OnNATS is the bus a membership names: the mesh's own, and the only one.
|
||||
const OnNATS = "nats"
|
||||
@@ -0,0 +1,193 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// The host's link on the bus being built.
|
||||
//
|
||||
// Two things a host does here that it cannot do on the other bus, and one it must not try.
|
||||
//
|
||||
// **It declares nothing.** On the bus the mesh has, a host declares its own queue on connecting,
|
||||
// because a queue that is not there means a node that hears nothing. Here the object it reads
|
||||
// through is a durable consumer, and a host's account reaches no part of the JetStream API — by
|
||||
// design, because the controller is the only writer of consumer definitions (design 25 §3). So the
|
||||
// host **binds** to a consumer the controller made when this node enrolled, and a missing one is
|
||||
// said as what it is rather than quietly created with whatever configuration this client happens to
|
||||
// default to.
|
||||
//
|
||||
// **It gets order for free, and keeps the drain anyway.** The declaration subject is last-per-subject
|
||||
// (design 29 §4), so a node that was away receives exactly the current declaration rather than a
|
||||
// queue of superseded ones, and the stream's sequence orders them definitively — the wire-level
|
||||
// answer to novox/hq issue 107. What the drain in run.go still answers is the live case: three
|
||||
// pushes to a *connected* node are three deliveries whatever the stream later retains.
|
||||
|
||||
// EnrolSubject is where a joining machine asks. One subject for every node, because a machine
|
||||
// enrolling has no name the mesh has agreed to yet — which is why its authority to publish here is
|
||||
// the whole of what its enrolment user may do.
|
||||
const EnrolSubject = "mesh.control.enrol"
|
||||
|
||||
// DeclareSubject is where this node's declaration lands. Its own, and no other node's: a host's
|
||||
// account subscribes exactly this and the subject is the authority on which node a declaration is
|
||||
// for.
|
||||
func DeclareSubject(node string) string { return "mesh.node." + node + ".declare" }
|
||||
|
||||
// natsURL is a bus address as the client wants it. A membership records host and port, because that
|
||||
// is what genesis sealed into it and what the other transport takes; the scheme is this transport's
|
||||
// own business.
|
||||
func natsURL(address string) string {
|
||||
if strings.Contains(address, "://") {
|
||||
return address
|
||||
}
|
||||
return "nats://" + address
|
||||
}
|
||||
|
||||
// natsLink is this node's connection as a JetStream subscription.
|
||||
type natsLink struct {
|
||||
conn *nats.Conn
|
||||
js nats.JetStreamContext
|
||||
sub *nats.Subscription
|
||||
node string
|
||||
arrived chan Declaration
|
||||
lost chan error
|
||||
}
|
||||
|
||||
func dialNats(ctx context.Context, m Membership, timeout time.Duration) (Link, error) {
|
||||
// Pinned exactly as the other transport is, and for once the Go client makes that easy: it
|
||||
// takes a *tls.Config, so the same PinnedConfig with the same VerifyPeerCertificate does the
|
||||
// work. **The constraint recorded against the tool runtime does not apply here** — that client
|
||||
// takes PEM strings with no verify hook, which is why the bus's certificate must carry a name
|
||||
// matching the address *modules* dial it by. A host checks the fingerprint and nothing else.
|
||||
config, err := PinnedConfig(m.Fingerprint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := []nats.Option{
|
||||
nats.Secure(config),
|
||||
// The mesh names a machine's bus user "node.<name>" (the controller's principal scheme), and
|
||||
// the server refused the bare name the first time a machine dialled it: "authentication
|
||||
// error - User". The same string the mesh composed into the user list, or nothing connects.
|
||||
nats.UserInfo("node."+m.Node, m.Password),
|
||||
// Replies to what this client asks the server arrive on its inbox, and the mesh grants a
|
||||
// machine exactly its own: the same prefix the user list was composed with.
|
||||
nats.CustomInboxPrefix("_INBOX.node." + m.Node),
|
||||
nats.Name("mesh-host/" + m.Node),
|
||||
nats.Timeout(timeout),
|
||||
// A node that has silently lost its route notices, rather than holding a connection the
|
||||
// server forgot about and believing it is still in the mesh.
|
||||
nats.PingInterval(10 * time.Second),
|
||||
nats.MaxPingsOutstanding(2),
|
||||
// Reconnection is the caller's: Hold already decides when to try again and how long to
|
||||
// wait, and a client quietly reconnecting underneath it would make that reasoning a
|
||||
// duplicate of the library's.
|
||||
nats.NoReconnect(),
|
||||
}
|
||||
|
||||
conn, err := nats.Connect(natsURL(m.Broker), opts...)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrWrongCertificate) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("cannot reach the bus at %s: %w", m.Broker, err)
|
||||
}
|
||||
js, err := conn.JetStream()
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("the bus at %s has no JetStream: %w", m.Broker, err)
|
||||
}
|
||||
|
||||
l := &natsLink{
|
||||
conn: conn, js: js, node: m.Node,
|
||||
arrived: make(chan Declaration, drainDepth),
|
||||
lost: make(chan error, 1),
|
||||
}
|
||||
|
||||
// Bound to the consumer the controller made for this node, named after the node because that is
|
||||
// what the node's own ack grant allows (`$JS.ACK.NODES.<node>.>`).
|
||||
feed := make(chan *nats.Msg, drainDepth)
|
||||
// The subject as well as the binding: the client checks what is asked for against the
|
||||
// consumer's own filter, and an empty subject is refused rather than taken to mean "whatever
|
||||
// that consumer delivers".
|
||||
sub, err := js.ChanSubscribe(DeclareSubject(m.Node), feed, nats.Bind("NODES", m.Node))
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf(
|
||||
"this node cannot read its declarations: %w. The mesh creates that when a node enrols, "+
|
||||
"and a host may not create one itself — so this is the mesh's to answer, not this "+
|
||||
"machine's", err)
|
||||
}
|
||||
l.sub = sub
|
||||
|
||||
conn.SetDisconnectErrHandler(func(_ *nats.Conn, err error) {
|
||||
select {
|
||||
case l.lost <- fmt.Errorf("the link dropped: %w", err):
|
||||
default:
|
||||
}
|
||||
})
|
||||
conn.SetClosedHandler(func(*nats.Conn) {
|
||||
select {
|
||||
case l.lost <- errors.New("the link closed"):
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
go func() {
|
||||
defer close(l.arrived)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg, ok := <-feed:
|
||||
if !ok {
|
||||
select {
|
||||
case l.lost <- errors.New("the bus stopped delivering"):
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case l.arrived <- natsDeclaration{msg}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (l *natsLink) Declarations() <-chan Declaration { return l.arrived }
|
||||
func (l *natsLink) Lost() <-chan error { return l.lost }
|
||||
|
||||
func (l *natsLink) Close() {
|
||||
if l.sub != nil {
|
||||
_ = l.sub.Unsubscribe()
|
||||
}
|
||||
if l.conn != nil {
|
||||
l.conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *natsLink) Report(ctx context.Context, node string, body []byte) error {
|
||||
return OverNATS{Conn: l.conn, JS: l.js}.Report(ctx, node, body)
|
||||
}
|
||||
|
||||
func (l *natsLink) Alive(ctx context.Context, node string, body []byte) error {
|
||||
return OverNATS{Conn: l.conn, JS: l.js}.Alive(ctx, node, body)
|
||||
}
|
||||
|
||||
// natsDeclaration is one declaration off the NODES stream.
|
||||
type natsDeclaration struct{ msg *nats.Msg }
|
||||
|
||||
func (d natsDeclaration) Body() []byte { return d.msg.Data }
|
||||
|
||||
// Handled acknowledges it. The ack goes to this node's own ack subject, which is the one thing
|
||||
// besides its reports a node's account may publish.
|
||||
func (d natsDeclaration) Handled() error { return d.msg.Ack() }
|
||||
@@ -0,0 +1,228 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// The host's link against a real server, because every claim here is about one.
|
||||
//
|
||||
// Whether binding to a consumer the host did not create works, whether a declaration on the node's
|
||||
// own subject arrives, whether acknowledging it removes it from the consumer's pending — none of
|
||||
// that can be reasoned out, and the first two are the ones that would leave a node silently hearing
|
||||
// nothing:
|
||||
//
|
||||
// docker run -d --rm --name t -p 14223:4222 nats:2.10-alpine -js
|
||||
// MESH_TEST_NATS=nats://127.0.0.1:14223 go test ./internal/link/ -run TestNats
|
||||
|
||||
func aBus(t *testing.T) (*nats.Conn, nats.JetStreamContext) {
|
||||
t.Helper()
|
||||
url := os.Getenv("MESH_TEST_NATS")
|
||||
if url == "" {
|
||||
t.Skip("MESH_TEST_NATS unset")
|
||||
}
|
||||
conn, err := nats.Connect(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(conn.Close)
|
||||
js, err := conn.JetStream()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// **Ensured and purged, not deleted and recreated.** Delete-then-add looked like a reset and is
|
||||
// not one: a test that did that inherited the previous test's messages, and the symptom was a
|
||||
// declaration counted as delivered twice — which reads as a redelivery bug in the code under
|
||||
// test rather than as a dirty stream. Purge is defined to empty a stream; recreating one is a
|
||||
// race with the server's own teardown.
|
||||
for _, want := range []*nats.StreamConfig{
|
||||
{Name: "NODES", Subjects: []string{"mesh.node.*.declare"}, MaxMsgsPerSubject: 1},
|
||||
{Name: "CONTROL", Subjects: []string{"mesh.control.*.report", "mesh.control.enrol"},
|
||||
Retention: nats.WorkQueuePolicy},
|
||||
} {
|
||||
if _, err := js.StreamInfo(want.Name); err != nil {
|
||||
if _, err := js.AddStream(want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := js.PurgeStream(want.Name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return conn, js
|
||||
}
|
||||
|
||||
// theMeshMakes is the consumer the controller creates when a node enrols. Made here by the test
|
||||
// because the host may not: its account reaches no part of the JetStream API, which is the whole
|
||||
// reason this binds rather than subscribes.
|
||||
//
|
||||
// Removed afterwards, and each test names its own node: two tests sharing a consumer name share its
|
||||
// delivery count and its pending list, and the first thing that goes wrong reads as a fault in the
|
||||
// host rather than in the test beside it.
|
||||
func theMeshMakes(t *testing.T, js nats.JetStreamContext, node string) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() { _ = js.DeleteConsumer("NODES", node) })
|
||||
if _, err := js.AddConsumer("NODES", &nats.ConsumerConfig{
|
||||
Durable: node,
|
||||
FilterSubject: DeclareSubject(node),
|
||||
AckPolicy: nats.AckExplicitPolicy,
|
||||
AckWait: 300 * time.Second,
|
||||
DeliverSubject: "_DELIVER." + node,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func signedBy(t *testing.T, key ed25519.PrivateKey, declaration []byte) []byte {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(Signed{
|
||||
Declaration: declaration, Signature: ed25519.Sign(key, declaration),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// A declaration on this node's own subject reaches the host, is applied, and acknowledging it
|
||||
// empties the consumer — which is what tells the mesh the node has it.
|
||||
func TestNatsADeclarationReachesTheHostAndIsSettled(t *testing.T) {
|
||||
conn, js := aBus(t)
|
||||
const node = "settling"
|
||||
theMeshMakes(t, js, node)
|
||||
|
||||
public, private, _ := ed25519.GenerateKey(nil)
|
||||
m := Membership{Node: node, Signer: public}
|
||||
|
||||
// Dialled directly rather than through Open: the test server has no TLS, and what is being
|
||||
// checked is the subscription and the settling, not the pin — which PinnedConfig owns and its
|
||||
// own tests cover.
|
||||
l := &natsLink{conn: conn, js: js, node: node,
|
||||
arrived: make(chan Declaration, drainDepth), lost: make(chan error, 1)}
|
||||
feed := make(chan *nats.Msg, drainDepth)
|
||||
sub, err := js.ChanSubscribe(DeclareSubject(node), feed, nats.Bind("NODES", node))
|
||||
if err != nil {
|
||||
t.Fatalf("the host could not bind to the consumer the mesh made for it: %v", err)
|
||||
}
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
go func() {
|
||||
for msg := range feed {
|
||||
l.arrived <- natsDeclaration{msg}
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := js.Publish(DeclareSubject(node),
|
||||
signedBy(t, private, []byte(`{"declared":"d1"}`))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case d := <-l.Declarations():
|
||||
report := handleBody(context.Background(), m, d.Body(),
|
||||
func(context.Context, []byte, []byte) Report {
|
||||
return Report{Applied: []string{"store"}}
|
||||
})
|
||||
if report.Refused != "" {
|
||||
t.Fatalf("a declaration the mesh signed was refused: %s", report.Refused)
|
||||
}
|
||||
if err := d.Handled(); err != nil {
|
||||
t.Fatalf("the node could not acknowledge its own declaration: %v", err)
|
||||
}
|
||||
case <-time.After(8 * time.Second):
|
||||
t.Fatal("no declaration reached the host")
|
||||
}
|
||||
|
||||
// **Nothing pending is the property**; a delivery count is not. Delivery is at-least-once by
|
||||
// design, so pinning "delivered exactly once" would be asserting something the mesh does not
|
||||
// rely on. What matters is that the acknowledgement landed, so the mesh can tell the node has
|
||||
// it — and that no redelivery was needed to get there, which is what would say the node was
|
||||
// too slow to answer for its own ack wait.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
var last string
|
||||
for time.Now().Before(deadline) {
|
||||
info, err := js.ConsumerInfo("NODES", node)
|
||||
switch {
|
||||
case err != nil:
|
||||
last = err.Error()
|
||||
case info.NumAckPending == 0 && info.NumRedelivered == 0:
|
||||
return
|
||||
default:
|
||||
last = fmt.Sprintf("pending %d, redelivered %d", info.NumAckPending, info.NumRedelivered)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("the declaration was not settled, so the mesh cannot tell the node has it: %s", last)
|
||||
}
|
||||
|
||||
// **A node that was away gets exactly the current declaration and nothing older.** Three pushed
|
||||
// while nothing is listening leave one on the stream, and it is the newest — the wire-level answer
|
||||
// to novox/hq issue 107, and the half of the drain that stops being the host's problem.
|
||||
func TestNatsANodeThatWasAwayGetsOnlyTheNewest(t *testing.T) {
|
||||
_, js := aBus(t)
|
||||
const node = "returning"
|
||||
_, private, _ := ed25519.GenerateKey(nil)
|
||||
|
||||
for _, id := range []string{"d1", "d2", "d3"} {
|
||||
if _, err := js.Publish(DeclareSubject(node),
|
||||
signedBy(t, private, []byte(`{"declared":"`+id+`"}`))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
info, err := js.StreamInfo("NODES")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.State.Msgs != 1 {
|
||||
t.Fatalf("%d declarations survived for one node; a node that was away would apply a backlog "+
|
||||
"of things nobody wants any more", info.State.Msgs)
|
||||
}
|
||||
|
||||
theMeshMakes(t, js, node)
|
||||
feed := make(chan *nats.Msg, drainDepth)
|
||||
sub, err := js.ChanSubscribe(DeclareSubject(node), feed, nats.Bind("NODES", node))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = sub.Unsubscribe() }()
|
||||
|
||||
select {
|
||||
case msg := <-feed:
|
||||
if declaredIn(msg.Data) != "d3" {
|
||||
t.Fatalf("the node was given %q rather than the newest", declaredIn(msg.Data))
|
||||
}
|
||||
case <-time.After(8 * time.Second):
|
||||
t.Fatal("the node that was away was given nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// A report goes through the stream and a heartbeat does not: the one that must survive the
|
||||
// controller's store restarting is kept, and the one that must not is not.
|
||||
func TestNatsAReportIsKeptAndAHeartbeatIsNot(t *testing.T) {
|
||||
conn, js := aBus(t)
|
||||
bus := OverNATS{Conn: conn, JS: js}
|
||||
ctx := context.Background()
|
||||
|
||||
body, _ := json.Marshal(Report{Node: "anchor", Declared: "d1"})
|
||||
if err := bus.Report(ctx, "anchor", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
beat, _ := json.Marshal(Alive{Node: "anchor"})
|
||||
if err := bus.Alive(ctx, "anchor", beat); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
info, err := js.StreamInfo("CONTROL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.State.Msgs != 1 {
|
||||
t.Fatalf("%d messages were kept; a report must be and a heartbeat must not", info.State.Msgs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The count that did not add up was the only symptom sixteen held resources had, and reading it meant
|
||||
// opening the node's state file by hand (novox/hq 04-ISSUES/125). The line that says what an apply did
|
||||
// says what it did not, too.
|
||||
|
||||
func TestTheApplyLineSaysWhatItHeldAndForWhichModule(t *testing.T) {
|
||||
got := heldNote([]Held{
|
||||
{ID: "ca", Module: "route-proxy", Kind: "directory"},
|
||||
{ID: "certs", Module: "route-proxy", Kind: "directory"},
|
||||
{ID: "server", Module: "route-proxy", Kind: "container"},
|
||||
{ID: "mail", Module: "mailu", Kind: "container"},
|
||||
})
|
||||
if !strings.Contains(got, "4 held") {
|
||||
t.Fatalf("the count of what was held is not in the line: %q", got)
|
||||
}
|
||||
// The module is the thing an operator can act on: `take` takes a module.
|
||||
if !strings.Contains(got, "route-proxy: 3") || !strings.Contains(got, "mailu: 1") {
|
||||
t.Fatalf("the line does not break the holds down by module: %q", got)
|
||||
}
|
||||
// Ordered, so two machines holding the same things read the same and a diff of two reports is
|
||||
// about what changed.
|
||||
if strings.Index(got, "mailu") > strings.Index(got, "route-proxy") {
|
||||
t.Fatalf("modules are not in a stated order: %q", got)
|
||||
}
|
||||
// It says why, because "held" alone reads as a failure and this is correct behaviour.
|
||||
if !strings.Contains(got, "taken") {
|
||||
t.Fatalf("the line does not say a hold ends when the module is taken: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnApplyThatHeldNothingSaysNothingExtra(t *testing.T) {
|
||||
// A converged machine holds nothing, which is most applies. Reporting "0 held" on every one of
|
||||
// them is how a line stops being read.
|
||||
if got := heldNote(nil); got != "" {
|
||||
t.Fatalf("an apply with no holds added %q to its line", got)
|
||||
}
|
||||
if got := heldNote([]Held{}); got != "" {
|
||||
t.Fatalf("an apply with no holds added %q to its line", got)
|
||||
}
|
||||
}
|
||||
+132
-1
@@ -1,6 +1,10 @@
|
||||
package link
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The wire formats shared with the control plane, which defines them separately because this
|
||||
// binary requires nothing present and does not import it. A test on each side asserts the field
|
||||
@@ -100,6 +104,108 @@ type Report struct {
|
||||
// published container port. Only an adopted node reports it; it is what converging the node
|
||||
// previews, so nothing closes without being named first.
|
||||
Reachable []Reach `json:"reachable,omitempty"`
|
||||
|
||||
// Tunnel is what this adopted node says about the tunnel it found and carried (novox/hq ADR
|
||||
// 0105): which interface, its port, range and peer count, whether the found interface is down
|
||||
// and the mesh's up in its place, and where the found configuration's original was kept.
|
||||
Tunnel *CarriedTunnel `json:"tunnel,omitempty"`
|
||||
|
||||
// Filters is what filters this machine now, every table and chain that refuses traffic with its
|
||||
// owner — the mesh's, the found firewall's, the container runtime's own, a ban list, or other
|
||||
// (novox/hq ADR 0168). Every node reports it, adopted or converged, so the mesh can say
|
||||
// truthfully what filters a converged machine and name what it did not write.
|
||||
Filters []Filter `json:"filters,omitempty"`
|
||||
|
||||
// FoundFirewall is the state of the firewall a converged machine was found with: whether it is
|
||||
// in force now, and how it came to be inactive — the mesh disabled it, or a reconcile found it so
|
||||
// (ADR 0168). Nil on a machine found with none, and on an adopted one, where Firewall says it.
|
||||
FoundFirewall *FoundFirewall `json:"found_firewall,omitempty"`
|
||||
|
||||
// Strays is what runs on the machine that the mesh neither wrote nor holds (novox/hq ADR
|
||||
// 0163): containers nobody declared and nobody holds, the ones a cutover leaves behind.
|
||||
Strays []Stray `json:"strays,omitempty"`
|
||||
|
||||
// Profile is what this machine can do, detected again by the apply that reports (novox/hq
|
||||
// ADR 0161) — the same shape enrolment sends — so a capability gained or lost since enrolment,
|
||||
// a network manager switched, reaches the mesh at the next push rather than never.
|
||||
Profile map[string]any `json:"profile,omitempty"`
|
||||
|
||||
// Host is the version of the host that produced this report (novox/hq ADR 0141).
|
||||
//
|
||||
// Without it nothing can say a machine is behind, so "every machine current with its source"
|
||||
// could not include the host — the one component the mesh did not deliver. It is a fact the
|
||||
// machine states about itself, like the firewall it found and the links that face outside.
|
||||
Host string `json:"host,omitempty"`
|
||||
|
||||
// Outward is the links on this machine that face outside it — the ones carrying a default
|
||||
// route (novox/hq ADR 0140). Every node reports it, adopted or converged, because a converged
|
||||
// node's filter is written around it.
|
||||
//
|
||||
// **It replaces a list of addresses.** The filter used to block everything passing through the
|
||||
// machine and then allow the machine's own containers back by naming the ranges they sit on.
|
||||
// A range describes one machine and goes stale in silence; the link carrying the default route
|
||||
// is read afresh on every report and does not change when a module is added or removed.
|
||||
//
|
||||
// Empty means this machine has no route off itself. The mesh then composes no filter for it and
|
||||
// leaves the one it has, rather than writing a rule around a link with no name — a rule set
|
||||
// that does not load is a machine filtering nothing while its unit reports success.
|
||||
Outward []string `json:"outward,omitempty"`
|
||||
|
||||
// Rekey is this node taking a found tunnel's key as its overlay key after enrolment (novox/hq
|
||||
// ADR 0105). Not an account of the machine: a report carrying one says nothing else.
|
||||
Rekey *Rekey `json:"rekey,omitempty"`
|
||||
}
|
||||
|
||||
// CarriedTunnel is this node's account of the tunnel it took over. State is one of the Carried
|
||||
// states below; Note is what the host did about it, when it did something.
|
||||
type CarriedTunnel struct {
|
||||
Interface string `json:"interface"`
|
||||
Port int `json:"port"`
|
||||
Range string `json:"range"`
|
||||
Peers int `json:"peers"`
|
||||
State string `json:"state"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Kept string `json:"kept,omitempty"`
|
||||
}
|
||||
|
||||
// The states a carried tunnel can be in: the found interface still up and the mesh's not; the
|
||||
// found one down and the mesh's up with its key; or the found one down and the mesh's not up — the
|
||||
// one state where the peers reach nothing, said as its own word so nothing reads it as either of
|
||||
// the others.
|
||||
const (
|
||||
CarriedNotTaken = "not-taken"
|
||||
CarriedTaken = "taken"
|
||||
CarriedDown = "down"
|
||||
)
|
||||
|
||||
// Rekey is this node saying it took a found tunnel's key as its overlay key after enrolling
|
||||
// (novox/hq ADR 0105): the path for a node that enrolled before the mesh knew to take a tunnel
|
||||
// over, since re-enrolling would rotate every key it holds. Signed with the identity key over
|
||||
// RekeyProof, so a report forged on a stolen broker account cannot move this node's overlay key.
|
||||
type Rekey struct {
|
||||
// Previous is the overlay key this node held until now, as the mesh records it; the mesh
|
||||
// refuses a rekey naming another, which is how a replayed one is refused.
|
||||
Previous string `json:"previous"`
|
||||
OverlayKey string `json:"overlay_key"`
|
||||
Tunnel *Tunnel `json:"tunnel"`
|
||||
Proof []byte `json:"proof"`
|
||||
}
|
||||
|
||||
// RekeyProof is what a node signs when it rekeys — the node, the key it leaves, the key it takes
|
||||
// and the tunnel it took it from — so a proof cannot be moved to another node or another tunnel.
|
||||
// Byte for byte the mesh's own (mesh-controller internal/link RekeyProof).
|
||||
func RekeyProof(node, previous, key string, tunnel *Tunnel) []byte {
|
||||
var t Tunnel
|
||||
if tunnel != nil {
|
||||
t = *tunnel
|
||||
}
|
||||
peers := make([]string, 0, len(t.Peers))
|
||||
for _, p := range t.Peers {
|
||||
peers = append(peers, p.PublicKey+"@"+p.Address)
|
||||
}
|
||||
return []byte("novox-mesh-rekey\x00" + node + "\x00" + previous + "\x00" + key + "\x00" +
|
||||
t.Interface + "\x00" + t.Unit + "\x00" + t.Config + "\x00" + strconv.Itoa(t.Port) + "\x00" +
|
||||
t.Address + "\x00" + t.Range + "\x00" + t.PublicKey + "\x00" + strings.Join(peers, ","))
|
||||
}
|
||||
|
||||
// Held is one file or container found on an adopted node and kept as it was.
|
||||
@@ -114,6 +220,16 @@ type Held struct {
|
||||
Changed string `json:"changed,omitempty"`
|
||||
// Kept is where a file's original was kept.
|
||||
Kept string `json:"kept,omitempty"`
|
||||
// Facts is the found thing beside what the module declares — what a take compares (novox/hq
|
||||
// ADR 0163). The same shape the host keeps; the controller reads it as data.
|
||||
Facts map[string]any `json:"facts,omitempty"`
|
||||
}
|
||||
|
||||
// A Stray is a container the mesh neither wrote nor holds (ADR 0163).
|
||||
type Stray struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// Reach is one thing reachable on the machine: a listening socket, or a published container port.
|
||||
@@ -128,3 +244,18 @@ type Reach struct {
|
||||
Published bool `json:"published,omitempty"`
|
||||
ContainerPort int `json:"container-port,omitempty"`
|
||||
}
|
||||
|
||||
// A Filter is one place on the machine that refuses traffic, with its owner (novox/hq ADR 0168):
|
||||
// the same shape the host's firewall package reads, carried as data.
|
||||
type Filter struct {
|
||||
Where string `json:"where"`
|
||||
Owner string `json:"owner"`
|
||||
Refuses string `json:"refuses"`
|
||||
}
|
||||
|
||||
// FoundFirewall is the state of a converged machine's found firewall (ADR 0168).
|
||||
type FoundFirewall struct {
|
||||
Kind string `json:"kind"`
|
||||
Active bool `json:"active"`
|
||||
RetiredBy string `json:"retired_by,omitempty"`
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
// verified runs what Run does to a delivery body, without a broker: unmarshal, check the
|
||||
// signature, and only then apply. Isolating it keeps this test about the check rather than about
|
||||
// AMQP, which is tested against a real broker in the lab.
|
||||
// the bus, which is tested against a real one in the lab.
|
||||
func verified(t *testing.T, signer ed25519.PublicKey, body []byte) (Report, bool) {
|
||||
t.Helper()
|
||||
applied := false
|
||||
|
||||
@@ -3,33 +3,46 @@ package link
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
// said is one declaration as a test hands it over, with no transport under it — which is what the
|
||||
// seam bought: the drain's reasoning was reachable only through a real broker before.
|
||||
type said struct {
|
||||
body []byte
|
||||
handled bool
|
||||
}
|
||||
|
||||
func (s *said) Body() []byte { return s.body }
|
||||
func (s *said) Handled() error { s.handled = true; return nil }
|
||||
|
||||
func arriving(bodies ...string) chan Declaration {
|
||||
ch := make(chan Declaration, 8)
|
||||
for _, b := range bodies {
|
||||
ch <- &said{body: []byte(b)}
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
// A machine asked to be five things becomes the last one: what is already waiting supersedes what
|
||||
// arrived first, and everything set aside is named so it can be reported.
|
||||
func TestWhatIsAlreadyWaitingSupersedesWhatArrivedFirst(t *testing.T) {
|
||||
deliveries := make(chan amqp.Delivery, 8)
|
||||
for _, id := range []string{"two", "three", "four"} {
|
||||
deliveries <- amqp.Delivery{Body: []byte(id)}
|
||||
waiting := arriving("two", "three", "four")
|
||||
apply, superseded := newest(waiting, &said{body: []byte("one")}, 50*time.Millisecond)
|
||||
if string(apply.Body()) != "four" {
|
||||
t.Fatalf("applied %q, not the newest", apply.Body())
|
||||
}
|
||||
apply, superseded := newest(deliveries, amqp.Delivery{Body: []byte("one")}, 50*time.Millisecond)
|
||||
if string(apply.Body) != "four" {
|
||||
t.Fatalf("applied %q, not the newest", apply.Body)
|
||||
}
|
||||
if len(superseded) != 3 || string(superseded[0].Body) != "one" || string(superseded[2].Body) != "three" {
|
||||
if len(superseded) != 3 || string(superseded[0].Body()) != "one" ||
|
||||
string(superseded[2].Body()) != "three" {
|
||||
t.Fatalf("set aside %d: %v", len(superseded), superseded)
|
||||
}
|
||||
}
|
||||
|
||||
// One declaration with nothing behind it is applied as it always was, after the window.
|
||||
func TestALoneDeclarationIsAppliedAfterTheWindow(t *testing.T) {
|
||||
deliveries := make(chan amqp.Delivery, 1)
|
||||
began := time.Now()
|
||||
apply, superseded := newest(deliveries, amqp.Delivery{Body: []byte("only")}, 30*time.Millisecond)
|
||||
if string(apply.Body) != "only" || len(superseded) != 0 {
|
||||
t.Fatalf("got %q with %d set aside", apply.Body, len(superseded))
|
||||
apply, superseded := newest(arriving(), &said{body: []byte("only")}, 30*time.Millisecond)
|
||||
if string(apply.Body()) != "only" || len(superseded) != 0 {
|
||||
t.Fatalf("got %q with %d set aside", apply.Body(), len(superseded))
|
||||
}
|
||||
if time.Since(began) < 30*time.Millisecond {
|
||||
t.Fatal("did not wait the window for a straggler")
|
||||
@@ -38,13 +51,13 @@ func TestALoneDeclarationIsAppliedAfterTheWindow(t *testing.T) {
|
||||
|
||||
// A straggler within the window is taken; one after it is the next push.
|
||||
func TestAStragglerWithinTheWindowIsTaken(t *testing.T) {
|
||||
deliveries := make(chan amqp.Delivery, 2)
|
||||
waiting := arriving()
|
||||
go func() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
deliveries <- amqp.Delivery{Body: []byte("late")}
|
||||
waiting <- &said{body: []byte("late")}
|
||||
}()
|
||||
apply, superseded := newest(deliveries, amqp.Delivery{Body: []byte("first")}, 100*time.Millisecond)
|
||||
if string(apply.Body) != "late" || len(superseded) != 1 {
|
||||
t.Fatalf("got %q with %d set aside", apply.Body, len(superseded))
|
||||
apply, superseded := newest(waiting, &said{body: []byte("first")}, 100*time.Millisecond)
|
||||
if string(apply.Body()) != "late" || len(superseded) != 1 {
|
||||
t.Fatalf("got %q with %d set aside", apply.Body(), len(superseded))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package link
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The drain picked the last to arrive. A backlog longer than the batch, or a broker that split a
|
||||
// burst, delivered a superseded declaration last (novox/hq 04-ISSUES/107).
|
||||
|
||||
func sequenced(t *testing.T, n int64) *said {
|
||||
t.Helper()
|
||||
inner, err := json.Marshal(map[string]any{"declaration": 1, "resources": []any{}, "sequence": n})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := json.Marshal(Signed{Declaration: inner, Signature: []byte("s")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &said{body: body}
|
||||
}
|
||||
|
||||
func TestTheDrainKeepsTheHighestSequenceNotTheLastToArrive(t *testing.T) {
|
||||
waiting := make(chan Declaration, 8)
|
||||
waiting <- sequenced(t, 9)
|
||||
waiting <- sequenced(t, 4) // arrived last, composed earlier
|
||||
latest, aside := newest(waiting, sequenced(t, 8), 30*time.Millisecond)
|
||||
if got := sequenceOf(latest.Body()); got != 9 {
|
||||
t.Fatalf("the drain kept sequence %d, and 9 was waiting", got)
|
||||
}
|
||||
if len(aside) != 2 {
|
||||
t.Fatalf("%d set aside, wanted 2 (the 8 and the late 4)", len(aside))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutSequencesTheLastToArriveStillWins(t *testing.T) {
|
||||
// The behaviour this had before, kept for a controller that sends no order.
|
||||
apply, aside := newest(arriving("two", "three"), &said{body: []byte("one")}, 30*time.Millisecond)
|
||||
if string(apply.Body()) != "three" || len(aside) != 2 {
|
||||
t.Fatalf("applied %q with %d set aside", apply.Body(), len(aside))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnreadableBodyClaimsNoOrder(t *testing.T) {
|
||||
if got := sequenceOf([]byte("not json")); got != 0 {
|
||||
t.Fatalf("garbage claimed sequence %d", got)
|
||||
}
|
||||
}
|
||||
+14
-3
@@ -73,8 +73,19 @@ func PinnedConfig(pin string) (*tls.Config, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Dial opens a TLS connection to the broker, refusing anything but the pinned certificate.
|
||||
func Dial(address, pin string, timeout time.Duration) (*tls.Conn, error) {
|
||||
// dialPinned completes a TLS handshake against an address, refusing anything but the pinned
|
||||
// certificate.
|
||||
//
|
||||
// **Not how the bus is reached, and it used to be** (novox/hq 04-ISSUES/146). Enrolment opened one
|
||||
// of these before it said anything, which was right while the broker answered TLS immediately and
|
||||
// wrong the moment the mesh moved to a bus that speaks its own protocol first. The pin itself was
|
||||
// never the problem — PinnedConfig is what the NATS client is given, and the verification runs
|
||||
// inside the handshake that client performs.
|
||||
//
|
||||
// It stays here because this is where the pin is proven: the tests beside it run a real TLS server
|
||||
// and assert that a wrong certificate is refused before a byte of application data is sent. What it
|
||||
// must not become again is something a caller uses to reach the bus.
|
||||
func dialPinned(address, pin string, timeout time.Duration) (*tls.Conn, error) {
|
||||
config, err := PinnedConfig(pin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -86,7 +97,7 @@ func Dial(address, pin string, timeout time.Duration) (*tls.Conn, error) {
|
||||
if errors.Is(err, ErrWrongCertificate) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, fmt.Errorf("cannot reach the broker at %s: %w", address, err)
|
||||
return nil, fmt.Errorf("cannot reach %s: %w", address, err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func server(t *testing.T) (address string, fingerprint string) {
|
||||
|
||||
func TestTheRightBrokerIsAccepted(t *testing.T) {
|
||||
address, pin := server(t)
|
||||
conn, err := Dial(address, pin, 5*time.Second)
|
||||
conn, err := dialPinned(address, pin, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("the broker its token describes was refused: %v", err)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func TestADifferentBrokerIsRefused(t *testing.T) {
|
||||
address, _ := server(t)
|
||||
_, other := server(t)
|
||||
|
||||
_, err := Dial(address, other, 5*time.Second)
|
||||
_, err := dialPinned(address, other, 5*time.Second)
|
||||
if err == nil {
|
||||
t.Fatal("a broker presenting a different certificate was accepted")
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func TestNothingIsSentToTheWrongBroker(t *testing.T) {
|
||||
|
||||
// A pin for a certificate this server does not have.
|
||||
_, elsewhere := server(t)
|
||||
if _, err := Dial(listener.Addr().String(), elsewhere, 5*time.Second); err == nil {
|
||||
if _, err := dialPinned(listener.Addr().String(), elsewhere, 5*time.Second); err == nil {
|
||||
t.Fatal("the impostor was accepted")
|
||||
}
|
||||
if n := <-received; n > 0 {
|
||||
@@ -160,7 +160,7 @@ func TestAnUnreachableBrokerIsAnOrdinaryFailure(t *testing.T) {
|
||||
address := listener.Addr().String()
|
||||
listener.Close()
|
||||
|
||||
_, err = Dial(address, pin, 2*time.Second)
|
||||
_, err = dialPinned(address, pin, 2*time.Second)
|
||||
if err == nil {
|
||||
t.Fatal("dialling a closed port succeeded")
|
||||
}
|
||||
|
||||
+137
-96
@@ -6,10 +6,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
// ErrForged is what a node returns for a declaration whose signature is not the mesh's.
|
||||
@@ -33,6 +32,9 @@ type Membership struct {
|
||||
Fingerprint string
|
||||
Password string
|
||||
Signer ed25519.PublicKey
|
||||
// Transport is which bus this membership was minted for (hearing.go): the mesh's own, and a
|
||||
// membership that names another is one this host cannot dial with.
|
||||
Transport string
|
||||
}
|
||||
|
||||
// Applier is what the host does with a declaration that has been proved to come from the mesh.
|
||||
@@ -190,122 +192,78 @@ func Run(ctx context.Context, m Membership, apply Applier, say Announce, timeout
|
||||
if say == nil {
|
||||
say = func(string) {}
|
||||
}
|
||||
config, err := PinnedConfig(m.Fingerprint)
|
||||
link, err := Open(ctx, m, timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer link.Close()
|
||||
|
||||
dsn := fmt.Sprintf("amqps://%s:%s@%s/",
|
||||
url.QueryEscape(m.Node), url.QueryEscape(m.Password), m.Broker)
|
||||
conn, err := amqp.DialConfig(dsn, amqp.Config{
|
||||
TLSClientConfig: config,
|
||||
Dial: amqp.DefaultDial(timeout),
|
||||
// Kept short so a node that has silently lost its route notices, rather than holding a
|
||||
// connection the broker forgot about and believing it is still in the mesh.
|
||||
Heartbeat: 10 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrWrongCertificate) {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("cannot reach the broker at %s: %w", m.Broker, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
channel, err := conn.Channel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer channel.Close()
|
||||
|
||||
queue := QueueFor(m.Node)
|
||||
if _, err := channel.QueueDeclare(queue, true, false, false, false, nil); err != nil {
|
||||
return fmt.Errorf("cannot declare this node's queue %s: %w", queue, err)
|
||||
}
|
||||
|
||||
// Applying is one at a time — two at once would race on the same filesystem — but SEEING is
|
||||
// not: with a prefetch of one the host could never know that a newer declaration was already
|
||||
// waiting, and so applied every one of a backlog in turn, at the better part of a minute each,
|
||||
// becoming things nobody wanted any more (novox/hq issue 031). A window of unacknowledged
|
||||
// deliveries lets it drain to the newest; each declaration still survives a restart on the
|
||||
// broker until it is acknowledged, which happens only after it is applied or set aside.
|
||||
if err := channel.Qos(drainDepth, 0, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deliveries, err := channel.ConsumeWithContext(ctx, queue, "", false, false, false, false, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Said, because it is the event anybody watching actually wants. Without it a node logs
|
||||
// every failure and nothing on success, so a log full of "trying again" and then silence
|
||||
// reads as still broken when it means the opposite.
|
||||
say("in the mesh, consuming " + queue)
|
||||
// Said, because it is the event anybody watching actually wants. Without it a node logs every
|
||||
// failure and nothing on success, so a log full of "trying again" and then silence reads as
|
||||
// still broken when it means the opposite.
|
||||
say("in the mesh, hearing what this node should be")
|
||||
|
||||
// A word every so often, so the mesh can tell a node that is quiet from one that is gone.
|
||||
// Cheap on purpose: it carries a name and nothing else, because anything more would be a
|
||||
// report, and reports are rare where this is constant.
|
||||
beat := time.NewTicker(AliveEvery)
|
||||
defer beat.Stop()
|
||||
publishAlive(ctx, channel, m, say, timeout)
|
||||
publishAlive(ctx, link, m, say, timeout)
|
||||
|
||||
closed := conn.NotifyClose(make(chan *amqp.Error, 1))
|
||||
|
||||
// Published mandatory, so the broker hands back anything it cannot route rather than
|
||||
// dropping it. Without this a report goes to an exchange with no matching binding, the
|
||||
// publisher is told nothing, and the mesh believes this node never answered while the node
|
||||
// believes it did — which is what happened when `report` was left unbound on the other side.
|
||||
returned := channel.NotifyReturn(make(chan amqp.Return, 4))
|
||||
go func() {
|
||||
for r := range returned {
|
||||
say(fmt.Sprintf("the broker could not route this node's %s: %s (%d %s)",
|
||||
r.RoutingKey, r.Exchange, r.ReplyCode, r.ReplyText))
|
||||
}
|
||||
}()
|
||||
declarations := link.Declarations()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-beat.C:
|
||||
publishAlive(ctx, channel, m, say, timeout)
|
||||
publishAlive(ctx, link, m, say, timeout)
|
||||
case unasked := <-outbox:
|
||||
// Said without having been asked: a reconcile found what an adopted node holds, or
|
||||
// its firewall, changed since it last said.
|
||||
published := publishReport(ctx, channel, m, unasked.Report, say, timeout)
|
||||
published := publishReport(ctx, link, m, unasked.Report, say, timeout)
|
||||
if unasked.Done != nil {
|
||||
unasked.Done(published)
|
||||
}
|
||||
case reason := <-closed:
|
||||
return fmt.Errorf("the link closed: %v", reason)
|
||||
case delivery, ok := <-deliveries:
|
||||
case reason := <-link.Lost():
|
||||
return reason
|
||||
case declaration, ok := <-declarations:
|
||||
if !ok {
|
||||
return errors.New("the broker stopped delivering")
|
||||
// The link's own reason, when it has managed to say one: "stopped delivering" on
|
||||
// its own says nothing about why, and why is the whole of what an operator wants.
|
||||
select {
|
||||
case reason := <-link.Lost():
|
||||
return reason
|
||||
default:
|
||||
return errors.New("the mesh stopped sending this node declarations")
|
||||
}
|
||||
}
|
||||
// Whatever else is already waiting supersedes this one. Each set-aside declaration
|
||||
// is reported as such, then acknowledged unapplied.
|
||||
delivery, superseded := newest(deliveries, delivery, drainWindow)
|
||||
// Whatever else is already waiting supersedes this one. Each set-aside declaration is
|
||||
// reported as such, then settled unapplied.
|
||||
declaration, superseded := newest(declarations, declaration, drainWindow)
|
||||
for _, old := range superseded {
|
||||
say("set aside a declaration: a newer one arrived with it")
|
||||
publishReport(ctx, channel, m, Report{Node: m.Node, Declared: declaredIn(old.Body),
|
||||
Superseded: declaredIn(delivery.Body)}, say, timeout)
|
||||
_ = old.Ack(false)
|
||||
publishReport(ctx, link, m, Report{Node: m.Node, Declared: declaredIn(old.Body()),
|
||||
Superseded: declaredIn(declaration.Body())}, say, timeout)
|
||||
_ = old.Handled()
|
||||
}
|
||||
report := handle(ctx, m, apply, delivery)
|
||||
report := handleBody(ctx, m, declaration.Body(), apply)
|
||||
switch {
|
||||
case report.Refused != "":
|
||||
say("refused a declaration: " + report.Refused)
|
||||
case len(report.Failed) > 0:
|
||||
say(fmt.Sprintf("applied %d and failed: %v", len(report.Applied), report.Failed))
|
||||
say(fmt.Sprintf("applied %d and failed: %v%s",
|
||||
len(report.Applied), report.Failed, heldNote(report.Held)))
|
||||
default:
|
||||
say(fmt.Sprintf("applied %d resource(s)", len(report.Applied)))
|
||||
say(fmt.Sprintf("applied %d resource(s)%s",
|
||||
len(report.Applied), heldNote(report.Held)))
|
||||
}
|
||||
publishReport(ctx, channel, m, report, say, timeout)
|
||||
// Acknowledged after the report is published. A node that dies between applying and
|
||||
// reporting leaves the declaration on the broker and applies it again on return,
|
||||
publishReport(ctx, link, m, report, say, timeout)
|
||||
// Settled after the report is published. A node that dies between applying and
|
||||
// reporting leaves the declaration with the mesh and applies it again on return,
|
||||
// which is safe because applying is reconciliation — it converges rather than
|
||||
// repeating.
|
||||
_ = delivery.Ack(false)
|
||||
_ = declaration.Handled()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,15 +279,37 @@ const (
|
||||
// newest takes what is already waiting behind `first` and returns the last of them to apply, and
|
||||
// the rest to set aside. It waits `window` for a straggler after each arrival and no longer: a
|
||||
// declaration in flight from the mesh arrives within that; one that does not is the next push.
|
||||
func newest(deliveries <-chan amqp.Delivery, first amqp.Delivery, window time.Duration) (amqp.Delivery, []amqp.Delivery) {
|
||||
//
|
||||
// **Its job narrows once declarations are state rather than messages, and does not disappear.**
|
||||
// On the bus being built, a declaration is last-per-subject (novox/hq design 29 §4), so a node
|
||||
// that was away receives exactly the current one instead of a queue of superseded ones — the
|
||||
// catch-up half of what this does is then the stream's. And a stream sequence orders them
|
||||
// definitively, where this window only infers order from arrival time, which is the wire-level
|
||||
// answer to novox/hq issue 107.
|
||||
//
|
||||
// What remains is the live case: three pushes in quick succession to a *connected* node are
|
||||
// three deliveries, whatever the stream later retains. So this is narrowed at the rollout, not
|
||||
// deleted — and saying which half goes is worth more than a note that it "can probably be
|
||||
// removed", which is how a load-bearing window gets deleted by somebody in a hurry.
|
||||
func newest(arriving <-chan Declaration, first Declaration, window time.Duration) (Declaration, []Declaration) {
|
||||
latest := first
|
||||
var superseded []amqp.Delivery
|
||||
var superseded []Declaration
|
||||
for {
|
||||
select {
|
||||
case next, ok := <-deliveries:
|
||||
case next, ok := <-arriving:
|
||||
if !ok {
|
||||
return latest, superseded
|
||||
}
|
||||
// **By sequence when both carry one, by arrival when either does not** (novox/hq
|
||||
// 04-ISSUES/107). Arrival is what this window had to go on, and it is wrong exactly
|
||||
// when it matters — a backlog drained out of order. A declaration that says where it
|
||||
// stands is believed over when it turned up; one that does not is the older
|
||||
// controller's, and arrival is all there is.
|
||||
if sequenceOf(next.Body()) < sequenceOf(latest.Body()) &&
|
||||
sequenceOf(next.Body()) > 0 && sequenceOf(latest.Body()) > 0 {
|
||||
superseded = append(superseded, next)
|
||||
continue
|
||||
}
|
||||
superseded = append(superseded, latest)
|
||||
latest = next
|
||||
case <-time.After(window):
|
||||
@@ -338,6 +318,24 @@ func newest(deliveries <-chan amqp.Delivery, first amqp.Delivery, window time.Du
|
||||
}
|
||||
}
|
||||
|
||||
// sequenceOf is the order a signed declaration claims, or zero when it claims none or cannot be
|
||||
// read. Read from the envelope alone; the signature is verified later, when the winner is applied,
|
||||
// and a forged message that lied about its sequence would only set aside real ones — which are
|
||||
// reported as set aside, and the next push sends the current one again.
|
||||
func sequenceOf(body []byte) int64 {
|
||||
var signed Signed
|
||||
if err := json.Unmarshal(body, &signed); err != nil {
|
||||
return 0
|
||||
}
|
||||
var d struct {
|
||||
Sequence int64 `json:"sequence"`
|
||||
}
|
||||
if err := json.Unmarshal(signed.Declaration, &d); err != nil {
|
||||
return 0
|
||||
}
|
||||
return d.Sequence
|
||||
}
|
||||
|
||||
// declaredIn is the id a signed declaration carries, for a report about one that was not applied.
|
||||
// Empty if the message is not one — a forged or garbled message is refused by handleBody when its
|
||||
// turn comes; here it is only named.
|
||||
@@ -355,10 +353,6 @@ func declaredIn(body []byte) string {
|
||||
return d.Declared
|
||||
}
|
||||
|
||||
func handle(ctx context.Context, m Membership, apply Applier, delivery amqp.Delivery) Report {
|
||||
return handleBody(ctx, m, delivery.Body, apply)
|
||||
}
|
||||
|
||||
// handleBody is the whole of deciding whether to trust a message, separated from the broker so it
|
||||
// can be tested as the security check it is rather than as message plumbing.
|
||||
func handleBody(ctx context.Context, m Membership, body []byte, apply Applier) Report {
|
||||
@@ -377,7 +371,23 @@ func handleBody(ctx context.Context, m Membership, body []byte, apply Applier) R
|
||||
}
|
||||
|
||||
// publishReport tells the mesh what this node did, and says whether the broker took it.
|
||||
func publishReport(ctx context.Context, channel *amqp.Channel, m Membership, report Report,
|
||||
// Publish sends one report on this node's own connection and returns: the one-shot path for a
|
||||
// report a command makes rather than the running host — a rekey (novox/hq ADR 0105). The same
|
||||
// account, the same pinned certificate and the same exchange as the running host's reports.
|
||||
func Publish(ctx context.Context, m Membership, report Report, timeout time.Duration) error {
|
||||
link, err := Open(ctx, m, timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer link.Close()
|
||||
var said string
|
||||
if !publishReport(ctx, link, m, report, func(s string) { said = s }, timeout) {
|
||||
return errors.New(said)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func publishReport(ctx context.Context, bus Bus, m Membership, report Report,
|
||||
say Announce, timeout time.Duration) bool {
|
||||
report.Node = m.Node
|
||||
body, err := json.Marshal(report)
|
||||
@@ -391,8 +401,7 @@ func publishReport(ctx context.Context, channel *amqp.Channel, m Membership, rep
|
||||
// Said rather than swallowed. A report that fails to publish leaves the mesh believing this
|
||||
// node never answered, while the node believes it did — and the two would go on disagreeing
|
||||
// with nothing anywhere saying so. That shape of fault is the one this project keeps finding.
|
||||
if err := channel.PublishWithContext(publish, Exchange, KeyReport, true, false,
|
||||
amqp.Publishing{ContentType: "application/json", Body: body}); err != nil {
|
||||
if err := bus.Report(publish, m.Node, body); err != nil {
|
||||
say(fmt.Sprintf("applied, and could not tell the mesh: %v", err))
|
||||
return false
|
||||
}
|
||||
@@ -400,7 +409,7 @@ func publishReport(ctx context.Context, channel *amqp.Channel, m Membership, rep
|
||||
}
|
||||
|
||||
// publishAlive says this node is here, and nothing else.
|
||||
func publishAlive(ctx context.Context, channel *amqp.Channel, m Membership, say Announce,
|
||||
func publishAlive(ctx context.Context, bus Bus, m Membership, say Announce,
|
||||
timeout time.Duration) {
|
||||
body, err := json.Marshal(Alive{Node: m.Node})
|
||||
if err != nil {
|
||||
@@ -411,8 +420,40 @@ func publishAlive(ctx context.Context, channel *amqp.Channel, m Membership, say
|
||||
// Not mandatory, unlike a report. Losing one is nothing: the next is a minute away, and the
|
||||
// mesh is reading a gap rather than counting arrivals. Insisting on delivery would turn a
|
||||
// harmless miss into a logged failure every minute.
|
||||
if err := channel.PublishWithContext(publish, Exchange, KeyAlive, false, false,
|
||||
amqp.Publishing{ContentType: "application/json", Body: body}); err != nil {
|
||||
if err := bus.Alive(publish, m.Node, body); err != nil {
|
||||
say("could not tell the mesh this node is here: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// heldNote is what this apply did NOT do, for the line that says what it did.
|
||||
//
|
||||
// **A count that does not add up is the only symptom a held resource had** (novox/hq 04-ISSUES/125).
|
||||
// An adopted node keeps what it found until its module is taken (ADR 0100), and that is correct — but
|
||||
// it was recorded only in the node's own state file. On the edge cut-over the mesh sent 346 resources,
|
||||
// the journal said it applied 330, and nothing anywhere said which sixteen or why. Reading it took
|
||||
// opening state.json by hand; not reading it took every public name on the machine down, because the
|
||||
// operator had four green surfaces and a discrepancy nobody could interpret.
|
||||
//
|
||||
// So the line that reports the apply carries it. Grouped by module and ordered by name, because the
|
||||
// sentence an operator needs is "route-proxy is assigned and not taken", and the module is the thing
|
||||
// they can act on — `take` is the verb, and it takes a module.
|
||||
func heldNote(held []Held) string {
|
||||
if len(held) == 0 {
|
||||
return ""
|
||||
}
|
||||
byModule := map[string]int{}
|
||||
for _, h := range held {
|
||||
byModule[h.Module]++
|
||||
}
|
||||
names := make([]string, 0, len(byModule))
|
||||
for name := range byModule {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
parts := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
parts = append(parts, fmt.Sprintf("%s: %d", name, byModule[name]))
|
||||
}
|
||||
return fmt.Sprintf(", %d held until their module is taken (%s)",
|
||||
len(held), strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Package outward reads which of this machine's links face outside it (novox/hq ADR 0140).
|
||||
//
|
||||
// The filter the mesh derives constrains traffic arriving from outside the machine and says nothing
|
||||
// about traffic that did not. To write that rule the mesh has to know which links "outside" arrives
|
||||
// on, and that is a thing only the machine can say — so it says it, once per report, the way it
|
||||
// already reports the kind of firewall it found and the tunnel it carried.
|
||||
//
|
||||
// **It replaces a list of addresses.** The filter used to allow the machine's own containers back
|
||||
// through by naming the address ranges they sit on: two ranges fixed in the control plane's source
|
||||
// and the rest typed by an operator. A range describes one machine and goes stale silently
|
||||
// (novox/hq 04-ISSUES/137 and /141). A link that carries the default route is a fact the machine
|
||||
// reads afresh every time, and it does not change when a module is added or removed.
|
||||
//
|
||||
// It reads the kernel's routing tables directly rather than asking a program. A module naming a
|
||||
// program the machine does not have is how the mesh already reported success while doing nothing
|
||||
// (novox/hq 04-ISSUES/136), and every machine has /proc.
|
||||
package outward
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProcNet is where the kernel publishes its routing tables. A parameter so a test can hold a
|
||||
// routing table without one.
|
||||
const ProcNet = "/proc/net"
|
||||
|
||||
// SysClassNet is where the kernel lists the machine's network interfaces, one directory each. A
|
||||
// parameter for the same reason.
|
||||
const SysClassNet = "/sys/class/net"
|
||||
|
||||
// Links are the interfaces carrying a default route, for both address families, and every interface
|
||||
// backed by a physical device, sorted and without repeats.
|
||||
//
|
||||
// **A physical link faces outside whether or not it is up** (novox/hq issue 197). The filter accepts
|
||||
// whatever did not arrive on a link named here, so a link left out of this list is not filtered at
|
||||
// all. A cable unplugged when the machine last reported carries no default route, and was left out:
|
||||
// plugged in, everything arriving on it was accepted until the next report and the next push — and a
|
||||
// second physical link that never carries the default route was never filtered. A physical device is
|
||||
// read from the kernel's own list, where it has a `device` entry; a bridge, a veth, the tunnel and the
|
||||
// loopback have none, and stay what they are, this machine's own.
|
||||
//
|
||||
// A machine may have more than one: a laptop with a cable and a radio has two, and both face
|
||||
// outside. A machine with none — no route off itself — returns nothing, and the mesh refuses to
|
||||
// compose a filter for it rather than writing a rule around a link with no name, which would be a
|
||||
// rule set that does not load and a machine filtering nothing while its unit reports success.
|
||||
func Links(procNet, sysClassNet string) ([]string, error) {
|
||||
if procNet == "" {
|
||||
procNet = ProcNet
|
||||
}
|
||||
if sysClassNet == "" {
|
||||
sysClassNet = SysClassNet
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
|
||||
four, err := defaultsV4(filepath.Join(procNet, "route"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
six, err := defaultsV6(filepath.Join(procNet, "ipv6_route"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
devices, err := physical(sysClassNet)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, name := range append(append(four, six...), devices...) {
|
||||
if name != "" && name != "lo" {
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(seen))
|
||||
for name := range seen {
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// physical is every interface the kernel lists with a device behind it. A list that is not there is
|
||||
// not an error — a machine without sysfs mounted reports what its routing table says, as before.
|
||||
func physical(sysClassNet string) ([]string, error) {
|
||||
entries, err := os.ReadDir(sysClassNet)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if _, err := os.Stat(filepath.Join(sysClassNet, e.Name(), "device")); err == nil {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// defaultsV4 reads /proc/net/route, whose columns are
|
||||
//
|
||||
// Iface Destination Gateway Flags RefCnt Use Metric Mask ...
|
||||
//
|
||||
// with addresses in hexadecimal. A default route is destination zero with mask zero — the mask
|
||||
// matters, because a route to the zero address with a real mask is not a default route.
|
||||
func defaultsV4(path string) ([]string, error) {
|
||||
lines, err := rows(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, fields := range lines {
|
||||
if len(fields) < 8 {
|
||||
continue
|
||||
}
|
||||
if isZeroHex(fields[1]) && isZeroHex(fields[7]) {
|
||||
out = append(out, fields[0])
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// defaultsV6 reads /proc/net/ipv6_route, whose columns are
|
||||
//
|
||||
// dest destprefix src srcprefix nexthop metric refcnt use flags iface
|
||||
//
|
||||
// A default route is the zero destination with a zero prefix length.
|
||||
func defaultsV6(path string) ([]string, error) {
|
||||
lines, err := rows(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, fields := range lines {
|
||||
if len(fields) < 10 {
|
||||
continue
|
||||
}
|
||||
if isZeroHex(fields[0]) && isZeroHex(fields[1]) {
|
||||
out = append(out, fields[9])
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// rows reads a routing table into fields per line, skipping a header and blank lines. A table that
|
||||
// is not there is not an error: a machine without the second address family has no file for it,
|
||||
// and that is not a machine that cannot be filtered.
|
||||
func rows(path string) ([][]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read the routing table at %s: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var out [][]string
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "Iface") {
|
||||
continue
|
||||
}
|
||||
out = append(out, strings.Fields(line))
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("cannot read the routing table at %s: %w", path, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isZeroHex is whether a hexadecimal field is all zeroes, whatever its width — the v4 table writes
|
||||
// eight digits and the v6 table thirty-two, and a prefix length is two.
|
||||
func isZeroHex(field string) bool {
|
||||
if field == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Trim(strings.ToLower(field), "0") == ""
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package outward
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A routing table as the kernel writes it: a default route, a route to the zero address that is not
|
||||
// one, and a route on the loopback. Only the default route's link faces outside.
|
||||
const routeV4 = `Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
|
||||
enp9s0 00000000 01FEA8C0 0003 0 0 100 00000000 0 0 0
|
||||
docker0 000011AC 00000000 0001 0 0 0 0000FFFF 0 0 0
|
||||
enp9s0 00000000 00000000 0001 0 0 100 00FFFFFF 0 0 0
|
||||
lo 00000000 00000000 0003 0 0 0 00000000 0 0 0
|
||||
`
|
||||
|
||||
const routeV6 = `00000000000000000000000000000000 00 00000000000000000000000000000000 00 fe800000000000000000000000000001 00000400 00000001 00000000 00000003 wlan0
|
||||
fd0000000000000000000000000000000 40 00000000000000000000000000000000 00 00000000000000000000000000000000 00000100 00000000 00000000 00000001 enp9s0
|
||||
`
|
||||
|
||||
func write(t *testing.T, dir, name, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinksAreTheOnesCarryingADefaultRoute(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, "route", routeV4)
|
||||
write(t, dir, "ipv6_route", routeV6)
|
||||
|
||||
got, err := Links(dir, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The cable from the v4 table and the radio from the v6 one. Not docker0, whose route is not a
|
||||
// default; not the loopback, which faces nothing; and not the v6 route with a real prefix.
|
||||
want := []string{"enp9s0", "wlan0"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("outward links are %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A route to the zero address with a real mask is not a default route. Trusting the destination
|
||||
// alone would name every link with such a route as facing outside, and a filter that treats an
|
||||
// internal bridge as outward constrains this machine's own guests — the fault ADR 0140 removes.
|
||||
func TestAZeroDestinationWithAMaskIsNotADefaultRoute(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, "route", `Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
|
||||
br-abc 00000000 00000000 0001 0 0 0 00FFFFFF 0 0 0
|
||||
`)
|
||||
got, err := Links(dir, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("outward links are %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine with no route off itself says so, rather than guessing. The mesh refuses to compose a
|
||||
// filter for it; a rule written around a link with no name does not load, and a rule set that does
|
||||
// not load is a machine filtering nothing while its unit reports success.
|
||||
func TestNoDefaultRouteIsNoLinks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, "route", "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n")
|
||||
got, err := Links(dir, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("outward links are %v, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine without the second address family has no file for it. That is not a machine that cannot
|
||||
// be filtered, so a missing table is read as no routes rather than as a failure.
|
||||
func TestAMissingTableIsNotAFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, "route", routeV4)
|
||||
got, err := Links(dir, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("a missing v6 table should not fail: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, []string{"enp9s0"}) {
|
||||
t.Fatalf("outward links are %v, want [enp9s0]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The same link carrying a default route in both families is reported once.
|
||||
func TestALinkIsReportedOnce(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, "route", routeV4)
|
||||
write(t, dir, "ipv6_route",
|
||||
"00000000000000000000000000000000 00 00000000000000000000000000000000 00 "+
|
||||
"fe800000000000000000000000000001 00000400 00000001 00000000 00000003 enp9s0\n")
|
||||
got, err := Links(dir, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, []string{"enp9s0"}) {
|
||||
t.Fatalf("outward links are %v, want [enp9s0]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Against this machine's own routing table, so the parse is held to what the kernel actually writes
|
||||
// and not only to a fixture written to agree with it.
|
||||
func TestAgainstThisMachinesOwnTable(t *testing.T) {
|
||||
got, err := Links("", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Skip("this machine has no default route")
|
||||
}
|
||||
t.Logf("this machine's outward links: %v", got)
|
||||
}
|
||||
|
||||
// sysNet is a /sys/class/net: each name a directory, with a `device` entry when a device backs it.
|
||||
func sysNet(t *testing.T, physical []string, virtual []string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for _, name := range physical {
|
||||
if err := os.MkdirAll(filepath.Join(dir, name, "device"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, name := range virtual {
|
||||
if err := os.MkdirAll(filepath.Join(dir, name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// **A physical link faces outside whether or not it carries the default route** (novox/hq issue
|
||||
// 197). A machine on its radio with its cable unplugged reported only the radio, and the filter then
|
||||
// accepted everything arriving on the cable the moment it was plugged in. Bridges, veths, the tunnel
|
||||
// and the loopback have no device behind them and stay this machine's own.
|
||||
func TestEveryPhysicalLinkFacesOutsideUpOrDown(t *testing.T) {
|
||||
proc := t.TempDir()
|
||||
write(t, proc, "route", `Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
|
||||
wlp5s0 00000000 01FEA8C0 0003 0 0 600 00000000 0 0 0
|
||||
`)
|
||||
sys := sysNet(t, []string{"wlp5s0", "enp6s0"}, []string{"lo", "docker0", "br-0123456789ab", "veth1", "mesh0"})
|
||||
got, err := Links(proc, sys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"enp6s0", "wlp5s0"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("outward links are %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,22 @@ const (
|
||||
CapServiceManager = "service-manager"
|
||||
CapFirewall = "firewall"
|
||||
CapOverlay = "overlay"
|
||||
// CapVirtualisation is a running virtualisation daemon: what the lab raises its machines on
|
||||
// (novox/hq ADR 0172), and what grants a module the daemon's socket.
|
||||
CapVirtualisation = "virtualisation"
|
||||
CapGraphicalSession = "graphical-session"
|
||||
// CapSeat is hardware: somewhere a display server COULD run. CapGraphicalSession above is
|
||||
// state: whether one IS running. Assignment needs the first.
|
||||
CapSeat = "seat"
|
||||
CapPrivileged = "privileged"
|
||||
|
||||
// The network manager this machine runs, one capability per dialect (novox/hq ADR 0161): the
|
||||
// uplink seat's holder declares its own, so the holder for a manager the machine does not run
|
||||
// is refused the way any missing capability is, naming it. Active, not installed — a machine
|
||||
// may have two of these on disk and runs one.
|
||||
CapUplinkNetworkManager = "uplink-networkmanager"
|
||||
CapUplinkSystemdNetworkd = "uplink-systemd-networkd"
|
||||
CapUplinkDhcpcd = "uplink-dhcpcd"
|
||||
)
|
||||
|
||||
// commandCapability is the shape most detectors take: run something, and treat a working
|
||||
@@ -197,11 +208,31 @@ func Default(runner Runner) []Detector {
|
||||
why: "lists the ruleset — needs the tool AND the privilege to use it",
|
||||
runner: runner,
|
||||
},
|
||||
commandCapability{
|
||||
name: CapVirtualisation, command: "incus", args: []string{"info"},
|
||||
why: "asks the virtualisation daemon about itself — a running daemon, not an installed client",
|
||||
runner: runner,
|
||||
},
|
||||
commandCapability{
|
||||
name: CapOverlay, command: "wg", args: []string{"show", "interfaces"},
|
||||
why: "asks the kernel for interfaces — needs the module, not just the tool",
|
||||
runner: runner,
|
||||
},
|
||||
commandCapability{
|
||||
name: CapUplinkNetworkManager, command: "systemctl", args: []string{"is-active", "NetworkManager.service"},
|
||||
why: "asks the init whether NetworkManager is running — the dialect the uplink seat's holder must speak",
|
||||
runner: runner,
|
||||
},
|
||||
commandCapability{
|
||||
name: CapUplinkSystemdNetworkd, command: "systemctl", args: []string{"is-active", "systemd-networkd.service"},
|
||||
why: "asks the init whether systemd-networkd is running — the dialect the uplink seat's holder must speak",
|
||||
runner: runner,
|
||||
},
|
||||
commandCapability{
|
||||
name: CapUplinkDhcpcd, command: "systemctl", args: []string{"is-active", "dhcpcd.service"},
|
||||
why: "asks the init whether dhcpcd is running — the dialect the uplink seat's holder must speak",
|
||||
runner: runner,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The uplink seat's holder must be the dialect the machine runs (novox/hq ADR 0161): the profile
|
||||
// names the network manager found active, one capability per manager, and nothing for one that is
|
||||
// merely installed.
|
||||
func TestTheProfileNamesTheNetworkManagerThatIsRunning(t *testing.T) {
|
||||
runner := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
if name == "systemctl" && len(args) == 2 && args[0] == "is-active" {
|
||||
if args[1] == "NetworkManager.service" {
|
||||
return "active\n", nil
|
||||
}
|
||||
return "inactive\n", errors.New("exit status 3")
|
||||
}
|
||||
return "", errors.New("not here")
|
||||
}
|
||||
var have []Detector
|
||||
for _, d := range Default(Runner(runner)) {
|
||||
switch d.Name() {
|
||||
case CapUplinkNetworkManager, CapUplinkSystemdNetworkd, CapUplinkDhcpcd:
|
||||
have = append(have, d)
|
||||
}
|
||||
}
|
||||
if len(have) != 3 {
|
||||
t.Fatalf("expected a detector per manager, found %d", len(have))
|
||||
}
|
||||
for _, d := range have {
|
||||
v := d.Detect(context.Background())
|
||||
want := d.Name() == CapUplinkNetworkManager
|
||||
if v.Present != want {
|
||||
t.Errorf("%s: present=%v, want %v (%s)", d.Name(), v.Present, want, v.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,25 @@ func SaveDeclared(path string, d Declared) error {
|
||||
return os.Rename(tmp.Name(), path)
|
||||
}
|
||||
|
||||
// ReadDeclared reads what was kept without proving it is the mesh's.
|
||||
//
|
||||
// For naming and comparing only — which declaration this node was last told, and what mode it
|
||||
// said — never for applying. A declaration to apply goes through LoadDeclared, which verifies.
|
||||
func ReadDeclared(path string) (Declared, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Declared{}, ErrNothingDeclared
|
||||
}
|
||||
if err != nil {
|
||||
return Declared{}, fmt.Errorf("this node was told something and cannot read it back: %w", err)
|
||||
}
|
||||
var d Declared
|
||||
if err := json.Unmarshal(raw, &d); err != nil {
|
||||
return Declared{}, fmt.Errorf("what this node was told is unreadable at %s: %w", path, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// LoadDeclared reads it back and proves it is still the mesh's.
|
||||
//
|
||||
// Verified against the signing key this node holds, which came from its token. A declaration on
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
// A resource whose target moves leaves what the host wrote under the old target on record as a
|
||||
// former one, undeclared by construction, so the next apply removes it (novox/hq issue 097, ADR 0163).
|
||||
func TestARecordWhoseTargetMovedKeepsTheFormerTargetToRemove(t *testing.T) {
|
||||
s := State{}
|
||||
s.Record(Applied{ID: "gitea.server", Type: "container", Target: "mesh-gitea", Origin: OriginDeclared})
|
||||
s.Record(Applied{ID: "gitea.server", Type: "container", Target: "gitea", Origin: OriginDeclared})
|
||||
if len(s.Resources) != 2 {
|
||||
t.Fatalf("a moved target produced %d record(s): %+v", len(s.Resources), s.Resources)
|
||||
}
|
||||
orphans := s.Orphans(map[string]bool{"gitea.server": true}, OriginDeclared)
|
||||
if len(orphans) != 1 || orphans[0].Target != "mesh-gitea" || !IsFormer(orphans[0].ID) {
|
||||
t.Fatalf("the former target is not an orphan to remove: %+v", orphans)
|
||||
}
|
||||
s.Forget(orphans[0].ID)
|
||||
if len(s.Resources) != 1 || s.Resources[0].Target != "gitea" {
|
||||
t.Fatalf("forgetting the former target touched the current one: %+v", s.Resources)
|
||||
}
|
||||
// The same target again is not a move; a carried record is not the host's to remove.
|
||||
s.Record(Applied{ID: "gitea.server", Type: "container", Target: "gitea", Origin: OriginDeclared})
|
||||
s.Record(Applied{ID: "bundle", Type: "file", Target: "/a"})
|
||||
s.Record(Applied{ID: "bundle", Type: "file", Target: "/b"})
|
||||
if len(s.Resources) != 2 {
|
||||
t.Fatalf("an unmoved or carried record grew the list: %+v", s.Resources)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// One apply at a time on a machine, whoever asks.
|
||||
//
|
||||
// Three things apply here and each reads the state, acts, and writes the state back: the link
|
||||
// and the reconcile loop inside `mesh-host run`, the host's own `reconcile` and `apply` run by
|
||||
// hand, and the installer at genesis. Inside one process a mutex serialises them; across
|
||||
// processes nothing did, and two applies interleaved leave the last saver writing a state read
|
||||
// before the other acted — a hold, a firewall record, a resource just applied, lost (novox/hq
|
||||
// issue 104 review). A lock on a file beside the state is what every one of them can take, however
|
||||
// it was started: a `reconcile` run against a service, or against a `mesh-host run` somebody
|
||||
// started by hand, waits the same way. Refusing while a named service is active would have known
|
||||
// the service's name and missed the hand-started one.
|
||||
//
|
||||
// An advisory lock, held for the life of the open file and released by the kernel when the
|
||||
// process ends, so a host that dies mid-apply leaves no lock behind for the next one to clear.
|
||||
|
||||
// LockName is the file the lock is taken on, beside the state.
|
||||
const LockName = "state.lock"
|
||||
|
||||
// Lock takes the machine's apply lock and returns what releases it. When another process holds
|
||||
// it, wait is told once — so a person running a command knows what they are waiting for — and
|
||||
// Lock blocks until it is free.
|
||||
func Lock(statePath string, wait func()) (func(), error) {
|
||||
dir := filepath.Dir(statePath)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(dir, LockName), os.O_CREATE|os.O_RDWR, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot take this node's apply lock: %w", err)
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
||||
if !errors.Is(err, syscall.EWOULDBLOCK) {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("cannot take this node's apply lock: %w", err)
|
||||
}
|
||||
if wait != nil {
|
||||
wait()
|
||||
}
|
||||
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("waiting for this node's apply lock: %w", err)
|
||||
}
|
||||
}
|
||||
return func() {
|
||||
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
f.Close()
|
||||
}, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user