mailu: the manifest matches the machine, provides smtp, and carries automx #73
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/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,
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+123
-6
@@ -14,8 +14,30 @@
|
||||
"name": "mailu"
|
||||
},
|
||||
"route": {
|
||||
"web": {
|
||||
"label": "mail",
|
||||
"port": 7080
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 `<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.changePassword(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(() => {});
|
||||
},
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user