diff --git a/sms-wa-bridge/files/build/Dockerfile b/sms-wa-bridge/files/build/Dockerfile new file mode 100644 index 0000000..d86d565 --- /dev/null +++ b/sms-wa-bridge/files/build/Dockerfile @@ -0,0 +1,21 @@ +FROM node:20-slim + +# Chromium deps for whatsapp-web.js (puppeteer) +RUN apt-get update && apt-get install -y --no-install-recommends \ + chromium ca-certificates fonts-liberation libasound2 libatk-bridge2.0-0 \ + libatk1.0-0 libcups2 libdbus-1-3 libdrm2 libgbm1 libgtk-3-0 libnspr4 \ + libnss3 libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 xdg-utils \ + wget xauth && rm -rf /var/lib/apt/lists/* + +ENV PUPPETEER_SKIP_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium +ENV NODE_ENV=production + +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev +COPY server.js rules.json entrypoint.sh ./ +RUN chmod +x entrypoint.sh + +EXPOSE 8080 +ENTRYPOINT ["./entrypoint.sh"] diff --git a/sms-wa-bridge/files/build/entrypoint.sh b/sms-wa-bridge/files/build/entrypoint.sh new file mode 100755 index 0000000..04ebb33 --- /dev/null +++ b/sms-wa-bridge/files/build/entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# Clear stale Chromium profile locks from previous container instances +# (hostname changes on recreate leave Singleton* behind and block startup) +rm -f /app/.wwebjs_auth/session/Singleton* 2>/dev/null +exec node server.js diff --git a/sms-wa-bridge/files/build/package.json b/sms-wa-bridge/files/build/package.json new file mode 100644 index 0000000..76c148f --- /dev/null +++ b/sms-wa-bridge/files/build/package.json @@ -0,0 +1,16 @@ +{ + "name": "sms-wa-bridge", + "version": "1.0.0", + "private": true, + "description": "SMS webhook -> routing rules -> WhatsApp sender. No LLM, no OpenClaw.", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "express": "^4.19.2", + "whatsapp-web.js": "^1.26.0", + "qrcode-terminal": "^0.12.0", + "qrcode": "^1.5.3" + } +} \ No newline at end of file diff --git a/sms-wa-bridge/files/build/rules.json b/sms-wa-bridge/files/build/rules.json new file mode 100644 index 0000000..f203e15 --- /dev/null +++ b/sms-wa-bridge/files/build/rules.json @@ -0,0 +1,21 @@ +{ + "comment": "SMS routing rules - reload from disk on every request. First matching route wins.", + "routes": [ + { + "name": "hdfc-otp", + "sender_contains": "hdfc", + "otp_only": true, + "targets": ["+918054923863", "+918054923862"] + }, + { + "name": "icici-otp", + "sender_contains": "icici", + "otp_only": true, + "targets": ["+918054923863", "+918054923862"] + } + ], + "fallback": { + "otp_only": true, + "targets": ["+919915020200"] + } +} diff --git a/sms-wa-bridge/files/build/server.js b/sms-wa-bridge/files/build/server.js new file mode 100644 index 0000000..efde766 --- /dev/null +++ b/sms-wa-bridge/files/build/server.js @@ -0,0 +1,142 @@ +const express = require("express"); +const fs = require("fs"); +const path = require("path"); +const { Client, LocalAuth } = require("whatsapp-web.js"); +const qrTerm = require("qrcode-terminal"); +const QRCode = require("qrcode"); + +const PORT = process.env.PORT || 8080; +const AUTH_SECRET = process.env.AUTH_SECRET || ""; +const LOG_FILE = process.env.LOG_FILE || path.join(__dirname, "bridge.log"); +const RULES_FILE = process.env.RULES_FILE || path.join(__dirname, "rules.json"); +const MAX_TEXT = 2000; + +if (!AUTH_SECRET) { + console.error("AUTH_SECRET env var is required"); + process.exit(1); +} + +function log(line) { + const ts = new Date().toISOString().replace("T", " ").slice(0, 19); + const full = ts + " | " + line; + console.log(full); + try { fs.appendFileSync(LOG_FILE, full + "\n"); } catch (e) {} +} + +// ---------- WhatsApp client (no LLM, no OpenClaw) ---------- +const client = new Client({ + authStrategy: new LocalAuth({ dataPath: path.join(__dirname, ".wwebjs_auth") }), + puppeteer: { + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"] + } +}); + +let waReady = false; +let lastQR = null; +const sendQueue = []; + +client.on("qr", s => { + lastQR = s; + console.log("\n=== SCAN THIS QR WITH WHATSAPP (Settings > Linked Devices) ==="); + console.log("=== or open /qr?auth= in a browser ===\n"); + qrTerm.generate(s, { small: true }); +}); +client.on("ready", () => { + waReady = true; + log("WhatsApp client ready"); + sendQueue.splice(0).forEach(fn => fn()); +}); +client.on("auth_failure", msg => { waReady = false; log("WhatsApp auth failure: " + msg); }); +client.on("disconnected", reason => { waReady = false; log("WhatsApp disconnected: " + reason); client.initialize(); }); + +function sendWA(target, msg) { + return new Promise(resolve => { + const run = async () => { + if (!waReady) { resolve(false); return; } + try { + const chatId = target.replace(/[^0-9]/g, "") + "@c.us"; + await client.sendMessage(chatId, msg); + resolve(true); + } catch (e) { + log("send error to " + target + ": " + e.message); + resolve(false); + } + }; + if (waReady) run(); else sendQueue.push(run); + }); +} + +client.initialize(); + +// ---------- Routing ---------- +function route(frm, text) { + const cfg = JSON.parse(fs.readFileSync(RULES_FILE, "utf8")); + const results = []; + for (const rule of cfg.routes || []) { + if (!frm.toLowerCase().includes(rule.sender_contains)) continue; + if (rule.otp_only && !/otp/i.test(text)) continue; + for (const t of rule.targets) results.push({ target: t, msg: "[" + rule.name + "] SMS from " + frm + ":\n" + text }); + return results; + } + const fb = cfg.fallback || {}; + if (fb.otp_only !== false && !/otp/i.test(text)) return results; + for (const t of fb.targets) results.push({ target: t, msg: "SMS from " + frm + ":\n" + text }); + return results; +} + +// ---------- HTTP ---------- +const app = express(); +app.use(express.text({ type: () => true, limit: "64kb" })); + +app.get("/health", (req, res) => { + res.json({ waReady, rules: fs.existsSync(RULES_FILE) ? "loaded" : "missing" }); +}); + +// Lenient JSON: escape raw control chars inside strings (MacroDroid sends real newlines) +function lenientParse(raw) { + let out = ""; + let inStr = false; + for (let i = 0; i < raw.length; i++) { + const c = raw[i]; + if (inStr && c === "\\") { out += c + raw[i + 1]; i++; continue; } + if (c === '"') { inStr = !inStr; out += c; continue; } + const code = c.charCodeAt(0); + if (inStr && code < 32) { + out += code === 10 ? "\\n" : code === 13 ? "\\r" : code === 9 ? "\\t" : "\\u" + code.toString(16).padStart(4, "0"); + continue; + } + out += c; + } + return JSON.parse(out); +} + +// QR as PNG for headless pairing: /qr?auth= +app.get("/qr", async (req, res) => { + if (req.query.auth !== AUTH_SECRET) return res.status(401).send("unauthorized"); + if (waReady) return res.status(200).send("already paired"); + if (!lastQR) return res.status(404).send("no QR yet - wait for the qr event"); + const png = await QRCode.toBuffer(lastQR, { width: 480 }); + res.type("png").send(png); +}); + +app.post("/sms", async (req, res) => { + if (req.get("X-Auth") !== AUTH_SECRET) return res.status(401).send("unauthorized"); + let body = {}; + try { body = lenientParse(req.body || "{}"); } + catch (e) { log("JSON parse failed: " + e.message); return res.status(400).send("bad json"); } + const frm = String(body.from || "unknown").slice(0, 32); + const text = String(body.text || "").slice(0, MAX_TEXT); + if (!text) { log(frm + " | EMPTY | dropped"); return res.status(400).send("empty"); } + + const jobs = route(frm, text); + if (!jobs.length) { log(frm + " | DROPPED (no match) | " + text.slice(0, 120)); return res.status(200).send("skipped"); } + + const results = await Promise.all(jobs.map(j => sendWA(j.target, j.msg))); + const ok = results.every(Boolean); + log(frm + " | " + (ok ? "sent->" + jobs.map(j => j.target).join(",") : "SEND FAILED") + " | " + text.slice(0, 120)); + res.status(ok ? 200 : 500).send(ok ? "sent x" + jobs.length : "send failed"); +}); + +app.listen(PORT, () => log("sms-wa-bridge listening on :" + PORT)); diff --git a/sms-wa-bridge/playbook.yml b/sms-wa-bridge/playbook.yml new file mode 100644 index 0000000..c0c6747 --- /dev/null +++ b/sms-wa-bridge/playbook.yml @@ -0,0 +1,59 @@ +--- +# sms-wa-bridge as a rootless Podman Quadlet unit on emily, behind the shared +# Caddy unit (caddy/playbook.yml). +# +# The app is SMS webhook -> routing rules -> WhatsApp, with no LLM in the path: +# an Android automation (MacroDroid) POSTs each SMS to https://bridge.tanshu.dev/sms +# with an X-Auth header, rules.json decides who gets it, and whatsapp-web.js +# sends it from a linked WhatsApp account. It was deployed on monoco as a plain +# `docker run` container built from the Dockerfile now vendored at +# files/build/ — see readme.md. +# +# Connects as the container user and runs everything as that user: the Quadlet +# units belong to it, so the connection's PAM session is what makes +# `systemctl --user` work without any environment juggling. +# +# gather_facts is required: podman_user and the home directory come from the +# user_id / user_dir facts. +# +# The rootless-Podman prerequisites (installing podman, enabling lingering, the +# low-port sysctl, creating the Quadlet directory) are deliberately NOT applied +# here. They are the shared task file at caddy/roles/podman/tasks/main.yaml, and +# a host that has already run caddy/playbook.yml has them: +# +# - name: Rootless Podman prerequisites +# ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/podman/tasks/main.yaml" +# +# The variables this play needs are duplicated at the top of vars/default.yml, so +# it stands on its own. (Applying the commented include above would also need +# podman_unprivileged_port_start, which lives in caddy/vars/default.yml.) +# +# The one prerequisite it *does* need beyond Caddy's is buildah, and only on the +# build path: `podman` Recommends it on Ubuntu, so caddy/roles/podman has already +# installed it. Nothing needs it when the image is loaded from monoco, which is +# the recommended route in readme.md. +# +# Run with: ansible-playbook -u tanshu sms-wa-bridge/playbook.yml +- name: Deploy sms-wa-bridge as a rootless Podman Quadlet + hosts: emily + gather_facts: true + vars_files: + - vars/default.yml + + pre_tasks: + # This must happen before the app unit is started, not after: the generated + # unit references {{ network_name }}.network, and Quadlet fails *unit + # generation* (so `systemctl --user start` reports "unit not found") when a + # referenced .network file does not exist yet. The same include writes the + # Caddy-side drop-in that attaches Caddy to this network. + # + # network_name is a play var, so the include can see it directly. + - name: Register the bridge's network with Caddy + ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml" + + roles: + # The app: the data tree and the two bind-mounted files, the environment + # file, the image (loaded or built), the Quadlet unit, start + verify. + - sms-wa-bridge + # Appends this site's block to the shared Caddyfile and reloads Caddy. + - caddy diff --git a/sms-wa-bridge/readme.md b/sms-wa-bridge/readme.md new file mode 100644 index 0000000..03b21c9 --- /dev/null +++ b/sms-wa-bridge/readme.md @@ -0,0 +1,466 @@ +# sms-wa-bridge (rootless Podman + Quadlet) + +Runs **sms-wa-bridge** on `emily` as a **rootless Podman** container managed by a +**systemd user unit** (Quadlet), behind the shared [rootless Caddy](../caddy/readme.md). +Replaces the `docker run` container on `monoco`. + +The service is SMS webhook → routing rules → WhatsApp, with no LLM and no +OpenClaw in the path: an Android automation (MacroDroid) POSTs every SMS to +`https://bridge.tanshu.dev/sms`, `rules.json` decides who should get it, and +`whatsapp-web.js` sends it from a WhatsApp account linked by QR code. The app +source lives in [files/build/](files/build/), and the public contract is +unchanged — same host, same path, same header, same body. + +Two things are worth knowing before anything else: + +- **The image exists in no registry.** It was built on monoco with + `docker build -t sms-wa-bridge`, so it has to be carried across with + `docker save | podman load` or rebuilt here. [Why](#there-is-no-registry-for-this-image) + also explains why reusing the loaded image is the safer of the two. +- **The state that matters is a Chromium profile**, not a database: + `wwebjs_auth/` holds the linked WhatsApp device, and it is the difference + between a cutover and asking somebody to scan a QR code again. See + [Migrating the data from monoco](#migrating-the-data-from-monoco). + +## Ordering + +1. [caddy/playbook.yml](../caddy/playbook.yml) — the reverse proxy, which owns + the Caddyfile this site appends to and is the one play that applies the + rootless-Podman prerequisites. +2. **The image and the data copy below — before the first run of this playbook.** + The app writes a fresh QR session into `~/podman-data/sms-wa-bridge` the moment + it starts, and a first start on an empty directory is a bridge that has to be + linked from scratch. +3. This playbook — **run it after the copy**, not before. + +## What the bridge serves + +| Endpoint | Auth | What it does | +|---|---|---| +| `POST /sms` | `X-Auth: ` | Body `{"from":"…","text":"…"}`. Routes it and sends WhatsApp messages; `200 sent xN`, `200 skipped` (no rule matched), `401`, `400`, or `500` if a send failed | +| `GET /health` | none | `{"waReady":bool,"rules":"loaded"\|"missing"}` | +| `GET /qr?auth=` | query param | The current pairing QR code as a PNG, for headless linking | + +Routing, from [files/build/rules.json](files/build/rules.json) — the **first** +matching route wins, and the file is re-read on every request, so edits apply +without a restart: + +| Route | Matches | Goes to | +|---|---|---| +| `hdfc-otp` | sender contains `hdfc` **and** text contains `otp` | two numbers | +| `icici-otp` | sender contains `icici` **and** text contains `otp` | two numbers | +| fallback | any other text containing `otp` | one number | +| — | everything else | dropped, and logged | + +## Layout + +``` +sms-wa-bridge/ +├── playbook.yml +├── readme.md +├── vars/default.yml +├── files/build/ # the image build context: Dockerfile, server.js, +│ # package.json, entrypoint.sh, rules.json +└── roles/ + ├── sms-wa-bridge/ # data tree, env file, image, Quadlet unit, verify + └── caddy/ # appends this site's block to the shared Caddyfile +``` + +| On the host | What | +|---|---| +| `~/.config/containers/systemd/sms-wa-bridge.container` | the generated unit (Ansible-templated) | +| `~/.config/containers/systemd/sms-wa-bridge.network` | the app network, written by `caddy/roles/register` | +| `~/.config/containers/systemd/caddy.container.d/50-sms-wa-bridge.conf` | attaches Caddy to that network | +| `~/podman-data/sms-wa-bridge/` | **everything the bridge owns** — see below | +| `~/podman-data/sms-wa-bridge-build/` | the copied build context, used only when the image is built here | + +and inside that data directory, the same four entries monoco has under +`/var/lib/sms-wa-bridge/`: + +| Entry | What | +|---|---| +| `wwebjs_auth/` | the linked-device session: a Chromium profile — 336 MB, and 1,700 of the tree's 1,707 files | +| `wwebjs_session/` | empty, and vestigial — unused by the current `whatsapp-web.js`, mounted so the tree keeps its shape | +| `rules.json` | routing rules, seeded once and then never overwritten | +| `bridge.log` | the app's own log, appended by the app | + +## How it works + +### There is no registry for this image + +monoco's image is tagged `sms-wa-bridge:latest` and lives only in its Docker +daemon. Two ways across, and the playbook finds either: + +```bash +# The recommended route: byte-identical, and it is the image the paired session +# was linked against. +set -o pipefail +ssh monoco 'sudo docker save sms-wa-bridge:latest' | ssh emily 'podman load' +``` + +A `.container` file cannot rely on short-name resolution, because there is no TTY +to answer podman's registry prompt at unit start. The unit therefore asks for +`localhost/sms-wa-bridge:latest` and sets `Pull=never`, so a missing image fails +loudly instead of turning into a Docker Hub lookup. + +`podman load` qualifies an unqualified docker tag against the *default* registry +rather than `localhost` — verified on emily with a real `docker save` round trip — +so the loaded image arrives as `docker.io/library/sms-wa-bridge:latest`. The play +re-tags it to the `localhost/...` name above, and the manual command in +[Migrating the data](#migrating-the-data-from-monoco) does the same. Skipping the +re-tag is not fatal: the play rebuilds the image instead. + +That rebuild is the other path — `podman build` from +[files/build/Dockerfile](files/build/Dockerfile), which the play does by itself +when no image is present under either name. It is slower (the Dockerfile installs +Chromium and its libraries) and it re-resolves the dependencies, which matters: + +> `package.json` pins `whatsapp-web.js` at `^1.26.0`. A rebuild can therefore +> install a newer client than the one running on monoco, and a client that has +> moved its session format will refuse the copied `wwebjs_auth/` and ask for a QR +> scan. That is the whole argument for loading the image rather than rebuilding +> one. Rebuild deliberately, with `-e rebuild_image=true`, and never as a +> surprise during a cutover. + +### Who runs as whom + +`UserNS=keep-id:uid=0,gid=0`: the container's root is `tanshu` on the host, and +nothing else is mapped. It is the same mapping [audiobookshelf](../audiobookshelf/readme.md) +and [caddy](../caddy/readme.md) use, and it is the right one here because the +image has no `USER` instruction — it runs as container-root throughout, and on +monoco everything under `/var/lib/sms-wa-bridge/` is `0 0` (checked with +`ls -ln`), which is exactly the root the app wrote as there. + +The consequence to internalise is on the migration side, not the deploy side: +**host uid 0 is not mapped into the container at all.** A `rules.json` or session +file copied with its original root ownership is unwritable from inside the +container — the app starts, then fails to write the session or append to the log, +which is the same class of failure [postgres/readme.md](../postgres/readme.md) +documents for `PGDATA`. So the copy uses `tar --no-same-owner`, and the play +asserts, before it starts anything, that nothing under the data directory belongs +to another user. + +### The mounts + +The four mounts are the Docker-era container's, unchanged in intent: + +``` +~/podman-data/sms-wa-bridge/wwebjs_auth -> /app/.wwebjs_auth +~/podman-data/sms-wa-bridge/wwebjs_session -> /app/.wwebjs_session +~/podman-data/sms-wa-bridge/rules.json -> /app/rules.json +~/podman-data/sms-wa-bridge/bridge.log -> /app/bridge.log +``` + +Two of them are **single files**, which is the trap worth knowing about: podman +creates a missing bind-mount source as a *directory*. With a directory where +`bridge.log` should be, every append fails; with one where `rules.json` should +be, the per-request re-read throws. So [roles/sms-wa-bridge](roles/sms-wa-bridge/tasks/main.yaml) +touches `bridge.log` and seeds `rules.json` before the unit starts — and seeds +`rules.json` with `force: false`, so the play never reverts a live edit. + +There is no `:Z` or `:z` on the mounts because there is no SELinux on these hosts +(Ubuntu, AppArmor); the other playbooks in this repository do the same. + +### Chromium under rootless Podman + +This is the classic rootless landmine, and `server.js` already defuses it: + +```js +args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"] +``` + +`--no-sandbox` removes the `CLONE_NEWUSER` requirement that a nested user +namespace would block, and `--disable-dev-shm-usage` removes the 64 MB `/dev/shm` +dependency. Confirmed by inspection in +[docs/podman-compat-audit.md](../docs/podman-compat-audit.md), "The Chromium +question, answered". The unit needs no extra capability, no bigger `/dev/shm`, +and no `--privileged` for this. + +The other Chromium wrinkle is already handled in the image's own +[entrypoint.sh](files/build/entrypoint.sh): it deletes +`/app/.wwebjs_auth/session/Singleton*` on every start. Those are Chromium profile +locks that name the *host* that created them, and a container recreate changes the +hostname — on monoco the three of them (`SingletonSocket`, `SingletonCookie`, +`SingletonLock`) are stale symlinks for exactly that reason. They travel with the +copy, and the entrypoint clears them. + +### What "healthy" means here + +The image ships no `HEALTHCHECK`, so the unit defines one and the play gates on +it. `/health` answers `200` whenever the HTTP server is up — which is **not** the +same as being useful. An unlinked bridge is a perfectly healthy process that +accepts every SMS and answers none of them: `server.js` queues the send until the +WhatsApp client emits `ready`, and nothing in it times out, so the POST stays open +and no message is delivered. So the play reads the payload back and reports it: + +``` +waReady=false, rules=loaded +``` + +and so does a browser: . `waReady` only turns +true once a linked WhatsApp device is connected — on a migration, from the copied +session, on its own and without a QR code being scanned anywhere. + +### Linking a device, if it comes to that + +The QR code is printed into the log and is also served as a PNG, which is what +makes linking possible over SSH: + +```bash +podman logs -f sms-wa-bridge # the QR, in the terminal +curl -sS 'https://bridge.tanshu.dev/qr?auth=' -o qr.png +``` + +Then WhatsApp → Settings → Linked Devices → Link a Device. **Two live copies +cannot share it**: the same linked device can only be connected from one place at +a time, and the second one to connect logs the first one out. + +## Settings + +| Variable | Purpose | +|---|---| +| `podman_user`, `podman_data_dir`, `quadlet_dir` | Rootless-Podman contract, duplicated from [caddy/vars/default.yml](../caddy/vars/default.yml) | +| `container_name` | Container, unit and network prefix — `sms-wa-bridge` | +| `host` | `bridge.tanshu.dev`, unchanged from the Docker era | +| `image` | `localhost/sms-wa-bridge:latest` — local, fully qualified, `Pull=never` | +| `image_docker_hub` | Where `podman load` puts an unqualified docker tag; the play re-tags it | +| `rebuild_image` | `false`. `-e rebuild_image=true` forces `podman build` from `files/build/` | +| `host_directory` | `~/podman-data/sms-wa-bridge` — all four mounts live under it | +| `build_dir` | `~/podman-data/sms-wa-bridge-build` — the copied build context | +| `container_port` | `8080`, the port `server.js` listens on; Caddy proxies to it | +| `auth_secret` | The `X-Auth` secret, carried over verbatim so MacroDroid needs only a URL change | +| `env_file` | `~/podman-data/sms-wa-bridge/sms-wa-bridge.env`, mode `0600`, holds `AUTH_SECRET` | +| `health_cmd` | The unit's `HealthCmd` — `wget` against `/health`, the only HTTP client the image has | +| `caddy_name`, `caddyfile_path`, `snippet_block` | The Caddy side, as in every service playbook | + +Keep the three `podman_*`/`quadlet_dir` vars in sync with +[caddy/vars/default.yml](../caddy/vars/default.yml) — they are duplicated rather +than imported because a play cannot include Caddy's vars file wholesale (it also +sets `host_directory`, which every service defines for itself). + +## Running + +```command +ansible-playbook -u tanshu sms-wa-bridge/playbook.yml +``` + +The play targets `emily` directly, so the inventory must contain it — the one in +`prepare-server/` does, and its `ansible.cfg` names it, so in practice: + +```command +cd prepare-server && ansible-playbook -u tanshu ../sms-wa-bridge/playbook.yml +``` + +This play applies **only** this app's unit, data directory, environment file and +image. The rootless-Podman prerequisites — installing podman, `loginctl +enable-linger`, the low-port sysctl and the Quadlet directory — are the shared +task file at [caddy/roles/podman/tasks/main.yaml](../caddy/roles/podman/tasks/main.yaml), +and are **not** run here. A host that has already run `caddy/playbook.yml` has +them. (`podman build` additionally needs buildah, which `podman` Recommends on +Ubuntu and that role therefore installed.) + +`systemctl --user enable sms-wa-bridge` is neither needed nor possible: the unit +is *generated*, and Quadlet applies `[Install] WantedBy=default.target` itself. + +`--check` is a real preview of this play, not a partial deploy: it reports the +data tree, the environment file, the image decision (the two `podman image +exists` probes run even under `--check`) and the unit, and it stops short of +starting anything. That is deliberate — see the notes on `check_mode: false` in +[roles/sms-wa-bridge](roles/sms-wa-bridge/tasks/main.yaml) — and it is how this +playbook was verified before it was first run for real. + +```command +cd prepare-server && ansible-playbook -u tanshu --check ../sms-wa-bridge/playbook.yml +``` + +## Migrating the data from monoco + +Run these from the machine that holds the `monoco` and `emily` SSH aliases. +Everything the bridge owns is a file: there is no database dump to take. + +What is there, and why it matters: + +| Source on monoco | Size | Keep? | +|---|---|---| +| `/var/lib/sms-wa-bridge/wwebjs_auth/` | 336 MB | **Yes** — the linked WhatsApp device | +| `/var/lib/sms-wa-bridge/wwebjs_session/` | empty | Harmless; carried so the tree matches | +| `/var/lib/sms-wa-bridge/rules.json` | 493 B | **Yes** — the live routing rules | +| `/var/lib/sms-wa-bridge/bridge.log` | 4 KB | Optional — log history only; the play creates an empty one if it is missing | + +Everything under it is `root:root`, and `wwebjs_auth/session` is mode `0700`, +which is why every read below needs `sudo` on monoco. + +```bash +# 0. Look before copying. `ls -ln` shows numeric owners for the reason above. +ssh monoco 'sudo du -sh /var/lib/sms-wa-bridge; sudo ls -ln /var/lib/sms-wa-bridge' + +# 1. Bring the image across, and normalise its name. Do this *before* the +# playbook runs, so the play finds the image and does not build one. +set -o pipefail +ssh monoco 'sudo docker save sms-wa-bridge:latest' | ssh emily 'podman load' +ssh emily 'podman tag docker.io/library/sms-wa-bridge:latest localhost/sms-wa-bridge:latest' +ssh emily 'podman image exists localhost/sms-wa-bridge:latest && echo "image ready"' + +# 2. Stop the old container so nothing writes while you copy — a live Chromium +# profile copy can be torn — and leave it stopped. emily is about to become +# the only live copy, and two copies behind the same linked device fight. +ssh monoco 'sudo docker stop sms-wa-bridge' + +# 3. Create the destination tree. The playbook creates this too, but the copy +# needs somewhere to land — and it must run *before* the playbook, because +# the app writes a fresh session here the moment it starts. +ssh emily 'mkdir -p ~/podman-data/sms-wa-bridge/wwebjs_auth ~/podman-data/sms-wa-bridge/wwebjs_session' + +# 4. Copy the tree. tar streams it across this workstation; rsync cannot copy +# remote-to-remote in one command. +# +# * the source needs sudo — everything there is root-owned, and the session +# directory is not even traversable without it; +# * --no-same-owner is what lands the files owned by tanshu, which is the only +# uid the container can write as (see "Who runs as whom"); +# * set -o pipefail matters: without it the pipeline reports only the +# *receiving* tar's status, so a read error or a dropped stream on monoco +# exits 0 and leaves a silently short copy. +set -o pipefail +ssh monoco 'sudo tar -C /var/lib/sms-wa-bridge -cf - .' \ + | ssh emily 'tar --no-same-owner -C ~/podman-data/sms-wa-bridge -xf -' + +# 5. Verify by content. Same relative paths on both sides, so the aggregate +# digests are comparable — and the hashing needs sudo on monoco too, because +# much of the Chromium profile is mode 0600. +ssh monoco 'cd /var/lib/sms-wa-bridge && sudo find . -type f -print0 | sort -z | sudo xargs -0 -r sha256sum | sha256sum' +ssh emily 'cd ~/podman-data/sms-wa-bridge && find . -type f -print0 | sort -z | xargs -0 -r sha256sum | sha256sum' +# identical digests = a byte-faithful copy +# (-type f skips the three stale Singleton* symlinks the entrypoint deletes +# anyway; 1707 files, so this takes a few seconds) + +# 6. Nothing may be root-owned on the far side: with keep-id:uid=0 the container +# can only write files owned by tanshu. Empty output is the correct answer, +# and the playbook asserts it before it starts anything. +ssh emily 'find ~/podman-data/sms-wa-bridge ! -user tanshu' + +# 7. Deploy — after the copy, so the app finds the linked session on first start. +cd prepare-server && ansible-playbook -u tanshu ../sms-wa-bridge/playbook.yml +``` + +If the link is slow or drops, do it in two hops instead: resumable, and it shows +progress. `--rsync-path="sudo rsync"` is what reaches the root-owned source, and +`--no-owner --no-group` is the rsync spelling of `--no-same-owner`. + +```bash +rsync -aP --no-owner --no-group --rsync-path="sudo rsync" \ + monoco:/var/lib/sms-wa-bridge/ ~/sms-wa-bridge-copy/ +rsync -aP --no-owner --no-group ~/sms-wa-bridge-copy/ \ + emily:~/podman-data/sms-wa-bridge/ +``` + +Rolling back means rolling back all of it: `ssh monoco 'sudo docker start +sms-wa-bridge'` is only meaningful while monoco still holds the session, and the +linked device can only be live in one place. **Never run both at once** — the +second one to connect logs the first one out, and the one that just lost the +device keeps accepting SMS without ever answering them. + +## Verification + +```bash +systemctl --user status sms-wa-bridge +journalctl --user -u sms-wa-bridge -f +podman logs sms-wa-bridge # the QR, once, if it is not linked + +# What the playbook checks for you: +podman healthcheck run sms-wa-bridge # /health over HTTP +find ~/podman-data/sms-wa-bridge ! -user tanshu # empty + +# The one thing health does not say — is WhatsApp actually linked? +podman exec sms-wa-bridge wget -q -O - http://127.0.0.1:8080/health +# {"waReady":true,"rules":"loaded"} + +# The state is where the host can see it, and the mounts are bind mounts: +podman inspect sms-wa-bridge --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' +ls -ln ~/podman-data/sms-wa-bridge + +# Running as tanshu on the host, as container-root inside — the keep-id mapping. +# `HUSER 1001` is tanshu on emily, and it is the uid every file the bridge writes +# will belong to: +podman top sms-wa-bridge huser,user,args | head -3 +``` + +Then end to end, which is the only check that proves the whole path — and it +**sends a real WhatsApp message**, so warn the recipients first: + +```bash +curl -sS https://bridge.tanshu.dev/health +curl -sS -X POST https://bridge.tanshu.dev/sms \ + -H 'X-Auth: 64f205031acc01744b6edc23' \ + -H 'Content-Type: application/json' \ + -d '{"from":"HDFC Bank","text":"Your OTP is 123456"}' +# sent x2 + +podman logs --tail 5 sms-wa-bridge # and the same line in bridge.log +``` + +Finally, reboot the host and confirm the unit returns unattended — without +`loginctl enable-linger tanshu` it will not, and the failure is silent. + +## Cutover + +One change on each phone: the MacroDroid HTTP action URL becomes +`https://bridge.tanshu.dev/sms`. The header (`X-Auth: 64f205031acc01744b6edc23`) +and the body (`{"from":"{sms_number}","text":"{sms_text}"}`) stay exactly as they +are — the goal of the whole arrangement is that nothing else has to be touched. + +`bridge.tanshu.dev` must resolve to emily before Caddy can obtain a certificate +for it (HTTP-01). If the address moves, check whether Caddy is serving the new +host with the certificate it already holds, or reissuing — see +[caddy/readme.md](../caddy/readme.md). + +## Notes and gotchas + +- **Only one live copy.** A linked WhatsApp device is a single session. Running + monoco's container and emily's unit at the same time logs one of them out, and + the logged-out side then accepts SMS and never answers them. This is the reason + the copy commands stop the old container and never start it again. +- **Rotating `AUTH_SECRET` is a client change**, not a server one: every phone + sending SMS has to be reconfigured, and an old header gets a `401`. It is + carried over verbatim for that reason, and it is committed in plaintext because + it always has been (the Docker-era container took it as an `-e` flag and the old + app readme printed it). +- **`latest` is not a version.** There is no digest pin anywhere in this + deployment, and no registry to pin against. `docker save`/`podman load` is what + makes the running version reproducible; a rebuild is a new dependency + resolution. +- **Logs do not migrate.** monoco's container logged to Docker's `json-file` + driver; `podman logs` and `journalctl --user -u sms-wa-bridge` replace it. + `bridge.log` is the app's own log and does travel, if you copy it. +- **Log timestamps are UTC**, always: the app formats them with + `new Date().toISOString()`, which ignores the timezone. There is deliberately no + `TZ` in the unit — it would change nothing. +- **`rules.json` is live state, not configuration.** The play seeds it once and + never overwrites it, so editing it on the host applies to the next SMS with no + restart. The copy in [files/build/](files/build/) is only the seed. +- **The `Singleton*` locks look alarming and are not.** They are stale symlinks + from the last host that ran Chromium; the image's entrypoint deletes them on + every start. +- **`whatsapp-web.js` is an unofficial client** linked to whichever WhatsApp + account scanned the QR, and it carries a small ban risk. Use a spare number if + that matters, and pick whose number the recipients should see — messages come + *from* the linked account. +- **A container recreate is a few seconds of downtime** for this site only: the + unit is restarted by a handler when the unit file, the environment file or the + image changes, and Caddy returns a connection error for SMS that arrive in that + window. A Caddyfile change, by contrast, is only a reload. +- **`/health` and `/qr` are not behind `X-Auth`** the way `/sms` is: `/health` + is public, and `/qr` is gated by the secret in the query string. That is the + Docker-era behaviour, kept as-is so the cutover stays a URL change. + +## Not done here + +- Rotating `AUTH_SECRET`, and moving the image to a registry so the deployment + can pin something. +- A systemd user timer for backups. The data directory is plain files owned by + `tanshu`, so a `tar` of `~/podman-data/sms-wa-bridge` is a complete backup — + but nothing schedules it here, and `bridge.log` grows. +- Adding this service to the shared migration notes in [docs/](../docs/): the + per-service verification list in + [docs/podman-compat-audit.md](../docs/podman-compat-audit.md) still describes + the bridge as a Docker container on monoco. diff --git a/sms-wa-bridge/roles/caddy/handlers/main.yaml b/sms-wa-bridge/roles/caddy/handlers/main.yaml new file mode 100644 index 0000000..9c4d17f --- /dev/null +++ b/sms-wa-bridge/roles/caddy/handlers/main.yaml @@ -0,0 +1,37 @@ +--- +# Caddy is its own playbook (caddy/playbook.yml) and a rootless Quadlet unit +# there. These handlers run as the connection user, so `systemctl --user` and +# `podman exec` work without any XDG_RUNTIME_DIR juggling. +# +# Restart Caddy carries `when: not ansible_check_mode` — unlike the other copies +# of this file in the repository, which predate the idea — so that a `--check` +# run of this playbook is a preview and not a partial deploy. The `command` +# handler below needs no guard: Ansible skips those under --check on its own. + +# Notified by this role when the Caddyfile changes. A reload keeps existing +# connections; a restart would briefly drop every hosted site. +# +# Note the config path: /etc/caddy/Caddyfile is the mount inside the container, +# not {{ caddyfile_path }}, which is the host side of it. +- name: Reload Caddy configuration + ansible.builtin.command: >- + podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile + changed_when: true + listen: "Reload Caddy" + +# Notified by caddy/roles/register when this service's network unit, or the +# drop-in that attaches Caddy to it, is written. Quadlet has no live equivalent +# of `docker network connect`, so the container has to be recreated — a brief +# restart of every hosted site, not just this one. See caddy/readme.md, +# "Trade-off worth knowing". +# +# Defined after the reload above so that when both are notified the cheap config +# reload happens first. +- name: Restart Caddy + ansible.builtin.systemd_service: + name: "{{ caddy_name }}.service" + scope: user + state: restarted + daemon_reload: true + when: not ansible_check_mode + listen: Restart Caddy diff --git a/sms-wa-bridge/roles/caddy/tasks/main.yaml b/sms-wa-bridge/roles/caddy/tasks/main.yaml new file mode 100644 index 0000000..ffbe0d8 --- /dev/null +++ b/sms-wa-bridge/roles/caddy/tasks/main.yaml @@ -0,0 +1,22 @@ +--- +# Append this site's block to the shared Caddyfile. +# +# The Caddyfile is a bind mount into the Caddy container, so Caddy sees the +# change as soon as the file is written; the handler only asks Caddy to re-read +# it. That is a reload, not a restart, so the other hosted sites are untouched. +# +# blockinfile is idempotent on its own via the marker, so there is no need to +# slurp the file and compare first — which also matters because slurp *fails* +# when the Caddyfile does not exist yet, on a host where the Caddy playbook has +# not run. + +- name: "Add the {{ host }} site block to the Caddyfile" + ansible.builtin.blockinfile: + path: "{{ caddyfile_path }}" + marker: "# {mark} Ansible managed Caddy snippet for {{ host }}" + block: "{{ snippet_block }}" + create: yes + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + notify: Reload Caddy diff --git a/sms-wa-bridge/roles/sms-wa-bridge/handlers/main.yaml b/sms-wa-bridge/roles/sms-wa-bridge/handlers/main.yaml new file mode 100644 index 0000000..8e9df2d --- /dev/null +++ b/sms-wa-bridge/roles/sms-wa-bridge/handlers/main.yaml @@ -0,0 +1,29 @@ +--- +# All handlers run as the connection user ({{ podman_user }}), so systemctl +# --user works without setting XDG_RUNTIME_DIR. +# +# The `systemd_service` handlers — this one and Caddy's, in roles/caddy — carry +# `when: not ansible_check_mode`: under --check the unit file was never written, +# so systemd cannot find the unit and a restart would fail rather than be +# skipped. The `command` handler below needs no guard: Ansible skips those under +# --check on its own. + +- name: Reload systemd user units + ansible.builtin.command: systemctl --user daemon-reload + changed_when: true + listen: Reload systemd user units + +# A changed unit file, a changed environment file, or a rebuilt image — all of +# them mean the app is recreated. Everything it owns is on the four bind mounts, +# so a recreate loses nothing: the linked-device session, the routing rules and +# the log all live on the host. It is a few seconds during which incoming SMS +# get a connection error from Caddy rather than a 200, which MacroDroid will +# retry or drop depending on how the action is configured. +- name: Restart sms-wa-bridge + ansible.builtin.systemd_service: + name: "{{ unit_name }}" + scope: user + state: restarted + daemon_reload: true + when: not ansible_check_mode + listen: Restart sms-wa-bridge diff --git a/sms-wa-bridge/roles/sms-wa-bridge/tasks/main.yaml b/sms-wa-bridge/roles/sms-wa-bridge/tasks/main.yaml new file mode 100644 index 0000000..0db2c76 --- /dev/null +++ b/sms-wa-bridge/roles/sms-wa-bridge/tasks/main.yaml @@ -0,0 +1,358 @@ +--- +# sms-wa-bridge as a rootless Podman Quadlet unit. +# +# Everything here runs as {{ podman_user }} — the connection user. No become, no +# XDG_RUNTIME_DIR: the SSH connection gave us a real PAM session, so systemd's +# user manager is reachable through the standard environment. + +# --------------------------------------------------------------------------- +# The data tree, and the two bind-mounted files +# --------------------------------------------------------------------------- +# The order matters here, and it is not housekeeping: all four mount sources in +# the unit have to exist before it starts. Podman creates a missing bind-mount +# source as a *directory*, so a missing bridge.log would come up as a directory +# where the app expects a file and every append to it would fail, and a missing +# rules.json would break the re-read that happens on every request. +# +# The copy in readme.md makes the two directories itself, because it has to run +# *before* this play. These tasks are what make a fresh install and a migrated +# one the same shape, and what guarantees the file sources exist on the day the +# unit is first started. +- name: Ensure the sms-wa-bridge data directory exists + ansible.builtin.file: + path: "{{ host_directory }}" + state: directory + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0755' + +- name: Ensure the directories the app writes into exist + ansible.builtin.file: + path: "{{ host_directory }}/{{ item }}" + state: directory + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0755' + loop: + # The paired WhatsApp session: the Chromium profile whatsapp-web.js logged + # in as. 336 MB on monoco, and not reproducible without scanning a QR code. + - wwebjs_auth + # Empty on monoco and unused by the current whatsapp-web.js, but mounted by + # the Docker-era container, so the migrated tree keeps its shape. + - wwebjs_session + +# The app's own log file, bind-mounted as a *file*. state=touch with preserve is +# what makes this idempotent: without the two preserve flags every run would +# bump the mtime of a log the app is appending to and report itself changed. +- name: Ensure the log file the app appends to exists + ansible.builtin.file: + path: "{{ host_directory }}/bridge.log" + state: touch + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + modification_time: preserve + access_time: preserve + +# Seeded from the image's own rules.json, and then never touched again: the +# app re-reads this file from disk on every request, so live edits are the +# normal way to work and an overwriting play would silently revert them. +# force=no means "only transfer when the destination does not exist". +# +# On a migration this task finds the file the copy in readme.md put there and +# leaves it alone — which is the point. +- name: Seed the routing rules if there are none yet + ansible.builtin.copy: + src: "{{ playbook_dir }}/files/build/rules.json" + dest: "{{ host_directory }}/rules.json" + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + force: false + +# The one failure the migration is most likely to arrive with, checked before +# anything expensive happens — and before the unit starts, because it cannot +# start usefully. +# +# With keep-id:uid=0 the app is container-root, which is {{ podman_user }} on the +# host. Host uid 0 is *not* mapped into the user namespace at all, so a +# root-owned file copied straight out of /var/lib/sms-wa-bridge (where everything +# is root-owned) is unwritable from inside the container: the app starts, then +# fails to write the session or append to the log. `tar --no-same-owner` in +# readme.md is what avoids it. +# +# Deliberately not `failed_when: false`: if `find` cannot read something in the +# tree, the tree is not provably clean, and that is the finding. +# +# Skipped under --check rather than run with `check_mode: false`: the directory +# above was not actually created in a check run, so `find` would fail on a +# missing path rather than answer the question. The assert below is written to +# tolerate that, and `default([])` is why. +- name: List anything in the data tree not owned by {{ podman_user }} + ansible.builtin.command: >- + find {{ host_directory }} ! -user {{ podman_user }} + register: sms_stray_owners + changed_when: false + when: not ansible_check_mode + +- name: Fail if the data tree holds files owned by another user + ansible.builtin.assert: + that: sms_stray_owners.stdout_lines | default([]) | length == 0 + fail_msg: >- + {{ sms_stray_owners.stdout_lines | default([]) | join(', ') }} — every file + under {{ host_directory }} must belong to {{ podman_user }}, because with + UserNS=keep-id:uid=0 the container's root *is* {{ podman_user }} and + nothing else is mapped. A root-owned file here came from a copy that + preserved the source ownership: re-run it with `tar --no-same-owner`, or + `chown -R {{ podman_user }}:{{ podman_user }} {{ host_directory }}` if the + copy was otherwise good. See readme.md, "Migrating the data from monoco". + +# --------------------------------------------------------------------------- +# The environment file (AUTH_SECRET) +# --------------------------------------------------------------------------- +# Consumed by podman through EnvironmentFile= in the Quadlet unit, not by +# Ansible, so it has to be written before the unit starts. 0600 because the unit +# that references it is world-readable and this is a shared secret. +- name: Write the environment file + ansible.builtin.template: + src: sms-wa-bridge.env.j2 + dest: "{{ env_file }}" + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0600' + notify: Restart sms-wa-bridge + +# --------------------------------------------------------------------------- +# The image +# --------------------------------------------------------------------------- +# The image exists in no registry, so it is either loaded from monoco +# (`docker save | podman load`, the recommended route — see readme.md) or built +# here from the vendored context. This looks for it under both names, re-tags a +# loaded one, and only then falls back to building. +# +# `check_mode: false` on the two `podman image exists` probes below is what makes +# `--check` an honest preview: a command task is normally skipped under --check, +# and a skipped probe would turn the build decision into a guess. They only read +# the host, so running them in a check run changes nothing. +- name: Check for the locally built image + ansible.builtin.command: "podman image exists {{ image }}" + register: sms_image_local + changed_when: false + failed_when: false + check_mode: false + +# `docker save` writes an unqualified RepoTags and podman load qualifies it +# against the default registry, so an image loaded from monoco lands as +# docker.io/library/sms-wa-bridge:latest rather than the localhost/... name the +# unit uses. Verified on emily with a real docker-archive round trip. +# +# Re-tagging rather than rebuilding is the point: the loaded image is the exact +# one whose WhatsApp session was paired. A rebuild re-resolves whatsapp-web.js +# (^1.26.0 in package.json), and a differently-versioned client can refuse that +# session and force a new QR scan. +- name: Re-tag an image that was loaded from monoco + when: (sms_image_local.rc | default(1)) != 0 + block: + - name: Check for the image under the name podman load gives it + ansible.builtin.command: "podman image exists {{ image_docker_hub }}" + register: sms_image_docker_hub + changed_when: false + failed_when: false + check_mode: false + + - name: Tag the loaded image as {{ image }} + ansible.builtin.command: "podman tag {{ image_docker_hub }} {{ image }}" + when: sms_image_docker_hub.rc == 0 + changed_when: true + notify: Restart sms-wa-bridge + +# One decision, made once, so the build context is only materialised on the host +# when something is actually going to read it. Set with `podman tag`/`podman +# build` staying check-mode aware: under --check they are skipped, while the two +# probes above really ran, so this reflects the host as it is. +- name: Decide whether the image has to be built + ansible.builtin.set_fact: + sms_build_image: >- + {{ ((sms_image_local.rc | default(1)) != 0 + and (sms_image_docker_hub.rc | default(1)) != 0) + or (rebuild_image | bool) }} + +# Only reached when no image is present under either name, or when the operator +# asked for a rebuild. `podman build` needs buildah, which `podman` Recommends on +# Ubuntu and caddy/roles/podman therefore installed. +- name: Build the image from the vendored Dockerfile + when: sms_build_image | bool + block: + # The context is copied rather than built from the repository working copy, + # so the host always builds from a fixed, tanshu-owned path — and so the + # files the running image came from are visible next to their data. + - name: Ensure the image build context directory exists + ansible.builtin.file: + path: "{{ build_dir }}" + state: directory + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0755' + + - name: Copy the image build context + ansible.builtin.copy: + src: "{{ playbook_dir }}/files/build/" + dest: "{{ build_dir }}/" + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + directory_mode: '0755' + mode: '0644' + + - name: Build the image + ansible.builtin.command: "podman build -t {{ image }} {{ build_dir }}" + changed_when: true + notify: Restart sms-wa-bridge + +# --------------------------------------------------------------------------- +# The unit +# --------------------------------------------------------------------------- +- name: Install the sms-wa-bridge Quadlet unit + ansible.builtin.template: + src: sms-wa-bridge.container.j2 + dest: "{{ quadlet_dir }}/{{ container_name }}.container" + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + notify: + - Reload systemd user units + - Restart sms-wa-bridge + +# Everything from here on inspects a *running* container, and none of it is +# meaningful under --check: the unit was never written, so systemd cannot find +# it. Guarding the runtime half rather than tolerating its failures is what makes +# `ansible-playbook --check` a usable preview of a deploy — it reports the files, +# templates and image decisions without touching the host. +- name: Start sms-wa-bridge + when: not ansible_check_mode + block: + - name: Start sms-wa-bridge + ansible.builtin.systemd_service: + name: "{{ unit_name }}" + scope: user + state: started + daemon_reload: true + rescue: + # systemd reports "control process exited with error code" and nothing else, + # which is useless for the usual failures (a missing network unit, an + # unwritable /app, an image that is not there). Pull the real reason out. + - name: Collect sms-wa-bridge diagnostics + ansible.builtin.command: "{{ item }}" + loop: + - "systemctl --user status {{ unit_name }} --no-pager" + - "journalctl --user -u {{ unit_name }} --no-pager -n 40" + - "podman logs --tail 40 {{ container_name }}" + register: sms_diag + changed_when: false + failed_when: false + + - name: Show sms-wa-bridge diagnostics + ansible.builtin.debug: + msg: "{{ sms_diag.results | map(attribute='stdout') | list }}" + + - name: Fail + ansible.builtin.fail: + msg: "sms-wa-bridge did not start — see the diagnostics above." + +# Quadlet honours [Install] WantedBy= when it generates the unit, so +# `systemctl --user enable` is neither needed nor possible on a generated unit. +- name: Verify sms-wa-bridge is running + ansible.builtin.command: "systemctl --user is-active {{ unit_name }}" + register: sms_active + changed_when: false + failed_when: sms_active.stdout != 'active' + when: not ansible_check_mode + +# `is-active` says the container is running; it does not say the app is serving. +# `Restart=always` means even a crash-looping container is reported active, and +# the image ships no HEALTHCHECK, so the unit defines one (see the template) and +# this runs exactly that command once, synchronously. +- name: Wait for sms-wa-bridge to pass its health check + ansible.builtin.command: "podman healthcheck run {{ container_name }}" + register: sms_health + until: sms_health.rc == 0 + retries: 24 + delay: 5 + changed_when: false + when: not ansible_check_mode + +# --------------------------------------------------------------------------- +# What a running container does not tell you +# --------------------------------------------------------------------------- +# The unit's health check proves the HTTP server is up. It does not prove +# WhatsApp is linked, and an unlinked bridge is the one failure here that is +# completely silent: the process is healthy, every SMS is accepted with a 200, +# and nothing is ever delivered. /health publishes exactly that as waReady, so +# read it back and say so out loud. +- name: Read the app's own health payload + ansible.builtin.command: >- + podman exec {{ container_name }} wget -q -O - + http://127.0.0.1:{{ container_port }}/health + register: sms_health_payload + changed_when: false + failed_when: false + when: not ansible_check_mode + +- name: Note the health payload + ansible.builtin.set_fact: + sms_health_json: "{{ sms_health_payload.stdout | from_json }}" + when: (sms_health_payload.rc | default(1)) == 0 + +# Reported, not asserted: an unpaired bridge is a legitimate state — a fresh +# install sits here until someone scans the QR code — and the deployment is +# still correct. Two tasks rather than one message with a conditional inside it, +# because the interesting case deserves its own sentence. +- name: Report what the bridge says about itself + ansible.builtin.debug: + msg: >- + waReady={{ sms_health_json.waReady | default(false) }}, + rules={{ sms_health_json.rules | default('unknown') }} — the HTTP server + answering, which is all the unit's health check proves. + when: sms_health_json is defined + +- name: Report an unlinked bridge + ansible.builtin.debug: + msg: >- + waReady=false is not a failure: the bridge is serving and waiting to be + linked to a WhatsApp account. Scan the QR at + https://{{ host }}/qr?auth= (it is a PNG), or watch it in + `podman logs -f {{ container_name }}`. On a migration this should turn true + by itself from the copied session — a fresh QR code appearing instead means + the copy did not land. See readme.md. + when: + - sms_health_json is defined + - not (sms_health_json.waReady | default(false)) + +- name: Report a linked bridge + ansible.builtin.debug: + msg: >- + waReady=true: a WhatsApp device is linked and the bridge can send. On a + migration that is the copied session being accepted — no QR code was + scanned, which is the point of copying wwebjs_auth/. + when: + - sms_health_json is defined + - sms_health_json.waReady | default(false) + +# The data half of the same question, asked from the host rather than from the +# app: wwebjs_auth/session is the Chromium profile the linked device lives in, and +# it is what the copy in readme.md puts there. Missing means the bridge will +# print a fresh QR code — correct on a brand-new host, and the sign that the +# copy has not run yet on a migration. +- name: Check whether a linked WhatsApp session was copied in + ansible.builtin.stat: + path: "{{ host_directory }}/wwebjs_auth/session" + register: sms_session + +- name: Report a data directory with no WhatsApp session in it + ansible.builtin.debug: + msg: >- + {{ host_directory }}/wwebjs_auth/session does not exist, so this bridge has + no linked device: it will show a QR code and drop SMS until one is scanned. + That is what a fresh install looks like — and it is what a migration looks + like when the copy from monoco has not run, or ran after this play. See + readme.md, "Migrating the data from monoco". + when: not sms_session.stat.exists diff --git a/sms-wa-bridge/roles/sms-wa-bridge/templates/sms-wa-bridge.container.j2 b/sms-wa-bridge/roles/sms-wa-bridge/templates/sms-wa-bridge.container.j2 new file mode 100644 index 0000000..654f89c --- /dev/null +++ b/sms-wa-bridge/roles/sms-wa-bridge/templates/sms-wa-bridge.container.j2 @@ -0,0 +1,102 @@ +# sms-wa-bridge — rootless Podman Quadlet unit. +# Managed by Ansible (sms-wa-bridge/roles/sms-wa-bridge). Do not edit by hand. +# +# Joins {{ network_name }}.network, written by caddy/roles/register — the same +# include that attaches Caddy to it. Caddy reaches the app by container name, so +# nothing is published on the host: the Docker-era container exposed 8080 only +# on its own network, and Caddy was the only thing that ever talked to it. + +[Unit] +Description=sms-wa-bridge (SMS webhook to WhatsApp) +After=network-online.target +Wants=network-online.target + +[Container] +ContainerName={{ container_name }} + +# There is no registry for this image: it was built on monoco with +# `docker build -t sms-wa-bridge` and comes across as +# `docker save | podman load`, or is built here from the vendored context in +# files/build/. Either way the name is a local one, fully qualified on purpose — +# podman resolves short names against unqualified-search-registries and there is +# no TTY to answer a prompt when a systemd unit starts. +# +# Pull=never is the other half of that: without it, a missing image becomes a +# Docker Hub lookup for docker.io/library/sms-wa-bridge, which fails slowly at +# unit start instead of loudly at deploy time. +Image={{ image }} +Pull=never + +# The image has no USER instruction and runs as container root throughout. That +# is not an assumption: on monoco every file under /var/lib/sms-wa-bridge is +# `0 0` (checked with `ls -ln`), which is the root the app wrote as there. +# +# Mapping container root onto {{ podman_user }} is what keeps the data tree +# owned by {{ podman_user }} on the host, so tar/rsync backups keep working and +# no subuid-owned file appears. It is also the reason the migration's copy must +# not preserve the source ownership: a host-root-owned rules.json or session +# file is *unwritable* from inside this container, because host uid 0 is not +# mapped into the user namespace at all. See readme.md, "Who runs as whom". +UserNS=keep-id:uid=0,gid=0 + +# AUTH_SECRET — the shared secret MacroDroid sends as `X-Auth` — lives here +# rather than in an Environment= line above, because this unit is world-readable +# in ~/.config (postgres/ and yamtrack/ keep their secrets the same way). Read +# by podman via --env-file, not by Ansible, so the file has to exist before this +# unit starts; roles/sms-wa-bridge writes it 0600 first. +EnvironmentFile={{ env_file }} + +Network={{ network_name }}.network + +# The app's state, and the four mounts the Docker-era container had. +# +# wwebjs_auth/ is the paired WhatsApp session — the Chromium profile +# whatsapp-web.js logged into — and it is the whole reason a migration is worth +# doing rather than scanning a fresh QR code. wwebjs_session/ is empty on monoco +# and unused by the current whatsapp-web.js; it is mounted so the migrated tree +# keeps its shape. +# +# rules.json and bridge.log are bind-mounted *files*, which is a rootless trap +# in its own right: podman creates a missing bind-mount source as a *directory*, +# so roles/sms-wa-bridge touches bridge.log and seeds rules.json before this unit +# ever starts. With a directory in bridge.log's place the app cannot append to +# it, and with a directory in rules.json's place every re-read of the rules +# throws. +Volume={{ host_directory }}/wwebjs_auth:/app/.wwebjs_auth +Volume={{ host_directory }}/wwebjs_session:/app/.wwebjs_session +Volume={{ host_directory }}/rules.json:/app/rules.json +Volume={{ host_directory }}/bridge.log:/app/bridge.log + +# The image ships no HEALTHCHECK, so the unit defines one and the play gates on +# it. /health is the app's own unauthenticated endpoint, and `wget` is what the +# runtime image has (the Dockerfile installs wget, not curl). +# +# It answers 200 whenever the HTTP server is up, so this proves the server is +# serving — not that WhatsApp is paired. waReady is in the /health *payload*: +# +# podman exec {{ container_name }} wget -q -O - http://127.0.0.1:{{ container_port }}/health +# +# which is what the play reports at the end, and what ties /health to reality +# here: an unpaired bridge is a perfectly healthy process that silently drops +# every SMS. +HealthCmd={{ health_cmd }} +HealthInterval=30s +HealthTimeout=5s +HealthRetries=3 +HealthStartPeriod=60s + +# Chromium is launched inside this container by whatsapp-web.js, and it needs +# nothing extra from this unit to survive rootless Podman: server.js already +# passes --no-sandbox (which removes the CLONE_NEWUSER requirement a nested user +# namespace would block) and --disable-dev-shm-usage (which removes the 64 MB +# /dev/shm dependency). Confirmed by inspection in +# docs/podman-compat-audit.md, "The Chromium question, answered". + +[Service] +Restart=always +# Launching Chromium and restoring the linked-device session before the HTTP +# server answers. +TimeoutStartSec=180 + +[Install] +WantedBy=default.target diff --git a/sms-wa-bridge/roles/sms-wa-bridge/templates/sms-wa-bridge.env.j2 b/sms-wa-bridge/roles/sms-wa-bridge/templates/sms-wa-bridge.env.j2 new file mode 100644 index 0000000..1611150 --- /dev/null +++ b/sms-wa-bridge/roles/sms-wa-bridge/templates/sms-wa-bridge.env.j2 @@ -0,0 +1,18 @@ +# Environment for {{ container_name }}.container. +# Managed by Ansible (sms-wa-bridge/roles/sms-wa-bridge). Do not edit by hand. +# +# Mode 0600 on purpose: the Quadlet unit that reads this file is world-readable +# in ~/.config. Note that `podman inspect` still prints the resulting +# environment to anyone who can run podman as this user. +# +# This is the environment the Docker-era container was given, minus the image's +# own PATH/NODE_*/PUPPETEER_* defaults (which are baked into the image) and the +# AUTH_SECRET, which is the only thing that had to move into a file. +# +# There is deliberately no TZ: the app timestamps its log with +# `new Date().toISOString()`, which ignores the timezone and is always UTC. + +# The shared secret MacroDroid sends as `X-Auth`. Carried over verbatim from the +# Docker-era container so the cutover is a URL change and nothing else — rotate +# it and every phone sending SMS has to be reconfigured. +AUTH_SECRET={{ auth_secret }} diff --git a/sms-wa-bridge/vars/default.yml b/sms-wa-bridge/vars/default.yml new file mode 100644 index 0000000..d477e81 --- /dev/null +++ b/sms-wa-bridge/vars/default.yml @@ -0,0 +1,113 @@ +--- +# --------------------------------------------------------------------------- +# Rootless Podman / Quadlet +# --------------------------------------------------------------------------- +# Duplicated on purpose from caddy/vars/default.yml: a play cannot include that +# file wholesale, because it also sets `host_directory` — to the Caddy data +# directory — and every playbook defines that for itself. Keep these three in +# sync with caddy/vars/default.yml. (Its fourth, podman_unprivileged_port_start, +# belongs to the podman tasks, which this play does not run: the bridge +# publishes nothing host-side.) +# +# podman_user is derived from the connection: this play carries no become, so +# the Quadlet units belong to whoever Ansible connects as. Requires gather_facts +# (set in playbook.yml). +podman_user: "{{ ansible_facts['user_id'] }}" +podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data" +quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd" + +# --------------------------------------------------------------------------- +# sms-wa-bridge +# --------------------------------------------------------------------------- +host: bridge.tanshu.dev + +container_name: sms-wa-bridge +unit_name: "{{ container_name }}.service" + +# The Quadlet file is .network, and caddy/roles/register pins the +# Podman network name to _net — so: sms-wa-bridge.network, +# sms-wa-bridge_net, which is the name the Docker-era deployment used. +network_name: "{{ container_name }}" + +# There is no registry: the image was built locally on monoco with +# `docker build -t sms-wa-bridge` and exists nowhere else. Two ways to get it +# onto emily, and the playbook supports both: +# +# * `docker save sms-wa-bridge:latest | podman load` — the recommended route, +# and the one readme.md documents. It carries over the *exact* image whose +# WhatsApp session was paired; a rebuild re-resolves whatsapp-web.js +# (^1.26.0 in package.json), and a differently-versioned client can refuse +# that session and force a new QR scan. +# * `podman build` from the vendored context in files/build/ — what the play +# does by itself when no image is present, so a host with no monoco still +# gets a working deployment. +# +# Fully qualified either way: rootless Podman resolves short names against +# unqualified-search-registries, and there is no TTY to answer a prompt when a +# systemd unit starts. The unit also sets Pull=never, so an image that is not +# there fails loudly instead of silently becoming a Docker Hub lookup. +image: localhost/sms-wa-bridge:latest + +# What `podman load` actually calls a docker-saved unqualified tag: it qualifies +# it against the default registry instead of `localhost`. Verified on emily with +# a real `docker save` round trip. The play re-tags this name to `image` above, +# so loading the image before the play is enough — see readme.md. +image_docker_hub: docker.io/library/sms-wa-bridge:latest + +# `podman build` is skipped when the image is already present. Set this to true +# to rebuild from files/build/ anyway, e.g. after editing server.js: +# +# ansible-playbook -u tanshu ../sms-wa-bridge/playbook.yml -e rebuild_image=true +rebuild_image: false + +# The whole of this service's state, and a faithful analogue of monoco's +# /var/lib/sms-wa-bridge — same four entries, same layout, so the tar in +# readme.md lands as-is: +# +# wwebjs_auth/ the paired WhatsApp session (336 MB of the 336 MB tree) +# wwebjs_session/ empty, and vestigial; carried over to keep the shape +# rules.json routing rules, re-read from disk on every request +# bridge.log the app's own log file +host_directory: "{{ podman_data_dir }}/{{ container_name }}" + +# The image build context, kept out of host_directory so the data tree stays a +# faithful copy of the old one. Only used when the play builds the image. +build_dir: "{{ podman_data_dir }}/{{ container_name }}-build" + +# The port server.js listens on inside the container (PORT's default). Caddy +# proxies to it; nothing is published on the host, exactly as on monoco. +container_port: 8080 + +# The shared secret MacroDroid sends as `X-Auth`, carried over verbatim from the +# Docker-era container so the cutover is a URL change and nothing else. It is +# committed in plaintext because it always has been (the old app readme had it, +# and the Docker-era container took it as an -e flag); the unit keeps it out of +# its own world-readable file via EnvironmentFile=. +auth_secret: "64f205031acc01744b6edc23" + +# AUTH_SECRET lives here rather than as an Environment= line in the unit: that +# file is world-readable in ~/.config. Read by podman (--env-file), not by +# Ansible, so it has to be written before the unit starts. +env_file: "{{ host_directory }}/{{ container_name }}.env" + +# The image ships no HEALTHCHECK, so the unit defines one and the play gates on +# it. /health is the app's own endpoint and `wget` is what the runtime image has +# — the Dockerfile installs wget, not curl. It answers 200 whenever the HTTP +# server is up, so this proves the server is serving; it does *not* prove +# WhatsApp is paired. That is what the /health *payload* shows, and the play +# reports it. +health_cmd: "wget -q -O /dev/null http://127.0.0.1:{{ container_port }}/health" + +# --------------------------------------------------------------------------- +# The shared Caddy unit and its Caddyfile, both owned by caddy/playbook.yml +# --------------------------------------------------------------------------- +caddy_name: caddy +caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile" + +# The same two lines monoco's Caddyfile has had since the bridge was deployed +# (`bridge.tanshu.dev -> sms-wa-bridge:8080`), so nothing about the public +# contract changes but the host it runs on. +snippet_block: | + {{ host }} { + reverse_proxy {{ container_name }}:{{ container_port }} + }