diff --git a/modules/builder/Dockerfile b/modules/builder/Dockerfile new file mode 100644 index 0000000..5b2f18f --- /dev/null +++ b/modules/builder/Dockerfile @@ -0,0 +1,25 @@ +ARG GO_BASE +ARG ALPINE_BASE +# builder's own image: the build machine itself, compiled into a container. +# +# **The source is not vendored here.** builder's actual code — cmd/mesh-builder, internal/builder, +# internal/catalogue — lives in the mesh-controller repository, the same control plane it is one +# half of. This module ships the packaging, not a second copy of the source, so the build context +# is the mesh-controller repository root (declared under build.artifacts[].context), and this +# Dockerfile compiles ./cmd/mesh-builder from it — the same shape route-proxy already uses for the +# same reason. +FROM ${GO_BASE} AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /mesh-builder ./cmd/mesh-builder + +# Unlike mesh-controller's own FROM scratch (ADR 0006: nothing to audit but one binary), the build +# machine's whole job is shelling out to git and docker — it needs a real userland to do that in, +# not a second copy of either tool vendored into this image. apk installs both from the base's own +# packages, not fetched on its own at build time. +FROM ${ALPINE_BASE} +RUN apk add --no-cache docker-cli git +COPY --from=build /mesh-builder /usr/local/bin/mesh-builder +ENTRYPOINT ["/usr/local/bin/mesh-builder"] diff --git a/modules/builder/module.json b/modules/builder/module.json index 6d02dfd..9d38701 100644 --- a/modules/builder/module.json +++ b/modules/builder/module.json @@ -11,14 +11,20 @@ } ], "requires": [ - "artifact-store" + "artifact-store", + "npm-package-registry" ], + "binds": { + "npm-package-registry": "/var/lib/mesh/builder/package-registry.json" + }, + "secrets": { + "npm-package-registry": "/var/lib/mesh/builder/package-registry.secret" + }, "emits": [ "module.builder.built" ], "own-secrets": { - "broker": "/var/lib/mesh/builder/broker", - "npm-password": "/var/lib/mesh/builder/npm-password" + "broker": "/var/lib/mesh/builder/broker" }, "resources": [ { @@ -38,27 +44,13 @@ "type": "file", "path": "/var/lib/mesh/builder/builder.env", "mode": "0600", - "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/npm-password\nMESH_WORKSPACE=/var/lib/builder/workspace\n" - }, - { - "id": "package-binding", - "type": "file", - "path": "/var/lib/mesh/builder/package-registry.json", - "mode": "0600", - "merge": "json", - "protected": [ - "provision", - "from", - "at", - "as" - ], - "content": "{\"provision\": \"package-registry\", \"from\": \"gitea\", \"at\": \"127.0.0.1\", \"as\": \"mesh-builder\", \"serves\": {\"scheme\": \"http\", \"port\": 3000, \"npm-path\": \"/api/packages/novox/npm/\"}}\n" + "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=${bound:artifact-store:at}:${bound:artifact-store:port}\nMESH_PACKAGE_BINDING=/run/mesh/package-registry.json\nMESH_NPM_TOKEN_FILE=/run/mesh/package-registry.secret\nMESH_WORKSPACE=/var/lib/builder/workspace\n" }, { "id": "server", "type": "container", "name": "mesh-builder", - "image": "mesh-builder@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "artifact": "server", "env-file": [ "/var/lib/mesh/builder/builder.env" ], @@ -68,11 +60,32 @@ "/var/run/docker.sock:/var/run/docker.sock" ], "restart-on": [ - "builder-env", - "package-binding", - "needs-npm-password" + "builder-env" ], "network": "host" } - ] + ], + "build": { + "artifacts": [ + { + "name": "server", + "kind": "image", + "from": "Dockerfile", + "context": { + "repository": "https://git.novox.be/novox/mesh-controller.git", + "ref": "main" + } + } + ], + "on": [ + { + "arg": "GO_BASE", + "image": "golang@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59" + }, + { + "arg": "ALPINE_BASE", + "image": "alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc" + } + ] + } } diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 983186e..06cbb7d 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -352,24 +352,34 @@ export class GiteaAdmin { GiteaAdmin.fail("/orgs", res); } - /** Ensure the org's package team exists, granting read+write on packages, and return its id. The - * team is found by name if it is already there, created otherwise; a lost create race is resolved - * by re-listing. */ + /** Ensure the org's package team exists with exactly these units, and return its id. Found or + * created, the units are applied either way — a team is configuration the reconcile loop owns, + * the same as a user's password, so a unit this code gains reaches a team that already exists + * rather than only the next mesh raised from scratch. A lost create race is resolved by + * re-listing. */ async ensureTeam(org: string, team: string, packageWrite: boolean): Promise { + // The units a consumer needs, and no more. `units_map` is exhaustive — a unit not named is a + // unit the team does not have — so code read must be said here: without it gitea answers a + // member's clone of a private repository with "not found", which is how the builder's first + // credentialed clone failed against a team that named only packages. + const units = { + permission: "read", + units_map: { "repo.code": "read", "repo.packages": packageWrite ? "write" : "read" }, + includes_all_repositories: true, + can_create_org_repo: false, + }; const found = await this.findTeam(org, team); - if (found !== null) return found; + if (found !== null) { + const patch = await this.request(`/teams/${found}`, { + method: "PATCH", + body: JSON.stringify({ name: team, ...units }), + }); + if (patch.status === 200) return found; + GiteaAdmin.fail(`/teams/${found}`, patch); + } const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`, { method: "POST", - body: JSON.stringify({ - name: team, - permission: "read", - // Package access is a per-unit grant; the team needs write on the packages unit and nothing - // else. includes_all_repositories keeps the team's repo view whole without widening its - // repo permission beyond read. - units_map: { "repo.packages": packageWrite ? "write" : "read" }, - includes_all_repositories: true, - can_create_org_repo: false, - }), + body: JSON.stringify({ name: team, ...units }), }); if (res.status === 201) return Number(res.body?.id); if (res.status === 422 || res.status === 409) { @@ -380,14 +390,18 @@ export class GiteaAdmin { } private async findTeam(org: string, team: string): Promise { - 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; } /** Ensure a user exists with exactly this password. Created if absent; if already there, its - * password is patched — so the mesh minting a new secret takes on the next reconcile. */ + * password is patched — so the mesh minting a new secret takes on the next reconcile. + * + * The edit path is taken only when the user actually exists. A 422 from the create is also what + * a plain validation failure returns, and reading it as "already there" made the follow-up edit + * 404 — burying the create's own message, which is the one that says what is actually wrong. */ async ensureUser(username: string, password: string, email: string): Promise { const res = await this.request("/admin/users", { method: "POST", @@ -395,13 +409,18 @@ export class GiteaAdmin { }); if (res.status === 201) return; if (res.status === 422 || res.status === 409) { - 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 }), - }); - if (patch.status === 200) return; - GiteaAdmin.fail(`/admin/users/${username}`, patch); + const seen = await this.request(`/users/${encodeURIComponent(username)}`); + if (seen.status === 200) { + 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. + // 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); + } } GiteaAdmin.fail("/admin/users", res); } @@ -416,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 { + 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 { diff --git a/modules/gitea/module.json b/modules/gitea/module.json index a5bb2ce..6961880 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -11,8 +11,16 @@ "name": "gitea" }, "route": { - "label": "git", - "port": 3000 + "web": { + "label": "git", + "port": 3000 + }, + "internal-api-refused": { + "label": "git", + "path": "/api/internal", + "deny": true, + "priority": 100000 + } } }, "binds": { @@ -42,25 +50,39 @@ "why": "the forge, over http" }, { - "port": 2222, + "port": 22, "protocol": "tcp", "from": "mesh", - "why": "git over ssh. Not 22: the machine's own daemon holds that, and a module does not take it" + "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" } ], "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": "/var/lib/gitea/grants/npm.json" }, "grants": { - "package-registry": "/var/lib/gitea/grants" + "npm-package-registry": "/var/lib/gitea/grants" }, + "claims": [ + { + "name": "npm-package-registry", + "scope": "mesh" + }, + { + "name": "git", + "scope": "mesh" + } + ], "own-secrets": { "broker": "/var/lib/mesh/gitea/broker" }, @@ -118,7 +140,7 @@ ], "ports": [ "3000", - "22" + "222:22" ], "volumes": [ "/services/gitea/gitea:/data" @@ -177,7 +199,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": "/var/lib/gitea/grants/npm.json" }, "artifact": "runtime", "restart-on": [ @@ -187,7 +209,11 @@ ], "provides": [ { - "name": "package-registry", + "name": "npm-package-registry", + "scope": "mesh" + }, + { + "name": "git", "scope": "mesh" } ], diff --git a/modules/gitea/package.json b/modules/gitea/package.json index 04e8df1..ec33440 100644 --- a/modules/gitea/package.json +++ b/modules/gitea/package.json @@ -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", diff --git a/modules/gitea/provisioner/index.ts b/modules/gitea/provisioner/index.ts index 36d66e2..1f234d1 100644 --- a/modules/gitea/provisioner/index.ts +++ b/modules/gitea/provisioner/index.ts @@ -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 { // The org and its package team are the same for every consumer; ensuring them per-create is // idempotent and needs no separate bootstrap step. @@ -34,11 +44,21 @@ runProvisioner("package-registry", { const teamId = await gitea.ensureTeam(ORG, PACKAGE_TEAM, true); // The user carries the consumer's login and the mesh's minted password, set every run so a // rotation takes. Membership of the package team is what grants read+write on packages. - await gitea.ensureUser(p.as, p.password, `${p.as}@localhost`); + // + // The address is gitea's own convention for one that is not real: its email validation + // requires a dotted domain, so `@localhost` was refused at create — the fault that had this + // grant retrying for a day — while `@noreply.localhost` is the shape gitea itself gives + // hidden addresses. + await gitea.ensureUser(p.as, p.password, `${p.as}@noreply.localhost`); await gitea.addUserToTeam(teamId, p.as); }, async remove(p: { as: string }): Promise { 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 { + return gitea.holdsTeamMember(ORG, PACKAGE_TEAM, p.as, p.password); + }, }); diff --git a/modules/invoicing/module.json b/modules/invoicing/module.json index 9acd9cf..d7386ad 100644 --- a/modules/invoicing/module.json +++ b/modules/invoicing/module.json @@ -18,8 +18,14 @@ "bucket": "invoicing" }, "route": { - "label": "invoicing", - "port": 80 + "site": { + "label": "invoicing", + "port": 80 + }, + "api": { + "label": "invoicing-api", + "port": 9000 + } } }, "binds": { @@ -63,7 +69,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", diff --git a/modules/lavinmq/client.ts b/modules/lavinmq/client.ts index 04716b2..04a30c4 100644 --- a/modules/lavinmq/client.ts +++ b/modules/lavinmq/client.ts @@ -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 { + 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(path: string): Promise { + 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 { const v = encodeURIComponent(login); diff --git a/modules/lavinmq/package.json b/modules/lavinmq/package.json index 1a4b297..9e21bb1 100644 --- a/modules/lavinmq/package.json +++ b/modules/lavinmq/package.json @@ -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", diff --git a/modules/lavinmq/provisioner/index.ts b/modules/lavinmq/provisioner/index.ts index f5514bf..7cea27f 100644 --- a/modules/lavinmq/provisioner/index.ts +++ b/modules/lavinmq/provisioner/index.ts @@ -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 { + return lavinmq.holdsConsumer(p.as, p.password); + }, }); diff --git a/modules/mailu/Dockerfile b/modules/mailu/Dockerfile index b4c8a17..2462f57 100644 --- a/modules/mailu/Dockerfile +++ b/modules/mailu/Dockerfile @@ -17,7 +17,7 @@ FROM ${BUILD_BASE} AS build # resolved away. WORKDIR /app/modules/mailu COPY . . -RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \ +RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \ --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist FROM ${RUNTIME_BASE} @@ -27,4 +27,4 @@ COPY --from=build /app/modules/mailu/dist /app/modules/mailu/dist # 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/mailu/dist/index.js,/app/modules/mailu/dist/tools/index.js +ENV MESH_TOOL_MODULES=/app/modules/mailu/dist/index.js,/app/modules/mailu/dist/tools/index.js,/app/modules/mailu/dist/provisioner/index.js diff --git a/modules/mailu/automx/Dockerfile b/modules/mailu/automx/Dockerfile new file mode 100644 index 0000000..c48cab2 --- /dev/null +++ b/modules/mailu/automx/Dockerfile @@ -0,0 +1,44 @@ +# automx2 — the autoconfig/autodiscover responder, carried by the mailu module as its own +# artifact: it is a config-baked sidecar of this mail server, not a standalone application +# (novox/hq ADR 0015 draws that line at applications). +# +# The base is named rather than pinned (novox/hq issue 044): declared in module.json's +# `build.on`. The build context is the module's own directory; every ADD says so. +ARG PYTHON_BASE + +FROM ${PYTHON_BASE} +RUN apk add --no-cache bash sqlite +WORKDIR /automx2 + +ADD automx/files/setupvenv.sh /automx2/setupvenv.sh +ADD automx/files/start /automx2/start +ADD automx/files/setup /automx2/setup +ADD automx/files/setup-db /automx2/setup-db +ADD automx/files/add-domains /automx2/add-domains +RUN chmod u+x setupvenv.sh start add-domains setup setup-db + +RUN ./setupvenv.sh \ + && . .venv/bin/activate \ + && pip install automx2==2021.6 + +# The launcher `start` expects. In the predecessor's image this wrapper appeared during a build +# step that never made it into the files this module carries — the image worked and the recipe +# could not reproduce it. Written here explicitly, verbatim from the proven image, so the build +# is the whole truth about the image again. +RUN mkdir -p .venv/scripts && printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -euo pipefail' \ + '. .venv/bin/activate' \ + "export FLASK_ENV='production'" \ + "export FLASK_APP='automx2.server:app'" \ + 'flask "$@"' > .venv/scripts/flask.sh && chmod +x .venv/scripts/flask.sh + +ENV AUTOMX2_CONF=/etc/automx2.conf +ADD automx/files/automx2.conf /etc/automx2.conf + +# VOLUME deliberately absent: the anonymous /data volume is exactly what lost db.sqlite on +# every recreate (measured on novox 2026-08-10). The manifest binds a real directory instead. +ENTRYPOINT ["/bin/sh"] +CMD ["./start"] + +EXPOSE 4243 diff --git a/modules/mailu/automx/files/add-domains b/modules/mailu/automx/files/add-domains new file mode 100644 index 0000000..e413623 --- /dev/null +++ b/modules/mailu/automx/files/add-domains @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -e + +echo "${MAIL_DOMAINS}" + +# Split domains into array +IFS=', ' read -r -a array <<< "${AMX_MAIL_DOMAINS}" + +# User configurable section -- START +PROVIDER_ID=001 +SQL_CMD=""; + +# Iterate domains resulting from split on second arg +for element in "${array[@]}" +do + # Set vars + DOMAIN=$element + PROVIDER_NAME=$DOMAIN + PROVIDER_SHORTNAME=$DOMAIN + + # Optional LDAP server + #LDAP_SERVER="ldap.${DOMAIN}" + # User configurable section -- END + s1_id=$((PROVIDER_ID + 1)) + s2_id=$((PROVIDER_ID + 2)) + s3_id=$((PROVIDER_ID + 3)) + dom_id=$((PROVIDER_ID + 4)) + + s3_id='NULL' + + SQL_CMD=$(cat <&2 "Directory '${dir}' already exists, exiting." + exit 1 +fi +python3 -m venv "${dir}" +source "${dir}/bin/activate" + +set +e +pip install -U pip setuptools wheel || true + +#set -e +## vim:tabstop=4:noexpandtab +## +## Creates a Python 3 virtual environment. The target directory can be passed +## as a parameter. The default path is 'venv' in the current directory. +# +#dir="${1:-venv}" +# +#set -e +#if [ -d "${dir}" ]; then +# echo "Directory '${dir}' already exists, exiting." >&2 +# exit 1 +#fi +#python3 -m venv "${dir}" +#. "${dir}/bin/activate" +# +#set +e +#pip install -U pip setuptools || true diff --git a/modules/mailu/automx/files/start b/modules/mailu/automx/files/start new file mode 100644 index 0000000..d23943b --- /dev/null +++ b/modules/mailu/automx/files/start @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -e + +# Setup +./setup + +# Start +./.venv/scripts/flask.sh run --host=0.0.0.0 --port=4243 diff --git a/modules/mailu/client.ts b/modules/mailu/client.ts index 340c70f..4ec4cdb 100644 --- a/modules/mailu/client.ts +++ b/modules/mailu/client.ts @@ -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 { + await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password, enabled: true }); + } + async deleteUser(email: string): Promise { 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 { + 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 { const aliases = await this.api("GET", "/alias"); return (aliases ?? []).map((a) => ({ diff --git a/modules/mailu/module.json b/modules/mailu/module.json index b04ce66..0ada60c 100644 --- a/modules/mailu/module.json +++ b/modules/mailu/module.json @@ -14,8 +14,30 @@ "name": "mailu" }, "route": { - "label": "mail", - "port": 7080 + "web": { + "label": "mail", + "port": 7443, + "scheme": "https", + "insecure": true + }, + "acme": { + "label": "mail", + "path": "/.well-known/acme-challenge", + "port": 7080, + "priority": 100 + }, + "autoconfig": { + "label": "autoconfig", + "port": 4243 + }, + "autodiscover": { + "label": "autodiscover", + "port": 4243 + }, + "automx": { + "label": "automx", + "port": 4243 + } } }, "binds": { @@ -44,6 +66,20 @@ "why": "mail from other mail servers", "fixed": true }, + { + "port": 110, + "protocol": "tcp", + "from": "anywhere", + "why": "POP3, kept at parity with the predecessor; pruning legacy protocols is its own deliberate change", + "fixed": true + }, + { + "port": 143, + "protocol": "tcp", + "from": "anywhere", + "why": "IMAP with STARTTLS, kept at parity", + "fixed": true + }, { "port": 465, "protocol": "tcp", @@ -55,7 +91,7 @@ "port": 587, "protocol": "tcp", "from": "anywhere", - "why": "submission", + "why": "submission; also what the smtp provision serves consumers", "fixed": true }, { @@ -65,11 +101,30 @@ "why": "IMAP over TLS", "fixed": true }, + { + "port": 995, + "protocol": "tcp", + "from": "anywhere", + "why": "POP3 over TLS, kept at parity", + "fixed": true + }, { "port": 7080, "protocol": "tcp", "from": "mesh", - "why": "the web interface (admin, webmail, admin API), behind the route proxy" + "why": "the web front over http; only the ACME HTTP-01 passthrough is routed here \u2014 everything else 301s to https and would loop a proxy" + }, + { + "port": 7443, + "protocol": "tcp", + "from": "mesh", + "why": "the web front over its own TLS (admin, webmail, API); the public name mail.novox.be is a route grant reaching it here" + }, + { + "port": 4243, + "protocol": "tcp", + "from": "mesh", + "why": "automx: mail client autoconfiguration; autoconfig/autodiscover/automx.novox.be are route grants reaching it here" } ], "own-secrets": { @@ -88,12 +143,24 @@ "path": "/var/lib/mailu", "mode": "0700" }, + { + "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", "mode": "0644", - "content": "DOMAIN=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\nHOST_ADMIN=mailu-admin\nHOST_ANTISPAM=mailu-antispam:11332\nHOST_IMAP=mailu-imap\nHOST_SMTP=mailu-smtp\nHOST_WEBMAIL=mailu-webmail\nHOST_WEBDAV=mailu-webdav:5232\nHOST_REDIS=mailu-redis\nHOST_FRONT=mailu-front\nREDIS_ADDRESS=mailu-redis\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=\nCOMPRESSION=\nCOMPRESS_LEVEL=\nCOMPRESSION_LEVEL=\nBIND_ADDRESS4=127.0.0.1\nBIND_ADDRESS6=::1\nMAILU_VERSION=1.9\nDOCKER_ORG=mailu\nDOCKER_PREFIX=\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\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" }, { "id": "secret-env", @@ -144,7 +211,7 @@ "id": "data-mailqueue", "type": "directory", "path": "/services/mailu/data/mailqueue", - "mode": "0700" + "mode": "0755" }, { "id": "data-filter", @@ -152,6 +219,12 @@ "path": "/services/mailu/data/filter", "mode": "0700" }, + { + "id": "data-clamav", + "type": "directory", + "path": "/services/mailu/data/clamav", + "mode": "0700" + }, { "id": "data-redis", "type": "directory", @@ -215,19 +288,20 @@ "id": "resolver", "type": "container", "name": "mailu-resolver", - "image": "ghcr.io/mailu/unbound@sha256:142aaad82ad1b0d5b59a5f1303778dba61a3e0a540f5d969c48862bcc99f6f5d", + "image": "ghcr.io/mailu/unbound@sha256:3a0fdfb364a63f4f9259526e013c1ef40f5f14de3621ce1560804b3a5909584a", "network": "mailu", "env-file": [ "/var/lib/mailu/mailu.env", "/var/lib/mailu/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" + "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" }, { "id": "redis", "type": "container", "name": "mailu-redis", - "image": "redis@sha256:1db42ccef14898aa29bae778452d567534b59c107129cbc1163fb552de184d3c", + "image": "redis@sha256:4bed291aa5efb9f0d77b76ff7d4ab71eee410962965d052552db1fb80576431d", "network": "mailu", "volumes": [ "/services/mailu/data/redis:/data" @@ -237,7 +311,7 @@ "id": "admin", "type": "container", "name": "mailu-admin", - "image": "ghcr.io/mailu/admin@sha256:dcac20e9cbdad560faef9653b1b5ac0d9266f4098dc00f0e7f0d35f4e70ed8f1", + "image": "ghcr.io/mailu/admin@sha256:6dbfdadc4a9590dcb7652357b505200115b689b74008653bbf369e4599a3be5a", "network": "mailu", "env-file": [ "/var/lib/mailu/mailu.env", @@ -249,13 +323,16 @@ "/services/mailu/data/data:/data", "/services/mailu/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" + "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": [ + "192.168.203.254" + ] }, { "id": "imap", "type": "container", "name": "mailu-imap", - "image": "ghcr.io/mailu/dovecot@sha256:46d18ba51032be8ebd6841aa49c1ef8762c729038c5fd86a081b5b884d478af9", + "image": "ghcr.io/mailu/dovecot@sha256:7f0ed5db996fbdc00adc5c5e38a08492e04f7eb4a9fbd66a03aa9a28ddf23993", "network": "mailu", "env-file": [ "/var/lib/mailu/mailu.env" @@ -263,13 +340,16 @@ "volumes": [ "/services/mailu/data/mail:/mail", "/services/mailu/data/overrides/dovecot:/overrides:ro" + ], + "dns": [ + "192.168.203.254" ] }, { "id": "smtp", "type": "container", "name": "mailu-smtp", - "image": "ghcr.io/mailu/postfix@sha256:bbf882880f68849511710b35237a933f3fe80c4b28bf48ff20205dbd1f1433d7", + "image": "ghcr.io/mailu/postfix@sha256:e2e49f39e53b80eac9e7a2f18d9df11edeb4914fd62dbba89b3155e8e034f62e", "network": "mailu", "env-file": [ "/var/lib/mailu/mailu.env" @@ -277,13 +357,16 @@ "volumes": [ "/services/mailu/data/mailqueue:/queue", "/services/mailu/data/overrides/postfix:/overrides:ro" + ], + "dns": [ + "192.168.203.254" ] }, { "id": "antispam", "type": "container", "name": "mailu-antispam", - "image": "ghcr.io/mailu/rspamd@sha256:e87ab93dd252cc69499caa5317dd10d445fd4291a7ecf6bca09793c7d475a0c8", + "image": "ghcr.io/mailu/rspamd@sha256:ff3666d8a61f17d309c5c6f6bcf4d40470b82299ca706ac650301175bb1a079d", "network": "mailu", "env-file": [ "/var/lib/mailu/mailu.env" @@ -291,28 +374,29 @@ "volumes": [ "/services/mailu/data/filter:/var/lib/rspamd", "/services/mailu/data/overrides/rspamd:/etc/rspamd/override.d:ro" + ], + "dns": [ + "192.168.203.254" ] }, { "id": "antivirus", "type": "container", "name": "mailu-antivirus", - "image": "ghcr.io/mailu/clamav@sha256:01d30483e4a8a20a54566addb1f9b00ebb51e8a103f9226602379c412cf5fb62", + "image": "clamav/clamav-debian@sha256:b12ef8fefddbba7d88de59bea8a32622f365339154adf02d38fd089112e6745a", "network": "mailu", - "env-file": [ - "/var/lib/mailu/mailu.env", - "/var/lib/mailu/secret.env" - ], "volumes": [ - "/services/mailu/data/filter:/data" + "/services/mailu/data/clamav:/var/lib/clamav" ], - "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": [ + "192.168.203.254" + ] }, { "id": "webmail", "type": "container", "name": "mailu-webmail", - "image": "ghcr.io/mailu/roundcube@sha256:19ccc9c21b2420dabb893ffa707ef90785c785e53dcb6bb9f98da01598412c43", + "image": "ghcr.io/mailu/webmail@sha256:bdbee44cdb05a4658f0e3b62cc448de55ca8f8aea172279fda594826144c04f6", "network": "mailu", "env-file": [ "/var/lib/mailu/mailu.env", @@ -322,13 +406,16 @@ "/services/mailu/data/webmail:/data", "/services/mailu/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" + "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": [ + "192.168.203.254" + ] }, { "id": "webdav", "type": "container", "name": "mailu-webdav", - "image": "ghcr.io/mailu/radicale@sha256:e13cbad3791c0a6841b5d387e57e49a117808dcef87b8c9969f671ae9c3b67c0", + "image": "ghcr.io/mailu/radicale@sha256:690ed6edf189dfef100a5a8b37c195ebf5d9241ac5f23f2f44b8b7b75726e3de", "network": "mailu", "env-file": [ "/var/lib/mailu/mailu.env", @@ -337,13 +424,16 @@ "volumes": [ "/services/mailu/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" + "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": [ + "192.168.203.254" + ] }, { "id": "fetchmail", "type": "container", "name": "mailu-fetchmail", - "image": "ghcr.io/mailu/fetchmail@sha256:7dcd1392882925d612ab2d0230d437f0c660989d572283c48b0d0f2d491adce7", + "image": "ghcr.io/mailu/fetchmail@sha256:f881c8412d3bbe73d638469b48321558d6403a9d45bfa043c1e52c752103d42d", "network": "mailu", "env-file": [ "/var/lib/mailu/mailu.env", @@ -352,27 +442,37 @@ "volumes": [ "/services/mailu/data/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" + "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": [ + "192.168.203.254" + ] }, { "id": "front", "type": "container", "name": "mailu-front", - "image": "ghcr.io/mailu/nginx@sha256:09f28ab6d36367fcacc7994f7021f132ac845bdc05f04bf80906102d11aaa057", + "image": "ghcr.io/mailu/nginx@sha256:36f98897cd1bc9d27628bbb4e04bdf60147af2ec7507d6da77f002c4f256896d", "network": "mailu", "env-file": [ "/var/lib/mailu/mailu.env" ], "ports": [ "25", + "110", + "143", "465", "587", "993", - "80" + "995", + "7080:80", + "7443:443" ], "volumes": [ "/services/mailu/data/certs:/certs", "/services/mailu/data/overrides/nginx:/overrides:ro" + ], + "dns": [ + "192.168.203.254" ] }, { @@ -391,20 +491,39 @@ "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", "/var/lib/mesh/mailu/config.json:/run/config/config.json:ro", "/var/run/docker.sock:/var/run/docker.sock" ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_MAILU_URL": "http://mailu-admin/api/v1", + "MESH_MAILU_URL": "http://mailu-admin:8080/api/v1", "MESH_MAILU_API_KEY_FILE": "/run/secrets/api-token", "MESH_MAILU_IMAP_CONTAINER": "mailu-imap", - "MESH_MAILU_CONFIG_FILE": "/run/config/config.json" + "MESH_MAILU_CONFIG_FILE": "/run/config/config.json", + "MESH_MAILU_DOMAIN": "novox.be", + "MESH_RECEIVES": "/var/lib/mailu/grants/mesh.json" }, "restart-on": [ "runtime-config" ], "artifact": "runtime" + }, + { + "id": "automx", + "type": "container", + "name": "mailu-automx", + "artifact": "automx", + "network": "mailu", + "env-file": [ + "/var/lib/mailu/mailu.env" + ], + "ports": [ + "4243" + ], + "volumes": [ + "/services/mailu/data/automx:/data" + ] } ], "build": { @@ -418,6 +537,10 @@ "arg": "RUNTIME_BASE", "module": "mesh-tools", "artifact": "runtime" + }, + { + "arg": "PYTHON_BASE", + "image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228" } ], "artifacts": [ @@ -425,7 +548,30 @@ "name": "runtime", "kind": "image", "from": "Dockerfile" + }, + { + "name": "automx", + "kind": "image", + "from": "automx/Dockerfile" } ] + }, + "provides": [ + { + "name": "smtp", + "scope": "mesh" + } + ], + "serves": { + "smtp": { + "port": 587, + "domain": "novox.be" + } + }, + "receives": { + "smtp": "/var/lib/mailu/grants/mesh.json" + }, + "grants": { + "smtp": "/var/lib/mailu/grants" } } diff --git a/modules/mailu/package.json b/modules/mailu/package.json index 00d231a..14156bd 100644 --- a/modules/mailu/package.json +++ b/modules/mailu/package.json @@ -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", diff --git a/modules/mailu/provisioner/index.ts b/modules/mailu/provisioner/index.ts new file mode 100644 index 0000000..2b73274 --- /dev/null +++ b/modules/mailu/provisioner/index.ts @@ -0,0 +1,70 @@ +// mailu's provisioner — the adapter that makes mailu a provider of the mesh `smtp` 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 mailu creates and removes a consumer's +// sending account (novox/hq ADR 0048/0076, gitea's package-registry provisioner is the sibling). +// +// The `smtp` interface: a consumer authenticates to submission (port 587, STARTTLS) as a real +// mailbox this provisioner creates. The address is `@`: the local part is the +// consumer's `account` contribution — the name it wants to send as — falling back to the mesh's +// own login for a consumer that named none; the domain is the mail server's, which is this +// module's fact, not the consumer's. +// +// **The password is the mesh's, not the provisioner's (ADR 0048).** The mesh mints it and hands +// it to both ends; mailu sets exactly that password every run — so a rotation takes — and seals +// nothing: the consumer already has its copy through the mesh's own channel. + +import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; +import { MailuClient } from "../client.js"; + +const mailu = MailuClient.fromEnv(); + +// The mail server's own domain. From the environment the manifest composes, because the client's +// config file carries the admin API's coordinates, not the mail domain. +function domain(): string { + const named = (process.env.MESH_MAILU_DOMAIN ?? "").trim(); + if (named === "") { + throw new Error("MESH_MAILU_DOMAIN is not set, so a consumer's address cannot be composed"); + } + return named; +} + +// The address one consumer sends as. The local part is refused rather than sanitised when it is +// not a plain mailbox name — a rewritten name is an address nobody asked for. +function addressOf(p: { as: string; values?: Readonly> }): string { + const contributed = typeof p.values?.["account"] === "string" ? (p.values["account"] as string).trim() : ""; + const local = contributed !== "" ? contributed : p.as; + if (!/^[a-z0-9][a-z0-9._-]*$/.test(local)) { + throw new Error(`${JSON.stringify(local)} is not a usable mailbox name`); + } + return `${local}@${domain()}`; +} + +runProvisioner("smtp", { + async create(p: Provision): Promise { + const email = addressOf(p); + // Create if absent, and set exactly the minted password either way so a rotation takes. + // Mailu's create refuses a duplicate address, which is the signal to fall through to the + // password set — the same found-then-apply shape gitea's ensureUser settled on. + try { + await mailu.createUser(email, p.password); + } catch { + await mailu.applyProvisioned(email, p.password); + } + }, + + async remove(p: { as: string }): Promise { + // The withdrawal only knows the mesh login, never the contributed local part — so accounts + // that contributed one are removed when the address matching the login is absent? No: the + // harness hands remove only `as`, and an address composed from a contribution cannot be + // recomputed from it. The account is therefore removed by its login-shaped address when one + // exists, and left otherwise — a mailbox holding mail is the one thing a background loop + // must not guess about (this module's own events file says the same). Withdrawal of a + // 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 { + return mailu.holdsUser(addressOf(p)); + }, +}); diff --git a/modules/minio/Dockerfile b/modules/minio/Dockerfile new file mode 100644 index 0000000..fc0e121 --- /dev/null +++ b/modules/minio/Dockerfile @@ -0,0 +1,40 @@ +# minio'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 +ARG MC_CLI + +# Named so the final stage's COPY --from can reference a stage, not an ARG — the legacy builder +# this host still runs doesn't expand ARGs inside COPY --from, only inside FROM. +FROM ${MC_CLI} AS mccli + +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/minio +COPY . . +RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts provisioner/index.ts \ + --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist + +FROM ${RUNTIME_BASE} +COPY --from=build /app/modules/minio/dist /app/modules/minio/dist +# The provisioner shells out to mc to actually create buckets and service accounts on the running +# minio server — mc itself was never in this runtime image, only in minio's own. Silently retried +# "spawn mc ENOENT" forever: a requirement was granted at the control-plane level without ever +# materializing the credential on minio. /usr/bin/mc there is a symlink to the real binary, mcli — +# both copied so the symlink resolves. +COPY --from=mccli /usr/bin/mcli /usr/bin/mcli +COPY --from=mccli /usr/bin/mc /usr/bin/mc +# 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. +ENV MESH_TOOL_MODULES=/app/modules/minio/dist/tools/index.js,/app/modules/minio/dist/provisioner/index.js diff --git a/modules/minio/client.ts b/modules/minio/client.ts index a3f2ebe..beb751f 100644 --- a/modules/minio/client.ts +++ b/modules/minio/client.ts @@ -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 { + 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 { 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 = {}, + 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"); diff --git a/modules/minio/module.json b/modules/minio/module.json index 4fcb31d..6a07e40 100644 --- a/modules/minio/module.json +++ b/modules/minio/module.json @@ -7,6 +7,21 @@ "scope": "mesh" } ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "api": { + "label": "files-api", + "port": 9000 + }, + "console": { + "label": "files", + "port": 9001 + } + } + }, "capabilities": [ "container-runtime" ], @@ -20,12 +35,18 @@ "protocol": "tcp", "from": "mesh", "why": "the S3 endpoint" + }, + { + "port": 9001, + "protocol": "tcp", + "from": "mesh", + "why": "the admin console" } ], "serves": { "s3-bucket": { "scheme": "http", - "region": "us-east-1", + "region": "eu-west", "port": 9000 } }, @@ -68,7 +89,7 @@ { "id": "data", "type": "directory", - "path": "/services/minio/data/data1-1", + "path": "/var/lib/minio-store", "mode": "0700" }, { @@ -80,7 +101,7 @@ "id": "server", "type": "container", "name": "minio", - "image": "quay.io/minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e", + "image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372", "network": "minio", "args": [ "server", @@ -92,21 +113,22 @@ "/var/lib/minio/root.env" ], "ports": [ - "9000" + "9000", + "9001" ], "volumes": [ - "/services/minio/data/data1-1:/data", + "/var/lib/minio-store:/data", "/var/lib/minio/root.secret:/run/secrets/root:ro" ], "env": { - "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root" + "MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", + "MINIO_REGION": "eu-west" } }, { "id": "runtime", "type": "container", "name": "mesh-minio", - "image": "mesh-runtime-minio@sha256:0000000000000000000000000000000000000000000000000000000000000000", "network": "minio", "volumes": [ "/var/lib/mesh/minio/broker:/run/secrets/broker:ro", @@ -117,9 +139,36 @@ "MESH_MINIO_ENDPOINT": "http://minio:9000", "MESH_MINIO_ROOT_USER": "meshroot", "MESH_MINIO_ROOT_PASSWORD_FILE": "/run/secrets/root", + "MESH_MINIO_REGION": "eu-west", "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_RECEIVES": "/var/lib/minio/grants/mesh.json" - } + }, + "artifact": "runtime" } - ] + ], + "build": { + "on": [ + { + "arg": "BUILD_BASE", + "module": "mesh-tools", + "artifact": "build" + }, + { + "arg": "RUNTIME_BASE", + "module": "mesh-tools", + "artifact": "runtime" + }, + { + "arg": "MC_CLI", + "image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372" + } + ], + "artifacts": [ + { + "name": "runtime", + "kind": "image", + "from": "Dockerfile" + } + ] + } } diff --git a/modules/minio/package.json b/modules/minio/package.json index 9e74934..7441fc6 100644 --- a/modules/minio/package.json +++ b/modules/minio/package.json @@ -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", diff --git a/modules/minio/provisioner/index.ts b/modules/minio/provisioner/index.ts index cc07052..5e15c01 100644 --- a/modules/minio/provisioner/index.ts +++ b/modules/minio/provisioner/index.ts @@ -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 { + 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 diff --git a/modules/mongodb/client.ts b/modules/mongodb/client.ts index ce4f2e3..ae8c666 100644 --- a/modules/mongodb/client.ts +++ b/modules/mongodb/client.ts @@ -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 { + // 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 { diff --git a/modules/mongodb/module.json b/modules/mongodb/module.json index 73b7263..d228b95 100644 --- a/modules/mongodb/module.json +++ b/modules/mongodb/module.json @@ -75,7 +75,7 @@ { "id": "server", "type": "container", - "name": "mongo", + "name": "mongodb-server", "image": "mongo@sha256:e3fa459b4f4b72f3257c67a23c145e250b8b5700f033860392c68539b998bbe3", "network": "mongodb", "env": { @@ -101,7 +101,7 @@ "/var/lib/mongodb/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" diff --git a/modules/mongodb/package.json b/modules/mongodb/package.json index 4195793..479e7ea 100644 --- a/modules/mongodb/package.json +++ b/modules/mongodb/package.json @@ -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", diff --git a/modules/mongodb/provisioner/index.ts b/modules/mongodb/provisioner/index.ts index b42b2fc..e532b62 100644 --- a/modules/mongodb/provisioner/index.ts +++ b/modules/mongodb/provisioner/index.ts @@ -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 { + return mongo.canAuthenticateAs(p.as, p.as, p.password); + }, }); diff --git a/modules/mosquitto/client.ts b/modules/mosquitto/client.ts index f1a7877..d6fb10e 100644 --- a/modules/mosquitto/client.ts +++ b/modules/mosquitto/client.ts @@ -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 { + 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 { 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 { + 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); + }); + }); +} diff --git a/modules/mosquitto/package.json b/modules/mosquitto/package.json index 6f33f27..156253d 100644 --- a/modules/mosquitto/package.json +++ b/modules/mosquitto/package.json @@ -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", diff --git a/modules/mosquitto/provisioner/index.ts b/modules/mosquitto/provisioner/index.ts index 39dc471..60f9ed6 100644 --- a/modules/mosquitto/provisioner/index.ts +++ b/modules/mosquitto/provisioner/index.ts @@ -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 { + return mosquitto.holdsClient(p.as, p.password); + }, }); diff --git a/modules/mssql/client.ts b/modules/mssql/client.ts index 4b1a89a..e3304ac 100644 --- a/modules/mssql/client.ts +++ b/modules/mssql/client.ts @@ -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[]> { + async query( + select: string, + database = "master", + variables: Record = {}, + ): Promise[]> { 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 { + private async sqlcmd(sql: string, database: string, variables: Record = {}): Promise { // `-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 { + // 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 { const dbs = await this.query( diff --git a/modules/mssql/package.json b/modules/mssql/package.json index b0b97f6..31eeb79 100644 --- a/modules/mssql/package.json +++ b/modules/mssql/package.json @@ -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", diff --git a/modules/mssql/provisioner/index.ts b/modules/mssql/provisioner/index.ts index e12e8b0..16d0907 100644 --- a/modules/mssql/provisioner/index.ts +++ b/modules/mssql/provisioner/index.ts @@ -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 { + return mssql.holdsLogin(p.as, p.as, p.password); + }, }); diff --git a/modules/nextcloud/Dockerfile b/modules/nextcloud/Dockerfile index 5a6e5a8..d8a00e9 100644 --- a/modules/nextcloud/Dockerfile +++ b/modules/nextcloud/Dockerfile @@ -9,6 +9,11 @@ # image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`. ARG BUILD_BASE ARG RUNTIME_BASE +ARG DOCKER_CLI + +# Named so the final stage's COPY --from can reference a stage, not an ARG — the legacy builder +# this host still runs doesn't expand ARGs inside COPY --from, only inside FROM. +FROM ${DOCKER_CLI} AS dockercli FROM ${BUILD_BASE} AS build # Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own @@ -22,6 +27,11 @@ RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts FROM ${RUNTIME_BASE} COPY --from=build /app/modules/nextcloud/dist /app/modules/nextcloud/dist +# occ runs inside nextcloud's own container, reached over the mounted docker socket — which needs +# the docker CLI itself present here, not only the socket. Copied from Docker's own official client +# image rather than apt-installed, so this stays the one binary and nothing else (no daemon, no +# systemd unit, no package manager tree pulled in for it). +COPY --from=dockercli /usr/local/bin/docker /usr/local/bin/docker # 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 diff --git a/modules/nextcloud/client.ts b/modules/nextcloud/client.ts index 3ee0ce4..e93d9c7 100644 --- a/modules/nextcloud/client.ts +++ b/modules/nextcloud/client.ts @@ -52,8 +52,13 @@ export class NextcloudClient { const container = cfg.container ?? env.MESH_NEXTCLOUD_CONTAINER ?? "nextcloud"; const ocsUrl = cfg.url ?? env.MESH_NEXTCLOUD_URL ?? `http://127.0.0.1:${env.NEXTCLOUD_PORT ?? "80"}`; const adminUser = cfg.user ?? env.MESH_NEXTCLOUD_ADMIN_USER ?? "admin"; - const adminPassword = cfg.password ?? env.MESH_NEXTCLOUD_ADMIN_PASSWORD; - if (!adminPassword) throw new Error("no Nextcloud admin password — set MESH_NEXTCLOUD_ADMIN_PASSWORD"); + const passwordFile = env.MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE; + const adminPassword = cfg.password ?? env.MESH_NEXTCLOUD_ADMIN_PASSWORD + ?? (passwordFile ? readFileSync(passwordFile, "utf8").trim() : undefined); + if (!adminPassword) { + throw new Error("no Nextcloud admin password — set MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE " + + "(or MESH_NEXTCLOUD_ADMIN_PASSWORD)"); + } return new NextcloudClient(container, ocsUrl.replace(/\/$/, ""), adminUser, adminPassword); } diff --git a/modules/nextcloud/module.json b/modules/nextcloud/module.json index 05a982b..621edeb 100644 --- a/modules/nextcloud/module.json +++ b/modules/nextcloud/module.json @@ -65,7 +65,7 @@ "type": "file", "path": "/var/lib/nextcloud-module/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=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\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=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" }, { "id": "html", @@ -78,7 +78,7 @@ "id": "server", "type": "container", "name": "nextcloud", - "image": "nextcloud@sha256:0b8261f6335af6b95264ce893b4d645857638e0fa151b5ba620f25f377318ae1", + "image": "nextcloud@sha256:fb966733647ea03f0446b0c22eac9733c8eb616d37b960caca9d4c3010e14a08", "env-file": [ "/var/lib/nextcloud-module/server.env" ], @@ -106,12 +106,15 @@ "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", "/var/run/docker.sock:/var/run/docker.sock" ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_NEXTCLOUD_URL": "http://127.0.0.1:80", - "MESH_NEXTCLOUD_CONFIG_FILE": "/run/config/config.json" + "MESH_NEXTCLOUD_URL": "http://127.0.0.1:${port:80}", + "MESH_NEXTCLOUD_CONFIG_FILE": "/run/config/config.json", + "MESH_NEXTCLOUD_ADMIN_USER": "mesh-admin", + "MESH_NEXTCLOUD_ADMIN_PASSWORD_FILE": "/run/secrets/admin" }, "restart-on": [ "runtime-config" @@ -130,6 +133,10 @@ "arg": "RUNTIME_BASE", "module": "mesh-tools", "artifact": "runtime" + }, + { + "arg": "DOCKER_CLI", + "image": "docker@sha256:018edbc908e08fcc9dbf029c812c34251e9b4719e6f71ca0e5eae2a987d014ca" } ], "artifacts": [ diff --git a/modules/only-office/module.json b/modules/only-office/module.json index 12fc97a..5afcf62 100644 --- a/modules/only-office/module.json +++ b/modules/only-office/module.json @@ -99,7 +99,7 @@ "/var/lib/only-office/server.env" ], "ports": [ - "80" + "9070:80" ], "volumes": [ "/services/only-office/logs:/var/log/onlyoffice", diff --git a/modules/portainer/module.json b/modules/portainer/module.json index cb36c93..2bc5c97 100644 --- a/modules/portainer/module.json +++ b/modules/portainer/module.json @@ -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,20 @@ { "id": "data", "type": "directory", - "path": "/services/portainer/data", + "path": "/services/portainer/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", + "/services/portainer/portainer_data:/data", "/var/run/docker.sock:/var/run/docker.sock" ] }, @@ -90,5 +97,17 @@ "from": "Dockerfile" } ] + }, + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "portainer", + "port": 9090 + } + }, + "binds": { + "route": "/var/lib/mesh/portainer/route.json" } } diff --git a/modules/postgres/client.ts b/modules/postgres/client.ts index fa60b1b..ee4d6a9 100644 --- a/modules/postgres/client.ts +++ b/modules/postgres/client.ts @@ -88,9 +88,11 @@ export class PostgresClient { async createDatabaseAndRole(database: string, role: string, password: string): Promise { 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 { + 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 { await this.query( diff --git a/modules/postgres/package.json b/modules/postgres/package.json index a348964..1256cb6 100644 --- a/modules/postgres/package.json +++ b/modules/postgres/package.json @@ -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", diff --git a/modules/postgres/provisioner/index.ts b/modules/postgres/provisioner/index.ts index 16bd09e..825cd98 100644 --- a/modules/postgres/provisioner/index.ts +++ b/modules/postgres/provisioner/index.ts @@ -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 { + return postgres.canConnectAs(p.as, p.as, p.password); + }, }); diff --git a/modules/public-acme/module.json b/modules/public-acme/module.json index df25ce7..39e0e07 100644 --- a/modules/public-acme/module.json +++ b/modules/public-acme/module.json @@ -13,7 +13,7 @@ "at": "acme-v02.api.letsencrypt.org", "port": 443, "path": "/directory", - "root": "" + "roots": "" } } } diff --git a/modules/redis/client.ts b/modules/redis/client.ts index 93a355d..3993842 100644 --- a/modules/redis/client.ts +++ b/modules/redis/client.ts @@ -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 { + 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(); diff --git a/modules/redis/package.json b/modules/redis/package.json index 7d32bdb..5e76d02 100644 --- a/modules/redis/package.json +++ b/modules/redis/package.json @@ -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", diff --git a/modules/redis/provisioner/index.ts b/modules/redis/provisioner/index.ts index c3ea7f7..84aea66 100644 --- a/modules/redis/provisioner/index.ts +++ b/modules/redis/provisioner/index.ts @@ -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 { + return redis.holdsAclUser(p.as, p.password); + }, }); diff --git a/modules/route-adapter/adapter.ts b/modules/route-adapter/adapter.ts index 9b0324a..84c503d 100644 --- a/modules/route-adapter/adapter.ts +++ b/modules/route-adapter/adapter.ts @@ -151,6 +151,24 @@ export function routesFrom(document: unknown, machine: string): { routes: Route[ skipped.push(`${from} asked for ${JSON.stringify(name)}, which is not a name this can write`); continue; } + // What this adapter's one file shape cannot say, it skips aloud rather than approximating: + // a backend over its own TLS (the file would send plain http into a TLS listener), a + // path-scoped or refusing or redirecting rule (the file routes whole hosts). The mesh's own + // proxy serves all of these the day it takes over; until then the predecessor's hand-authored + // files keep covering them, exactly as they do today. + const scheme = typeof entry.values?.["scheme"] === "string" ? (entry.values["scheme"] as string).trim().toLowerCase() : ""; + if (scheme !== "" && scheme !== "http") { + skipped.push(`${from} asked for route ${name} over ${scheme}, which this file shape cannot say`); + continue; + } + if (typeof entry.values?.["path"] === "string" && (entry.values["path"] as string).trim() !== "") { + skipped.push(`${from} asked for route ${name} scoped to a path, which this file shape cannot say`); + continue; + } + if (entry.values?.["deny"] === true || typeof entry.values?.["redirect"] === "string") { + skipped.push(`${from} asked for route ${name} with a policy this file shape cannot say`); + continue; + } const port = asPort(entry.values?.["port"]); if (port === undefined) { skipped.push(`${from} asked for route ${name} and gave no usable port`); diff --git a/modules/route-adapter/test/adapter.test.ts b/modules/route-adapter/test/adapter.test.ts index b8515f6..6a7aaca 100644 --- a/modules/route-adapter/test/adapter.test.ts +++ b/modules/route-adapter/test/adapter.test.ts @@ -205,6 +205,27 @@ test("a contribution it cannot act on is skipped and named", async () => { assert.deepEqual(routesFrom(undefined, machine).routes, []); }); +// What the file shape cannot say is skipped aloud, never approximated: plain http into a TLS +// listener, a whole-host file for a path-scoped rule, a proxying file for a refusal or redirect. +// The mesh's own proxy serves all of these the day it takes over; until then the predecessor's +// hand-authored files keep covering them. +test("a contribution the file shape cannot say is skipped and says which part", async () => { + const machine = defaults.machine; + const { routes, skipped } = routesFrom({ given: [ + { from: "mailu", values: { name: "mail.example", port: 7443, scheme: "https", insecure: true } }, + { from: "mailu", values: { name: "mail.example", port: 7080, path: "/.well-known/acme-challenge" } }, + { from: "gitea", values: { name: "git.example", path: "/api/internal", deny: true, priority: 100000 } }, + { from: "site", values: { name: "www.example", redirect: "https://example" } }, + { from: "mailu", values: { name: "autoconfig.example", port: 4243 } }, + ] }, machine); + assert.deepEqual(routes.map((r) => r.name), ["autoconfig.example"]); + assert.equal(skipped.length, 4); + assert.match(skipped[0]!, /over https/); + assert.match(skipped[1]!, /scoped to a path/); + assert.match(skipped[2]!, /scoped to a path/); + assert.match(skipped[3]!, /policy/); +}); + // The directory is the predecessor's and the mesh only mounts it. Absent, there is nothing to write // into — and writing anyway would put route files somewhere nothing reads, reporting success. test("it refuses when the predecessor's directory is not there, and says why", async () => { diff --git a/modules/route-proxy/module.json b/modules/route-proxy/module.json index aecaca4..94c0d2e 100644 --- a/modules/route-proxy/module.json +++ b/modules/route-proxy/module.json @@ -18,10 +18,12 @@ "route": "/var/lib/route-proxy/routes/mesh.json" }, "requires": [ - "acme-ca" + "acme-ca", + "internal-acme-ca" ], "binds": { - "acme-ca": "/var/lib/route-proxy/acme-ca.json" + "acme-ca": "/var/lib/route-proxy/acme-ca.json", + "internal-acme-ca": "/var/lib/route-proxy/internal-acme-ca.json" }, "listens": [ { @@ -67,7 +69,14 @@ "type": "file", "path": "/var/lib/route-proxy/acme.env", "mode": "0600", - "content": "ACME_DIRECTORY=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:path}\nACME_ROOTS=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:roots}\n" + "content": "ACME_DIRECTORY=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:path}\nACME_ROOTS=https://${bound:acme-ca:at}:${bound:acme-ca:port}${bound:acme-ca:roots}\nACME_ROOTS_PATH=${bound:acme-ca:roots}\n" + }, + { + "id": "internal-acme-env", + "type": "file", + "path": "/var/lib/route-proxy/internal-acme.env", + "mode": "0600", + "content": "INTERNAL_ACME_DIRECTORY=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:path}\nINTERNAL_ACME_ROOTS=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots}\nINTERNAL_ACME_ROOTS_PATH=${bound:internal-acme-ca:roots}\n" }, { "id": "trust", @@ -85,20 +94,43 @@ "args": [ "sh", "-c", - "for i in $(seq 1 60); do wget -q -T 10 --no-check-certificate -O /ca/root.crt \"$ACME_ROOTS\" && grep -q 'BEGIN CERTIFICATE' /ca/root.crt && exit 0; sleep 2; done; echo \"the authority at $ACME_ROOTS did not serve its roots within two minutes\" >&2; exit 1" + "if [ -z \"$ACME_ROOTS_PATH\" ]; then cp /etc/ssl/certs/ca-certificates.crt /ca/root.crt; exit 0; fi; for i in $(seq 1 60); do wget -q -T 10 --no-check-certificate -O /ca/root.crt \"$ACME_ROOTS\" && grep -q 'BEGIN CERTIFICATE' /ca/root.crt && exit 0; sleep 2; done; echo \"the authority at $ACME_ROOTS did not serve its roots within two minutes\" >&2; exit 1" ], "restart-on": [ "acme-env" ] }, + { + "id": "internal-trust", + "type": "container", + "name": "route-proxy-internal-trust", + "artifact": "trust", + "run-once": true, + "network": "host", + "env-file": [ + "/var/lib/route-proxy/internal-acme.env" + ], + "volumes": [ + "/var/lib/route-proxy/ca:/ca" + ], + "args": [ + "sh", + "-c", + "if [ -z \"$INTERNAL_ACME_ROOTS_PATH\" ]; then cp /etc/ssl/certs/ca-certificates.crt /ca/internal-root.crt; exit 0; fi; for i in $(seq 1 60); do wget -q -T 10 --no-check-certificate -O /ca/internal-root.crt \"$INTERNAL_ACME_ROOTS\" && grep -q 'BEGIN CERTIFICATE' /ca/internal-root.crt && exit 0; sleep 2; done; echo \"the authority at $INTERNAL_ACME_ROOTS did not serve its roots within two minutes\" >&2; exit 1" + ], + "restart-on": [ + "internal-acme-env" + ] + }, { "id": "server", "type": "container", "name": "route-proxy", - "image": "mesh-route-proxy@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "artifact": "server", "network": "host", "env-file": [ - "/var/lib/route-proxy/acme.env" + "/var/lib/route-proxy/acme.env", + "/var/lib/route-proxy/internal-acme.env" ], "volumes": [ "/var/lib/route-proxy/routes:/routes:ro", @@ -110,11 +142,14 @@ "LISTEN": ":80", "TLS_LISTEN": ":443", "ACME_CACHE": "/acme", - "ACME_CA_BUNDLE": "/ca/root.crt" + "ACME_CA_BUNDLE": "/ca/root.crt", + "INTERNAL_ACME_CA_BUNDLE": "/ca/internal-root.crt" }, "restart-on": [ "trust", - "acme-env" + "acme-env", + "internal-trust", + "internal-acme-env" ] } ], @@ -123,7 +158,11 @@ { "name": "server", "kind": "image", - "from": "Dockerfile" + "from": "Dockerfile", + "context": { + "repository": "https://git.novox.be/novox/mesh-controller.git", + "ref": "main" + } }, { "name": "trust", diff --git a/modules/step-ca/module.json b/modules/step-ca/module.json index 31cc53a..d73f612 100644 --- a/modules/step-ca/module.json +++ b/modules/step-ca/module.json @@ -8,12 +8,20 @@ { "name": "acme-ca", "scope": "mesh" + }, + { + "name": "internal-acme-ca", + "scope": "mesh" } ], "serves": { "acme-ca": { "path": "/acme/acme/directory", "roots": "/roots.pem" + }, + "internal-acme-ca": { + "path": "/acme/acme/directory", + "roots": "/roots.pem" } }, "listens": [ diff --git a/modules/verdaccio/module.json b/modules/verdaccio/module.json index ad291cd..5a161d4 100644 --- a/modules/verdaccio/module.json +++ b/modules/verdaccio/module.json @@ -102,7 +102,7 @@ }, "provides": [ { - "name": "package-registry", + "name": "npm-package-registry", "scope": "mesh" } ],