Compare commits

..
Author SHA1 Message Date
mesh-admin ea73e713f5 i3status-rust: name the pacman-contrib package apart from the update script, and expect it in the test (issue 628)
mesh/merge-gate out of date: judged against trunk cd239283207a, the trunk is now 0d723484a84a; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk cd239283207a, the trunk is now 0d723484a84a; judged again on a recheck or when it is merged
mesh/delivery checking
The package took the id `updates`, which the update script already holds, so the node-engine refused two resources with one identity.
2026-10-13 11:59:31 +00:00
mesh-admin e79197d5a1 i3status-rust, docker-compose: each module declares the packages it needs (issue 628)
mesh/merge-gate out of date: judged against trunk 19ecad261cf2, the trunk is now 8922c890595b; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 19ecad261cf2, the trunk is now 8922c890595b; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
i3status-rust left pacman-contrib to the pacman module because two modules declaring one package was once refused; ADR 0303 allows it, so the bar declares what its update block runs. docker-compose's README said the package goes with the module, which the node-engine never does.
2026-10-13 11:52:38 +00:00
mesh-admin 19ecad261c Merge pull request 'lab: install each checkout where its lock file is, check out mesh-tools-go, and have lab_check catch both (issue 624)' (#268) from fix/624-lab-run-installs-what-each-checkout-has into main 2026-10-13 11:11:58 +00:00
jochen c6a9c52dc7 Install each lab checkout where its lock file is, check out mesh-tools-go, and have lab_check catch both (hq issue 624)
mesh/merge-gate head c6a9c52dc7f2 merged into trunk 45d01e77c26d — pass: builds lab → shanks; no bus step; every machine composes with the change as it did…
mesh/repo-check head c6a9c52dc7f2 merged into trunk 45d01e77c26d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
lab_run ran npm ci at the root of mesh-sdk, mesh-tools and mesh-lab; mesh-tools keeps its package in
node-tools/ with no lock file at its root, so every run failed at its setup in nine seconds. A run now
installs (and, for the SDK and the base, builds) where a package-lock.json is: the root, or each
directory one level down; a package.json with no lock file fails the run naming it. It checks out
mesh-tools-go, which genesis builds since issue 616, and tells the beds where its checkouts are
(MESH_LAB_REPOS). lab_check asks the forge for every repository a run clones and applies the same install
rule to the ones it installs, so a run's setup failure is found before a run.
2026-10-13 11:55:01 +02:00
mesh-admin 45d01e77c2 Merge pull request 'fail2ban: know the own-addresses list in the form fail2ban prints it (hq issue 238)' (#267) from fix/238-fail2bans-own-file-form into main 2026-10-13 09:18:26 +00:00
jochen c78d0a0134 fail2ban: know the own-addresses list in the form fail2ban prints it (hq issue 238)
mesh/merge-gate head c78d0a01342c merged into trunk f35b06750452 — pass: builds fail2ban → ace, g14, novox, shanks; no bus step; every machine composes wit…
mesh/repo-check head c78d0a01342c merged into trunk f35b06750452 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
fail2ban reads the ignoreip entry file:/var/lib/... and prints it back as file://var/lib/...,
in get <jail> ignoreip and in its "Ignore ... by ..." log lines alike. The watch compared the
text, so on ace it said every jail lacked the list while each held it, failing fail2ban's health
and its walk; and it would never have counted a failure from the operator's own address. Compare
the path instead, with a test on fail2ban's real form.
2026-10-13 11:14:24 +02:00
29 changed files with 276 additions and 2575 deletions
+3 -2
View File
@@ -19,8 +19,9 @@ runtime is missing or unreachable rather than an empty list.
- **An owner for a package both workstations already carry.** On both, `docker-compose` 5.5.0 is
installed explicitly by hand, as the plugin in `/usr/lib/docker/cli-plugins`. Assigning the module
changes nothing on disk; from then on the package is the mesh's, upgraded with the machine and
given back when the module goes.
changes nothing on disk; from then on the mesh says the package is needed, and it is upgraded with
the machine. Unassigning the module leaves the package installed: the node-engine never removes a
package a declaration stopped naming (novox/hq ADR 0303).
- **The projects become visible from the mesh** without a shell on the machine: which are running,
where their files are, their containers, logs and rendered configuration.
- **No account-level copy of the plugin** exists on either workstation (`~/.docker/cli-plugins` is
@@ -44,6 +44,20 @@ const (
IgnoredBy = "file:" + ListFile
)
// isOurList says whether an entry of a jail's ignore list, or the source the daemon's log names, is the list of
// the operator's own addresses. fail2ban keeps a file entry in its own form, not as written: it reads
// `file:/var/lib/…` and prints it back as `file://var/lib/…` (server/ipdns.py, FileIPAddrSet.__repr__, in 1.1),
// in `get <jail> ignoreip` and in its `Ignore … by …` lines alike. So the path is compared, with fail2ban's own
// reading of the slashes after `file:`, never the text (seen on ace on 2026-10-13: every jail held the list
// and the watch said none did).
func isOurList(word string) bool {
path, ok := strings.CutPrefix(strings.TrimSpace(word), "file:")
if !ok {
return false
}
return "/"+strings.TrimLeft(path, "/") == ListFile
}
// Own is the setting read: its entries, and each one that is not an address or a range, with why.
type Own struct {
Entries []netip.Prefix `json:"-"`
@@ -15,10 +15,15 @@ import (
"time"
)
// asPrinted is the list's entry as fail2ban 1.1 prints it back, in `get <jail> ignoreip` and in its log: it reads
// `file:/var/lib/…` and keeps it as `file://var/lib/…` (FileIPAddrSet.__repr__), as ace's daemon printed it on
// 2026-10-13.
const asPrinted = "file://var/lib/mesh-fail2ban/own-addresses"
// ignoring is a jail's ignore list as fail2ban-client 1.1 prints it, with the base the module writes.
const ignoring = "These IP addresses/networks are ignored:\n|- 127.0.0.0/8\n|- 10.10.0.0/24\n|- 10.0.0.0/8\n" +
"|- 172.16.0.0/12\n|- 192.168.0.0/16\n|- 169.254.0.0/16\n|- fc00::/7\n|- fe80::/10\n" +
"|- file:/var/lib/mesh-fail2ban/own-addresses\n`- ::1\n"
"|- " + asPrinted + "\n`- ::1\n"
// home is the operator's home uplink in these tests (RFC 5737).
const home = "203.0.113.5"
@@ -153,7 +158,7 @@ func appendLog(t *testing.T, root, text string) {
func daemon(missing bool) Runner {
gitea := ignoring
if missing {
gitea = strings.Replace(ignoring, "|- file:/var/lib/mesh-fail2ban/own-addresses\n", "", 1)
gitea = strings.Replace(ignoring, "|- "+asPrinted+"\n", "", 1)
}
return fake(map[string]string{"status": "Status\n|- Number of jail:\t2\n`- Jail list:\tgitea, recidive\n",
"get gitea ignoreip": gitea, "get recidive ignoreip": ignoring}, nil)
@@ -168,7 +173,7 @@ func TestAFailureIgnoredFromTheOperatorsOwnAddressIsSaidUntilADayAfterTheLast(t
clock := now
w := &Watcher{F: Fail2ban{Run: daemon(false)}, Root: root, Now: func() time.Time { return clock }}
appendLog(t, root, logAt(now.Add(-48*time.Hour), "gitea", home, IgnoredBy)+
appendLog(t, root, logAt(now.Add(-48*time.Hour), "gitea", home, asPrinted)+
logAt(now.Add(-time.Minute), "sshd", "192.168.1.1", "ip"))
s := w.Look(context.Background())
if err := WriteState(root, s); err != nil {
@@ -183,7 +188,7 @@ func TestAFailureIgnoredFromTheOperatorsOwnAddressIsSaidUntilADayAfterTheLast(t
var loop strings.Builder
for i := 0; i < 40; i++ {
loop.WriteString(logAt(now.Add(time.Duration(i)*15*time.Second), "gitea", home, IgnoredBy))
loop.WriteString(logAt(now.Add(time.Duration(i)*15*time.Second), "gitea", home, asPrinted))
}
appendLog(t, root, loop.String()[:loop.Len()-10]) // the daemon is still writing the last line
clock = now.Add(10 * time.Minute)
@@ -303,3 +308,21 @@ func TestTheManifestGivesEveryJailTheListAndTheWatchItsHealth(t *testing.T) {
t.Errorf("tools %v", m.Tools)
}
}
// The list is known by its path, in the form the module writes it and in the form fail2ban prints it back.
func TestTheListIsKnownInTheFormFail2banPrintsItBack(t *testing.T) {
for _, word := range []string{IgnoredBy, asPrinted, "file:///var/lib/mesh-fail2ban/own-addresses"} {
if !isOurList(word) {
t.Errorf("%q is the list and was not known", word)
}
}
for _, word := range []string{"ip", "/var/lib/mesh-fail2ban/own-addresses", "file://var/lib/mesh-fail2ban/other",
"file:/var/lib/mesh-fail2ban/own-addresses.new", "192.168.0.0/16"} {
if isOurList(word) {
t.Errorf("%q was taken for the list", word)
}
}
if !namesOurList(listed(ignoring)) {
t.Errorf("the jail's list as the daemon prints it was read as lacking the list")
}
}
+11 -2
View File
@@ -103,7 +103,7 @@ func (w *Watcher) Look(ctx context.Context) State {
s.Unread = append(s.Unread, fmt.Sprintf("the jail %s's ignore list cannot be read: %v", j, err))
continue
}
if !contains(listed(out), IgnoredBy) {
if !namesOurList(listed(out)) {
s.Missing = append(s.Missing, j)
}
}
@@ -115,6 +115,15 @@ func (w *Watcher) Look(ctx context.Context) State {
return s
}
func namesOurList(entries []string) bool {
for _, e := range entries {
if isOurList(e) {
return true
}
}
return false
}
func contains(list []string, want string) bool {
for _, l := range list {
if l == want {
@@ -162,7 +171,7 @@ func (w *Watcher) readLog(now time.Time) error {
// take keeps one line of the log when it is a failure ignored because of the operator's own addresses.
func (w *Watcher) take(line string, now time.Time) {
m := ignoredLine.FindStringSubmatch(line)
if m == nil || m[4] != IgnoredBy {
if m == nil || !isOurList(m[4]) {
return
}
at, err := time.ParseInLocation("2006-01-02 15:04:05", m[1], time.Local)
+1 -1
View File
@@ -2,7 +2,7 @@
The bars as a module (novox/hq ADR 0208, research 026/05).
- Installs `i3status-rust`. The update block's `checkupdates` comes from `pacman-contrib`, which the `pacman` module installs on every node; two modules declaring one package is refused at composition.
- Installs `i3status-rust`, and `pacman-contrib` for the update block's `checkupdates`. The `pacman` module declares `pacman-contrib` too; several modules may declare one package (novox/hq ADR 0303), so each declares what it needs.
Claims the mesh's `node-bar` seat (no verbs yet, ADR 0208 §2) and renders the blocks other modules
contribute to it (ADR 0255, below). Requires `x11-display` on its own
machine: its bars are i3bar's.
@@ -23,7 +23,8 @@ func TestItClaimsTheBarSeatAndRequiresTheXDisplay(t *testing.T) {
if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
t.Fatalf("requires: %v", m.Requires)
}
if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"i3status-rust"}) || absent != nil {
// pacman-contrib for the update block's checkupdates, declared here though the pacman module declares it too (ADR 0303).
if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"i3status-rust", "pacman-contrib"}) || absent != nil {
t.Fatalf("packages: %v, absent %v", present, absent)
}
}
+5
View File
@@ -35,6 +35,11 @@
"type": "package",
"package": "i3status-rust"
},
{
"id": "checkupdates",
"type": "package",
"package": "pacman-contrib"
},
{
"id": "configuration-dir",
"type": "directory",
+3
View File
@@ -4,6 +4,9 @@
"description": "lab — the lab, as a module: runs beds against the forge's branches when the mesh asks (novox/hq ADR 0172).",
"type": "module",
"private": true,
"scripts": {
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": { "@novox/mesh-sdk": "^0.1.0" },
"devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.6.0" }
}
+100
View File
@@ -0,0 +1,100 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { installPlan, installStep, NPM_REPOSITORIES, REPOSITORIES, script } from "../tools/runs.ts";
// Defends novox/hq issue 624: lab_run ran `npm ci` at the root of every checkout it installs, and
// mesh-tools keeps its package in node-tools/ with no lock file at its root, so every run failed at its
// setup, in 9 seconds, before anything was built.
function aCheckout(files: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), "lab-checkout-"));
for (const [path, content] of Object.entries(files)) {
mkdirSync(join(root, path, ".."), { recursive: true });
writeFileSync(join(root, path), content);
}
return root;
}
test("a checkout with a lock file at its root is installed there", () => {
const root = aCheckout({ "package.json": "{}", "package-lock.json": "{}", "node-tools/package.json": "{}" });
assert.deepEqual(installPlan(root), { install: [root], unlocked: [] });
rmSync(root, { recursive: true, force: true });
});
test("a checkout whose package lives one directory down is installed there, as mesh-tools' node-tools", () => {
const root = aCheckout({ "node-tools/package.json": "{}", "node-tools/package-lock.json": "{}", "Dockerfile": "" });
assert.deepEqual(installPlan(root), { install: [join(root, "node-tools")], unlocked: [] });
rmSync(root, { recursive: true, force: true });
});
test("a package.json with no lock file is named, never installed", () => {
const root = aCheckout({ "package.json": "{}" });
assert.deepEqual(installPlan(root), { install: [], unlocked: [root] });
rmSync(root, { recursive: true, force: true });
});
// The shell a run executes follows the same rule: npm is asked where installPlan says, and a checkout
// with nothing installable fails the run loudly, naming it.
function runStep(root: string, build: boolean): { ok: boolean; said: string; asked: string[] } {
const bin = mkdtempSync(join(tmpdir(), "lab-npm-"));
const record = join(bin, "asked");
writeFileSync(join(bin, "npm"), `#!/bin/sh\necho "$(pwd) $*" >> ${JSON.stringify(record)}\n`);
chmodSync(join(bin, "npm"), 0o755);
const ran = spawnSync("bash", ["-c", `set -euo pipefail\n${installStep(root, build)}`], {
encoding: "utf8", env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
});
let asked: string[] = [];
try {
asked = readFileSync(record, "utf8").trim().split("\n").filter(Boolean);
} catch { /* nothing asked */ }
rmSync(bin, { recursive: true, force: true });
return { ok: ran.status === 0, said: `${ran.stdout}${ran.stderr}`, asked };
}
test("a run installs and builds mesh-tools in node-tools, where its lock file is", () => {
const root = aCheckout({ "node-tools/package.json": "{}", "node-tools/package-lock.json": "{}" });
const r = runStep(root, true);
assert.ok(r.ok, r.said);
assert.deepEqual(r.asked, [
`${join(root, "node-tools")} ci --no-audit --no-fund --loglevel=error`,
`${join(root, "node-tools")} run build --if-present`,
]);
rmSync(root, { recursive: true, force: true });
});
test("a run installs a checkout with its lock file at the root there, and builds only when asked", () => {
const root = aCheckout({ "package.json": "{}", "package-lock.json": "{}" });
const r = runStep(root, false);
assert.ok(r.ok, r.said);
assert.deepEqual(r.asked, [`${root} ci --no-audit --no-fund --loglevel=error`]);
rmSync(root, { recursive: true, force: true });
});
test("a run fails at its setup, naming the checkout, when nothing of it can be installed", () => {
const layouts: Record<string, string>[] = [{ "package.json": "{}" }, { "Dockerfile": "" }, { "sub/package.json": "{}" }];
for (const files of layouts) {
const root = aCheckout(files);
const r = runStep(root, false);
assert.equal(r.ok, false, `installed ${JSON.stringify(files)}`);
assert.match(r.said, /issue 624/);
assert.deepEqual(r.asked, []);
rmSync(root, { recursive: true, force: true });
}
});
// A run checks out mesh-tools-go too (novox/hq issue 616): genesis builds the Go toolchain from the
// repository beside mesh-tools, and a bed that raises genesis serves it. And it tells the beds where the
// checkouts are.
test("a run checks out mesh-tools-go and says where its checkouts are", () => {
assert.ok(REPOSITORIES.includes("mesh-tools-go"));
const s = script("/work", "run-x", "http://forge", ["test/integration/genesis-keeps-its-bus.test.ts"], {});
assert.match(s, /novox\/mesh-tools-go\.git' '\/work\/run-x\/mesh-tools-go'/);
assert.match(s, /export MESH_LAB_REPOS='\/work\/run-x'/);
assert.doesNotMatch(s, /cd '\/work\/run-x'\/\$repo && npm ci/);
for (const repo of NPM_REPOSITORIES) assert.ok(s.includes(`'/work/run-x/${repo}'/package-lock.json`), repo);
});
+39 -2
View File
@@ -4,7 +4,7 @@
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { listRuns, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js";
import { installPlan, listRuns, NPM_REPOSITORIES, readStatus, REPOSITORIES, running, start, stop, tail } from "./runs.js";
export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
const work = env.MESH_LAB_WORK ?? "/var/lib/mesh-lab-runs";
@@ -26,7 +26,12 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
if (clone.status !== 0) return { ok: false, output: clone.stderr };
spawnSync("npm", ["ci", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: dir, encoding: "utf8" });
const check = spawnSync("node", ["--experimental-strip-types", "src/cli.ts", "check"], { cwd: dir, encoding: "utf8" });
return { ok: check.status === 0, output: `${check.stdout}${check.stderr}`.trim() };
// **And what a run would check out and install, before a run finds out** (novox/hq issue 624): every
// repository it clones answers on the forge, and every one it installs has a lock file where the run
// installs from.
const problems = setupProblems(forge, `${work}/check-setup`);
const output = [`${check.stdout}${check.stderr}`.trim(), ...problems].filter(Boolean).join("\n");
return { ok: check.status === 0 && problems.length === 0, output };
},
},
{
@@ -89,6 +94,38 @@ export function getLabTools(env: NodeJS.ProcessEnv): ToolDefinition[] {
];
}
/**
* What would stop a run at its setup, in sentences: a repository the run clones that the forge does not
* answer for, and a checkout it installs with nothing installable (installPlan). Empty when a run's setup
* would go through.
*/
export function setupProblems(forge: string, dir: string): string[] {
const problems: string[] = [];
spawnSync("rm", ["-rf", dir]);
for (const repo of REPOSITORIES) {
const url = `${forge}/novox/${repo}.git`;
const remote = spawnSync("git", ["ls-remote", "--heads", url, "main"], { encoding: "utf8" });
if (remote.status !== 0 || !remote.stdout.includes("refs/heads/main")) {
problems.push(`a run checks out ${repo}, and ${url} answers no main branch`);
continue;
}
if (!NPM_REPOSITORIES.includes(repo)) continue;
const at = `${dir}/${repo}`;
const clone = spawnSync("git", ["clone", "--quiet", "--depth", "1", url, at], { encoding: "utf8" });
if (clone.status !== 0) {
problems.push(`${repo} could not be cloned from ${url}: ${clone.stderr.trim()}`);
continue;
}
const plan = installPlan(at);
for (const d of plan.unlocked) problems.push(`${repo}: ${d.slice(at.length) || "/"} has a package.json and no package-lock.json, so a run's npm ci cannot install it`);
if (plan.install.length === 0 && plan.unlocked.length === 0) {
problems.push(`${repo}: no package-lock.json at its root or one directory down, so a run installs nothing of it`);
}
}
spawnSync("rm", ["-rf", dir]);
return problems;
}
/** One word from an env-file (`KEY=value` lines), or "" when the file or the word is absent. */
export function wordIn(file: string | undefined, word: string): string {
if (!file) return "";
+52 -5
View File
@@ -10,8 +10,55 @@ import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
/** The repositories a lab run checks out, side by side, as the lab expects its siblings. */
export const REPOSITORIES = ["mesh-lab", "mesh-controller", "mesh-host", "mesh-catalog", "mesh-tools", "mesh-sdk"];
/**
* The repositories a lab run checks out, side by side, as the lab expects its siblings. mesh-tools-go is
* one since genesis builds the Go toolchain at its base step, from the repository beside mesh-tools
* (novox/hq issue 616): a bed that raises genesis serves it from its forge.
*/
export const REPOSITORIES = ["mesh-lab", "mesh-controller", "mesh-host", "mesh-catalog", "mesh-tools", "mesh-tools-go", "mesh-sdk"];
/** The checkouts whose JavaScript dependencies a run installs: the lab, the SDK and the base. */
export const NPM_REPOSITORIES = ["mesh-sdk", "mesh-tools", "mesh-lab"];
/**
* Where a checkout's JavaScript dependencies are installed (novox/hq issue 624): its root when the root
* holds a lock file, otherwise each directory one level down that holds one. `npm ci` installs only from a
* lock file, and a repository that moved its package into a directory of its own (mesh-tools' node-tools)
* has none at its root; the run asked for one there and failed before it built anything. A package.json
* with no lock file beside it cannot be installed reproducibly, and is named rather than installed.
*/
export function installPlan(root: string): { install: string[]; unlocked: string[] } {
const has = (dir: string, file: string) => existsSync(join(dir, file));
if (has(root, "package-lock.json")) return { install: [root], unlocked: [] };
const install: string[] = [];
const unlocked: string[] = has(root, "package.json") ? [root] : [];
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue;
const dir = join(root, entry.name);
if (has(dir, "package-lock.json")) install.push(dir);
else if (has(dir, "package.json")) unlocked.push(dir);
}
return { install: install.sort(), unlocked: unlocked.sort() };
}
/**
* installStep is the shell a run installs one checkout with: the same rule as installPlan, so what
* lab_check says a run would install is what it installs. Building runs where the package says how.
*/
export function installStep(root: string, build: boolean): string {
const q = shellQuote(root);
const install = `(cd "$d" && npm ci --no-audit --no-fund --loglevel=error${build ? " && npm run build --if-present" : ""})`;
return `if [ -f ${q}/package-lock.json ]; then d=${q}; ${install}; else
[ ! -f ${q}/package.json ] || { echo "${root}: a package.json and no package-lock.json, so npm ci cannot install it (novox/hq issue 624)" >&2; exit 1; }
found=0
for d in ${q}/*/; do
d="\${d%/}"; [ "$(basename "$d")" = node_modules ] && continue
if [ -f "$d/package-lock.json" ]; then found=1; ${install}
elif [ -f "$d/package.json" ]; then echo "$d: a package.json and no package-lock.json, so npm ci cannot install it (novox/hq issue 624)" >&2; exit 1; fi
done
[ "$found" = 1 ] || { echo "${root}: no package-lock.json at its root or one directory down, so nothing of it can be installed (novox/hq issue 624)" >&2; exit 1; }
fi`;
}
export interface RunStatus {
id: string;
@@ -104,9 +151,7 @@ ${setState("building")}
# resolves it; nothing else is asked of it.
printf '%s\n' ${shellQuote(`@novox:registry=${forge}/api/packages/novox/npm/`)} > ${shellQuote(join(dir, ".npmrc"))}
export NPM_CONFIG_USERCONFIG=${shellQuote(join(dir, ".npmrc"))}
for repo in mesh-sdk mesh-tools mesh-lab; do (cd ${shellQuote(dir)}/$repo && npm ci --no-audit --no-fund --loglevel=error); done
(cd ${shellQuote(dir)}/mesh-sdk && npm run build --if-present)
(cd ${shellQuote(dir)}/mesh-tools && npm run build --if-present)
${NPM_REPOSITORIES.map((repo) => installStep(join(dir, repo), repo !== "mesh-lab")).join("\n")}
mkdir -p ${shellQuote(bin)}
for p in postgres-provisioner objectstore-provisioner route-proxy; do
(cd ${shellQuote(dir)}/mesh-controller && CGO_ENABLED=0 go build -o ${shellQuote(bin)}/$p ./examples/$p)
@@ -117,6 +162,8 @@ export MESH_LAB_MODULES=${shellQuote(join(dir, "mesh-controller", "examples", "m
export MESH_LAB_BUILDER=${shellQuote(join(dir, "mesh-controller", "build", "mesh-builder"))}
export MESH_LAB_BOOTSTRAP_BINARY=${shellQuote(join(dir, "mesh-host", "mesh-bootstrap"))}
export MESH_LAB_CATALOG=${shellQuote(join(dir, "mesh-catalog", "modules"))}
# Where a bed that serves repositories from its own forge finds them: this run's checkouts, side by side.
export MESH_LAB_REPOS=${shellQuote(dir)}
export MESH_LAB_PROVISIONER=${shellQuote(join(bin, "postgres-provisioner"))}
export MESH_LAB_OBJECTSTORE_PROVISIONER=${shellQuote(join(bin, "objectstore-provisioner"))}
export MESH_LAB_ROUTE_PROXY=${shellQuote(join(bin, "route-proxy"))}
+1 -14
View File
@@ -17,20 +17,7 @@ another node requires.
(`/etc/nfs-server/machines`, rendered by the mesh), never one a consumer gives, and it must lie inside
the private network's range. The control node, or any node, gets a share only when both name it. A
want that is not granted, and a grant nobody wants, are said by `exports`. Port 2049 is opened to the
mesh by default. The machine's local network is reached only by a grant to `local-network` (below).
- **The local network, only when granted and admitted** (novox/hq ADR 0341, after ADR 0340). `grants` may
name `local-network` in a node's place: `library=ws:rw,local-network:ro`. The process then reads the
machine's links (`ip -j`) and takes the subnets on the links carrying its default route that lie wholly
in a private range (RFC 1918, fc00::/7); a public subnet, the private network's link and container
bridges are never counted, and no range is ever typed in a setting. It exports to such a subnet only
when the mesh's packet filter (table `inet mesh`, chain `input`, read with `nft -j list ruleset`)
admits 2049/tcp from all of it, which it does once the assignment binds 2049 with the reach
`local-network`. A grant the filter does not admit is not exported, said by `exports`, and makes the
module unhealthy, naming that act. Every device on the local network then acts as the folder's owner,
as the hand-written export it replaces already allowed.
- **A dataset's `sharenfs` is cleared only when nobody loses the folder.** `adopt` refuses while the
property, by its value or the file ZFS writes for it, exports to a client the mesh's live export does
not cover: for a property to the home network, the module's `local-network` export.
mesh only; the home network is never named.
- **Every client is the folder's owner.** NFS with `sec=sys` believes the user id a client sends, and one
id names different accounts on different machines. Every line is `all_squash` with `anonuid` and
`anongid` read from the folder's owner on this machine, so a file written from any node lands owned by
@@ -11,7 +11,7 @@ import (
func TestStrayFindsAnExportOfTheModulesThatNoFileCarries(t *testing.T) {
live := []Entry{{Path: "/srv/library", Client: "10.0.0.4"}}
if got := Stray(live, nil, nil, meshRange, nil); len(got) != 1 {
if got := Stray(live, nil, nil, meshRange); len(got) != 1 {
t.Errorf("an export inside the mesh range that no file carries is the module's leftover: %+v", got)
}
}
@@ -21,7 +21,7 @@ func TestStrayLeavesAnExportAnotherFileCarriesEvenInsideTheMeshRange(t *testing.
// ZFS's own file exports the library to a client inside the mesh's range.
foreign := ParseExports("/srv/library 10.0.0.9(rw,no_subtree_check)\n", "/etc/exports.d/zfs.exports")
live := []Entry{{Path: "/srv/library", Client: "10.0.0.9", Options: "rw"}}
if got := Stray(live, nil, foreign, meshRange, nil); len(got) != 0 {
if got := Stray(live, nil, foreign, meshRange); len(got) != 0 {
t.Errorf("what ZFS's file exports is not the module's, whatever its client: %+v", got)
}
}
@@ -30,7 +30,7 @@ func TestStrayLeavesAnExportAnotherFileCarriesEvenInsideTheMeshRange(t *testing.
func TestStrayLeavesAClientOutsideTheMeshRange(t *testing.T) {
live := []Entry{{Path: "/srv/library", Client: "192.0.2.0/24"}, {Path: "/srv/library", Client: "192.0.2.7"},
{Path: "/srv/library", Client: "10.0.0.0/16"}}
if got := Stray(live, nil, nil, meshRange, nil); len(got) != 0 {
if got := Stray(live, nil, nil, meshRange); len(got) != 0 {
t.Errorf("a client the module never exports to (outside or wider than the mesh's range) is not its leftover: %+v", got)
}
}
@@ -1,417 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/netip"
"sort"
"strings"
)
// A share granted to the machine's local network (novox/hq ADR 0341 clauses 8 and 9, after ADR 0340).
//
// **The grant names `local-network`, never a range.** `grants` may name `local-network` for a share beside
// node names: `library=ws:rw,local-network:ro`. The export goes to the subnets this machine's own local
// network is, by ADR 0340's rules: the networks on the links that carry its default route (the uplink),
// each wholly inside a private range (RFC 1918, or fc00::/7), never a public or link-local one, never the
// private network's own link or a container bridge. A range typed in a setting is never used.
//
// **A grant the filter does not admit is not exported, and is said.** ADR 0340 lets an assignment bind a
// port with the reach `local-network`, and the filter then admits it from those subnets on that port's
// rule alone. The module does not take the reach on trust: every pass it reads the mesh's filter (the
// table `inet mesh`, chain `input`) and exports to a local subnet only when 2049/tcp is admitted from all
// of it. Otherwise the share is not exported there, and the status and the health say so, naming the act
// (bind 2049 with the reach local-network) — a grant never reads as served while the filter drops it.
//
// **What this does not promise.** NFS with sec=sys trusts the client, so every device on the local
// network acts as the share's owner (all_squash, as for a node). That is what the hand-written export it
// replaces already allowed.
// LocalNetwork is the name a grant gives the machine's local network.
const LocalNetwork = "local-network"
// NFSPort is the port the filter must admit from a local subnet before a share is exported to it.
const NFSPort = 2049
// private are the ranges a local network must lie wholly inside (ADR 0340 §2).
var private = []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.168.0.0/16"),
netip.MustParsePrefix("fc00::/7"),
}
// wholly says whether a network lies wholly inside one of the private ranges.
func wholly(p netip.Prefix) bool {
for _, r := range private {
if p.Bits() >= r.Bits() && r.Contains(p.Addr()) {
return true
}
}
return false
}
// ipRoute is one route as `ip -j route show default` answers it.
type ipRoute struct {
Dev string `json:"dev"`
}
// ipLink is one link as `ip -j addr show` answers it.
type ipLink struct {
Name string `json:"ifname"`
AddrInfo []struct {
Family string `json:"family"`
Local string `json:"local"`
Prefixlen int `json:"prefixlen"`
Scope string `json:"scope"`
} `json:"addr_info"`
}
// LocalNetworksFrom is the machine's local networks from what `ip` answered: the links carrying a default
// route, and their addresses. A network not wholly private, or not global in scope, is left out, and why.
func LocalNetworksFrom(routes4, routes6, addrs string) ([]string, []string) {
uplinks := map[string]bool{}
for _, text := range []string{routes4, routes6} {
var rs []ipRoute
if strings.TrimSpace(text) == "" {
continue
}
if err := json.Unmarshal([]byte(text), &rs); err != nil {
continue
}
for _, r := range rs {
if r.Dev != "" {
uplinks[r.Dev] = true
}
}
}
var links []ipLink
if strings.TrimSpace(addrs) != "" {
_ = json.Unmarshal([]byte(addrs), &links)
}
seen := map[string]bool{}
var out, left []string
for _, l := range links {
if !uplinks[l.Name] {
continue
}
for _, a := range l.AddrInfo {
if a.Scope != "global" {
continue
}
addr, err := netip.ParseAddr(a.Local)
if err != nil {
continue
}
p, err := addr.Prefix(a.Prefixlen)
if err != nil {
continue
}
if !wholly(p) {
left = append(left, fmt.Sprintf("%s on %s is not wholly inside a private range, so it is not the local network", p, l.Name))
continue
}
if !seen[p.String()] {
seen[p.String()] = true
out = append(out, p.String())
}
}
}
sort.Strings(out)
return out, left
}
// LocalNetworks is this machine's local networks now, read with `ip`, and what was left out or could not
// be read.
func (m *Machine) LocalNetworks(ctx context.Context) ([]string, []string) {
r4 := m.Run(ctx, "ip", "-j", "-4", "route", "show", "default")
r6 := m.Run(ctx, "ip", "-j", "-6", "route", "show", "default")
ad := m.Run(ctx, "ip", "-j", "addr", "show")
var notes []string
if r4.Code != 0 || ad.Code != 0 {
notes = append(notes, "this machine's links could not be read with ip: "+firstLine(r4.Err+" "+ad.Err))
}
nets, left := LocalNetworksFrom(r4.Out, r6.Out, ad.Out)
return nets, append(notes, left...)
}
// nftRule is one rule of `nft -j list ruleset`.
type nftRule struct {
Family string `json:"family"`
Table string `json:"table"`
Chain string `json:"chain"`
Expr []json.RawMessage `json:"expr"`
}
// nftChain is one chain of `nft -j list ruleset`.
type nftChain struct {
Family string `json:"family"`
Table string `json:"table"`
Name string `json:"name"`
Policy string `json:"policy"`
}
// Admitted says, for each subnet, whether the mesh's filter (table inet mesh, chain input) accepts tcp to
// the port from all of it, read from `nft -j list ruleset`. A rule counts only when every condition it has
// is one this reads — the protocol, the destination port and the source address — and its verdict is
// accept; a rule with any other condition (an interface, a connection state) is left out, so nothing is
// called admitted that may not be. With no such table, nothing is admitted, and why.
func Admitted(ruleset string, port int, subnets []string) (map[string]bool, string) {
out := map[string]bool{}
var doc struct {
Nftables []map[string]json.RawMessage `json:"nftables"`
}
if err := json.Unmarshal([]byte(ruleset), &doc); err != nil {
return out, "the packet filter's rules could not be read (nft -j list ruleset): " + err.Error()
}
var rules []nftRule
found := false
policy := ""
for _, item := range doc.Nftables {
if raw, ok := item["chain"]; ok {
var c nftChain
if json.Unmarshal(raw, &c) == nil && c.Family == "inet" && c.Table == "mesh" && c.Name == "input" {
found, policy = true, c.Policy
}
}
if raw, ok := item["rule"]; ok {
var r nftRule
if json.Unmarshal(raw, &r) == nil && r.Family == "inet" && r.Table == "mesh" && r.Chain == "input" {
rules = append(rules, r)
}
}
}
if !found {
return out, "the mesh's packet filter (table inet mesh, chain input) is not there, so no local network is admitted"
}
if policy == "accept" {
for _, s := range subnets {
out[s] = true
}
return out, ""
}
for _, r := range rules {
sources, ok := admitsPort(r.Expr, port)
if !ok {
continue
}
for _, s := range subnets {
p, err := netip.ParsePrefix(s)
if err != nil {
continue
}
if sources == nil {
out[s] = true
continue
}
for _, src := range sources {
if src.Bits() <= p.Bits() && src.Contains(p.Addr()) {
out[s] = true
}
}
}
}
return out, ""
}
// admitsPort reads one rule: whether it accepts tcp to the port, and from which sources (nil: from
// anywhere). ok is false for a rule that does not accept that port, or that has a condition this does not
// read.
func admitsPort(exprs []json.RawMessage, port int) (sources []netip.Prefix, ok bool) {
accepts, portMatched, anywhere := false, false, true
for _, raw := range exprs {
var e map[string]json.RawMessage
if json.Unmarshal(raw, &e) != nil {
return nil, false
}
if _, is := e["accept"]; is {
accepts = true
continue
}
if _, is := e["counter"]; is {
continue
}
m, is := e["match"]
if !is {
return nil, false
}
var match struct {
Op string `json:"op"`
Left json.RawMessage `json:"left"`
Right json.RawMessage `json:"right"`
}
if json.Unmarshal(m, &match) != nil || (match.Op != "==" && match.Op != "in") {
return nil, false
}
var left struct {
Payload *struct {
Protocol string `json:"protocol"`
Field string `json:"field"`
} `json:"payload"`
Meta *struct {
Key string `json:"key"`
} `json:"meta"`
}
if json.Unmarshal(match.Left, &left) != nil {
return nil, false
}
switch {
case left.Meta != nil && left.Meta.Key == "l4proto":
var proto string
if json.Unmarshal(match.Right, &proto) != nil || proto != "tcp" {
return nil, false
}
case left.Payload != nil && left.Payload.Protocol == "tcp" && left.Payload.Field == "dport":
if !hasPort(match.Right, port) {
return nil, false
}
portMatched = true
case left.Payload != nil && (left.Payload.Protocol == "ip" || left.Payload.Protocol == "ip6") && left.Payload.Field == "saddr":
anywhere = false
sources = append(sources, prefixesOf(match.Right)...)
default:
return nil, false
}
}
if !accepts || !portMatched {
return nil, false
}
if anywhere {
return nil, true
}
if sources == nil {
sources = []netip.Prefix{}
}
return sources, true
}
// hasPort reads a match's right side: a number, or a set holding it.
func hasPort(raw json.RawMessage, port int) bool {
var n int
if json.Unmarshal(raw, &n) == nil {
return n == port
}
var set struct {
Set []json.RawMessage `json:"set"`
}
if json.Unmarshal(raw, &set) == nil {
for _, item := range set.Set {
if json.Unmarshal(item, &n) == nil && n == port {
return true
}
}
}
return false
}
// prefixesOf reads a match's right side as sources: an address, a prefix, or a set of either.
func prefixesOf(raw json.RawMessage) []netip.Prefix {
var s string
if json.Unmarshal(raw, &s) == nil {
if a, err := netip.ParseAddr(s); err == nil {
return []netip.Prefix{netip.PrefixFrom(a, a.BitLen())}
}
if p, err := netip.ParsePrefix(s); err == nil {
return []netip.Prefix{p.Masked()}
}
return nil
}
var pre struct {
Prefix *struct {
Addr string `json:"addr"`
Len int `json:"len"`
} `json:"prefix"`
Set []json.RawMessage `json:"set"`
}
if json.Unmarshal(raw, &pre) != nil {
return nil
}
if pre.Prefix != nil {
if a, err := netip.ParseAddr(pre.Prefix.Addr); err == nil {
if p, err := a.Prefix(pre.Prefix.Len); err == nil {
return []netip.Prefix{p}
}
}
return nil
}
var out []netip.Prefix
for _, item := range pre.Set {
out = append(out, prefixesOf(item)...)
}
return out
}
// LocalNetworkAdmitted is what the process learns each pass: the machine's local subnets, which of them the
// filter admits 2049 from, and what it could not learn.
func (m *Machine) LocalNetworkAdmitted(ctx context.Context) (nets []string, admitted map[string]bool, notes []string) {
nets, notes = m.LocalNetworks(ctx)
if len(nets) == 0 {
return nets, map[string]bool{}, notes
}
r := m.Run(ctx, "nft", "-j", "list", "ruleset")
if r.Code != 0 {
return nets, map[string]bool{}, append(notes, "the packet filter's rules could not be read (nft -j list ruleset): "+firstLine(r.Err))
}
admitted, note := Admitted(r.Out, NFSPort, nets)
if note != "" {
notes = append(notes, note)
}
return nets, admitted, notes
}
// Covered says whether a client a hand-made export names is covered by the mesh's live export of the same
// path: the same address, or a network inside one the mesh exports to. "*" and a name are never covered.
func Covered(client string, mine []Grantee) bool {
var want netip.Prefix
if a, err := netip.ParseAddr(client); err == nil {
want = netip.PrefixFrom(a, a.BitLen())
} else if p, err := netip.ParsePrefix(client); err == nil {
want = p.Masked()
} else {
return false
}
for _, g := range mine {
var have netip.Prefix
if a, err := netip.ParseAddr(g.Address); err == nil {
have = netip.PrefixFrom(a, a.BitLen())
} else if p, err := netip.ParsePrefix(g.Address); err == nil {
have = p.Masked()
} else {
continue
}
if have.Bits() <= want.Bits() && have.Contains(want.Addr()) {
return true
}
}
return false
}
// PropertyClients is who a dataset's sharenfs property exports to: every `@network` or address its value
// names, and "*" for a bare `on` or a value naming nobody, which nothing the mesh exports ever covers.
func PropertyClients(sharenfs string) []string {
v := strings.TrimSpace(sharenfs)
if v == "on" || v == "" {
return []string{"*"}
}
var out []string
for _, opt := range strings.Split(v, ",") {
key, list, ok := strings.Cut(strings.TrimSpace(opt), "=")
if key != "rw" && key != "ro" {
continue
}
if !ok {
return []string{"*"} // rw or ro alone: to everyone
}
for _, c := range strings.Split(list, ":") {
c = strings.TrimPrefix(strings.TrimSpace(c), "@")
if c != "" {
out = append(out, c)
}
}
}
if len(out) == 0 {
return []string{"*"}
}
return out
}
@@ -1,184 +0,0 @@
package main
import (
"context"
"reflect"
"strings"
"testing"
)
// Defends novox/hq ADR 0341 clauses 8 to 10: a share granted to local-network is exported to the machine's
// own private subnets on its uplink, only those the mesh's filter admits 2049 from; a grant the filter does
// not admit is said and unhealthy; and sharenfs is cleared only once the mesh's export covers every client
// the property exported to.
const homeNet = "192.168.1.0/24"
// The home server's links, as `ip -j` answers them: the uplink with a private and a public address, the
// private network's link, and a container bridge.
const (
routes4 = `[{"dst":"default","gateway":"192.168.1.1","dev":"enp6s0"}]`
addrs = `[
{"ifname":"lo","addr_info":[{"family":"inet","local":"127.0.0.1","prefixlen":8,"scope":"host"}]},
{"ifname":"enp6s0","addr_info":[
{"family":"inet","local":"192.168.1.135","prefixlen":24,"scope":"global"},
{"family":"inet","local":"198.51.100.7","prefixlen":24,"scope":"global"},
{"family":"inet6","local":"fe80::1","prefixlen":64,"scope":"link"}]},
{"ifname":"mesh0","addr_info":[{"family":"inet","local":"10.0.0.2","prefixlen":32,"scope":"global"}]},
{"ifname":"docker0","addr_info":[{"family":"inet","local":"172.17.0.1","prefixlen":16,"scope":"global"}]}]`
)
// rulesetAdmitting is the mesh's filter with 2049 admitted from the nodes and, when local is set, from it.
func rulesetAdmitting(local string) string {
src := `{"set":["10.0.0.1","10.0.0.3"]}`
if local != "" {
addr, bits, _ := strings.Cut(local, "/")
src = `{"set":["10.0.0.1","10.0.0.3",{"prefix":{"addr":"` + addr + `","len":` + bits + `}}]}`
}
return `{"nftables":[{"metainfo":{"version":"1.1"}},
{"table":{"family":"inet","name":"mesh"}},
{"chain":{"family":"inet","table":"mesh","name":"input","type":"filter","hook":"input","prio":0,"policy":"drop"}},
{"rule":{"family":"inet","table":"mesh","chain":"input","expr":[{"match":{"op":"!=","left":{"meta":{"key":"iifname"}},"right":{"set":["enp6s0","mesh0"]}}},{"accept":null}]}},
{"rule":{"family":"inet","table":"mesh","chain":"input","expr":[{"match":{"op":"==","left":{"payload":{"protocol":"ip","field":"saddr"}},"right":` + src + `}},{"match":{"op":"==","left":{"payload":{"protocol":"tcp","field":"dport"}},"right":2049}},{"accept":null}]}},
{"rule":{"family":"inet","table":"mesh","chain":"input","expr":[{"match":{"op":"==","left":{"payload":{"protocol":"tcp","field":"dport"}},"right":445}},{"accept":null}]}}]}`
}
func (f *fake) homeLinks(ruleset string) {
f.answers["ip -j -4 route show default"] = Ran{Out: routes4}
f.answers["ip -j -6 route show default"] = Ran{Out: "[]"}
f.answers["ip -j addr show"] = Ran{Out: addrs}
f.answers["nft -j list ruleset"] = Ran{Out: ruleset}
}
func TestTheLocalNetworkIsTheUplinksPrivateSubnetsOnly(t *testing.T) {
nets, left := LocalNetworksFrom(routes4, "", addrs)
if !reflect.DeepEqual(nets, []string{homeNet}) {
t.Errorf("local networks %v; the uplink's private subnet alone, never the public one, the mesh's link or a bridge", nets)
}
if len(left) != 1 || !strings.Contains(left[0], "198.51.100.0/24") {
t.Errorf("the public subnet is not said as left out: %v", left)
}
if nets, _ := LocalNetworksFrom("", "", addrs); len(nets) != 0 {
t.Errorf("with no default route there is no uplink and no local network: %v", nets)
}
}
func TestAdmittedReadsThePortsOwnRuleOnly(t *testing.T) {
got, note := Admitted(rulesetAdmitting(homeNet), 2049, []string{homeNet})
if note != "" || !got[homeNet] {
t.Errorf("2049 admitted from the home network is not read as admitted: %v %q", got, note)
}
got, _ = Admitted(rulesetAdmitting(""), 2049, []string{homeNet})
if got[homeNet] {
t.Error("2049 admitted from the nodes only is read as admitted from the home network")
}
// A rule open to every source on 445 says nothing of 2049, and the interface rule is not read as one.
if got, _ := Admitted(rulesetAdmitting(""), 445, []string{homeNet}); !got[homeNet] {
t.Error("a port open from anywhere is not admitted")
}
if got, note := Admitted(`{"nftables":[]}`, 2049, []string{homeNet}); got[homeNet] || note == "" {
t.Errorf("with no mesh filter a subnet is admitted, or nothing is said: %v %q", got, note)
}
}
// grantedLocal is the library granted to ws and to the local network, ws wanting it.
func grantedLocal(f *fake, grant string) {
f.mountinfo("/", "/srv/library")
f.dir("/srv/library")
f.file(ConfigFile, grantsConfig("library=/srv/library", "library=ws:rw,"+grant))
f.serve("library")
f.kernelReads()
}
func TestAShareGrantedToTheLocalNetworkGoesToItsSubnetWhenTheFilterAdmitsIt(t *testing.T) {
f := newFake(t)
grantedLocal(f, "local-network:ro")
f.homeLinks(rulesetAdmitting(homeNet))
s := f.machine().Pass(context.Background(), nil)
text := f.read(ExportFile)
if !strings.Contains(text, " "+homeNet+"(ro,mp,sync,secure,no_subtree_check,all_squash,") {
t.Errorf("the library is not exported read-only to the home network, squashed:\n%s", text)
}
if strings.Contains(text, "198.51.100") || strings.Contains(text, "172.17") {
t.Errorf("a public subnet or a bridge is exported to:\n%s", text)
}
if len(s.Unreached) != 0 || !s.Shares[0].Live || len(s.Shares[0].To) != 2 {
t.Errorf("status: %+v", s)
}
if h := f.machine().Health(); !h.Healthy || !strings.Contains(h.Why, "local-network ("+homeNet+", ro)") {
t.Errorf("health: %+v", h)
}
}
func TestAGrantTheFilterDoesNotAdmitIsNotExportedAndIsUnhealthy(t *testing.T) {
f := newFake(t)
grantedLocal(f, "local-network:rw")
f.homeLinks(rulesetAdmitting(""))
s := f.machine().Pass(context.Background(), nil)
if strings.Contains(f.read(ExportFile), homeNet) {
t.Errorf("the library is exported to a subnet the filter drops:\n%s", f.read(ExportFile))
}
if len(s.Unreached) != 1 || !strings.Contains(s.Unreached[0], "reach local-network") {
t.Errorf("the unreached grant is not said with its act: %v", s.Unreached)
}
if h := f.machine().Health(); h.Healthy || !strings.Contains(h.Why, "does not admit 2049/tcp") {
t.Errorf("health: %+v", h)
}
}
func TestWithNoPrivateNetworkOnTheUplinkALocalGrantGoesNowhereAndIsSaid(t *testing.T) {
f := newFake(t)
grantedLocal(f, "local-network:rw")
s := f.machine().Pass(context.Background(), nil) // ip answers nothing
if strings.Contains(f.read(ExportFile), "192.168") {
t.Errorf("exported to a local network that was not found:\n%s", f.read(ExportFile))
}
if !strings.Contains(strings.Join(s.Said, "\n"), "reports no private network on its uplink") {
t.Errorf("not said: %v", s.Said)
}
}
func TestAdoptWaitsUntilTheLocalNetworkExportCoversTheProperty(t *testing.T) {
f := newFake(t)
f.uid = 1000
zfsWith(f, "rw=@"+homeNet)
f.file("/etc/exports.d/zfs.exports", "/srv/library "+homeNet+"(sec=sys,rw,no_subtree_check,mountpoint)\n")
grantedLocal(f, "local-network:rw")
f.homeLinks(rulesetAdmitting("")) // the filter does not admit the home network yet
f.machine().Pass(context.Background(), nil)
if _, err := f.machine().Adopt("/srv/library", false, ""); err == nil || !strings.Contains(err.Error(), "does not cover") {
t.Fatalf("adopt while the home network is not covered: %v", err)
}
f.homeLinks(rulesetAdmitting(homeNet))
f.machine().Pass(context.Background(), nil)
f.ran = nil
dry, err := f.machine().Adopt("/srv/library", false, "")
if err != nil || !strings.Contains(dry.Said, "would set sharenfs=off") {
t.Fatalf("dry run once covered: %+v %v", dry, err)
}
for _, r := range f.ran {
if strings.Contains(r, "zfs set") {
t.Fatalf("a dry run changed something: %v", f.ran)
}
}
}
func TestPropertyClientsAndCovered(t *testing.T) {
for v, want := range map[string]string{
"rw=@192.168.1.0/24": "192.168.1.0/24",
"rw=@192.168.1.0/24:@10.0.0.3": "192.168.1.0/24 10.0.0.3",
"on": "*",
"rw,no_root_squash": "*",
"sec=sys,ro=@10.0.0.3": "10.0.0.3",
} {
if got := strings.Join(PropertyClients(v), " "); got != want {
t.Errorf("PropertyClients(%q) = %q, not %q", v, got, want)
}
}
mine := []Grantee{{Node: "ws", Address: "10.0.0.3"}, {Node: LocalNetwork, Address: homeNet}}
for client, want := range map[string]bool{"10.0.0.3": true, homeNet: true, "192.168.1.7": true, "192.168.0.0/16": false, "*": false, "10.0.0.4": false} {
if Covered(client, mine) != want {
t.Errorf("Covered(%q) is not %v", client, want)
}
}
}
@@ -259,9 +259,7 @@ func TestAdoptIsADryRunWithoutConfirmAndRefusesWhileTheMeshExportIsNotLive(t *te
f := newFake(t)
f.uid = 1000
f.dir("/srv/library")
// A property the mesh's export covers once live: the one node. One to the home network is
// localnet_test.go's.
zfsWith(f, "rw=@"+wsAddress)
zfsWith(f, "rw=@192.0.2.0/24")
f.file(ConfigFile, config("library=/srv/library"))
f.serve("library")
// The kernel has not read the mesh's file: no exportfs effect.
+7 -45
View File
@@ -31,11 +31,6 @@ type Status struct {
// Warnings make the module no less healthy but are said in its health: what it could not learn, and
// how old what it relies on is.
Warnings []string `json:"warnings,omitempty"`
// Local is the machine's local subnets this pass (ADR 0341): what a grant to local-network may reach.
Local []string `json:"local_networks,omitempty"`
// Unreached is each grant to the local network the packet filter does not admit: not exported, and the
// module is unhealthy until it is.
Unreached []string `json:"unreached,omitempty"`
// Reloaded is when the process last had the kernel read the export files again, and why.
Reloaded *time.Time `json:"reloaded,omitempty"`
ReloadWhy string `json:"reload_why,omitempty"`
@@ -66,17 +61,8 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
if _, note := m.Backup(); note != "" {
s.Warnings = append(s.Warnings, note)
}
if grantsLocal(c) {
nets, admitted, notes := m.LocalNetworkAdmitted(ctx)
c.Local = &Local{Nets: nets, Admitted: admitted}
s.Local = nets
s.Said = append(s.Said, notes...)
}
m.setWalk(ctx)
exports, refused, said := m.Judge(c, roster, wants)
if c.Local != nil {
s.Unreached = c.Local.Unreached
}
m.setWalk(nil)
if ctx.Err() != nil {
// Stopping: write nothing and reload nothing, so Stopped starts at once.
@@ -114,7 +100,7 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
}
}
if reload == "" {
if stray := Stray(live, mine, foreign, c.Range, s.Local); len(stray) > 0 {
if stray := Stray(live, mine, foreign, c.Range); len(stray) > 0 {
reload = fmt.Sprintf("the kernel exports %s to %s, which the export file no longer carries", stray[0].Path, stray[0].Client)
}
}
@@ -130,7 +116,7 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
if note != "" && len(exports) > 0 {
s.Problems = append(s.Problems, note)
}
for _, e := range Stray(live, mine, foreign, c.Range, s.Local) {
for _, e := range Stray(live, mine, foreign, c.Range) {
s.Problems = append(s.Problems, fmt.Sprintf("the kernel still exports %s to %s, which the module's export file "+
"no longer carries: a reload has to take it away (exportfs -ra as root)", e.Path, e.Client))
}
@@ -270,34 +256,21 @@ func loud(what string) {
}
// Stray is what the kernel exports that is the module's and that its file no longer carries: an entry to a
// client inside the private network's range, or one of the machine's local subnets — the only clients the
// module ever exports to — that neither the module's file nor any other export file (/etc/exports, ZFS's
// own) has. What another file exports is not the module's, whatever its client.
func Stray(live, mine, foreign []Entry, rng string, local []string) []Entry {
// client inside the private network's range — the only clients the module ever exports to — that neither
// the module's file nor any other export file (/etc/exports, ZFS's own) has. What another file exports is
// not the module's, whatever its client.
func Stray(live, mine, foreign []Entry, rng string) []Entry {
prefix, err := netip.ParsePrefix(rng)
if err != nil {
return nil
}
ours := []netip.Prefix{prefix}
for _, n := range local {
if p, err := netip.ParsePrefix(n); err == nil {
ours = append(ours, p.Masked())
}
}
has := map[string]bool{}
for _, e := range append(append([]Entry{}, mine...), foreign...) {
has[path.Clean(e.Path)+" "+e.Client] = true
}
var out []Entry
for _, e := range live {
if has[path.Clean(e.Path)+" "+e.Client] {
continue
}
inside := false
for _, p := range ours {
inside = inside || insideRange(e.Client, p)
}
if !inside {
if has[path.Clean(e.Path)+" "+e.Client] || !insideRange(e.Client, prefix) {
continue
}
out = append(out, e)
@@ -315,14 +288,3 @@ func insideRange(client string, prefix netip.Prefix) bool {
}
return false
}
// grantsLocal says whether any share is granted to the local network, so the process looks at the links
// and the filter only when a grant asks for them.
func grantsLocal(c Config) bool {
for _, nodes := range c.Grants {
if _, ok := nodes[LocalNetwork]; ok {
return true
}
}
return false
}
+3 -34
View File
@@ -28,8 +28,7 @@ import (
// write to WantsFile). The access is the lower of the two: a grant of ro is never widened by a want. The
// address is the mesh's own, from the roster the mesh renders (RosterFile), never one a consumer gave,
// and it must lie in the private network's range. A share nobody is granted and wants is exported to
// nobody. The local network is named only by a grant to `local-network`, and then only the subnets the
// packet filter admits 2049 from (localnet.go, novox/hq ADR 0341).
// nobody. The home network is never named.
//
// **Never the backups, never root's.** A share is judged at its real path (symlinks resolved). It is
// refused when it is the machine's root, is not there, is not a folder, is owned by root or root's group
@@ -87,17 +86,6 @@ type Config struct {
// Grants is, per share, the nodes this machine lets mount it and with which access.
Grants map[string]map[string]string
Range string
// Local is the machine's local network as the process found it this pass (localnet.go): nil when it was
// not looked at, and a grant to local-network then goes nowhere.
Local *Local
}
// Local is the machine's local subnets, which of them the filter admits 2049 from, and — written by Judge —
// each local-network grant the filter does not admit.
type Local struct {
Nets []string
Admitted map[string]bool
Unreached []string
}
// Want is one share a node's mounts module wants of this machine, as it put it on the bus.
@@ -184,8 +172,7 @@ func ParseShares(value string) ([]Share, []string) {
}
// ParseGrants reads the `grants` setting: `share=node:rw|ro[,node:rw|ro]`, entries separated by spaces;
// `none` grants nothing. `local-network` in a node's place grants the machine's local network
// (localnet.go).
// `none` grants nothing.
func ParseGrants(value string) (map[string]map[string]string, []string) {
out := map[string]map[string]string{}
seen := map[string]bool{}
@@ -470,25 +457,8 @@ func (m *Machine) Judge(c Config, roster map[string]string, wants map[string][]W
e.To = append(e.To, Grantee{Node: node, Address: addr, Access: access})
}
}
if g, ok := granted[LocalNetwork]; ok {
switch {
case c.Local == nil || len(c.Local.Nets) == 0:
said = append(said, fmt.Sprintf("%s is granted to the local network, and this machine reports no private network on its uplink, so it is exported to no local network", s.Name))
default:
for _, n := range c.Local.Nets {
if !c.Local.Admitted[n] {
c.Local.Unreached = append(c.Local.Unreached, fmt.Sprintf("the share %s is granted to the local network %s, and the packet filter does not admit %d/tcp from it, so it is not exported there: bind nfs-server's %d with the reach local-network (novox/hq ADR 0340, ADR 0341)", s.Name, n, NFSPort, NFSPort))
continue
}
e.To = append(e.To, Grantee{Node: LocalNetwork, Address: n, Access: lower(g, s.Access)})
}
}
}
grantedNodes := make([]string, 0, len(granted))
for n := range granted {
if n == LocalNetwork {
continue
}
if !wanting[n] {
grantedNodes = append(grantedNodes, n)
}
@@ -504,7 +474,7 @@ func (m *Machine) Judge(c Config, roster map[string]string, wants map[string][]W
e.Clients = strings.Join(addrs, ",")
e.Line = ExportLine(e)
if len(e.To) == 0 {
said = append(said, fmt.Sprintf("%s is exported to no node: none is both granted it and wants it, and no local network is granted and admitted", s.Name))
said = append(said, fmt.Sprintf("%s is exported to no node: none is both granted it and wants it", s.Name))
}
out = append(out, e)
}
@@ -547,7 +517,6 @@ func ExportFileText(exports []Exported) string {
b.WriteString("# Written by the mesh's module nfs-server (novox/hq ADR 0263). Rendered by the module's own process\n")
b.WriteString("# from its assignment's `shares` and `grants` and the nodes that want each share; edit the settings,\n")
b.WriteString("# never this file. One share per folder, to each granted and wanting node's private address only,\n")
b.WriteString("# and to a local subnet only when local-network is granted and the filter admits 2049 from it;\n")
b.WriteString("# every client squashed to the folder's owner.\n")
for _, e := range exports {
if e.Line == "" {
+1 -29
View File
@@ -127,7 +127,7 @@ func (m *Machine) Exports() ExportsAnswer {
}
a.Notes = append(a.Notes, st.Problems...)
a.Notes = append(a.Notes, st.Warnings...)
a.Unmatched = append(append([]string{}, st.Said...), st.Unreached...)
a.Unmatched = st.Said
}
entries, sets, notes := m.Foreign()
a.Notes = append(a.Notes, notes...)
@@ -380,31 +380,6 @@ func (m *Machine) Adopt(p string, confirm bool, why string) (AdoptAnswer, error)
if lnote != "" || !SharedLive(live, *mine) {
return a, fmt.Errorf("the mesh's export of %s to %s is not live in the kernel yet, so taking over would unexport it; wait for the module's process, or call reload", p, grantees(mine.To))
}
// Every client the property exported to must be covered by the mesh's live export, so no client loses
// the folder in the take-over (novox/hq ADR 0341 clause 10): for a property to the home network, the
// module's local-network export of it. Who the property exports to is read from its value and from the
// file ZFS writes for it.
clients := PropertyClients(ds.ShareNFS)
entries, _, _ := m.Foreign()
for _, e := range entries {
if strings.Contains(e.From, "zfs") && path.Clean(e.Path) == p {
clients = append(clients, e.Client)
}
}
var uncovered []string
seenClient := map[string]bool{}
for _, c := range clients {
if seenClient[c] {
continue
}
seenClient[c] = true
if !Covered(c, mine.To) {
uncovered = append(uncovered, c)
}
}
if len(uncovered) > 0 {
return a, fmt.Errorf("the dataset's sharenfs exports %s to %s, which the mesh's export (to %s) does not cover, so taking over would unexport it for them: grant the share to local-network (and bind %d with the reach local-network) until the mesh's export covers them, or have a person decide they lose it", p, strings.Join(uncovered, ", "), grantees(mine.To), NFSPort)
}
plan := fmt.Sprintf("set sharenfs=off on the dataset %s (now %q) and run exportfs -ra; %s stays exported as the share %s to %s, every client as %d:%d",
ds.Name, ds.ShareNFS, p, mine.Name, grantees(mine.To), mine.UID, mine.GID)
if !confirm {
@@ -453,9 +428,6 @@ func (m *Machine) Health() Health {
for _, r := range st.Refused {
found = append(found, r.Why)
}
for _, u := range st.Unreached {
found = append(found, sentence(u))
}
if st.ReloadError != "" {
found = append(found, sentence("the kernel could not read the export files again: "+st.ReloadError))
}
+3 -3
View File
@@ -55,7 +55,7 @@
"grants": {
"kind": "preference",
"default": "none",
"why": "which nodes may mount which share, and how, is this machine's to say (hq ADR 0263, review of mesh-catalog #136): share=node:rw|ro[,node:rw|ro], entries separated by spaces; none exports to no node, whatever a node wants. local-network in a node's place grants this machine's local network, the private subnets on its uplink (hq ADR 0341)"
"why": "which nodes may mount which share, and how, is this machine's to say (hq ADR 0263, review of mesh-catalog #136): share=node:rw|ro[,node:rw|ro], entries separated by spaces; none exports to no node, whatever a node wants"
}
},
"facts": {
@@ -74,7 +74,7 @@
"protocol": "tcp",
"from": "mesh",
"fixed": true,
"why": "the shares, to the mesh's machines (hq ADR 0263): NFS version 4 alone, which needs no other port. A machine whose grants name local-network binds this port with the reach local-network (hq ADR 0340, ADR 0341), and the module exports to a local subnet only while the filter admits it from there"
"why": "the shares, to the mesh's machines only (hq ADR 0263): NFS version 4 alone, which needs no other port, and never the home network, where a device that is not a node could claim any user id"
}
],
"resources": [
@@ -154,7 +154,7 @@
"type": "file",
"path": "/etc/nfs-server/shares.conf",
"mode": "0644",
"content": "# Written by the mesh (module nfs-server, novox/hq ADR 0263) from this machine's assignment.\n# Replaced on every push; change the `shares` setting, never this file.\n#\n# The shares: name=folder, or name=folder:ro, one share per folder. The grants: share=node:rw|ro[,…],\n# which nodes may mount each and how; local-network in a node's place grants this machine's local\n# network (novox/hq ADR 0341). The module's process exports a share to a node's private address only\n# when it is granted here and that node's mounts module wants it, and to a local subnet only when the\n# packet filter admits 2049 from it; every client mapped to the folder's owner.\nshares=${setting:shares}\ngrants=${setting:grants}\nrange=${machine:mesh-range}\n"
"content": "# Written by the mesh (module nfs-server, novox/hq ADR 0263) from this machine's assignment.\n# Replaced on every push; change the `shares` setting, never this file.\n#\n# The shares: name=folder, or name=folder:ro, one share per folder. The grants: share=node:rw|ro[,…],\n# which nodes may mount each and how. The module's process exports a share to a node's private address\n# only when it is granted here and that node's mounts module wants it, every client mapped to the\n# folder's owner, and only to addresses inside the range below.\nshares=${setting:shares}\ngrants=${setting:grants}\nrange=${machine:mesh-range}\n"
}
],
"build": {
-288
View File
@@ -1,288 +0,0 @@
package main
import (
"fmt"
"path"
"sort"
"strings"
)
// The shares and logins, from the assignment, the hand-written configuration as found, and the module's
// rendering (novox/hq ADR 0341 clauses 2 to 4).
//
// **One share per folder, every client as its owner.** A share is a name and a path in the `shares`
// setting (`library=/srv/library`, `:ro` for read-only). It is served with `force user` and `force group`
// read from the real folder's owner, never written as numbers in a setting, so a file written by any client
// lands owned by the folder's owner. The refusals are ADR 0263's: the machine's root, a folder not there or
// not a folder, a folder owned by root or root's group, and a folder inside or holding a backup repository.
//
// **SMB 2.1 and later, on 445 alone.** NetBIOS is off (`disable netbios`, `smb ports = 445`), so 137, 138 and
// 139 are never served; the minimum protocol is SMB2_10. No guest: `map to guest = never`.
// Share is one share the operator asked for.
type Share struct {
Name string `json:"name"`
Path string `json:"path"`
Access string `json:"access"`
}
// Config is what the mesh rendered from the assignment.
type Config struct {
Shares []Share
Users []string
// Passwords is the folder the mesh places the logins' secrets in (smb-password-<user>.secret).
Passwords string
}
// ParseConfig reads the config file: `shares=`, `users=` and `passwords=` lines.
func ParseConfig(text string) (Config, []string) {
var c Config
var problems []string
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
problems = append(problems, fmt.Sprintf("the line %q of %s says nothing this module reads", line, ConfigFile))
continue
}
switch strings.TrimSpace(key) {
case "shares":
shares, bad := ParseShares(value)
c.Shares, problems = shares, append(problems, bad...)
case "users":
users, bad := ParseUsers(value)
c.Users, problems = users, append(problems, bad...)
case "passwords":
c.Passwords = strings.TrimSpace(value)
default:
problems = append(problems, fmt.Sprintf("%s names %q, which this module does not read", ConfigFile, key))
}
}
return c, problems
}
// ParseShares reads the `shares` setting: `name=path` or `name=path:ro`, separated by spaces.
func ParseShares(value string) ([]Share, []string) {
var shares []Share
var problems []string
seen := map[string]bool{}
for _, item := range strings.Fields(value) {
if item == "none" {
continue
}
name, p, ok := strings.Cut(item, "=")
if !ok || name == "" || p == "" {
problems = append(problems, fmt.Sprintf("the share %q is not name=path", item))
continue
}
if !validName(name) {
problems = append(problems, fmt.Sprintf("the share name %q is not lower-case letters, digits and dashes", name))
continue
}
if seen[name] {
problems = append(problems, fmt.Sprintf("the share %q is named twice", name))
continue
}
seen[name] = true
s := Share{Name: name, Path: p, Access: "rw"}
if strings.HasSuffix(p, ":ro") {
s.Path, s.Access = strings.TrimSuffix(p, ":ro"), "ro"
} else if strings.HasSuffix(p, ":rw") {
s.Path = strings.TrimSuffix(p, ":rw")
}
shares = append(shares, s)
}
sort.Slice(shares, func(i, j int) bool { return shares[i].Name < shares[j].Name })
return shares, problems
}
// ParseUsers reads the `users` setting: login names separated by spaces; `none` is none.
func ParseUsers(value string) ([]string, []string) {
var out, problems []string
seen := map[string]bool{}
for _, u := range strings.Fields(value) {
if u == "none" {
continue
}
if !validName(u) {
problems = append(problems, fmt.Sprintf("the login %q is not lower-case letters, digits and dashes", u))
continue
}
if !seen[u] {
seen[u] = true
out = append(out, u)
}
}
sort.Strings(out)
return out, problems
}
func validName(n string) bool {
if n == "" || n[0] == '-' {
return false
}
for _, r := range n {
if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') {
return false
}
}
return true
}
// Section is one section of a Samba configuration: its name and its parameters, keys lower-cased with their
// spaces folded.
type Section struct {
Name string `json:"name"`
Params map[string]string `json:"params"`
}
// ParseSamba reads a Samba configuration's sections, in order. Comments (# and ;) and continuation lines are
// handled as Samba does.
func ParseSamba(text string) []Section {
var out []Section
var cur *Section
text = strings.ReplaceAll(text, "\\\n", " ")
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" || line[0] == '#' || line[0] == ';' {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
out = append(out, Section{Name: strings.TrimSpace(line[1 : len(line)-1]), Params: map[string]string{}})
cur = &out[len(out)-1]
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok || cur == nil {
continue
}
cur.Params[foldKey(k)] = strings.TrimSpace(v)
}
return out
}
func foldKey(k string) string { return strings.Join(strings.Fields(strings.ToLower(k)), " ") }
// special are the sections that are not shares.
var special = map[string]bool{"global": true, "homes": true, "printers": true, "print$": true}
// HandShare is one share of the hand-written configuration, and the setting's share that takes its place.
type HandShare struct {
Name string `json:"name"`
Path string `json:"path"`
// ValidUsers is who may log in to it, as the hand-written file said.
ValidUsers string `json:"valid_users,omitempty"`
ReadOnly bool `json:"read_only"`
// Counterpart is the setting's share of the same name and folder; empty when the settings name none.
Counterpart string `json:"counterpart,omitempty"`
Why string `json:"why,omitempty"`
}
// HandShares are the hand-written configuration's shares, each matched to the setting's share of the same
// name serving the same folder (a trailing slash aside).
func HandShares(sections []Section, shares []Share) []HandShare {
byName := map[string]Share{}
for _, s := range shares {
byName[strings.ToLower(s.Name)] = s
}
var out []HandShare
for _, sec := range sections {
if special[strings.ToLower(sec.Name)] {
continue
}
h := HandShare{Name: sec.Name, Path: sec.Params["path"], ValidUsers: sec.Params["valid users"], ReadOnly: readOnly(sec.Params)}
s, ok := byName[strings.ToLower(sec.Name)]
switch {
case !ok:
h.Why = fmt.Sprintf("the setting shares names no share %s", sec.Name)
case cleanPath(s.Path) != cleanPath(h.Path):
h.Why = fmt.Sprintf("the setting's share %s serves %s, and the hand-written one serves %s", s.Name, s.Path, h.Path)
default:
h.Counterpart = s.Name
}
out = append(out, h)
}
return out
}
func cleanPath(p string) string {
if p == "" {
return ""
}
return path.Clean(p)
}
// readOnly is Samba's reading of a share's access: read only = yes unless writable/writeable/write ok says
// otherwise, as Samba defaults it.
func readOnly(p map[string]string) bool {
yes := func(v string) bool {
v = strings.ToLower(strings.TrimSpace(v))
return v == "yes" || v == "true" || v == "1"
}
if v, ok := p["read only"]; ok {
return yes(v)
}
for _, k := range []string{"writable", "writeable", "write ok"} {
if v, ok := p[k]; ok {
return !yes(v)
}
}
return true
}
// Served is a share as the module serves it.
type Served struct {
Name string `json:"name"`
Path string `json:"path"`
Access string `json:"access"`
UID int `json:"uid"`
GID int `json:"gid"`
User string `json:"force_user"`
Group string `json:"force_group"`
Users []string `json:"valid_users"`
// Live is whether Samba serves it now, as testparm reads the configuration in use.
Live bool `json:"live"`
}
// Refused is a share that is not served, and why.
type Refused struct {
Name string `json:"name"`
Path string `json:"path"`
Why string `json:"why"`
}
// Render is Samba's whole configuration from the shares the module serves.
func Render(shares []Served) string {
var b strings.Builder
b.WriteString(Marker + "\n")
b.WriteString("# Rendered by the module's own process from its assignment's settings shares and users; change the\n")
b.WriteString("# settings, never this file. The hand-written configuration it replaced is kept beside it, dated.\n")
b.WriteString("[global]\n")
for _, kv := range [][2]string{
{"server role", "standalone server"},
{"security", "user"},
{"passdb backend", "tdbsam"},
{"map to guest", "never"},
{"server min protocol", "SMB2_10"},
{"disable netbios", "yes"},
{"smb ports", "445"},
{"load printers", "no"},
{"printing", "bsd"},
{"printcap name", "/dev/null"},
{"disable spoolss", "yes"},
} {
fmt.Fprintf(&b, " %s = %s\n", kv[0], kv[1])
}
for _, s := range shares {
ro := "no"
if s.Access == "ro" {
ro = "yes"
}
fmt.Fprintf(&b, "\n[%s]\n path = %s\n read only = %s\n browseable = yes\n guest ok = no\n", s.Name, s.Path, ro)
fmt.Fprintf(&b, " valid users = %s\n force user = %s\n force group = %s\n", strings.Join(s.Users, " "), s.User, s.Group)
}
return b.String()
}
@@ -1,279 +0,0 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
// The machine as the module sees it (novox/hq ADR 0341).
//
// **Two processes, two accounts.** The tools run as the operator account (ADR 0175 §4) and read; what only
// root may do (`smbcontrol`, `smbstatus`) goes through `sudo -n`. The module's long-running process runs as
// root: it judges the shares, and once a person adopted the configuration it writes Samba's configuration
// file whole, sets the logins' passwords from their secrets, and stops NetBIOS.
//
// **It writes its own files and nothing else.** Samba's configuration (only after adoption), its dated copy
// of the hand-written one, the status, and the record of which passwords it set. Nothing here can create,
// chown, chmod or remove anything under a shared path: the machine offers no such call, and Write refuses
// every path but the module's (novox/hq ADR 0051).
// The module's files on the machine.
const (
// ConfigFile is what the mesh renders from the assignment: the shares, the logins, where the secrets are.
ConfigFile = "/etc/smb-server/shares.conf"
// SambaConf is Samba's configuration: the hand-written one until adoption, the module's after.
SambaConf = "/etc/samba/smb.conf"
// Candidate is the module's rendering, checked with testparm before it takes SambaConf's place.
Candidate = "/etc/samba/smb.conf.mesh-new"
// HandCopyPrefix begins the dated copy of the hand-written configuration kept at adoption.
HandCopyPrefix = "/etc/samba/smb.conf.hand-written-"
// StatusFile is the process's last look, for the tools.
StatusFile = "/run/smb-server/status.json"
// AdoptDir is where the adopt verb, as the operator account, leaves a person's word for the process.
AdoptDir = "/var/lib/smb-server/adopt"
// AdoptFile is that word: when, and why.
AdoptFile = AdoptDir + "/adopt.json"
// SetFile is which password the process last set for each login, as a digest, readable by root only.
SetFile = "/var/lib/smb-server/passwords.json"
// Unit is Samba's service; NMBUnit its NetBIOS name service, stopped once the module takes over.
Unit = "smb.service"
NMBUnit = "nmb.service"
// Port is the one port SMB is served on.
Port = 445
)
// Marker is the first line of a configuration the module wrote: how it tells its own from a person's.
const Marker = "# Written by the mesh (module smb-server, novox/hq ADR 0341)."
// writable is every file the module may write.
func writable(p string) bool {
switch p {
case SambaConf, Candidate, StatusFile, AdoptFile, SetFile:
return true
}
return strings.HasPrefix(p, HandCopyPrefix) && !strings.Contains(strings.TrimPrefix(p, HandCopyPrefix), "/")
}
// CommandTimeout bounds every command.
const CommandTimeout = 20 * time.Second
// Ran is what one command did.
type Ran struct {
Out string
Err string
Code int
}
// Runner runs one command, with what it reads on stdin; injected, so everything is tested over recorded
// answers.
type Runner func(ctx context.Context, input, name string, args ...string) Ran
// ExecRunner runs a command on the machine, bounded by CommandTimeout.
func ExecRunner(ctx context.Context, input, name string, args ...string) Ran {
ctx, cancel := context.WithTimeout(ctx, CommandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
if input != "" {
cmd.Stdin = strings.NewReader(input)
}
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
r := Ran{Out: stdout.String(), Err: strings.TrimSpace(stderr.String())}
if err != nil {
var exit *exec.ExitError
if errors.As(err, &exit) {
r.Code = exit.ExitCode()
} else {
r.Code = -1
if r.Err == "" {
r.Err = err.Error()
}
}
}
return r
}
// Owner is who owns a path, and whether it is a directory.
type Owner struct {
UID, GID int
Dir bool
}
// Machine is a filesystem root (the real one, or a test's tree), a runner, who this process is, and a clock.
type Machine struct {
Root string
Run Runner
UID int
Now func() time.Time
// ownerOf reads a file's owner from its inode; tests set it so their folders stand for an owner.
ownerOf func(info os.FileInfo) (uid, gid int)
}
// Here is this machine.
func Here() *Machine {
return &Machine{Root: "/", Run: ExecRunner, UID: os.Getuid(), Now: time.Now, ownerOf: inodeOwner}
}
func (m *Machine) at(p string) string { return filepath.Join(m.Root, p) }
// Read reads a file.
func (m *Machine) Read(p string) (string, error) {
b, err := os.ReadFile(m.at(p))
return string(b), err
}
// Exists says whether a path is there.
func (m *Machine) Exists(p string) bool {
_, err := os.Lstat(m.at(p))
return err == nil
}
// Stat is a path's owner and whether it is a folder.
func (m *Machine) Stat(p string) (Owner, error) {
info, err := os.Stat(m.at(p))
if err != nil {
return Owner{}, err
}
of := m.ownerOf
if of == nil {
of = inodeOwner
}
uid, gid := of(info)
return Owner{UID: uid, GID: gid, Dir: info.IsDir()}, nil
}
// Real is a path with every symbolic link followed, as the machine sees it.
func (m *Machine) Real(p string) (string, error) {
real, err := filepath.EvalSymlinks(m.at(p))
if err != nil {
return "", err
}
rel, err := filepath.Rel(m.Root, real)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("%s leads outside the machine", p)
}
return path.Join("/", filepath.ToSlash(rel)), nil
}
// Children are a folder's subfolders, by name; symbolic links are not followed.
func (m *Machine) Children(p string) []string {
entries, err := os.ReadDir(m.at(p))
if err != nil {
return nil
}
var out []string
for _, e := range entries {
if e.Type()&fs.ModeSymlink == 0 && e.IsDir() {
out = append(out, e.Name())
}
}
return out
}
// Write writes one of the module's own files, and refuses every other path.
func (m *Machine) Write(p, content string, mode os.FileMode) error {
if !writable(p) {
return fmt.Errorf("%s is not one of the module's files; it writes nothing else", p)
}
if err := os.MkdirAll(filepath.Dir(m.at(p)), 0o755); err != nil {
return err
}
tmp := m.at(p) + ".tmp"
if err := os.WriteFile(tmp, []byte(content), mode); err != nil {
return err
}
return os.Rename(tmp, m.at(p))
}
// Place moves the checked candidate into Samba's configuration's place.
func (m *Machine) Place() error { return os.Rename(m.at(Candidate), m.at(SambaConf)) }
// NameOf is the account or group name for an id, read from the machine's passwd or group file.
func (m *Machine) NameOf(file string, id int) string {
text, _ := m.Read(file)
for _, line := range strings.Split(text, "\n") {
f := strings.Split(line, ":")
if len(f) >= 3 && f[2] == strconv.Itoa(id) {
return f[0]
}
}
return ""
}
// HasAccount says whether the machine has an account of that name.
func (m *Machine) HasAccount(name string) bool {
text, _ := m.Read("/etc/passwd")
for _, line := range strings.Split(text, "\n") {
if f := strings.SplitN(line, ":", 2); len(f) == 2 && f[0] == name {
return true
}
}
return false
}
// Escalated is a command as the operator account runs what only root may: through sudo without a prompt.
func (m *Machine) Escalated(name string, args ...string) (string, []string) {
if m.UID == 0 {
return name, args
}
return "sudo", append([]string{"-n", name}, args...)
}
// AsRoot runs what only root may do, and names a refusal by how it failed.
func (m *Machine) AsRoot(ctx context.Context, input, name string, args ...string) (Ran, error) {
program, argv := m.Escalated(name, args...)
r := m.Run(ctx, input, program, argv...)
if r.Code != 0 {
why := r.Err
if why == "" {
why = strings.TrimSpace(r.Out)
}
return r, fmt.Errorf("%s %s: exit %d: %s", name, strings.Join(args, " "), r.Code, why)
}
return r, nil
}
// UnitState is what the service manager says of a unit: active, inactive, failed, …
func (m *Machine) UnitState(unit string) string {
r := m.Run(context.Background(), "", "systemctl", "is-active", unit)
if s := strings.TrimSpace(r.Out); s != "" {
return s
}
return "unknown"
}
// UnitEnabled is what the service manager says of a unit's boot: enabled, disabled, …
func (m *Machine) UnitEnabled(unit string) string {
r := m.Run(context.Background(), "", "systemctl", "is-enabled", unit)
if s := strings.TrimSpace(r.Out); s != "" {
return s
}
return "unknown"
}
func inodeOwner(info os.FileInfo) (uid, gid int) {
if st, ok := info.Sys().(*syscall.Stat_t); ok {
return int(st.Uid), int(st.Gid)
}
return -1, -1
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
-38
View File
@@ -1,38 +0,0 @@
// The smb-server module's Go bundle (novox/hq ADR 0341): a node serves folders over SMB to the devices on its
// own network, one share per folder, every client as the folder's owner, SMB 2.1 and later on 445 alone.
//
// Started with no arguments it is served by the node's runtime as the operator account, over MCP on stdio
// (ADR 0188, ADR 0193): the node-smb-server seat's verbs and the module's health. Started as
// `smb-server config` it is the module's long-running process, as root (process.go). stdout is the MCP
// channel; everything else is said on stderr.
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if len(os.Args) > 1 {
if os.Args[1] != "config" || len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: smb-server [config]")
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer stop()
if err := Here().RunConfig(ctx); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
if err := stdio.Serve("", Tools(Here())); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
@@ -1,397 +0,0 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"path"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/health"
)
// The module's process, as root: every Every it judges the shares, and — only once a person adopted the
// configuration — writes Samba's configuration whole, checks it with testparm before Samba reads it, sets the
// logins' passwords from their secrets and stops NetBIOS (novox/hq ADR 0341 clauses 3 to 7). Before adoption
// it reads the hand-written configuration, reports it, and writes nothing but its status.
// Every is how often the process looks.
const Every = 30 * time.Second
// Adoption is a person's word to take over, left by the adopt verb.
type Adoption struct {
At string `json:"at"`
Why string `json:"why"`
// Copy is the dated copy of the hand-written configuration, once the process kept it.
Copy string `json:"copy,omitempty"`
}
// Login is one login of the `users` setting, as the process found it.
type Login struct {
Name string `json:"name"`
// Password is "set" (from its secret), "kept as found" (in Samba's database, no secret given),
// "waits" (no secret, not in Samba's database) or "refused" (why says).
Password string `json:"password"`
Why string `json:"why,omitempty"`
}
// Status is the process's last look, as the tools read it.
type Status struct {
At string `json:"at"`
// Mode is "found" (the hand-written configuration serves, kept as found) or "adopted" (the module's).
Mode string `json:"mode"`
Shares []Served `json:"shares"`
Refused []Refused `json:"refused,omitempty"`
Hand []HandShare `json:"hand_written,omitempty"`
Logins []Login `json:"logins,omitempty"`
Problems []string `json:"problems,omitempty"`
Waits []health.Wait `json:"waits,omitempty"`
Adopted *Adoption `json:"adopted,omitempty"`
// Written is when the process last wrote Samba's configuration, and Reloaded when Samba last read it.
Written string `json:"written,omitempty"`
Reloaded string `json:"reloaded,omitempty"`
NetBIOS string `json:"netbios,omitempty"`
}
// WalkDepth and WalkLimit bound the look below a share for a backup repository (ADR 0263 §3).
const (
WalkDepth = 4
WalkLimit = 200000
)
// isRepository says whether a folder is a restic repository: `config` beside `keys`, `snapshots`, `data`.
func (m *Machine) isRepository(dir string) bool {
if o, err := m.Stat(path.Join(dir, "config")); err != nil || o.Dir {
return false
}
for _, sub := range []string{"keys", "snapshots", "data"} {
if o, err := m.Stat(path.Join(dir, sub)); err != nil || !o.Dir {
return false
}
}
return true
}
// repositoryNear finds a backup repository at, above or within WalkDepth levels below a folder; complete is
// false when the look below stopped at its bound.
func (m *Machine) repositoryNear(real string) (found string, complete bool) {
for p := real; ; p = path.Dir(p) {
if m.isRepository(p) {
return p, true
}
if p == "/" {
break
}
}
seen, level := 0, []string{real}
for depth := 1; depth <= WalkDepth && len(level) > 0; depth++ {
var next []string
for _, d := range level {
for _, c := range m.Children(d) {
if seen++; seen > WalkLimit {
return "", false
}
p := path.Join(d, c)
if m.isRepository(p) {
return p, true
}
next = append(next, p)
}
}
level = next
}
return "", true
}
// Judge turns the asked-for shares into served shares, or refusals.
func (m *Machine) Judge(c Config) ([]Served, []Refused) {
var out []Served
var refused []Refused
for _, s := range c.Shares {
no := func(why string) { refused = append(refused, Refused{Name: s.Name, Path: s.Path, Why: why}) }
if !path.IsAbs(s.Path) || strings.ContainsAny(s.Path, "\n\r\"\\") {
no(fmt.Sprintf("The share %s names %q, which is not an absolute path a configuration can carry.", s.Name, s.Path))
continue
}
real, err := m.Real(path.Clean(s.Path))
if err != nil {
no(fmt.Sprintf("The share %s's folder %s is not there (%v); the module never creates it.", s.Name, s.Path, err))
continue
}
if real == "/" {
no(fmt.Sprintf("The share %s is the machine's root, which is never shared.", s.Name))
continue
}
o, err := m.Stat(real)
if err != nil || !o.Dir {
no(fmt.Sprintf("The share %s's path %s is not a folder.", s.Name, real))
continue
}
if o.UID == 0 || o.GID == 0 {
no(fmt.Sprintf("The share %s's folder %s is owned by root (%d:%d), and every client would act as root there: give it an owner of its own.", s.Name, real, o.UID, o.GID))
continue
}
if p, complete := m.repositoryNear(real); p != "" {
no(fmt.Sprintf("The share %s's folder %s is at, inside or above the backup repository %s, and backups are never shared.", s.Name, real, p))
continue
} else if !complete {
no(fmt.Sprintf("The share %s's folder %s holds more folders than the module looks through (%d levels, at most %d), so it could not make sure no backup repository is under it.", s.Name, real, WalkDepth, WalkLimit))
continue
}
user, group := m.NameOf("/etc/passwd", o.UID), m.NameOf("/etc/group", o.GID)
if user == "" || group == "" {
no(fmt.Sprintf("The share %s's folder %s is owned by %d:%d, which this machine names no account or group for, so no client can be made its owner.", s.Name, real, o.UID, o.GID))
continue
}
if len(c.Users) == 0 {
no(fmt.Sprintf("The share %s is not served: the setting users names no login.", s.Name))
continue
}
out = append(out, Served{Name: s.Name, Path: real, Access: s.Access, UID: o.UID, GID: o.GID, User: user, Group: group, Users: c.Users})
}
return out, refused
}
// PasswordSecret is the secret holding a login's password.
func PasswordSecret(user string) string { return "smb-password-" + user }
// readSecret reads a login's password from its secret: one line; absent is not given.
func (m *Machine) readSecret(dir, user string) (string, bool, error) {
if dir == "" || !path.IsAbs(dir) {
return "", false, fmt.Errorf("the mesh gave no folder for the logins' secrets")
}
raw, err := m.Read(path.Join(dir, PasswordSecret(user)+".secret"))
if err != nil {
if !m.Exists(path.Join(dir, PasswordSecret(user)+".secret")) {
return "", false, nil
}
return "", false, err
}
pw := strings.TrimRight(raw, "\r\n")
if pw == "" || strings.ContainsAny(pw, "\r\n") {
return "", false, fmt.Errorf("its secret is not one line")
}
return pw, true, nil
}
func digest(user, pw string) string {
h := sha256.Sum256([]byte(user + "\x00" + pw))
return hex.EncodeToString(h[:])
}
// Logins judges every login of the setting and, when adopted, sets a password whose secret is new or changed
// (clause 5). A login in Samba's database with no secret given keeps its password; the module never removes
// or resets an entry by itself.
func (m *Machine) Logins(ctx context.Context, c Config, adopted bool) ([]Login, []health.Wait, []string) {
var logins []Login
var waits []health.Wait
var problems []string
set := map[string]string{}
if raw, err := m.Read(SetFile); err == nil {
_ = json.Unmarshal([]byte(raw), &set)
}
changed := false
for _, u := range c.Users {
l := Login{Name: u}
inDB := m.Run(ctx, "", "pdbedit", "-L", "-u", u).Code == 0
pw, given, err := m.readSecret(c.Passwords, u)
switch {
case err != nil:
l.Password, l.Why = "refused", "its password "+PasswordSecret(u)+" could not be read: "+err.Error()
problems = append(problems, fmt.Sprintf("the login %s: %s", u, l.Why))
case !given && inDB:
l.Password, l.Why = "kept as found", "no secret "+PasswordSecret(u)+" is given, so the password in Samba's database stands"
case !given:
l.Password, l.Why = "waits", "no secret "+PasswordSecret(u)+" is given and Samba's database has no such login"
waits = append(waits, health.Wait{Part: "the login " + u, Secret: PasswordSecret(u), What: "the password of the login " + u})
case !m.HasAccount(u):
l.Password, l.Why = "refused", "this machine has no account "+u+"; the module creates no account"
problems = append(problems, fmt.Sprintf("the login %s: %s", u, l.Why))
case !adopted:
l.Password, l.Why = "kept as found", "its secret is given and is set once the configuration is adopted"
case set[u] == digest(u, pw) && inDB:
l.Password = "set"
default:
// smbpasswd -s reads the new password twice from stdin; -a adds the login when it is not there.
args := []string{"-s", "-a", u}
if r := m.Run(ctx, pw+"\n"+pw+"\n", "smbpasswd", args...); r.Code != 0 {
l.Password, l.Why = "refused", "smbpasswd refused it: "+firstLine(r.Err)
problems = append(problems, fmt.Sprintf("the login %s: %s", u, l.Why))
break
}
set[u], changed, l.Password = digest(u, pw), true, "set"
}
logins = append(logins, l)
}
if changed {
raw, _ := json.MarshalIndent(set, "", " ")
if err := m.Write(SetFile, string(raw)+"\n", 0o600); err != nil {
problems = append(problems, "which passwords were set could not be kept: "+err.Error())
}
}
return logins, waits, problems
}
// Pass is one look; what it did is in the status it answers, which it has written.
func (m *Machine) Pass(ctx context.Context) Status {
s := Status{At: m.Now().UTC().Format(time.RFC3339), Mode: "found", Shares: []Served{}}
text, err := m.Read(ConfigFile)
if err != nil {
s.Problems = append(s.Problems, fmt.Sprintf("the assignment's shares (%s) could not be read: %v", ConfigFile, err))
}
c, problems := ParseConfig(text)
s.Problems = append(s.Problems, problems...)
served, refused := m.Judge(c)
s.Refused = refused
conf, _ := m.Read(SambaConf)
ours := strings.HasPrefix(conf, Marker)
if !ours {
s.Hand = HandShares(ParseSamba(conf), c.Shares)
}
var adoption *Adoption
if raw, err := m.Read(AdoptFile); err == nil {
var a Adoption
if json.Unmarshal([]byte(raw), &a) == nil && a.At != "" {
adoption = &a
}
}
if ours && adoption == nil {
// Written by the module before, and the word is gone: what it wrote stands, and is the module's.
adoption = &Adoption{At: "before", Why: "the configuration is already the module's"}
}
s.Adopted = adoption
if adoption != nil {
if !ours {
if missing := withoutCounterpart(s.Hand); len(missing) > 0 {
s.Problems = append(s.Problems, "the configuration is not taken over: "+strings.Join(missing, "; "))
adoption = nil
} else if served == nil && len(c.Shares) > 0 {
s.Problems = append(s.Problems, "the configuration is not taken over: no share the settings name can be served")
adoption = nil
}
}
}
if adoption != nil {
s.Mode = "adopted"
m.takeOver(ctx, &s, conf, ours, served, adoption)
} else {
for i := range served {
served[i].Live = false
}
}
logins, waits, lp := m.Logins(ctx, c, s.Mode == "adopted")
s.Logins, s.Waits = logins, waits
s.Problems = append(s.Problems, lp...)
if s.Mode == "adopted" {
s.Shares = m.markLive(ctx, served)
} else {
s.Shares = served
}
raw, _ := json.MarshalIndent(s, "", " ")
if err := m.Write(StatusFile, string(raw)+"\n", 0o644); err != nil {
s.Problems = append(s.Problems, "the status file could not be written: "+err.Error())
}
return s
}
// withoutCounterpart is each hand-written share the settings do not take the place of, in words.
func withoutCounterpart(hand []HandShare) []string {
var out []string
for _, h := range hand {
if h.Counterpart == "" {
out = append(out, fmt.Sprintf("the hand-written share %s (%s) has no counterpart: %s", h.Name, h.Path, h.Why))
}
}
return out
}
// takeOver writes the module's configuration when it differs from what is there: the first time, a dated
// copy of the hand-written one is kept; every time, testparm checks the candidate before it takes the
// configuration's place and Samba reads it. Then NetBIOS is stopped.
func (m *Machine) takeOver(ctx context.Context, s *Status, conf string, ours bool, served []Served, a *Adoption) {
want := Render(served)
if conf != want {
if err := m.Write(Candidate, want, 0o644); err != nil {
s.Problems = append(s.Problems, "the module's configuration could not be written: "+err.Error())
return
}
if r := m.Run(ctx, "", "testparm", "-s", m.at(Candidate)); r.Code != 0 {
s.Problems = append(s.Problems, "the module's configuration fails Samba's own check and is kept out of use: "+firstLine(r.Err))
return
}
if !ours {
copyAt := HandCopyPrefix + m.Now().UTC().Format("20060102T150405Z")
if err := m.Write(copyAt, conf, 0o644); err != nil {
s.Problems = append(s.Problems, "the hand-written configuration could not be kept, so it is not replaced: "+err.Error())
return
}
a.Copy = copyAt
if a.At != "before" {
raw, _ := json.MarshalIndent(a, "", " ")
_ = m.Write(AdoptFile, string(raw)+"\n", 0o644)
}
}
if err := m.Place(); err != nil {
s.Problems = append(s.Problems, "the module's configuration could not take its place: "+err.Error())
return
}
s.Written = m.Now().UTC().Format(time.RFC3339)
if r := m.Run(ctx, "", "smbcontrol", "smbd", "reload-config"); r.Code != 0 {
s.Problems = append(s.Problems, "Samba did not read its configuration again: "+firstLine(r.Err))
} else {
s.Reloaded = s.Written
}
}
if m.UnitState(NMBUnit) == "active" || m.UnitEnabled(NMBUnit) == "enabled" {
if r := m.Run(ctx, "", "systemctl", "disable", "--now", NMBUnit); r.Code != 0 {
s.Problems = append(s.Problems, "NetBIOS ("+NMBUnit+") could not be stopped: "+firstLine(r.Err))
}
}
s.NetBIOS = m.UnitState(NMBUnit)
}
// markLive marks each share Samba serves now, as testparm reads the configuration in use.
func (m *Machine) markLive(ctx context.Context, served []Served) []Served {
r := m.Run(ctx, "", "testparm", "-s", m.at(SambaConf))
live := map[string]bool{}
if r.Code == 0 {
for _, sec := range ParseSamba(r.Out) {
live[strings.ToLower(sec.Name)] = true
}
}
for i := range served {
served[i].Live = live[strings.ToLower(served[i].Name)] && m.UnitState(Unit) == "active"
}
return served
}
// RunConfig is the process: a pass now and every Every until the context ends.
func (m *Machine) RunConfig(ctx context.Context) error {
m.Pass(ctx)
t := time.NewTicker(Every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-t.C:
m.Pass(ctx)
}
}
}
// LastStatus is the process's last look, or why there is none.
func (m *Machine) LastStatus() (*Status, error) {
text, err := m.Read(StatusFile)
if err != nil {
return nil, fmt.Errorf("the module's process has written no status (%s): %v", StatusFile, err)
}
var s Status
if err := json.Unmarshal([]byte(text), &s); err != nil {
return nil, fmt.Errorf("the module's status file is not one: %v", err)
}
return &s, nil
}
@@ -1,301 +0,0 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Defends novox/hq ADR 0341 clauses 2 to 7 and 11 to 13: an SMB share is served as its folder's owner, SMB
// 2.1 and later on 445 alone with NetBIOS off; before a person adopts it the hand-written configuration is
// kept as found and nothing is written; adoption keeps a dated copy, writes the module's configuration only
// once testparm passes it, and refuses while a hand-written share has no counterpart; a login without its
// secret keeps its password, and one with it is set once.
type fake struct {
t *testing.T
root string
answers map[string]Ran
ran []string
inputs map[string]string
now time.Time
}
func newFake(t *testing.T) *fake {
f := &fake{t: t, root: t.TempDir(), answers: map[string]Ran{}, inputs: map[string]string{}, now: time.Date(2026, 10, 13, 9, 30, 0, 0, time.UTC)}
f.answers["systemctl is-active smb.service"] = Ran{Out: "active\n"}
f.answers["systemctl is-active nmb.service"] = Ran{Out: "active\n"}
f.answers["systemctl is-enabled nmb.service"] = Ran{Out: "enabled\n"}
f.answers["pdbedit -L -u media"] = Ran{Out: "media:1001:\n"}
f.file("/etc/passwd", "root:x:0:0::/root:/bin/sh\nmedia:x:1001:2000::/home/media:/bin/sh\n")
f.file("/etc/group", "root:x:0:\nmedia:x:2000:\n")
return f
}
func (f *fake) machine() *Machine {
return &Machine{Root: f.root, UID: 0, Now: func() time.Time { return f.now },
ownerOf: func(info os.FileInfo) (int, int) {
if strings.HasPrefix(info.Name(), "rootowned") {
return 0, 0
}
return 1001, 2000
},
Run: func(_ context.Context, input, name string, args ...string) Ran {
line := strings.Join(append([]string{name}, args...), " ")
line = strings.ReplaceAll(line, f.root, "")
f.ran = append(f.ran, line)
if input != "" {
f.inputs[line] = input
}
if line == "systemctl disable --now nmb.service" {
f.answers["systemctl is-active nmb.service"] = Ran{Out: "inactive\n"}
f.answers["systemctl is-enabled nmb.service"] = Ran{Out: "disabled\n"}
}
if strings.HasPrefix(line, "testparm -s ") {
if r, ok := f.answers["testparm"]; ok {
return r
}
return Ran{Out: f.read(strings.TrimPrefix(line, "testparm -s "))}
}
return f.answers[line]
}}
}
func (f *fake) dir(p string) {
if err := os.MkdirAll(filepath.Join(f.root, p), 0o755); err != nil {
f.t.Fatal(err)
}
}
func (f *fake) file(p, content string) {
f.dir(filepath.Dir(p))
if err := os.WriteFile(filepath.Join(f.root, p), []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
func (f *fake) read(p string) string {
b, _ := os.ReadFile(filepath.Join(f.root, p))
return string(b)
}
func (f *fake) ran1(prefix string) []string {
var out []string
for _, r := range f.ran {
if strings.HasPrefix(r, prefix) {
out = append(out, r)
}
}
return out
}
// handWritten is the home server's configuration as issue 169's survey found it.
const handWritten = "[global]\n workgroup = WORKGROUP\n server string = home\n\n[media]\n path = /storage/media/\n valid users = media\n writable = yes\n"
func (f *fake) home(shares, users string) {
f.dir("/storage/media")
f.file(ConfigFile, "shares="+shares+"\nusers="+users+"\npasswords=/var/lib/mesh/smb-server/state\n")
f.file(SambaConf, handWritten)
}
func TestTheRenderingServesAsTheOwnerOnFourFortyFiveAloneWithoutNetBIOS(t *testing.T) {
text := Render([]Served{{Name: "media", Path: "/storage/media", Access: "rw", User: "media", Group: "media", Users: []string{"media"}}})
for _, want := range []string{Marker, "server min protocol = SMB2_10", "disable netbios = yes", "smb ports = 445", "map to guest = never",
"[media]", "path = /storage/media", "read only = no", "valid users = media", "force user = media", "force group = media"} {
if !strings.Contains(text, want) {
t.Errorf("the rendering lacks %q:\n%s", want, text)
}
}
if strings.Contains(text, "139") || strings.Contains(text, "1001") {
t.Errorf("the rendering names 139, or an owner as a number:\n%s", text)
}
}
func TestAShareIsRefusedAtRootRootOwnedMissingOrNearABackupAndNothingIsCreated(t *testing.T) {
f := newFake(t)
f.dir("/srv/rootowned")
f.dir("/srv/pool/library")
f.file("/srv/pool/library/deep/repo/config", "x")
for _, d := range []string{"keys", "snapshots", "data"} {
f.dir("/srv/pool/library/deep/repo/" + d)
}
f.dir("/srv/ok")
c, _ := ParseConfig("shares=root=/ owned=/srv/rootowned gone=/srv/gone lib=/srv/pool/library ok=/srv/ok:ro\nusers=media\n")
served, refused := f.machine().Judge(c)
why := map[string]string{}
for _, r := range refused {
why[r.Name] = r.Why
}
for name, says := range map[string]string{"root": "machine's root", "owned": "owned by root", "gone": "not there", "lib": "backup repository"} {
if !strings.Contains(why[name], says) {
t.Errorf("%s is not refused as %q: %q", name, says, why[name])
}
}
if len(served) != 1 || served[0].Name != "ok" || served[0].Access != "ro" || served[0].User != "media" {
t.Errorf("served: %+v", served)
}
if f.machine().Exists("/srv/gone") {
t.Error("the module created a missing folder")
}
}
func TestBeforeAdoptionTheHandWrittenConfigurationIsKeptAndNothingIsWritten(t *testing.T) {
f := newFake(t)
f.home("media=/storage/media", "media")
s := f.machine().Pass(context.Background())
if s.Mode != "found" || f.read(SambaConf) != handWritten {
t.Fatalf("mode %s; the hand-written configuration changed:\n%s", s.Mode, f.read(SambaConf))
}
if len(s.Hand) != 1 || s.Hand[0].Counterpart != "media" || s.Hand[0].ReadOnly {
t.Errorf("the hand-written share is not matched: %+v", s.Hand)
}
for _, r := range f.ran {
if strings.HasPrefix(r, "smbcontrol") || strings.HasPrefix(r, "systemctl disable") || strings.HasPrefix(r, "smbpasswd") {
t.Errorf("before adoption the process ran %q", r)
}
}
if s.Logins[0].Password != "kept as found" {
t.Errorf("the login: %+v", s.Logins)
}
if h := f.machine().Health(); !h.Healthy || !strings.Contains(h.Why, "adopt") {
t.Errorf("health before adoption: %+v", h)
}
}
func TestAdoptRefusesWhileAHandWrittenShareHasNoCounterpart(t *testing.T) {
f := newFake(t)
f.home("library=/storage/media", "media")
f.machine().Pass(context.Background())
if _, err := f.machine().Adopt(true, "take it over"); err == nil || !strings.Contains(err.Error(), "no share disappears") {
t.Fatalf("adopt with a hand-written share the settings do not name: %v", err)
}
if f.machine().Exists(AdoptFile) {
t.Error("a refused adopt left the word for the process")
}
f.file(ConfigFile, "shares=media=/storage/media\nusers=media\npasswords=/x\n")
f.machine().Pass(context.Background())
dry, err := f.machine().Adopt(false, "")
if err != nil || !dry.DryRun || len(dry.Beside) != 1 || !strings.Contains(dry.Beside[0], "every client as media:media") {
t.Fatalf("dry run: %+v %v", dry, err)
}
if f.machine().Exists(AdoptFile) {
t.Error("a dry run left the word for the process")
}
if _, err := f.machine().Adopt(true, ""); err == nil {
t.Error("adopt with confirm and no why was done")
}
}
func TestAdoptionKeepsADatedCopyChecksAndThenStopsNetBIOS(t *testing.T) {
f := newFake(t)
f.home("media=/storage/media", "media")
f.machine().Pass(context.Background())
f.file(AdoptFile, `{"at":"2026-10-13T09:31:00Z","why":"the operator keeps the share"}`)
f.ran = nil
s := f.machine().Pass(context.Background())
if s.Mode != "adopted" || len(s.Problems) != 0 {
t.Fatalf("status: %+v", s)
}
copyAt := HandCopyPrefix + "20261013T093000Z"
if f.read(copyAt) != handWritten {
t.Errorf("the dated copy is not the hand-written configuration: %q", f.read(copyAt))
}
if !strings.HasPrefix(f.read(SambaConf), Marker) || !strings.Contains(f.read(SambaConf), "force user = media") {
t.Errorf("the module's configuration is not in place:\n%s", f.read(SambaConf))
}
want := []string{"testparm -s " + Candidate, "smbcontrol smbd reload-config", "systemctl disable --now nmb.service"}
for _, w := range want {
if len(f.ran1(w)) != 1 {
t.Errorf("%q ran %d times: %v", w, len(f.ran1(w)), f.ran)
}
}
if !s.Shares[0].Live || s.NetBIOS != "inactive" {
t.Errorf("served: %+v netbios %s", s.Shares, s.NetBIOS)
}
// The next pass finds its own configuration and writes nothing again.
f.ran = nil
f.machine().Pass(context.Background())
if len(f.ran1("smbcontrol")) != 0 || len(f.ran1("systemctl disable")) != 0 {
t.Errorf("an unchanged configuration was written or reloaded again: %v", f.ran)
}
}
func TestAConfigurationFailingTestparmIsKeptOutOfUse(t *testing.T) {
f := newFake(t)
f.home("media=/storage/media", "media")
f.file(AdoptFile, `{"at":"2026-10-13T09:31:00Z","why":"take over"}`)
f.answers["testparm"] = Ran{Code: 1, Err: "Unknown parameter encountered"}
s := f.machine().Pass(context.Background())
if f.read(SambaConf) != handWritten {
t.Errorf("a configuration testparm refused took the hand-written one's place")
}
if len(f.ran1("smbcontrol")) != 0 || !strings.Contains(strings.Join(s.Problems, " "), "fails Samba's own check") {
t.Errorf("not said, or reloaded: %v %v", s.Problems, f.ran)
}
if h := f.machine().Health(); h.Healthy {
t.Errorf("healthy with a refused configuration: %+v", h)
}
}
func TestALoginKeepsItsPasswordWithoutASecretWaitsWhenNewAndIsSetOnce(t *testing.T) {
f := newFake(t)
f.home("media=/storage/media", "media guest2")
f.answers["pdbedit -L -u guest2"] = Ran{Code: 255}
f.file(AdoptFile, `{"at":"2026-10-13T09:31:00Z","why":"take over"}`)
s := f.machine().Pass(context.Background())
got := map[string]string{}
for _, l := range s.Logins {
got[l.Name] = l.Password
}
if got["media"] != "kept as found" || got["guest2"] != "waits" || len(f.ran1("smbpasswd")) != 0 {
t.Fatalf("logins %v; smbpasswd ran %v", got, f.ran1("smbpasswd"))
}
h := f.machine().Health()
if h.State != "waits" || len(h.Waits) != 1 || h.Waits[0].Secret != "smb-password-guest2" {
t.Errorf("health: %+v", h)
}
// The secret given: set once from stdin, never on the command line, and not again while unchanged.
f.file("/etc/passwd", f.read("/etc/passwd")+"guest2:x:1002:2000::/:/bin/sh\n")
f.file("/var/lib/mesh/smb-server/state/smb-password-guest2.secret", "s3cret\n")
f.machine().Pass(context.Background())
runs := f.ran1("smbpasswd")
if len(runs) != 1 || runs[0] != "smbpasswd -s -a guest2" || f.inputs[runs[0]] != "s3cret\ns3cret\n" {
t.Fatalf("smbpasswd: %v %q", runs, f.inputs)
}
f.answers["pdbedit -L -u guest2"] = Ran{Out: "guest2:1002:\n"}
f.machine().Pass(context.Background())
if len(f.ran1("smbpasswd")) != 1 {
t.Errorf("an unchanged password was set again: %v", f.ran1("smbpasswd"))
}
var set map[string]string
_ = json.Unmarshal([]byte(f.read(SetFile)), &set)
if strings.Contains(f.read(SetFile), "s3cret") || set["guest2"] == "" {
t.Errorf("the record of set passwords holds the password, or nothing: %s", f.read(SetFile))
}
if info, err := os.Stat(filepath.Join(f.root, SetFile)); err != nil || info.Mode().Perm() != 0o600 {
t.Errorf("the record of set passwords is not root's alone: %v", info.Mode())
}
}
func TestTheModuleWritesOnlyItsOwnFiles(t *testing.T) {
m := newFake(t).machine()
for _, p := range []string{"/etc/passwd", "/storage/media/x", "/etc/samba/other.conf", HandCopyPrefix + "x/../../evil"} {
if err := m.Write(p, "x", 0o644); err == nil {
t.Errorf("wrote %s", p)
}
}
}
func TestParseSambaReadsSectionsAndAccess(t *testing.T) {
secs := ParseSamba("; c\n[global]\nworkgroup = W\n[a]\n path = /x\n read only = no\n[b]\n path = /y\n[c]\n path = /z\n writeable = yes\n")
if len(secs) != 4 || secs[1].Params["path"] != "/x" {
t.Fatalf("%+v", secs)
}
if readOnly(secs[1].Params) || !readOnly(secs[2].Params) || readOnly(secs[3].Params) {
t.Error("read only is not read as Samba reads it")
}
}
-366
View File
@@ -1,366 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/health"
)
// Seat is the role this module holds on its machine (novox/hq ADR 0341).
const Seat = "node-smb-server"
// Stale is how old the process's last look may be before the tools say it stopped looking.
const Stale = 5 * time.Minute
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run}
}
func str(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
func arg(a map[string]any, k string) string {
v, _ := a[k].(string)
return strings.TrimSpace(v)
}
// confirmed says whether an act was confirmed: the boolean true, or the string "true" the seat's schema passes.
func confirmed(a map[string]any) bool {
switch v := a["confirm"].(type) {
case bool:
return v
case string:
return strings.EqualFold(strings.TrimSpace(v), "true")
}
return false
}
// Tools are the seat's verbs and the module's health.
func Tools(m *Machine) []stdio.Tool {
return []stdio.Tool{
verb("shares", "Every SMB share this machine serves: its name, folder, rw or ro, the owner every client acts as "+
"(uid, gid, names), the logins allowed and whether each login's password was set from its secret, is kept "+
"as found or waits for it, and whether Samba serves it now; whether the configuration is the mesh's or still "+
"the hand-written one, and each hand-written share with whether the settings take its place; the shares "+
"refused, and why. A password is never shown. Replaces testparm -s, cat /etc/samba/smb.conf and pdbedit -L. (r)",
map[string]any{}, func(map[string]any) (any, error) { return m.Shares(), nil }),
verb("sessions", "Which devices are connected to which share now, with the login and protocol each uses, as "+
"Samba knows them (smbstatus, as root). Replaces smbstatus. (r)",
map[string]any{}, func(map[string]any) (any, error) { return m.Sessions(context.Background()) }),
verb("test", "Whether this machine serves one share now: asked for and not refused, served by Samba, Samba "+
"active, and 445 listening. Replaces smbclient -L. (r)",
map[string]any{"share": str("the share's name")},
func(a map[string]any) (any, error) { return m.Test(context.Background(), arg(a, "share")) }),
verb("reload", "Have Samba read its configuration again now (smbcontrol smbd reload-config, as root). The "+
"module's process does this itself whenever it writes the configuration; this is for after a change made "+
"outside it. Changes no shared folder. Replaces smbcontrol smbd reload-config. (a)",
map[string]any{}, func(map[string]any) (any, error) { return m.Reload(context.Background()) }),
verb("adopt", "Take over Samba's hand-written configuration: the module's process keeps a dated copy of it "+
"beside it and writes the mesh's own in its place, checked with testparm, then stops NetBIOS. Only when every "+
"hand-written share has its counterpart in the setting shares (same name, same folder), so no share "+
"disappears. Without confirm, a dry run: each hand-written share beside the module's rendering of it. A "+
"person's act, with why. Replaces edit /etc/samba/smb.conf. (a)",
map[string]any{
"confirm": str("\"true\" to do it; anything else, a dry run"),
"why": str("why, in a sentence (required with confirm)"),
},
func(a map[string]any) (any, error) { return m.Adopt(confirmed(a), arg(a, "why")) }),
{
Name: "smb_health",
Description: "Whether this machine's SMB shares are served, as the module's health check (novox/hq ADR 0240, " +
"ADR 0341): unhealthy when Samba is not active, the module's process has not looked for five minutes, a " +
"share is refused or not served, or the configuration fails Samba's check; waits when a login's password " +
"is its secret not yet given; with why. (r)",
Run: func(map[string]any) (any, error) { return m.Health(), nil },
},
}
}
// SharesAnswer is what `shares` answers.
type SharesAnswer struct {
Mode string `json:"configuration"`
Shares []Served `json:"shares"`
Refused []Refused `json:"refused,omitempty"`
Hand []HandShare `json:"hand_written,omitempty"`
Logins []Login `json:"logins,omitempty"`
Service string `json:"service"`
Looked string `json:"looked,omitempty"`
Said []string `json:"said"`
Notes []string `json:"notes,omitempty"`
}
// Shares is the machine's SMB shares now.
func (m *Machine) Shares() SharesAnswer {
a := SharesAnswer{Shares: []Served{}, Service: m.UnitState(Unit)}
st, err := m.LastStatus()
if err != nil {
a.Notes = append(a.Notes, err.Error())
a.Said = append(a.Said, "the module's process has not looked yet")
return a
}
a.Mode, a.Shares, a.Refused, a.Hand, a.Logins, a.Looked = st.Mode, st.Shares, st.Refused, st.Hand, st.Logins, st.At
a.Notes = st.Problems
if st.Mode == "found" {
a.Said = append(a.Said, "the configuration is the hand-written one, kept as found; the module takes it over by adopt, a person's act")
} else {
a.Said = append(a.Said, "the configuration is the module's")
}
for _, s := range st.Shares {
state := "served"
if !s.Live {
state = "NOT served"
}
a.Said = append(a.Said, fmt.Sprintf("%s: %s, %s, every client as %s:%s, for %s — %s", s.Name, s.Path, s.Access, s.User, s.Group, strings.Join(s.Users, " "), state))
}
for _, h := range st.Hand {
if h.Counterpart != "" {
a.Said = append(a.Said, fmt.Sprintf("hand-written %s (%s): the setting's share %s takes its place", h.Name, h.Path, h.Counterpart))
} else {
a.Said = append(a.Said, fmt.Sprintf("hand-written %s (%s): %s", h.Name, h.Path, h.Why))
}
}
for _, l := range st.Logins {
a.Said = append(a.Said, fmt.Sprintf("login %s: password %s", l.Name, l.Password))
}
for _, r := range st.Refused {
a.Said = append(a.Said, "refused: "+r.Why)
}
a.Said = append(a.Said, "Samba is "+a.Service)
return a
}
// Sessions is what smbstatus says of the connected devices.
func (m *Machine) Sessions(ctx context.Context) (any, error) {
r, err := m.AsRoot(ctx, "", "smbstatus", "--json")
if err != nil {
return nil, err
}
var v struct {
Sessions map[string]struct {
Machine string `json:"remote_machine"`
Username string `json:"username"`
Dialect string `json:"session_dialect"`
} `json:"sessions"`
Tcons map[string]struct {
Service string `json:"service"`
Machine string `json:"machine"`
} `json:"tcons"`
}
if err := json.Unmarshal([]byte(r.Out), &v); err != nil {
return nil, fmt.Errorf("smbstatus answered what this cannot read: %v", err)
}
type session struct {
Device string `json:"device"`
Login string `json:"login"`
Protocol string `json:"protocol"`
Shares []string `json:"shares"`
}
var out []session
for _, s := range v.Sessions {
se := session{Device: s.Machine, Login: s.Username, Protocol: s.Dialect}
for _, t := range v.Tcons {
if t.Machine == s.Machine {
se.Shares = append(se.Shares, t.Service)
}
}
out = append(out, se)
}
return map[string]any{"sessions": out}, nil
}
// TestAnswer is what `test` answers.
type TestAnswer struct {
Share string `json:"share"`
Served bool `json:"served"`
Service string `json:"service"`
Listening bool `json:"listening_445"`
Said string `json:"said"`
}
// Test says whether one share is served now.
func (m *Machine) Test(ctx context.Context, share string) (TestAnswer, error) {
if share == "" {
return TestAnswer{}, fmt.Errorf("name the share")
}
a := TestAnswer{Share: share, Service: m.UnitState(Unit)}
r := m.Run(ctx, "", "ss", "-ltnH", "sport", "=", ":445")
a.Listening = strings.TrimSpace(r.Out) != ""
st, err := m.LastStatus()
if err != nil {
return a, err
}
for _, s := range st.Shares {
if s.Name == share {
a.Served = s.Live && a.Service == "active" && a.Listening
}
}
for _, rf := range st.Refused {
if rf.Name == share {
a.Said = rf.Why
return a, nil
}
}
switch {
case a.Served:
a.Said = fmt.Sprintf("%s is served on 445", share)
case st.Mode == "found":
a.Said = fmt.Sprintf("%s is not served by the module: the configuration is still the hand-written one", share)
default:
a.Said = fmt.Sprintf("%s is not served now: Samba is %s, 445 listening: %v", share, a.Service, a.Listening)
}
return a, nil
}
// Reload has Samba read its configuration again.
func (m *Machine) Reload(ctx context.Context) (any, error) {
if _, err := m.AsRoot(ctx, "", "smbcontrol", "smbd", "reload-config"); err != nil {
return nil, err
}
return m.Shares(), nil
}
// AdoptAnswer is what `adopt` answers.
type AdoptAnswer struct {
DryRun bool `json:"dry_run"`
Done bool `json:"done"`
Copy string `json:"copy,omitempty"`
Beside []string `json:"beside"`
Said string `json:"said"`
}
// AdoptWait is how long a confirmed adopt waits for the process to take over.
var AdoptWait = 45 * time.Second
// Adopt leaves a person's word for the process to take over, after a dry run that shows each hand-written share
// beside its rendering. It refuses while a hand-written share has no counterpart.
func (m *Machine) Adopt(confirm bool, why string) (AdoptAnswer, error) {
a := AdoptAnswer{DryRun: !confirm}
st, err := m.LastStatus()
if err != nil {
return a, err
}
if st.Mode == "adopted" {
return a, fmt.Errorf("the configuration is already the module's; nothing to take over")
}
served := map[string]Served{}
for _, s := range st.Shares {
served[s.Name] = s
}
var missing []string
for _, h := range st.Hand {
if h.Counterpart == "" {
missing = append(missing, fmt.Sprintf("%s (%s): %s", h.Name, h.Path, h.Why))
a.Beside = append(a.Beside, fmt.Sprintf("hand-written %s, %s, valid users %s → no counterpart", h.Name, h.Path, h.ValidUsers))
continue
}
s, ok := served[h.Counterpart]
if !ok {
missing = append(missing, fmt.Sprintf("%s: the setting's share %s is refused", h.Name, h.Counterpart))
continue
}
a.Beside = append(a.Beside, fmt.Sprintf("hand-written %s, %s, valid users %s, read only %v → the mesh's %s, %s, %s, valid users %s, every client as %s:%s",
h.Name, h.Path, h.ValidUsers, h.ReadOnly, s.Name, s.Path, s.Access, strings.Join(s.Users, " "), s.User, s.Group))
}
if len(missing) > 0 {
return a, fmt.Errorf("not taken over, so no share disappears: %s", strings.Join(missing, "; "))
}
plan := "keep a dated copy of the hand-written configuration beside it, write the mesh's own in its place once testparm passes it, have Samba read it, and stop NetBIOS"
if !confirm {
a.Said = "Dry run: would " + plan + "."
return a, nil
}
if why == "" {
return a, fmt.Errorf("say why: adopt is a person's act, and the why is kept with it")
}
raw, _ := json.MarshalIndent(Adoption{At: m.Now().UTC().Format(time.RFC3339), Why: why}, "", " ")
if err := m.Write(AdoptFile, string(raw)+"\n", 0o644); err != nil {
return a, err
}
deadline := m.Now().Add(AdoptWait)
for {
if st, err := m.LastStatus(); err == nil && st.Mode == "adopted" && st.Written != "" {
a.Done = true
if st.Adopted != nil {
a.Copy = st.Adopted.Copy
}
a.Said = fmt.Sprintf("Did: %s. Why: %s", plan, why)
return a, nil
}
if !m.Now().Before(deadline) {
a.Said = "The word is left for the module's process, which has not taken over yet; read shares for why."
return a, nil
}
time.Sleep(2 * time.Second)
}
}
// HealthAnswer is the module's health: mesh-sdk's answer, with what was found.
type HealthAnswer struct {
health.Answer
Found []string `json:"found,omitempty"`
}
// Health judges the shares.
func (m *Machine) Health() HealthAnswer {
var found []string
if s := m.UnitState(Unit); s != "active" {
found = append(found, fmt.Sprintf("Samba (%s) is %s.", Unit, s))
}
st, err := m.LastStatus()
if err != nil {
found = append(found, err.Error()+".")
return HealthAnswer{Answer: health.Unhealthy(strings.Join(found, " ")), Found: found}
}
if at, err := time.Parse(time.RFC3339, st.At); err != nil || m.Now().Sub(at) > Stale {
found = append(found, "The module's process has not looked for five minutes.")
}
for _, p := range st.Problems {
found = append(found, sentence(p))
}
for _, r := range st.Refused {
found = append(found, r.Why)
}
if st.Mode == "adopted" {
for _, s := range st.Shares {
if !s.Live {
found = append(found, fmt.Sprintf("The share %s (%s) is not served by Samba.", s.Name, s.Path))
}
}
}
if len(found) > 0 {
return HealthAnswer{Answer: health.Unhealthy(strings.Join(found, " "), st.Waits...), Found: found}
}
if len(st.Waits) > 0 {
var why []string
for _, w := range st.Waits {
why = append(why, fmt.Sprintf("%s waits for %s.", sentence(w.Part), w.What))
}
return HealthAnswer{Answer: health.Waiting(strings.Join(why, " "), st.Waits...)}
}
if st.Mode == "found" {
return HealthAnswer{Answer: health.Healthy("Samba serves the hand-written configuration, kept as found; the module takes it over by node-smb-server.adopt, a person's act.")}
}
var parts []string
for _, s := range st.Shares {
parts = append(parts, s.Name)
}
return HealthAnswer{Answer: health.Healthy(fmt.Sprintf("Samba is active and serves %s on 445.", strings.Join(parts, ", ")))}
}
func sentence(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return s
}
s = strings.ToUpper(s[:1]) + s[1:]
if !strings.HasSuffix(s, ".") {
s += "."
}
return s
}
-5
View File
@@ -1,5 +0,0 @@
module smbserver
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c
-8
View File
@@ -1,8 +0,0 @@
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010190644-622b6b4f556d h1:BR8uwv4BqyLaBwITuLylrd9DRUnu0D5DjJw0RuQSXbk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010190644-622b6b4f556d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
-144
View File
@@ -1,144 +0,0 @@
{
"module": "smb-server",
"version": "1",
"upgrade": {
"policy": "record",
"why": "the folders the devices on the home network open: a build that breaks Samba's configuration leaves every device's share refused, and the gate on this one machine does not see the devices (hq ADR 0236, ADR 0341)"
},
"capabilities": [
"package-manager",
"service-manager"
],
"claims": [
{
"name": "node-smb-server",
"scope": "node",
"serves": [
"shares",
"sessions",
"test",
"reload",
"adopt"
]
}
],
"tools": [
"smb_health"
],
"settings": {
"shares": {
"kind": "preference",
"default": "none",
"why": "a machine serves no SMB share until its assignment names one: space-separated name=folder, each optionally :ro, one share per folder, every client acting as the folder's owner (hq ADR 0341)"
},
"users": {
"kind": "preference",
"default": "none",
"why": "the logins allowed on every share, space-separated; each login's password is the secret smb-password-<login>, and a login already in Samba's database whose secret is not given keeps its password (hq ADR 0341)"
}
},
"own-secrets": {
"smb-password-*": {
"path": "${dir:state}/smb-password-*.secret",
"issued-by": "outside"
}
},
"listens": [
{
"name": "smb",
"port": 445,
"protocol": "tcp",
"from": "mesh",
"fixed": true,
"why": "SMB 2.1 and later, on 445 alone: NetBIOS is off, so 137, 138 and 139 are never opened. The home server's assignment binds it with the reach local-network (hq ADR 0340, ADR 0341), so the devices on its own network reach it and nothing beyond"
}
],
"resources": [
{
"id": "package",
"type": "package",
"package": "samba"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "config-dir",
"type": "directory",
"path": "/etc/smb-server",
"mode": "0755"
},
{
"id": "run-dir",
"type": "directory",
"path": "/run/smb-server",
"mode": "0755"
},
{
"id": "lib-dir",
"type": "directory",
"path": "/var/lib/smb-server",
"mode": "0755"
},
{
"id": "adopt-dir",
"type": "directory",
"path": "/var/lib/smb-server/adopt",
"mode": "0755",
"owner": "${machine:account}"
},
{
"id": "config",
"type": "file",
"path": "/etc/smb-server/shares.conf",
"mode": "0644",
"content": "# Written by the mesh (module smb-server, novox/hq ADR 0341) from this machine's assignment.\n# Replaced on every push; change the settings, never this file.\n#\n# The shares: name=folder, or name=folder:ro, one share per folder. The users: the logins allowed. The\n# module's process serves each share with every client as the folder's owner, and writes Samba's\n# configuration only once a person adopted it (node-smb-server.adopt).\nshares=${setting:shares}\nusers=${setting:users}\npasswords=${dir:state}\n"
},
{
"id": "server",
"type": "service",
"unit": "smb.service",
"state": "running",
"boot": "enabled",
"health": {
"kind": "unit"
}
},
{
"id": "config-process",
"type": "process",
"name": "smb-server-config",
"artifact": "tools",
"run": [
"./smb-server",
"config"
],
"health": {
"kind": "tool",
"tool": "smb_health",
"interval": "60s",
"timeout": "10s",
"looks": 2,
"grace": "90s"
}
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/smb-server",
"binary": "smb-server",
"loads": [
"smb-server"
]
}
]
}
}