HTTP service that provisions per-recipient ntfy channels for WeckRuf (reconciles ntfy users/ACLs/tokens from datastar-app as source of truth)
  • Go 97.2%
  • Dockerfile 2.8%
Find a file
Tobias Ehn 8a4c58568d
All checks were successful
CI / test (push) Successful in 13s
CI / lint (push) Successful in 3s
docs: Org-ACK-Topic dokumentieren (manuell, außerhalb des Service-Scopes)
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).
2026-07-22 17:19:18 +02:00
.claude Initial ntfy-user-provisioner: Go service for per-recipient ntfy channel provisioning 2026-06-22 17:00:51 +02:00
.forgejo/workflows Initial ntfy-user-provisioner: Go service for per-recipient ntfy channel provisioning 2026-06-22 17:00:51 +02:00
docs docs: Org-ACK-Topic dokumentieren (manuell, außerhalb des Service-Scopes) 2026-07-22 17:19:18 +02:00
internal docs: correct stale auth-model comments and document godoc/pkgsite 2026-06-23 21:57:50 +02:00
.env.example Initial ntfy-user-provisioner: Go service for per-recipient ntfy channel provisioning 2026-06-22 17:00:51 +02:00
.gitignore docs: correct stale auth-model comments and document godoc/pkgsite 2026-06-23 21:57:50 +02:00
CHANGELOG.md feat: POST /service-token — issue & rotate the org service token 2026-06-23 20:57:16 +02:00
compose.example.yml docs(compose): warn against duplicate top-level networks key 2026-06-22 17:45:21 +02:00
Dockerfile Initial ntfy-user-provisioner: Go service for per-recipient ntfy channel provisioning 2026-06-22 17:00:51 +02:00
go.mod Initial ntfy-user-provisioner: Go service for per-recipient ntfy channel provisioning 2026-06-22 17:00:51 +02:00
main.go Initial ntfy-user-provisioner: Go service for per-recipient ntfy channel provisioning 2026-06-22 17:00:51 +02:00
README.md docs: Org-ACK-Topic dokumentieren (manuell, außerhalb des Service-Scopes) 2026-07-22 17:19:18 +02:00

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) with write on <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; /sync leaves 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 legacy docker-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.