alerting: Signal via signal-cli-rest-api, and faster failure detection
Three changes: detection windows tightened, a Signal transport deployed, and
alerts attached to all 84 non-transport endpoints.
── Failing faster ──────────────────────────────────────────────────────────
The heartbeat is a TICKER, not a deadline: Gatus wakes every interval and asks
"did anything arrive in the last interval", so real detection is 1-2x the
window. And a window can only ever be as tight as the push frequency - which is
why two checks moved rather than just having their numbers changed.
liveness, cpu, ups, service-health, probes 16m -> 11m
disk, zfs daily/30h -> 6-hourly/7h
backup store + pull job daily/30h -> 6-hourly/7h
DNS records 24h -> 6h
backup dump 30h -> 26h
backup-dump stays slow because the dump genuinely is daily. The store-side check
catches the same fault within 6h by reading the source's dump timestamp out of
the artefact filename, so 26h is a backstop rather than the primary signal.
── Thresholds differ by check type, deliberately ───────────────────────────
`failure-threshold` counts consecutive failures, but "consecutive" is a
different amount of wall-clock time per check: a push endpoint produces one
failure per heartbeat window, a pulled one per interval. The default of 3 would
mean 33 minutes on an 11m heartbeat and over a day on a 7h one.
More importantly the heartbeat window ALREADY encodes the tolerance - an 11m
window on a 5-minute push is exactly "one missed push forgiven" - so stacking a
threshold of 3 triples a tolerance that was already chosen. Hence:
push/heartbeat endpoints failure-threshold 1
pulled, 5m (public) failure-threshold 3 (= 15 minutes)
pulled, 6h/24h (dns, domain) failure-threshold 1
── The Signal transport ────────────────────────────────────────────────────
roles/signal_api runs signal-cli-rest-api on the observability host, pinned by
digest, MODE=native.
It publishes NO PORTS. The API has no authentication of any kind - anything that
reaches it can send messages as you and read your Signal. Gatus talks to it over
a shared docker network by service name, which is also WHY the network exists:
Gatus runs in a container, so the host's loopback is unreachable from it and a
port published on 127.0.0.1 would not have worked.
MODE=native and not json-rpc because this VPS has 464MB of RAM and already runs
Gatus and Caddy. The json-rpc modes hold a resident JVM; native runs a binary
per request, and alerts are rare enough that startup cost per alert is the right
trade.
Monitored - Gatus polls /v1/health over the same network path the alerts take,
so it proves the delivery route rather than mere container liveness. Deliberately
NOT backed up: the data directory holds Signal private keys and the recovery
path is to link the device again from the phone.
Neither the signal-api endpoint nor Gatus's self-check carries a Signal alert.
If either is down, Signal is precisely what cannot deliver the alert.
── Four traps hit while linking, all now in the role README ────────────────
* /v1/qrcodelink is BROKEN in native mode - returns "no data to encode" while
the binary itself emits a perfectly good URI. Not worked around by switching
MODE, which would put a JVM in the path of every alert permanently.
* The data dir must be owned by uid 1000, not root. A root-owned 0700 dir
cannot be traversed by the container user, so linking silently never
completes and /v1/accounts returns "Failed to read local accounts list".
* `docker exec` runs as ROOT while the service runs as uid 1000, so without
--config the account is written to /root/... on the container's ephemeral
layer. It reports success and is destroyed on the next recreate.
* The phone reporting "network error" was IPv6: chat.signal.org resolves to
dualstack AAAA records first, the container has no IPv6 address, and this
host's IPv6 path is broken - the same edge that 404'd the Go tarball. Fixed
with a mounted gai.conf that prefers IPv4.
Verified: provider loads (configuredProviders=[signal]), a test message was
delivered and confirmed received, 84 endpoints carry alerts, 86 UP / 0 DOWN.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
85040d5f67
commit
3a9e1d5851
20 changed files with 752 additions and 181 deletions
133
ansible/roles/signal_api/README.md
Normal file
133
ansible/roles/signal_api/README.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# signal_api
|
||||
|
||||
Runs [signal-cli-rest-api](https://github.com/bbernhard/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:
|
||||
|
||||
```json
|
||||
{"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`.
|
||||
|
||||
1. 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.uri
|
||||
|
||||
Do **not** add `setsid`, and do **not** background `docker exec` itself 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; `stdbuf` does not help, as the
|
||||
buffering is GraalVM's, not libc's.
|
||||
|
||||
2. Render the QR on your own machine and scan it:
|
||||
|
||||
qrencode -o /tmp/qr.png -s 12 -m 4 "sgnl://linkdevice?uuid=...&pub_key=..."
|
||||
|
||||
3. Phone: Signal → Settings → Linked devices → **+** → scan. Provisioning links
|
||||
expire in a couple of minutes, so generate and scan in one sitting.
|
||||
|
||||
4. Confirm — this must list the number, not `[]`:
|
||||
|
||||
docker exec signal-api curl -s http://localhost:8080/v1/accounts
|
||||
|
||||
5. 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
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
39
ansible/roles/signal_api/defaults/main.yml
Normal file
39
ansible/roles/signal_api/defaults/main.yml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
# signal-cli-rest-api: the transport Gatus uses to send Signal messages.
|
||||
#
|
||||
# Gatus does not speak Signal. It POSTs JSON to this service, which holds the
|
||||
# actual Signal identity and does the protocol work.
|
||||
|
||||
# Pinned by digest for the same reason as Gatus: a tag is mutable.
|
||||
# Upstream publishes no versioned tags worth pinning to, so this pins the
|
||||
# DIGEST that `latest` resolved to when this was reviewed. `latest` is a moving
|
||||
# target; a digest is a content address, and `docker compose pull` either
|
||||
# fetches exactly this image or fails.
|
||||
signal_api_image_digest: "sha256:2399d449123cdad56c4d859277e3b9127e1a00c4d2ab4601c239882609286cf8"
|
||||
signal_api_image: "bbernhard/signal-cli-rest-api@{{ signal_api_image_digest }}"
|
||||
|
||||
signal_api_dir: /opt/signal-api
|
||||
signal_api_data_dir: "{{ signal_api_dir }}/data"
|
||||
|
||||
# MODE matters on this host. Upstream offers normal / native / json-rpc /
|
||||
# json-rpc-native. json-rpc keeps a resident JVM daemon and upstream describes it
|
||||
# as "increased memory" - this VPS has 464MB total and already runs Gatus and
|
||||
# Caddy, so 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 per alert is the right trade.
|
||||
signal_api_mode: native
|
||||
|
||||
# Port INSIDE the shared docker network. Never published to the host: this API
|
||||
# has NO AUTHENTICATION of any kind. Anyone who can reach it can send messages
|
||||
# as you and read your Signal.
|
||||
signal_api_port: 8080
|
||||
|
||||
# Both this and Gatus join this network so Gatus can reach the API by service
|
||||
# name. Gatus runs in a container, so the host's loopback is NOT reachable from
|
||||
# it - this is why a shared network is required rather than a published port.
|
||||
signal_api_network: monitoring
|
||||
signal_api_service_name: signal-api
|
||||
|
||||
# The uid the upstream image drops to (`setpriv --reuid=1000`). The data
|
||||
# directory must be owned by it or signal-cli cannot write the account.
|
||||
signal_api_uid: 1000
|
||||
93
ansible/roles/signal_api/tasks/main.yml
Normal file
93
ansible/roles/signal_api/tasks/main.yml
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
---
|
||||
- name: Assert Docker is available
|
||||
ansible.builtin.command: docker --version
|
||||
register: signal_docker_check
|
||||
changed_when: false
|
||||
|
||||
# Created explicitly rather than by either compose file, so neither stack has to
|
||||
# be deployed before the other and neither owns it.
|
||||
- name: Ensure the shared monitoring network exists
|
||||
ansible.builtin.command: "docker network create {{ signal_api_network }}"
|
||||
register: signal_net
|
||||
changed_when: "'already exists' not in signal_net.stderr"
|
||||
failed_when:
|
||||
- signal_net.rc != 0
|
||||
- "'already exists' not in signal_net.stderr"
|
||||
|
||||
- name: Create the signal-api directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ signal_api_dir }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
# Owned by the container's uid, NOT root.
|
||||
#
|
||||
# The image drops to uid 1000 (`setpriv --reuid=1000`), and a root-owned 0700
|
||||
# directory cannot be traversed by uid 1000 - signal-cli then fails to write the
|
||||
# account and linking silently never completes, leaving a 39-byte accounts.json
|
||||
# with no accounts and the API returning "Failed to read local accounts list".
|
||||
#
|
||||
# 0700 on uid 1000 is still private: only that uid and root can read the Signal
|
||||
# private keys, which is the property actually wanted.
|
||||
- name: Create the signal-api data directory owned by the container user
|
||||
ansible.builtin.file:
|
||||
path: "{{ signal_api_data_dir }}"
|
||||
state: directory
|
||||
owner: "{{ signal_api_uid }}"
|
||||
group: "{{ signal_api_uid }}"
|
||||
mode: "0700"
|
||||
|
||||
- name: Write the IPv4-preference resolver config
|
||||
ansible.builtin.template:
|
||||
src: gai.conf.j2
|
||||
dest: "{{ signal_api_dir }}/gai.conf"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
|
||||
- name: Write the docker compose file
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ signal_api_dir }}/docker-compose.yml"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
|
||||
- name: Pull the pinned signal-api image
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose pull
|
||||
chdir: "{{ signal_api_dir }}"
|
||||
register: signal_pull
|
||||
changed_when: "'Downloaded newer image' in signal_pull.stderr or 'Pull complete' in signal_pull.stderr"
|
||||
|
||||
- name: Start signal-api
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d --remove-orphans
|
||||
chdir: "{{ signal_api_dir }}"
|
||||
register: signal_up
|
||||
changed_when: "'Started' in signal_up.stderr or 'Created' in signal_up.stderr or 'Recreated' in signal_up.stderr"
|
||||
|
||||
- name: Wait for the API to answer
|
||||
ansible.builtin.command:
|
||||
cmd: "docker exec {{ signal_api_service_name }} curl -fsS http://localhost:{{ signal_api_port }}/v1/health"
|
||||
register: signal_health
|
||||
until: signal_health.rc == 0
|
||||
retries: 12
|
||||
delay: 5
|
||||
changed_when: false
|
||||
|
||||
- name: Report whether an account is linked yet
|
||||
ansible.builtin.command:
|
||||
cmd: "docker exec {{ signal_api_service_name }} curl -fsS http://localhost:{{ signal_api_port }}/v1/accounts"
|
||||
register: signal_accounts
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Show the linking status
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
{{ 'Linked account(s): ' ~ signal_accounts.stdout
|
||||
if (signal_accounts.stdout | default('[]') | trim) not in ['[]', '', 'null']
|
||||
else 'NO ACCOUNT LINKED YET - this is a one-time manual step, see the role README.' }}
|
||||
47
ansible/roles/signal_api/templates/docker-compose.yml.j2
Normal file
47
ansible/roles/signal_api/templates/docker-compose.yml.j2
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# Managed by Ansible (roles/signal_api)
|
||||
services:
|
||||
{{ signal_api_service_name }}:
|
||||
image: {{ signal_api_image }}
|
||||
container_name: {{ signal_api_service_name }}
|
||||
restart: unless-stopped
|
||||
|
||||
environment:
|
||||
MODE: "{{ signal_api_mode }}"
|
||||
|
||||
volumes:
|
||||
# Prefer IPv4. See gai.conf.j2 - without this, signal-cli reaches for
|
||||
# chat.signal.org's IPv6 address, which is unreachable from here, and
|
||||
# linking fails with an opaque "network error" on the phone.
|
||||
- {{ signal_api_dir }}/gai.conf:/etc/gai.conf:ro
|
||||
# Holds the Signal identity: the linked-device keys and registration
|
||||
# state. Lose this and the device must be linked again by scanning a new
|
||||
# QR code from the phone. It is also the most sensitive thing on this
|
||||
# host - anyone with these keys can send and read Signal as you.
|
||||
- {{ signal_api_data_dir }}:/home/.local/share/signal-cli
|
||||
|
||||
networks:
|
||||
- {{ signal_api_network }}
|
||||
|
||||
# NO PORTS. Deliberately.
|
||||
#
|
||||
# This API has no authentication whatsoever - no key, no token, nothing.
|
||||
# Publishing it, even on 127.0.0.1, would expose "send a Signal message as
|
||||
# this identity" to anything that can reach the host. Gatus talks to it over
|
||||
# the shared docker network by service name instead, which is why no port is
|
||||
# published and why there is no Caddy vhost.
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/v1/health"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
networks:
|
||||
{{ signal_api_network }}:
|
||||
external: true
|
||||
17
ansible/roles/signal_api/templates/gai.conf.j2
Normal file
17
ansible/roles/signal_api/templates/gai.conf.j2
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Managed by Ansible (roles/signal_api)
|
||||
#
|
||||
# Prefer IPv4 over IPv6 in getaddrinfo.
|
||||
#
|
||||
# chat.signal.org resolves to AWS Global Accelerator dualstack addresses, and
|
||||
# DNS returns the AAAA records first. This container has NO IPv6 address at all,
|
||||
# and this host's IPv6 path is unreliable anyway - the same edge that made
|
||||
# Google's IPv6 endpoint return a confident 404 for the Go tarball.
|
||||
#
|
||||
# signal-cli would connect to the AAAA address, fail, and report
|
||||
# Link request error: Connection closed!
|
||||
# while the phone showed a bare "network error" - a failure with no obvious
|
||||
# cause on either end.
|
||||
#
|
||||
# This line flips the precedence so IPv4-mapped addresses sort first, which is
|
||||
# the standard glibc fix. It does NOT disable IPv6; it only changes the order.
|
||||
precedence ::ffff:0:0/96 100
|
||||
Loading…
Add table
Add a link
Reference in a new issue