From ed5d1386cea4d25d77100a7c63aa1b8b69afe809 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 22:39:29 +0200 Subject: [PATCH] mailu: the manifest matches the machine, provides smtp, and carries automx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five gaps between the draft and what actually runs, each verified live before being written down: - front published bare 80 — the machine port Traefik holds; now the predecessor's own mappings (7080:80, 7443:443) plus the 110/143/995 parity ports the draft dropped. Pruning legacy protocols is its own deliberate change, not a cutover side effect. - TLS_FLAVOR said cert, which nothing supplies; live is letsencrypt — mailu runs its own certbot, state already on disk, HTTP-01 answered through a path-scoped route contribution (priority above the web one). - the web route said http:7080, the redirect-loop shape; it now says what the hand-authored file always knew: https 7443, insecure. - automx was absent entirely: the autoconfig responder is now a second artifact (its Containerfile moved in from the predecessor's images dir, base declared per ADR 0097), a container on a real data dir — the anonymous-volume loss of 2026-08-10 stays fixed — and the three public names are route contributions. - and the reason this moved ahead of de-spiegel: mailu now provides smtp. A consumer contributes the account it sends as; the provisioner creates @ via the admin API and applies the minted password every reconcile (ADR 0048). The domain is served on the binding so a consumer composes its own login from mesh facts. route-adapter learns to say no: a contribution over https, scoped to a path, or carrying a policy is skipped aloud rather than written into a file shape that cannot say it — plain http into a TLS listener was the concrete wrong file this prevents. The hand-authored files keep covering those routes until the mesh's own proxy takes over, exactly as today. --- modules/mailu/Dockerfile | 4 +- modules/mailu/automx/Dockerfile | 32 +++++ modules/mailu/automx/files/add-domains | 49 ++++++++ modules/mailu/automx/files/automx2.conf | 21 ++++ modules/mailu/automx/files/setup | 12 ++ modules/mailu/automx/files/setup-db | 83 +++++++++++++ modules/mailu/automx/files/setupvenv.sh | 38 ++++++ modules/mailu/automx/files/start | 8 ++ modules/mailu/module.json | 131 +++++++++++++++++++-- modules/mailu/provisioner/index.ts | 65 ++++++++++ modules/route-adapter/adapter.ts | 18 +++ modules/route-adapter/test/adapter.test.ts | 21 ++++ 12 files changed, 473 insertions(+), 9 deletions(-) create mode 100644 modules/mailu/automx/Dockerfile create mode 100644 modules/mailu/automx/files/add-domains create mode 100644 modules/mailu/automx/files/automx2.conf create mode 100644 modules/mailu/automx/files/setup create mode 100644 modules/mailu/automx/files/setup-db create mode 100644 modules/mailu/automx/files/setupvenv.sh create mode 100644 modules/mailu/automx/files/start create mode 100644 modules/mailu/provisioner/index.ts 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..7fd7933 --- /dev/null +++ b/modules/mailu/automx/Dockerfile @@ -0,0 +1,32 @@ +# 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 + +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/module.json b/modules/mailu/module.json index b04ce66..7aa4793 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": "DOMAIN=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\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" }, { "id": "secret-env", @@ -365,10 +432,14 @@ ], "ports": [ "25", + "110", + "143", "465", "587", "993", - "80" + "995", + "7080:80", + "7443:443" ], "volumes": [ "/services/mailu/data/certs:/certs", @@ -391,6 +462,7 @@ "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" ], @@ -399,12 +471,30 @@ "MESH_MAILU_URL": "http://mailu-admin/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 +508,10 @@ "arg": "RUNTIME_BASE", "module": "mesh-tools", "artifact": "runtime" + }, + { + "arg": "PYTHON_BASE", + "image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228" } ], "artifacts": [ @@ -425,7 +519,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/provisioner/index.ts b/modules/mailu/provisioner/index.ts new file mode 100644 index 0000000..42ef44d --- /dev/null +++ b/modules/mailu/provisioner/index.ts @@ -0,0 +1,65 @@ +// 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.changePassword(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(() => {}); + }, +}); 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 () => {