Merge main

# Conflicts:
#	modules/minio/module.json
This commit is contained in:
jochen
2026-09-26 15:05:40 +02:00
56 changed files with 1242 additions and 153 deletions
+25
View File
@@ -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"]
+36 -23
View File
@@ -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"
}
]
}
}
+65 -23
View File
@@ -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<number> {
// 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<number | null> {
const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams`);
const res = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`);
if (res.status !== 200) return null;
const match = (res.body as any[] | null)?.find((t) => t?.name === team);
return match ? Number(match.id) : null;
}
/** 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<void> {
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<boolean> {
const me = await fetch(`${this.baseUrl}/api/v1/user`, {
headers: { Authorization: "Basic " + Buffer.from(`${username}:${password}`).toString("base64") },
});
if (me.status === 401 || me.status === 403) return false;
if (me.status !== 200) throw new Error(`Gitea GET /user as ${username}: ${me.status}`);
const teams = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`);
if (teams.status === 404) return false;
if (teams.status !== 200) GiteaAdmin.fail(`/orgs/${org}/teams`, teams);
const found = (teams.body as { id: number; name: string }[]).find((t) => t.name === team);
if (!found) return false;
const member = await this.request(`/teams/${found.id}/members/${encodeURIComponent(username)}`);
if (member.status === 200 || member.status === 204) return true;
if (member.status === 404) return false;
GiteaAdmin.fail(`/teams/${found.id}/members/${username}`, member);
}
/** Delete a user, purging what they own. A 404 means the mesh already withdrew them — success, not
* an error, so a re-run of remove is safe. */
async deleteUser(username: string): Promise<void> {
+36 -10
View File
@@ -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"
}
],
+1 -1
View File
@@ -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",
+27 -7
View File
@@ -1,9 +1,15 @@
// gitea's provisioner — the adapter that makes gitea a provider of the mesh `package-registry`
// interface. The reconcile loop, the contributions file, and reading the mesh's minted password are
// the sdk harness's; this writes only the per-service half: how gitea creates and removes a
// consumer's npm credential (novox/hq ADR 0048/0076).
// gitea's provisioner — the adapter that makes gitea a provider of the mesh
// `npm-package-registry` interface. The reconcile loop, the contributions file, and reading the
// mesh's minted password are the sdk harness's; this writes only the per-service half: how gitea
// creates and removes a consumer's npm credential (novox/hq ADR 0048/0076).
//
// The `package-registry` interface: a consumer authenticates to the npm registry at
// **A package registry seat is one per ecosystem (novox/hq ADR 0109).** gitea holds the npm seat
// (ADR 0110). Adding cargo or PyPI is adding a provision — another `provides` entry, another
// `receives` path and another registration below — not widening this one. `git`, which gitea also
// provides, mints nothing and so registers nothing here: the mesh's own repositories are public,
// and a clone credential is not yet decided (ADR 0111).
//
// The `npm-package-registry` interface: a consumer authenticates to the npm registry at
// `/api/packages/novox/npm/` with basic auth, as `as` with the password the mesh minted, and can
// read and write packages under the `@novox` scope. The registry's npm owner is the gitea org
// `novox`; a consumer is a gitea *user* placed on that org's package team.
@@ -26,7 +32,11 @@ const PACKAGE_TEAM = "packages";
const gitea = GiteaAdmin.fromEnv();
runProvisioner("package-registry", {
// Where this registration's contributions land comes from $MESH_RECEIVES, never a path written
// here: the mesh writes the file where the manifest's `receives` says, and a second copy of that
// path in code would drift from it. One variable carries one path, so a second registration in this
// module needs the mesh to say where each provision's file is — not yet possible, and not faked.
runProvisioner("npm-package-registry", {
async create(p: Provision): Promise<void> {
// The org and its package team are the same for every consumer; ensuring them per-create is
// idempotent and needs no separate bootstrap step.
@@ -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<void> {
await gitea.deleteUser(p.as);
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return gitea.holdsTeamMember(ORG, PACKAGE_TEAM, p.as, p.password);
},
});
+9 -3
View File
@@ -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",
+4 -2
View File
@@ -88,7 +88,9 @@
"env": {
"KC_DB": "postgres",
"KC_HTTP_ENABLED": "true",
"KC_HEALTH_ENABLED": "true"
"KC_HEALTH_ENABLED": "true",
"KC_HOSTNAME": "https://keycloak.novox.be",
"KC_PROXY_HEADERS": "xforwarded"
},
"env-file": [
"/var/lib/keycloak/admin.env",
@@ -118,7 +120,7 @@
],
"env": {
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_KEYCLOAK_URL": "http://127.0.0.1:8080",
"MESH_KEYCLOAK_URL": "http://127.0.0.1:${port:8080}",
"MESH_KEYCLOAK_CONFIG_FILE": "/run/config/config.json"
},
"restart-on": [
+33
View File
@@ -94,6 +94,39 @@ export class LavinmqClient {
await this.api("PUT", `/permissions/${v}/${u}`, { configure: ".*", write: ".*", read: ".*" });
}
/**
* Whether a consumer's user exists with exactly this password and full permissions on its own
* vhost. Read-only: the stored hash is salted SHA-256, the scheme `rabbitHash` writes, so the
* password is checked by hashing it with the stored salt rather than by logging in. `false` when
* the user or its permission is gone or the password differs; an unreachable API rejects
* (novox/hq issue 120).
*/
async holdsConsumer(login: string, password: string): Promise<boolean> {
const v = encodeURIComponent(login);
const u = encodeURIComponent(login);
const user = await this.getOrNull<{ password_hash?: string; hashing_algorithm?: string }>(`/users/${u}`);
if (!user?.password_hash) return false;
if (user.hashing_algorithm && !/sha256/i.test(user.hashing_algorithm)) {
throw new Error(`lavinmq user ${login} is hashed with ${user.hashing_algorithm}, which this check cannot verify`);
}
const stored = Buffer.from(user.password_hash, "base64");
if (stored.length < 5 || rabbitHash(password, stored.subarray(0, 4)) !== user.password_hash) return false;
const perm = await this.getOrNull<{ configure?: string; write?: string; read?: string }>(`/permissions/${v}/${u}`);
return perm?.configure === ".*" && perm?.write === ".*" && perm?.read === ".*";
}
/** A GET that answers null for a 404 and rejects on anything else that is not 2xx. */
private async getOrNull<T>(path: string): Promise<T | null> {
const resp = await fetch(`${this.conn.base}/api${path}`, {
headers: {
Authorization: "Basic " + Buffer.from(`${this.conn.adminUser}:${this.conn.adminPassword}`).toString("base64"),
},
});
if (resp.status === 404) return null;
if (!resp.ok) throw new Error(`lavinmq management API GET ${path} -> ${resp.status}: ${await resp.text()}`);
return (await resp.json()) as T;
}
/** Remove a consumer's vhost and user, idempotently. A DELETE of what is already gone is tolerated. */
async removeConsumer(login: string): Promise<void> {
const v = encodeURIComponent(login);
+1 -1
View File
@@ -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",
+5
View File
@@ -48,4 +48,9 @@ runProvisioner("amqp", {
await lavinmq.removeConsumer(p.as);
await announce("module.lavinmq.amqp.deprovisioned", { user: p.as, vhost: p.as });
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return lavinmq.holdsConsumer(p.as, p.password);
},
});
+2 -2
View File
@@ -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
+44
View File
@@ -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
+49
View File
@@ -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 <<EOT
$SQL_CMD
INSERT INTO provider(id, name, short_name) VALUES(${PROVIDER_ID}, '${PROVIDER_NAME}', '${PROVIDER_SHORTNAME}');
INSERT INTO server(id, port, type, name, socket_type, user_name, authentication)
VALUES(${s1_id}, ${AMX_IMAP_PORT}, 'imap', '${AMX_IMAP_SERVER}', 'STARTTLS', '%EMAILADDRESS%', 'password-cleartext');
INSERT INTO server(id, port, type, name, socket_type, user_name, authentication)
VALUES(${s2_id}, ${AMX_SMTP_PORT}, 'smtp', '${AMX_SMTP_ADDRESS}', 'STARTTLS', '%EMAILADDRESS%', 'password-cleartext');
INSERT INTO domain(id, name, provider_id, ldapserver_id) VALUES(${dom_id}, '${DOMAIN}', ${PROVIDER_ID}, ${s3_id});
INSERT INTO server_domain(server_id, domain_id) VALUES(${s1_id}, ${dom_id});
INSERT INTO server_domain(server_id, domain_id) VALUES(${s2_id}, ${dom_id});
EOT
)
PROVIDER_ID=$((PROVIDER_ID+10))
done
echo -e ${SQL_CMD}
echo -e ${SQL_CMD} | sqlite3 /data/db.sqlite
+21
View File
@@ -0,0 +1,21 @@
[automx2]
# A typical production setup would use loglevel = WARNING
loglevel = WARNING
# Echo SQL commands into log? Used for debugging.
db_echo = false
# In-memory SQLite database
# db_uri = sqlite:///:memory:
# SQLite database in a UNIX-like file system
db_uri = sqlite:////data/db.sqlite
# MySQL database on a remote server. This example does not use an encrypted
# connection and is therefore *not* recommended for production use.
#db_uri = mysql://username:password@server.example.com/db
# Number of proxy servers between automx2 and the client (default: 0).
# If your logs only show 127.0.0.1 or ::1 as the source IP for incoming
# connections, proxy_count probably needs to be changed.
proxy_count = 1
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -e
if [ ! -e /data/db.sqlite ]; then
# DB SETUP
echo "SETTING UP DB"
./setup-db
echo "ADDING DOMAINS"
# Add the domains
./add-domains
fi
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
set -e
# LDAP-Server
LDAP=$(cat <<EOT
CREATE TABLE ldapserver(
id INT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
port INT NOT NULL,
use_ssl INT NOT NULL,
search_base TEXT NOT NULL,
search_filter TEXT NOT NULL,
attr_uid TEXT NOT NULL,
attr_cn TEXT NOT NULL,
bind_password TEXT NOT NULL,
bind_user TEXT NOT NULL
);
EOT
)
# Provider
PROVIDER=$(cat <<EOT
CREATE TABLE provider(
id INT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
short_name TEXT NOT NULL
);
EOT
)
# Server
SERVER=$(cat <<EOT
CREATE TABLE server(
id INT PRIMARY KEY NOT NULL,
prio INT NOT NULL DEFAULT 10,
name TEXT NOT NULL,
port INT NOT NULL,
type TEXT NOT NULL,
socket_type TEXT NOT NULL,
user_name TEXT NOT NULL,
authentication TEXT NOT NULL
);
EOT
)
# Domain
DOMAIN=$(cat <<EOT
CREATE TABLE domain(
id INT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
provider_id INT NOT NULL,
ldapserver_id INT NULL,
FOREIGN KEY(ldapserver_id) REFERENCES ldapserver(id),
FOREIGN KEY(provider_id) REFERENCES provider(id)
);
CREATE UNIQUE INDEX domain_name ON domain(name);
EOT
)
# Server-Domain
SERVER_DOMAIN=$(cat <<EOT
CREATE TABLE server_domain(
server_id INT NOT NULL,
domain_id INT NOT NULL,
FOREIGN KEY(server_id) REFERENCES server(id),
FOREIGN KEY(domain_id) REFERENCES domain(id)
);
EOT
)
## TODO Foreign keys
SQL_CMD=$(cat <<EOT
$LDAP
$PROVIDER
$SERVER
$DOMAIN
$SERVER_DOMAIN
EOT
)
echo -e ${SQL_CMD}
echo -e ${SQL_CMD} | sqlite3 /data/db.sqlite
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# vim:ts=4:sw=4:noet
#
# 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}"
echo "Setup dir $dir"
set -e
if [ -d "${dir}" ]; then
echo >&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
+8
View File
@@ -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
+29
View File
@@ -130,10 +130,39 @@ export class MailuClient {
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password });
}
/**
* Set the mesh's password on a mailbox the mesh provisions, and enable it. A disabled mailbox is
* what the provisioner's check reports as lost, so applying again must enable it, or the two would
* disagree for ever. Separate from changePassword, which an operator's tool uses and which must
* not re-enable a mailbox someone disabled.
*/
async applyProvisioned(email: string, password: string): Promise<void> {
await this.api("PATCH", `/user/${encodeURIComponent(email)}`, { raw_password: password, enabled: true });
}
async deleteUser(email: string): Promise<void> {
await this.api("DELETE", `/user/${encodeURIComponent(email)}`);
}
/**
* Whether a mailbox exists and is enabled. Read-only, through the admin API.
*
* **The password is not checked.** Mailu authenticates in its admin service, behind the front;
* the imap server's own password database accepts any password from Mailu's subnet, so asking it
* (`doveadm auth test`) proves nothing, or refuses everyone. A lost or disabled mailbox is caught;
* a password changed by hand is not (novox/hq issue 120).
*/
async holdsUser(email: string): Promise<boolean> {
const res = await fetch(`${this.baseUrl}/user/${encodeURIComponent(email)}`, {
headers: { Authorization: this.apiKey, Accept: "application/json" },
});
if (res.status === 404) return false;
if (!res.ok) throw new Error(`Mailu API GET /user/${email}: ${res.status} ${await res.text()}`);
const user = (await res.json()) as { enabled?: boolean };
return user.enabled !== false;
}
async listAliases(): Promise<MailuAlias[]> {
const aliases = await this.api<any[]>("GET", "/alias");
return (aliases ?? []).map((a) => ({
+177 -31
View File
@@ -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\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"
}
}
+1 -1
View File
@@ -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",
+70
View File
@@ -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 `<account>@<domain>`: 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<Record<string, unknown>> }): 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<void> {
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<void> {
// 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<boolean> {
return mailu.holdsUser(addressOf(p));
},
});
+12
View File
@@ -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 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
@@ -22,6 +27,13 @@ RUN node /app/node_modules/typescript/bin/tsc client.ts tools/index.ts provision
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.
+17 -4
View File
@@ -125,6 +125,18 @@ export class MinioClient {
throw new Error(`minio bucketExists ${bucket}: ${status}`);
}
/**
* Whether a consumer's access key, with exactly this secret, reaches its bucket: a HEAD of the
* bucket signed as the consumer, the way it signs. Read-only. `false` when the key is unknown, the
* secret wrong, access denied or the bucket gone; any other answer rejects (novox/hq issue 120).
*/
async canReachAs(bucket: string, accessKey: string, secretKey: string): Promise<boolean> {
const { status } = await this.request("HEAD", `/${bucket}`, {}, { accessKey, secretKey });
if (status === 200) return true;
if (status === 403 || status === 404) return false;
throw new Error(`minio HEAD ${bucket} as ${accessKey}: ${status}`);
}
async createBucket(bucket: string): Promise<void> {
const { status, text } = await this.request("PUT", `/${bucket}`);
// 200 created; 409 BucketAlreadyOwnedByYou — idempotent, a re-provision must not fail.
@@ -251,6 +263,7 @@ export class MinioClient {
method: string,
path: string,
query: Record<string, string> = {},
as: { accessKey: string; secretKey: string } = { accessKey: this.rootUser, secretKey: this.rootPassword },
): Promise<{ status: number; headers: Headers; text: string }> {
const { amzDate, dateStamp } = this.stamp();
const host = new URL(this.baseUrl).host;
@@ -262,8 +275,8 @@ export class MinioClient {
const canonicalRequest = [method, encodedPath, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join("\n");
const scope = `${dateStamp}/${this.region}/s3/aws4_request`;
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256hex(canonicalRequest)].join("\n");
const signature = hmac(this.signingKey(dateStamp), stringToSign).toString("hex");
const authorization = `AWS4-HMAC-SHA256 Credential=${this.rootUser}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
const signature = hmac(this.signingKey(dateStamp, as.secretKey), stringToSign).toString("hex");
const authorization = `AWS4-HMAC-SHA256 Credential=${as.accessKey}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
const url = `${this.baseUrl}${encodedPath}${canonicalQuery ? `?${canonicalQuery}` : ""}`;
const res = await fetch(url, {
@@ -275,8 +288,8 @@ export class MinioClient {
return { status: res.status, headers: res.headers, text };
}
private signingKey(dateStamp: string): Buffer {
const kDate = hmac(`AWS4${this.rootPassword}`, dateStamp);
private signingKey(dateStamp: string, secretKey: string = this.rootPassword): Buffer {
const kDate = hmac(`AWS4${secretKey}`, dateStamp);
const kRegion = hmac(kDate, this.region);
const kService = hmac(kRegion, "s3");
return hmac(kService, "aws4_request");
+17 -3
View File
@@ -103,9 +103,19 @@
"name": "minio",
"image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372",
"network": "minio-net",
"args": ["server", "/data", "--console-address", ":9001"],
"env-file": ["/var/lib/minio/root.env"],
"ports": ["9000", "9001"],
"args": [
"server",
"/data",
"--console-address",
":9001"
],
"env-file": [
"/var/lib/minio/root.env"
],
"ports": [
"9000",
"9001"
],
"volumes": [
"/var/lib/minio-store:/data",
"/var/lib/minio/root.secret:/run/secrets/root:ro"
@@ -148,6 +158,10 @@
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
},
{
"arg": "MC_CLI",
"image": "docker.io/pgsty/minio@sha256:b6bfe7239bfc83fb90d31612d9704d86039dd714f7904b3f1ad68f211e602372"
}
],
"artifacts": [
+1 -1
View File
@@ -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",
+6
View File
@@ -53,6 +53,12 @@ runProvisioner("s3-bucket", {
await announce("module.minio.bucket.removed", { bucket, accessKey: p.as });
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return minio.canReachAs(bucketFor(p.as), p.as, p.password);
},
});
/** Emit best-effort: a broker hiccup is logged and dropped, never allowed to throw back and fail a
+28
View File
@@ -109,6 +109,34 @@ print(EJSON.stringify({ ok: 1 }));
await this.evalJs<{ ok: number }>(js);
}
/**
* Whether `user` authenticates against `database` with exactly `password` and holds `dbOwner`
* there: checked by connecting as the consumer, the way it connects. Read-only. `false` only on an
* authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120).
*/
async canAuthenticateAs(database: string, user: string, password: string): Promise<boolean> {
// Connected without credentials, then authenticated inside the eval from the environment, so
// the consumer's password is neither on argv nor in the message of a failed command.
const uri = `mongodb://${this.conn.host}:${this.conn.port}/?serverSelectionTimeoutMS=10000`;
const js =
"const t = db.getSiblingDB(process.env.MESH_HOLDS_DB);" +
"t.auth(process.env.MESH_HOLDS_USER, process.env.MESH_HOLDS_PW);" +
"print(EJSON.stringify(t.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUserRoles))";
let stdout: string;
try {
({ stdout } = await run("mongosh", [uri, "--quiet", "--eval", js], {
env: { ...process.env, MESH_HOLDS_DB: database, MESH_HOLDS_USER: user, MESH_HOLDS_PW: password },
timeout: 30_000,
}));
} catch (err) {
const text = `${(err as { stderr?: string }).stderr ?? ""}${(err as { stdout?: string }).stdout ?? ""}`;
if (/Authentication failed|AuthenticationFailed/i.test(text)) return false;
throw new Error(`mongosh could not check ${user}: ${text.trim().slice(0, 500) || String((err as Error).message).split("\n")[0]}`);
}
const roles = JSON.parse(stdout.trim()) as { role: string; db: string }[];
return roles.some((r) => r.role === "dbOwner" && r.db === database);
}
/** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the
* user is removed first so a re-grant of the same login starts clean. */
async dropDatabaseAndUser(database: string, user: string): Promise<void> {
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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",
+5
View File
@@ -45,4 +45,9 @@ runProvisioner("mongodb-database", {
await mongo.dropDatabaseAndUser(p.as, p.as);
await announce("module.mongodb.database.deprovisioned", { database: p.as });
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return mongo.canAuthenticateAs(p.as, p.as, p.password);
},
});
+94 -3
View File
@@ -15,6 +15,7 @@
// The one cost dynsec carries is the bootstrap file; see initBootstrapFile() and the module README.
import { randomBytes } from "node:crypto";
import { connect as tcpConnect } from "node:net";
import { readFileSync } from "node:fs";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
@@ -87,9 +88,20 @@ export class MosquittoClient {
"-u", this.conn.adminUser,
"-P", this.conn.adminPassword,
];
const { stdout, stderr } = await run("mosquitto_ctrl", [...base, "dynsec", ...args], {
maxBuffer: 16 << 20,
});
let stdout: string;
let stderr: string;
try {
({ stdout, stderr } = await run("mosquitto_ctrl", [...base, "dynsec", ...args], {
maxBuffer: 16 << 20,
timeout: 30_000,
}));
} catch (err) {
// A failed run's message repeats its argv, the admin password (-P) included; say what failed
// without it.
const e = err as { code?: unknown; signal?: unknown; stderr?: string; stdout?: string };
const detail = `${e.stderr ?? ""}${e.stdout ?? ""}`.trim().slice(0, 500);
throw new Error(`mosquitto_ctrl dynsec ${args[0] ?? ""} could not run (${e.code ?? e.signal ?? "error"}): ${detail}`);
}
const failure = ctlError(`${stdout}\n${stderr}`);
if (failure) {
throw new Error(`mosquitto_ctrl dynsec ${args[0] ?? ""} failed: ${failure}`);
@@ -140,6 +152,11 @@ export class MosquittoClient {
if (await this.clientExists(username)) {
await this.ctl("setClientPassword", username, password);
// A disabled client is refused like a wrong password, so the check the provisioner runs
// reports it lost; applying again must enable it, or the two would disagree for ever.
if (/Disabled:\s*true/i.test(await this.ctl("getClient", username))) {
await this.ctl("enableClient", username);
}
} else {
await this.ctl("createClient", username, "-p", password);
}
@@ -163,6 +180,28 @@ export class MosquittoClient {
}
}
/**
* Whether a consumer's client accepts exactly this password and still carries its own role.
* Read-only. The password is checked the way the consumer is checked, by an MQTT CONNECT as it,
* and the broker's CONNACK code is the answer: 0 accepted, 4 bad credentials, 5 not authorised.
* Nothing rides on argv. An unreachable broker rejects (novox/hq issue 120).
*/
async holdsClient(username: string, password: string): Promise<boolean> {
const code = await mqttConnack(this.conn.host, this.conn.port, username, password);
if (code === 4 || code === 5) return false;
if (code !== 0) throw new Error(`mosquitto refused ${username} with CONNACK ${code}`);
// The role, asked directly: only "not found" means absent. Any other failure to ask rejects,
// unlike clientHasRole, which reads every failure as "no role".
let out: string;
try {
out = await this.ctl("getClient", username);
} catch (err) {
if (/not\s*found|does not exist|no such/i.test(String(err))) return false;
throw err;
}
return new RegExp(`(^|\\s)${escapeRegExp(username)}\\s+\\(priority`, "m").test(out);
}
/** Remove a client and the per-client role created for it, idempotently. */
async deleteScopedClient(username: string): Promise<void> {
await ignoreMissing(this.ctl("deleteClient", username));
@@ -249,3 +288,55 @@ function readSecretFile(path: string | undefined): string | undefined {
return undefined;
}
}
/**
* Connect once over MQTT 3.1.1 with a username and password, return the broker's CONNACK return code,
* and disconnect. A clean session under a throwaway client id, so no consumer session is taken over.
*/
function mqttConnack(host: string, port: number, username: string, password: string): Promise<number> {
const str = (v: string): Buffer => {
const b = Buffer.from(v, "utf8");
const len = Buffer.alloc(2);
len.writeUInt16BE(b.length);
return Buffer.concat([len, b]);
};
const variable = Buffer.concat([str("MQTT"), Buffer.from([4, 0xc2, 0, 10])]); // level 4; user+pass+clean; keepalive 10s
const payload = Buffer.concat([str(`mesh-holds-${randomBytes(6).toString("hex")}`), str(username), str(password)]);
let remaining = variable.length + payload.length;
const lenBytes: number[] = [];
do {
let byte = remaining % 128;
remaining = Math.floor(remaining / 128);
if (remaining > 0) byte |= 0x80;
lenBytes.push(byte);
} while (remaining > 0);
const packet = Buffer.concat([Buffer.from([0x10, ...lenBytes]), variable, payload]);
return new Promise((resolve, reject) => {
const socket = tcpConnect({ host, port });
let buf = Buffer.alloc(0);
const timer = setTimeout(() => {
socket.destroy();
reject(new Error(`no CONNACK from ${host}:${port} within 10s`));
}, 10_000);
socket.on("connect", () => socket.write(packet));
socket.on("data", (chunk) => {
buf = Buffer.concat([buf, chunk]);
if (buf.length < 4) return;
clearTimeout(timer);
if (buf[0] !== 0x20) {
socket.destroy();
reject(new Error(`unexpected MQTT packet 0x${buf[0].toString(16)} instead of CONNACK`));
return;
}
const code = buf[3];
if (code === 0) socket.end(Buffer.from([0xe0, 0])); // DISCONNECT
else socket.destroy();
resolve(code);
});
socket.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
});
}
+1 -1
View File
@@ -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",
+5
View File
@@ -43,4 +43,9 @@ runProvisioner("mqtt-topic", {
await mosquitto.deleteScopedClient(p.as);
await announce("module.mosquitto.topic.deprovisioned", { username: p.as });
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return mosquitto.holdsClient(p.as, p.password);
},
});
+54 -4
View File
@@ -75,14 +75,18 @@ export class MssqlClient {
* prints (split across output lines for a large result, and reassembled here) is parsed. An
* empty result yields no output at all — an empty array.
*/
async query(select: string, database = "master"): Promise<Record<string, unknown>[]> {
async query(
select: string,
database = "master",
variables: Record<string, string> = {},
): Promise<Record<string, unknown>[]> {
const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`;
const stdout = await this.sqlcmd(wrapped, database);
const stdout = await this.sqlcmd(wrapped, database, variables);
return parseJsonRows(stdout);
}
/** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */
private async sqlcmd(sql: string, database: string): Promise<string> {
private async sqlcmd(sql: string, database: string, variables: Record<string, string> = {}): Promise<string> {
// `-h -1` drops the column-header rule; `-y 0`/`-Y 0` lift the display-width cap so a long
// JSON document is not truncated; `-W` trims trailing whitespace so the JSON chunks rejoin
// cleanly. sqlcmd from the mssql-tools ships in the runtime container, the way `psql` ships
@@ -101,7 +105,9 @@ export class MssqlClient {
"-W",
"-Q", sql,
],
{ env: { ...process.env, SQLCMDPASSWORD: this.conn.password }, maxBuffer: 16 << 20 },
// `variables` reach sqlcmd as environment variables, which it substitutes as `$(NAME)` scripting
// variables: a value that must not appear on argv, or in the message of a failed command.
{ env: { ...process.env, ...variables, SQLCMDPASSWORD: this.conn.password }, maxBuffer: 16 << 20 },
);
return stdout;
}
@@ -121,6 +127,9 @@ export class MssqlClient {
);
} else {
await this.exec(`ALTER LOGIN ${ident(login)} WITH PASSWORD = ${literal(password)}`);
// A disabled login is refused like a wrong password; the check the provisioner runs reports it
// lost, so applying again must enable it or the two would disagree for ever.
await this.exec(`ALTER LOGIN ${ident(login)} ENABLE`);
}
const dbs = await this.query(
@@ -138,10 +147,51 @@ export class MssqlClient {
);
if (users.length === 0) {
await this.exec(`CREATE USER ${ident(login)} FOR LOGIN ${ident(login)}`, database);
} else {
// Re-point an existing user at the login when its SID is not the login's: a database restored
// from elsewhere keeps its user under the old login's SID, orphaned. Only then, so a user that
// is already mapped is left alone.
const orphaned = await this.query(
`SELECT 1 AS ok FROM sys.database_principals WHERE name = ${literal(login)} ` +
`AND (sid IS NULL OR sid <> SUSER_SID(${literal(login)}))`,
database,
);
if (orphaned.length > 0) {
await this.exec(`ALTER USER ${ident(login)} WITH LOGIN = ${ident(login)}`, database);
}
}
await this.exec(`ALTER ROLE db_owner ADD MEMBER ${ident(login)}`, database);
}
/**
* Whether `login` exists, is enabled, has exactly `password`, and is a db_owner user of
* `database`. Read-only: the password is compared with PWDCOMPARE against the stored hash, so
* nothing logs in and no failed-login is recorded (novox/hq issue 120).
*/
async holdsLogin(database: string, login: string, password: string): Promise<boolean> {
// The password reaches sqlcmd as a scripting variable from the environment, never inside the
// query text, so it is neither on argv nor in the message of a failed command. It is the mesh's
// minted value, which carries no quote.
const server = await this.query(
`SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` +
`AND is_disabled = 0 AND PWDCOMPARE(N'$(MESHHOLDSPW)', password_hash) = 1) ` +
`AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`,
"master",
{ MESHHOLDSPW: password },
);
if (Number(server[0]?.ok) !== 1) return false;
// The user must be this login's, by SID, and a db_owner. A user orphaned by a restore has the
// right name and the wrong SID, and cannot be reached through the login.
const owner = await this.query(
`SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.database_principals dp ` +
`JOIN sys.server_principals sp ON dp.sid = sp.sid ` +
`WHERE dp.name = ${literal(login)} AND sp.name = ${literal(login)}) ` +
`AND IS_ROLEMEMBER('db_owner', ${literal(login)}) = 1 THEN 1 ELSE 0 END AS int) AS ok`,
database,
);
return Number(owner[0]?.ok) === 1;
}
/** Drop a database and its login, idempotently, after evicting live connections. */
async dropDatabaseAndLogin(database: string, login: string): Promise<void> {
const dbs = await this.query(
+1 -1
View File
@@ -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",
+5
View File
@@ -44,4 +44,9 @@ runProvisioner("mssql-database", {
await mssql.dropDatabaseAndLogin(p.as, p.as);
await announce("module.mssql.database.deprovisioned", { database: p.as });
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return mssql.holdsLogin(p.as, p.as, p.password);
},
});
+10
View File
@@ -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
+7 -2
View File
@@ -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);
}
+11 -4
View File
@@ -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": [
+1 -1
View File
@@ -99,7 +99,7 @@
"/var/lib/only-office/server.env"
],
"ports": [
"80"
"9070:80"
],
"volumes": [
"/services/only-office/logs:/var/log/onlyoffice",
+24 -5
View File
@@ -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"
}
}
+28 -2
View File
@@ -88,9 +88,11 @@ export class PostgresClient {
async createDatabaseAndRole(database: string, role: string, password: string): Promise<void> {
const roles = await this.query("SELECT 1 FROM pg_roles WHERE rolname = " + literal(role));
if (roles.rows.length === 0) {
await this.query(`CREATE ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`);
await this.query(`CREATE ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)} VALID UNTIL 'infinity'`);
} else {
await this.query(`ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)}`);
// VALID UNTIL 'infinity': a password that expired is refused like a wrong one, so the check the
// provisioner runs would report it lost, and only clearing the expiry makes applying it again work.
await this.query(`ALTER ROLE ${ident(role)} WITH LOGIN PASSWORD ${literal(password)} VALID UNTIL 'infinity'`);
}
const dbs = await this.query("SELECT 1 FROM pg_database WHERE datname = " + literal(database));
if (dbs.rows.length === 0) {
@@ -99,6 +101,30 @@ export class PostgresClient {
await this.query(`GRANT ALL PRIVILEGES ON DATABASE ${ident(database)} TO ${ident(role)}`);
}
/**
* Whether `role` can log in to `database` with exactly `password`: the consumer's own view of its
* credential, checked by connecting as it. Read-only. `false` only when the server says so (the
* role, the password or the database is wrong or gone); an unreachable server rejects instead,
* because being unable to ask is not evidence of loss (novox/hq issue 120).
*/
async canConnectAs(database: string, role: string, password: string): Promise<boolean> {
try {
await run(
"psql",
["-h", this.conn.host, "-p", String(this.conn.port), "-U", role, "-d", database,
"-v", "ON_ERROR_STOP=1", "--no-psqlrc", "-tAc", "SELECT 1"],
{ env: { ...process.env, PGPASSWORD: password, PGCONNECT_TIMEOUT: "10" }, timeout: 20_000 },
);
return true;
} catch (err) {
const text = `${(err as { stderr?: string }).stderr ?? ""}`;
if (/password authentication failed|role ".*" does not exist|database ".*" does not exist|not permitted to log in|permission denied for database/i.test(text)) {
return false;
}
throw err;
}
}
/** Drop a database and its owning role, idempotently, after evicting live connections. */
async dropDatabaseAndRole(database: string, role: string): Promise<void> {
await this.query(
+2 -1
View File
@@ -73,7 +73,8 @@
"id": "store-data",
"type": "directory",
"path": "/var/lib/mesh-store",
"mode": "0700"
"mode": "0700",
"owner": "999:70"
},
{
"id": "server",
+1 -1
View File
@@ -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",
+5
View File
@@ -45,4 +45,9 @@ runProvisioner("postgres-database", {
await postgres.dropDatabaseAndRole(p.as, p.as);
await announce("module.postgres.database.deprovisioned", { database: p.as });
},
// Asked every minute by the harness: whether the backend still holds this consumer exactly as
// the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return postgres.canConnectAs(p.as, p.as, p.password);
},
});
+1 -1
View File
@@ -13,7 +13,7 @@
"at": "acme-v02.api.letsencrypt.org",
"port": 443,
"path": "/directory",
"root": ""
"roots": ""
}
}
}
+22 -1
View File
@@ -8,7 +8,7 @@
// order requests were sent, which is what the queue below relies on.
import { createConnection, type Socket } from "node:net";
import { randomBytes } from "node:crypto";
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
/** A parsed RESP value. Errors are surfaced as rejected commands, not as this type. */
@@ -113,6 +113,27 @@ export class RedisClient {
await this.command("ACL", "DELUSER", username);
}
/**
* Whether an ACL user exists, is enabled, and accepts exactly this password. Read-only: it asks
* `ACL GETUSER`, which answers nil for an unknown user and otherwise a flat list of fields, among
* them `flags` and `passwords`, the latter as SHA-256 hex. This server keeps no ACL file, so its
* users live in memory and a restart forgets them. This is how the provisioner notices
* (novox/hq issue 120).
*/
async holdsAclUser(username: string, password: string): Promise<boolean> {
const reply = await this.command("ACL", "GETUSER", username);
if (!Array.isArray(reply)) return false;
const field = (name: string): RespValue | undefined => {
const i = reply.indexOf(name);
return i >= 0 ? reply[i + 1] : undefined;
};
const flags = field("flags");
const passwords = field("passwords");
if (!Array.isArray(flags) || !flags.includes("on")) return false;
if (!Array.isArray(passwords)) return false;
return passwords.includes(createHash("sha256").update(password).digest("hex"));
}
close(): void {
if (this.socket) {
this.socket.destroy();
+1 -1
View File
@@ -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",
+7
View File
@@ -43,4 +43,11 @@ runProvisioner("redis-cache", {
await redis.deleteAclUser(p.as);
await announce("module.redis.cache.deprovisioned", { username: p.as });
},
// This server keeps its ACL users in memory only, so a restart of it forgets every consumer while
// this provisioner keeps running. Asked every minute, so a forgotten user is made again instead
// of every consumer failing to authenticate in silence (novox/hq issue 120).
async holds(p: Provision): Promise<boolean> {
return redis.holdsAclUser(p.as, p.password);
},
});
+18
View File
@@ -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`);
@@ -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 () => {
+48 -9
View File
@@ -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",
+8
View File
@@ -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": [
+1 -1
View File
@@ -102,7 +102,7 @@
},
"provides": [
{
"name": "package-registry",
"name": "npm-package-registry",
"scope": "mesh"
}
],