Add the sms-wa-bridge playbook

This commit is contained in:
tanshu committed 2026-10-18 21:35:53 +05:30
1 parent d248675643
commit 26a0654fea
14 files changed
+1409

No files matched your search

+466
View File
@@ -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.