Add the sms-wa-bridge playbook
This commit is contained in:
1 parent
d248675643
commit
26a0654fea
14 files changed
+1409
No files matched your search
@@ -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"]
|
||||||
Executable
+5
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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=<AUTH_SECRET> 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=<AUTH_SECRET>
|
||||||
|
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));
|
||||||
@@ -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
|
||||||
@@ -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: <AUTH_SECRET>` | 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=<AUTH_SECRET>` | 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: <https://bridge.tanshu.dev/health>. `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=<AUTH_SECRET>' -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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=<AUTH_SECRET> (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
|
||||||
@@ -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
|
||||||
@@ -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 }}
|
||||||
@@ -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_name>.network, and caddy/roles/register pins the
|
||||||
|
# Podman network name to <network_name>_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 }}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user