Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
048f1b8284 | ||
|
|
278610c0c3 | ||
|
|
6bedcd3f21 | ||
|
|
968473219a | ||
|
|
ad219beee2 | ||
|
|
fd09b1a50e | ||
|
|
7aea08d6c3 | ||
|
|
23d735a0bf | ||
|
|
226eab4c6f | ||
|
|
bb8f2e76a9 | ||
|
|
372450851f | ||
|
|
f4e4e12c99 | ||
|
|
fd9be011c0 | ||
|
|
a85b0ee346 | ||
|
|
34243c9e34 | ||
|
|
870a541072 | ||
|
|
a59750fa28 | ||
|
|
81592a3b2c | ||
|
|
705ceec1e7 | ||
|
|
afdd149ab7 | ||
|
|
dde8b15483 | ||
|
|
8a046be198 | ||
|
|
668278bde4 | ||
|
|
6761bb02a1 | ||
|
|
f98c9859d2 | ||
|
|
cac5eab7da | ||
|
|
770c9f6a78 | ||
|
|
5427118614 | ||
|
|
53765335cf | ||
|
|
5fd2ed9686 | ||
|
|
f7887d706d | ||
|
|
d6dd21a091 | ||
|
|
a844701577 | ||
|
|
50a99f022c | ||
|
|
382a44621e | ||
|
|
ddb67fc095 | ||
|
|
78595e4db3 | ||
|
|
37634de1e3 | ||
|
|
36c5f87130 | ||
|
|
b2e39eb2cd | ||
|
|
3d73c9f54e | ||
|
|
fb95eb6e46 | ||
|
|
4d715f8b73 | ||
|
|
afe8aae826 | ||
|
|
fa91be4941 | ||
|
|
87f73dce6a | ||
|
|
fc5ccdfe2a | ||
|
|
4489e56935 | ||
|
|
08e947e4c8 | ||
|
|
7fb9dd0254 | ||
|
|
420d05e8dd | ||
|
|
6769e66c82 | ||
|
|
15b35b53db | ||
|
|
6177565741 | ||
|
|
6b2ea0972a | ||
|
|
a978b53d1c | ||
|
|
7501c1db9e | ||
|
|
00ada1e9f7 | ||
|
|
67b443d5ad | ||
|
|
a2da2e4910 | ||
|
|
bcb9ca8f93 | ||
|
|
2409afda60 | ||
|
|
511200ed9c | ||
|
|
b704bf5ad8 | ||
|
|
142d65c52a | ||
|
|
ccb6e7500e | ||
|
|
bbda88c13b | ||
|
|
620b47d309 | ||
|
|
6fd93afc6c | ||
|
|
0cb0f814b4 | ||
|
|
3d271f72ea | ||
|
|
45dd036623 | ||
|
|
107090310d | ||
|
|
440e3e446e | ||
|
|
20df40c949 | ||
|
|
6a6dd4a7dc | ||
|
|
973d80aaa2 | ||
|
|
945390e59a | ||
|
|
ed0f4602a6 | ||
|
|
13d0361640 | ||
|
|
61eb201f8a |
@@ -6,19 +6,19 @@
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "the-build-machine",
|
||||
"scope": "node"
|
||||
"name": "mesh-build-machine",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
"artifact-store",
|
||||
"package-registry"
|
||||
"npm-package-registry"
|
||||
],
|
||||
"binds": {
|
||||
"package-registry": "/var/lib/mesh/builder/package-registry.json"
|
||||
"npm-package-registry": "/var/lib/mesh/builder/package-registry.json"
|
||||
},
|
||||
"secrets": {
|
||||
"package-registry": "/var/lib/mesh/builder/package-registry.secret"
|
||||
"npm-package-registry": "/var/lib/mesh/builder/package-registry.secret"
|
||||
},
|
||||
"emits": [
|
||||
"module.builder.built"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# dhcpcd
|
||||
|
||||
The uplink seat's module for a machine whose own network is dhcpcd's (novox/hq ADR 0117). It
|
||||
asks two things of dhcpcd, and nothing else: leave the resolver file to the mesh, and leave the
|
||||
private network's interface alone. It never declares an interface, an address, a route, a
|
||||
wireless network or its credentials — the link dhcpcd keeps is the only channel the mesh reaches
|
||||
the machine over.
|
||||
|
||||
## What it writes
|
||||
|
||||
Two lines into `/etc/dhcpcd.conf`, as the mesh's marked region (`into: block`) — dhcpcd reads no
|
||||
drop-in directory, so the mesh writes into its one file rather than over it (ADR 0102):
|
||||
|
||||
- `nohook resolv.conf` — dhcpcd's resolv.conf hook rewrites `/etc/resolv.conf` on every lease it
|
||||
takes or renews, which would silently replace the resolver `resolv-conf` names.
|
||||
- `denyinterfaces mesh0` — dhcpcd never asks for a lease on the private network's interface, and
|
||||
never takes it down. dhcpcd leaves a point-to-point interface alone by default; this says so
|
||||
rather than relying on it.
|
||||
|
||||
**At the start of the file** (`at: start`). Both are global options, and dhcpcd reads every line
|
||||
after an `interface` or `ssid` line as that interface's own. A configured machine's file ends in
|
||||
exactly such a block (the interface, its static address), so appended at the end these two would
|
||||
quietly apply to one interface only.
|
||||
|
||||
## Why it declares no service
|
||||
|
||||
dhcpcd is the machine's, not the mesh's. The mesh never starts, stops or enables it: stopping it
|
||||
drops the address the machine is reached at, and a module unassigned by mistake must not be able
|
||||
to do that. And there is nothing to reload it with — `dhcpcd.service` reports `CanReload=no`, and
|
||||
a restart drops the lease. So the two lines take effect at **dhcpcd's next start**.
|
||||
|
||||
On an adopted machine that is normally no gap: the predecessor wrote the same `nohook` line, and
|
||||
it is already in force. **On a machine that was not adopted, it is one:** until dhcpcd next
|
||||
starts (a reboot, or the operator restarting it in a window of their choosing), a lease renewal
|
||||
still rewrites `/etc/resolv.conf`, and `resolv-conf` puts it back at the next push. Assign this
|
||||
module before `resolv-conf` on such a machine, and restart dhcpcd once, by hand, when losing the
|
||||
link for a moment is acceptable.
|
||||
|
||||
## One manager per machine
|
||||
|
||||
It claims `the-uplink`: a machine runs one network manager, and assigning a second module that
|
||||
claims the seat is refused. Assigning this one to a machine whose network is NetworkManager's
|
||||
installs the package and writes the two lines, and starts nothing.
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"module": "dhcpcd",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-uplink",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "dhcpcd"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "file",
|
||||
"path": "/etc/dhcpcd.conf",
|
||||
"mode": "0644",
|
||||
"into": "block",
|
||||
"at": "start",
|
||||
"content": "# The mesh's two lines (module dhcpcd, novox/hq ADR 0117). Global options, so\n# kept above any interface line; read at dhcpcd's next start.\nnohook resolv.conf\ndenyinterfaces mesh0\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -6,7 +6,7 @@
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "the-intrusion-prevention",
|
||||
"name": "node-intrusion-prevention",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
@@ -33,7 +33,7 @@
|
||||
"type": "file",
|
||||
"path": "/etc/fail2ban/jail.local",
|
||||
"mode": "0644",
|
||||
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\nbanaction = ufw\nbanaction_allports = iptables-allports\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
|
||||
"content": "[INCLUDES]\n\nbefore = paths-arch.conf\n\n[DEFAULT]\n\n# Never act on the machine itself or on a tunnel peer: the mesh's private range is\n# ${machine:mesh-range}, named here rather than written as a value the module cannot\n# know (novox/hq ADR 0112). Without this, fail2ban could ban the mesh's own nodes.\nignoreip = 127.0.0.1/8 ::1 ${machine:mesh-range}\n\nbantime = 10m\nfindtime = 10m\nmaxretry = 5\n\nbanaction = ufw\nbanaction_allports = iptables-allports\n\n[sshd]\nenabled = true\nport = ssh\nlogpath = %(sshd_log)s\nbackend = %(sshd_backend)s\n"
|
||||
},
|
||||
{
|
||||
"id": "jail-sshd",
|
||||
|
||||
+27
-2
@@ -390,7 +390,7 @@ export class GiteaAdmin {
|
||||
}
|
||||
|
||||
private async findTeam(org: string, team: string): Promise<number | null> {
|
||||
const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`);
|
||||
const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`);
|
||||
if (res.status !== 200) return null;
|
||||
const match = (res.body as any[] | null)?.find((t) => t?.name === team);
|
||||
return match ? Number(match.id) : null;
|
||||
@@ -414,7 +414,9 @@ export class GiteaAdmin {
|
||||
const patch = await this.request(`/admin/users/${encodeURIComponent(username)}`, {
|
||||
method: "PATCH",
|
||||
// login_name is required by the admin edit endpoint; for a local user it is the username.
|
||||
body: JSON.stringify({ login_name: username, password, must_change_password: false }),
|
||||
// active and prohibit_login: a deactivated or login-prohibited user is refused like a wrong
|
||||
// password, so the provisioner's check reports it lost; applying again must undo both.
|
||||
body: JSON.stringify({ login_name: username, password, must_change_password: false, active: true, prohibit_login: false }),
|
||||
});
|
||||
if (patch.status === 200) return;
|
||||
GiteaAdmin.fail(`/admin/users/${username}`, patch);
|
||||
@@ -433,6 +435,29 @@ export class GiteaAdmin {
|
||||
GiteaAdmin.fail(`/teams/${teamId}/members/${username}`, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a consumer's user logs in with exactly this password and is still a member of the
|
||||
* package team. Read-only: the password is checked as the consumer presents it, basic auth on the
|
||||
* API, and membership through the admin API. `false` for a refused login or a missing member; any
|
||||
* other answer rejects (novox/hq issue 120).
|
||||
*/
|
||||
async holdsTeamMember(org: string, team: string, username: string, password: string): Promise<boolean> {
|
||||
const me = await fetch(`${this.baseUrl}/api/v1/user`, {
|
||||
headers: { Authorization: "Basic " + Buffer.from(`${username}:${password}`).toString("base64") },
|
||||
});
|
||||
if (me.status === 401 || me.status === 403) return false;
|
||||
if (me.status !== 200) throw new Error(`Gitea GET /user as ${username}: ${me.status}`);
|
||||
const teams = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`);
|
||||
if (teams.status === 404) return false;
|
||||
if (teams.status !== 200) GiteaAdmin.fail(`/orgs/${org}/teams`, teams);
|
||||
const found = (teams.body as { id: number; name: string }[]).find((t) => t.name === team);
|
||||
if (!found) return false;
|
||||
const member = await this.request(`/teams/${found.id}/members/${encodeURIComponent(username)}`);
|
||||
if (member.status === 200 || member.status === 204) return true;
|
||||
if (member.status === 404) return false;
|
||||
GiteaAdmin.fail(`/teams/${found.id}/members/${username}`, member);
|
||||
}
|
||||
|
||||
/** Delete a user, purging what they own. A 404 means the mesh already withdrew them — success, not
|
||||
* an error, so a re-run of remove is safe. */
|
||||
async deleteUser(username: string): Promise<void> {
|
||||
|
||||
+39
-23
@@ -24,14 +24,14 @@
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"postgres-database": "/var/lib/gitea/database.json",
|
||||
"route": "/var/lib/gitea/route.json"
|
||||
"postgres-database": "${dir:state}/database.json",
|
||||
"route": "${dir:state}/route.json"
|
||||
},
|
||||
"secrets": {
|
||||
"postgres-database": "/var/lib/gitea/database.secret",
|
||||
"postgres-database": "${dir:state}/database.secret",
|
||||
"secret": {
|
||||
"internal-token": "/var/lib/gitea/internal-token.secret",
|
||||
"admin": "/var/lib/gitea/admin.secret"
|
||||
"internal-token": "${dir:state}/internal-token.secret",
|
||||
"admin": "${dir:state}/admin.secret"
|
||||
}
|
||||
},
|
||||
"capabilities": [
|
||||
@@ -53,22 +53,36 @@
|
||||
"port": 22,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention — not 22, which the machine's own daemon holds and a module does not take"
|
||||
"why": "git over ssh, gitea's own unmodified sshd. Published on the machine's own side at 222, the mesh's fixed public convention \u2014 not 22, which the machine's own daemon holds and a module does not take"
|
||||
}
|
||||
],
|
||||
"serves": {
|
||||
"package-registry": {
|
||||
"npm-package-registry": {
|
||||
"scheme": "http",
|
||||
"port": 3000,
|
||||
"npm-path": "/api/packages/novox/npm/"
|
||||
},
|
||||
"git": {
|
||||
"scheme": "http",
|
||||
"port": 3000
|
||||
}
|
||||
},
|
||||
"receives": {
|
||||
"package-registry": "/var/lib/gitea/grants/mesh.json"
|
||||
"npm-package-registry": "${dir:grants}/npm.json"
|
||||
},
|
||||
"grants": {
|
||||
"package-registry": "/var/lib/gitea/grants"
|
||||
"npm-package-registry": "${dir:grants}"
|
||||
},
|
||||
"claims": [
|
||||
{
|
||||
"name": "npm-package-registry",
|
||||
"scope": "mesh"
|
||||
},
|
||||
{
|
||||
"name": "git",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/gitea/broker"
|
||||
},
|
||||
@@ -88,26 +102,24 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/gitea",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "grants",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/gitea/grants",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "server-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/gitea/server.env",
|
||||
"path": "${dir:state}/server.env",
|
||||
"mode": "0600",
|
||||
"content": "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\nGITEA__database__DB_TYPE=postgres\nGITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nGITEA__database__NAME=${bound:postgres-database:as}\nGITEA__database__USER=${bound:postgres-database:as}\nGITEA__database__PASSWD=${secret:postgres-database}\n"
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/gitea/gitea",
|
||||
"mode": "0700",
|
||||
"owner": "1000:1000"
|
||||
},
|
||||
@@ -122,14 +134,14 @@
|
||||
"USER_GID": "1000"
|
||||
},
|
||||
"env-file": [
|
||||
"/var/lib/gitea/server.env"
|
||||
"${dir:state}/server.env"
|
||||
],
|
||||
"ports": [
|
||||
"3000",
|
||||
"222:22"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/gitea/gitea:/data"
|
||||
"${dir:data}:/data"
|
||||
],
|
||||
"secrets-in-environment": "gitea honours GITEA__database__PASSWD__FILE and GITEA__security__INTERNAL_TOKEN__FILE; convertible, awaiting a bed that proves it"
|
||||
},
|
||||
@@ -145,11 +157,11 @@
|
||||
"MESH_GITEA_ADMIN_USER": "mesh-admin"
|
||||
},
|
||||
"env-file": [
|
||||
"/var/lib/gitea/server.env"
|
||||
"${dir:state}/server.env"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/gitea/gitea:/data",
|
||||
"/var/lib/gitea/admin.secret:/run/secrets/admin:ro"
|
||||
"${dir:data}:/data",
|
||||
"${dir:state}/admin.secret:/run/secrets/admin:ro"
|
||||
],
|
||||
"args": [
|
||||
"/bin/sh",
|
||||
@@ -174,8 +186,8 @@
|
||||
"volumes": [
|
||||
"/var/lib/mesh/gitea/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/gitea/config.json:/run/config/config.json:ro",
|
||||
"/var/lib/gitea/grants:/var/lib/gitea/grants:ro",
|
||||
"/var/lib/gitea/admin.secret:/run/secrets/admin:ro",
|
||||
"${dir:grants}:${dir:grants}:ro",
|
||||
"${dir:state}/admin.secret:/run/secrets/admin:ro",
|
||||
"/var/lib/mesh/gitea/state:/run/state"
|
||||
],
|
||||
"env": {
|
||||
@@ -185,7 +197,7 @@
|
||||
"MESH_GITEA_ADMIN_USER": "mesh-admin",
|
||||
"MESH_GITEA_ADMIN_PASSWORD_FILE": "/run/secrets/admin",
|
||||
"MESH_GITEA_STATE_DIR": "/run/state",
|
||||
"MESH_RECEIVES": "/var/lib/gitea/grants/mesh.json"
|
||||
"MESH_RECEIVES": "${dir:grants}/npm.json"
|
||||
},
|
||||
"artifact": "runtime",
|
||||
"restart-on": [
|
||||
@@ -195,7 +207,11 @@
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
"name": "package-registry",
|
||||
"name": "npm-package-registry",
|
||||
"scope": "mesh"
|
||||
},
|
||||
{
|
||||
"name": "git",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
// gitea's provisioner — the adapter that makes gitea a provider of the mesh `package-registry`
|
||||
// interface. The reconcile loop, the contributions file, and reading the mesh's minted password are
|
||||
// the sdk harness's; this writes only the per-service half: how gitea creates and removes a
|
||||
// consumer's npm credential (novox/hq ADR 0048/0076).
|
||||
// gitea's provisioner — the adapter that makes gitea a provider of the mesh
|
||||
// `npm-package-registry` interface. The reconcile loop, the contributions file, and reading the
|
||||
// mesh's minted password are the sdk harness's; this writes only the per-service half: how gitea
|
||||
// creates and removes a consumer's npm credential (novox/hq ADR 0048/0076).
|
||||
//
|
||||
// The `package-registry` interface: a consumer authenticates to the npm registry at
|
||||
// **A package registry seat is one per ecosystem (novox/hq ADR 0109).** gitea holds the npm seat
|
||||
// (ADR 0110). Adding cargo or PyPI is adding a provision — another `provides` entry, another
|
||||
// `receives` path and another registration below — not widening this one. `git`, which gitea also
|
||||
// provides, mints nothing and so registers nothing here: the mesh's own repositories are public,
|
||||
// and a clone credential is not yet decided (ADR 0111).
|
||||
//
|
||||
// The `npm-package-registry` interface: a consumer authenticates to the npm registry at
|
||||
// `/api/packages/novox/npm/` with basic auth, as `as` with the password the mesh minted, and can
|
||||
// read and write packages under the `@novox` scope. The registry's npm owner is the gitea org
|
||||
// `novox`; a consumer is a gitea *user* placed on that org's package team.
|
||||
@@ -26,7 +32,11 @@ const PACKAGE_TEAM = "packages";
|
||||
|
||||
const gitea = GiteaAdmin.fromEnv();
|
||||
|
||||
runProvisioner("package-registry", {
|
||||
// Where this registration's contributions land comes from $MESH_RECEIVES, never a path written
|
||||
// here: the mesh writes the file where the manifest's `receives` says, and a second copy of that
|
||||
// path in code would drift from it. One variable carries one path, so a second registration in this
|
||||
// module needs the mesh to say where each provision's file is — not yet possible, and not faked.
|
||||
runProvisioner("npm-package-registry", {
|
||||
async create(p: Provision): Promise<void> {
|
||||
// The org and its package team are the same for every consumer; ensuring them per-create is
|
||||
// idempotent and needs no separate bootstrap step.
|
||||
@@ -46,4 +56,9 @@ runProvisioner("package-registry", {
|
||||
async remove(p: { as: string }): Promise<void> {
|
||||
await gitea.deleteUser(p.as);
|
||||
},
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||
async holds(p: Provision): Promise<boolean> {
|
||||
return gitea.holdsTeamMember(ORG, PACKAGE_TEAM, p.as, p.password);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,12 +14,15 @@
|
||||
"mongodb-database": {
|
||||
"name": "invoicing"
|
||||
},
|
||||
"s3-bucket": {
|
||||
"bucket": "invoicing"
|
||||
},
|
||||
"route": {
|
||||
"label": "invoicing",
|
||||
"port": 80
|
||||
"site": {
|
||||
"label": "invoicing",
|
||||
"port": 80
|
||||
},
|
||||
"api": {
|
||||
"label": "invoicing-api",
|
||||
"port": 9000
|
||||
}
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
@@ -63,7 +66,7 @@
|
||||
"type": "file",
|
||||
"path": "/var/lib/invoicing/api.env",
|
||||
"mode": "0600",
|
||||
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/invoicing?authSource=admin\nMINIO_BUCKET=invoicing\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n"
|
||||
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=${bound:mongodb-database:as}\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_BUCKET=mesh-novox-invoice\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\n"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
|
||||
@@ -88,7 +88,9 @@
|
||||
"env": {
|
||||
"KC_DB": "postgres",
|
||||
"KC_HTTP_ENABLED": "true",
|
||||
"KC_HEALTH_ENABLED": "true"
|
||||
"KC_HEALTH_ENABLED": "true",
|
||||
"KC_HOSTNAME": "https://keycloak.novox.be",
|
||||
"KC_PROXY_HEADERS": "xforwarded"
|
||||
},
|
||||
"env-file": [
|
||||
"/var/lib/keycloak/admin.env",
|
||||
@@ -118,7 +120,7 @@
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_KEYCLOAK_URL": "http://127.0.0.1:8080",
|
||||
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}",
|
||||
"MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
|
||||
@@ -94,6 +94,39 @@ export class LavinmqClient {
|
||||
await this.api("PUT", `/permissions/${v}/${u}`, { configure: ".*", write: ".*", read: ".*" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a consumer's user exists with exactly this password and full permissions on its own
|
||||
* vhost. Read-only: the stored hash is salted SHA-256, the scheme `rabbitHash` writes, so the
|
||||
* password is checked by hashing it with the stored salt rather than by logging in. `false` when
|
||||
* the user or its permission is gone or the password differs; an unreachable API rejects
|
||||
* (novox/hq issue 120).
|
||||
*/
|
||||
async holdsConsumer(login: string, password: string): Promise<boolean> {
|
||||
const v = encodeURIComponent(login);
|
||||
const u = encodeURIComponent(login);
|
||||
const user = await this.getOrNull<{ password_hash?: string; hashing_algorithm?: string }>(`/users/${u}`);
|
||||
if (!user?.password_hash) return false;
|
||||
if (user.hashing_algorithm && !/sha256/i.test(user.hashing_algorithm)) {
|
||||
throw new Error(`lavinmq user ${login} is hashed with ${user.hashing_algorithm}, which this check cannot verify`);
|
||||
}
|
||||
const stored = Buffer.from(user.password_hash, "base64");
|
||||
if (stored.length < 5 || rabbitHash(password, stored.subarray(0, 4)) !== user.password_hash) return false;
|
||||
const perm = await this.getOrNull<{ configure?: string; write?: string; read?: string }>(`/permissions/${v}/${u}`);
|
||||
return perm?.configure === ".*" && perm?.write === ".*" && perm?.read === ".*";
|
||||
}
|
||||
|
||||
/** A GET that answers null for a 404 and rejects on anything else that is not 2xx. */
|
||||
private async getOrNull<T>(path: string): Promise<T | null> {
|
||||
const resp = await fetch(`${this.conn.base}/api${path}`, {
|
||||
headers: {
|
||||
Authorization: "Basic " + Buffer.from(`${this.conn.adminUser}:${this.conn.adminPassword}`).toString("base64"),
|
||||
},
|
||||
});
|
||||
if (resp.status === 404) return null;
|
||||
if (!resp.ok) throw new Error(`lavinmq management API GET ${path} -> ${resp.status}: ${await resp.text()}`);
|
||||
return (await resp.json()) as T;
|
||||
}
|
||||
|
||||
/** Remove a consumer's vhost and user, idempotently. A DELETE of what is already gone is tolerated. */
|
||||
async removeConsumer(login: string): Promise<void> {
|
||||
const v = encodeURIComponent(login);
|
||||
|
||||
@@ -81,12 +81,6 @@
|
||||
"path": "/var/lib/mesh-broker",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "broker-tls",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh-broker-tls",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
@@ -147,5 +141,11 @@
|
||||
"from": "Dockerfile"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"accesses": [
|
||||
{
|
||||
"path": "/var/lib/mesh-broker-tls",
|
||||
"mode": "read"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -48,4 +48,9 @@ runProvisioner("amqp", {
|
||||
await lavinmq.removeConsumer(p.as);
|
||||
await announce("module.lavinmq.amqp.deprovisioned", { user: p.as, vhost: p.as });
|
||||
},
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||
async holds(p: Provision): Promise<boolean> {
|
||||
return lavinmq.holdsConsumer(p.as, p.password);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -130,10 +130,39 @@ export class MailuClient {
|
||||
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the mesh's password on a mailbox the mesh provisions, and enable it. A disabled mailbox is
|
||||
* what the provisioner's check reports as lost, so applying again must enable it, or the two would
|
||||
* disagree for ever. Separate from changePassword, which an operator's tool uses and which must
|
||||
* not re-enable a mailbox someone disabled.
|
||||
*/
|
||||
async applyProvisioned(email: string, password: string): Promise<void> {
|
||||
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password, enabled: true });
|
||||
}
|
||||
|
||||
async deleteUser(email: string): Promise<void> {
|
||||
await this.api("DELETE", `/user/${encodeURIComponent(email)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a mailbox exists and is enabled. Read-only, through the admin API.
|
||||
*
|
||||
* **The password is not checked.** Mailu authenticates in its admin service, behind the front;
|
||||
* the imap server's own password database accepts any password from Mailu's subnet, so asking it
|
||||
* (`doveadm auth test`) proves nothing, or refuses everyone. A lost or disabled mailbox is caught;
|
||||
* a password changed by hand is not (novox/hq issue 120).
|
||||
*/
|
||||
async holdsUser(email: string): Promise<boolean> {
|
||||
const res = await fetch(`${this.baseUrl}/user/${encodeURIComponent(email)}`, {
|
||||
headers: { Authorization: this.apiKey, Accept: "application/json" },
|
||||
});
|
||||
if (res.status === 404) return false;
|
||||
if (!res.ok) throw new Error(`Mailu API GET /user/${email}: ${res.status} ${await res.text()}`);
|
||||
const user = (await res.json()) as { enabled?: boolean };
|
||||
return user.enabled !== false;
|
||||
}
|
||||
|
||||
|
||||
async listAliases(): Promise<MailuAlias[]> {
|
||||
const aliases = await this.api<any[]>("GET", "/alias");
|
||||
return (aliases ?? []).map((a) => ({
|
||||
|
||||
+54
-71
@@ -41,15 +41,15 @@
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"postgres-database": "/var/lib/mailu/database.json",
|
||||
"route": "/var/lib/mailu/route.json"
|
||||
"postgres-database": "${dir:state}/database.json",
|
||||
"route": "${dir:state}/route.json"
|
||||
},
|
||||
"secrets": {
|
||||
"postgres-database": "/var/lib/mailu/database.secret",
|
||||
"postgres-database": "${dir:state}/database.secret",
|
||||
"secret": {
|
||||
"secret-key": "/var/lib/mailu/secret-key.secret",
|
||||
"admin": "/var/lib/mailu/admin.secret",
|
||||
"api-token": "/var/lib/mailu/api-token.secret"
|
||||
"secret-key": "${dir:state}/secret-key.secret",
|
||||
"admin": "${dir:state}/admin.secret",
|
||||
"api-token": "${dir:state}/api-token.secret"
|
||||
}
|
||||
},
|
||||
"emits": [
|
||||
@@ -140,143 +140,125 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mailu",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "grants",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mailu/grants",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-automx",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/automx",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "config-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mailu/mailu.env",
|
||||
"path": "${dir:state}/mailu.env",
|
||||
"mode": "0644",
|
||||
"content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=cert\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nWEBMAIL_ADDRESS=webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n"
|
||||
"content": "ADMIN_ADDRESS=mailu-admin\nANTISPAM_ADDRESS=mailu-antispam\nANTIVIRUS_ADDRESS=mailu-antivirus\nIMAP_ADDRESS=mailu-imap\nSMTP_ADDRESS=mailu-smtp\nFRONT_ADDRESS=mailu-front\nWEBMAIL_ADDRESS=mailu-webmail\nWEBDAV_ADDRESS=mailu-webdav\nREDIS_ADDRESS=mailu-redis\nPORTS=25,80,443,465,993,995,4190,110,143,587\nDOMAIN=novox.be\nHOSTNAMES=mail.novox.be\nPOSTMASTER=admin\nSITENAME=Novox\nWEBSITE=https://novox.be\nTLS_FLAVOR=letsencrypt\nSUBNET=192.168.203.0/24\nCOMPOSE_PROJECT_NAME=mailu\nANTIVIRUS=clamav\nWEBMAIL=roundcube\nWEBDAV=radicale\nFETCHMAIL_ENABLED=True\nFETCHMAIL_DELAY=600\nADMIN=true\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBROOT_REDIRECT=/webmail\nAPI=true\nWEB_API=/api\nAUTH_RATELIMIT_IP=6000/hour\nAUTH_RATELIMIT_USER=1000/day\nCREDENTIAL_ROUNDS=12\nPASSWORD_SCHEME=PBKDF2\nDISABLE_STATISTICS=True\nMESSAGE_SIZE_LIMIT=50000000\nMESSAGE_RATELIMIT=200/day\nRECIPIENT_DELIMITER=+\nPOSTFIX_MYNETWORKS=127.0.0.0/8 [::1]/128\nRELAYNETS=\nRELAYHOST=\nREJECT_UNLISTED_RECIPIENT=\nDB_FLAVOR=postgresql\nINITIAL_ADMIN_ACCOUNT=admin\nINITIAL_ADMIN_DOMAIN=novox.be\nINITIAL_ADMIN_MODE=ifmissing\nSMTP_PORT=25\nSMTPS_PORT=465\nSUBMISSION_PORT=587\nPOP3_PORT=110\nPOP3S_PORT=995\nIMAP_PORT=143\nIMAPS_PORT=993\nHTTP_PORT=7080\nHTTPS_PORT=7443\nAUTOMX_PORT=4243\nAMX_SMTP_ADDRESS=mail.novox.be\nAMX_SMTP_PORT=587\nAMX_IMAP_ADDRESS=mail.novox.be\nAMX_IMAP_PORT=143\nAMX_MAIL_DOMAINS=novox.be\nDMARC_RUA=admin\nDMARC_RUF=admin\nLETSENCRYPT_SHORTCHAIN=True\nTZ=Etc/UTC\nLOG_LEVEL=INFO\nWELCOME=false\nREAL_IP_HEADER=X-Real-IP\nREAL_IP_FROM=142.132.152.141\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\nWELCOME_SUBJECT=Welcome to your new email account\nWELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!\n"
|
||||
},
|
||||
{
|
||||
"id": "secret-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mailu/secret.env",
|
||||
"path": "${dir:state}/secret.env",
|
||||
"mode": "0600",
|
||||
"content": "SECRET_KEY=${secret:secret-key}\n"
|
||||
},
|
||||
{
|
||||
"id": "database-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mailu/database.env",
|
||||
"path": "${dir:state}/database.env",
|
||||
"mode": "0600",
|
||||
"content": "DB_FLAVOR=postgresql\nDB_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nDB_USER=${bound:postgres-database:as}\nDB_NAME=${bound:postgres-database:as}\nDB_PW=${secret:postgres-database}\n"
|
||||
},
|
||||
{
|
||||
"id": "admin-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mailu/admin.env",
|
||||
"path": "${dir:state}/admin.env",
|
||||
"mode": "0600",
|
||||
"content": "INITIAL_ADMIN_PW=${secret:admin}\nAPI_TOKEN=${secret:api-token}\n"
|
||||
},
|
||||
{
|
||||
"id": "data-certs",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/certs",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-data",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/data",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-dkim",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/dkim",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-mail",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/mail",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-mailqueue",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/mailqueue",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "data-filter",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/filter",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-clamav",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/clamav",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-redis",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/redis",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-webmail",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/webmail",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-dav",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/dav",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-fetchmail",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/data/fetchmail",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-overrides-nginx",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/overrides/nginx",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-overrides-dovecot",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/overrides/dovecot",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-overrides-postfix",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/overrides/postfix",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-overrides-rspamd",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/overrides/rspamd",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data-overrides-roundcube",
|
||||
"type": "directory",
|
||||
"path": "/services/mailu/data/overrides/roundcube",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
@@ -291,8 +273,8 @@
|
||||
"image": "ghcr.io/mailu/unbound@sha256:3a0fdfb364a63f4f9259526e013c1ef40f5f14de3621ce1560804b3a5909584a",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env",
|
||||
"/var/lib/mailu/secret.env"
|
||||
"${dir:state}/mailu.env",
|
||||
"${dir:state}/secret.env"
|
||||
],
|
||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified",
|
||||
"ip": "192.168.203.254"
|
||||
@@ -304,7 +286,7 @@
|
||||
"image": "redis@sha256:4bed291aa5efb9f0d77b76ff7d4ab71eee410962965d052552db1fb80576431d",
|
||||
"network": "mailu",
|
||||
"volumes": [
|
||||
"/services/mailu/data/redis:/data"
|
||||
"${dir:data-redis}:/data"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -314,14 +296,14 @@
|
||||
"image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env",
|
||||
"/var/lib/mailu/secret.env",
|
||||
"/var/lib/mailu/database.env",
|
||||
"/var/lib/mailu/admin.env"
|
||||
"${dir:state}/mailu.env",
|
||||
"${dir:state}/secret.env",
|
||||
"${dir:state}/database.env",
|
||||
"${dir:state}/admin.env"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mailu/data/data:/data",
|
||||
"/services/mailu/data/dkim:/dkim"
|
||||
"${dir:data-data}:/data",
|
||||
"${dir:data-dkim}:/dkim"
|
||||
],
|
||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified",
|
||||
"dns": [
|
||||
@@ -335,11 +317,11 @@
|
||||
"image": "ghcr.io/mailu/dovecot@sha256:7f0ed5db996fbdc00adc5c5e38a08492e04f7eb4a9fbd66a03aa9a28ddf23993",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env"
|
||||
"${dir:state}/mailu.env"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mailu/data/mail:/mail",
|
||||
"/services/mailu/data/overrides/dovecot:/overrides:ro"
|
||||
"${dir:data-mail}:/mail",
|
||||
"${dir:data-overrides-dovecot}:/overrides:ro"
|
||||
],
|
||||
"dns": [
|
||||
"192.168.203.254"
|
||||
@@ -352,11 +334,11 @@
|
||||
"image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env"
|
||||
"${dir:state}/mailu.env"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mailu/data/mailqueue:/queue",
|
||||
"/services/mailu/data/overrides/postfix:/overrides:ro"
|
||||
"${dir:data-mailqueue}:/queue",
|
||||
"${dir:data-overrides-postfix}:/overrides:ro"
|
||||
],
|
||||
"dns": [
|
||||
"192.168.203.254"
|
||||
@@ -369,11 +351,11 @@
|
||||
"image": "ghcr.io/mailu/rspamd@sha256:ff3666d8a61f17d309c5c6f6bcf4d40470b82299ca706ac650301175bb1a079d",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env"
|
||||
"${dir:state}/mailu.env"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mailu/data/filter:/var/lib/rspamd",
|
||||
"/services/mailu/data/overrides/rspamd:/etc/rspamd/override.d:ro"
|
||||
"${dir:data-filter}:/var/lib/rspamd",
|
||||
"${dir:data-overrides-rspamd}:/etc/rspamd/override.d:ro"
|
||||
],
|
||||
"dns": [
|
||||
"192.168.203.254"
|
||||
@@ -386,7 +368,7 @@
|
||||
"image": "clamav/clamav-debian@sha256:b12ef8fefddbba7d88de59bea8a32622f365339154adf02d38fd089112e6745a",
|
||||
"network": "mailu",
|
||||
"volumes": [
|
||||
"/services/mailu/data/clamav:/var/lib/clamav"
|
||||
"${dir:data-clamav}:/var/lib/clamav"
|
||||
],
|
||||
"dns": [
|
||||
"192.168.203.254"
|
||||
@@ -399,12 +381,12 @@
|
||||
"image": "ghcr.io/mailu/webmail@sha256:bdbee44cdb05a4658f0e3b62cc448de55ca8f8aea172279fda594826144c04f6",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env",
|
||||
"/var/lib/mailu/secret.env"
|
||||
"${dir:state}/mailu.env",
|
||||
"${dir:state}/secret.env"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mailu/data/webmail:/data",
|
||||
"/services/mailu/data/overrides/roundcube:/overrides:ro"
|
||||
"${dir:data-webmail}:/data",
|
||||
"${dir:data-overrides-roundcube}:/overrides:ro"
|
||||
],
|
||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified",
|
||||
"dns": [
|
||||
@@ -418,11 +400,11 @@
|
||||
"image": "ghcr.io/mailu/radicale@sha256:690ed6edf189dfef100a5a8b37c195ebf5d9241ac5f23f2f44b8b7b75726e3de",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env",
|
||||
"/var/lib/mailu/secret.env"
|
||||
"${dir:state}/mailu.env",
|
||||
"${dir:state}/secret.env"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mailu/data/dav:/data"
|
||||
"${dir:data-dav}:/data"
|
||||
],
|
||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified",
|
||||
"dns": [
|
||||
@@ -436,11 +418,11 @@
|
||||
"image": "ghcr.io/mailu/fetchmail@sha256:f881c8412d3bbe73d638469b48321558d6403a9d45bfa043c1e52c752103d42d",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env",
|
||||
"/var/lib/mailu/secret.env"
|
||||
"${dir:state}/mailu.env",
|
||||
"${dir:state}/secret.env"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mailu/data/data/fetchmail:/data"
|
||||
"${dir:data-fetchmail}:/data"
|
||||
],
|
||||
"secrets-in-environment": "mailu-admin honours SECRET_KEY_FILE, DB_PW_FILE and API_TOKEN_FILE (configuration.py) but INITIAL_ADMIN_PW is env-only (start.py); the remaining containers' need for SECRET_KEY is unverified",
|
||||
"dns": [
|
||||
@@ -454,7 +436,7 @@
|
||||
"image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env"
|
||||
"${dir:state}/mailu.env"
|
||||
],
|
||||
"ports": [
|
||||
"25",
|
||||
@@ -468,8 +450,8 @@
|
||||
"7443:443"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mailu/data/certs:/certs",
|
||||
"/services/mailu/data/overrides/nginx:/overrides:ro"
|
||||
"${dir:data-certs}:/certs",
|
||||
"${dir:data-overrides-nginx}:/overrides:ro"
|
||||
],
|
||||
"dns": [
|
||||
"192.168.203.254"
|
||||
@@ -490,8 +472,8 @@
|
||||
"network": "mailu",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/mailu/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mailu/api-token.secret:/run/secrets/api-token:ro",
|
||||
"/var/lib/mailu/grants:/var/lib/mailu/grants:ro",
|
||||
"${dir:state}/api-token.secret:/run/secrets/api-token:ro",
|
||||
"${dir:grants}:${dir:grants}:ro",
|
||||
"/var/lib/mesh/mailu/config.json:/run/config/config.json:ro",
|
||||
"/var/run/docker.sock:/var/run/docker.sock"
|
||||
],
|
||||
@@ -502,7 +484,7 @@
|
||||
"MESH_MAILU_IMAP_CONTAINER": "mailu-imap",
|
||||
"MESH_MAILU_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_MAILU_DOMAIN": "novox.be",
|
||||
"MESH_RECEIVES": "/var/lib/mailu/grants/mesh.json"
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
@@ -516,13 +498,13 @@
|
||||
"artifact": "automx",
|
||||
"network": "mailu",
|
||||
"env-file": [
|
||||
"/var/lib/mailu/mailu.env"
|
||||
"${dir:state}/mailu.env"
|
||||
],
|
||||
"ports": [
|
||||
"4243"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mailu/data/automx:/data"
|
||||
"${dir:data-automx}:/data"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -565,13 +547,14 @@
|
||||
"serves": {
|
||||
"smtp": {
|
||||
"port": 587,
|
||||
"domain": "novox.be"
|
||||
"domain": "novox.be",
|
||||
"name": "mail.novox.be"
|
||||
}
|
||||
},
|
||||
"receives": {
|
||||
"smtp": "/var/lib/mailu/grants/mesh.json"
|
||||
"smtp": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"grants": {
|
||||
"smtp": "/var/lib/mailu/grants"
|
||||
"smtp": "${dir:grants}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -48,7 +48,7 @@ runProvisioner("smtp", {
|
||||
try {
|
||||
await mailu.createUser(email, p.password);
|
||||
} catch {
|
||||
await mailu.changePassword(email, p.password);
|
||||
await mailu.applyProvisioned(email, p.password);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -62,4 +62,9 @@ runProvisioner("smtp", {
|
||||
// named-account consumer is an operator action until the harness carries values here.
|
||||
await mailu.deleteUser(`${p.as}@${domain()}`).catch(() => {});
|
||||
},
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||
async holds(p: Provision): Promise<boolean> {
|
||||
return mailu.holdsUser(addressOf(p));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "the-catalogue",
|
||||
"name": "mesh-catalog",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
|
||||
+17
-4
@@ -125,6 +125,18 @@ export class MinioClient {
|
||||
throw new Error(`minio bucketExists ${bucket}: ${status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a consumer's access key, with exactly this secret, reaches its bucket: a HEAD of the
|
||||
* bucket signed as the consumer, the way it signs. Read-only. `false` when the key is unknown, the
|
||||
* secret wrong, access denied or the bucket gone; any other answer rejects (novox/hq issue 120).
|
||||
*/
|
||||
async canReachAs(bucket: string, accessKey: string, secretKey: string): Promise<boolean> {
|
||||
const { status } = await this.request("HEAD", `/${bucket}`, {}, { accessKey, secretKey });
|
||||
if (status === 200) return true;
|
||||
if (status === 403 || status === 404) return false;
|
||||
throw new Error(`minio HEAD ${bucket} as ${accessKey}: ${status}`);
|
||||
}
|
||||
|
||||
async createBucket(bucket: string): Promise<void> {
|
||||
const { status, text } = await this.request("PUT", `/${bucket}`);
|
||||
// 200 created; 409 BucketAlreadyOwnedByYou — idempotent, a re-provision must not fail.
|
||||
@@ -251,6 +263,7 @@ export class MinioClient {
|
||||
method: string,
|
||||
path: string,
|
||||
query: Record<string, string> = {},
|
||||
as: { accessKey: string; secretKey: string } = { accessKey: this.rootUser, secretKey: this.rootPassword },
|
||||
): Promise<{ status: number; headers: Headers; text: string }> {
|
||||
const { amzDate, dateStamp } = this.stamp();
|
||||
const host = new URL(this.baseUrl).host;
|
||||
@@ -262,8 +275,8 @@ export class MinioClient {
|
||||
const canonicalRequest = [method, encodedPath, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join("\n");
|
||||
const scope = `${dateStamp}/${this.region}/s3/aws4_request`;
|
||||
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n");
|
||||
const signature = hmac(this.signingKey(dateStamp), stringToSign).toString("hex");
|
||||
const authorization = `AWS4-HMAC-SHA256 Credential=${this.rootUser}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||
const signature = hmac(this.signingKey(dateStamp, as.secretKey), stringToSign).toString("hex");
|
||||
const authorization = `AWS4-HMAC-SHA256 Credential=${as.accessKey}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||
|
||||
const url = `${this.baseUrl}${encodedPath}${canonicalQuery ? `?${canonicalQuery}` : ""}`;
|
||||
const res = await fetch(url, {
|
||||
@@ -275,8 +288,8 @@ export class MinioClient {
|
||||
return { status: res.status, headers: res.headers, text };
|
||||
}
|
||||
|
||||
private signingKey(dateStamp: string): Buffer {
|
||||
const kDate = hmac(`AWS4${this.rootPassword}`, dateStamp);
|
||||
private signingKey(dateStamp: string, secretKey: string = this.rootPassword): Buffer {
|
||||
const kDate = hmac(`AWS4${secretKey}`, dateStamp);
|
||||
const kRegion = hmac(kDate, this.region);
|
||||
const kService = hmac(kRegion, "s3");
|
||||
return hmac(kService, "aws4_request");
|
||||
|
||||
@@ -95,14 +95,14 @@
|
||||
{
|
||||
"id": "net",
|
||||
"type": "network",
|
||||
"name": "minio"
|
||||
"name": "minio-net"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "minio",
|
||||
"image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372",
|
||||
"network": "minio",
|
||||
"network": "minio-net",
|
||||
"args": [
|
||||
"server",
|
||||
"/data",
|
||||
@@ -122,6 +122,7 @@
|
||||
],
|
||||
"env": {
|
||||
"MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root",
|
||||
"MINIO_BROWSER_REDIRECT_URL": "https://files.novox.be",
|
||||
"MINIO_REGION": "eu-west"
|
||||
}
|
||||
},
|
||||
@@ -129,7 +130,7 @@
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-minio",
|
||||
"network": "minio",
|
||||
"network": "minio-net",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/minio/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/minio/grants:/var/lib/minio/grants:ro",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -53,6 +53,12 @@ runProvisioner("s3-bucket", {
|
||||
|
||||
await announce("module.minio.bucket.removed", { bucket, accessKey: p.as });
|
||||
},
|
||||
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||
async holds(p: Provision): Promise<boolean> {
|
||||
return minio.canReachAs(bucketFor(p.as), p.as, p.password);
|
||||
},
|
||||
});
|
||||
|
||||
/** Emit best-effort: a broker hiccup is logged and dropped, never allowed to throw back and fail a
|
||||
|
||||
@@ -109,6 +109,34 @@ print(EJSON.stringify({ ok: 1 }));
|
||||
await this.evalJs<{ ok: number }>(js);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `user` authenticates against `database` with exactly `password` and holds `dbOwner`
|
||||
* there: checked by connecting as the consumer, the way it connects. Read-only. `false` only on an
|
||||
* authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120).
|
||||
*/
|
||||
async canAuthenticateAs(database: string, user: string, password: string): Promise<boolean> {
|
||||
// Connected without credentials, then authenticated inside the eval from the environment, so
|
||||
// the consumer's password is neither on argv nor in the message of a failed command.
|
||||
const uri = `mongodb://${this.conn.host}:${this.conn.port}/?serverSelectionTimeoutMS=10000`;
|
||||
const js =
|
||||
"const t = db.getSiblingDB(process.env.MESH_HOLDS_DB);" +
|
||||
"t.auth(process.env.MESH_HOLDS_USER, process.env.MESH_HOLDS_PW);" +
|
||||
"print(EJSON.stringify(t.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUserRoles))";
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await run("mongosh", [uri, "--quiet", "--eval", js], {
|
||||
env: { ...process.env, MESH_HOLDS_DB: database, MESH_HOLDS_USER: user, MESH_HOLDS_PW: password },
|
||||
timeout: 30_000,
|
||||
}));
|
||||
} catch (err) {
|
||||
const text = `${(err as { stderr?: string }).stderr ?? ""}${(err as { stdout?: string }).stdout ?? ""}`;
|
||||
if (/Authentication failed|AuthenticationFailed/i.test(text)) return false;
|
||||
throw new Error(`mongosh could not check ${user}: ${text.trim().slice(0, 500) || String((err as Error).message).split("\n")[0]}`);
|
||||
}
|
||||
const roles = JSON.parse(stdout.trim()) as { role: string; db: string }[];
|
||||
return roles.some((r) => r.role === "dbOwner" && r.db === database);
|
||||
}
|
||||
|
||||
/** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the
|
||||
* user is removed first so a re-grant of the same login starts clean. */
|
||||
async dropDatabaseAndUser(database: string, user: string): Promise<void> {
|
||||
|
||||
+12
-14
@@ -32,13 +32,13 @@
|
||||
}
|
||||
},
|
||||
"receives": {
|
||||
"mongodb-database": "/var/lib/mongodb/grants/mesh.json"
|
||||
"mongodb-database": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"grants": {
|
||||
"mongodb-database": "/var/lib/mongodb/grants"
|
||||
"mongodb-database": "${dir:grants}"
|
||||
},
|
||||
"own-secrets": {
|
||||
"root": "/var/lib/mongodb/root.secret",
|
||||
"root": "${dir:state}/root.secret",
|
||||
"broker": "/var/lib/mesh/mongodb/broker"
|
||||
},
|
||||
"secrets-owner": "999:999",
|
||||
@@ -52,19 +52,17 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mongodb",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "grants",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mongodb/grants",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/mongodb/db-data",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
@@ -75,7 +73,7 @@
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "mongo",
|
||||
"name": "mongodb-server",
|
||||
"image": "mongo@sha256:e3fa459b4f4b72f3257c67a23c145e250b8b5700f033860392c68539b998bbe3",
|
||||
"network": "mongodb",
|
||||
"env": {
|
||||
@@ -86,8 +84,8 @@
|
||||
"27017"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mongodb/db-data:/data/db",
|
||||
"/var/lib/mongodb/root.secret:/run/secrets/root:ro"
|
||||
"${dir:data}:/data/db",
|
||||
"${dir:state}/root.secret:/run/secrets/root:ro"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -97,14 +95,14 @@
|
||||
"network": "mongodb",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/mongodb/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mongodb/grants:/var/lib/mongodb/grants:ro",
|
||||
"/var/lib/mongodb/root.secret:/run/secrets/root:ro"
|
||||
"${dir:grants}:${dir:grants}:ro",
|
||||
"${dir:state}/root.secret:/run/secrets/root:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_PROVISION_MONGODB": "mongodb://root@mongo:27017/admin?authSource=admin",
|
||||
"MESH_PROVISION_MONGODB": "mongodb://root@mongodb-server:27017/admin?authSource=admin",
|
||||
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/root",
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_RECEIVES": "/var/lib/mongodb/grants/mesh.json"
|
||||
"MESH_RECEIVES": "${dir:grants}/mesh.json"
|
||||
},
|
||||
"artifact": "runtime"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -45,4 +45,9 @@ runProvisioner("mongodb-database", {
|
||||
await mongo.dropDatabaseAndUser(p.as, p.as);
|
||||
await announce("module.mongodb.database.deprovisioned", { database: p.as });
|
||||
},
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||
async holds(p: Provision): Promise<boolean> {
|
||||
return mongo.canAuthenticateAs(p.as, p.as, p.password);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
// The one cost dynsec carries is the bootstrap file; see initBootstrapFile() and the module README.
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { connect as tcpConnect } from "node:net";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
@@ -87,9 +88,20 @@ export class MosquittoClient {
|
||||
"-u", this.conn.adminUser,
|
||||
"-P", this.conn.adminPassword,
|
||||
];
|
||||
const { stdout, stderr } = await run("mosquitto_ctrl", [...base, "dynsec", ...args], {
|
||||
maxBuffer: 16 << 20,
|
||||
});
|
||||
let stdout: string;
|
||||
let stderr: string;
|
||||
try {
|
||||
({ stdout, stderr } = await run("mosquitto_ctrl", [...base, "dynsec", ...args], {
|
||||
maxBuffer: 16 << 20,
|
||||
timeout: 30_000,
|
||||
}));
|
||||
} catch (err) {
|
||||
// A failed run's message repeats its argv, the admin password (-P) included; say what failed
|
||||
// without it.
|
||||
const e = err as { code?: unknown; signal?: unknown; stderr?: string; stdout?: string };
|
||||
const detail = `${e.stderr ?? ""}${e.stdout ?? ""}`.trim().slice(0, 500);
|
||||
throw new Error(`mosquitto_ctrl dynsec ${args[0] ?? ""} could not run (${e.code ?? e.signal ?? "error"}): ${detail}`);
|
||||
}
|
||||
const failure = ctlError(`${stdout}\n${stderr}`);
|
||||
if (failure) {
|
||||
throw new Error(`mosquitto_ctrl dynsec ${args[0] ?? ""} failed: ${failure}`);
|
||||
@@ -140,6 +152,11 @@ export class MosquittoClient {
|
||||
|
||||
if (await this.clientExists(username)) {
|
||||
await this.ctl("setClientPassword", username, password);
|
||||
// A disabled client is refused like a wrong password, so the check the provisioner runs
|
||||
// reports it lost; applying again must enable it, or the two would disagree for ever.
|
||||
if (/Disabled:\s*true/i.test(await this.ctl("getClient", username))) {
|
||||
await this.ctl("enableClient", username);
|
||||
}
|
||||
} else {
|
||||
await this.ctl("createClient", username, "-p", password);
|
||||
}
|
||||
@@ -163,6 +180,28 @@ export class MosquittoClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a consumer's client accepts exactly this password and still carries its own role.
|
||||
* Read-only. The password is checked the way the consumer is checked, by an MQTT CONNECT as it,
|
||||
* and the broker's CONNACK code is the answer: 0 accepted, 4 bad credentials, 5 not authorised.
|
||||
* Nothing rides on argv. An unreachable broker rejects (novox/hq issue 120).
|
||||
*/
|
||||
async holdsClient(username: string, password: string): Promise<boolean> {
|
||||
const code = await mqttConnack(this.conn.host, this.conn.port, username, password);
|
||||
if (code === 4 || code === 5) return false;
|
||||
if (code !== 0) throw new Error(`mosquitto refused ${username} with CONNACK ${code}`);
|
||||
// The role, asked directly: only "not found" means absent. Any other failure to ask rejects,
|
||||
// unlike clientHasRole, which reads every failure as "no role".
|
||||
let out: string;
|
||||
try {
|
||||
out = await this.ctl("getClient", username);
|
||||
} catch (err) {
|
||||
if (/not\s*found|does not exist|no such/i.test(String(err))) return false;
|
||||
throw err;
|
||||
}
|
||||
return new RegExp(`(^|\\s)${escapeRegExp(username)}\\s+\\(priority`, "m").test(out);
|
||||
}
|
||||
|
||||
/** Remove a client and the per-client role created for it, idempotently. */
|
||||
async deleteScopedClient(username: string): Promise<void> {
|
||||
await ignoreMissing(this.ctl("deleteClient", username));
|
||||
@@ -249,3 +288,55 @@ function readSecretFile(path: string | undefined): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect once over MQTT 3.1.1 with a username and password, return the broker's CONNACK return code,
|
||||
* and disconnect. A clean session under a throwaway client id, so no consumer session is taken over.
|
||||
*/
|
||||
function mqttConnack(host: string, port: number, username: string, password: string): Promise<number> {
|
||||
const str = (v: string): Buffer => {
|
||||
const b = Buffer.from(v, "utf8");
|
||||
const len = Buffer.alloc(2);
|
||||
len.writeUInt16BE(b.length);
|
||||
return Buffer.concat([len, b]);
|
||||
};
|
||||
const variable = Buffer.concat([str("MQTT"), Buffer.from([4, 0xc2, 0, 10])]); // level 4; user+pass+clean; keepalive 10s
|
||||
const payload = Buffer.concat([str(`mesh-holds-${randomBytes(6).toString("hex")}`), str(username), str(password)]);
|
||||
let remaining = variable.length + payload.length;
|
||||
const lenBytes: number[] = [];
|
||||
do {
|
||||
let byte = remaining % 128;
|
||||
remaining = Math.floor(remaining / 128);
|
||||
if (remaining > 0) byte |= 0x80;
|
||||
lenBytes.push(byte);
|
||||
} while (remaining > 0);
|
||||
const packet = Buffer.concat([Buffer.from([0x10, ...lenBytes]), variable, payload]);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = tcpConnect({ host, port });
|
||||
let buf = Buffer.alloc(0);
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new Error(`no CONNACK from ${host}:${port} within 10s`));
|
||||
}, 10_000);
|
||||
socket.on("connect", () => socket.write(packet));
|
||||
socket.on("data", (chunk) => {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
if (buf.length < 4) return;
|
||||
clearTimeout(timer);
|
||||
if (buf[0] !== 0x20) {
|
||||
socket.destroy();
|
||||
reject(new Error(`unexpected MQTT packet 0x${buf[0].toString(16)} instead of CONNACK`));
|
||||
return;
|
||||
}
|
||||
const code = buf[3];
|
||||
if (code === 0) socket.end(Buffer.from([0xe0, 0])); // DISCONNECT
|
||||
else socket.destroy();
|
||||
resolve(code);
|
||||
});
|
||||
socket.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -43,4 +43,9 @@ runProvisioner("mqtt-topic", {
|
||||
await mosquitto.deleteScopedClient(p.as);
|
||||
await announce("module.mosquitto.topic.deprovisioned", { username: p.as });
|
||||
},
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||
async holds(p: Provision): Promise<boolean> {
|
||||
return mosquitto.holdsClient(p.as, p.password);
|
||||
},
|
||||
});
|
||||
|
||||
+54
-4
@@ -75,14 +75,18 @@ export class MssqlClient {
|
||||
* prints (split across output lines for a large result, and reassembled here) is parsed. An
|
||||
* empty result yields no output at all — an empty array.
|
||||
*/
|
||||
async query(select: string, database = "master"): Promise<Record<string, unknown>[]> {
|
||||
async query(
|
||||
select: string,
|
||||
database = "master",
|
||||
variables: Record<string, string> = {},
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`;
|
||||
const stdout = await this.sqlcmd(wrapped, database);
|
||||
const stdout = await this.sqlcmd(wrapped, database, variables);
|
||||
return parseJsonRows(stdout);
|
||||
}
|
||||
|
||||
/** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */
|
||||
private async sqlcmd(sql: string, database: string): Promise<string> {
|
||||
private async sqlcmd(sql: string, database: string, variables: Record<string, string> = {}): Promise<string> {
|
||||
// `-h -1` drops the column-header rule; `-y 0`/`-Y 0` lift the display-width cap so a long
|
||||
// JSON document is not truncated; `-W` trims trailing whitespace so the JSON chunks rejoin
|
||||
// cleanly. sqlcmd from the mssql-tools ships in the runtime container, the way `psql` ships
|
||||
@@ -101,7 +105,9 @@ export class MssqlClient {
|
||||
"-W",
|
||||
"-Q", sql,
|
||||
],
|
||||
{ env: { ...process.env, SQLCMDPASSWORD: this.conn.password }, maxBuffer: 16 << 20 },
|
||||
// `variables` reach sqlcmd as environment variables, which it substitutes as `$(NAME)` scripting
|
||||
// variables: a value that must not appear on argv, or in the message of a failed command.
|
||||
{ env: { ...process.env, ...variables, SQLCMDPASSWORD: this.conn.password }, maxBuffer: 16 << 20 },
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
@@ -121,6 +127,9 @@ export class MssqlClient {
|
||||
);
|
||||
} else {
|
||||
await this.exec(`ALTER LOGIN ${ident(login)} WITH PASSWORD = ${literal(password)}`);
|
||||
// A disabled login is refused like a wrong password; the check the provisioner runs reports it
|
||||
// lost, so applying again must enable it or the two would disagree for ever.
|
||||
await this.exec(`ALTER LOGIN ${ident(login)} ENABLE`);
|
||||
}
|
||||
|
||||
const dbs = await this.query(
|
||||
@@ -138,10 +147,51 @@ export class MssqlClient {
|
||||
);
|
||||
if (users.length === 0) {
|
||||
await this.exec(`CREATE USER ${ident(login)} FOR LOGIN ${ident(login)}`, database);
|
||||
} else {
|
||||
// Re-point an existing user at the login when its SID is not the login's: a database restored
|
||||
// from elsewhere keeps its user under the old login's SID, orphaned. Only then, so a user that
|
||||
// is already mapped is left alone.
|
||||
const orphaned = await this.query(
|
||||
`SELECT 1 AS ok FROM sys.database_principals WHERE name = ${literal(login)} ` +
|
||||
`AND (sid IS NULL OR sid <> SUSER_SID(${literal(login)}))`,
|
||||
database,
|
||||
);
|
||||
if (orphaned.length > 0) {
|
||||
await this.exec(`ALTER USER ${ident(login)} WITH LOGIN = ${ident(login)}`, database);
|
||||
}
|
||||
}
|
||||
await this.exec(`ALTER ROLE db_owner ADD MEMBER ${ident(login)}`, database);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `login` exists, is enabled, has exactly `password`, and is a db_owner user of
|
||||
* `database`. Read-only: the password is compared with PWDCOMPARE against the stored hash, so
|
||||
* nothing logs in and no failed-login is recorded (novox/hq issue 120).
|
||||
*/
|
||||
async holdsLogin(database: string, login: string, password: string): Promise<boolean> {
|
||||
// The password reaches sqlcmd as a scripting variable from the environment, never inside the
|
||||
// query text, so it is neither on argv nor in the message of a failed command. It is the mesh's
|
||||
// minted value, which carries no quote.
|
||||
const server = await this.query(
|
||||
`SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` +
|
||||
`AND is_disabled = 0 AND PWDCOMPARE(N'$(MESHHOLDSPW)', password_hash) = 1) ` +
|
||||
`AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`,
|
||||
"master",
|
||||
{ MESHHOLDSPW: password },
|
||||
);
|
||||
if (Number(server[0]?.ok) !== 1) return false;
|
||||
// The user must be this login's, by SID, and a db_owner. A user orphaned by a restore has the
|
||||
// right name and the wrong SID, and cannot be reached through the login.
|
||||
const owner = await this.query(
|
||||
`SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.database_principals dp ` +
|
||||
`JOIN sys.server_principals sp ON dp.sid = sp.sid ` +
|
||||
`WHERE dp.name = ${literal(login)} AND sp.name = ${literal(login)}) ` +
|
||||
`AND IS_ROLEMEMBER('db_owner', ${literal(login)}) = 1 THEN 1 ELSE 0 END AS int) AS ok`,
|
||||
database,
|
||||
);
|
||||
return Number(owner[0]?.ok) === 1;
|
||||
}
|
||||
|
||||
/** Drop a database and its login, idempotently, after evicting live connections. */
|
||||
async dropDatabaseAndLogin(database: string, login: string): Promise<void> {
|
||||
const dbs = await this.query(
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/mssql/data",
|
||||
"mode": "0700",
|
||||
"owner": "10001:0"
|
||||
},
|
||||
@@ -90,7 +89,7 @@
|
||||
"1433"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/mssql/data:/var/opt/mssql"
|
||||
"${dir:data}:/var/opt/mssql"
|
||||
],
|
||||
"secrets-in-environment": "the image documents only MSSQL_SA_PASSWORD, no _FILE and no configuration field; not convertible without a wrapper entrypoint"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -44,4 +44,9 @@ runProvisioner("mssql-database", {
|
||||
await mssql.dropDatabaseAndLogin(p.as, p.as);
|
||||
await announce("module.mssql.database.deprovisioned", { database: p.as });
|
||||
},
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||
async holds(p: Provision): Promise<boolean> {
|
||||
return mssql.holdsLogin(p.as, p.as, p.password);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"module": "networkmanager",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-uplink",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "networkmanager"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "file",
|
||||
"path": "/etc/NetworkManager/conf.d/50-mesh.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module networkmanager). Replaced on every push; edit the\n# catalogue instead.\n#\n# This machine's uplink is NetworkManager's, and this file is the whole of what\n# the mesh asks of it (novox/hq ADR 0117): leave the resolver file to the mesh,\n# and leave the private network's interface alone. Nothing more. The mesh never\n# declares a connection profile, an address, a route, a wireless network or its\n# credentials \u2014 those are joined at the machine, by the person using it, and\n# the link they make is the only channel the mesh reaches this machine over. A\n# push that got a link wrong could not be undone by the next one.\n#\n# A drop-in of the mesh's own, beside NetworkManager.conf and whatever else the\n# operator keeps in this directory. NetworkManager reads the files here sorted by\n# name and a later one wins a key it sets again \u2014 so a file of the operator's\n# that sorts after this one (any name starting with a letter does) and sets dns=\n# or unmanaged-devices= overrides it. That is the operator's to decide, and the\n# reason this file sets nothing but the two keys it must.\n#\n# NetworkManager itself is the machine's: the mesh never starts, stops, enables\n# or disables it (its service is declared with no state), because stopping it\n# takes every link down, this machine's channel to the mesh included \u2014 and a\n# module unassigned by mistake must not be able to do that. When this file\n# changes, a running NetworkManager is reloaded (its D-Bus Reload call, which\n# re-reads its configuration \u2014 NetworkManager(8)), never restarted.\n\n[main]\n# The resolver file is the mesh's: resolv-conf writes /etc/resolv.conf and names\n# the mesh's resolver. Without this line NetworkManager rewrites that file on\n# every connectivity change \u2014 every network joined, every lease renewed \u2014\n# and the mesh's resolver is silently replaced while every surface of the mesh\n# still reads green. none: \"NetworkManager will not modify resolv.conf. This\n# implies rc-manager unmanaged\" (NetworkManager.conf(5), 1.58). On an adopted\n# machine the predecessor wrote the same line in a file of its own; both say one\n# thing, and the predecessor's is retired by hand after the take.\ndns=none\n\n[keyfile]\n# mesh0 is the private network's interface: the mesh brings it up and the mesh\n# alone configures it. A manager that considers every interface its own could\n# try to configure it, or tear it down on a profile change.\n#\n# unmanaged-devices rather than a [device-mesh0] section with managed=0, because\n# NetworkManager.conf(5) says a device unmanaged by this key \"is strictly\n# unmanaged and cannot be overruled by using the API like nmcli device set\n# $IFNAME managed yes\", while device*.managed \"can be overruled at runtime via\n# D-Bus\". The same page adds that device*.managed \"may be a better choice\" for\n# exactly those reasons \u2014 for an interface the operator might want to hand back\n# at runtime. For the mesh's own interface, strict is the point.\n#\n# += rather than =: the same page documents appending to a list-valued key set\n# earlier (\"plugins+=another-plugin\") as an extension of its key file format,\n# and unmanaged-devices is a device list. = would replace whatever devices the\n# operator already keeps NetworkManager away from; += adds this one to them\n# (novox/hq ADR 0102: a list is added to, never replaced). A file of the\n# operator's read after this one that sets the key with = replaces it again;\n# that is the operator's to decide.\nunmanaged-devices+=interface-name:mesh0\n"
|
||||
},
|
||||
{
|
||||
"id": "service",
|
||||
"type": "service",
|
||||
"unit": "NetworkManager.service",
|
||||
"reload-on": [
|
||||
"config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,29 +11,26 @@
|
||||
"postgres-database": {
|
||||
"name": "nextcloud"
|
||||
},
|
||||
"s3-bucket": {
|
||||
"bucket": "nextcloud"
|
||||
},
|
||||
"route": {
|
||||
"label": "drive",
|
||||
"port": 80
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"postgres-database": "/var/lib/nextcloud-module/database.json",
|
||||
"s3-bucket": "/var/lib/nextcloud-module/store.json",
|
||||
"route": "/var/lib/nextcloud-module/route.json"
|
||||
"postgres-database": "${dir:state}/database.json",
|
||||
"s3-bucket": "${dir:state}/store.json",
|
||||
"route": "${dir:state}/route.json"
|
||||
},
|
||||
"secrets": {
|
||||
"postgres-database": "/var/lib/nextcloud-module/database.secret",
|
||||
"s3-bucket": "/var/lib/nextcloud-module/store.secret"
|
||||
"postgres-database": "${dir:state}/database.secret",
|
||||
"s3-bucket": "${dir:state}/store.secret"
|
||||
},
|
||||
"emits": [
|
||||
"module.nextcloud.user.created",
|
||||
"module.nextcloud.share.created"
|
||||
],
|
||||
"own-secrets": {
|
||||
"admin": "/var/lib/nextcloud-module/admin.secret",
|
||||
"admin": "${dir:state}/admin.secret",
|
||||
"broker": "/var/lib/mesh/nextcloud/broker"
|
||||
},
|
||||
"capabilities": [
|
||||
@@ -57,20 +54,19 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/nextcloud-module",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "server-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/nextcloud-module/server.env",
|
||||
"path": "${dir:state}/server.env",
|
||||
"mode": "0600",
|
||||
"content": "POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nPOSTGRES_DB=${bound:postgres-database:as}\nPOSTGRES_USER=${bound:postgres-database:as}\nPOSTGRES_PASSWORD=${secret:postgres-database}\nNEXTCLOUD_ADMIN_USER=mesh-admin\nNEXTCLOUD_ADMIN_PASSWORD=${secret:admin}\nOBJECTSTORE_S3_HOST=${bound:s3-bucket:at}\nOBJECTSTORE_S3_PORT=${bound:s3-bucket:port}\nOBJECTSTORE_S3_BUCKET=nextcloud\nOBJECTSTORE_S3_KEY=${bound:s3-bucket:as}\nOBJECTSTORE_S3_SECRET=${secret:s3-bucket}\nOBJECTSTORE_S3_SSL=false\nOBJECTSTORE_S3_USEPATH_STYLE=true\nOBJECTSTORE_S3_REGION=${bound:s3-bucket:region}\n"
|
||||
"content": "POSTGRES_HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\nPOSTGRES_DB=${bound:postgres-database:as}\nPOSTGRES_USER=${bound:postgres-database:as}\nPOSTGRES_PASSWORD=${secret:postgres-database}\nNEXTCLOUD_ADMIN_USER=mesh-admin\nNEXTCLOUD_ADMIN_PASSWORD=${secret:admin}\nOBJECTSTORE_S3_HOST=${bound:s3-bucket:at}\nOBJECTSTORE_S3_PORT=${bound:s3-bucket:port}\nOBJECTSTORE_S3_BUCKET=mesh-novox-ncloud\nOBJECTSTORE_S3_KEY=${bound:s3-bucket:as}\nOBJECTSTORE_S3_SECRET=${secret:s3-bucket}\nOBJECTSTORE_S3_SSL=false\nOBJECTSTORE_S3_USEPATH_STYLE=true\nOBJECTSTORE_S3_REGION=${bound:s3-bucket:region}\n"
|
||||
},
|
||||
{
|
||||
"id": "html",
|
||||
"type": "directory",
|
||||
"path": "/services/nextcloud/html",
|
||||
"mode": "0750",
|
||||
"owner": "33:33"
|
||||
},
|
||||
@@ -80,13 +76,13 @@
|
||||
"name": "nextcloud",
|
||||
"image": "nextcloud@sha256:fb966733647ea03f0446b0c22eac9733c8eb616d37b960caca9d4c3010e14a08",
|
||||
"env-file": [
|
||||
"/var/lib/nextcloud-module/server.env"
|
||||
"${dir:state}/server.env"
|
||||
],
|
||||
"ports": [
|
||||
"80"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/nextcloud/html:/var/www/html"
|
||||
"${dir:html}:/var/www/html"
|
||||
],
|
||||
"secrets-in-environment": "the image honours POSTGRES_PASSWORD_FILE and NEXTCLOUD_ADMIN_PASSWORD_FILE (entrypoint file_env); OBJECTSTORE_S3_SECRET has none and needs a generated config fragment; convertible, awaiting a bed"
|
||||
},
|
||||
@@ -106,7 +102,7 @@
|
||||
"volumes": [
|
||||
"/var/lib/mesh/nextcloud/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/nextcloud/config.json:/run/config/config.json:ro",
|
||||
"/var/lib/nextcloud-module/admin.secret:/run/secrets/admin:ro",
|
||||
"${dir:state}/admin.secret:/run/secrets/admin:ro",
|
||||
"/var/run/docker.sock:/var/run/docker.sock"
|
||||
],
|
||||
"env": {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "the-packet-filter",
|
||||
"name": "node-packet-filter",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
@@ -30,7 +30,7 @@
|
||||
"id": "stock-unit-stop",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/nftables.service.d/mesh.conf",
|
||||
"content": "# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset\n# (novox/hq ADR 0100) — a flush would take the container runtime's rules and any firewall with it.\n[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n",
|
||||
"content": "# The mesh: stopping the stock unit deletes only the mesh's table, never the whole ruleset\n# (novox/hq ADR 0100) \u2014 a flush would take the container runtime's rules and any firewall with it.\n[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "/var/lib/only-office/route.json"
|
||||
"route": "${dir:state}/route.json"
|
||||
},
|
||||
"own-secrets": {
|
||||
"jwt": "/var/lib/only-office/jwt.secret"
|
||||
"jwt": "${dir:state}/jwt.secret"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
@@ -32,56 +32,49 @@
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/only-office",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "server-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/only-office/server.env",
|
||||
"path": "${dir:state}/server.env",
|
||||
"mode": "0600",
|
||||
"content": "JWT_ENABLED=true\nJWT_SECRET=${secret:jwt}\nJWT_HEADER=Authorization\nJWT_IN_BODY=true\nALLOW_PRIVATE_IP_ADDRESS=true\n"
|
||||
},
|
||||
{
|
||||
"id": "logs",
|
||||
"type": "directory",
|
||||
"path": "/services/only-office/logs",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/only-office/data",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "lib",
|
||||
"type": "directory",
|
||||
"path": "/services/only-office/lib",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "db",
|
||||
"type": "directory",
|
||||
"path": "/services/only-office/db",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "rabbitmq",
|
||||
"type": "directory",
|
||||
"path": "/services/only-office/rabbitmq",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "redis",
|
||||
"type": "directory",
|
||||
"path": "/services/only-office/redis",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "fonts",
|
||||
"type": "directory",
|
||||
"path": "/services/only-office/fonts",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
@@ -96,19 +89,19 @@
|
||||
"image": "onlyoffice/documentserver@sha256:3ab6ebc7c605e5a32b7ae3ff19daed4925090245acc8100ce2230bd766c88212",
|
||||
"network": "only-office",
|
||||
"env-file": [
|
||||
"/var/lib/only-office/server.env"
|
||||
"${dir:state}/server.env"
|
||||
],
|
||||
"ports": [
|
||||
"80"
|
||||
"9070:80"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/only-office/logs:/var/log/onlyoffice",
|
||||
"/services/only-office/data:/var/www/onlyoffice/Data",
|
||||
"/services/only-office/lib:/var/lib/onlyoffice",
|
||||
"/services/only-office/db:/var/lib/postgresql",
|
||||
"/services/only-office/rabbitmq:/var/lib/rabbitmq",
|
||||
"/services/only-office/redis:/var/lib/redis",
|
||||
"/services/only-office/fonts:/usr/share/fonts/truetype/custom"
|
||||
"${dir:logs}:/var/log/onlyoffice",
|
||||
"${dir:data}:/var/www/onlyoffice/Data",
|
||||
"${dir:lib}:/var/lib/onlyoffice",
|
||||
"${dir:db}:/var/lib/postgresql",
|
||||
"${dir:rabbitmq}:/var/lib/rabbitmq",
|
||||
"${dir:redis}:/var/lib/redis",
|
||||
"${dir:fonts}:/usr/share/fonts/truetype/custom"
|
||||
],
|
||||
"secrets-in-environment": "run-document-server.sh regenerates JWT_SECRET from the environment on every start and overwrites local.json; not convertible"
|
||||
}
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
"route"
|
||||
],
|
||||
"contributes": {
|
||||
"s3-bucket": {
|
||||
"bucket": "photos"
|
||||
},
|
||||
"mongodb-database": {
|
||||
"name": "photos"
|
||||
},
|
||||
@@ -56,7 +53,7 @@
|
||||
"type": "file",
|
||||
"path": "/var/lib/photos/server.env",
|
||||
"mode": "0600",
|
||||
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=admin\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_BUCKET=photos\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\nMINIO_USE_SSL=false\n"
|
||||
"content": "NODE_ENV=production\nPORT=9000\nMONGO_URL=mongodb://${bound:mongodb-database:as}:${secret:mongodb-database}@${bound:mongodb-database:at}:${bound:mongodb-database:port}/${bound:mongodb-database:as}?authSource=admin\nMONGO_DB=${bound:mongodb-database:as}\nMINIO_ENDPOINT=${bound:s3-bucket:at}\nMINIO_PORT=${bound:s3-bucket:port}\nMINIO_BUCKET=mesh-novox-photos\nMINIO_ACCESSKEY=${bound:s3-bucket:as}\nMINIO_SECRET=${secret:s3-bucket}\nMINIO_USE_SSL=false\n"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
|
||||
@@ -6,11 +6,17 @@
|
||||
"container-runtime"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"port": 9090,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the dashboard over http; portainer.novox.be is a route grant and the proxy reaches it here \u2014 the machine side of 9090:9000, the predecessor's number"
|
||||
},
|
||||
{
|
||||
"port": 9443,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the container dashboard, over its own tls"
|
||||
"why": "the same dashboard over its own tls; the runtime sidecar talks to it here"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
@@ -23,19 +29,19 @@
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"path": "/services/portainer/data",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "portainer",
|
||||
"image": "portainer/portainer-ce@sha256:511f3f06c96fe3b993ebeaafde311c1959cae73a7ef825dba6397d51b450dffa",
|
||||
"image": "portainer/portainer-ce@sha256:4d616db18cfeb5dd41a69c0958bc825c84483ea9cde1106eb82a5d26f3bd8b0e",
|
||||
"ports": [
|
||||
"9443"
|
||||
"9090:9000",
|
||||
"9443:9443"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/portainer/data:/data",
|
||||
"${dir:data}:/data",
|
||||
"/var/run/docker.sock:/var/run/docker.sock"
|
||||
]
|
||||
},
|
||||
@@ -90,5 +96,17 @@
|
||||
"from": "Dockerfile"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requires": [
|
||||
"route"
|
||||
],
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "portainer",
|
||||
"port": 9090
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "/var/lib/mesh/portainer/route.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,9 +88,11 @@ export class PostgresClient {
|
||||
async createDatabaseAndRole(database: string, role: string, password: string): Promise<void> {
|
||||
const roles = await this.query("SELECT 1 FROM pg_roles WHERE rolname = " + literal(role));
|
||||
if (roles.rows.length === 0) {
|
||||
await this.query(`CREATE ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`);
|
||||
await this.query(`CREATE ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)} VALID UNTIL 'infinity'`);
|
||||
} else {
|
||||
await this.query(`ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`);
|
||||
// VALID UNTIL 'infinity': a password that expired is refused like a wrong one, so the check the
|
||||
// provisioner runs would report it lost, and only clearing the expiry makes applying it again work.
|
||||
await this.query(`ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)} VALID UNTIL 'infinity'`);
|
||||
}
|
||||
const dbs = await this.query("SELECT 1 FROM pg_database WHERE datname = " + literal(database));
|
||||
if (dbs.rows.length === 0) {
|
||||
@@ -99,6 +101,30 @@ export class PostgresClient {
|
||||
await this.query(`GRANT ALL PRIVILEGES ON DATABASE ${ident(database)} TO ${ident(role)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `role` can log in to `database` with exactly `password`: the consumer's own view of its
|
||||
* credential, checked by connecting as it. Read-only. `false` only when the server says so (the
|
||||
* role, the password or the database is wrong or gone); an unreachable server rejects instead,
|
||||
* because being unable to ask is not evidence of loss (novox/hq issue 120).
|
||||
*/
|
||||
async canConnectAs(database: string, role: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
await run(
|
||||
"psql",
|
||||
["-h", this.conn.host, "-p", String(this.conn.port), "-U", role, "-d", database,
|
||||
"-v", "ON_ERROR_STOP=1", "--no-psqlrc", "-tAc", "SELECT 1"],
|
||||
{ env: { ...process.env, PGPASSWORD: password, PGCONNECT_TIMEOUT: "10" }, timeout: 20_000 },
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const text = `${(err as { stderr?: string }).stderr ?? ""}`;
|
||||
if (/password authentication failed|role ".*" does not exist|database ".*" does not exist|not permitted to log in|permission denied for database/i.test(text)) {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a database and its owning role, idempotently, after evicting live connections. */
|
||||
async dropDatabaseAndRole(database: string, role: string): Promise<void> {
|
||||
await this.query(
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
"id": "store-data",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh-store",
|
||||
"mode": "0700"
|
||||
"mode": "0700",
|
||||
"owner": "999:70"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -45,4 +45,9 @@ runProvisioner("postgres-database", {
|
||||
await postgres.dropDatabaseAndRole(p.as, p.as);
|
||||
await announce("module.postgres.database.deprovisioned", { database: p.as });
|
||||
},
|
||||
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
|
||||
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
|
||||
async holds(p: Provision): Promise<boolean> {
|
||||
return postgres.canConnectAs(p.as, p.as, p.password);
|
||||
},
|
||||
});
|
||||
|
||||
+22
-1
@@ -8,7 +8,7 @@
|
||||
// order requests were sent, which is what the queue below relies on.
|
||||
|
||||
import { createConnection, type Socket } from "node:net";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
/** A parsed RESP value. Errors are surfaced as rejected commands, not as this type. */
|
||||
@@ -113,6 +113,27 @@ export class RedisClient {
|
||||
await this.command("ACL", "DELUSER", username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an ACL user exists, is enabled, and accepts exactly this password. Read-only: it asks
|
||||
* `ACL GETUSER`, which answers nil for an unknown user and otherwise a flat list of fields, among
|
||||
* them `flags` and `passwords`, the latter as SHA-256 hex. This server keeps no ACL file, so its
|
||||
* users live in memory and a restart forgets them. This is how the provisioner notices
|
||||
* (novox/hq issue 120).
|
||||
*/
|
||||
async holdsAclUser(username: string, password: string): Promise<boolean> {
|
||||
const reply = await this.command("ACL", "GETUSER", username);
|
||||
if (!Array.isArray(reply)) return false;
|
||||
const field = (name: string): RespValue | undefined => {
|
||||
const i = reply.indexOf(name);
|
||||
return i >= 0 ? reply[i + 1] : undefined;
|
||||
};
|
||||
const flags = field("flags");
|
||||
const passwords = field("passwords");
|
||||
if (!Array.isArray(flags) || !flags.includes("on")) return false;
|
||||
if (!Array.isArray(passwords)) return false;
|
||||
return passwords.includes(createHash("sha256").update(password).digest("hex"));
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.socket) {
|
||||
this.socket.destroy();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -43,4 +43,11 @@ runProvisioner("redis-cache", {
|
||||
await redis.deleteAclUser(p.as);
|
||||
await announce("module.redis.cache.deprovisioned", { username: p.as });
|
||||
},
|
||||
|
||||
// This server keeps its ACL users in memory only, so a restart of it forgets every consumer while
|
||||
// this provisioner keeps running. Asked every minute, so a forgotten user is made again instead
|
||||
// of every consumer failing to authenticate in silence (novox/hq issue 120).
|
||||
async holds(p: Provision): Promise<boolean> {
|
||||
return redis.holdsAclUser(p.as, p.password);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,12 +2,22 @@
|
||||
"module": "resolv-conf",
|
||||
"version": "1",
|
||||
"slug": "resolv",
|
||||
|
||||
"requires": ["wildcard-resolution"],
|
||||
"claims": [{"name": "the-resolver-configuration", "scope": "node"}],
|
||||
|
||||
"requires": [
|
||||
"wildcard-resolution"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-resolver-config",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{"id": "resolv", "type": "file", "path": "/etc/resolv.conf", "mode": "0644",
|
||||
"content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead — this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's resolver, and only it — the one line the predecessor wrote on every\n# machine it set up. It answers the mesh's names itself and forwards everything\n# else to upstreams named in its own configuration, never read from this file.\n# This file used to carry a second nameserver as a placeholder for \"whatever\n# this machine used before\"; that was never a fallback for names the mesh does\n# not know — a resolver's second line is asked only when the first does not\n# answer at all — and now that the first answers everything it would be a line\n# nothing ever reached.\nnameserver 127.0.0.1\noptions edns0\n"}
|
||||
{
|
||||
"id": "resolv",
|
||||
"type": "file",
|
||||
"path": "/etc/resolv.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead \u2014 this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's resolver, and only it \u2014 the one line the predecessor wrote on every\n# machine it set up. It answers the mesh's names itself and forwards everything\n# else to upstreams named in its own configuration, never read from this file.\n# This file used to carry a second nameserver as a placeholder for \"whatever\n# this machine used before\"; that was never a fallback for names the mesh does\n# not know \u2014 a resolver's second line is asked only when the first does not\n# answer at all \u2014 and now that the first answers everything it would be a line\n# nothing ever reached.\nnameserver 127.0.0.1\noptions edns0\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,18 +2,38 @@
|
||||
"module": "resolved-split-dns",
|
||||
"version": "1",
|
||||
"slug": "splitdns",
|
||||
|
||||
"requires": ["wildcard-resolution"],
|
||||
"claims": [{"name": "the-resolver-configuration", "scope": "node"}],
|
||||
|
||||
"requires": [
|
||||
"wildcard-resolution"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-resolver-config",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{"id": "drop-in", "type": "directory", "path": "/etc/systemd/resolved.conf.d", "mode": "0755"},
|
||||
|
||||
{"id": "route", "type": "file",
|
||||
"path": "/etc/systemd/resolved.conf.d/mesh.conf", "mode": "0644",
|
||||
"content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims. The mesh's resolver can forward the rest too;\n# this module is for a machine that wants systemd-resolved to stay in charge of\n# that, and only lends it the mesh's suffix.\n#\n# 127.0.0.1 is where the mesh's resolver answers on every machine — a fixed\n# address, so this file needs to know nothing about this particular machine.\n# systemd-resolved holds .53 and .54 itself, which is why the resolver is on\n# neither, and why the two coexist here.\n[Resolve]\nDNS=127.0.0.1\nDomains=~internal\n"},
|
||||
|
||||
{"id": "resolved", "type": "service", "unit": "systemd-resolved.service",
|
||||
"state": "running", "boot": "enabled", "restart-on": ["route"]}
|
||||
{
|
||||
"id": "drop-in",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/resolved.conf.d",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "route",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/resolved.conf.d/mesh.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims. The mesh's resolver can forward the rest too;\n# this module is for a machine that wants systemd-resolved to stay in charge of\n# that, and only lends it the mesh's suffix.\n#\n# 127.0.0.1 is where the mesh's resolver answers on every machine \u2014 a fixed\n# address, so this file needs to know nothing about this particular machine.\n# systemd-resolved holds .53 and .54 itself, which is why the resolver is on\n# neither, and why the two coexist here.\n[Resolve]\nDNS=127.0.0.1\nDomains=~internal\n"
|
||||
},
|
||||
{
|
||||
"id": "resolved",
|
||||
"type": "service",
|
||||
"unit": "systemd-resolved.service",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"restart-on": [
|
||||
"route"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -159,3 +159,15 @@ cd modules/route-adapter && npm test
|
||||
They hold it to what ADR 0104 says holds it: one file per contribution, a file removed when its
|
||||
contribution goes, every file it did not write left alone — and the two facts a route file has to
|
||||
get right, the port the contributor publishes and the address of the machine it is on.
|
||||
|
||||
## A body limit
|
||||
|
||||
A contribution may say `max-request-body`, in bytes, and the adapter writes it as the predecessor's
|
||||
own `buffering` middleware, named after the router so the two halves cannot drift. A route that says
|
||||
nothing gets no middleware and the predecessor's default stands.
|
||||
|
||||
This is the one thing the file shape *can* say that a policy cannot, which is why it is written
|
||||
rather than skipped: the predecessor already served its own registry name this way. A limit that is
|
||||
not a whole positive number of bytes takes the route with it — written without the limit, the
|
||||
predecessor would carry exactly what the module said not to carry, and this module would report
|
||||
success doing it.
|
||||
|
||||
@@ -75,6 +75,15 @@ export interface Route {
|
||||
from: string;
|
||||
/** Where the predecessor's proxy is to send it. */
|
||||
target: string;
|
||||
/**
|
||||
* The largest request body, in bytes, the predecessor may carry to it — the contribution's
|
||||
* `max-request-body`. Absent is whatever the predecessor does by default.
|
||||
*
|
||||
* Unlike a policy, this file shape *can* say it: the predecessor has a buffering middleware, and
|
||||
* its own registry route used exactly this. A registry takes image layers in single requests of
|
||||
* gigabytes, so a route that could not say it would be a name nothing could be pushed to.
|
||||
*/
|
||||
maxRequestBody?: number;
|
||||
}
|
||||
|
||||
/** What one pass changed. */
|
||||
@@ -176,12 +185,40 @@ export function routesFrom(document: unknown, machine: string): { routes: Route[
|
||||
}
|
||||
// Where the mesh says that machine is. Empty means this one, and this one is reached from
|
||||
// inside the predecessor's container by the machine's own name, not by loopback.
|
||||
// A limit it cannot honour is a route it does not write — skipped and named, like a port that
|
||||
// is not one. Written without the limit instead, the predecessor would carry exactly what the
|
||||
// module said not to carry, and this adapter would report success.
|
||||
const askedLimit = entry.values?.["max-request-body"];
|
||||
const limit = asBodyLimit(askedLimit);
|
||||
if (limit === null) {
|
||||
skipped.push(
|
||||
`${from} asked for route ${name} with a max-request-body of ${JSON.stringify(askedLimit)}, ` +
|
||||
`which is not a whole positive number of bytes`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const at = typeof entry.at === "string" && entry.at.trim() !== "" ? entry.at.trim() : machine;
|
||||
routes.push({ name, from, target: `http://${at}:${port}` });
|
||||
routes.push({ name, from, target: `http://${at}:${port}`, ...(limit === undefined ? {} : { maxRequestBody: limit }) });
|
||||
}
|
||||
return { routes, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* The body limit a contribution asked for: a number, `undefined` for silence, `null` for unusable.
|
||||
*
|
||||
* Three answers rather than two, because "said nothing" and "said something wrong" must not become
|
||||
* the same route.
|
||||
*/
|
||||
function asBodyLimit(value: unknown): number | undefined | null {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The file one route is written to. The prefix is how the mesh recognises its own. */
|
||||
export function fileNameFor(name: string): string {
|
||||
return `mesh-${name}.yml`;
|
||||
@@ -201,6 +238,10 @@ export function routerNameFor(name: string): string {
|
||||
*/
|
||||
export function routeFile(route: Route, settings: Settings): string {
|
||||
const id = routerNameFor(route.name);
|
||||
// The body limit is a middleware in the predecessor's vocabulary — its `buffering`, with the one
|
||||
// field the predecessor's own registry route set — named after the router so the two halves cannot
|
||||
// drift, and written only when the contribution asked for it.
|
||||
const limited = route.maxRequestBody !== undefined;
|
||||
return [
|
||||
marker,
|
||||
`# ${route.from} contributed this route. It is removed when that contribution goes.`,
|
||||
@@ -210,10 +251,19 @@ export function routeFile(route: Route, settings: Settings): string {
|
||||
` entryPoints: [${settings.entrypoint}]`,
|
||||
` rule: Host(\`${route.name}\`)`,
|
||||
` service: ${id}`,
|
||||
...(limited ? [` middlewares: [${id}-body]`] : []),
|
||||
" tls:",
|
||||
` certResolver: ${settings.resolver}`,
|
||||
" domains:",
|
||||
` - main: ${route.name}`,
|
||||
...(limited
|
||||
? [
|
||||
" middlewares:",
|
||||
` ${id}-body:`,
|
||||
" buffering:",
|
||||
` maxRequestBodyBytes: ${route.maxRequestBody}`,
|
||||
]
|
||||
: []),
|
||||
" services:",
|
||||
` ${id}:`,
|
||||
" loadBalancer:",
|
||||
|
||||
@@ -22,7 +22,9 @@ async function predecessor(already: Record<string, string> = {}): Promise<Settin
|
||||
}
|
||||
|
||||
/** The contributions file the mesh writes, in the shape the mesh's own proxy also reads. */
|
||||
function contributed(...given: { from: string; node?: string; at?: string; name: string; port: number }[]) {
|
||||
function contributed(
|
||||
...given: { from: string; node?: string; at?: string; name: string; port: number; limit?: unknown }[]
|
||||
) {
|
||||
return {
|
||||
contributions: 1,
|
||||
requirement: "route",
|
||||
@@ -30,7 +32,7 @@ function contributed(...given: { from: string; node?: string; at?: string; name:
|
||||
from: g.from,
|
||||
node: g.node ?? "control-node",
|
||||
at: g.at ?? "",
|
||||
values: { name: g.name, port: g.port },
|
||||
values: { name: g.name, port: g.port, ...(g.limit === undefined ? {} : { "max-request-body": g.limit }) },
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -232,3 +234,41 @@ test("it refuses when the predecessor's directory is not there, and says why", a
|
||||
const settings = { ...defaults, dynamic: join(await mkdtemp(join(tmpdir(), "route-adapter-")), "absent") };
|
||||
await assert.rejects(reconcile([], settings), /is not there.*`dynamic` setting.*mounts it/s);
|
||||
});
|
||||
|
||||
// **A registry is why a route needs to say this.** Image layers arrive as single requests of
|
||||
// gigabytes, and the predecessor served its own registry name with a `buffering` middleware for
|
||||
// exactly that reason. The contribution carries the limit as `max-request-body`, the adapter writes
|
||||
// the middleware the predecessor already understands, named after the router so the two halves
|
||||
// cannot drift — and writes nothing of the kind for a route that did not ask.
|
||||
test("a body limit is written as the predecessor's buffering middleware", async () => {
|
||||
const settings = await predecessor();
|
||||
const changed = await pass(settings, contributed(
|
||||
{ from: "registry", name: "images.example", port: 5001, limit: 21474836480 },
|
||||
{ from: "forge", name: "git.example", port: 2999 },
|
||||
));
|
||||
assert.deepEqual(changed.written, ["mesh-git.example.yml", "mesh-images.example.yml"]);
|
||||
|
||||
const written = await readFile(join(settings.dynamic, "mesh-images.example.yml"), "utf8");
|
||||
assert.match(written, /^ {6}middlewares: \[mesh-images-example-body\]$/m);
|
||||
assert.match(written, /^ {2}middlewares:\n {4}mesh-images-example-body:\n {6}buffering:\n {8}maxRequestBodyBytes: 21474836480$/m);
|
||||
|
||||
// The route that asked for nothing carries no middleware — the predecessor's default stands.
|
||||
const plain = await readFile(join(settings.dynamic, "mesh-git.example.yml"), "utf8");
|
||||
assert.doesNotMatch(plain, /middlewares|buffering/);
|
||||
});
|
||||
|
||||
// A limit it cannot honour is a route it does not write. Written without it, the predecessor would
|
||||
// carry exactly what the module said not to carry, and this module would report success.
|
||||
test("a body limit that is not a whole number of bytes is skipped and named", () => {
|
||||
for (const limit of ["20g", 0, -1, 1.5, true, null]) {
|
||||
const { routes, skipped } = routesFrom(
|
||||
contributed({ from: "registry", name: "images.example", port: 5001, limit }), defaults.machine);
|
||||
assert.deepEqual(routes, [], `a limit of ${JSON.stringify(limit)} was served`);
|
||||
assert.equal(skipped.length, 1);
|
||||
assert.match(skipped[0]!, /max-request-body/);
|
||||
}
|
||||
// And a limit the mesh's own proxy would accept is carried through, as a number.
|
||||
const { routes } = routesFrom(
|
||||
contributed({ from: "registry", name: "images.example", port: 5001, limit: 1024 }), defaults.machine);
|
||||
assert.equal(routes[0]?.maxRequestBody, 1024);
|
||||
});
|
||||
|
||||
+182
-59
@@ -2,72 +2,195 @@
|
||||
"module": "showcase",
|
||||
"version": "1",
|
||||
"slug": "show",
|
||||
|
||||
"capabilities": ["container-runtime"],
|
||||
|
||||
"provides": [{ "name": "greeting", "scope": "mesh" }],
|
||||
"serves": { "greeting": { "path": "/greeting" } },
|
||||
"requires": ["postgres-database"],
|
||||
"binds": { "postgres-database": "/var/lib/showcase/database.json" },
|
||||
"secrets": { "postgres-database": "/var/lib/showcase/database.secret" },
|
||||
"own-secrets": { "broker": "/var/lib/mesh/showcase/broker" },
|
||||
|
||||
"claims": [{ "name": "the-showcase", "scope": "node" }],
|
||||
|
||||
"emits": ["module.showcase.acknowledged"],
|
||||
"consumes": ["module.showcase.greeted"],
|
||||
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
"name": "greeting",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"serves": {
|
||||
"greeting": {
|
||||
"path": "/greeting"
|
||||
}
|
||||
},
|
||||
"requires": [
|
||||
"postgres-database"
|
||||
],
|
||||
"binds": {
|
||||
"postgres-database": "/var/lib/showcase/database.json"
|
||||
},
|
||||
"secrets": {
|
||||
"postgres-database": "/var/lib/showcase/database.secret"
|
||||
},
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/showcase/broker"
|
||||
},
|
||||
"claims": [
|
||||
{
|
||||
"name": "the-showcase",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"emits": [
|
||||
"module.showcase.acknowledged"
|
||||
],
|
||||
"consumes": [
|
||||
"module.showcase.greeted"
|
||||
],
|
||||
"listens": [
|
||||
{ "port": 8080, "protocol": "tcp", "from": "mesh",
|
||||
"why": "the port the daemon itself listens on. The mesh assigns the machine-side number and tells consumers that one (ADR 0038)" }
|
||||
{
|
||||
"port": 8080,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the port the daemon itself listens on. The mesh assigns the machine-side number and tells consumers that one (ADR 0038)"
|
||||
}
|
||||
],
|
||||
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{ "name": "code", "kind": "bundle", "language": "typescript",
|
||||
"entrypoints": ["index.js", "tools/index.js", "provisioner/index.js",
|
||||
"daemon/index.js", "step/index.js", "report/index.js"] },
|
||||
{ "name": "files", "kind": "archive", "from": "files" },
|
||||
{ "name": "helper", "kind": "upstream",
|
||||
"from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" }
|
||||
{
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"index.js",
|
||||
"tools/index.js",
|
||||
"provisioner/index.js",
|
||||
"daemon/index.js",
|
||||
"step/index.js",
|
||||
"report/index.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
},
|
||||
{
|
||||
"name": "helper",
|
||||
"kind": "upstream",
|
||||
"from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"resources": [
|
||||
{ "id": "account", "type": "user", "name": "showcase", "shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/showcase" },
|
||||
|
||||
{ "id": "logs", "type": "access", "path": "/var/log", "mode": "0755" },
|
||||
|
||||
{ "id": "mesh-state", "type": "directory", "path": "/var/lib/mesh/showcase", "mode": "0700" },
|
||||
{ "id": "state", "type": "directory", "path": "/var/lib/showcase", "mode": "0755" },
|
||||
|
||||
{ "id": "settings", "type": "file", "path": "/var/lib/showcase/showcase.env", "mode": "0600",
|
||||
"content": "SHOWCASE_GREETING=hello\nSHOWCASE_EVERY_SECONDS=30\nSHOWCASE_STATE=/var/lib/showcase\nSHOWCASE_DATABASE=${bound:postgres-database:at}\nSHOWCASE_LISTEN=${port:8080}\n" },
|
||||
|
||||
{ "id": "packed", "type": "archive", "path": "/opt/showcase", "artifact": "files" },
|
||||
|
||||
{ "id": "net", "type": "network", "name": "showcase" },
|
||||
|
||||
{ "id": "tooling", "type": "package", "package": "jq" },
|
||||
|
||||
{ "id": "migrate", "type": "process", "name": "showcase-migrate", "artifact": "code",
|
||||
"run": ["node", "step/index.js"], "run-once": true,
|
||||
"env-file": ["/var/lib/showcase/showcase.env"] },
|
||||
|
||||
{ "id": "server", "type": "process", "name": "showcase", "artifact": "code",
|
||||
"run": ["node", "daemon/index.js"], "user": "showcase",
|
||||
"env-file": ["/var/lib/showcase/showcase.env"],
|
||||
"restart-on": ["settings"] },
|
||||
|
||||
{ "id": "reporting", "type": "process", "name": "showcase-report", "artifact": "code",
|
||||
"run": ["node", "report/index.js"], "schedule": "0 3 * * *",
|
||||
"env-file": ["/var/lib/showcase/showcase.env"] },
|
||||
|
||||
{ "id": "tools", "type": "container", "name": "mesh-showcase", "artifact": "helper",
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "showcase",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/showcase"
|
||||
},
|
||||
{
|
||||
"id": "logs",
|
||||
"type": "access",
|
||||
"path": "/var/log",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/showcase",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/showcase",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
"type": "file",
|
||||
"path": "/var/lib/showcase/showcase.env",
|
||||
"mode": "0600",
|
||||
"content": "SHOWCASE_GREETING=hello\nSHOWCASE_EVERY_SECONDS=30\nSHOWCASE_STATE=/var/lib/showcase\nSHOWCASE_DATABASE=${bound:postgres-database:at}\nSHOWCASE_LISTEN=${port:8080}\n"
|
||||
},
|
||||
{
|
||||
"id": "packed",
|
||||
"type": "archive",
|
||||
"path": "/opt/showcase",
|
||||
"artifact": "files"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
"type": "network",
|
||||
"name": "showcase"
|
||||
},
|
||||
{
|
||||
"id": "tooling",
|
||||
"type": "package",
|
||||
"package": "jq"
|
||||
},
|
||||
{
|
||||
"id": "migrate",
|
||||
"type": "process",
|
||||
"name": "showcase-migrate",
|
||||
"artifact": "code",
|
||||
"run": [
|
||||
"node",
|
||||
"step/index.js"
|
||||
],
|
||||
"run-once": true,
|
||||
"env-file": [
|
||||
"/var/lib/showcase/showcase.env"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "process",
|
||||
"name": "showcase",
|
||||
"artifact": "code",
|
||||
"run": [
|
||||
"node",
|
||||
"daemon/index.js"
|
||||
],
|
||||
"user": "showcase",
|
||||
"env-file": [
|
||||
"/var/lib/showcase/showcase.env"
|
||||
],
|
||||
"restart-on": [
|
||||
"settings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "reporting",
|
||||
"type": "process",
|
||||
"name": "showcase-report",
|
||||
"artifact": "code",
|
||||
"run": [
|
||||
"node",
|
||||
"report/index.js"
|
||||
],
|
||||
"schedule": "0 3 * * *",
|
||||
"env-file": [
|
||||
"/var/lib/showcase/showcase.env"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tools",
|
||||
"type": "container",
|
||||
"name": "mesh-showcase",
|
||||
"artifact": "helper",
|
||||
"network": "showcase",
|
||||
"volumes": ["/var/lib/mesh/showcase/broker:/run/secrets/broker:ro"],
|
||||
"env": { "MESH_BROKER_FILE": "/run/secrets/broker" },
|
||||
"args": ["sleep", "infinity"] }
|
||||
"volumes": [
|
||||
"/var/lib/mesh/showcase/broker:/run/secrets/broker:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker"
|
||||
},
|
||||
"args": [
|
||||
"sleep",
|
||||
"infinity"
|
||||
]
|
||||
}
|
||||
],
|
||||
"seats": [
|
||||
{
|
||||
"name": "the-showcase",
|
||||
"scope": "node"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"module": "ssh-client",
|
||||
"version": "1",
|
||||
"resources": [
|
||||
{
|
||||
"id": "openssh",
|
||||
"type": "package",
|
||||
"package": "openssh"
|
||||
},
|
||||
{
|
||||
"id": "ssh-dir",
|
||||
"type": "directory",
|
||||
"path": "${machine:account-home}/.ssh",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0700"
|
||||
}
|
||||
],
|
||||
"facts": {
|
||||
"ssh-config": {
|
||||
"path": ".ssh/config",
|
||||
"home": true,
|
||||
"shared": true,
|
||||
"template": "# The mesh's Host blocks — every other node, so `ssh <node>` reaches it as the\n# right account. This region is replaced whenever a node joins, leaves or is\n# renamed; the rest of this file is yours and is kept untouched.\n{{range .Machines}}{{if ne .Name $.Node}}\nHost {{.Name}} {{.FQDN}}\n HostName {{.FQDN}}\n{{if .Account}} User {{.Account}}\n{{end}}{{end}}{{end}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"module": "sshd",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
],
|
||||
"listens": [
|
||||
{
|
||||
"port": 22,
|
||||
"protocol": "tcp",
|
||||
"from": "anywhere",
|
||||
"why": "the operator's own door. From anywhere because the machines that need it are exactly the ones not on the mesh yet \u2014 and locking the operator out is the one failure a firewall must never arrange"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "openssh"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "file",
|
||||
"path": "/etc/ssh/sshd_config.d/10-mesh.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module sshd). Replaced on every push; edit the catalogue instead.\nPort 22\nPermitRootLogin no\nPasswordAuthentication no\nPubkeyAuthentication yes\nKbdInteractiveAuthentication no\nUsePAM yes\nX11Forwarding no\nPrintMotd no\nAcceptEnv LANG LC_*\n"
|
||||
},
|
||||
{
|
||||
"id": "run",
|
||||
"type": "service",
|
||||
"unit": "sshd.service",
|
||||
"state": "running",
|
||||
"restart-on": [
|
||||
"config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"module": "systemd-networkd",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-uplink",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "systemd"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/network/00-mesh0.network",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh (module systemd-networkd). Replaced on every push; edit\n# the catalogue instead.\n#\n# This machine's uplink is systemd-networkd's, and the mesh asks one thing of it\n# here (novox/hq ADR 0117): leave the private network's interface alone. mesh0\n# is the mesh's; the mesh brings it up and configures it itself. The mesh never\n# declares a link, an address, a route, a wireless network or its credentials,\n# nor a network file for any of this machine's own interfaces \u2014 those are\n# the operator's, and the link they make is the only channel the mesh reaches\n# this machine over.\n#\n# 00-: networkd applies the first .network file, in alphanumeric order across\n# every directory, that matches an interface, and ignores every later one even\n# if it matches too (systemd.network(5), [Match]). A catch-all of the operator's\n# \u2014 Name=*, Type=ether, a file with no [Match] at all \u2014 sorted before\n# this one would claim mesh0 first. 00 sorts before every numbered prefix the\n# man page recommends.\n#\n# Unmanaged=yes: \"no attempts are made to bring up or configure matching links,\n# equivalent to when there are no matching network files\" (systemd.network(5),\n# [Link], since 233). A match that ends the search, and does nothing else.\n#\n# No DNS setting, because none is needed: networkd never writes\n# /etc/resolv.conf. What it learns from a lease it hands only to\n# systemd-resolved, and the resolver file stays whatever resolv-conf wrote.\n# Whether resolved runs, and what it does with that, is the resolver\n# configuration's question, not the uplink's.\n#\n# The service is reloaded when this file changes, never restarted: a restart\n# drops the links networkd holds, this machine's channel to the mesh among them.\n[Match]\nName=mesh0\n\n[Link]\nUnmanaged=yes\n"
|
||||
},
|
||||
{
|
||||
"id": "service",
|
||||
"type": "service",
|
||||
"unit": "systemd-networkd.service",
|
||||
"reload-on": [
|
||||
"config"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
# verdaccio's runtime: the tool runtime, carrying this module's compiled code.
|
||||
#
|
||||
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
|
||||
# the base images, published like any other artifact — which is what makes this buildable by the
|
||||
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
|
||||
# happens to have the siblings.
|
||||
#
|
||||
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
|
||||
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
|
||||
ARG BUILD_BASE
|
||||
ARG RUNTIME_BASE
|
||||
|
||||
FROM ${BUILD_BASE} AS build
|
||||
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
|
||||
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
|
||||
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
|
||||
# resolved away.
|
||||
WORKDIR /app/modules/verdaccio
|
||||
COPY . .
|
||||
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
|
||||
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
|
||||
|
||||
FROM ${RUNTIME_BASE}
|
||||
COPY --from=build /app/modules/verdaccio/dist /app/modules/verdaccio/dist
|
||||
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
|
||||
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
|
||||
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
|
||||
# provisioner (`run`) served no tools and emitted no events; a container that named no command
|
||||
# ran no provisioner at all.
|
||||
ENV MESH_TOOL_MODULES=/app/modules/verdaccio/dist/index.js,/app/modules/verdaccio/dist/tools/index.js
|
||||
@@ -1,91 +0,0 @@
|
||||
// The Verdaccio (npm registry) client — verdaccio's own code, living in the module (novox/hq
|
||||
// ADR 0039). Both this module's tools and its events entrypoint import it, and nothing outside
|
||||
// verdaccio does.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export interface VerdaccioPackage {
|
||||
name: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
time?: string;
|
||||
}
|
||||
|
||||
export interface PackageInfo {
|
||||
name: string;
|
||||
latest?: string;
|
||||
versions: string[];
|
||||
description?: string;
|
||||
modified?: string;
|
||||
}
|
||||
|
||||
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
|
||||
function meshConfig(file?: string): Record<string, string> {
|
||||
if (!file) return {};
|
||||
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
export class VerdaccioClient {
|
||||
readonly baseUrl: string;
|
||||
|
||||
// A bearer token is optional: package listing and reading are public on most registries, so the
|
||||
// token is sent only when configured, for a registry that gates reads behind auth.
|
||||
constructor(
|
||||
url: string,
|
||||
private readonly token?: string,
|
||||
) {
|
||||
this.baseUrl = url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build from the module's resolved environment. The URL is MESH_VERDACCIO_URL (or the local
|
||||
* port); an optional MESH_VERDACCIO_TOKEN authenticates. Throws when no URL is configured.
|
||||
*/
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): VerdaccioClient {
|
||||
const cfg = meshConfig(env.MESH_VERDACCIO_CONFIG_FILE);
|
||||
const url = cfg.url ?? (env.MESH_VERDACCIO_URL ?? `http://127.0.0.1:${env.VERDACCIO_PORT ?? "4873"}`);
|
||||
if (!url) throw new Error("no verdaccio URL — set MESH_VERDACCIO_URL");
|
||||
return new VerdaccioClient(url, cfg.token ?? env.MESH_VERDACCIO_TOKEN);
|
||||
}
|
||||
|
||||
private async getJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`Verdaccio ${path}: ${res.status} ${await res.text()}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every package the registry hosts, from Verdaccio's own web API — the same list its UI shows.
|
||||
* Each entry carries the latest version and the time it was last published.
|
||||
*/
|
||||
async listPackages(): Promise<VerdaccioPackage[]> {
|
||||
const raw = await this.getJson<any[]>("/-/verdaccio/data/packages");
|
||||
return (raw ?? []).map((p) => ({
|
||||
name: p.name,
|
||||
version: p.version ?? p["dist-tags"]?.latest,
|
||||
description: p.description,
|
||||
time: p.time?.modified ?? p.time,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The full detail of one package — its dist-tags, every published version, and timestamps —
|
||||
* from the standard npm packument endpoint (`GET /<name>`).
|
||||
*/
|
||||
async getPackageInfo(name: string): Promise<PackageInfo> {
|
||||
const doc = await this.getJson<any>(`/${encodeURIComponent(name).replace(/%2F/g, "/")}`);
|
||||
return {
|
||||
name: doc.name ?? name,
|
||||
latest: doc["dist-tags"]?.latest,
|
||||
versions: Object.keys(doc.versions ?? {}),
|
||||
description: doc.description,
|
||||
modified: doc.time?.modified,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// verdaccio's events. The tool runtime imports this once the broker is bound.
|
||||
//
|
||||
// Emits (novox/hq ADR 0041/0042):
|
||||
// module.verdaccio.package.published — a new package version was published to the registry
|
||||
//
|
||||
// A genuinely useful signal: a package was just published, so anything on the mesh that pins,
|
||||
// mirrors or announces dependency releases can react without polling the registry. Verdaccio has
|
||||
// no publish webhook, so the module discovers it by diffing the package list's latest versions.
|
||||
//
|
||||
// The polling is deliberately unhurried: a publish a minute late is still the event, whereas
|
||||
// hammering the registry for immediacy nobody asked for is not.
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { VerdaccioClient } from "./client.js";
|
||||
|
||||
const verdaccio = VerdaccioClient.fromEnv();
|
||||
|
||||
// The latest version we have seen per package name. Primed silently on the first look so a registry
|
||||
// that was already populated when this started does not announce its whole catalog as freshly
|
||||
// published.
|
||||
const latest = new Map<string, string>();
|
||||
let primed = false;
|
||||
|
||||
async function pollPackages(): Promise<void> {
|
||||
const packages = await verdaccio.listPackages();
|
||||
for (const pkg of packages) {
|
||||
if (!pkg.version) continue;
|
||||
const known = latest.get(pkg.name);
|
||||
if (known !== pkg.version) {
|
||||
// A name we have not seen, or a name whose latest version moved — both are a publish.
|
||||
if (primed) await emit("module.verdaccio.package.published", { name: pkg.name, version: pkg.version });
|
||||
latest.set(pkg.name, pkg.version);
|
||||
}
|
||||
}
|
||||
primed = true;
|
||||
}
|
||||
|
||||
const tick = (fn: () => Promise<void>, everyMs: number): void => {
|
||||
const run = (): void => void fn().catch((err) => console.error(`[verdaccio] ${err}`));
|
||||
setInterval(run, everyMs);
|
||||
run();
|
||||
};
|
||||
tick(pollPackages, 60_000);
|
||||
|
||||
console.log("[verdaccio] watching the registry for newly published packages");
|
||||
@@ -1,130 +0,0 @@
|
||||
{
|
||||
"module": "verdaccio",
|
||||
"version": "1",
|
||||
"slug": "verdacc",
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.verdaccio.package.published"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/verdaccio/broker"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"port": 4873,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the package registry, for installs and publishes"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "mesh-state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh/verdaccio",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "conf",
|
||||
"type": "directory",
|
||||
"path": "/services/verdaccio/conf",
|
||||
"mode": "0755",
|
||||
"owner": "10001:10001"
|
||||
},
|
||||
{
|
||||
"id": "storage",
|
||||
"type": "directory",
|
||||
"path": "/services/verdaccio/storage",
|
||||
"mode": "0700",
|
||||
"owner": "10001:10001"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "file",
|
||||
"path": "/services/verdaccio/conf/config.yaml",
|
||||
"mode": "0644",
|
||||
"content": "storage: /verdaccio/storage\nauth:\n htpasswd:\n file: /verdaccio/conf/htpasswd\n max_users: 10\nuplinks:\n npmjs:\n url: https://registry.npmjs.org/\npackages:\n \"**\":\n access: $all\n publish: $authenticated\n proxy: npmjs\nserver:\n keepAliveTimeout: 60\n maxBodySize: 10mb\nmiddlewares:\n audit:\n enabled: true\nlog:\n type: stdout\n format: pretty\n level: http\n"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "verdaccio",
|
||||
"image": "verdaccio/verdaccio@sha256:7b067a47ae51fb9dff3dcdce60ec0a2cbd7650c208cb4b9f6d37cb1b09b39d43",
|
||||
"ports": [
|
||||
"4873"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/verdaccio/storage:/verdaccio/storage",
|
||||
"/services/verdaccio/conf:/verdaccio/conf"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "/var/lib/mesh/verdaccio/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "container",
|
||||
"name": "mesh-verdaccio",
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/verdaccio/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/verdaccio/config.json:/run/config/config.json:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_VERDACCIO_URL": "http://127.0.0.1:4873",
|
||||
"MESH_VERDACCIO_CONFIG_FILE": "/run/config/config.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"runtime-config"
|
||||
],
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
"route"
|
||||
],
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "npm",
|
||||
"port": 4873
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "/var/lib/mesh/verdaccio/route.json"
|
||||
},
|
||||
"provides": [
|
||||
{
|
||||
"name": "package-registry",
|
||||
"scope": "mesh"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "BUILD_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "build"
|
||||
},
|
||||
{
|
||||
"arg": "RUNTIME_BASE",
|
||||
"module": "mesh-tools",
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "runtime",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "@novox/module-verdaccio",
|
||||
"version": "0.1.0",
|
||||
"description": "verdaccio — private npm registry. Its API client, tools and events live here (novox/hq ADR 0039).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// verdaccio's tools — its own code (novox/hq ADR 0039), importing verdaccio's own client. They
|
||||
// return structured data; the mesh serves them through the sdk's tool harness.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { VerdaccioClient } from "../client.js";
|
||||
|
||||
export function getVerdaccioTools(verdaccio: VerdaccioClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "verdaccio_list_packages",
|
||||
description: "List every package hosted on the private npm registry, with each one's latest version.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const packages = await verdaccio.listPackages();
|
||||
return { count: packages.length, packages };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "verdaccio_package_info",
|
||||
description: "Details of one package on the registry: its latest tag, all published versions, and description.",
|
||||
input: { name: { type: "string", description: "the package name, e.g. '@novox/mesh-sdk'" } },
|
||||
run: async (args) => verdaccio.getPackageInfo(String(args.name)),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The tools exist only when a registry URL is configured; otherwise verdaccio contributes none
|
||||
// rather than failing the whole runtime.
|
||||
registerModuleTools("verdaccio", (env) => {
|
||||
try {
|
||||
return getVerdaccioTools(VerdaccioClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user