- Go 97.2%
- Dockerfile 2.8%
WeckRuf#13: pro Org ein <org>-ack-<zufall>-Topic — anonymer write-only (Button-POST ohne Token, Topicname = Capability-Geheimnis) + Service-User read-write (SSE-Abo). Anlage-Runbook in architecture.md; /sync-Reconcile lässt die Regeln unangetastet (verwaltet nur <org>-<operator>-Accounts). |
||
|---|---|---|
| .claude | ||
| .forgejo/workflows | ||
| docs | ||
| internal | ||
| .env.example | ||
| .gitignore | ||
| CHANGELOG.md | ||
| compose.example.yml | ||
| Dockerfile | ||
| go.mod | ||
| main.go | ||
| README.md | ||
ntfy-user-provisioner
Small HTTP service that provisions per-recipient ntfy channels for WeckRuf.
datastar-app is the single source of
truth for operators; on any relevant change it POSTs the full operator list and
this service reconciles the org's ntfy accounts against it.
Why this exists
ntfy has no admin REST API for user management — accounts, ACLs and tokens
can only be created via the ntfy CLI (the account signup API is disabled on the
shared server, and granular read-ACLs are CLI-only anyway). To keep a single user
management in datastar-app while still using POST/HTTP from the app, this service
wraps the CLI behind a thin authenticated API.
It needs no SSH and no Docker socket: the container ships the ntfy binary
and shares the server's auth.db via a mounted volume. A separate process
writing that SQLite file is seen immediately by the running ntfy server
(verified on the live host).
Model
- One ntfy account per recipient, named
<org>-<operator>(e.g.uhv21-beckmann), with a read-only ACL on its own private topic of the same name. Org-prefixing keeps recipients collision-free on a shared, multi-tenant ntfy instance. - One service user per org (e.g.
weckruf-uhv21) withwriteon<org>-*, used by the WeckRuf sidecar to publish. This service never touches it. - Topic schema is flat
<org>-<name>because ntfy disallows/in topics. - One ACK topic per org (
<org>-ack-<random>, for the alarm push ACK button) exists outside this service's scope: created once via CLI, anonymous write-only + service-user read-write. The random name is the access secret and is never documented;/syncleaves these ACLs untouched. See docs/architecture.md.
API
All write endpoints require Authorization: Bearer <token>, where the token must
be the one mapped to the request's org (tenant isolation, constant-time check).
POST /sync
Reconcile an org's recipients against the desired operator list. Creates missing
accounts (+ read-ACL on their private topic), removes stale <org>-* accounts.
No credentials are returned — operator passwords are set on demand via
/set-password, so the caller never has to store a secret.
// request
{ "org": "uhv21", "operators": ["beckmann", "hempler"] }
// response
{
"org": "uhv21",
"added": [
{ "operator": "hempler", "username": "uhv21-hempler",
"topic": "uhv21-hempler",
"subscribe_url": "https://ntfy.onlinemonitoring.io/uhv21-hempler" }
],
"removed": ["uhv21-mueller"],
"unchanged": ["uhv21-beckmann"]
}
POST /set-password
Set a fresh random password for one operator and return it once. The account
is provisioned on demand if it does not exist yet (no prior /sync needed).
// request
{ "org": "uhv21", "operator": "beckmann" }
// response (the password is shown this one time and not stored anywhere)
{ "operator": "beckmann", "username": "uhv21-beckmann", "topic": "uhv21-beckmann",
"password": "…", "subscribe_url": "https://ntfy.onlinemonitoring.io/uhv21-beckmann" }
The ntfy mobile app authenticates with username + password (it has no field
for an access token), so this is the credential a human operator needs. On loss,
just call /set-password again.
POST /service-token
(Re)issue the service token for an org's service user (weckruf-<org>) — the
credential WeckRuf publishes with — and return it once. Provisions the service
account on demand (account + write ACL on <org>-*) if it does not exist yet.
// request
{ "org": "uhv21" }
// response (the token is shown this one time and never logged)
{ "org": "uhv21", "service_user": "weckruf-uhv21", "token": "tk_…" }
Rotation is revoke-all-then-one: ntfy never overwrites tokens (each token add appends a row), so a naive "create token" would pile them up. The fresh
token is minted before the old ones are revoked, so there is never a window
without a valid token. Result: exactly one active token, no publish gap.
GET /health
Liveness probe ({"status":"ok"}), no auth.
How a recipient gets connected
datastar-app shows each operator their server URL, username (<org>-<name>)
and topic, plus a "reset password" action that calls /set-password and reveals
the new password once. In the ntfy mobile app the operator adds the server with
that username + password and subscribes to their private topic. WeckRuf publishes
to <org>-<name> via the org's service token (a separate credential,
weckruf-<org> with write on <org>-*), issued and rotated via
/service-token.
Configuration
See .env.example. Key var: PROVISION_TOKENS — a JSON map
org -> bearer token (one token per datastar-app instance).
Deploy
Add the service to the ntfy host's compose stack — see
compose.example.yml. It joins the existing web network
and is exposed via Traefik under its own subdomain. It must run as the UID that
owns the ntfy data (user: "1000:1000").
Use
docker compose(v2) on the host, not the legacydocker-compose(v1).
Development
go vet ./...
go test ./...
go build .
Stdlib only — no external dependencies. The ntfy CLI runner is injectable, so the parsing/reconcile logic is unit-tested without a real ntfy binary.