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

@@ -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
+22
View File
@@ -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 }}