# sms-wa-bridge (rootless Podman + Quadlet) Runs **sms-wa-bridge** on `emily` as a **rootless Podman** container managed by a **systemd user unit** (Quadlet), behind the shared [rootless Caddy](../caddy/readme.md). Replaces the `docker run` container on `monoco`. The service is SMS webhook → routing rules → WhatsApp, with no LLM and no OpenClaw in the path: an Android automation (MacroDroid) POSTs every SMS to `https://bridge.tanshu.dev/sms`, `rules.json` decides who should get it, and `whatsapp-web.js` sends it from a WhatsApp account linked by QR code. The app source lives in [files/build/](files/build/), and the public contract is unchanged — same host, same path, same header, same body. Two things are worth knowing before anything else: - **The image exists in no registry.** It was built on monoco with `docker build -t sms-wa-bridge`, so it has to be carried across with `docker save | podman load` or rebuilt here. [Why](#there-is-no-registry-for-this-image) also explains why reusing the loaded image is the safer of the two. - **The state that matters is a Chromium profile**, not a database: `wwebjs_auth/` holds the linked WhatsApp device, and it is the difference between a cutover and asking somebody to scan a QR code again. See [Migrating the data from monoco](#migrating-the-data-from-monoco). ## Ordering 1. [caddy/playbook.yml](../caddy/playbook.yml) — the reverse proxy, which owns the Caddyfile this site appends to and is the one play that applies the rootless-Podman prerequisites. 2. **The image and the data copy below — before the first run of this playbook.** The app writes a fresh QR session into `~/podman-data/sms-wa-bridge` the moment it starts, and a first start on an empty directory is a bridge that has to be linked from scratch. 3. This playbook — **run it after the copy**, not before. ## What the bridge serves | Endpoint | Auth | What it does | |---|---|---| | `POST /sms` | `X-Auth: ` | Body `{"from":"…","text":"…"}`. Routes it and sends WhatsApp messages; `200 sent xN`, `200 skipped` (no rule matched), `401`, `400`, or `500` if a send failed | | `GET /health` | none | `{"waReady":bool,"rules":"loaded"\|"missing"}` | | `GET /qr?auth=` | query param | The current pairing QR code as a PNG, for headless linking | Routing, from [files/build/rules.json](files/build/rules.json) — the **first** matching route wins, and the file is re-read on every request, so edits apply without a restart: | Route | Matches | Goes to | |---|---|---| | `hdfc-otp` | sender contains `hdfc` **and** text contains `otp` | two numbers | | `icici-otp` | sender contains `icici` **and** text contains `otp` | two numbers | | fallback | any other text containing `otp` | one number | | — | everything else | dropped, and logged | ## Layout ``` sms-wa-bridge/ ├── playbook.yml ├── readme.md ├── vars/default.yml ├── files/build/ # the image build context: Dockerfile, server.js, │ # package.json, entrypoint.sh, rules.json └── roles/ ├── sms-wa-bridge/ # data tree, env file, image, Quadlet unit, verify └── caddy/ # appends this site's block to the shared Caddyfile ``` | On the host | What | |---|---| | `~/.config/containers/systemd/sms-wa-bridge.container` | the generated unit (Ansible-templated) | | `~/.config/containers/systemd/sms-wa-bridge.network` | the app network, written by `caddy/roles/register` | | `~/.config/containers/systemd/caddy.container.d/50-sms-wa-bridge.conf` | attaches Caddy to that network | | `~/podman-data/sms-wa-bridge/` | **everything the bridge owns** — see below | | `~/podman-data/sms-wa-bridge-build/` | the copied build context, used only when the image is built here | and inside that data directory, the same four entries monoco has under `/var/lib/sms-wa-bridge/`: | Entry | What | |---|---| | `wwebjs_auth/` | the linked-device session: a Chromium profile — 336 MB, and 1,700 of the tree's 1,707 files | | `wwebjs_session/` | empty, and vestigial — unused by the current `whatsapp-web.js`, mounted so the tree keeps its shape | | `rules.json` | routing rules, seeded once and then never overwritten | | `bridge.log` | the app's own log, appended by the app | ## How it works ### There is no registry for this image monoco's image is tagged `sms-wa-bridge:latest` and lives only in its Docker daemon. Two ways across, and the playbook finds either: ```bash # The recommended route: byte-identical, and it is the image the paired session # was linked against. set -o pipefail ssh monoco 'sudo docker save sms-wa-bridge:latest' | ssh emily 'podman load' ``` A `.container` file cannot rely on short-name resolution, because there is no TTY to answer podman's registry prompt at unit start. The unit therefore asks for `localhost/sms-wa-bridge:latest` and sets `Pull=never`, so a missing image fails loudly instead of turning into a Docker Hub lookup. `podman load` qualifies an unqualified docker tag against the *default* registry rather than `localhost` — verified on emily with a real `docker save` round trip — so the loaded image arrives as `docker.io/library/sms-wa-bridge:latest`. The play re-tags it to the `localhost/...` name above, and the manual command in [Migrating the data](#migrating-the-data-from-monoco) does the same. Skipping the re-tag is not fatal: the play rebuilds the image instead. That rebuild is the other path — `podman build` from [files/build/Dockerfile](files/build/Dockerfile), which the play does by itself when no image is present under either name. It is slower (the Dockerfile installs Chromium and its libraries) and it re-resolves the dependencies, which matters: > `package.json` pins `whatsapp-web.js` at `^1.26.0`. A rebuild can therefore > install a newer client than the one running on monoco, and a client that has > moved its session format will refuse the copied `wwebjs_auth/` and ask for a QR > scan. That is the whole argument for loading the image rather than rebuilding > one. Rebuild deliberately, with `-e rebuild_image=true`, and never as a > surprise during a cutover. ### Who runs as whom `UserNS=keep-id:uid=0,gid=0`: the container's root is `tanshu` on the host, and nothing else is mapped. It is the same mapping [audiobookshelf](../audiobookshelf/readme.md) and [caddy](../caddy/readme.md) use, and it is the right one here because the image has no `USER` instruction — it runs as container-root throughout, and on monoco everything under `/var/lib/sms-wa-bridge/` is `0 0` (checked with `ls -ln`), which is exactly the root the app wrote as there. The consequence to internalise is on the migration side, not the deploy side: **host uid 0 is not mapped into the container at all.** A `rules.json` or session file copied with its original root ownership is unwritable from inside the container — the app starts, then fails to write the session or append to the log, which is the same class of failure [postgres/readme.md](../postgres/readme.md) documents for `PGDATA`. So the copy uses `tar --no-same-owner`, and the play asserts, before it starts anything, that nothing under the data directory belongs to another user. ### The mounts The four mounts are the Docker-era container's, unchanged in intent: ``` ~/podman-data/sms-wa-bridge/wwebjs_auth -> /app/.wwebjs_auth ~/podman-data/sms-wa-bridge/wwebjs_session -> /app/.wwebjs_session ~/podman-data/sms-wa-bridge/rules.json -> /app/rules.json ~/podman-data/sms-wa-bridge/bridge.log -> /app/bridge.log ``` Two of them are **single files**, which is the trap worth knowing about: podman creates a missing bind-mount source as a *directory*. With a directory where `bridge.log` should be, every append fails; with one where `rules.json` should be, the per-request re-read throws. So [roles/sms-wa-bridge](roles/sms-wa-bridge/tasks/main.yaml) touches `bridge.log` and seeds `rules.json` before the unit starts — and seeds `rules.json` with `force: false`, so the play never reverts a live edit. There is no `:Z` or `:z` on the mounts because there is no SELinux on these hosts (Ubuntu, AppArmor); the other playbooks in this repository do the same. ### Chromium under rootless Podman This is the classic rootless landmine, and `server.js` already defuses it: ```js args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"] ``` `--no-sandbox` removes the `CLONE_NEWUSER` requirement that a nested user namespace would block, and `--disable-dev-shm-usage` removes the 64 MB `/dev/shm` dependency. Confirmed by inspection in [docs/podman-compat-audit.md](../docs/podman-compat-audit.md), "The Chromium question, answered". The unit needs no extra capability, no bigger `/dev/shm`, and no `--privileged` for this. The other Chromium wrinkle is already handled in the image's own [entrypoint.sh](files/build/entrypoint.sh): it deletes `/app/.wwebjs_auth/session/Singleton*` on every start. Those are Chromium profile locks that name the *host* that created them, and a container recreate changes the hostname — on monoco the three of them (`SingletonSocket`, `SingletonCookie`, `SingletonLock`) are stale symlinks for exactly that reason. They travel with the copy, and the entrypoint clears them. ### What "healthy" means here The image ships no `HEALTHCHECK`, so the unit defines one and the play gates on it. `/health` answers `200` whenever the HTTP server is up — which is **not** the same as being useful. An unlinked bridge is a perfectly healthy process that accepts every SMS and answers none of them: `server.js` queues the send until the WhatsApp client emits `ready`, and nothing in it times out, so the POST stays open and no message is delivered. So the play reads the payload back and reports it: ``` waReady=false, rules=loaded ``` and so does a browser: . `waReady` only turns true once a linked WhatsApp device is connected — on a migration, from the copied session, on its own and without a QR code being scanned anywhere. ### Linking a device, if it comes to that The QR code is printed into the log and is also served as a PNG, which is what makes linking possible over SSH: ```bash podman logs -f sms-wa-bridge # the QR, in the terminal curl -sS 'https://bridge.tanshu.dev/qr?auth=' -o qr.png ``` Then WhatsApp → Settings → Linked Devices → Link a Device. **Two live copies cannot share it**: the same linked device can only be connected from one place at a time, and the second one to connect logs the first one out. ## Settings | Variable | Purpose | |---|---| | `podman_user`, `podman_data_dir`, `quadlet_dir` | Rootless-Podman contract, duplicated from [caddy/vars/default.yml](../caddy/vars/default.yml) | | `container_name` | Container, unit and network prefix — `sms-wa-bridge` | | `host` | `bridge.tanshu.dev`, unchanged from the Docker era | | `image` | `localhost/sms-wa-bridge:latest` — local, fully qualified, `Pull=never` | | `image_docker_hub` | Where `podman load` puts an unqualified docker tag; the play re-tags it | | `rebuild_image` | `false`. `-e rebuild_image=true` forces `podman build` from `files/build/` | | `host_directory` | `~/podman-data/sms-wa-bridge` — all four mounts live under it | | `build_dir` | `~/podman-data/sms-wa-bridge-build` — the copied build context | | `container_port` | `8080`, the port `server.js` listens on; Caddy proxies to it | | `auth_secret` | The `X-Auth` secret, carried over verbatim so MacroDroid needs only a URL change | | `env_file` | `~/podman-data/sms-wa-bridge/sms-wa-bridge.env`, mode `0600`, holds `AUTH_SECRET` | | `health_cmd` | The unit's `HealthCmd` — `wget` against `/health`, the only HTTP client the image has | | `caddy_name`, `caddyfile_path`, `snippet_block` | The Caddy side, as in every service playbook | Keep the three `podman_*`/`quadlet_dir` vars in sync with [caddy/vars/default.yml](../caddy/vars/default.yml) — they are duplicated rather than imported because a play cannot include Caddy's vars file wholesale (it also sets `host_directory`, which every service defines for itself). ## Running ```command ansible-playbook -u tanshu sms-wa-bridge/playbook.yml ``` The play targets `emily` directly, so the inventory must contain it — the one in `prepare-server/` does, and its `ansible.cfg` names it, so in practice: ```command cd prepare-server && ansible-playbook -u tanshu ../sms-wa-bridge/playbook.yml ``` This play applies **only** this app's unit, data directory, environment file and image. The rootless-Podman prerequisites — installing podman, `loginctl enable-linger`, the low-port sysctl and the Quadlet directory — are the shared task file at [caddy/roles/podman/tasks/main.yaml](../caddy/roles/podman/tasks/main.yaml), and are **not** run here. A host that has already run `caddy/playbook.yml` has them. (`podman build` additionally needs buildah, which `podman` Recommends on Ubuntu and that role therefore installed.) `systemctl --user enable sms-wa-bridge` is neither needed nor possible: the unit is *generated*, and Quadlet applies `[Install] WantedBy=default.target` itself. `--check` is a real preview of this play, not a partial deploy: it reports the data tree, the environment file, the image decision (the two `podman image exists` probes run even under `--check`) and the unit, and it stops short of starting anything. That is deliberate — see the notes on `check_mode: false` in [roles/sms-wa-bridge](roles/sms-wa-bridge/tasks/main.yaml) — and it is how this playbook was verified before it was first run for real. ```command cd prepare-server && ansible-playbook -u tanshu --check ../sms-wa-bridge/playbook.yml ``` ## Migrating the data from monoco Run these from the machine that holds the `monoco` and `emily` SSH aliases. Everything the bridge owns is a file: there is no database dump to take. What is there, and why it matters: | Source on monoco | Size | Keep? | |---|---|---| | `/var/lib/sms-wa-bridge/wwebjs_auth/` | 336 MB | **Yes** — the linked WhatsApp device | | `/var/lib/sms-wa-bridge/wwebjs_session/` | empty | Harmless; carried so the tree matches | | `/var/lib/sms-wa-bridge/rules.json` | 493 B | **Yes** — the live routing rules | | `/var/lib/sms-wa-bridge/bridge.log` | 4 KB | Optional — log history only; the play creates an empty one if it is missing | Everything under it is `root:root`, and `wwebjs_auth/session` is mode `0700`, which is why every read below needs `sudo` on monoco. ```bash # 0. Look before copying. `ls -ln` shows numeric owners for the reason above. ssh monoco 'sudo du -sh /var/lib/sms-wa-bridge; sudo ls -ln /var/lib/sms-wa-bridge' # 1. Bring the image across, and normalise its name. Do this *before* the # playbook runs, so the play finds the image and does not build one. set -o pipefail ssh monoco 'sudo docker save sms-wa-bridge:latest' | ssh emily 'podman load' ssh emily 'podman tag docker.io/library/sms-wa-bridge:latest localhost/sms-wa-bridge:latest' ssh emily 'podman image exists localhost/sms-wa-bridge:latest && echo "image ready"' # 2. Stop the old container so nothing writes while you copy — a live Chromium # profile copy can be torn — and leave it stopped. emily is about to become # the only live copy, and two copies behind the same linked device fight. ssh monoco 'sudo docker stop sms-wa-bridge' # 3. Create the destination tree. The playbook creates this too, but the copy # needs somewhere to land — and it must run *before* the playbook, because # the app writes a fresh session here the moment it starts. ssh emily 'mkdir -p ~/podman-data/sms-wa-bridge/wwebjs_auth ~/podman-data/sms-wa-bridge/wwebjs_session' # 4. Copy the tree. tar streams it across this workstation; rsync cannot copy # remote-to-remote in one command. # # * the source needs sudo — everything there is root-owned, and the session # directory is not even traversable without it; # * --no-same-owner is what lands the files owned by tanshu, which is the only # uid the container can write as (see "Who runs as whom"); # * set -o pipefail matters: without it the pipeline reports only the # *receiving* tar's status, so a read error or a dropped stream on monoco # exits 0 and leaves a silently short copy. set -o pipefail ssh monoco 'sudo tar -C /var/lib/sms-wa-bridge -cf - .' \ | ssh emily 'tar --no-same-owner -C ~/podman-data/sms-wa-bridge -xf -' # 5. Verify by content. Same relative paths on both sides, so the aggregate # digests are comparable — and the hashing needs sudo on monoco too, because # much of the Chromium profile is mode 0600. ssh monoco 'cd /var/lib/sms-wa-bridge && sudo find . -type f -print0 | sort -z | sudo xargs -0 -r sha256sum | sha256sum' ssh emily 'cd ~/podman-data/sms-wa-bridge && find . -type f -print0 | sort -z | xargs -0 -r sha256sum | sha256sum' # identical digests = a byte-faithful copy # (-type f skips the three stale Singleton* symlinks the entrypoint deletes # anyway; 1707 files, so this takes a few seconds) # 6. Nothing may be root-owned on the far side: with keep-id:uid=0 the container # can only write files owned by tanshu. Empty output is the correct answer, # and the playbook asserts it before it starts anything. ssh emily 'find ~/podman-data/sms-wa-bridge ! -user tanshu' # 7. Deploy — after the copy, so the app finds the linked session on first start. cd prepare-server && ansible-playbook -u tanshu ../sms-wa-bridge/playbook.yml ``` If the link is slow or drops, do it in two hops instead: resumable, and it shows progress. `--rsync-path="sudo rsync"` is what reaches the root-owned source, and `--no-owner --no-group` is the rsync spelling of `--no-same-owner`. ```bash rsync -aP --no-owner --no-group --rsync-path="sudo rsync" \ monoco:/var/lib/sms-wa-bridge/ ~/sms-wa-bridge-copy/ rsync -aP --no-owner --no-group ~/sms-wa-bridge-copy/ \ emily:~/podman-data/sms-wa-bridge/ ``` Rolling back means rolling back all of it: `ssh monoco 'sudo docker start sms-wa-bridge'` is only meaningful while monoco still holds the session, and the linked device can only be live in one place. **Never run both at once** — the second one to connect logs the first one out, and the one that just lost the device keeps accepting SMS without ever answering them. ## Verification ```bash systemctl --user status sms-wa-bridge journalctl --user -u sms-wa-bridge -f podman logs sms-wa-bridge # the QR, once, if it is not linked # What the playbook checks for you: podman healthcheck run sms-wa-bridge # /health over HTTP find ~/podman-data/sms-wa-bridge ! -user tanshu # empty # The one thing health does not say — is WhatsApp actually linked? podman exec sms-wa-bridge wget -q -O - http://127.0.0.1:8080/health # {"waReady":true,"rules":"loaded"} # The state is where the host can see it, and the mounts are bind mounts: podman inspect sms-wa-bridge --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' ls -ln ~/podman-data/sms-wa-bridge # Running as tanshu on the host, as container-root inside — the keep-id mapping. # `HUSER 1001` is tanshu on emily, and it is the uid every file the bridge writes # will belong to: podman top sms-wa-bridge huser,user,args | head -3 ``` Then end to end, which is the only check that proves the whole path — and it **sends a real WhatsApp message**, so warn the recipients first: ```bash curl -sS https://bridge.tanshu.dev/health curl -sS -X POST https://bridge.tanshu.dev/sms \ -H "X-Auth: $AUTH_SECRET" \ -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: ` 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.