Relinking after the rebuild failed: the phone said "network error" and signal-cli logged "Link request error: Connection closed!", with the network itself fine (TLS to chat.signal.org completed). The pinned 0.100 bundled signal-cli 0.14.5 from June; Signal stops accepting clients that fall a few months behind. 0.101 (signal-cli 0.14.8) linked first time. A stale pin fails the same way for alerts - silently - so the pin now carries signal_api_version beside the digest and a note to bump it every couple of months. The old comment saying upstream publishes no versioned tags was wrong. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| defaults | ||
| tasks | ||
| templates | ||
| README.md | ||
signal_api
Runs signal-cli-rest-api on
the observability host. Gatus uses it to deliver alerts over Signal.
Gatus does not speak Signal — it POSTs JSON to this service, which holds the Signal identity and does the protocol work.
It is never published, and that is not optional
This API has no authentication of any kind. No key, no token, no basic auth. Anything that can reach the port can send messages as your identity and read your Signal. So the compose file publishes no ports at all and there is no Caddy vhost.
Gatus reaches it over a shared docker network (monitoring) by service name:
http://signal-api:8080. That is also why a shared network is needed rather
than a published port — Gatus runs in a container, so 127.0.0.1 for Gatus is
the Gatus container, not the host.
The network is created by an explicit Ansible task in both this role and
gatus, so neither stack has to be deployed before the other.
MODE, and why native
Upstream offers normal, native, json-rpc and json-rpc-native. The
json-rpc modes keep a resident JVM daemon and upstream describes them as
"increased memory".
This VPS has 464 MB of RAM, already running Gatus and Caddy. A resident JVM
is not affordable. native runs a precompiled GraalVM binary per request — no
daemon, no resident cost — and alerts are rare enough that paying startup cost
per alert is the right trade.
Linking the device — a one-time manual step
Ansible cannot scan a QR code, so this is manual. Do not use
/v1/qrcodelink — it is broken in native mode.
The trap
GET /v1/qrcodelink?device_name=... returns:
{"error":"Couldn't create QR code: no data to encode"}
The linking itself is fine: running the binary directly inside the container emits a perfectly good provisioning URI.
$ docker exec signal-api signal-cli-native link -n gatus
sgnl://linkdevice?uuid=...&pub_key=...
It is the REST wrapper that fails to capture that output in native mode.
Do not "fix" this by switching MODE to normal or json-rpc. That puts a
JVM in the path of every alert on a 464 MB host, permanently degrading the
running system to work around a step performed once. Generate the QR yourself
instead.
The procedure
docker exec runs as root, but the service runs as uid 1000. Without
--config, signal-cli writes the linked account to /root/.local/share/signal-cli
— the container's ephemeral layer, NOT the mounted volume. It looks like it
worked (Associated with: +34…), /v1/accounts keeps returning [], and the
account is destroyed on the next docker compose up. Always pass --config.
-
Start the link and capture the URI. It must keep running while you scan:
docker exec signal-api sh -c "rm -f /tmp/link.uri; \ nohup signal-cli-native --config /home/.local/share/signal-cli \ link -n gatus > /tmp/link.uri 2>/tmp/link.log & echo started" sleep 10 docker exec signal-api cat /tmp/link.uriDo not add
setsid, and do not backgrounddocker execitself from the host — the first stops the URI appearing, the second is killed when the Ansible task returns. The output is block-buffered because stdout is a file, so the URI appears only after several seconds;stdbufdoes not help, as the buffering is GraalVM's, not libc's. -
Render the QR on your own machine and scan it:
qrencode -o /tmp/qr.png -s 12 -m 4 "sgnl://linkdevice?uuid=...&pub_key=..." -
Phone: Signal → Settings → Linked devices → + → scan. Provisioning links expire in a couple of minutes, so generate and scan in one sitting.
-
Confirm — this must list the number, not
[]:docker exec signal-api curl -s http://localhost:8080/v1/accounts -
Send a test message:
docker exec signal-api curl -s -X POST -H "Content-Type: application/json" \ -d '{"message":"test","number":"+34…","recipients":["+34…"]}' \ http://localhost:8080/v2/send
Alerts are sent from your own number, so sending to yourself lands in Note to Self. If the device is ever unlinked from the phone, alerts stop silently — which is why this service is itself monitored.
If the phone says "network error"
The phone is not the problem. chat.signal.org resolves to AWS Global
Accelerator dualstack addresses with the AAAA records first, this container
has no IPv6 address at all, and this host's IPv6 path is broken — the same edge
that returned a bogus 404 for the Go tarball. signal-cli reaches for an
unreachable IPv6 address and dies with Link request error: Connection closed!,
while the phone can only report a failed handshake.
That is what gai.conf (mounted at /etc/gai.conf) fixes. If linking starts
failing again, check it is still mounted and that getent ahosts chat.signal.org
returns an IPv4 address first.
Backups
Deliberately not backed up. The data directory holds Signal private keys, and the recovery path is to link again from the phone — which takes a minute and does not depend on any stored artefact. Backing it up would copy a credential off the host to buy nothing.
Verifying
docker ps --filter name=signal-api
docker exec signal-api curl -fsS http://localhost:8080/v1/health
docker exec signal-api curl -fsS http://localhost:8080/v1/accounts
docker logs signal-api --tail 50