commit ec38e680d6e55042dd949fceed4c996993a98713 Author: Admin <1+admin@noreply.git.dcntrlized.org> Date: Sun Oct 18 22:31:16 2026 +0100 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..126150d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +# Keep the build context small and deterministic. Never ship host artifacts. +.git +.gitignore +.vscode +src/tmp +src/logs diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..50e72fc --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,75 @@ +# Template action to generate OCI-compliant containers +# Don't forget to change the template variables if any were missed + +name: Build and Publish Container + +on: + push: + tags: + - 'v*' + +jobs: + build-and-push: + runs-on: alpine-latest + steps: + - name: Install Dependencies + shell: sh + run: apk add --no-cache bash docker-cli docker-cli-buildx git + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Get full git history + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.REGISTRY }} + username: ${{ secrets.USERNAME }} + password: ${{ secrets.TOKEN }} + + - name: Normalize release version + id: version + shell: sh + run: | + ORG_REPO="$(printf '%s' "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')" + echo "image_base=${{ secrets.REGISTRY }}/${ORG_REPO}" >> "${GITHUB_OUTPUT}" + TAG="${GITHUB_REF_NAME#v}" + PRERELEASE="" + case "$TAG" in + *-*) PRERELEASE="-${TAG#*-}"; TAG="${TAG%%-*}";; + esac + IFS='.' read -r MAJOR MINOR PATCH <> "${GITHUB_OUTPUT}" + + - name: Extract metadata (labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.version.outputs.image_base }} + labels: | + org.opencontainers.image.title=Test Service + org.opencontainers.image.vendor=Dcntrlized + org.opencontainers.image.description=A test service used to test service creation and deployment + org.opencontainers.image.authors=Admin + org.opencontainers.image.version=${{ steps.version.outputs.version }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ${{ steps.version.outputs.image_base }}:${{ steps.version.outputs.version }} + ${{ steps.version.outputs.image_base }}:latest + labels: ${{ steps.meta.outputs.labels }} + build-args: | + IMAGE_VERSION=${{ steps.version.outputs.version }} + IMAGE_REVISION=${{ gitea.sha }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb77a58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Go build artifacts (Air runs inside the dev container as root) +src/tmp/ +src/logs/ + +# Logger writes its logs/ dir relative to the working directory, so package +# tests (which run with the package dir as cwd) can drop logs under src/*/. +src/handlers/config/logs/ +src/handlers/manifest/logs/ +src/actions/logs/ +src/realtime/logs/ +src/db/logs/ diff --git a/.license-header.tpl b/.license-header.tpl new file mode 100644 index 0000000..be776d8 --- /dev/null +++ b/.license-header.tpl @@ -0,0 +1,14 @@ +Copyright (C) {{.Year}} {{.Holder}} + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . diff --git a/.licenseignore b/.licenseignore new file mode 100644 index 0000000..591c418 --- /dev/null +++ b/.licenseignore @@ -0,0 +1,30 @@ +# addlicense ignore patterns (doublestar globs, relative to the repo root). +# Blank lines and lines starting with '#' are skipped. addlicense validates every +# pattern and skips file types it does not understand on its own, so this file is +# only the escape hatch for files that must not carry an AGPL header. + +# generated / vendored / build output +**/node_modules/** +**/vendor/** +**/tmp/** +**/dist/** +**/build/** + +# non-code and config files +**/*.md +**/*.json +**/*.yaml +**/*.yml +**/*.toml +**/*.lock +**/*.sum +**/*.mod +**/*.log +**/*.txt +**/*.debug + +# repo plumbing +.licenseignore +.gitignore +.gitmodules +.vscode/** diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..661a58c --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,50 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Backend", + "type": "go", + "request": "attach", + "mode": "remote", + "port": 2349, + "host": "127.0.0.1", + "debugAdapter": "legacy", + "substitutePath": [ + { + "from": "${workspaceFolder}/src", + "to": "/app" + } + ], + "cwd": "${workspaceFolder}/src", + "showLog": true, + "preLaunchTask": "Launch Backend", + "presentation": { + "hidden": false, + "group": "Alarm", + "order": 0 + } + }, + { + "name": "Attach to Alarm (Air)", + "type": "go", + "request": "attach", + "mode": "remote", + "port": 2349, + "host": "127.0.0.1", + "debugAdapter": "legacy", + "substitutePath": [ + { + "from": "${workspaceFolder}/src", + "to": "/app" + } + ], + "cwd": "${workspaceFolder}/src", + "showLog": true, + "presentation": { + "hidden": false, + "group": "Alarm", + "order": 1 + } + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..cb97e78 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,54 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Launch Backend", + "type": "shell", + "command": "docker compose -f docker-compose.debug.yaml up -d && until nc -z 127.0.0.1 2349 >/dev/null 2>&1; do sleep 1; done && echo DAE_BACKEND_READY", + "isBackground": true, + "problemMatcher": [ + { + "pattern": { + "regexp": "^.*$" + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".*", + "endsPattern": "DAE_BACKEND_READY" + } + } + ], + "presentation": { + "reveal": "always", + "panel": "shared" + } + }, + { + "label": "Follow Backend Logs", + "type": "shell", + "command": "docker compose -f docker-compose.debug.yaml logs -f", + "presentation": { + "reveal": "always", + "panel": "shared" + } + }, + { + "label": "Stop Backend", + "type": "shell", + "command": "docker compose -f docker-compose.debug.yaml down", + "presentation": { + "reveal": "always", + "panel": "shared" + } + }, + { + "label": "Rebuild Backend Image", + "type": "shell", + "command": "docker compose -f docker-compose.debug.yaml up -d --build", + "presentation": { + "reveal": "always", + "panel": "shared" + } + } + ] +} diff --git a/API.md b/API.md new file mode 100644 index 0000000..e2013d2 --- /dev/null +++ b/API.md @@ -0,0 +1,744 @@ +# Alarm — service API (v1, frozen) + +The Alarm service is the server half of the Dcntrlized alert feature: one user +(the **admin**) creates an alert and holds an admin token; everybody else joins +by scanning the alert's **subscribe token** as a QR code; the admin can trigger +and disarm the alert at will, and a trigger must fire **live** on every +subscribed client. + +- Container / DNS name: `alarm` +- One plaintext listener inside the container: `:8080` (dev host port `8088`) +- Module: `git.dcntrlized.org/Dcntrlized/Alarm` +- Image: `192.168.122.183:3000/dcntrlized/alarm:dev` + +Everything — the API, the auth proxy and the WebSocket — rides that **single +port**. There is no second listener and no TLS on the service itself (an edge +proxy terminates TLS). + +## Routes + +| Route | Purpose | +| --- | --- | +| `GET /_manifest` | service manifest consumed by Maestro | +| `/auth/*` | shared-auth proxy (`dcntrlized-auth`), unchanged from every other service | +| `/action/{actionName}` | the action API documented below | +| `GET /ws/alarm` | the live alarm socket | + +`/ws/alarm` is deliberately registered on the **outer** mux, outside the +logging/CORS chain: the connection is hijacked and the subscribe token travels +in the query string, so it must never reach a request logger. + +## Envelope and authentication + +`/action/*` uses the platform envelope: + +```json +{ "payload": { "...": "action specific" } } +``` + +- `POST`, `PUT` and `DELETE` read the envelope from the body. Unknown fields + **inside `payload` are ignored** (forward compatible); an unknown top-level + envelope field is rejected by the dispatcher with a plain-text `400` (see + *Errors* below). +- `GET` carries no body: the same actions take their arguments from the query + string, e.g. `GET /action/getAlert?alertId=…`. +- Every action that addresses an alert accepts **`alertId` under the alias + `id`**, both as a query parameter and as a payload key: + `?id=` and `{ "payload": { "id": "" } }` work exactly like + `alertId`. Precedence is query `alertId` → query `id` → payload `alertId` → + payload `id`; the first non-empty one wins. + +**Every alert action requires a verified access token** in the `X-Access-Token` +header (minted by `dcntrlized-auth`). A missing, malformed or expired token is +always: + +```json +{ "error": { "code": "ERR_UNAUTHORIZED", "message": "…" } } +``` + +with HTTP `401`. The service never trusts a user id, name or email from the +payload — identity comes from the token only. The shared-auth proxies +(`login`, `register`, `refresh`, `delete`, `logout`, `me`) are the exception: +`login`/`register` mint the token, `me`/`logout`/`refresh`/`delete` forward +whatever header the caller sent and let `dcntrlized-auth` decide. + +Two further credentials exist, both **in the payload** (never in a header): + +- `adminToken` — the alert's admin secret. Minted once by `createAlert`, + stored only as a SHA-256 hash server-side, and required by the destructive + actions. +- `subscribeToken` — the shareable secret rendered as the QR code. It is + *rotatable* and is the only thing a joining client needs. + +Errors are **not uniform across the request pipeline**. Clients must know +where the boundary is. + +**Action-level errors** — everything the action handlers themselves return — +use the platform envelope: + +```json +{ "error": { "code": "ERR_…", "message": "human readable" } } +``` + +That covers every code in the table below (401/403/404/409/500) as well as the +WebSocket rejections. + +**Dispatch-level errors** are the exception. They are produced by the layer +that routes `/action/{actionName}` by HTTP method, *before* any handler or +error helper runs, and they are **plain text** (`text/plain; charset=utf-8`), +never JSON. A client that calls `JSON.parse` on every error body throws on +exactly these — check `Content-Type` first, and treat a non-JSON body as a +generic failure: + +| Situation | HTTP | Body | Example | +| --- | --- | --- | --- | +| unknown action name for this verb | `501` | `Action not implemented` | `POST /action/getAlerts` (typo), `GET /action/triggerAlert`, **any** `PUT /action/` | +| malformed request envelope | `400` | the JSON decoder's message | body is not valid JSON, has an unknown **top-level** field (`DisallowUnknownFields`), or cannot be decoded into the envelope — e.g. `json: unknown field "payloads"` | +| unsupported HTTP verb | `405` | `method not allowed` | `PATCH /action/createAlert` | + +Consequences worth internalising: + +- `PUT` is routed but implements nothing, so **every** `PUT /action/` is + `501` — including for actions that exist under another verb. +- A known action sent with the *wrong* verb is also `501`, not `405`. `405` is + reserved for a verb outside `GET`/`POST`/`PUT`/`DELETE`. +- `GET` never decodes a body, so it can only produce the `501` row. +- Only `POST`, `PUT` and `DELETE` can produce the plain-text `400`. + +## Error codes + +| Code | HTTP | Meaning | +| --- | --- | --- | +| `ERR_VALIDATION` | 400 | malformed payload, bad uuid, field too long, bad query parameter | +| `ERR_UNAUTHORIZED` | 401 | missing/invalid access token | +| `ERR_ALERT_TOKEN_INVALID` | 403 | the `adminToken` or `subscribeToken` does not match this alert | +| `ERR_ALERT_NOT_ADMIN` | 403 | a valid access token, but not the alert's admin, for an admin-only action | +| `ERR_ALERT_NOT_FOUND` | 404 | no such alert (also returned instead of 403 where hiding existence matters) | +| `ERR_ALERT_NOT_SUBSCRIBED` | 404 | caller is neither the admin nor a subscriber of this alert | +| `ERR_ALERT_ALREADY_JOINED` | 409 | unique-constraint race on `(alertId, user, deviceId)`; the normal re-scan path is idempotent and returns `200` | +| `ERR_ALERT_STATE` | 409 | the action is illegal in the alert's current state (e.g. disarming a non-triggered alert, deleting a live one) | +| `ERR_INTERNAL` | 500 | unexpected server/database failure | + +`ERR_METHOD_NOT_ALLOWED` (405) and `ERR_UNAVAILABLE` (503) also exist on the +WebSocket endpoint — see below. These codes only ever appear in a JSON +envelope; the dispatch-level `405` described above is plain text and carries no +code. + +## Action summary + +The dispatcher routes by HTTP method (`GET`, `POST`, `PUT`, `DELETE`); in every +case the action name is the path segment after `/action/`. + +`POST` accepts every **alert** action plus the shared-auth proxies. `GET` +accepts the read-only alert subset plus `me` and `logout` — **`me` is +GET-only** (`POST /action/me` is a `501`). `DELETE` accepts the two removals. +`PUT` implements nothing at all. + +### Alert actions + +| Action | Verbs | Admin token | Access | +| --- | --- | --- | --- | +| `createAlert` | POST | — | any authenticated user becomes the admin | +| `getAlert` | GET, POST | — | admin **or** subscriber | +| `joinAlert` | POST | — | any authenticated user holding the subscribe token | +| `listSubscriptions` | GET, POST | — | alert admin only: the access-token user must be the alert's `adminUserId`; **no** admin token is used | +| `removeSubscription` | DELETE, POST | — | admin (any row) or the subscriber (own row) | +| `triggerAlert` | POST | required | admin | +| `disarmAlert` | POST | required | admin | +| `alarmStatus` | GET, POST | — | admin **or** subscriber | +| `deleteAlert` | DELETE, POST | required | admin | +| `qrImage` | GET, POST | — | admin **or** subscriber | +| `rotateSubscribeToken` | POST | required | admin | + +### Shared-auth actions (`/auth/*` proxies) + +Thin proxies to `dcntrlized-auth`, kept on `/action/` for the RN clients that +send everything through one path. They take the same `{payload}` envelope; the +payload is forwarded as the raw auth body and the auth response is relayed +as-is, unwrapped (so these return the auth service's own body shape, not the +alert shapes documented here). + +| Action | Verbs | Proxies | Payload | +| --- | --- | --- | --- | +| `me` | GET | `GET /auth/me` | — (access token header only) | +| `logout` | GET, POST | `POST /auth/logout` | POST: `{ "refreshToken": "…" }`; GET: bodyless no-op | +| `login` | POST | `POST /auth/login` | `{ "username": "…", "password": "…" }` | +| `register` | POST | `POST /auth/register` | `{ "username": "…", "password": "…", "email": "…" }` | +| `refresh` | POST | `POST /auth/refresh` | `{ "refreshToken": "…" }` | +| `delete` | POST | `POST /auth/delete` | `{ "password": "…" }` | + +Direct `/auth/*` routes exist for the same calls; the RN clients call those for +login/register (raw body, no envelope). These `/action/` variants are the +envelope-wrapped form of the same operations. + +--- + +## `createAlert` + +Creates an alert and makes the caller its admin. + +**POST** `/action/createAlert` + +```json +{ "payload": { + "name": "Fire drill", + "message": "Leave the building now", + "autoStopSeconds": 300 +} } +``` + +| Field | Type | Rules | +| --- | --- | --- | +| `name` | string | required, 1–255 characters after trimming | +| `message` | string | optional, ≤ 2000 bytes, the default text pushed with a trigger | +| `autoStopSeconds` | int | optional, `0`–`86400`; `0` (default) means "ring until disarmed" | + +Response `200`: + +```json +{ + "alertId": "0f8fad5b-d9cb-469f-a165-70867728950e", + "name": "Fire drill", + "message": "Leave the building now", + "status": "armed", + "active": false, + "adminToken": "…", // shown ONCE, store it + "subscribeToken": "…", + "qrPayload": "https://alarm.dcntrlized.org/a/0f8f…e#…", + "autoStopSeconds": 300, + "tokenVersion": 1, + "created": "2026-10-18T17:00:00Z" +} +``` + +`adminToken` is returned **only here** (and nowhere else, ever). The server +keeps a hash. Losing it means losing admin rights over that alert. + +Errors: `ERR_VALIDATION`, `ERR_UNAUTHORIZED`. + +## `getAlert` + +Returns the alert, the caller's relationship to it, and the newest event. + +**GET** `/action/getAlert?alertId=` · **POST** `{ "payload": { "alertId": "…" } }` + +Response `200`: + +```json +{ + "alertId": "…", + "name": "Fire drill", + "message": "Leave the building now", + "status": "triggered", // "armed" | "triggered" | "disarmed" + "active": true, // derived, see alarmStatus + "autoStopSeconds": 300, + "tokenVersion": 1, + "created": "2026-10-18T17:00:00Z", + "modified": "2026-10-18T17:05:00Z", + "triggeredAt": "2026-10-18T17:05:00Z", + "qrPayload": "https://alarm.dcntrlized.org/a/…#…", + "isAdmin": true, + "subscriptionCount": 2, + "lastEvent": { + "eventId": "…", "action": "trigger", "message": "Leave the building now", + "serverTime": "2026-10-18T17:05:00Z" + } +} +``` + +`qrPayload` is present for the admin and for subscribers (the subscriber already +holds that token). `isAdmin` tells the client which controls to render. + +Optional fields are **omitted, never sent as `null`** (`omitempty` on the +wire). That covers `triggeredAt` (absent until the first trigger — and *kept* +after a disarm, so it always means "when it last rang"), `disarmedAt` (absent +until a disarm) and `lastEvent` (absent while the alert has no events). Test for +presence, not for `null`. + +Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`, +`ERR_UNAUTHORIZED`. + +## `joinAlert` + +Subscribes the caller's device. This is what the QR scan calls. + +**POST** `/action/joinAlert` + +```json +{ "payload": { + "alertId": "0f8fad5b-…", + "subscribeToken": "…", + "deviceId": "android-8f2c…", + "deviceLabel": "Pixel 7", + "platform": "android" +} } +``` + +| Field | Type | Rules | +| --- | --- | --- | +| `alertId` | uuid | required (the scanner takes it from the QR payload) | +| `subscribeToken` | string | required, 16–64 url-safe characters, must match the alert | +| `deviceId` | string | **required**, 1–128 characters after trimming, no control characters — the client generates and persists a stable per-install id and sends it on every join | +| `deviceLabel` | string | optional, ≤ 128 chars | +| `platform` | string | optional, ≤ 32 chars (`android`, `ios`, …) | + +`deviceId` is part of the subscription's identity, so it is **not optional**: +missing or empty is `400 ERR_VALIDATION` (`deviceId is required`). Generate it +once per install (a random UUID persisted in app storage is enough) and never +rotate it — a new value makes the same user a separate subscription, which is +exactly the "second device" behaviour. Reuse the same value as `?deviceId=` on +`/ws/alarm` so the right subscription's `last_seen_at` is refreshed. + +Response `200` — the subscription view (see `listSubscriptions`). + +**Idempotent per `(alertId, user, deviceId)`**: re-scanning the same QR on the +same device returns `200` with the same subscription id and creates no new row. +The same user scanning on a *second* device creates a second subscription. +`ERR_ALERT_ALREADY_JOINED` is only the 409 fallback for a concurrent duplicate +insert race. + +Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_TOKEN_INVALID`, +`ERR_ALERT_ALREADY_JOINED`, `ERR_UNAUTHORIZED`. + +## `listSubscriptions` + +**GET** `/action/listSubscriptions?alertId=` · +**POST** `{ "payload": { "alertId": "…" } }` + +Admin only (the access token must belong to the alert's admin — the admin token +is *not* required for this read). + +```json +{ "items": [ + { "id": "…", "userId": "…", "deviceId": "android-8f2c…", + "deviceLabel": "Pixel 7", "platform": "android", + "enabled": true, "lastSeenAt": "2026-10-18T17:04:00Z", + "created": "2026-10-18T16:00:00Z" } +] } +``` + +`username` is intentionally **never** populated: it lives in the auth service's +schema and Alarm does not read another service's tables. The field is omitted +rather than sent empty. `lastSeenAt` is refreshed whenever that subscription's +socket connects or heartbeats. + +Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_ADMIN`, +`ERR_UNAUTHORIZED`. + +## `removeSubscription` + +**DELETE** `/action/removeSubscription` · **POST** (same envelope) + +```json +{ "payload": { "alertId": "…", "subscriptionId": "…" } } +``` + +The admin may remove any subscription of the alert; a subscriber may remove its +own. Response `200`: + +```json +{ "alertId": "…", "subscriptionId": "…", "removed": true } +``` + +Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`, +`ERR_ALERT_NOT_ADMIN`, `ERR_UNAUTHORIZED`. + +## `triggerAlert` + +Fires the alarm **live** on every subscriber of every instance. + +**POST** `/action/triggerAlert` + +```json +{ "payload": { + "alertId": "0f8fad5b-…", + "adminToken": "…", + "message": "Optional per-trigger override" +} } +``` + +`message` is optional; when omitted the alert's stored message is used. The +override is **not** persisted — it applies to this event only. + +Response `200`: + +```json +{ + "eventId": "…", + "action": "trigger", + "serverTime": "2026-10-18T17:05:00.123Z", + "autoStopSeconds": 300, + "delivered": 2 +} +``` + +- `delivered` is the number of **live WebSocket clients on this instance** that + the frame was written to. It is not a delivery guarantee: clients that were + not connected (or are on another instance) are reached by the reconnect + catch-up and by the `alarmStatus` fallback instead. +- Re-triggering an already-triggered alert is allowed and always produces a + **new `eventId`**, restarting the auto-stop window. Clients must treat each + `eventId` as a new alarm. +- The alert state is stored **before** the fan-out, so a client that reconnects + immediately after the call still sees the live event. + +Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_TOKEN_INVALID`, +`ERR_ALERT_NOT_ADMIN`, `ERR_UNAUTHORIZED`. + +## `disarmAlert` + +**POST** `/action/disarmAlert` — same payload as `triggerAlert` (the `message` +override applies to the disarm event the same way). + +```json +{ "payload": { "alertId": "…", "adminToken": "…", "message": "All clear" } } +``` + +Response `200` — same shape as `triggerAlert` with `"action": "disarm"`: + +```json +{ + "eventId": "…", + "action": "disarm", + "serverTime": "2026-10-18T17:09:00.456Z", + "autoStopSeconds": 300, + "delivered": 2 +} +``` + +`autoStopSeconds` echoes the alert's **configured** value (e.g. `300`), not +`0`: disarming does not rewrite the stored column, and the same value rides the +broadcast `disarm` frame. Treat it as informational — "that is how long the +trigger would have rung" — never as a stop signal. **Cancel the ring on +`action: "disarm"`** (or on `active: false` from `alarmStatus`). A client that +waits for `autoStopSeconds === 0` before stopping will keep ringing. + +Disarming an alert that is **not** currently active is `409 ERR_ALERT_STATE`. + +Errors: as `triggerAlert`, plus `ERR_ALERT_STATE`. + +## `alarmStatus` + +The cheap, indexed fallback a client polls when its socket is down, or on cold +start when it may have been killed mid-alarm. + +**GET** `/action/alarmStatus?alertId=` · +**POST** `{ "payload": { "alertId": "…" } }` + +```json +{ + "active": true, + "eventId": "…", + "action": "trigger", + "message": "Leave the building now", + "serverTime": "2026-10-18T17:05:00.123Z", + "autoStopSeconds": 300 +} +``` + +`active` is **derived**, never a timer: it is true only when the newest event is +a `trigger`, the stored status is `triggered`, and (when `autoStopSeconds > 0`) +`now < triggeredAt + autoStopSeconds`. Nothing sweeps the database, so the +answer stays correct across restarts and across instances. When there is no +event yet the fields are empty and `active` is `false`. + +`serverTime` is the event's timestamp (or the server's clock when there is no +event) — use it to correct client clock skew rather than comparing with the +device clock. + +Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`, +`ERR_UNAUTHORIZED`. + +## `deleteAlert` + +**DELETE** `/action/deleteAlert` · **POST** (same envelope) + +```json +{ "payload": { "alertId": "…", "adminToken": "…" } } +``` + +Deletes the alert, its subscriptions and its event history (cascade). Response +`200`: + +```json +{ "alertId": "…", "deleted": true } +``` + +Refused with `409 ERR_ALERT_STATE` while the alert is active — disarm first, so +a ringing alarm cannot be deleted out from under its subscribers. + +Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_TOKEN_INVALID`, +`ERR_ALERT_NOT_ADMIN`, `ERR_ALERT_STATE`, `ERR_UNAUTHORIZED`. + +## `qrImage` + +Renders the alert's subscribe QR as a PNG. + +**GET** `/action/qrImage?alertId=&scale=8&quietZone=4` · +**POST** `{ "payload": { "alertId": "…" } }` + +| Query | Default | Range | +| --- | --- | --- | +| `scale` | `8` | 1–64 pixels per module | +| `quietZone` (alias `margin`) | `4` | 4–32 modules | + +A quiet zone below **4 modules** is rejected with `400 ERR_VALIDATION`: the +ISO/IEC 18004 minimum is 4 and smaller margins produce symbols real scanners +refuse (measured: `zbarimg` returns "no symbol" at 0–3). + +Response `200` is **binary**, not JSON: `Content-Type: image/png`, +`Cache-Control: no-store`, `X-Content-Type-Options: nosniff`. The image encodes +exactly `qrPayload` (below). The QR is byte-mode, EC level **M**, version +chosen automatically from the payload length. + +Keep the quiet zone at ≥ 4 modules; scanners reject codes without it. + +Errors: `ERR_VALIDATION`, `ERR_ALERT_NOT_FOUND`, `ERR_ALERT_NOT_SUBSCRIBED`, +`ERR_UNAUTHORIZED`, `ERR_INTERNAL` (encoder failure — never expected). + +## `rotateSubscribeToken` + +Invalidates the current QR and mints a new subscribe token. Existing +subscriptions keep working; only *joining* with the old token stops working. +Use it when the QR leaked. + +**POST** `/action/rotateSubscribeToken` + +```json +{ "payload": { "alertId": "…", "adminToken": "…" } } +``` + +Response `200`: + +```json +{ "alertId": "…", "subscribeToken": "…", + "qrPayload": "https://alarm.dcntrlized.org/a/…#…", + "tokenVersion": 2 } +``` + +Errors: as `triggerAlert`. + +--- + +## QR payload format + +The QR encodes exactly the `qrPayload` field: + +``` +https://alarm.dcntrlized.org/a/# +``` + +An app-scheme deep link carries the same pair for in-app scanners: + +``` +dcntrlized-alarm://join?alert=&token= +``` + +`alertId` is a UUID and `subscribeToken` is 16–64 url-safe characters +(`A–Z a–z 0–9 - _`). The fragment is used because a URL fragment is never sent +to a server by a browser — the token stays client-side until the app posts it to +`joinAlert`. + +--- + +## Live protocol — `GET /ws/alarm` + +``` +ws://:8088/ws/alarm?alertId=&token=&accessToken=[&deviceId=] +``` + +The socket is **authenticated, not anonymous**: the access token identifies the +user and the second token proves the relationship to the alert. + +### Authentication + +| Credential | Where | Required | +| --- | --- | --- | +| access token (who you are) | `X-Access-Token` header **or** `?accessToken=` / `?access_token=` | yes | +| `alertId` | query | yes, valid uuid | +| `token` | query | yes — the admin token **or** the subscribe token | +| `deviceId` | query | optional; the `deviceId` used at `joinAlert` — selects which subscription's `last_seen_at` is refreshed (without it the oldest subscription is used) | + +- admin token match **and** the caller is the alert's admin → accepted as the + admin connection (no subscription required); +- subscribe token match **and** the caller has a subscription on that alert → + accepted as a subscriber connection (`?deviceId=` selects the subscription, + otherwise the oldest one is used); +- anything else is rejected **before the upgrade** with the usual JSON error + (`401 ERR_UNAUTHORIZED`, `403 ERR_ALERT_TOKEN_INVALID`, + `403 ERR_ALERT_NOT_ADMIN`, `404 ERR_ALERT_NOT_SUBSCRIBED`, + `404 ERR_ALERT_NOT_FOUND`, or `503 ERR_UNAVAILABLE` during shutdown). + A non-`GET` request gets `405 ERR_METHOD_NOT_ALLOWED`. + +A header is preferred for the access token (headers do not appear in access +logs); the query form exists because browsers cannot set headers on a WebSocket +handshake. + +### Server → client frames + +`alarm` — the alarm event. Every subscribed client receives the **same frame** +from one fan-out: + +```json +{ + "type": "alarm", + "alertId": "0f8fad5b-…", + "eventId": "…", + "action": "trigger", // "trigger" | "disarm" + "message": "Leave the building now", + "serverTime": "2026-10-18T17:05:00.123Z", + "autoStopSeconds": 300 +} +``` + +`ack` — the server's acknowledgement of a client ack: + +```json +{ "type": "ack", "eventId": "…" } +``` + +### Client → server frames + +`ack` — "I have this event and I am ringing": + +```json +{ "type": "ack", "eventId": "…" } +``` + +`ping` — application-level keepalive. The server answers with a WebSocket +**pong control frame** (there is no JSON pong) and refreshes the subscription's +`last_seen_at`. + +```json +{ "type": "ping" } +``` + +Unknown or malformed client frames are ignored, never fatal. + +**The one fatal exception is frame size.** A client frame larger than **4096 +bytes** is a protocol violation: the server refuses it and closes the +connection with the WebSocket close code `1009` (message too big). `ack` and +`ping` are tiny, so this only bites a client that tries to send something else +through the socket — don't. If you see an unexplained disconnect, this is a +candidate; the same applies to sending non-JSON frames. + +### Keepalive and timeouts + +- The server sends a WebSocket ping every **20s**; a client that sends nothing + (no pong, no `ping`) for **60s** is dropped. +- Every write has a **10s** deadline, so a dead peer can never block the hub. +- Each connection has a buffered send queue. A client that cannot keep up is + **dropped** (its socket closed) rather than allowed to slow the fan-out down + for everyone else. Reconnect and the catch-up frame restores the state. + +### Reconnect catch-up + +The service keeps the **last event per alert** in memory. On connect, if that +event is still active (a `trigger` whose auto-stop window has not expired), the +server sends it immediately — so a client that reconnects mid-alarm fires +without polling, and a client that reconnects *after* the alarm is over is never +re-alarmed. + +### Multiple instances + +Fan-out is local: `Broadcast` writes to this instance's connections. Every +trigger/disarm is also an `INSERT` into `alarm.alarm_events`, whose trigger +issues `pg_notify('alarm_events', …)`; each instance holds a `LISTEN` and +re-broadcasts to its own clients. So a trigger raised on instance A fires on +subscribers of instance B as well. Duplicate deliveries are suppressed by +`eventId`, so the local fast path and the NOTIFY path cannot double-fire. + +### Minimal client + +```js +const ws = new WebSocket( + `ws://alarm:8088/ws/alarm?alertId=${alertId}&token=${subscribeToken}` + + `&accessToken=${accessToken}&deviceId=${deviceId}`); + +ws.onmessage = (ev) => { + const f = JSON.parse(ev.data); + if (f.type === "alarm" && f.action === "trigger") { + // autoStopSeconds: 0 = ring until a disarm arrives; >0 = self-stop after N + ring(f.message, f.eventId, f.autoStopSeconds); // max volume, wake screen + ws.send(JSON.stringify({ type: "ack", eventId: f.eventId })); + } + if (f.type === "alarm" && f.action === "disarm") stopRinging(f.eventId); +}; +setInterval(() => ws.readyState === 1 && ws.send('{"type":"ping"}'), 25000); +``` + +### Polling fallback + +When the socket cannot be established, poll `alarmStatus` (every 5–15s while +the app is foregrounded) and treat `active: true` with a new `eventId` exactly +like a live `trigger` frame, and `active: false` (or `action: "disarm"`) as the +stop signal. Always prefer the socket: it is the < 1s path, and polling only +bounds the worst case. + +--- + +## `GET /_manifest` + +```json +{ + "name": "Alarm", + "author": "Dcntrlized", + "capabilities": ["database", "alarm.live"], + "has_auth": true, + "public_key": "", + "icon": "" +} +``` + +Served verbatim, `Content-Type: application/json`. `database` declares the +schema/migration capability Maestro provisions; `has_auth` tells Maestro to +mount the shared-auth proxy. + +--- + +## Worked example + +```bash +BASE=http://localhost:8088 + +# 1. access token (shared auth) +TOKEN=$(curl -s -X POST "$BASE/auth/login" \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"admin_password_123"}' | jq -r .accessToken) + +# 2. create the alert (keep adminToken!) +curl -s -X POST "$BASE/action/createAlert" -H "X-Access-Token: $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"payload":{"name":"Fire drill","message":"Leave the building","autoStopSeconds":300}}' | jq + +# 3. a second user scans the QR payload and joins +curl -s -X POST "$BASE/action/joinAlert" -H "X-Access-Token: $BOB" \ + -H 'Content-Type: application/json' \ + -d '{"payload":{"alertId":"'$ALERT'","subscribeToken":"'$SUB'","deviceId":"pixel-7"}}' | jq + +# 4. bob opens the socket (leave it running in another terminal) +websocat "ws://localhost:8088/ws/alarm?alertId=$ALERT&token=$SUB&accessToken=$BOB&deviceId=pixel-7" + +# 5. trigger — bob's socket prints the alarm frame +curl -s -X POST "$BASE/action/triggerAlert" -H "X-Access-Token: $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"payload":{"alertId":"'$ALERT'","adminToken":"'$ADMIN'"}}' | jq + +# 6. disarm +curl -s -X POST "$BASE/action/disarmAlert" -H "X-Access-Token: $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"payload":{"alertId":"'$ALERT'","adminToken":"'$ADMIN'"}}' | jq +``` + +## Storage + +All state lives in the shared PostgreSQL database, schema `alarm`: + +| Table | Contents | +| --- | --- | +| `alarm.alerts` | one row per alert: name, message, admin user id, admin-token hash, subscribe token + version, status, timestamps, `auto_stop_seconds` | +| `alarm.subscriptions` | `(alert, user, device)` unique, label/platform, `enabled`, `last_seen_at` | +| `alarm.alarm_events` | append-only trigger/disarm history; its `AFTER INSERT` trigger issues the `pg_notify` that fans out to other instances | + +Migrations live in `database/migrations/` and are applied by the compose +`migrate` one-shot; they are idempotent and safe to re-apply. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d06db5d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# --- Build Stage --- +FROM golang:1.26 AS builder +WORKDIR /app + +# Shared modules (git.dcntrlized.org/dcntrlized/dcntrlized-auth) are served by +# the dev Gitea Go module proxy. Override via --build-arg in production. +ARG GOPROXY_VALUE=http://192.168.122.183:3000/api/packages/Dcntrlized/go +ARG GONOSUMDB_VALUE=git.dcntrlized.org +ENV GOPROXY=${GOPROXY_VALUE},https://proxy.golang.org,direct +ENV GONOSUMDB=${GONOSUMDB_VALUE} + +COPY src/go.mod src/go.sum ./ +RUN go mod download + +COPY src/ ./ + +# Build the whole main package (not just main.go): task-2/task-4 add files to +# package main (and any embed directive would need it), so a single-file build +# is not safe. +RUN CGO_ENABLED=0 GOOS=linux go build -o server . + +# --- Run Stage --- +FROM alpine:latest +WORKDIR /root/ + +ARG IMAGE_VERSION=dev +ARG IMAGE_REVISION= + +LABEL org.dcntrlized.service=alarm +LABEL org.dcntrlized.port=8080 +LABEL org.opencontainers.image.version=$IMAGE_VERSION +LABEL org.opencontainers.image.revision=$IMAGE_REVISION + +COPY --from=builder /app/server . + +# 8080 is the service's ONLY port: the API (/_manifest, /auth/*, /action/*) and +# the /ws/alarm WebSocket share it, plaintext in-container (the operator's TLS +# proxy fronts alarm.dcntrlized.org in prod). +EXPOSE 8080 + +CMD ["./server"] diff --git a/Dockerfile.debug b/Dockerfile.debug new file mode 100644 index 0000000..4ef7901 --- /dev/null +++ b/Dockerfile.debug @@ -0,0 +1,70 @@ +# Source: https://oneuptime.com/blog/post/2026-01-07-go-hot-reloading-docker-air/view#step-3-create-the-development-dockerfile + +# Dockerfile (Development) +# This Dockerfile is optimized for development with hot reloading +# It includes Air and mounts your source code as a volume + +# Use the official Go image as our base +# Alpine variant is smaller but we use the full image for better compatibility +FROM golang:1.26-bookworm + +# Set the working directory inside the container +# All subsequent commands will run from this directory +WORKDIR /app + +# Shared modules (git.dcntrlized.org/dcntrlized/dcntrlized-auth) are served by +# the dev Gitea Go module proxy. Override via --build-arg in production. +ARG GOPROXY_VALUE=http://192.168.122.183:3000/api/packages/Dcntrlized/go +ARG GONOSUMDB_VALUE=git.dcntrlized.org +ENV GOPROXY=${GOPROXY_VALUE},https://proxy.golang.org,direct +ENV GONOSUMDB=${GONOSUMDB_VALUE} + +# Install Air for hot reloading +# We install it globally so it's available in the PATH +RUN go install github.com/air-verse/air@latest + +# Install additional development tools (optional but useful) +# - delve: Go debugger for debugging inside containers +# - golangci-lint: Linter for code quality checks +RUN go install github.com/go-delve/delve/cmd/dlv@latest && \ + curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.11.2 + +# Copy go.mod and go.sum first for better layer caching +# This layer only rebuilds when dependencies change +COPY src/go.mod src/go.sum* ./ + +# Download all dependencies +# This is cached separately from source code changes +RUN go mod download && go mod verify + +# Copy the Air configuration file +# This tells Air how to watch and rebuild our application +COPY ./src/.air.toml ./ + +# Note: We do NOT copy source code here! +# Source code is mounted as a volume in Docker Compose +# This enables hot reloading without rebuilding the image + +# Expose the application port +# Make sure this matches your application's listening port +EXPOSE 8080 + +# Expose delve debugger port (optional, for debugging) +EXPOSE 2345 + +# Set environment variables for development +ENV GO111MODULE=on + +# Default command: run Air for hot reloading +# Air will watch for changes and rebuild automatically +CMD ["air", "-c", ".air.toml"] + +## + +ARG IMAGE_VERSION=dev +ARG IMAGE_REVISION= + +LABEL org.dcntrlized.service=Alarm +LABEL org.dcntrlized.port=8080 +LABEL org.opencontainers.image.version=$IMAGE_VERSION +LABEL org.opencontainers.image.revision=$IMAGE_REVISION \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..4c431fa --- /dev/null +++ b/build.sh @@ -0,0 +1,29 @@ +#!/bin/env bash +# Copyright (C) 2026 Dcntrlized +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +set -euo pipefail + +IMAGE_NAME="alarm" +DOCKERFILE="Dockerfile" + +if [[ "${1:-}" == "--debug" ]]; then + DOCKERFILE="Dockerfile.debug" + IMAGE_NAME="alarm:debug" +fi + +VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo dev)" +REVISION="$(git rev-parse --short HEAD 2>/dev/null || echo dev)" +docker build --network=host -t "$IMAGE_NAME" -f "$DOCKERFILE" --build-arg IMAGE_VERSION="$VERSION" --build-arg IMAGE_REVISION="$REVISION" . diff --git a/database/migrations/001_alarm_schema.sql b/database/migrations/001_alarm_schema.sql new file mode 100644 index 0000000..e629886 --- /dev/null +++ b/database/migrations/001_alarm_schema.sql @@ -0,0 +1,187 @@ +\c dcntrlized; + +-- ============================================================================ +-- Alarm service v1 schema (contract frozen 2026-10-18). +-- +-- Identity comes from the shared dcntrlized-auth service: every *_user_id +-- column is a plain UUID pointing at auth.users(id) -- NO foreign key, so the +-- alarm schema never depends on the auth service's internals (platform +-- convention, see Calendar/KeepNote). +-- +-- The whole file is idempotent: every statement is guarded, so the compose +-- `migrate` one-shot can re-apply it against an already-migrated database. +-- ============================================================================ + +CREATE SCHEMA IF NOT EXISTS alarm; + +-- --------------------------------------------------------------------------- +-- Guarded enum types. CREATE TYPE has no IF NOT EXISTS, so each one is wrapped +-- in a DO block that checks pg_type/pg_namespace first. +-- --------------------------------------------------------------------------- +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE t.typname = 'alert_status' AND n.nspname = 'alarm' + ) THEN + CREATE TYPE alarm.alert_status AS ENUM ('armed', 'triggered', 'disarmed'); + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE t.typname = 'event_kind' AND n.nspname = 'alarm' + ) THEN + CREATE TYPE alarm.event_kind AS ENUM ('trigger', 'disarm'); + END IF; +END +$$; + +-- --------------------------------------------------------------------------- +-- alerts: one row per alert created by an admin (user A). +-- +-- admin_token_hash sha256(adminToken); the plaintext admin token is returned +-- exactly once at creation and never stored. +-- subscribe_token stored PLAINTEXT on purpose: it is shared through the QR +-- code, and rotating it is a cheap UPDATE (token_version). +-- status last COMMANDED state. `active` is derived, never stored: +-- active = status = 'triggered' AND (auto_stop_seconds = 0 +-- OR now() < triggered_at + auto_stop_seconds). +-- Deriving it keeps auto-stop stateless and multi-instance +-- safe (no timers, no background sweep). +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS alarm.alerts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + admin_user_id UUID NOT NULL, + name VARCHAR(255) NOT NULL, + message TEXT, + status alarm.alert_status NOT NULL DEFAULT 'armed', + admin_token_hash BYTEA NOT NULL, + subscribe_token VARCHAR(128) NOT NULL, + token_version INTEGER NOT NULL DEFAULT 1, + auto_stop_seconds INTEGER NOT NULL DEFAULT 0, + created TIMESTAMPTZ NOT NULL DEFAULT NOW(), + modified TIMESTAMPTZ NOT NULL DEFAULT NOW(), + triggered_at TIMESTAMPTZ, + disarmed_at TIMESTAMPTZ, + CONSTRAINT alerts_auto_stop_seconds_range CHECK (auto_stop_seconds >= 0 AND auto_stop_seconds <= 86400), + CONSTRAINT alerts_token_version_positive CHECK (token_version >= 1) +); + +-- --------------------------------------------------------------------------- +-- subscriptions: one row per (alert, subscriber user, device). A user may +-- subscribe several devices; the same device is idempotent. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS alarm.subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + alert_id UUID NOT NULL REFERENCES alarm.alerts(id) ON DELETE CASCADE, + subscriber_user_id UUID NOT NULL, + device_id VARCHAR(128) NOT NULL, + device_label VARCHAR(128), + platform VARCHAR(32), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_seen_at TIMESTAMPTZ, + created TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT subscriptions_alert_user_device_key UNIQUE (alert_id, subscriber_user_id, device_id) +); + +-- --------------------------------------------------------------------------- +-- alarm_events: append-only trigger/disarm log. It is the source of truth for +-- alarmStatus (the polling fallback) and for the WS catch-up frame. +-- +-- The AFTER INSERT trigger publishes the event on the `alarm_events` NOTIFY +-- channel, so the fan-out reaches EVERY service instance. NOTIFY is delivered +-- on COMMIT, so a frame can never be observed before the alert state change is +-- visible. +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS alarm.alarm_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + alert_id UUID NOT NULL REFERENCES alarm.alerts(id) ON DELETE CASCADE, + kind alarm.event_kind NOT NULL, + actor_user_id UUID NOT NULL, + message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- --------------------------------------------------------------------------- +-- Indexes. idx_alarm_events_latest backs the alarmStatus single-row lookup +-- (alert_id, newest first); the others back the ownership/subscriber checks. +-- --------------------------------------------------------------------------- +CREATE INDEX IF NOT EXISTS idx_alerts_admin_user_id ON alarm.alerts(admin_user_id); +CREATE INDEX IF NOT EXISTS idx_subscriptions_alert_id ON alarm.subscriptions(alert_id); +CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON alarm.subscriptions(subscriber_user_id); +CREATE INDEX IF NOT EXISTS idx_alarm_events_alert_id ON alarm.alarm_events(alert_id); +CREATE INDEX IF NOT EXISTS idx_alarm_events_latest ON alarm.alarm_events(alert_id, created_at DESC, id DESC); + +-- --------------------------------------------------------------------------- +-- modified maintenance for alerts. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION alarm.update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.modified = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS update_alerts_modtime ON alarm.alerts; +CREATE TRIGGER update_alerts_modtime + BEFORE UPDATE ON alarm.alerts + FOR EACH ROW + EXECUTE FUNCTION alarm.update_updated_at_column(); + +-- --------------------------------------------------------------------------- +-- Cross-instance fan-out. The payload is the transport form of the WS frame: +-- the bridge formats serverTime from createdAtMs, so the in-process fast path +-- and the NOTIFY path agree on the wire shape. +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION alarm.notify_alarm_event() +RETURNS TRIGGER AS $$ +DECLARE + auto_stop INTEGER; +BEGIN + SELECT a.auto_stop_seconds INTO auto_stop FROM alarm.alerts a WHERE a.id = NEW.alert_id; + + PERFORM pg_notify('alarm_events', json_build_object( + 'alertId', NEW.alert_id::text, + 'eventId', NEW.id::text, + 'action', NEW.kind::text, + 'message', COALESCE(NEW.message, ''), + 'autoStopSeconds', COALESCE(auto_stop, 0), + 'createdAtMs', (EXTRACT(EPOCH FROM NEW.created_at) * 1000)::bigint + )::text); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS notify_alarm_event ON alarm.alarm_events; +CREATE TRIGGER notify_alarm_event + AFTER INSERT ON alarm.alarm_events + FOR EACH ROW + EXECUTE FUNCTION alarm.notify_alarm_event(); + +-- --------------------------------------------------------------------------- +-- Least-privilege role, mirroring the Calendar/KeepNote house pattern. The +-- shared database currently authenticates as the shared `postgres` role +-- (contract rev 9), so these grants are inert-but-ready; they are idempotent +-- and safe to re-run. +-- --------------------------------------------------------------------------- +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'alarm_user') THEN + CREATE USER alarm_user WITH PASSWORD 'alarm'; + END IF; +END +$$; + +GRANT CONNECT ON DATABASE dcntrlized TO alarm_user; +GRANT USAGE ON SCHEMA alarm TO alarm_user; +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA alarm TO alarm_user; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA alarm TO alarm_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA alarm GRANT ALL ON TABLES TO alarm_user; diff --git a/database/requirements.txt b/database/requirements.txt new file mode 100644 index 0000000..d594be8 --- /dev/null +++ b/database/requirements.txt @@ -0,0 +1,4 @@ +# Dependencies for the database/seeds/*.py scripts. Installed by the +# compose 'seed' service (and used for host-side seeding via setup.py). +psycopg2-binary +bcrypt diff --git a/database/seeds/001_seed_example.py b/database/seeds/001_seed_example.py new file mode 100644 index 0000000..64766d3 --- /dev/null +++ b/database/seeds/001_seed_example.py @@ -0,0 +1,43 @@ +# Copyright (C) 2026 Dcntrlized +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +""" +Example seed for the shared dcntrlized database. + +Seeds run automatically after migrations complete, via the compose 'seed' +service: database/seeds is mounted read-only into a python container and every +*.py file here is executed in sorted order. Connection settings come from the +PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE environment variables set by that +service (psycopg2.connect() reads them implicitly). + +Keep every seed idempotent: it re-runs on every `docker compose up` (use +ON CONFLICT DO NOTHING / IF NOT EXISTS style upserts, stable uuids, ...). +""" + +import psycopg2 + + +def main(): + conn = psycopg2.connect() + cur = conn.cursor() + cur.execute("SELECT 1") + cur.fetchone() + cur.close() + conn.close() + print("001_seed_example.py: no-op seed. Replace with real seeds.") + + +if __name__ == "__main__": + main() diff --git a/dev/index.html b/dev/index.html new file mode 100644 index 0000000..613cad7 --- /dev/null +++ b/dev/index.html @@ -0,0 +1,77 @@ + + + + + + + + Dev + + + + +
+ + +
+ + +
+ +
+
+ + +
+ + +
+ +
+ + diff --git a/docker-compose.debug.yaml b/docker-compose.debug.yaml new file mode 100644 index 0000000..cd59248 --- /dev/null +++ b/docker-compose.debug.yaml @@ -0,0 +1,119 @@ +# Source: https://oneuptime.com/blog/post/2026-01-07-go-hot-reloading-docker-air/view#step-4-set-up-docker-compose + +# docker-compose.yml +# Development environment with hot reloading + +services: + # Main Go application with Air hot reloading + alarm-debug: + extra_hosts: + - "host.docker.internal:host-gateway" + + image: alarm:debug + container_name: alarm-debug + + # Cold `docker compose -f docker-compose.debug.yaml up -d` builds this image + # directly — no pre-build step needed. Do NOT add `network: host` here: + # `docker compose up`/`build` cannot grant the `network.host` build + # entitlement (that needs `--allow=network.host`, which only `docker build` + # accepts), so a cold up would fail with "additional privileges requested". + # The default bridge reaches the dev Gitea (192.168.122.183), so + # Dockerfile.debug's GOPROXY resolves shared modules without host networking. + build: + context: . + dockerfile: Dockerfile.debug + + # `alarm:debug` is a local-only tag: build it, never look it up in a + # registry (otherwise a cold `up` warns "pull access denied"). + pull_policy: build + + + # Expose ports: host:container + ports: + # Frozen v1 contract: host 8088 -> container 8080 (single port: API + + # /ws/alarm). + - "8088:8080" + # Delve: unique host port per service (auth-debug 2345, keepnote-debug + # 2346, calendar-debug 2347, store 2348), so the Go attach launch config + # reaches Air's Delve listener (in-container 2345) from the host. + - "2349:2345" # Delve debugger port + + + # Volume mounts - THE KEY TO HOT RELOADING + # Mount source code as a volume so changes are reflected immediately + volumes: + - ./src:/app + - go-mod-cache:/go/pkg/mod + - go-build-cache:/root/.cache/go-build + - /app/src/tmp # Exclude Air's tmp dir from syncing + + working_dir: /app/ + + # Environment variables for the application + environment: + # DCNTRLIZED_DEV is the single dev selector (registry/auth URLs). + - DCNTRLIZED_DEV=true + # Contract rev 9: internal traffic is plaintext, so the debug selector no + # longer gates any listener — this listener is ALWAYS plaintext now. + - DCNTRLIZED_DEBUG=true + + # Shared auth service target (dcntrlized-auth on the dcntrlized network) + - AUTH_SERVICE_URL=http://dcntrlized-auth:8080 + + # Service identity (inert in v1 — no /config edge — kept for fleet parity) + - DCNTRLIZED_SERVICE_NAME=alarm + + # Database connection (shared dcntrlized-db, scram-sha-256, sslmode=disable) + - DATABASE_URL=postgres://postgres:dcntrlized@dcntrlized-db:5432/dcntrlized?sslmode=disable + + # Go specific settings + - GO111MODULE=on + - GOPROXY=http://192.168.122.183:3000/api/packages/Dcntrlized/go,https://proxy.golang.org,direct + - GONOSUMDB=git.dcntrlized.org + depends_on: + - migrate + + # Restart policy for development + restart: unless-stopped + + # Network for service communication + networks: + - dcntrlized_net + + cap_add: + - SYS_PTRACE + security_opt: + - apparmor:unconfined + migrate: + image: postgres:16-alpine + container_name: alarm-migrate + labels: + org.dcntrlized.oneoff: "true" + networks: + - dcntrlized_net + volumes: + - ./database/migrations:/migrations:ro + environment: + - PGHOST=dcntrlized-db + - PGPORT=5432 + - PGUSER=postgres + - PGPASSWORD= + - PGDATABASE=dcntrlized + # Role-agnostic (no -U/-d): Maestro injects the shared credentials/env. + command: > + sh -c 'until pg_isready -h dcntrlized-db; do sleep 2; done; + for f in /migrations/*.sql; do [ -f "$$f" ] || continue; echo "Applying $$f"; psql -v ON_ERROR_STOP=1 -f "$$f" || exit 1; done' + +# Named volumes for data persistence and caching +volumes: + # Go module cache - shared across rebuilds + go-mod-cache: + driver: local + + # Go build cache - speeds up compilation + go-build-cache: + driver: local + +networks: + dcntrlized_net: + external: true diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..d991613 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,51 @@ +services: + alarm-service: + extra_hosts: + - "host.docker.internal:host-gateway" + # Deliberately no build: block — compose-from-git pulls this image + # (Calendar still carries one; do not copy it here). A prod compose that + # can build invites a silent local build when the registry pull fails. + image: 192.168.122.183:3000/dcntrlized/alarm:dev + container_name: alarm + networks: + - dcntrlized_net + expose: + # Single port: the API (/_manifest, /auth/*, /action/*) and the + # /ws/alarm WebSocket all share :8080. Expose-only so the operator's TLS + # proxy (NPM-private) can front it on alarm.dcntrlized.org without a + # host port. + - "8080" + ports: + # Frozen v1 contract: host 8088 -> container 8080. + - "8088:8080" + environment: + - DATABASE_URL=postgres://postgres:dcntrlized@dcntrlized-db:5432/dcntrlized?sslmode=disable + - AUTH_SERVICE_URL=http://dcntrlized-auth:8080 + # Service identity (same value Maestro injects for the /config ES256 + # token audience in Calendar/KeepNote). Alarm mounts no /config edge in + # v1, so this is inert today and correct if one is added later. + - DCNTRLIZED_SERVICE_NAME=alarm + depends_on: + - migrate + migrate: + image: postgres:16-alpine + container_name: alarm-migrate + labels: + org.dcntrlized.oneoff: "true" + networks: + - dcntrlized_net + volumes: + - ./database/migrations:/migrations:ro + environment: + - PGHOST=dcntrlized-db + - PGPORT=5432 + - PGUSER=postgres + - PGPASSWORD= + - PGDATABASE=dcntrlized + command: > + sh -c 'until pg_isready -h dcntrlized-db; do sleep 2; done; + for f in /migrations/*.sql; do [ -f "$$f" ] || continue; echo "Applying $$f"; psql -v ON_ERROR_STOP=1 -f "$$f" || exit 1; done' + +networks: + dcntrlized_net: + external: true diff --git a/src/.air.toml b/src/.air.toml new file mode 100644 index 0000000..859c04f --- /dev/null +++ b/src/.air.toml @@ -0,0 +1,144 @@ +# Source: https://oneuptime.com/blog/post/2026-01-07-go-hot-reloading-docker-air/view#step-2-install-and-configure-air + +# .air.toml - Air Configuration for Hot Reloading + +# This file configures how Air watches and rebuilds your Go application + +# Root directory for watching - use "." for current directory +root = "." + +# Temporary directory where Air stores build artifacts +# This directory is created automatically and should be gitignored +tmp_dir = "tmp" + +[build] +# Command to build your application +# Add any build flags you need here (like -race for race detection) +cmd = "go build -gcflags='all=-N -l' -o ./tmp/main ." + +# Tell Air to launch the compiled binary inside Delve using the legacy API +full_bin = "dlv exec --accept-multiclient --headless --continue --listen=:2345 --api-version=2 ./tmp/main" + +# Path to the built binary that Air will execute +# bin = "./tmp/main" + +# Entrypoint binary relative to root +# Air recommends this over relying on bin alone +# entrypoint = ["./tmp/main"] + +# Arguments to pass to your application when running +# Useful for passing flags or configuration +args_bin = [] + +# Pre-build command - runs before each build +# Useful for code generation, proto compilation, etc. +pre_cmd = [] + +# Post-build command - runs after each successful build +# Useful for running migrations or other setup tasks +post_cmd = [] + +# Enable polling mode for file watching (useful for Docker on some OSes) +# Set to true if you're on Windows or using certain Docker setups +poll = false + +# Polling interval in milliseconds (only used if poll = true) +poll_interval = 500 + +# Build delay after file change is detected (in milliseconds) +# Helps batch multiple rapid changes into one rebuild +delay = 1000 + +# Stop running the old binary before building the new one +# Recommended to avoid port conflicts +stop_on_error = true + +# Send interrupt signal before killing process +# Allows graceful shutdown of your application +send_interrupt = true + +# Delay after interrupt before force killing +kill_delay = "500ms" + +# Rerun binary on build error - useful for debugging +rerun = false + +# Delay before rerunning (in milliseconds) +rerun_delay = 500 + +# Build error log file, placed inside tmp_dir +log = "build-errors.log" + +# Directories to watch for changes +# Air watches the whole root unless you restrict it here +include_dir = [] + +# Directories to exclude from watching +# These directories will be ignored when detecting file changes +exclude_dir = [ + "tmp", + "vendor", + "node_modules", + ".git", + "testdata", + "docs", + "scripts", +] + +# File patterns to exclude from watching +# Useful for ignoring generated files, tests, or documentation +exclude_regex = [ + "_test\\.go$", + "_mock\\.go$", + "\\.pb\\.go$", + "swagger\\.json$", + "\\.md$", +] + +# File extensions to watch - only these file types trigger rebuilds +# Add more extensions if you have other file types that should trigger rebuilds +include_ext = ["go", "tpl", "tmpl", "html", "env"] + +# Specific files to exclude from watching +exclude_file = [ + "go.sum", + ".air.toml", +] + +# Specific files to watch (in addition to extension-based matching) +include_file = [] + +# Exclude unchanged files from triggering rebuilds +exclude_unchanged = false + +[color] +# Customize output colors for better readability in terminal +main = "magenta" +watcher = "cyan" +build = "yellow" +runner = "green" + +[log] +# Log output settings +# Time format for logs (Go time format) +time = true + +# Only show main binary output (hide Air's internal logs) +main_only = false + +[misc] +# Clean temporary directory on exit +clean_on_exit = true + +[screen] +# Clear screen on rebuild for cleaner output +clear_on_rebuild = true + +# Keep scroll position (set to false for always scrolling to top) +keep_scroll = true + +[proxy] +# Proxy configuration (optional, for advanced setups) +enabled = false +proxy_port = 8090 +app_port = 8080 \ No newline at end of file diff --git a/src/actions/alerts.go b/src/actions/alerts.go new file mode 100644 index 0000000..7a1b744 --- /dev/null +++ b/src/actions/alerts.go @@ -0,0 +1,388 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package actions + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "git.dcntrlized.org/Dcntrlized/Alarm/db" + "git.dcntrlized.org/Dcntrlized/Alarm/internal/alarmtoken" + "git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate" + "git.dcntrlized.org/Dcntrlized/Alarm/internal/qrpayload" + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient" + "github.com/google/uuid" +) + +// ---- gates ------------------------------------------------------------------ + +// requireOwner verifies the access token, resolves the alert and checks that +// the caller is its admin. It writes the refusal itself. +func requireOwner(w http.ResponseWriter, r *http.Request, action string, payload any) (*db.Alert, *authclient.Principal, bool) { + p, ok := principal(w, r, action) + if !ok { + return nil, nil, false + } + alertID, err := alertIDFrom(r, payload) + if err != nil { + validationError(w, "%v", err) + return nil, nil, false + } + alert, err := db.GetAlert(r.Context(), alertID) + if err != nil { + dbError(w, action, err) + return nil, nil, false + } + if alert.AdminUserID != p.ID { + Warn(action, "auth", "user=%s is not the admin of alert=%s", p.ID, alert.ID) + writeError(w, http.StatusForbidden, CodeAlertNotAdmin, "caller is not the alert admin") + return nil, nil, false + } + return alert, p, true +} + +// requireAdminToken is requireOwner plus verification of the alert's admin +// token (the second factor for live and destructive commands). +func requireAdminToken(w http.ResponseWriter, r *http.Request, action string, payload any) (*db.Alert, *authclient.Principal, bool) { + alert, p, ok := requireOwner(w, r, action, payload) + if !ok { + return nil, nil, false + } + token := adminTokenFrom(r, payload) + if token == "" { + writeError(w, http.StatusForbidden, CodeAlertTokenInvalid, "adminToken is required") + return nil, nil, false + } + if !alarmtoken.EqualHash(alert.AdminTokenHash, token) { + Warn(action, "auth", "admin token mismatch alert=%s", alert.ID) + writeError(w, http.StatusForbidden, CodeAlertTokenInvalid, "invalid admin token") + return nil, nil, false + } + return alert, p, true +} + +// isAdminOrSubscriber is the read gate for getAlert/alarmStatus/qrImage. +func isAdminOrSubscriber(r *http.Request, alert *db.Alert, userID uuid.UUID) (bool, error) { + if alert.AdminUserID == userID { + return true, nil + } + return db.IsSubscriber(r.Context(), alert.ID, userID) +} + +// ---- payload extraction ----------------------------------------------------- + +// lenientString pulls one string field out of an arbitrary payload object +// without rejecting the other fields. It is used for the credentials that ride +// the payload alongside the action's own arguments. +func lenientString(payload any, field string) string { + if payload == nil { + return "" + } + raw, err := json.Marshal(payload) + if err != nil { + return "" + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return "" + } + s, _ := m[field].(string) + return strings.TrimSpace(s) +} + +func adminTokenFrom(r *http.Request, payload any) string { + if v := strings.TrimSpace(r.URL.Query().Get("adminToken")); v != "" { + return v + } + return lenientString(payload, "adminToken") +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} + +// ---- views ------------------------------------------------------------------ + +type eventView struct { + EventID string `json:"eventId"` + Action string `json:"action"` + Message string `json:"message"` + ServerTime string `json:"serverTime"` +} + +type alertView struct { + AlertID string `json:"alertId"` + Name string `json:"name"` + Message string `json:"message"` + Status string `json:"status"` + Active bool `json:"active"` + AutoStopSeconds int `json:"autoStopSeconds"` + TokenVersion int `json:"tokenVersion"` + Created string `json:"created"` + Modified string `json:"modified"` + TriggeredAt *string `json:"triggeredAt,omitempty"` + DisarmedAt *string `json:"disarmedAt,omitempty"` + QRPayload string `json:"qrPayload"` + IsAdmin bool `json:"isAdmin"` + SubscriptionCount int `json:"subscriptionCount"` + LastEvent *eventView `json:"lastEvent,omitempty"` +} + +func newAlertView(a *db.Alert, isAdmin bool, count int, ev *db.Event, now time.Time) alertView { + v := alertView{ + AlertID: a.ID.String(), + Name: a.Name, + Message: deref(a.Message), + Status: a.Status, + Active: alertstate.IsActive(a.Status, a.TriggeredAt, a.AutoStopSeconds, now), + AutoStopSeconds: a.AutoStopSeconds, + TokenVersion: a.TokenVersion, + Created: rfc3339(a.Created), + Modified: rfc3339(a.Modified), + TriggeredAt: rfc3339Ptr(a.TriggeredAt), + DisarmedAt: rfc3339Ptr(a.DisarmedAt), + QRPayload: qrpayload.Build(a.ID, a.SubscribeToken), + IsAdmin: isAdmin, + SubscriptionCount: count, + } + if ev != nil { + v.LastEvent = &eventView{ + EventID: ev.ID.String(), + Action: ev.Kind, + Message: deref(ev.Message), + ServerTime: rfc3339(ev.CreatedAt), + } + } + return v +} + +// ---- createAlert ------------------------------------------------------------ + +type createAlertRequest struct { + Name string `json:"name"` + Message string `json:"message"` + AutoStopSeconds *int `json:"autoStopSeconds"` +} + +type createAlertResponse struct { + AlertID string `json:"alertId"` + Name string `json:"name"` + Message string `json:"message"` + Status string `json:"status"` + Active bool `json:"active"` + AdminToken string `json:"adminToken"` + SubscribeToken string `json:"subscribeToken"` + QRPayload string `json:"qrPayload"` + AutoStopSeconds int `json:"autoStopSeconds"` + TokenVersion int `json:"tokenVersion"` + Created string `json:"created"` +} + +// CreateAlert implements POST /action/createAlert. +// +// The admin token is generated here, returned in this response and never +// stored: only its SHA-256 digest reaches the database. +func CreateAlert(w http.ResponseWriter, r *http.Request, payload any) { + const action = "createAlert" + p, ok := principal(w, r, action) + if !ok { + return + } + + var req createAlertRequest + if err := decodePayload(payload, &req); err != nil { + validationError(w, "invalid payload: %v", err) + return + } + name, err := validateName(req.Name) + if err != nil { + validationError(w, "%v", err) + return + } + message, err := validateMessage(req.Message) + if err != nil { + validationError(w, "%v", err) + return + } + autoStop := 0 + if req.AutoStopSeconds != nil { + autoStop = *req.AutoStopSeconds + } + if err := validateAutoStop(autoStop); err != nil { + validationError(w, "%v", err) + return + } + + adminToken, err := alarmtoken.NewAdminToken() + if err != nil { + Error(action, "random", "%v", err) + writeError(w, http.StatusInternalServerError, CodeInternal, "could not generate credentials") + return + } + subscribeToken, err := alarmtoken.NewSubscribeToken() + if err != nil { + Error(action, "random", "%v", err) + writeError(w, http.StatusInternalServerError, CodeInternal, "could not generate credentials") + return + } + + var msgPtr *string + if strings.TrimSpace(message) != "" { + msgPtr = &message + } + + alert, err := db.CreateAlert(r.Context(), &db.Alert{ + AdminUserID: p.ID, + Name: name, + Message: msgPtr, + AdminTokenHash: alarmtoken.Hash(adminToken), + SubscribeToken: subscribeToken, + AutoStopSeconds: autoStop, + }) + if err != nil { + dbError(w, action, err) + return + } + + Debug(action, "POST", "alert=%s admin=%s autoStop=%d", alert.ID, p.ID, autoStop) + writeJSON(w, http.StatusOK, createAlertResponse{ + AlertID: alert.ID.String(), + Name: alert.Name, + Message: deref(alert.Message), + Status: alert.Status, + Active: false, + AdminToken: adminToken, + SubscribeToken: alert.SubscribeToken, + QRPayload: qrpayload.Build(alert.ID, alert.SubscribeToken), + AutoStopSeconds: alert.AutoStopSeconds, + TokenVersion: alert.TokenVersion, + Created: rfc3339(alert.Created), + }) +} + +// ---- getAlert --------------------------------------------------------------- + +// GetAlert implements GET/POST /action/getAlert. It is readable by the alert's +// admin and by any subscribed user. +func GetAlert(w http.ResponseWriter, r *http.Request, payload any) { + const action = "getAlert" + p, ok := principal(w, r, action) + if !ok { + return + } + alertID, err := alertIDFrom(r, payload) + if err != nil { + validationError(w, "%v", err) + return + } + + res, err := db.GetAlertWithLatestEvent(r.Context(), alertID) + if err != nil { + dbError(w, action, err) + return + } + isAdmin := res.Alert.AdminUserID == p.ID + if !isAdmin { + allowed, err := db.IsSubscriber(r.Context(), alertID, p.ID) + if err != nil { + dbError(w, action, err) + return + } + if !allowed { + writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") + return + } + } + count, err := db.CountSubscriptions(r.Context(), alertID) + if err != nil { + dbError(w, action, err) + return + } + + writeJSON(w, http.StatusOK, newAlertView(res.Alert, isAdmin, count, res.Event, time.Now().UTC())) +} + +// ---- deleteAlert ------------------------------------------------------------ + +// DeleteAlert implements POST/DELETE /action/deleteAlert. Subscriptions and +// events cascade. A live alarm must be disarmed first: deleting it would leave +// every ringing client with no way to learn the alarm is over. +func DeleteAlert(w http.ResponseWriter, r *http.Request, payload any) { + const action = "deleteAlert" + alert, _, ok := requireAdminToken(w, r, action, payload) + if !ok { + return + } + + now := time.Now().UTC() + if !alertstate.CanDelete(alertstate.IsActive(alert.Status, alert.TriggeredAt, alert.AutoStopSeconds, now)) { + writeError(w, http.StatusConflict, CodeAlertState, "disarm the alert before deleting it") + return + } + if err := db.DeleteAlert(r.Context(), alert.ID); err != nil { + dbError(w, action, err) + return + } + Debug(action, "POST", "alert=%s deleted", alert.ID) + writeJSON(w, http.StatusOK, map[string]any{ + "alertId": alert.ID.String(), + "deleted": true, + }) +} + +// ---- rotateSubscribeToken --------------------------------------------------- + +type rotateResponse struct { + AlertID string `json:"alertId"` + SubscribeToken string `json:"subscribeToken"` + QRPayload string `json:"qrPayload"` + TokenVersion int `json:"tokenVersion"` +} + +// RotateSubscribeToken implements POST /action/rotateSubscribeToken. Existing +// subscriptions survive: rotation invalidates the QR/join path, not the people +// who already joined. +func RotateSubscribeToken(w http.ResponseWriter, r *http.Request, payload any) { + const action = "rotateSubscribeToken" + alert, _, ok := requireAdminToken(w, r, action, payload) + if !ok { + return + } + + token, err := alarmtoken.NewSubscribeToken() + if err != nil { + Error(action, "random", "%v", err) + writeError(w, http.StatusInternalServerError, CodeInternal, "could not generate a token") + return + } + version, err := db.RotateSubscribeToken(r.Context(), alert.ID, token) + if err != nil { + dbError(w, action, err) + return + } + Debug(action, "POST", "alert=%s token rotated to version %d", alert.ID, version) + writeJSON(w, http.StatusOK, rotateResponse{ + AlertID: alert.ID.String(), + SubscribeToken: token, + QRPayload: qrpayload.Build(alert.ID, token), + TokenVersion: version, + }) +} diff --git a/src/actions/auth.go b/src/actions/auth.go new file mode 100644 index 0000000..c781f6a --- /dev/null +++ b/src/actions/auth.go @@ -0,0 +1,63 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package actions + +import ( + "net/http" + + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy" +) + +// Login proxies the Maestro-mode action to /auth/login. The {payload} envelope +// was already unwrapped by the dispatcher; the inner body is forwarded raw and +// the auth response is relayed unwrapped. +func Login(w http.ResponseWriter, r *http.Request, payload interface{}) { + authproxy.ProxyPayload(w, r, "login", payload) +} + +// Register proxies the Maestro-mode action to /auth/register. +func Register(w http.ResponseWriter, r *http.Request, payload interface{}) { + authproxy.ProxyPayload(w, r, "register", payload) +} + +// Logout proxies the logout action: POST forwards {refreshToken} so the auth +// service revokes the session (M5 client logout); GET stays a bodyless no-op +// for the legacy transition. +func Logout(w http.ResponseWriter, r *http.Request, payload interface{}) { + if r.Method == http.MethodPost { + authproxy.ProxyPayload(w, r, "logout", payload) + return + } + authproxy.ProxyEmpty(w, r, "logout") +} + +// Me proxies the GET action variant to /auth/me. The X-Access-Token header is +// forwarded unchanged so the auth service resolves the caller (boot +// revalidation, M5). +func Me(w http.ResponseWriter, r *http.Request) { + authproxy.Proxy(w, r, "me") +} + +// Refresh proxies the Maestro-mode refresh action (M5 auto-refresh path). +func Refresh(w http.ResponseWriter, r *http.Request, payload interface{}) { + authproxy.ProxyPayload(w, r, "refresh", payload) +} + +// Delete proxies the Maestro-mode delete action to /auth/delete; the payload +// carries the password only and the X-Access-Token header is forwarded. +func Delete(w http.ResponseWriter, r *http.Request, payload interface{}) { + authproxy.ProxyPayload(w, r, "delete", payload) +} diff --git a/src/actions/errors.go b/src/actions/errors.go new file mode 100644 index 0000000..7dbd98a --- /dev/null +++ b/src/actions/errors.go @@ -0,0 +1,234 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package actions implements the Alarm service's /action/* surface. +// +// Every handler follows the same shape: verify the caller's X-Access-Token +// (authclient.Authorize), validate the {payload} envelope, do the work through +// the db package, and answer with a plain JSON object. Failures use the +// platform envelope {"error":{"code","message"}} with the frozen v1 codes. +package actions + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "git.dcntrlized.org/Dcntrlized/Alarm/db" + "git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate" + "git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr" + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient" + "github.com/google/uuid" +) + +// Frozen v1 error codes, aliased from internal/apierr so the WebSocket layer +// (which cannot import this package) uses the same strings. +const ( + CodeAlertNotFound = apierr.AlertNotFound + CodeAlertTokenInvalid = apierr.AlertTokenInvalid + CodeAlertNotAdmin = apierr.AlertNotAdmin + CodeAlertAlreadyJoined = apierr.AlertAlreadyJoined + CodeAlertNotSubscribed = apierr.AlertNotSubscribed + CodeAlertState = apierr.AlertState + CodeValidation = apierr.Validation + CodeUnauthorized = apierr.Unauthorized + CodeInternal = apierr.Internal +) + +// maxMessageBytes / maxNameChars bound the free-text fields. +const ( + maxNameChars = 255 + maxMessageBytes = 2000 + maxDeviceIDLen = 128 + maxLabelLen = 128 + maxPlatformLen = 32 +) + +// errorEnvelope is the platform error body. +type errorEnvelope struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +// writeError emits {"error":{"code","message"}}. +func writeError(w http.ResponseWriter, status int, code, message string) { + var body errorEnvelope + body.Error.Code = code + body.Error.Message = message + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +// writeJSON emits a success body. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// validationError reports a client-side input problem. +func validationError(w http.ResponseWriter, format string, v ...any) { + writeError(w, http.StatusBadRequest, CodeValidation, fmt.Sprintf(format, v...)) +} + +// dbError maps the db package's sentinels onto wire codes. It returns true when +// it wrote a response. +func dbError(w http.ResponseWriter, action string, err error) bool { + switch { + case errors.Is(err, db.ErrNotFound): + writeError(w, http.StatusNotFound, CodeAlertNotFound, "alert not found") + case errors.Is(err, db.ErrNotAdmin): + writeError(w, http.StatusForbidden, CodeAlertNotAdmin, "caller is not the alert admin") + case errors.Is(err, db.ErrState): + writeError(w, http.StatusConflict, CodeAlertState, "operation not allowed in the current alert state") + default: + Error(action, "error", "%v", err) + writeError(w, http.StatusInternalServerError, CodeInternal, "internal error") + } + return true +} + +// principal verifies the X-Access-Token header. On failure it writes the 401 +// envelope and returns false. +func principal(w http.ResponseWriter, r *http.Request, action string) (*authclient.Principal, bool) { + p, err := authclient.Authorize(r) + if err != nil { + Warn(action, "auth", "rejected: %v", err) + writeError(w, http.StatusUnauthorized, CodeUnauthorized, "missing or invalid access token") + return nil, false + } + return p, true +} + +// decodePayload unmarshals the {payload} wrapper's inner value into dst. +// +// Unknown fields are deliberately ignored: the clients are shipped separately +// from the service, so a newer client sending an extra field must not be +// rejected with a 400 by an older server. +func decodePayload(payload any, dst any) error { + if payload == nil { + return errors.New("missing payload") + } + raw, err := json.Marshal(payload) + if err != nil { + return err + } + return json.NewDecoder(bytes.NewReader(raw)).Decode(dst) +} + +// alertIDFrom extracts an alert id from the query string (GET form) or from the +// action payload (POST form). Accepts `alertId` and the shorthand `id`. +func alertIDFrom(r *http.Request, payload any) (uuid.UUID, error) { + raw := strings.TrimSpace(r.URL.Query().Get("alertId")) + if raw == "" { + raw = strings.TrimSpace(r.URL.Query().Get("id")) + } + if raw == "" && payload != nil { + // Lenient read: only these two keys matter, everything else in the + // payload is ignored rather than treated as a validation failure. + raw = strings.TrimSpace(lenientString(payload, "alertId")) + if raw == "" { + raw = strings.TrimSpace(lenientString(payload, "id")) + } + } + if raw == "" { + return uuid.Nil, errors.New("alertId is required") + } + id, err := uuid.Parse(raw) + if err != nil { + return uuid.Nil, errors.New("alertId is not a valid uuid") + } + return id, nil +} + +// ---- input validation ------------------------------------------------------- + +func validateName(raw string) (string, error) { + name := strings.TrimSpace(raw) + if name == "" { + return "", errors.New("name is required") + } + if len([]rune(name)) > maxNameChars { + return "", fmt.Errorf("name must be at most %d characters", maxNameChars) + } + return name, nil +} + +func validateMessage(raw string) (string, error) { + if len(raw) > maxMessageBytes { + return "", fmt.Errorf("message must be at most %d bytes", maxMessageBytes) + } + return raw, nil +} + +func validateAutoStop(seconds int) error { + if seconds < 0 || seconds > alertstate.MaxAutoStopSeconds { + return fmt.Errorf("autoStopSeconds must be between 0 and %d", alertstate.MaxAutoStopSeconds) + } + return nil +} + +func validateDeviceID(raw string) (string, error) { + id := strings.TrimSpace(raw) + if id == "" { + return "", errors.New("deviceId is required") + } + if len(id) > maxDeviceIDLen { + return "", fmt.Errorf("deviceId must be at most %d characters", maxDeviceIDLen) + } + for i := 0; i < len(id); i++ { + if id[i] < 0x20 || id[i] == 0x7f { + return "", errors.New("deviceId must not contain control characters") + } + } + return id, nil +} + +func validateOptional(raw string, field string, max int) (*string, error) { + s := strings.TrimSpace(raw) + if s == "" { + return nil, nil + } + if len(s) > max { + return nil, fmt.Errorf("%s must be at most %d characters", field, max) + } + for i := 0; i < len(s); i++ { + if s[i] < 0x20 || s[i] == 0x7f { + return nil, fmt.Errorf("%s must not contain control characters", field) + } + } + return &s, nil +} + +// ---- shared views ----------------------------------------------------------- + +// rfc3339 renders a timestamp for the wire: RFC3339 in UTC. +func rfc3339(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) } + +// rfc3339Ptr renders an optional timestamp, omitting it when unset. +func rfc3339Ptr(t *time.Time) *string { + if t == nil { + return nil + } + s := rfc3339(*t) + return &s +} diff --git a/src/actions/events.go b/src/actions/events.go new file mode 100644 index 0000000..15a0548 --- /dev/null +++ b/src/actions/events.go @@ -0,0 +1,198 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package actions + +import ( + "net/http" + "strings" + "time" + + "git.dcntrlized.org/Dcntrlized/Alarm/db" + "git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate" + "git.dcntrlized.org/Dcntrlized/Alarm/realtime" +) + +// actionResponse is the trigger/disarm result. +type actionResponse struct { + EventID string `json:"eventId"` + Action string `json:"action"` + ServerTime string `json:"serverTime"` + AutoStopSeconds int `json:"autoStopSeconds"` + Delivered int `json:"delivered"` +} + +type triggerRequest struct { + AlertID string `json:"alertId"` + AdminToken string `json:"adminToken"` + Message *string `json:"message"` +} + +// TriggerAlert implements POST /action/triggerAlert. +// +// The state transition and the event row are written in one transaction, and +// the schema's NOTIFY trigger publishes the event on commit, so every service +// instance fans it out. This instance additionally broadcasts in-process for +// the fast path; the hub de-duplicates the two by event id. +func TriggerAlert(w http.ResponseWriter, r *http.Request, payload any) { + const action = "triggerAlert" + alert, p, ok := requireAdminToken(w, r, action, payload) + if !ok { + return + } + + var req triggerRequest + _ = decodePayload(payload, &req) + message := alert.Message + if req.Message != nil { + m, err := validateMessage(*req.Message) + if err != nil { + validationError(w, "%v", err) + return + } + if strings.TrimSpace(m) != "" { + message = &m + } + } + + updated, event, err := db.TriggerAlert(r.Context(), alert.ID, p.ID, message) + if err != nil { + dbError(w, action, err) + return + } + + delivered := 0 + if hub := realtime.Current(); hub != nil { + delivered = hub.Broadcast(updated.ID, realtime.AlarmFrame{ + Type: "alarm", + AlertID: updated.ID.String(), + EventID: event.ID.String(), + Action: alertstate.KindTrigger, + Message: deref(event.Message), + ServerTime: rfc3339(event.CreatedAt), + AutoStopSeconds: updated.AutoStopSeconds, + }) + } + + Debug(action, "POST", "alert=%s event=%s delivered=%d", updated.ID, event.ID, delivered) + writeJSON(w, http.StatusOK, actionResponse{ + EventID: event.ID.String(), + Action: alertstate.KindTrigger, + ServerTime: rfc3339(event.CreatedAt), + AutoStopSeconds: updated.AutoStopSeconds, + Delivered: delivered, + }) +} + +// DisarmAlert implements POST /action/disarmAlert. +func DisarmAlert(w http.ResponseWriter, r *http.Request, payload any) { + const action = "disarmAlert" + alert, p, ok := requireAdminToken(w, r, action, payload) + if !ok { + return + } + + updated, event, err := db.DisarmAlert(r.Context(), alert.ID, p.ID, nil) + if err != nil { + dbError(w, action, err) + return + } + + delivered := 0 + if hub := realtime.Current(); hub != nil { + delivered = hub.Broadcast(updated.ID, realtime.AlarmFrame{ + Type: "alarm", + AlertID: updated.ID.String(), + EventID: event.ID.String(), + Action: alertstate.KindDisarm, + Message: deref(event.Message), + ServerTime: rfc3339(event.CreatedAt), + AutoStopSeconds: updated.AutoStopSeconds, + }) + } + + Debug(action, "POST", "alert=%s event=%s delivered=%d", updated.ID, event.ID, delivered) + writeJSON(w, http.StatusOK, actionResponse{ + EventID: event.ID.String(), + Action: alertstate.KindDisarm, + ServerTime: rfc3339(event.CreatedAt), + AutoStopSeconds: updated.AutoStopSeconds, + Delivered: delivered, + }) +} + +// statusView is the alarmStatus polling fallback payload (frozen field set). +type statusView struct { + Active bool `json:"active"` + EventID string `json:"eventId"` + Action string `json:"action"` + Message string `json:"message"` + ServerTime string `json:"serverTime"` + AutoStopSeconds int `json:"autoStopSeconds"` +} + +// AlarmStatus implements GET/POST /action/alarmStatus: the cheap, indexed +// fallback a client polls when its socket is down or it was killed mid-alarm. +// +// `active` is derived (status + auto-stop window), never stored, so no +// background job is needed to expire an alarm. +func AlarmStatus(w http.ResponseWriter, r *http.Request, payload any) { + const action = "alarmStatus" + p, ok := principal(w, r, action) + if !ok { + return + } + alertID, err := alertIDFrom(r, payload) + if err != nil { + validationError(w, "%v", err) + return + } + + res, err := db.GetAlertWithLatestEvent(r.Context(), alertID) + if err != nil { + dbError(w, action, err) + return + } + allowed, err := isAdminOrSubscriber(r, res.Alert, p.ID) + if err != nil { + dbError(w, action, err) + return + } + if !allowed { + writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") + return + } + + now := time.Now().UTC() + active := alertstate.IsActive(res.Alert.Status, res.Alert.TriggeredAt, res.Alert.AutoStopSeconds, now) + if res.Event == nil || res.Event.Kind != alertstate.KindTrigger { + // A never-triggered alert, or one whose newest event is a disarm, is + // not live even if the stored status says "triggered". + active = false + } + + view := statusView{ + Active: active, + ServerTime: rfc3339(now), + AutoStopSeconds: res.Alert.AutoStopSeconds, + } + if res.Event != nil { + view.EventID = res.Event.ID.String() + view.Action = res.Event.Kind + view.Message = deref(res.Event.Message) + view.ServerTime = rfc3339(res.Event.CreatedAt) + } + writeJSON(w, http.StatusOK, view) +} diff --git a/src/actions/logging.go b/src/actions/logging.go new file mode 100644 index 0000000..da220c5 --- /dev/null +++ b/src/actions/logging.go @@ -0,0 +1,52 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package actions + +import ( + "fmt" + + "git.dcntrlized.org/Dcntrlized/Alarm/logger" +) + +func init() { + for _, name := range []string{ + "git.dcntrlized.org/Dcntrlized/Alarm/actions.Debug", + "git.dcntrlized.org/Dcntrlized/Alarm/actions.Warn", + "git.dcntrlized.org/Dcntrlized/Alarm/actions.Error", + "git.dcntrlized.org/Dcntrlized/Alarm/actions.logAction", + } { + logger.RegisterLogHelper(name) + } +} + +// Debug logs at debug level. Callers must never pass tokens or credentials. +func Debug(action, verb, format string, v ...any) { + logAction(logger.Debug, action, verb, format, v...) +} + +// Warn logs at warn level. Callers must never pass tokens or credentials. +func Warn(action, verb, format string, v ...any) { + logAction(logger.Warn, action, verb, format, v...) +} + +// Error logs at error level. Callers must never pass tokens or credentials. +func Error(action, verb, format string, v ...any) { + logAction(logger.Error, action, verb, format, v...) +} + +func logAction(logf func(format string, v ...any), action, verb, format string, v ...any) { + logf("[action=%s verb=%s] %s", action, verb, fmt.Sprintf(format, v...)) +} diff --git a/src/actions/qr.go b/src/actions/qr.go new file mode 100644 index 0000000..af2c3d9 --- /dev/null +++ b/src/actions/qr.go @@ -0,0 +1,130 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package actions + +import ( + "errors" + "net/http" + "strconv" + + "git.dcntrlized.org/Dcntrlized/Alarm/db" + "git.dcntrlized.org/Dcntrlized/Alarm/internal/qr" + "git.dcntrlized.org/Dcntrlized/Alarm/internal/qrpayload" +) + +// QR rendering defaults: 8 px per module and the 4-module quiet zone the QR +// specification requires for reliable scanning. +const ( + defaultQRScale = 8 + defaultQRQuietZone = 4 +) + +// QRImage implements GET/POST /action/qrImage: the PNG of the alert's QR code. +// +// It is a convenience — the Android client renders the same payload locally — +// so it is deliberately simple: admin or subscriber, no caching, no ETag. The +// QR encodes the subscribe token, which both audiences already hold (the admin +// through createAlert/qrPayload, the subscriber through the token they scanned). +func QRImage(w http.ResponseWriter, r *http.Request, payload any) { + const action = "qrImage" + p, ok := principal(w, r, action) + if !ok { + return + } + alertID, err := alertIDFrom(r, payload) + if err != nil { + validationError(w, "%v", err) + return + } + + alert, err := db.GetAlert(r.Context(), alertID) + if err != nil { + dbError(w, action, err) + return + } + allowed, err := isAdminOrSubscriber(r, alert, p.ID) + if err != nil { + dbError(w, action, err) + return + } + if !allowed { + writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "not subscribed to this alert") + return + } + + scale, err := intQuery(r, "scale", defaultQRScale, 1, qr.MaxScale) + if err != nil { + validationError(w, "%v", err) + return + } + // `margin` is the more common name for the quiet zone; accept either. + quiet := defaultQRQuietZone + quietParam := "quietZone" + if r.URL.Query().Get(quietParam) == "" { + quietParam = "margin" + } + if r.URL.Query().Get(quietParam) != "" { + // The lower bound is the ISO minimum, not 0: a symbol with a smaller + // quiet zone is not reliably scannable, so reject it as a validation + // error here instead of serving an image no scanner can read. + quiet, err = intQuery(r, quietParam, defaultQRQuietZone, qr.MinQuietZone, qr.MaxQuietZone) + if err != nil { + validationError(w, "%v", err) + return + } + } + + matrix, err := qr.EncodeString(qrpayload.Build(alert.ID, alert.SubscribeToken)) + if err != nil { + Error(action, "encode", "%v", err) + writeError(w, http.StatusInternalServerError, CodeInternal, "could not encode the QR code") + return + } + pngBytes, err := qr.PNG(matrix, scale, quiet) + if err != nil { + if errors.Is(err, qr.ErrBadMatrix) { + Error(action, "render", "%v", err) + writeError(w, http.StatusInternalServerError, CodeInternal, "could not render the QR code") + return + } + validationError(w, "%v", err) + return + } + + Debug(action, "GET", "alert=%s modules=%d scale=%d quiet=%d", alert.ID, len(matrix), scale, quiet) + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Content-Length", strconv.Itoa(len(pngBytes))) + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(pngBytes) +} + +// intQuery reads a bounded integer query parameter. +func intQuery(r *http.Request, name string, def, min, max int) (int, error) { + raw := r.URL.Query().Get(name) + if raw == "" { + return def, nil + } + v, err := strconv.Atoi(raw) + if err != nil { + return 0, errors.New(name + " must be an integer") + } + if v < min || v > max { + return 0, errors.New(name + " is out of range") + } + return v, nil +} diff --git a/src/actions/subscriptions.go b/src/actions/subscriptions.go new file mode 100644 index 0000000..461c74d --- /dev/null +++ b/src/actions/subscriptions.go @@ -0,0 +1,254 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package actions + +import ( + "errors" + "net/http" + "strings" + + "git.dcntrlized.org/Dcntrlized/Alarm/db" + "git.dcntrlized.org/Dcntrlized/Alarm/internal/alarmtoken" + "github.com/google/uuid" +) + +type subscriptionView struct { + ID string `json:"id"` + UserID string `json:"userId"` + Username string `json:"username,omitempty"` + DeviceID string `json:"deviceId"` + DeviceLabel string `json:"deviceLabel,omitempty"` + Platform string `json:"platform,omitempty"` + Enabled bool `json:"enabled"` + LastSeenAt *string `json:"lastSeenAt,omitempty"` + Created string `json:"created"` +} + +// newSubscriptionView maps a row to the wire view. +// +// `username` is deliberately never populated: it lives in the auth service's +// schema, and the Alarm service does not read another service's tables. The +// field is omitted rather than sent empty. +func newSubscriptionView(s *db.Subscription) subscriptionView { + return subscriptionView{ + ID: s.ID.String(), + UserID: s.SubscriberUserID.String(), + DeviceID: s.DeviceID, + DeviceLabel: deref(s.DeviceLabel), + Platform: deref(s.Platform), + Enabled: s.Enabled, + LastSeenAt: rfc3339Ptr(s.LastSeenAt), + Created: rfc3339(s.Created), + } +} + +// ---- joinAlert -------------------------------------------------------------- + +type joinRequest struct { + AlertID string `json:"alertId"` + SubscribeToken string `json:"subscribeToken"` + DeviceID string `json:"deviceId"` + DeviceLabel string `json:"deviceLabel"` + Platform string `json:"platform"` +} + +// JoinAlert implements POST /action/joinAlert. +// +// Idempotency (frozen contract): a repeat for the same +// (alertId, user, deviceId) returns the existing subscription with 200 and +// creates nothing. A different deviceId creates a second subscription for the +// same user. ERR_ALERT_ALREADY_JOINED is reserved for the genuine concurrent +// race, where two inserts for the same device hit the unique index at once. +func JoinAlert(w http.ResponseWriter, r *http.Request, payload any) { + const action = "joinAlert" + p, ok := principal(w, r, action) + if !ok { + return + } + + var req joinRequest + if err := decodePayload(payload, &req); err != nil { + validationError(w, "invalid payload: %v", err) + return + } + alertID, err := uuid.Parse(strings.TrimSpace(req.AlertID)) + if err != nil { + validationError(w, "alertId is not a valid uuid") + return + } + deviceID, err := validateDeviceID(req.DeviceID) + if err != nil { + validationError(w, "%v", err) + return + } + label, err := validateOptional(req.DeviceLabel, "deviceLabel", maxLabelLen) + if err != nil { + validationError(w, "%v", err) + return + } + platform, err := validateOptional(req.Platform, "platform", maxPlatformLen) + if err != nil { + validationError(w, "%v", err) + return + } + if !alarmtoken.ValidSubscribeTokenShape(strings.TrimSpace(req.SubscribeToken)) { + writeError(w, http.StatusForbidden, CodeAlertTokenInvalid, "invalid subscribe token") + return + } + + alert, err := db.GetAlert(r.Context(), alertID) + if err != nil { + dbError(w, action, err) + return + } + if !alarmtoken.EqualToken(alert.SubscribeToken, strings.TrimSpace(req.SubscribeToken)) { + Warn(action, "auth", "subscribe token mismatch alert=%s user=%s", alert.ID, p.ID) + writeError(w, http.StatusForbidden, CodeAlertTokenInvalid, "invalid subscribe token") + return + } + + existing, err := db.GetSubscriptionByDevice(r.Context(), alertID, p.ID, deviceID) + switch { + case err == nil: + Debug(action, "POST", "idempotent re-join alert=%s sub=%s device=%s", alertID, existing.ID, deviceID) + writeJSON(w, http.StatusOK, newSubscriptionView(existing)) + return + case !errors.Is(err, db.ErrNotFound): + dbError(w, action, err) + return + } + + sub, err := db.CreateSubscription(r.Context(), &db.Subscription{ + AlertID: alertID, + SubscriberUserID: p.ID, + DeviceID: deviceID, + DeviceLabel: label, + Platform: platform, + }) + if errors.Is(err, db.ErrConflict) { + // Two concurrent joins for the same device: the sequential repeat path + // above already handled the normal re-scan case. + Warn(action, "POST", "concurrent duplicate join alert=%s user=%s device=%s", alertID, p.ID, deviceID) + writeError(w, http.StatusConflict, CodeAlertAlreadyJoined, "device is already subscribed to this alert") + return + } + if err != nil { + dbError(w, action, err) + return + } + + Debug(action, "POST", "joined alert=%s user=%s sub=%s device=%s", alertID, p.ID, sub.ID, deviceID) + writeJSON(w, http.StatusOK, newSubscriptionView(sub)) +} + +// ---- listSubscriptions ------------------------------------------------------ + +type listSubscriptionsResponse struct { + Items []subscriptionView `json:"items"` +} + +// ListSubscriptions implements GET/POST /action/listSubscriptions. Admin only. +func ListSubscriptions(w http.ResponseWriter, r *http.Request, payload any) { + const action = "listSubscriptions" + alert, _, ok := requireOwner(w, r, action, payload) + if !ok { + return + } + + subs, err := db.ListSubscriptions(r.Context(), alert.ID) + if err != nil { + dbError(w, action, err) + return + } + items := make([]subscriptionView, 0, len(subs)) + for i := range subs { + items = append(items, newSubscriptionView(&subs[i])) + } + writeJSON(w, http.StatusOK, listSubscriptionsResponse{Items: items}) +} + +// ---- removeSubscription ----------------------------------------------------- + +type removeSubscriptionRequest struct { + AlertID string `json:"alertId"` + SubscriptionID string `json:"subscriptionId"` +} + +// RemoveSubscription implements POST/DELETE /action/removeSubscription. The +// alert's admin may remove any device; a subscriber may remove their own. +func RemoveSubscription(w http.ResponseWriter, r *http.Request, payload any) { + const action = "removeSubscription" + p, ok := principal(w, r, action) + if !ok { + return + } + + var req removeSubscriptionRequest + _ = decodePayload(payload, &req) + if req.SubscriptionID == "" { + req.SubscriptionID = strings.TrimSpace(r.URL.Query().Get("subscriptionId")) + } + if req.AlertID == "" { + req.AlertID = strings.TrimSpace(r.URL.Query().Get("alertId")) + } + alertID, err := uuid.Parse(strings.TrimSpace(req.AlertID)) + if err != nil { + validationError(w, "alertId is not a valid uuid") + return + } + subscriptionID, err := uuid.Parse(strings.TrimSpace(req.SubscriptionID)) + if err != nil { + validationError(w, "subscriptionId is not a valid uuid") + return + } + + sub, err := db.GetSubscription(r.Context(), alertID, subscriptionID) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "subscription not found") + return + } + dbError(w, action, err) + return + } + + if sub.SubscriberUserID != p.ID { + // Not the owner: only the alert admin may remove someone else's device. + // A stranger gets the same code as a missing row so the endpoint never + // confirms that the subscription exists. + alert, err := db.GetAlert(r.Context(), alertID) + if err != nil { + dbError(w, action, err) + return + } + if alert.AdminUserID != p.ID { + Warn(action, "auth", "user=%s may not remove sub=%s", p.ID, subscriptionID) + writeError(w, http.StatusNotFound, CodeAlertNotSubscribed, "subscription not found") + return + } + } + + if err := db.DeleteSubscription(r.Context(), alertID, subscriptionID); err != nil { + dbError(w, action, err) + return + } + Debug(action, "POST", "removed sub=%s alert=%s by=%s", subscriptionID, alertID, p.ID) + writeJSON(w, http.StatusOK, map[string]any{ + "alertId": alertID.String(), + "subscriptionId": subscriptionID.String(), + "removed": true, + }) +} diff --git a/src/db/alerts.go b/src/db/alerts.go new file mode 100644 index 0000000..76362ec --- /dev/null +++ b/src/db/alerts.go @@ -0,0 +1,176 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package db + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// Alert is one alarm.alerts row. +type Alert struct { + ID uuid.UUID + AdminUserID uuid.UUID + Name string + Message *string + Status string + AdminTokenHash []byte + SubscribeToken string + TokenVersion int + AutoStopSeconds int + Created time.Time + Modified time.Time + TriggeredAt *time.Time + DisarmedAt *time.Time +} + +const alertColumns = `id, admin_user_id, name, message, status::text, admin_token_hash, + subscribe_token, token_version, auto_stop_seconds, created, modified, triggered_at, disarmed_at` + +// alertColumnsAliased is the same list qualified with the `a` alias used by the +// join in GetAlertWithLatestEvent. +const alertColumnsAliased = `a.id, a.admin_user_id, a.name, a.message, a.status::text, a.admin_token_hash, + a.subscribe_token, a.token_version, a.auto_stop_seconds, a.created, a.modified, a.triggered_at, a.disarmed_at` + +func scanAlert(row pgx.Row) (*Alert, error) { + var a Alert + err := row.Scan(&a.ID, &a.AdminUserID, &a.Name, &a.Message, &a.Status, &a.AdminTokenHash, + &a.SubscribeToken, &a.TokenVersion, &a.AutoStopSeconds, &a.Created, &a.Modified, + &a.TriggeredAt, &a.DisarmedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &a, nil +} + +// CreateAlert inserts a new alert and returns the stored row. +func CreateAlert(ctx context.Context, a *Alert) (*Alert, error) { + const q = `INSERT INTO alarm.alerts + (admin_user_id, name, message, admin_token_hash, subscribe_token, auto_stop_seconds) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING ` + alertColumns + return scanAlert(Pool().QueryRow(ctx, q, + a.AdminUserID, a.Name, a.Message, a.AdminTokenHash, a.SubscribeToken, a.AutoStopSeconds)) +} + +// GetAlert fetches one alert by id. It returns ErrNotFound when the row is gone. +func GetAlert(ctx context.Context, alertID uuid.UUID) (*Alert, error) { + const q = `SELECT ` + alertColumns + ` FROM alarm.alerts WHERE id = $1` + return scanAlert(Pool().QueryRow(ctx, q, alertID)) +} + +// DeleteAlert removes an alert; subscriptions and events cascade. It reports +// ErrNotFound when nothing was deleted, so a repeated delete is visible. +func DeleteAlert(ctx context.Context, alertID uuid.UUID) error { + tag, err := Pool().Exec(ctx, `DELETE FROM alarm.alerts WHERE id = $1`, alertID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// RotateSubscribeToken replaces the subscribe token and bumps token_version. +// Existing subscriptions are untouched: rotation invalidates the QR/join path, +// not the subscribers who already joined. +func RotateSubscribeToken(ctx context.Context, alertID uuid.UUID, newToken string) (int, error) { + var version int + err := Pool().QueryRow(ctx, + `UPDATE alarm.alerts SET subscribe_token = $2, token_version = token_version + 1 + WHERE id = $1 RETURNING token_version`, alertID, newToken).Scan(&version) + if errors.Is(err, pgx.ErrNoRows) { + return 0, ErrNotFound + } + if err != nil { + return 0, err + } + return version, nil +} + +// CountSubscriptions returns how many devices are subscribed to an alert. +func CountSubscriptions(ctx context.Context, alertID uuid.UUID) (int, error) { + var n int + err := Pool().QueryRow(ctx, + `SELECT COUNT(*) FROM alarm.subscriptions WHERE alert_id = $1`, alertID).Scan(&n) + return n, err +} + +// AlertWithLatestEvent is the cheap single-round-trip read behind alarmStatus +// and getAlert: the alert row plus its newest event (nil when it has none). +type AlertWithLatestEvent struct { + Alert *Alert + Event *Event +} + +// GetAlertWithLatestEvent runs one indexed query: the alert plus a LATERAL +// lookup of its newest event on (alert_id, created_at DESC, id DESC). +func GetAlertWithLatestEvent(ctx context.Context, alertID uuid.UUID) (*AlertWithLatestEvent, error) { + const q = ` + SELECT ` + alertColumnsAliased + `, + e.id, e.alert_id, e.kind::text, e.actor_user_id, e.message, e.created_at + FROM alarm.alerts a + LEFT JOIN LATERAL ( + SELECT id, alert_id, kind, actor_user_id, message, created_at + FROM alarm.alarm_events + WHERE alert_id = a.id + ORDER BY created_at DESC, id DESC + LIMIT 1 + ) e ON TRUE + WHERE a.id = $1` + + var a Alert + var ( + evID *uuid.UUID + evAlertID *uuid.UUID + evKind *string + evActor *uuid.UUID + evMessage *string + evCreated *time.Time + ) + err := Pool().QueryRow(ctx, q, alertID).Scan( + &a.ID, &a.AdminUserID, &a.Name, &a.Message, &a.Status, &a.AdminTokenHash, + &a.SubscribeToken, &a.TokenVersion, &a.AutoStopSeconds, &a.Created, &a.Modified, + &a.TriggeredAt, &a.DisarmedAt, + &evID, &evAlertID, &evKind, &evActor, &evMessage, &evCreated, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + out := &AlertWithLatestEvent{Alert: &a} + if evID != nil { + out.Event = &Event{ + ID: *evID, + AlertID: *evAlertID, + Kind: *evKind, + ActorUserID: *evActor, + Message: evMessage, + CreatedAt: *evCreated, + } + } + return out, nil +} diff --git a/src/db/db.go b/src/db/db.go new file mode 100644 index 0000000..23bb1b3 --- /dev/null +++ b/src/db/db.go @@ -0,0 +1,146 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package db owns the Alarm service's PostgreSQL access: the pool bootstrap and +// one file per table family (alerts, subscriptions, events). All state +// transitions that must be atomic (trigger/disarm) run inside a transaction +// with the alert row locked, so two concurrent admins cannot interleave. +package db + +import ( + "context" + "errors" + "log" + "os" + "strings" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Sentinel errors mapped to the wire error codes by the action layer. +var ( + // ErrNotFound is returned when a row does not exist. + ErrNotFound = errors.New("db: not found") + // ErrConflict is returned for a unique-constraint violation (23505). + ErrConflict = errors.New("db: conflict") + // ErrNotAdmin is returned when the caller is not the alert's admin. + ErrNotAdmin = errors.New("db: caller is not the alert admin") + // ErrState is returned when a transition is illegal for the current status. + ErrState = errors.New("db: illegal state transition") +) + +// DB is the process-wide connection pool holder. +type DB struct { + Pool *pgxpool.Pool +} + +var ( + instance *DB + once sync.Once +) + +// DatabaseURL returns DATABASE_URL, defaulting to the shared database as +// reachable by DNS on the dcntrlized network. Host-side development overrides +// it (e.g. postgres://postgres:dcntrlized@localhost:55432/dcntrlized). The +// shared database authenticates with scram-sha-256, hence the shared password +// in the default; Maestro injects its own DSN for registry-managed services. +func DatabaseURL() string { + if url := os.Getenv("DATABASE_URL"); url != "" { + return url + } + return "postgres://postgres:dcntrlized@dcntrlized-db:5432/dcntrlized?sslmode=disable" +} + +// Pool returns the process-wide pool, creating it on first use. Creation +// tolerates startup ordering: the shared database container (or the migrate +// one-shot applying the schema) may not be ready when this service boots, so +// the pool pings until it is reachable. +func Pool() *pgxpool.Pool { + once.Do(func() { + instance = newDB() + }) + return instance.Pool +} + +// NewDB creates (or returns) the pool and is safe to call more than once. +func NewDB() *DB { + Pool() + return instance +} + +func newDB() *DB { + url := DatabaseURL() + pool, err := pgxpool.New(context.Background(), url) + if err != nil { + log.Fatalf("Unable to create database pool: %v", err) + } + + pingCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + for { + if err := pool.Ping(pingCtx); err == nil { + break + } else { + select { + case <-pingCtx.Done(): + pool.Close() + log.Fatalf("Database %s unreachable within 30s: %v", redact(url), err) + case <-time.After(500 * time.Millisecond): + } + } + } + return &DB{Pool: pool} +} + +// Close releases the pool. +func Close() { + if instance != nil && instance.Pool != nil { + instance.Pool.Close() + } +} + +// redact strips the password from a DSN before it can reach a log line: +// postgres://user:password@host -> postgres://user:***@host. +func redact(dsn string) string { + scheme := strings.Index(dsn, "://") + if scheme < 0 { + return dsn + } + at := strings.LastIndexByte(dsn, '@') + if at < 0 { + return dsn + } + colon := strings.IndexByte(dsn[scheme+3:], ':') + if colon < 0 { + return dsn + } + colon += scheme + 3 + if colon >= at { + return dsn + } + return dsn[:colon+1] + "***" + dsn[at:] +} + +// isUniqueViolation reports whether err is a PostgreSQL 23505. +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return pgErr.Code == "23505" + } + return false +} diff --git a/src/db/events.go b/src/db/events.go new file mode 100644 index 0000000..b2da8f4 --- /dev/null +++ b/src/db/events.go @@ -0,0 +1,144 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package db + +import ( + "context" + "errors" + "time" + + "git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// Event is one alarm.alarm_events row (append-only trigger/disarm log). +type Event struct { + ID uuid.UUID + AlertID uuid.UUID + Kind string + ActorUserID uuid.UUID + Message *string + CreatedAt time.Time +} + +const eventColumns = `id, alert_id, kind::text, actor_user_id, message, created_at` + +func scanEvent(row pgx.Row) (*Event, error) { + var e Event + err := row.Scan(&e.ID, &e.AlertID, &e.Kind, &e.ActorUserID, &e.Message, &e.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &e, nil +} + +// LatestEvent returns the newest event for an alert (ErrNotFound when the alert +// has never fired). It is the indexed read behind alarmStatus. +func LatestEvent(ctx context.Context, alertID uuid.UUID) (*Event, error) { + const q = `SELECT ` + eventColumns + ` + FROM alarm.alarm_events WHERE alert_id = $1 + ORDER BY created_at DESC, id DESC LIMIT 1` + return scanEvent(Pool().QueryRow(ctx, q, alertID)) +} + +// TriggerAlert transitions an alert to triggered and appends the event, in one +// transaction with the alert row locked. Re-triggering a live alert is allowed +// and restarts the auto-stop window; it produces a new event id so every client +// re-fires and the hub's event-id de-duplication does not swallow it. +// +// The caller must already have verified the admin token; this function +// re-checks the actor against admin_user_id so the check cannot be skipped. +func TriggerAlert(ctx context.Context, alertID, actorUserID uuid.UUID, message *string) (*Alert, *Event, error) { + return transition(ctx, alertID, actorUserID, alertstate.KindTrigger, message, nil) +} + +// DisarmAlert transitions an alert to disarmed and appends the event. Disarming +// an alert that is not triggered is ErrState (the client's view is stale). +func DisarmAlert(ctx context.Context, alertID, actorUserID uuid.UUID, message *string) (*Alert, *Event, error) { + guard := func(a *Alert) error { + if !alertstate.CanDisarm(a.Status) { + return ErrState + } + return nil + } + return transition(ctx, alertID, actorUserID, alertstate.KindDisarm, message, guard) +} + +func transition( + ctx context.Context, + alertID, actorUserID uuid.UUID, + kind string, + message *string, + guard func(*Alert) error, +) (*Alert, *Event, error) { + tx, err := Pool().Begin(ctx) + if err != nil { + return nil, nil, err + } + defer func() { _ = tx.Rollback(ctx) }() + + // Lock the row for the whole transition so two concurrent commands cannot + // interleave their status writes. + current, err := scanAlert(tx.QueryRow(ctx, + `SELECT `+alertColumns+` FROM alarm.alerts WHERE id = $1 FOR UPDATE`, alertID)) + if err != nil { + return nil, nil, err + } + if current.AdminUserID != actorUserID { + return nil, nil, ErrNotAdmin + } + if guard != nil { + if err := guard(current); err != nil { + return nil, nil, err + } + } + + var updated *Alert + switch kind { + case alertstate.KindTrigger: + updated, err = scanAlert(tx.QueryRow(ctx, + `UPDATE alarm.alerts SET status = 'triggered', triggered_at = NOW() + WHERE id = $1 RETURNING `+alertColumns, alertID)) + case alertstate.KindDisarm: + updated, err = scanAlert(tx.QueryRow(ctx, + `UPDATE alarm.alerts SET status = 'disarmed', disarmed_at = NOW() + WHERE id = $1 RETURNING `+alertColumns, alertID)) + default: + return nil, nil, errors.New("db: unknown event kind") + } + if err != nil { + return nil, nil, err + } + + event, err := scanEvent(tx.QueryRow(ctx, + `INSERT INTO alarm.alarm_events (alert_id, kind, actor_user_id, message) + VALUES ($1, $2, $3, $4) RETURNING `+eventColumns, + alertID, kind, actorUserID, message)) + if err != nil { + return nil, nil, err + } + + // The NOTIFY trigger fires on COMMIT, so no frame can be observed before + // the new status is visible to every reader. + if err := tx.Commit(ctx); err != nil { + return nil, nil, err + } + return updated, event, nil +} diff --git a/src/db/subscriptions.go b/src/db/subscriptions.go new file mode 100644 index 0000000..561c621 --- /dev/null +++ b/src/db/subscriptions.go @@ -0,0 +1,164 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package db + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// Subscription is one alarm.subscriptions row: a device that receives the alarm. +type Subscription struct { + ID uuid.UUID + AlertID uuid.UUID + SubscriberUserID uuid.UUID + DeviceID string + DeviceLabel *string + Platform *string + Enabled bool + LastSeenAt *time.Time + Created time.Time +} + +const subscriptionColumns = `id, alert_id, subscriber_user_id, device_id, device_label, + platform, enabled, last_seen_at, created` + +func scanSubscription(row pgx.Row) (*Subscription, error) { + var s Subscription + err := row.Scan(&s.ID, &s.AlertID, &s.SubscriberUserID, &s.DeviceID, &s.DeviceLabel, + &s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return &s, nil +} + +// CreateSubscription inserts a subscription. A duplicate +// (alert_id, subscriber_user_id, device_id) is reported as ErrConflict, which +// the join action turns into the ERR_ALERT_ALREADY_JOINED race fallback — the +// sequential re-scan path is handled by the caller's lookup and never reaches +// this error. +func CreateSubscription(ctx context.Context, s *Subscription) (*Subscription, error) { + const q = `INSERT INTO alarm.subscriptions + (alert_id, subscriber_user_id, device_id, device_label, platform) + VALUES ($1, $2, $3, $4, $5) + RETURNING ` + subscriptionColumns + row, err := scanSubscription(Pool().QueryRow(ctx, q, + s.AlertID, s.SubscriberUserID, s.DeviceID, s.DeviceLabel, s.Platform)) + if isUniqueViolation(err) { + return nil, ErrConflict + } + return row, err +} + +// GetSubscriptionByDevice is the idempotency lookup for joinAlert. +func GetSubscriptionByDevice(ctx context.Context, alertID, userID uuid.UUID, deviceID string) (*Subscription, error) { + const q = `SELECT ` + subscriptionColumns + ` + FROM alarm.subscriptions + WHERE alert_id = $1 AND subscriber_user_id = $2 AND device_id = $3` + return scanSubscription(Pool().QueryRow(ctx, q, alertID, userID, deviceID)) +} + +// GetSubscription fetches one subscription scoped to its alert. +func GetSubscription(ctx context.Context, alertID, subscriptionID uuid.UUID) (*Subscription, error) { + const q = `SELECT ` + subscriptionColumns + ` + FROM alarm.subscriptions WHERE alert_id = $1 AND id = $2` + return scanSubscription(Pool().QueryRow(ctx, q, alertID, subscriptionID)) +} + +// ListSubscriptions returns every device subscribed to an alert, oldest first. +func ListSubscriptions(ctx context.Context, alertID uuid.UUID) ([]Subscription, error) { + const q = `SELECT ` + subscriptionColumns + ` + FROM alarm.subscriptions WHERE alert_id = $1 ORDER BY created, id` + rows, err := Pool().Query(ctx, q, alertID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]Subscription, 0, 4) + for rows.Next() { + var s Subscription + if err := rows.Scan(&s.ID, &s.AlertID, &s.SubscriberUserID, &s.DeviceID, &s.DeviceLabel, + &s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +// DeleteSubscription removes one subscription. ErrNotFound when it was already +// gone, so a repeated removal is visible to the caller. +func DeleteSubscription(ctx context.Context, alertID, subscriptionID uuid.UUID) error { + tag, err := Pool().Exec(ctx, + `DELETE FROM alarm.subscriptions WHERE alert_id = $1 AND id = $2`, alertID, subscriptionID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// IsSubscriber reports whether the user has at least one subscription row on +// the alert. It is the authorisation check behind getAlert/alarmStatus. +func IsSubscriber(ctx context.Context, alertID, userID uuid.UUID) (bool, error) { + var exists bool + err := Pool().QueryRow(ctx, + `SELECT EXISTS (SELECT 1 FROM alarm.subscriptions WHERE alert_id = $1 AND subscriber_user_id = $2)`, + alertID, userID).Scan(&exists) + return exists, err +} + +// UserSubscriptionIDs lists the user's subscription ids on an alert, so a WS +// connection can be tied to the right device rows. +func UserSubscriptionIDs(ctx context.Context, alertID, userID uuid.UUID) ([]uuid.UUID, error) { + rows, err := Pool().Query(ctx, + `SELECT id FROM alarm.subscriptions WHERE alert_id = $1 AND subscriber_user_id = $2 ORDER BY created`, + alertID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +// TouchSubscription stamps last_seen_at. It is called on WS connect and on +// every heartbeat; a no-op update (the row vanished mid-connection) is not an +// error worth propagating. +func TouchSubscription(ctx context.Context, subscriptionID uuid.UUID) error { + _, err := Pool().Exec(ctx, + `UPDATE alarm.subscriptions SET last_seen_at = NOW() WHERE id = $1`, subscriptionID) + return err +} diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..94b345b --- /dev/null +++ b/src/go.mod @@ -0,0 +1,20 @@ +module git.dcntrlized.org/Dcntrlized/Alarm + +require ( + git.dcntrlized.org/dcntrlized/dcntrlized-auth v0.1.5 + github.com/boombuler/barcode v1.0.1 + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 + github.com/jackc/pgx/v5 v5.10.0 +) + +require ( + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.40.0 // indirect +) + +go 1.26.5 diff --git a/src/go.sum b/src/go.sum new file mode 100644 index 0000000..b70729f --- /dev/null +++ b/src/go.sum @@ -0,0 +1,36 @@ +git.dcntrlized.org/dcntrlized/dcntrlized-auth v0.1.5 h1:T2To6w6+8J+PlvresQawoqVUiKZWSLG0FtRLuTS/4L4= +git.dcntrlized.org/dcntrlized/dcntrlized-auth v0.1.5/go.mod h1:BCGcoKSC+yVLRDnYZGUCBcrgu7NaFWdA39PdGG1+oNM= +github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= +github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/src/handlers/actions/action__actionName_.go b/src/handlers/actions/action__actionName_.go new file mode 100644 index 0000000..a058b1a --- /dev/null +++ b/src/handlers/actions/action__actionName_.go @@ -0,0 +1,131 @@ +package actions + +import ( + "encoding/json" + "net/http" + + "git.dcntrlized.org/Dcntrlized/Alarm/actions" + "git.dcntrlized.org/Dcntrlized/Alarm/types" +) + +// ActionActionNameGet dispatches the GET /action/{actionName} request. +// +// GET carries no body: the read actions take their arguments from the query +// string (e.g. /action/getAlert?alertId=…). The POST switch below accepts the +// same actions with a {payload} envelope for clients that prefer one verb. +func ActionActionNameGet(w http.ResponseWriter, r *http.Request, params map[string]string) { + action := params["actionName"] + + switch action { + case "getAlert": + actions.GetAlert(w, r, nil) + case "listSubscriptions": + actions.ListSubscriptions(w, r, nil) + case "alarmStatus": + actions.AlarmStatus(w, r, nil) + case "qrImage": + actions.QRImage(w, r, nil) + case "logout": + actions.Logout(w, r, nil) + case "me": + actions.Me(w, r) + default: + http.Error(w, "Action not implemented", http.StatusNotImplemented) + return + } +} + +// ActionActionNamePost dispatches the POST /action/{actionName} request. +func ActionActionNamePost(w http.ResponseWriter, r *http.Request, params map[string]string) { + action := params["actionName"] + var req types.ActionRequest + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + switch action { + // Alarm actions. + case "createAlert": + actions.CreateAlert(w, r, req.Payload) + case "getAlert": + actions.GetAlert(w, r, req.Payload) + case "joinAlert": + actions.JoinAlert(w, r, req.Payload) + case "listSubscriptions": + actions.ListSubscriptions(w, r, req.Payload) + case "removeSubscription": + actions.RemoveSubscription(w, r, req.Payload) + case "triggerAlert": + actions.TriggerAlert(w, r, req.Payload) + case "disarmAlert": + actions.DisarmAlert(w, r, req.Payload) + case "alarmStatus": + actions.AlarmStatus(w, r, req.Payload) + case "deleteAlert": + actions.DeleteAlert(w, r, req.Payload) + case "qrImage": + actions.QRImage(w, r, req.Payload) + case "rotateSubscribeToken": + actions.RotateSubscribeToken(w, r, req.Payload) + + // Shared-auth proxies. + case "login": + actions.Login(w, r, req.Payload) + case "register": + actions.Register(w, r, req.Payload) + case "refresh": + actions.Refresh(w, r, req.Payload) + case "delete": + actions.Delete(w, r, req.Payload) + case "logout": + actions.Logout(w, r, req.Payload) + default: + http.Error(w, "Action not implemented", http.StatusNotImplemented) + return + } +} + +// ActionActionNamePut dispatches the PUT /action/{actionName} request. +func ActionActionNamePut(w http.ResponseWriter, r *http.Request, params map[string]string) { + action := params["actionName"] + var req types.ActionRequest + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + switch action { + default: + http.Error(w, "Action not implemented", http.StatusNotImplemented) + return + } +} + +// ActionActionNameDelete dispatches the DELETE /action/{actionName} request. +// The two removals are also reachable as POST; DELETE is the REST-idiomatic +// verb and carries the same {payload} envelope. +func ActionActionNameDelete(w http.ResponseWriter, r *http.Request, params map[string]string) { + action := params["actionName"] + var req types.ActionRequest + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + switch action { + case "deleteAlert": + actions.DeleteAlert(w, r, req.Payload) + case "removeSubscription": + actions.RemoveSubscription(w, r, req.Payload) + default: + http.Error(w, "Action not implemented", http.StatusNotImplemented) + return + } +} diff --git a/src/handlers/auth/auth_delete.go b/src/handlers/auth/auth_delete.go new file mode 100644 index 0000000..21a77a2 --- /dev/null +++ b/src/handlers/auth/auth_delete.go @@ -0,0 +1,26 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package auth + +import ( + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy" + "net/http" +) + +// AuthDeletePost proxies POST /auth/delete to the shared dcntrlized-auth service. +func AuthDeletePost(w http.ResponseWriter, r *http.Request, params map[string]string) { + authproxy.Proxy(w, r, "delete") +} diff --git a/src/handlers/auth/auth_login.go b/src/handlers/auth/auth_login.go new file mode 100644 index 0000000..815393e --- /dev/null +++ b/src/handlers/auth/auth_login.go @@ -0,0 +1,26 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package auth + +import ( + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy" + "net/http" +) + +// AuthLoginPost proxies POST /auth/login to the shared dcntrlized-auth service. +func AuthLoginPost(w http.ResponseWriter, r *http.Request, params map[string]string) { + authproxy.Proxy(w, r, "login") +} diff --git a/src/handlers/auth/auth_logout.go b/src/handlers/auth/auth_logout.go new file mode 100644 index 0000000..26a739c --- /dev/null +++ b/src/handlers/auth/auth_logout.go @@ -0,0 +1,31 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package auth + +import ( + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy" + "net/http" +) + +// AuthLogoutGet proxies GET /auth/logout to the shared dcntrlized-auth service. +func AuthLogoutGet(w http.ResponseWriter, r *http.Request, params map[string]string) { + authproxy.ProxyEmpty(w, r, "logout") +} + +// AuthLogoutPost proxies POST /auth/logout to the shared dcntrlized-auth service. +func AuthLogoutPost(w http.ResponseWriter, r *http.Request, params map[string]string) { + authproxy.Proxy(w, r, "logout") +} diff --git a/src/handlers/auth/auth_me.go b/src/handlers/auth/auth_me.go new file mode 100644 index 0000000..0bebd5e --- /dev/null +++ b/src/handlers/auth/auth_me.go @@ -0,0 +1,26 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package auth + +import ( + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy" + "net/http" +) + +// AuthMeGet proxies GET /auth/me to the shared dcntrlized-auth service. +func AuthMeGet(w http.ResponseWriter, r *http.Request, params map[string]string) { + authproxy.ProxyEmpty(w, r, "me") +} diff --git a/src/handlers/auth/auth_refresh.go b/src/handlers/auth/auth_refresh.go new file mode 100644 index 0000000..9b2ca69 --- /dev/null +++ b/src/handlers/auth/auth_refresh.go @@ -0,0 +1,26 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package auth + +import ( + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy" + "net/http" +) + +// AuthRefreshPost proxies POST /auth/refresh to the shared dcntrlized-auth service. +func AuthRefreshPost(w http.ResponseWriter, r *http.Request, params map[string]string) { + authproxy.Proxy(w, r, "refresh") +} diff --git a/src/handlers/auth/auth_register.go b/src/handlers/auth/auth_register.go new file mode 100644 index 0000000..acc8c01 --- /dev/null +++ b/src/handlers/auth/auth_register.go @@ -0,0 +1,26 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package auth + +import ( + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy" + "net/http" +) + +// AuthRegisterPost proxies POST /auth/register to the shared dcntrlized-auth service. +func AuthRegisterPost(w http.ResponseWriter, r *http.Request, params map[string]string) { + authproxy.Proxy(w, r, "register") +} diff --git a/src/handlers/auth/types.go b/src/handlers/auth/types.go new file mode 100644 index 0000000..bf18211 --- /dev/null +++ b/src/handlers/auth/types.go @@ -0,0 +1,18 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package auth + +// No inline route-specific types were generated for this tag. diff --git a/src/handlers/manifest/manifest.go b/src/handlers/manifest/manifest.go new file mode 100644 index 0000000..e64fa4e --- /dev/null +++ b/src/handlers/manifest/manifest.go @@ -0,0 +1,30 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package manifest + +import ( + _ "embed" + "net/http" +) + +//go:embed manifest.json +var manifest string + +func ManifestHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(manifest)) +} diff --git a/src/handlers/manifest/manifest.json b/src/handlers/manifest/manifest.json new file mode 100644 index 0000000..771792d --- /dev/null +++ b/src/handlers/manifest/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "Alarm", + "author": "Dcntrlized", + "capabilities": [ + "database", + "alarm.live" + ], + "has_auth": true, + "public_key": "", + "icon": "" +} diff --git a/src/internal/alarmtoken/token.go b/src/internal/alarmtoken/token.go new file mode 100644 index 0000000..d288918 --- /dev/null +++ b/src/internal/alarmtoken/token.go @@ -0,0 +1,114 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package alarmtoken holds the Alarm service's two bearer secrets and their +// comparison rules (frozen contract v1): +// +// - the ADMIN token is 32 random bytes, base64url (43 chars). It is returned +// exactly once at alert creation and stored ONLY as its SHA-256 digest, so +// a database leak does not yield a usable admin token. Comparison is +// constant-time over the digest. +// - the SUBSCRIBE token is 24 random bytes, base64url (32 chars). It is +// stored in plaintext on purpose: it is shared through the QR code with +// every subscriber, and rotation is a cheap UPDATE (token_version). It is +// still compared in constant time so a timing oracle cannot be used to +// recover it byte by byte. +package alarmtoken + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" +) + +const ( + // AdminTokenBytes is the entropy of an admin token (frozen contract: 32). + AdminTokenBytes = 32 + // SubscribeTokenBytes is the entropy of a subscribe token (frozen contract: 24). + SubscribeTokenBytes = 24 + // HashBytes is the length of the stored SHA-256 digest. + HashBytes = sha256.Size +) + +// ErrRandomSource reports a failure of crypto/rand. It is returned rather than +// panicking so a caller can answer 500 instead of killing the process. +var ErrRandomSource = errors.New("alarmtoken: crypto/rand failed") + +// NewAdminToken returns a fresh 32-byte admin token, base64url without padding. +func NewAdminToken() (string, error) { + return randomToken(AdminTokenBytes) +} + +// NewSubscribeToken returns a fresh 24-byte subscribe token, base64url without +// padding (32 characters). +func NewSubscribeToken() (string, error) { + return randomToken(SubscribeTokenBytes) +} + +func randomToken(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("%w: %v", ErrRandomSource, err) + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +// Hash returns the SHA-256 digest of a token. That digest is what is persisted +// for admin tokens; the plaintext never touches the database. +func Hash(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} + +// EqualHash reports whether token hashes to stored. It is constant-time with +// respect to the digest contents and rejects a stored digest of the wrong +// length up front (a malformed row must never compare equal). +func EqualHash(stored []byte, token string) bool { + if len(stored) != HashBytes { + return false + } + return subtle.ConstantTimeCompare(stored, Hash(token)) == 1 +} + +// EqualToken compares two plaintext tokens in constant time. Both sides are +// hashed first, so the comparison time does not depend on either length and a +// length mismatch cannot short-circuit. +func EqualToken(a, b string) bool { + ha := sha256.Sum256([]byte(a)) + hb := sha256.Sum256([]byte(b)) + return subtle.ConstantTimeCompare(ha[:], hb[:]) == 1 +} + +// ValidSubscribeTokenShape reports whether s looks like a token this service +// issues: base64url alphabet, between 16 and 64 characters. It is a cheap +// pre-filter for untrusted input (QR payloads, query strings), not a substitute +// for EqualToken. +func ValidSubscribeTokenShape(s string) bool { + if len(s) < 16 || len(s) > 64 { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-', c == '_': + default: + return false + } + } + return true +} diff --git a/src/internal/alarmtoken/token_test.go b/src/internal/alarmtoken/token_test.go new file mode 100644 index 0000000..32cb98b --- /dev/null +++ b/src/internal/alarmtoken/token_test.go @@ -0,0 +1,133 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package alarmtoken + +import ( + "bytes" + "encoding/base64" + "strings" + "testing" +) + +func TestNewTokensAreDistinctAndWellShaped(t *testing.T) { + seen := make(map[string]bool) + for i := 0; i < 64; i++ { + admin, err := NewAdminToken() + if err != nil { + t.Fatalf("NewAdminToken: %v", err) + } + sub, err := NewSubscribeToken() + if err != nil { + t.Fatalf("NewSubscribeToken: %v", err) + } + if seen[admin] || seen[sub] { + t.Fatalf("duplicate token generated") + } + seen[admin], seen[sub] = true, true + + raw, err := base64.RawURLEncoding.DecodeString(admin) + if err != nil { + t.Fatalf("admin token is not raw-url base64: %v", err) + } + if len(raw) != AdminTokenBytes { + t.Fatalf("admin token entropy = %d bytes, want %d", len(raw), AdminTokenBytes) + } + raw, err = base64.RawURLEncoding.DecodeString(sub) + if err != nil { + t.Fatalf("subscribe token is not raw-url base64: %v", err) + } + if len(raw) != SubscribeTokenBytes { + t.Fatalf("subscribe token entropy = %d bytes, want %d", len(raw), SubscribeTokenBytes) + } + if !ValidSubscribeTokenShape(sub) { + t.Fatalf("freshly generated subscribe token %q fails its own shape check", sub) + } + } +} + +func TestHashAndEqualHash(t *testing.T) { + token, err := NewAdminToken() + if err != nil { + t.Fatalf("NewAdminToken: %v", err) + } + digest := Hash(token) + if len(digest) != HashBytes { + t.Fatalf("Hash length = %d, want %d", len(digest), HashBytes) + } + if !bytes.Equal(digest, Hash(token)) { + t.Fatalf("Hash is not deterministic") + } + if !EqualHash(digest, token) { + t.Fatalf("EqualHash rejected the token it was computed from") + } + other, err := NewAdminToken() + if err != nil { + t.Fatalf("NewAdminToken: %v", err) + } + if EqualHash(digest, other) { + t.Fatalf("EqualHash accepted a different token") + } + // A malformed (short) stored digest must never compare equal. + if EqualHash(digest[:HashBytes-1], token) { + t.Fatalf("EqualHash accepted a truncated digest") + } + if EqualHash(nil, token) { + t.Fatalf("EqualHash accepted an empty digest") + } +} + +func TestEqualToken(t *testing.T) { + a, _ := NewSubscribeToken() + b, _ := NewSubscribeToken() + if !EqualToken(a, a) { + t.Fatalf("EqualToken rejected identical tokens") + } + if EqualToken(a, b) { + t.Fatalf("EqualToken accepted different tokens") + } + // Different lengths must compare unequal, not panic. + if EqualToken(a, a+"x") { + t.Fatalf("EqualToken accepted a longer token") + } + if !EqualToken("", "") { + t.Fatalf("EqualToken rejected two empty strings") + } +} + +func TestValidSubscribeTokenShape(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"", false}, + {"short", false}, + {"0123456789abcde", false}, // 15 chars -> too short + {"0123456789abcdef", true}, // 16 chars, alphabet ok + {strings.Repeat("a", 64), true}, // max length + {strings.Repeat("a", 65), false}, // too long + {"0123456789abcde!", false}, // illegal character + {"0123456789abcde/", false}, // not url-safe + {"0123456789abcde ", false}, // whitespace + {"0123456789abcdeé", false}, // non-ASCII + {"AAAA0123456789-_abcdef", true}, // - and _ are legal + {"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJ", true}, + } + for _, tc := range cases { + if got := ValidSubscribeTokenShape(tc.in); got != tc.want { + t.Errorf("ValidSubscribeTokenShape(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} diff --git a/src/internal/alertstate/state.go b/src/internal/alertstate/state.go new file mode 100644 index 0000000..ff4591d --- /dev/null +++ b/src/internal/alertstate/state.go @@ -0,0 +1,100 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package alertstate holds the alarm state machine as pure functions, so the +// transitions are unit-testable without a database. +// +// Frozen v1 semantics: +// +// - `status` is the last COMMANDED state (armed | triggered | disarmed). +// - `active` is DERIVED: a triggered alert stops being active once its +// auto-stop window elapses, with no background job and no timer. That is +// what makes auto-stop correct across several service instances. +package alertstate + +import "time" + +// Commanded statuses. +const ( + StatusArmed = "armed" + StatusTriggered = "triggered" + StatusDisarmed = "disarmed" +) + +// Event kinds (also the wire `action` values). +const ( + KindTrigger = "trigger" + KindDisarm = "disarm" +) + +// MaxAutoStopSeconds bounds auto_stop_seconds (mirrors the SQL CHECK). +const MaxAutoStopSeconds = 86400 + +// ValidStatus reports whether s is one of the three commanded statuses. +func ValidStatus(s string) bool { + switch s { + case StatusArmed, StatusTriggered, StatusDisarmed: + return true + default: + return false + } +} + +// ValidKind reports whether k is a valid event kind. +func ValidKind(k string) bool { + return k == KindTrigger || k == KindDisarm +} + +// IsActive reports whether the alert is live at now. +// +// A never-triggered alert (nil triggeredAt) is never active. autoStopSeconds +// == 0 means "ring until disarmed"; a positive value bounds the window. The +// window is inclusive of its start and exclusive at its end. +func IsActive(status string, triggeredAt *time.Time, autoStopSeconds int, now time.Time) bool { + if status != StatusTriggered || triggeredAt == nil { + return false + } + if autoStopSeconds <= 0 { + return true + } + return now.Before(triggeredAt.Add(time.Duration(autoStopSeconds) * time.Second)) +} + +// RemainingAutoStopSeconds returns how much of the auto-stop window is left at +// now, or 0 when the alert is not bounded or has already elapsed. It is what +// alarmStatus reports so a client can run its own countdown. +func RemainingAutoStopSeconds(status string, triggeredAt *time.Time, autoStopSeconds int, now time.Time) int { + if !IsActive(status, triggeredAt, autoStopSeconds, now) || autoStopSeconds <= 0 { + return 0 + } + left := int(triggeredAt.Add(time.Duration(autoStopSeconds) * time.Second).Sub(now).Seconds()) + if left < 0 { + return 0 + } + return left +} + +// CanDisarm reports whether a disarm command is legal for the current status. +// Disarming an alert that was never triggered (or is already disarmed) is a +// state error, not a no-op, so the client learns its view is stale. +func CanDisarm(status string) bool { + return status == StatusTriggered +} + +// CanDelete reports whether the alert may be deleted. A live alarm must be +// disarmed first: deleting it would silently orphan every ringing client. +func CanDelete(active bool) bool { + return !active +} diff --git a/src/internal/alertstate/state_test.go b/src/internal/alertstate/state_test.go new file mode 100644 index 0000000..792b603 --- /dev/null +++ b/src/internal/alertstate/state_test.go @@ -0,0 +1,105 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package alertstate + +import ( + "testing" + "time" +) + +func TestValidStatusAndKind(t *testing.T) { + for _, s := range []string{StatusArmed, StatusTriggered, StatusDisarmed} { + if !ValidStatus(s) { + t.Errorf("ValidStatus(%q) = false", s) + } + } + for _, s := range []string{"", "ARMED", "firing", "trigger"} { + if ValidStatus(s) { + t.Errorf("ValidStatus(%q) = true, want false", s) + } + } + for _, k := range []string{KindTrigger, KindDisarm} { + if !ValidKind(k) { + t.Errorf("ValidKind(%q) = false", k) + } + } + for _, k := range []string{"", "TRIGGER", "alarm", "stop"} { + if ValidKind(k) { + t.Errorf("ValidKind(%q) = true, want false", k) + } + } +} + +func TestIsActive(t *testing.T) { + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + earlier := now.Add(-10 * time.Minute) + + cases := []struct { + name string + status string + at *time.Time + auto int + want bool + }{ + {"armed is never active", StatusArmed, &earlier, 0, false}, + {"disarmed is never active", StatusDisarmed, &earlier, 0, false}, + {"triggered without auto-stop stays active", StatusTriggered, &earlier, 0, true}, + {"triggered with auto-stop still inside the window", StatusTriggered, &earlier, 3600, true}, + {"triggered with auto-stop past the window", StatusTriggered, &earlier, 60, false}, + {"triggered with no timestamp", StatusTriggered, nil, 0, false}, + {"unknown status", "firing", &earlier, 0, false}, + } + for _, tc := range cases { + if got := IsActive(tc.status, tc.at, tc.auto, now); got != tc.want { + t.Errorf("%s: IsActive = %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestRemainingAutoStopSeconds(t *testing.T) { + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + at := now.Add(-10 * time.Minute) + + if got := RemainingAutoStopSeconds(StatusTriggered, &at, 3600, now); got != 3000 { + t.Errorf("remaining = %d, want 3000", got) + } + if got := RemainingAutoStopSeconds(StatusTriggered, &at, 60, now); got != 0 { + t.Errorf("expired remaining = %d, want 0", got) + } + if got := RemainingAutoStopSeconds(StatusArmed, &at, 3600, now); got != 0 { + t.Errorf("non-triggered remaining = %d, want 0", got) + } + if got := RemainingAutoStopSeconds(StatusTriggered, &at, 0, now); got != 0 { + t.Errorf("auto-stop 0 remaining = %d, want 0", got) + } +} + +func TestCanDisarmAndDelete(t *testing.T) { + if !CanDisarm(StatusTriggered) { + t.Errorf("CanDisarm(triggered) = false") + } + for _, s := range []string{StatusArmed, StatusDisarmed, ""} { + if CanDisarm(s) { + t.Errorf("CanDisarm(%q) = true, want false", s) + } + } + if CanDelete(true) { + t.Errorf("CanDelete(active) = true, want false") + } + if !CanDelete(false) { + t.Errorf("CanDelete(inactive) = false, want true") + } +} diff --git a/src/internal/apierr/codes.go b/src/internal/apierr/codes.go new file mode 100644 index 0000000..c6f53ff --- /dev/null +++ b/src/internal/apierr/codes.go @@ -0,0 +1,34 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package apierr holds the frozen v1 wire error codes. +// +// They live in their own package so the HTTP action layer and the WebSocket +// layer (which cannot import each other without a cycle) agree on one source of +// truth. These strings are part of the published contract: never rename one +// without a contract revision. +package apierr + +const ( + AlertNotFound = "ERR_ALERT_NOT_FOUND" + AlertTokenInvalid = "ERR_ALERT_TOKEN_INVALID" + AlertNotAdmin = "ERR_ALERT_NOT_ADMIN" + AlertAlreadyJoined = "ERR_ALERT_ALREADY_JOINED" + AlertNotSubscribed = "ERR_ALERT_NOT_SUBSCRIBED" + AlertState = "ERR_ALERT_STATE" + Validation = "ERR_VALIDATION" + Unauthorized = "ERR_UNAUTHORIZED" + Internal = "ERR_INTERNAL" +) diff --git a/src/internal/qr/png.go b/src/internal/qr/png.go new file mode 100644 index 0000000..c775dbf --- /dev/null +++ b/src/internal/qr/png.go @@ -0,0 +1,87 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package qr + +import ( + "bytes" + "errors" + "image" + "image/color" + "image/png" +) + +// Render limits. They bound the memory a single unauthenticated-ish request can +// allocate (a 64px module on a 57-module matrix is ~13 MB). +const ( + MaxScale = 64 + MaxQuietZone = 32 + + // MinQuietZone is the quiet zone ISO/IEC 18004 requires (4 modules on each + // side). Below it a decoder legitimately fails to locate the symbol — + // measured: zbarimg exits 4 with "no symbol" at 0..2 modules — so this + // package never renders an unscannable PNG. + MinQuietZone = 4 +) + +// ErrBadMatrix reports a matrix that is not a non-empty square. +var ErrBadMatrix = errors.New("qr: matrix must be a non-empty square") + +// PNG renders a module matrix as a PNG image. Each module becomes scale×scale +// pixels and the image is surrounded by a quietZone-module light border, which +// scanners require. +func PNG(matrix [][]bool, scale, quietZone int) ([]byte, error) { + size := len(matrix) + if size == 0 { + return nil, ErrBadMatrix + } + for _, row := range matrix { + if len(row) != size { + return nil, ErrBadMatrix + } + } + if scale < 1 || scale > MaxScale { + return nil, errors.New("qr: scale out of range") + } + if quietZone < MinQuietZone || quietZone > MaxQuietZone { + return nil, errors.New("qr: quiet zone out of range (minimum 4 modules)") + } + + side := (size + 2*quietZone) * scale + img := image.NewPaletted(image.Rect(0, 0, side, side), + color.Palette{color.White, color.Black}) + + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + if !matrix[y][x] { + continue + } + x0 := (x + quietZone) * scale + y0 := (y + quietZone) * scale + for dy := 0; dy < scale; dy++ { + row := img.PixOffset(x0, y0+dy) + for dx := 0; dx < scale; dx++ { + img.Pix[row+dx] = 1 + } + } + } + } + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/src/internal/qr/qr.go b/src/internal/qr/qr.go new file mode 100644 index 0000000..c1b8a1b --- /dev/null +++ b/src/internal/qr/qr.go @@ -0,0 +1,77 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package qr encodes a string as a QR symbol and renders it as a PNG. +// +// The symbol is produced by github.com/boombuler/barcode/qr. This package only +// fixes the encoding profile the Alarm service needs — error-correction level +// M, automatic mode selection, and a row-major [][]bool module matrix (true = +// dark, no quiet zone) — and renders that matrix with the quiet zone scanners +// require (png.go). +// +// Why a dependency instead of the in-repo encoder that lived here: that encoder +// stored its Reed-Solomon generator polynomial in the wrong coefficient order, +// so every ECC block it computed was wrong and no decoder could read its output. +// Verified two ways: extracting the codeword stream from a libqrencode reference +// symbol (the data codewords matched, the ECC bytes did not, and a generator +// derived from its roots reproduced the reference ECC exactly), and zbarimg +// returning "no symbol" for its PNGs at every quiet zone. A QR encoder a real +// decoder cannot read is worthless, and re-deriving Reed-Solomon, placement and +// mask selection is not worth ~690 lines in a service repo. The library is +// pinned in go.mod and its output is verified by a zbarimg round-trip in +// qr_test.go. +package qr + +import ( + "fmt" + "image/color" + + "github.com/boombuler/barcode" + barcodeqr "github.com/boombuler/barcode/qr" +) + +// Encode encodes data as a QR symbol and returns its module matrix. +// +// The error-correction level is M (~15%) and the encoding mode is chosen +// automatically by the library (a URL with lowercase letters and a '#' lands in +// byte mode). The returned matrix has no quiet zone; PNG adds it. +func Encode(data []byte) ([][]bool, error) { + return EncodeString(string(data)) +} + +// EncodeString encodes s as a QR symbol and returns its module matrix. +func EncodeString(s string) ([][]bool, error) { + code, err := barcodeqr.Encode(s, barcodeqr.M, barcodeqr.Auto) + if err != nil { + return nil, fmt.Errorf("qr: encode: %w", err) + } + return moduleMatrix(code), nil +} + +// moduleMatrix converts a one-pixel-per-module barcode image into a row-major +// boolean matrix. +func moduleMatrix(code barcode.Barcode) [][]bool { + b := code.Bounds() + matrix := make([][]bool, b.Dy()) + for y := 0; y < b.Dy(); y++ { + row := make([]bool, b.Dx()) + for x := 0; x < b.Dx(); x++ { + gray := color.GrayModel.Convert(code.At(b.Min.X+x, b.Min.Y+y)).(color.Gray) + row[x] = gray.Y < 128 + } + matrix[y] = row + } + return matrix +} diff --git a/src/internal/qr/qr_test.go b/src/internal/qr/qr_test.go new file mode 100644 index 0000000..4218a05 --- /dev/null +++ b/src/internal/qr/qr_test.go @@ -0,0 +1,193 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package qr + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "git.dcntrlized.org/Dcntrlized/Alarm/internal/qrpayload" + "github.com/google/uuid" +) + +// realPayload is the exact string the service puts in a QR code +// (https://alarm.dcntrlized.org/a/{alertId}#{subscribeToken}). +func realPayload() string { + return qrpayload.Build( + uuid.MustParse("6f1d0c4e-8a3b-4f2e-9c7d-2b5a1e0f9c33"), + "Zm9vYmFyYmF6cXV1eGNvcmdlZ3JhdWx0c3R1Yg", + ) +} + +func testPayloads() map[string]string { + return map[string]string{ + "short": "ALARM", + "real": realPayload(), + "long": strings.Repeat("a", 200), + } +} + +// TestEncodeDecodeRoundTrip is the test that matters: a real decoder must read +// what this package produces. It renders the PNG exactly as the qrImage action +// does (scale 8, quiet zone 4) and shells out to zbarimg. +// +// This test is skipped when zbarimg is not installed. It must never be replaced +// by a golden-matrix comparison: the golden-matrix tests it replaces passed +// while the previous in-repo encoder emitted symbols no decoder could read. +func TestEncodeDecodeRoundTrip(t *testing.T) { + zbarimg, err := exec.LookPath("zbarimg") + if err != nil { + t.Skip("zbarimg not installed; skipping QR decode round-trip") + } + + dir := t.TempDir() + for name, payload := range testPayloads() { + t.Run(name, func(t *testing.T) { + matrix, err := EncodeString(payload) + if err != nil { + t.Fatalf("EncodeString: %v", err) + } + pngBytes, err := PNG(matrix, 8, MinQuietZone) + if err != nil { + t.Fatalf("PNG: %v", err) + } + path := filepath.Join(dir, name+".png") + if err := os.WriteFile(path, pngBytes, 0o600); err != nil { + t.Fatalf("write png: %v", err) + } + + out, err := exec.Command(zbarimg, "--raw", "-q", path).CombinedOutput() + if err != nil { + t.Fatalf("zbarimg could not decode the symbol (%v): %s", err, bytes.TrimSpace(out)) + } + got := strings.TrimRight(string(out), "\r\n") + if got != payload { + t.Fatalf("decoded payload mismatch:\n got: %q\nwant: %q", got, payload) + } + }) + } +} + +// TestEncodeMatchesEncodeString pins that the []byte and string entry points +// are the same encoding. +func TestEncodeMatchesEncodeString(t *testing.T) { + payload := realPayload() + fromString, err := EncodeString(payload) + if err != nil { + t.Fatalf("EncodeString: %v", err) + } + fromBytes, err := Encode([]byte(payload)) + if err != nil { + t.Fatalf("Encode: %v", err) + } + if len(fromString) != len(fromBytes) { + t.Fatalf("matrix sizes differ: %d vs %d", len(fromString), len(fromBytes)) + } + for y := range fromString { + for x := range fromString[y] { + if fromString[y][x] != fromBytes[y][x] { + t.Fatalf("module (%d,%d) differs between Encode and EncodeString", y, x) + } + } + } +} + +// TestMatrixShape checks the structural invariants of a QR symbol: square, +// odd-sized, and carrying the three finder patterns. +func TestMatrixShape(t *testing.T) { + matrix, err := EncodeString(realPayload()) + if err != nil { + t.Fatalf("EncodeString: %v", err) + } + size := len(matrix) + if size == 0 || size%2 == 0 || (size-17)%4 != 0 { + t.Fatalf("not a valid QR size: %d", size) + } + for y, row := range matrix { + if len(row) != size { + t.Fatalf("row %d has %d modules, want %d", y, len(row), size) + } + } + for _, origin := range [][2]int{{0, 0}, {0, size - 7}, {size - 7, 0}} { + if !hasFinder(matrix, origin[0], origin[1]) { + t.Fatalf("missing finder pattern at (%d,%d)", origin[0], origin[1]) + } + } +} + +func hasFinder(matrix [][]bool, r0, c0 int) bool { + want := [7][7]bool{ + {true, true, true, true, true, true, true}, + {true, false, false, false, false, false, true}, + {true, false, true, true, true, false, true}, + {true, false, true, true, true, false, true}, + {true, false, true, true, true, false, true}, + {true, false, false, false, false, false, true}, + {true, true, true, true, true, true, true}, + } + for dy := 0; dy < 7; dy++ { + for dx := 0; dx < 7; dx++ { + if matrix[r0+dy][c0+dx] != want[dy][dx] { + return false + } + } + } + return true +} + +// TestPNGGeometry checks the output format and the quiet-zone floor. +func TestPNGGeometry(t *testing.T) { + matrix, err := EncodeString(realPayload()) + if err != nil { + t.Fatalf("EncodeString: %v", err) + } + const scale, quiet = 8, 4 + pngBytes, err := PNG(matrix, scale, quiet) + if err != nil { + t.Fatalf("PNG: %v", err) + } + signature := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'} + if !bytes.HasPrefix(pngBytes, signature) { + t.Fatal("output is not a PNG") + } + + for _, quietZone := range []int{0, 1, 3} { + if _, err := PNG(matrix, scale, quietZone); err == nil { + t.Fatalf("PNG accepted quiet zone %d below the 4-module minimum", quietZone) + } + } + if _, err := PNG(matrix, 0, quiet); err == nil { + t.Fatal("PNG accepted scale 0") + } + if _, err := PNG(nil, scale, quiet); err == nil { + t.Fatal("PNG accepted an empty matrix") + } + if _, err := PNG([][]bool{{true, false}}, scale, quiet); err == nil { + t.Fatal("PNG accepted a non-square matrix") + } +} + +// TestEncodeRejectsOversized pins that content which cannot fit in any QR +// version surfaces as an error instead of a truncated symbol. +func TestEncodeRejectsOversized(t *testing.T) { + if _, err := EncodeString(strings.Repeat("x", 4000)); err == nil { + t.Fatal("expected an error for content beyond QR capacity") + } +} diff --git a/src/internal/qrpayload/payload_test.go b/src/internal/qrpayload/payload_test.go new file mode 100644 index 0000000..5b20063 --- /dev/null +++ b/src/internal/qrpayload/payload_test.go @@ -0,0 +1,95 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package qrpayload + +import ( + "errors" + "strings" + "testing" + + "github.com/google/uuid" +) + +func TestBuildParseRoundTrip(t *testing.T) { + alertID := uuid.New() + token := "AbCdEf0123456789_-xyzABC" + raw := Build(alertID, token) + if !strings.HasPrefix(raw, "https://"+PublicHost+PathPrefix) { + t.Fatalf("Build = %q, want the canonical https form", raw) + } + gotID, gotToken, err := Parse(raw) + if err != nil { + t.Fatalf("Parse(Build(...)): %v", err) + } + if gotID != alertID { + t.Fatalf("alert id = %s, want %s", gotID, alertID) + } + if gotToken != token { + t.Fatalf("token = %q, want %q", gotToken, token) + } +} + +func TestBuildSchemeParseRoundTrip(t *testing.T) { + alertID := uuid.New() + token := "AbCdEf0123456789_-xyzABC" + raw := BuildScheme(alertID, token) + if !strings.HasPrefix(raw, Scheme+"://"+SchemeHost+"?") { + t.Fatalf("BuildScheme = %q, want the app-scheme form", raw) + } + gotID, gotToken, err := Parse(raw) + if err != nil { + t.Fatalf("Parse(BuildScheme(...)): %v", err) + } + if gotID != alertID || gotToken != token { + t.Fatalf("round-trip = (%s, %q), want (%s, %q)", gotID, gotToken, alertID, token) + } +} + +func TestParseRejectsMalformed(t *testing.T) { + alertID := uuid.New() + cases := []string{ + "", + " ", + "not a url at all", + "ftp://" + PublicHost + PathPrefix + alertID.String() + "#token", + "https://" + PublicHost + PathPrefix + alertID.String(), // no fragment + "https://" + PublicHost + "/b/" + alertID.String() + "#token", + "https://" + PublicHost + PathPrefix + "not-a-uuid#token", + "https://" + PublicHost + PathPrefix + alertID.String() + "#", + "https://" + PublicHost + PathPrefix + alertID.String() + "#short", + "dcntrlized-alarm://join?alert=nope&token=AbCdEf0123456789", + "dcntrlized-alarm://join?token=AbCdEf0123456789", + } + for _, in := range cases { + if _, _, err := Parse(in); err == nil { + t.Errorf("Parse(%q) succeeded, want an error", in) + } else if !errors.Is(err, ErrInvalidPayload) { + t.Errorf("Parse(%q) error = %v, want it to wrap ErrInvalidPayload", in, err) + } + } +} + +func TestParseTrimsSurroundingSpace(t *testing.T) { + alertID := uuid.New() + token := "AbCdEf0123456789_-xyzABC" + gotID, gotToken, err := Parse(" " + Build(alertID, token) + "\n") + if err != nil { + t.Fatalf("Parse with surrounding space: %v", err) + } + if gotID != alertID || gotToken != token { + t.Fatalf("round-trip = (%s, %q)", gotID, gotToken) + } +} diff --git a/src/internal/qrpayload/qrpayload.go b/src/internal/qrpayload/qrpayload.go new file mode 100644 index 0000000..112e433 --- /dev/null +++ b/src/internal/qrpayload/qrpayload.go @@ -0,0 +1,153 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package qrpayload is the single source of truth for the alarm QR payload. +// +// The frozen v1 wire form is +// +// https://alarm.dcntrlized.org/a/{alertId}#{subscribeToken} +// +// The fragment (not the query string) carries the token so the secret is never +// sent to the host if a scanner ever resolves the URL in a browser. Build is +// used by createAlert/getAlert/rotateSubscribeToken, and Parse is used by any +// component that accepts a scanned payload (the Android client's scanner and +// the service's own tests). +// +// Parse additionally accepts the app-scheme form +// +// dcntrlized-alarm://join?alert={alertId}&token={subscribeToken} +// +// which the Android client can register as a deep link, and it is deliberately +// lenient about the https host: the host grants nothing, the alert id and the +// subscribe token are the only things that matter, and a self-hosted instance +// may legitimately hand out its own hostname. +package qrpayload + +import ( + "errors" + "fmt" + "net/url" + "strings" + + "git.dcntrlized.org/Dcntrlized/Alarm/internal/alarmtoken" + "github.com/google/uuid" +) + +const ( + // PublicHost is the canonical host in the QR payload. + PublicHost = "alarm.dcntrlized.org" + // PathPrefix is the canonical path prefix ("/a/" + alert id). + PathPrefix = "/a/" + // Scheme is the custom deep-link scheme. + Scheme = "dcntrlized-alarm" + // SchemeHost is the custom deep-link host ("join"). + SchemeHost = "join" +) + +// ErrInvalidPayload is returned by Parse for anything that is not a well-formed +// alarm payload. Callers answer ERR_VALIDATION. +var ErrInvalidPayload = errors.New("qrpayload: invalid alarm payload") + +// Build renders the canonical QR payload for an alert. +func Build(alertID uuid.UUID, subscribeToken string) string { + return "https://" + PublicHost + PathPrefix + alertID.String() + "#" + subscribeToken +} + +// BuildScheme renders the app-scheme deep link for the same alert. +func BuildScheme(alertID uuid.UUID, subscribeToken string) string { + return Scheme + "://" + SchemeHost + "?alert=" + alertID.String() + "&token=" + url.QueryEscape(subscribeToken) +} + +// Parse extracts the alert id and subscribe token from a scanned payload. It +// accepts the canonical https form and the app-scheme form. +func Parse(raw string) (uuid.UUID, string, error) { + var zero uuid.UUID + + s := strings.TrimSpace(raw) + if s == "" { + return zero, "", fmt.Errorf("%w: empty payload", ErrInvalidPayload) + } + // A scanner may hand back the payload with the fragment stripped (some + // scanners normalise URLs); the canonical form always has a non-empty + // fragment, so an empty one is a hard error below. + u, err := url.Parse(s) + if err != nil { + return zero, "", fmt.Errorf("%w: unparseable url", ErrInvalidPayload) + } + + switch strings.ToLower(u.Scheme) { + case "https", "http": + return parseWeb(u) + case Scheme: + return parseScheme(u) + default: + return zero, "", fmt.Errorf("%w: unsupported scheme %q", ErrInvalidPayload, u.Scheme) + } +} + +func parseWeb(u *url.URL) (uuid.UUID, string, error) { + var zero uuid.UUID + + if u.Host == "" { + return zero, "", fmt.Errorf("%w: missing host", ErrInvalidPayload) + } + path := u.EscapedPath() + if !strings.HasPrefix(path, PathPrefix) { + return zero, "", fmt.Errorf("%w: path must start with %q", ErrInvalidPayload, PathPrefix) + } + rawID := strings.TrimPrefix(path, PathPrefix) + // The token rides the fragment; reject anything with a trailing path + // segment or a query string in the id slot. + if rawID == "" || strings.ContainsAny(rawID, "/?#") { + return zero, "", fmt.Errorf("%w: malformed alert id", ErrInvalidPayload) + } + alertID, err := uuid.Parse(rawID) + if err != nil { + return zero, "", fmt.Errorf("%w: alert id is not a uuid", ErrInvalidPayload) + } + token := u.Fragment + if err := validateToken(token); err != nil { + return zero, "", err + } + return alertID, token, nil +} + +func parseScheme(u *url.URL) (uuid.UUID, string, error) { + var zero uuid.UUID + + if !strings.EqualFold(u.Host, SchemeHost) { + return zero, "", fmt.Errorf("%w: scheme host must be %q", ErrInvalidPayload, SchemeHost) + } + q := u.Query() + alertID, err := uuid.Parse(q.Get("alert")) + if err != nil { + return zero, "", fmt.Errorf("%w: alert id is not a uuid", ErrInvalidPayload) + } + token := q.Get("token") + if err := validateToken(token); err != nil { + return zero, "", err + } + return alertID, token, nil +} + +func validateToken(token string) error { + if token == "" { + return fmt.Errorf("%w: missing subscribe token", ErrInvalidPayload) + } + if !alarmtoken.ValidSubscribeTokenShape(token) { + return fmt.Errorf("%w: malformed subscribe token", ErrInvalidPayload) + } + return nil +} diff --git a/src/logger/logger.go b/src/logger/logger.go new file mode 100644 index 0000000..c728c50 --- /dev/null +++ b/src/logger/logger.go @@ -0,0 +1,167 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package logger is the service-wide leveled logger (house pattern: KeepNote, +// Calendar). Info/Warn/Debug go to stdout (plus a file under logs/ when the +// working directory is writable); Error goes to stderr and resolves the true +// call site even through registered log-helper wrappers. +// +// It never logs secrets on its own: callers must not pass admin tokens, +// subscribe tokens or credentials into a format string. +package logger + +import ( + "fmt" + "io" + "log" + "os" + "path/filepath" + "runtime" + "sync" + "time" +) + +var ( + infoLogger *log.Logger + warnLogger *log.Logger + errorLogger *log.Logger + debugLogger *log.Logger + + once sync.Once +) + +// helperFuncs tracks log entry points and any registered wrapper functions +// (e.g. git.dcntrlized.org/Dcntrlized/Alarm/actions.Debug) so callerDepth +// can skip them and report the true call site. +var helperFuncs = map[string]bool{} + +func init() { + for _, name := range []string{ + "git.dcntrlized.org/Dcntrlized/Alarm/logger.Info", + "git.dcntrlized.org/Dcntrlized/Alarm/logger.Warn", + "git.dcntrlized.org/Dcntrlized/Alarm/logger.Error", + "git.dcntrlized.org/Dcntrlized/Alarm/logger.Debug", + } { + helperFuncs[name] = true + } +} + +func initLogger() { + if err := os.MkdirAll("logs", 0755); err != nil { + log.Printf("Failed to create logs directory: %v. Falling back to standard output.", err) + } + + // 1. Setup Info Logger + var infoOutput io.Writer = os.Stdout + if infoFile, err := os.OpenFile("logs/info.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666); err == nil { + infoOutput = io.MultiWriter(os.Stdout, infoFile) + writeHeader(infoFile) + } + infoLogger = log.New(infoOutput, "INFO: ", log.Ldate|log.Ltime|log.LUTC) + + // 2. Setup Warn Logger + var warnOutput io.Writer = os.Stdout + if warnFile, err := os.OpenFile("logs/warn.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666); err == nil { + warnOutput = io.MultiWriter(os.Stdout, warnFile) + writeHeader(warnFile) + } + warnLogger = log.New(warnOutput, "WARN: ", log.Ldate|log.Ltime|log.LUTC) + + // 3. Setup Error Logger + var errorOutput io.Writer = os.Stderr + if errorFile, err := os.OpenFile("logs/error.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666); err == nil { + errorOutput = io.MultiWriter(os.Stderr, errorFile) + writeHeader(errorFile) + } + // The error logger does not rely on Lshortfile; Error resolves the true + // call site itself via callSite, so line numbers stay correct even through + // log helper wrappers. + errorLogger = log.New(errorOutput, "ERROR: ", log.Ldate|log.Ltime|log.LUTC) + + // 4. Setup Debug Logger + var debugOutput io.Writer = os.Stdout + if debugFile, err := os.OpenFile("logs/debug.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666); err == nil { + debugOutput = io.MultiWriter(os.Stdout, debugFile) + writeHeader(debugFile) + } + debugLogger = log.New(debugOutput, "DEBUG: ", log.Ldate|log.Ltime|log.LUTC) +} + +func writeHeader(file *os.File) { + file.WriteString("========== Start Log ==========\n") + file.WriteString("[INFO] Logger initialized\n") + file.WriteString("[" + time.Now().Format(time.RFC3339) + "]\n") +} + +// RegisterLogHelper registers a wrapper function around this package's log +// functions so that call sites logged through it point at the true caller +// instead of the wrapper. Pass the fully qualified function name, e.g. +// "git.dcntrlized.org/Dcntrlized/Alarm/actions.Debug". +func RegisterLogHelper(fullFuncName string) { + helperFuncs[fullFuncName] = true +} + +func isLogHelper(name string) bool { + return helperFuncs[name] +} + +// callSite walks the call stack, skipping this package's entry points and any +// registered log helpers, and returns the file:line of the true call site. +// +//go:noinline +func callSite() (string, int) { + pcs := make([]uintptr, 64) + n := runtime.Callers(2, pcs) + frames := runtime.CallersFrames(pcs[:n]) + for { + frame, more := frames.Next() + if !more { + return "???", 0 + } + if isLogHelper(frame.Function) { + continue + } + return frame.File, frame.Line + } +} + +// Info accepts a format string and an optional, arbitrary number of arguments. +func Info(format string, v ...any) { + once.Do(initLogger) + infoLogger.Printf(format, v...) +} + +// Warn accepts a format string and an optional, arbitrary number of arguments. +func Warn(format string, v ...any) { + once.Do(initLogger) + warnLogger.Printf(format, v...) +} + +// Error accepts a format string and an optional, arbitrary number of arguments. +// The reported file:line points at the true call site, even through registered +// log helper wrappers. +// +//go:noinline +func Error(format string, v ...any) { + once.Do(initLogger) + file, line := callSite() + errorLogger.Output(0, fmt.Sprintf("%s:%d: %s", filepath.Base(file), line, fmt.Sprintf(format, v...))) +} + +// Debug accepts a format string and an optional, arbitrary number of arguments. +func Debug(format string, v ...any) { + once.Do(initLogger) + debugLogger.Printf(format, v...) +} diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..c55047c --- /dev/null +++ b/src/main.go @@ -0,0 +1,239 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Command alarm is the Dcntrlized alarm/alert service. It owns alerts, their +// admin/subscribe tokens and the live trigger fan-out; identity comes from the +// shared dcntrlized-auth service and storage from the shared dcntrlized-db. +// +// API shape (frozen v1 contract): GET /_manifest, the raw /auth/* proxies, +// POST/GET /action/ ({"payload": …} envelope, X-Access-Token required) +// and the GET /ws/alarm WebSocket. Everything rides ONE plaintext :8080 +// listener (contract rev 9 §B): no second socket, no TLS, no /config edge. +package main + +import ( + "context" + "net" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "git.dcntrlized.org/Dcntrlized/Alarm/db" + "git.dcntrlized.org/Dcntrlized/Alarm/handlers/actions" + "git.dcntrlized.org/Dcntrlized/Alarm/handlers/auth" + "git.dcntrlized.org/Dcntrlized/Alarm/handlers/manifest" + "git.dcntrlized.org/Dcntrlized/Alarm/logger" + "git.dcntrlized.org/Dcntrlized/Alarm/middleware" + "git.dcntrlized.org/Dcntrlized/Alarm/realtime" + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient" + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authproxy" +) + +// servicePort is the service's ONLY listening port. The API, the raw auth +// proxies and the /ws/alarm WebSocket all share it. +const servicePort = "8080" + +func main() { + // Root context cancelled on SIGINT/SIGTERM. The realtime hub (task-4) is + // owned by it, so a deploy drains subscribers instead of cutting them off. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // Shared-auth consumer: authclient validates RS256 access tokens against + // the published key; authproxy.Target() resolves AUTH_SERVICE_URL + // (http://dcntrlized-auth:8080 on dcntrlized_net). + if err := authclient.Init(context.Background(), authproxy.Target()); err != nil { + logger.Error("failed to initialize auth client: %v", err) + } + + logger.Info("Alarm service starting") + + // The HTTP surface is built by newAPIMux/newOuterMux so main_test.go can + // construct exactly the same wiring and fail the gate on a route conflict: + // http.ServeMux panics on a duplicate pattern at registration time, which + // build/vet/gofmt cannot see. + apiMux := newAPIMux() + outer := newOuterMux(apiMux) + + // --- task-2: the shared database pool --- + // Created here, not lazily on first request: newDB() pings the shared + // database for up to 30s to tolerate container start ordering, and that + // wait must not happen inside a request. + database := db.NewDB() + defer db.Close() + + // --- task-4: the realtime layer --- + // Registers GET /ws/alarm on the outer mux (outside the logging/CORS + // chain), starts the hub + the DB notify bridge, and returns the drain func + // used in the shutdown path below. + realtimeShutdown := realtime.Register(outer, database) + + // ONE plaintext listener (contract rev 9 §B). ReadHeaderTimeout/IdleTimeout + // cover slow-header and idle-socket abuse. ReadTimeout/WriteTimeout are + // deliberately ZERO: this listener also serves the long-lived /ws/alarm + // WebSocket, and a server-level read/write deadline is applied to the + // hijacked connection, which would tear live alarms down mid-stream. The + // WS layer owns its per-message deadlines (ping/pong + SetReadDeadline / + // SetWriteDeadline) instead. + server := &http.Server{ + Handler: outer, + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + + ln, err := net.Listen("tcp", ":"+servicePort) + if err != nil { + logger.Error("listen failed on :%s: %v", servicePort, err) + os.Exit(1) + } + + serveErr := make(chan error, 1) + logger.Info("Alarm listening plaintext on :%s (single port: API + /ws/alarm)", servicePort) + go func() { serveErr <- server.Serve(ln) }() + + select { + case <-ctx.Done(): + logger.Info("signal received, shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + realtimeShutdown(shutdownCtx) // drains live subscribers before the HTTP server closes + _ = server.Shutdown(shutdownCtx) + case err := <-serveErr: + if err != nil && err != http.ErrServerClosed { + logger.Error("HTTP server failed: %v", err) + os.Exit(1) + } + } +} + +// newAPIMux builds the API surface reached through the API chain +// (LoggingMiddleware(CorsMiddleware(apiMux))). The logging middleware +// deliberately does NOT log request bodies or query strings: every /action +// payload carries an adminToken or a subscribeToken (frozen contract) and those +// must never land in logs. +// +// House pattern (see Calendar/src/main.go): exactly ONE registration per path, +// with the handler dispatching on method. Registering the same pattern twice +// panics at startup, so /auth/logout — which serves GET (bodyless no-op) and +// POST (revoke the refresh token) — is a single method-aware registration. +func newAPIMux() *http.ServeMux { + apiMux := http.NewServeMux() + + apiMux.HandleFunc("/_manifest", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + manifest.ManifestHandler(w, r) + }) + + apiMux.HandleFunc("/auth/delete", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + auth.AuthDeletePost(w, r, map[string]string{}) + }) + + apiMux.HandleFunc("/auth/login", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + auth.AuthLoginPost(w, r, map[string]string{}) + }) + + apiMux.HandleFunc("/auth/logout", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + auth.AuthLogoutGet(w, r, map[string]string{}) + case http.MethodPost: + auth.AuthLogoutPost(w, r, map[string]string{}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }) + + apiMux.HandleFunc("/auth/me", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + auth.AuthMeGet(w, r, map[string]string{}) + }) + + apiMux.HandleFunc("/auth/refresh", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + auth.AuthRefreshPost(w, r, map[string]string{}) + }) + + apiMux.HandleFunc("/auth/register", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + auth.AuthRegisterPost(w, r, map[string]string{}) + }) + + // /action/ dispatch is by HTTP method (GET/POST/PUT/DELETE); the + // switch contents live in handlers/actions (task-2 scope). + apiMux.HandleFunc("/action/", func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/action/") { + http.NotFound(w, r) + return + } + rest := strings.TrimPrefix(r.URL.Path, "/action/") + params := map[string]string{"actionName": rest} + switch r.Method { + case http.MethodDelete: + actions.ActionActionNameDelete(w, r, params) + return + case http.MethodGet: + actions.ActionActionNameGet(w, r, params) + return + case http.MethodPost: + actions.ActionActionNamePost(w, r, params) + return + case http.MethodPut: + actions.ActionActionNamePut(w, r, params) + return + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + }) + + return apiMux +} + +// newOuterMux builds the single-listener mux. The API prefixes ride the API +// chain; /ws/alarm is registered separately by the realtime package (task-4) +// because a WebSocket upgrade must not be touched by the logging/CORS chain. +func newOuterMux(apiMux *http.ServeMux) *http.ServeMux { + outer := http.NewServeMux() + + apiChain := middleware.LoggingMiddleware(middleware.CorsMiddleware(apiMux)) + outer.Handle("/_manifest", apiChain) + outer.Handle("/auth/", apiChain) + outer.Handle("/action/", apiChain) + + return outer +} diff --git a/src/main_test.go b/src/main_test.go new file mode 100644 index 0000000..6161280 --- /dev/null +++ b/src/main_test.go @@ -0,0 +1,109 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// TestRoutesConstruct builds exactly the wiring main() builds and fails on a +// panic. http.ServeMux panics at registration time when the same pattern is +// registered twice, which kills the process before it binds :8080 — and +// go build/vet/gofmt/go test cannot see it unless a test constructs the mux. +// This test exists because a duplicate /auth/logout registration shipped and +// only a real launch caught it. +func TestRoutesConstruct(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("route registration panicked — the service would not boot: %v", r) + } + }() + + apiMux := newAPIMux() + if apiMux == nil { + t.Fatal("newAPIMux returned nil") + } + outer := newOuterMux(apiMux) + if outer == nil { + t.Fatal("newOuterMux returned nil") + } +} + +// TestWebSocketPatternIsFree proves main.go leaves /ws/alarm to the realtime +// layer: registering it on the outer mux must not conflict with anything +// main.go registered (realtime.Register does exactly this at boot). +func TestWebSocketPatternIsFree(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("/ws/alarm conflicts with a pattern registered by main.go: %v", r) + } + }() + + outer := newOuterMux(newAPIMux()) + outer.Handle("/ws/alarm", http.NotFoundHandler()) +} + +// TestManifestThroughOuterChain serves GET /_manifest through the real outer +// mux (logging + CORS chain included), which is what a caller hits. +func TestManifestThroughOuterChain(t *testing.T) { + outer := newOuterMux(newAPIMux()) + + rec := httptest.NewRecorder() + outer.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/_manifest", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /_manifest = %d, want 200 (body: %s)", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") { + t.Fatalf("GET /_manifest Content-Type = %q, want application/json", ct) + } + if body := rec.Body.String(); !strings.Contains(body, `"name": "Alarm"`) && !strings.Contains(body, `"name":"Alarm"`) { + t.Fatalf("GET /_manifest body does not name the service: %s", body) + } +} + +// TestLogoutServesBothMethods pins the method-aware registration: GET (bodyless +// no-op) and POST (revoke the refresh token) must both be routed, and any other +// verb must be rejected with 405. The handler proxies to dcntrlized-auth, so the +// requests carry a short deadline: the assertion is about routing, and the test +// must not depend on (or wait for) a live auth service. +func TestLogoutServesBothMethods(t *testing.T) { + outer := newOuterMux(newAPIMux()) + + do := func(method string) int { + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + req := httptest.NewRequest(method, "/auth/logout", nil).WithContext(ctx) + rec := httptest.NewRecorder() + outer.ServeHTTP(rec, req) + return rec.Code + } + + for _, method := range []string{http.MethodGet, http.MethodPost} { + if code := do(method); code == http.StatusMethodNotAllowed || code == http.StatusNotFound { + t.Fatalf("%s /auth/logout = %d, want the method to be routed", method, code) + } + } + + if code := do(http.MethodPut); code != http.StatusMethodNotAllowed { + t.Fatalf("PUT /auth/logout = %d, want 405", code) + } +} diff --git a/src/middleware/cors.go b/src/middleware/cors.go new file mode 100644 index 0000000..e7e2aca --- /dev/null +++ b/src/middleware/cors.go @@ -0,0 +1,44 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package middleware + +import "net/http" + +// CorsMiddleware is the house-pattern CORS shim for the RN clients (no cookie +// auth anywhere in the fleet, so echoing the Origin and allowing +// X-Access-Token is safe). It answers preflights directly. +func CorsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Dynamically echo the origin sent by the browser + origin := r.Header.Get("Origin") + if origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + } else { + w.Header().Set("Access-Control-Allow-Origin", "*") + } + + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + // Note: Match the exact casing the browser requested, just to be safe + w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, Authorization, X-Access-Token") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/src/middleware/logger.go b/src/middleware/logger.go new file mode 100644 index 0000000..fce9f7c --- /dev/null +++ b/src/middleware/logger.go @@ -0,0 +1,104 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package middleware + +import ( + "bufio" + "net" + "net/http" + "strings" + "time" + + "git.dcntrlized.org/Dcntrlized/Alarm/logger" +) + +// LoggingMiddleware logs one line per API request: method, path, status, +// duration, user agent and client IP. +// +// It deliberately deviates from the Calendar/KeepNote house shim in ONE way: +// it never logs the request body or the query string. In Alarm every +// /action payload carries an adminToken or a subscribeToken, and the +// /ws/alarm upgrade carries the subscribe token in its query string, so the +// frozen contract's "adminToken is stored only as SHA-256" promise would be +// broken by writing them to logs/. +func LoggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + ip := r.RemoteAddr + if host, _, err := net.SplitHostPort(ip); err == nil { + ip = host + } + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + ip = strings.TrimSpace(strings.Split(forwarded, ",")[0]) + } + + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + + logger.Info("%s request=%s %s path=%s status=%d ua=%q ip=%s duration=%s", + time.Now().Format(time.RFC3339), + r.Method, + r.Proto, + r.URL.Path, + rec.status, + r.UserAgent(), + ip, + time.Since(start), + ) + }) +} + +// statusRecorder captures the response status without altering the response. +// It preserves http.Hijacker so the /ws/alarm upgrade still works when the +// route is mounted behind this middleware. +type statusRecorder struct { + http.ResponseWriter + status int + wroteHeader bool +} + +func (s *statusRecorder) WriteHeader(code int) { + if !s.wroteHeader { + s.status = code + s.wroteHeader = true + } + s.ResponseWriter.WriteHeader(code) +} + +func (s *statusRecorder) Write(b []byte) (int, error) { + if !s.wroteHeader { + s.wroteHeader = true + } + return s.ResponseWriter.Write(b) +} + +// Hijack forwards to the wrapped ResponseWriter so a WebSocket upgrade can +// take over the connection. +func (s *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hj, ok := s.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, http.ErrNotSupported + } + return hj.Hijack() +} + +// Flush forwards to the wrapped ResponseWriter when it supports flushing. +func (s *statusRecorder) Flush() { + if f, ok := s.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} diff --git a/src/realtime/auth.go b/src/realtime/auth.go new file mode 100644 index 0000000..1e14208 --- /dev/null +++ b/src/realtime/auth.go @@ -0,0 +1,148 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package realtime + +import ( + "errors" + "net/http" + "strings" + + "git.dcntrlized.org/Dcntrlized/Alarm/db" + "git.dcntrlized.org/Dcntrlized/Alarm/internal/alarmtoken" + "git.dcntrlized.org/Dcntrlized/Alarm/internal/apierr" + "git.dcntrlized.org/Dcntrlized/Alarm/logger" + "git.dcntrlized.org/dcntrlized/dcntrlized-auth/authclient" + "github.com/google/uuid" +) + +// authenticateWS resolves a GET /ws/alarm upgrade request: +// +// ?alertId= required +// &token= required; the alert's admin token OR its subscribe token +// &deviceId= optional; picks which device row is kept alive +// +// The caller's identity always comes from a verified X-Access-Token header. The +// access token may also be supplied as ?accessToken= / ?access_token= for +// clients (and debugging tools) that cannot set headers on the upgrade; that is +// a convenience, not a second trust path — the token is verified identically. +// +// A subscribe token authorises only a user who actually has a subscription on +// the alert, so knowing the shared token is not enough on its own. The admin +// token is only honoured for the user the alert was created by. +func authenticateWS(r *http.Request) (*Identity, error) { + access := strings.TrimSpace(r.Header.Get("X-Access-Token")) + if access == "" { + access = strings.TrimSpace(r.URL.Query().Get("accessToken")) + } + if access == "" { + access = strings.TrimSpace(r.URL.Query().Get("access_token")) + } + if access == "" { + return nil, &AuthError{ + Status: http.StatusUnauthorized, Code: apierr.Unauthorized, Message: "missing access token", + } + } + + authReq := r.Clone(r.Context()) + authReq.Header.Set("X-Access-Token", access) + p, err := authclient.Authorize(authReq) + if err != nil { + logger.Warn("ws: access token rejected: %v", err) + return nil, &AuthError{ + Status: http.StatusUnauthorized, Code: apierr.Unauthorized, Message: "invalid access token", + } + } + + alertID, err := uuid.Parse(strings.TrimSpace(r.URL.Query().Get("alertId"))) + if err != nil { + return nil, &AuthError{ + Status: http.StatusBadRequest, Code: apierr.Validation, Message: "alertId is required", + } + } + token := strings.TrimSpace(r.URL.Query().Get("token")) + if token == "" { + return nil, &AuthError{ + Status: http.StatusForbidden, Code: apierr.AlertTokenInvalid, Message: "token is required", + } + } + + alert, err := db.GetAlert(r.Context(), alertID) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + return nil, &AuthError{ + Status: http.StatusNotFound, Code: apierr.AlertNotFound, Message: "alert not found", + } + } + logger.Error("ws: alert lookup failed: %v", err) + return nil, &AuthError{ + Status: http.StatusInternalServerError, Code: apierr.Internal, Message: "internal error", + } + } + + // Admin path: the alert's own admin token, presented by the alert's admin. + if alarmtoken.EqualHash(alert.AdminTokenHash, token) { + if alert.AdminUserID != p.ID { + logger.Warn("ws: user=%s presented the admin token of alert=%s without owning it", p.ID, alert.ID) + return nil, &AuthError{ + Status: http.StatusForbidden, Code: apierr.AlertNotAdmin, Message: "caller is not the alert admin", + } + } + return &Identity{AlertID: alert.ID, UserID: p.ID, IsAdmin: true}, nil + } + + // Subscriber path: the shared subscribe token plus a real subscription. + if !alarmtoken.EqualToken(alert.SubscribeToken, token) { + logger.Warn("ws: token mismatch alert=%s user=%s", alert.ID, p.ID) + return nil, &AuthError{ + Status: http.StatusForbidden, Code: apierr.AlertTokenInvalid, Message: "invalid token", + } + } + sub, err := resolveSubscription(r, alert.ID, p.ID) + if err != nil { + if errors.Is(err, db.ErrNotFound) { + return nil, &AuthError{ + Status: http.StatusNotFound, Code: apierr.AlertNotSubscribed, Message: "no subscription for this user on the alert", + } + } + logger.Error("ws: subscription lookup failed: %v", err) + return nil, &AuthError{ + Status: http.StatusInternalServerError, Code: apierr.Internal, Message: "internal error", + } + } + return &Identity{ + AlertID: alert.ID, + UserID: p.ID, + SubscriptionID: &sub.ID, + DeviceID: sub.DeviceID, + }, nil +} + +// resolveSubscription picks the device row a connection keeps alive: the one +// named by ?deviceId= when given, otherwise the user's oldest subscription on +// the alert. +func resolveSubscription(r *http.Request, alertID, userID uuid.UUID) (*db.Subscription, error) { + if deviceID := strings.TrimSpace(r.URL.Query().Get("deviceId")); deviceID != "" { + return db.GetSubscriptionByDevice(r.Context(), alertID, userID, deviceID) + } + ids, err := db.UserSubscriptionIDs(r.Context(), alertID, userID) + if err != nil { + return nil, err + } + if len(ids) == 0 { + return nil, db.ErrNotFound + } + return db.GetSubscription(r.Context(), alertID, ids[0]) +} diff --git a/src/realtime/client.go b/src/realtime/client.go new file mode 100644 index 0000000..16c0b8d --- /dev/null +++ b/src/realtime/client.go @@ -0,0 +1,156 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package realtime + +import ( + "encoding/json" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// client is one WebSocket connection. Reads and writes are owned by two +// separate goroutines (readPump/writePump); the only shared state is the +// buffered send channel and the close-once guard. +type client struct { + hub *Hub + conn *websocket.Conn + identity Identity + send chan []byte + done chan struct{} + + closeOnce sync.Once + + // ackMu guards acks; the counter is read by tests and by hub.Acks. + ackMu sync.Mutex + acks int +} + +func newClient(h *Hub, conn *websocket.Conn, identity Identity) *client { + return &client{ + hub: h, + conn: conn, + identity: identity, + send: make(chan []byte, h.opts.SendBuffer), + done: make(chan struct{}), + } +} + +// trySend queues a frame without ever blocking. It reports false when the +// client's buffer is full (the caller then drops the client). +func (c *client) trySend(payload []byte) bool { + select { + case c.send <- payload: + return true + case <-c.done: + return false + default: + return false + } +} + +// close signals both pumps to stop. It is idempotent. +func (c *client) close() { + c.closeOnce.Do(func() { + close(c.done) + }) +} + +func (c *client) touch() { + c.hub.touch(c) +} + +// readPump owns all reads. The read deadline is refreshed by pongs, so a dead +// peer is detected within PongWait. +func (c *client) readPump() { + defer func() { + c.hub.unregister(c) + c.close() + _ = c.conn.Close() + }() + + c.conn.SetReadLimit(c.hub.opts.MaxFrameBytes) + _ = c.conn.SetReadDeadline(time.Now().Add(c.hub.opts.PongWait)) + c.conn.SetPongHandler(func(string) error { + _ = c.conn.SetReadDeadline(time.Now().Add(c.hub.opts.PongWait)) + c.touch() + return nil + }) + + for { + _, data, err := c.conn.ReadMessage() + if err != nil { + return + } + var f ClientFrame + if err := json.Unmarshal(data, &f); err != nil { + // A malformed client frame is ignored, never fatal. + continue + } + switch f.Type { + case "ack": + c.ackMu.Lock() + c.acks++ + c.ackMu.Unlock() + c.hub.recordAck(f.EventID) + case "ping": + c.touch() + // Answer at the protocol level: the frozen contract defines no + // JSON pong frame. + _ = c.conn.WriteControl(websocket.PongMessage, nil, time.Now().Add(c.hub.opts.WriteWait)) + } + } +} + +// writePump owns all writes except the WriteControl calls in readPump (which +// gorilla explicitly allows concurrently). +func (c *client) writePump() { + ticker := time.NewTicker(c.hub.opts.PingInterval) + defer func() { + ticker.Stop() + c.close() + _ = c.conn.Close() + }() + + for { + select { + case payload := <-c.send: + _ = c.conn.SetWriteDeadline(time.Now().Add(c.hub.opts.WriteWait)) + if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil { + return + } + case <-ticker.C: + _ = c.conn.SetWriteDeadline(time.Now().Add(c.hub.opts.WriteWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + c.touch() + case <-c.done: + _ = c.conn.SetWriteDeadline(time.Now().Add(c.hub.opts.WriteWait)) + _ = c.conn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseGoingAway, "server shutting down")) + return + } + } +} + +// ackCount reports how many ack frames this connection sent. +func (c *client) ackCount() int { + c.ackMu.Lock() + defer c.ackMu.Unlock() + return c.acks +} diff --git a/src/realtime/hub.go b/src/realtime/hub.go new file mode 100644 index 0000000..ea59830 --- /dev/null +++ b/src/realtime/hub.go @@ -0,0 +1,453 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package realtime is the Alarm service's live delivery path: a WebSocket hub +// with one buffered send channel per client, an in-memory last-event cache for +// reconnect catch-up, and a PostgreSQL LISTEN/NOTIFY bridge so a trigger or +// disarm started on one service instance reaches subscribers connected to any +// other instance. +// +// Design rules (frozen contract v1): +// +// - The hub NEVER blocks: Broadcast does a non-blocking channel send and +// drops a client whose buffer is full (slow client), rather than stalling +// the other subscribers. +// - All deadlines live in this layer, not in http.Server: the service sets +// Read/WriteTimeout=0 because a non-zero server timeout kills a hijacked +// WebSocket connection. Each connection gets its own read deadline +// (PongWait), per-message write deadline (WriteWait) and server pings +// (PingInterval). +// - De-duplication is by event id: the in-process fast path and the NOTIFY +// bridge both deliver the same event, and the second one is dropped. +package realtime + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "sync" + "time" + + "git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate" + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +// Defaults for the keepalive envelope. +const ( + DefaultPingInterval = 20 * time.Second + DefaultPongWait = 60 * time.Second + DefaultWriteWait = 10 * time.Second + DefaultSendBuffer = 16 + DefaultMaxFrameSize = 4096 + + // ackCap bounds the per-event ack counters kept for observability. + ackCap = 256 +) + +// AlarmFrame is the server -> client alarm frame. +type AlarmFrame struct { + Type string `json:"type"` // always "alarm" + AlertID string `json:"alertId"` + EventID string `json:"eventId"` + Action string `json:"action"` // "trigger" | "disarm" + Message string `json:"message"` + ServerTime string `json:"serverTime"` // RFC3339, UTC + AutoStopSeconds int `json:"autoStopSeconds"` +} + +// AckFrame is the server -> client acknowledgement of a client ack. +type AckFrame struct { + Type string `json:"type"` // always "ack" + EventID string `json:"eventId"` +} + +// ClientFrame is the client -> server frame. `ping` is a keepalive; the server +// answers it with a WebSocket pong control frame (the contract defines no JSON +// pong) and refreshes subscriptions.last_seen_at. +type ClientFrame struct { + Type string `json:"type"` + EventID string `json:"eventId,omitempty"` +} + +// Identity is the authenticated WebSocket caller. +type Identity struct { + AlertID uuid.UUID + UserID uuid.UUID + IsAdmin bool + SubscriptionID *uuid.UUID + DeviceID string +} + +// AuthError lets the authentication callback choose the pre-upgrade HTTP +// status and wire error code. Any other error is reported as 401 +// ERR_UNAUTHORIZED. +type AuthError struct { + Status int + Code string + Message string +} + +func (e *AuthError) Error() string { + return fmt.Sprintf("realtime: auth rejected (%d %s): %s", e.Status, e.Code, e.Message) +} + +// Options configures a Hub. Authenticate is required; the rest default. +type Options struct { + // Authenticate resolves the caller from the upgrade request (query params + // plus headers). Returning an error refuses the upgrade. + Authenticate func(r *http.Request) (*Identity, error) + // Touch refreshes subscriptions.last_seen_at. Optional. + Touch func(ctx context.Context, subscriptionID uuid.UUID) error + + PingInterval time.Duration + PongWait time.Duration + WriteWait time.Duration + SendBuffer int + MaxFrameBytes int64 + Logf func(format string, v ...any) +} + +func (o Options) withDefaults() Options { + if o.PingInterval <= 0 { + o.PingInterval = DefaultPingInterval + } + if o.PongWait <= 0 { + o.PongWait = DefaultPongWait + } + if o.WriteWait <= 0 { + o.WriteWait = DefaultWriteWait + } + if o.SendBuffer <= 0 { + o.SendBuffer = DefaultSendBuffer + } + if o.MaxFrameBytes <= 0 { + o.MaxFrameBytes = DefaultMaxFrameSize + } + if o.Logf == nil { + o.Logf = log.Printf + } + return o +} + +type lastEvent struct { + frame AlarmFrame + at time.Time +} + +func (e lastEvent) active(now time.Time) bool { + if e.frame.Action != alertstate.KindTrigger { + return false + } + if e.frame.AutoStopSeconds <= 0 { + return true + } + return now.Before(e.at.Add(time.Duration(e.frame.AutoStopSeconds) * time.Second)) +} + +// Hub fans alarm frames out to every WebSocket client of an alert. +type Hub struct { + opts Options + upgrader websocket.Upgrader + + mu sync.Mutex + clients map[uuid.UUID]map[*client]struct{} + last map[uuid.UUID]lastEvent + acks map[string]int + ackRing []string + closed bool + + wg sync.WaitGroup +} + +// NewHub builds a hub. It panics when Authenticate is nil: a hub that cannot +// authenticate must never be mounted. +func NewHub(opts Options) *Hub { + if opts.Authenticate == nil { + panic("realtime: Options.Authenticate is required") + } + opts = opts.withDefaults() + return &Hub{ + opts: opts, + upgrader: websocket.Upgrader{ + HandshakeTimeout: 10 * time.Second, + ReadBufferSize: 1024, + WriteBufferSize: 1024, + // Mobile clients send no Origin and the handshake is bearer-token + // authenticated (X-Access-Token + alert token), so Origin carries + // no authority here. A browser page cannot read the token. + CheckOrigin: func(r *http.Request) bool { return true }, + }, + clients: make(map[uuid.UUID]map[*client]struct{}), + last: make(map[uuid.UUID]lastEvent), + acks: make(map[string]int), + } +} + +// Handler upgrades GET /ws/alarm?alertId=&token= and serves the connection. +func (h *Hub) Handler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + writeJSONError(w, http.StatusMethodNotAllowed, "ERR_METHOD_NOT_ALLOWED", "method not allowed") + return + } + + identity, err := h.opts.Authenticate(r) + if err != nil { + var authErr *AuthError + if errors.As(err, &authErr) { + writeJSONError(w, authErr.Status, authErr.Code, authErr.Message) + return + } + writeJSONError(w, http.StatusUnauthorized, "ERR_UNAUTHORIZED", "unauthorized") + return + } + + h.mu.Lock() + closed := h.closed + h.mu.Unlock() + if closed { + writeJSONError(w, http.StatusServiceUnavailable, "ERR_UNAVAILABLE", "server is shutting down") + return + } + + conn, err := h.upgrader.Upgrade(w, r, nil) + if err != nil { + // Upgrade already wrote the HTTP error response. + h.opts.Logf("ws: upgrade failed alert=%s: %v", identity.AlertID, err) + return + } + + c := newClient(h, conn, *identity) + h.register(c) + + // Reconnect catch-up: replay the last event only while it is still + // live, so a client that reconnects mid-alarm fires immediately and a + // client reconnecting after the alarm is never re-alarmed. + if f, ok := h.lastActive(identity.AlertID); ok { + if b, err := json.Marshal(f); err == nil { + c.trySend(b) + } + } + + c.touch() + + h.wg.Add(2) + go func() { + defer h.wg.Done() + c.writePump() + }() + go func() { + defer h.wg.Done() + c.readPump() + }() + } +} + +func (h *Hub) register(c *client) { + h.mu.Lock() + defer h.mu.Unlock() + if h.clients[c.identity.AlertID] == nil { + h.clients[c.identity.AlertID] = make(map[*client]struct{}) + } + h.clients[c.identity.AlertID][c] = struct{}{} +} + +func (h *Hub) unregister(c *client) { + h.mu.Lock() + defer h.mu.Unlock() + if set, ok := h.clients[c.identity.AlertID]; ok { + delete(set, c) + if len(set) == 0 { + delete(h.clients, c.identity.AlertID) + } + } +} + +// Broadcast delivers f to every live client of the alert and returns how many +// connections the alert has on this instance. A duplicate event id (the same +// event arriving through both the in-process fast path and the NOTIFY bridge) +// is delivered once. +func (h *Hub) Broadcast(alertID uuid.UUID, f AlarmFrame) int { + if f.Type == "" { + f.Type = "alarm" + } + + h.mu.Lock() + if h.closed { + h.mu.Unlock() + return 0 + } + if prev, ok := h.last[alertID]; ok && prev.frame.EventID != "" && prev.frame.EventID == f.EventID { + n := len(h.clients[alertID]) + h.mu.Unlock() + return n + } + at := time.Now().UTC() + if ts, err := time.Parse(time.RFC3339Nano, f.ServerTime); err == nil { + at = ts + } + h.last[alertID] = lastEvent{frame: f, at: at} + + targets := make([]*client, 0, len(h.clients[alertID])) + for c := range h.clients[alertID] { + targets = append(targets, c) + } + h.mu.Unlock() + + payload, err := json.Marshal(f) + if err != nil { + h.opts.Logf("ws: marshal frame alert=%s event=%s: %v", alertID, f.EventID, err) + return 0 + } + + delivered := 0 + for _, c := range targets { + if c.trySend(payload) { + delivered++ + continue + } + // Buffer full: the client is not keeping up. Dropping it keeps the + // alarm live for everyone else. + h.opts.Logf("ws: dropping slow client user=%s alert=%s", c.identity.UserID, alertID) + c.close() + } + return delivered +} + +// LastEvent returns the most recent broadcast frame for an alert, active or +// not. Exposed for alarmStatus cross-checks and tests. +func (h *Hub) LastEvent(alertID uuid.UUID) (AlarmFrame, bool) { + h.mu.Lock() + defer h.mu.Unlock() + e, ok := h.last[alertID] + return e.frame, ok +} + +func (h *Hub) lastActive(alertID uuid.UUID) (AlarmFrame, bool) { + h.mu.Lock() + defer h.mu.Unlock() + e, ok := h.last[alertID] + if !ok || !e.active(time.Now().UTC()) { + return AlarmFrame{}, false + } + return e.frame, true +} + +// ClientCount returns the number of live connections for an alert. +func (h *Hub) ClientCount(alertID uuid.UUID) int { + h.mu.Lock() + defer h.mu.Unlock() + return len(h.clients[alertID]) +} + +// TotalClients returns the number of live connections across all alerts. +func (h *Hub) TotalClients() int { + h.mu.Lock() + defer h.mu.Unlock() + n := 0 + for _, set := range h.clients { + n += len(set) + } + return n +} + +func (h *Hub) recordAck(eventID string) { + if eventID == "" { + return + } + h.mu.Lock() + defer h.mu.Unlock() + if _, seen := h.acks[eventID]; !seen { + h.ackRing = append(h.ackRing, eventID) + for len(h.ackRing) > ackCap { + evict := h.ackRing[0] + h.ackRing = h.ackRing[1:] + delete(h.acks, evict) + } + } + h.acks[eventID]++ +} + +// Acks returns how many clients acknowledged an event on this instance. +func (h *Hub) Acks(eventID string) int { + h.mu.Lock() + defer h.mu.Unlock() + return h.acks[eventID] +} + +// Run blocks until ctx is cancelled, then drains every connection. It is meant +// to be started as `go hub.Run(ctx)`. +func (h *Hub) Run(ctx context.Context) { + <-ctx.Done() + drainCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := h.Shutdown(drainCtx); err != nil { + h.opts.Logf("ws: drain incomplete: %v", err) + } +} + +// Shutdown stops accepting connections, asks every client to close, and waits +// for the pumps to finish or ctx to expire. It is idempotent. +func (h *Hub) Shutdown(ctx context.Context) error { + h.mu.Lock() + h.closed = true + all := make([]*client, 0, 16) + for _, set := range h.clients { + for c := range set { + all = append(all, c) + } + } + h.mu.Unlock() + + for _, c := range all { + c.close() + } + + done := make(chan struct{}) + go func() { + h.wg.Wait() + close(done) + }() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (h *Hub) touch(c *client) { + if h.opts.Touch == nil || c.identity.SubscriptionID == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := h.opts.Touch(ctx, *c.identity.SubscriptionID); err != nil { + h.opts.Logf("ws: last_seen_at update failed sub=%s: %v", *c.identity.SubscriptionID, err) + } +} + +func writeJSONError(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]string{"code": code, "message": message}, + }) +} diff --git a/src/realtime/hub_test.go b/src/realtime/hub_test.go new file mode 100644 index 0000000..96be95e --- /dev/null +++ b/src/realtime/hub_test.go @@ -0,0 +1,238 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package realtime + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +// testIdentity is what a successful authentication resolves to. +func testIdentity(alertID uuid.UUID) Identity { + return Identity{AlertID: alertID, UserID: uuid.New()} +} + +// startTestServer runs a hub whose authentication always succeeds and returns +// the server, its ws:// URL and the hub. +func startTestServer(t *testing.T, alertID uuid.UUID) (*httptest.Server, string, *Hub) { + t.Helper() + hub := NewHub(Options{ + Authenticate: func(r *http.Request) (*Identity, error) { + id := testIdentity(alertID) + return &id, nil + }, + Logf: func(format string, v ...any) { t.Logf(format, v...) }, + }) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go hub.Run(ctx) + + srv := httptest.NewServer(hub.Handler()) + t.Cleanup(srv.Close) + return srv, "ws" + strings.TrimPrefix(srv.URL, "http"), hub +} + +// dial opens one subscriber connection. +func dial(t *testing.T, wsURL string) *websocket.Conn { + t.Helper() + conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + status := 0 + if resp != nil { + status = resp.StatusCode + } + t.Fatalf("dial %s: %v (http %d)", wsURL, err, status) + } + t.Cleanup(func() { _ = conn.Close() }) + return conn +} + +// waitClients blocks until the hub has n clients on the alert. +func waitClients(t *testing.T, hub *Hub, alertID uuid.UUID, n int) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if hub.ClientCount(alertID) == n { + return + } + time.Sleep(2 * time.Millisecond) + } + t.Fatalf("hub has %d clients on alert %s, want %d", hub.ClientCount(alertID), alertID, n) +} + +// readAlarm reads one server frame and requires it to be an alarm frame. +func readAlarm(t *testing.T, conn *websocket.Conn) AlarmFrame { + t.Helper() + if err := conn.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + var frame AlarmFrame + if err := conn.ReadJSON(&frame); err != nil { + t.Fatalf("read alarm frame: %v", err) + } + if frame.Type != "alarm" { + t.Fatalf("frame type = %q, want \"alarm\"", frame.Type) + } + return frame +} + +// TestBroadcastReachesTwoClientsAndAckRoundTrips is the task-4 acceptance test: +// ONE Broadcast call must reach TWO live subscribers, each must see the same +// event id, and each client ack must be recorded by the hub. +func TestBroadcastReachesTwoClientsAndAckRoundTrips(t *testing.T) { + alertID := uuid.New() + _, wsURL, hub := startTestServer(t, alertID) + + first := dial(t, wsURL) + second := dial(t, wsURL) + waitClients(t, hub, alertID, 2) + + eventID := uuid.NewString() + frame := AlarmFrame{ + Type: "alarm", + AlertID: alertID.String(), + EventID: eventID, + Action: "trigger", + Message: "fire alarm", + ServerTime: time.Now().UTC().Format(time.RFC3339Nano), + } + + if delivered := hub.Broadcast(alertID, frame); delivered != 2 { + t.Fatalf("Broadcast delivered to %d clients, want 2", delivered) + } + + for i, conn := range []*websocket.Conn{first, second} { + got := readAlarm(t, conn) + if got.EventID != eventID { + t.Fatalf("client %d event id = %q, want %q", i, got.EventID, eventID) + } + if got.Action != "trigger" || got.Message != "fire alarm" { + t.Fatalf("client %d frame = %+v, want the triggered alarm", i, got) + } + // Ack round-trip: the client acknowledges, the hub counts it. + if err := conn.WriteJSON(ClientFrame{Type: "ack", EventID: eventID}); err != nil { + t.Fatalf("client %d ack: %v", i, err) + } + } + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) && hub.Acks(eventID) < 2 { + time.Sleep(2 * time.Millisecond) + } + if got := hub.Acks(eventID); got != 2 { + t.Fatalf("hub recorded %d acks for event %s, want 2", got, eventID) + } +} + +// TestBroadcastIsDedupedByEventID proves the local broadcast and the database +// NOTIFY path cannot deliver the same event twice: a repeat broadcast of an +// event the hub already holds delivers nothing, it only reports how many +// clients currently hold it. +func TestBroadcastIsDedupedByEventID(t *testing.T) { + alertID := uuid.New() + _, wsURL, hub := startTestServer(t, alertID) + + conn := dial(t, wsURL) + waitClients(t, hub, alertID, 1) + + frame := AlarmFrame{ + Type: "alarm", + AlertID: alertID.String(), + EventID: uuid.NewString(), + Action: "trigger", + ServerTime: time.Now().UTC().Format(time.RFC3339Nano), + } + if delivered := hub.Broadcast(alertID, frame); delivered != 1 { + t.Fatalf("first Broadcast delivered to %d clients, want 1", delivered) + } + // Same event id again: the frame must NOT be written a second time. + if delivered := hub.Broadcast(alertID, frame); delivered != 1 { + t.Fatalf("repeat Broadcast reported %d clients, want 1 (already held)", delivered) + } + if got := readAlarm(t, conn); got.EventID != frame.EventID { + t.Fatalf("event id = %q, want %q", got.EventID, frame.EventID) + } + // Nothing else may arrive: the duplicate was suppressed, not queued. + if err := conn.SetReadDeadline(time.Now().Add(250 * time.Millisecond)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + var extra AlarmFrame + if err := conn.ReadJSON(&extra); err == nil { + t.Fatalf("duplicate event was delivered: %+v", extra) + } +} + +// TestReconnectReplaysLiveTrigger covers the catch-up path: a subscriber that +// connects while an alarm is still live is alarmed immediately. +func TestReconnectReplaysLiveTrigger(t *testing.T) { + alertID := uuid.New() + _, wsURL, hub := startTestServer(t, alertID) + + // Broadcast with nobody connected: the hub keeps it as the last event. + frame := AlarmFrame{ + Type: "alarm", + AlertID: alertID.String(), + EventID: uuid.NewString(), + Action: "trigger", + Message: "still ringing", + ServerTime: time.Now().UTC().Format(time.RFC3339Nano), + AutoStopSeconds: 300, + } + if delivered := hub.Broadcast(alertID, frame); delivered != 0 { + t.Fatalf("Broadcast with no clients delivered to %d, want 0", delivered) + } + + conn := dial(t, wsURL) + got := readAlarm(t, conn) + if got.EventID != frame.EventID || got.Message != "still ringing" { + t.Fatalf("catch-up frame = %+v, want event %s", got, frame.EventID) + } +} + +// TestHandlerRejectsNonGet keeps the WS endpoint's error shape uniform with +// /action/*: a plain JSON error, not an upgrade attempt. +func TestHandlerRejectsNonGet(t *testing.T) { + alertID := uuid.New() + srv, _, _ := startTestServer(t, alertID) + + resp, err := http.Post(srv.URL+"/ws/alarm", "application/json", strings.NewReader("{}")) + if err != nil { + t.Fatalf("post: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want 405", resp.StatusCode) + } + var body struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode error body: %v", err) + } + if body.Error.Code == "" { + t.Fatalf("error body has no code: %+v", body) + } +} diff --git a/src/realtime/latency_test.go b/src/realtime/latency_test.go new file mode 100644 index 0000000..35a50d6 --- /dev/null +++ b/src/realtime/latency_test.go @@ -0,0 +1,77 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package realtime + +import ( + "sort" + "testing" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +// TestBroadcastLatency measures the fan-out path on real sockets: from the +// instant Broadcast is called to the instant BOTH subscribers have decoded the +// frame. It excludes the HTTP action and the database insert that precede a +// trigger in production, so it is the floor for the end-to-end number. +// +// It logs p50/p95/max and only fails on a gross regression (p95 > 500ms) to +// stay useful without being flaky on a loaded machine. +func TestBroadcastLatency(t *testing.T) { + alertID := uuid.New() + _, wsURL, hub := startTestServer(t, alertID) + + first := dial(t, wsURL) + second := dial(t, wsURL) + waitClients(t, hub, alertID, 2) + + const iterations = 100 + samples := make([]time.Duration, 0, iterations) + for i := 0; i < iterations; i++ { + eventID := uuid.NewString() + frame := AlarmFrame{ + Type: "alarm", + AlertID: alertID.String(), + EventID: eventID, + Action: "trigger", + Message: "latency probe", + ServerTime: time.Now().UTC().Format(time.RFC3339Nano), + AutoStopSeconds: 300, + } + start := time.Now() + if delivered := hub.Broadcast(alertID, frame); delivered != 2 { + t.Fatalf("iteration %d: Broadcast delivered to %d clients, want 2", i, delivered) + } + for _, conn := range []*websocket.Conn{first, second} { + got := readAlarm(t, conn) + if got.EventID != eventID { + t.Fatalf("iteration %d: event id = %q, want %q", i, got.EventID, eventID) + } + } + samples = append(samples, time.Since(start)) + } + + sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] }) + p50 := samples[len(samples)*50/100] + p95 := samples[len(samples)*95/100] + max := samples[len(samples)-1] + t.Logf("broadcast -> 2 clients decoded: n=%d p50=%v p95=%v max=%v", + len(samples), p50, p95, max) + if p95 > 500*time.Millisecond { + t.Fatalf("p95 latency %v exceeds the 500ms budget", p95) + } +} diff --git a/src/realtime/notify.go b/src/realtime/notify.go new file mode 100644 index 0000000..e573693 --- /dev/null +++ b/src/realtime/notify.go @@ -0,0 +1,141 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package realtime + +import ( + "context" + "encoding/json" + "errors" + "time" + + "git.dcntrlized.org/Dcntrlized/Alarm/internal/alertstate" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// NotifyChannel is the PostgreSQL NOTIFY channel the alarm schema publishes +// trigger/disarm events on (see alarm.notify_alarm_event() in +// database/migrations/001_alarm_schema.sql). +const NotifyChannel = "alarm_events" + +// NotifyPayload is the transport form of an alarm frame on the NOTIFY channel. +// It is emitted by the database trigger, so every service instance — not just +// the one that served the trigger request — fans the event out to its own +// subscribers. +type NotifyPayload struct { + AlertID string `json:"alertId"` + EventID string `json:"eventId"` + Action string `json:"action"` + Message string `json:"message"` + AutoStopSeconds int `json:"autoStopSeconds"` + CreatedAtMs int64 `json:"createdAtMs"` +} + +// Frame converts the transport payload into the wire frame. ServerTime is the +// EVENT time, not the delivery time, so every instance and every client agrees +// on when the alarm started (and therefore on the auto-stop countdown). +func (p NotifyPayload) Frame() AlarmFrame { + return AlarmFrame{ + Type: "alarm", + AlertID: p.AlertID, + EventID: p.EventID, + Action: p.Action, + Message: p.Message, + ServerTime: time.UnixMilli(p.CreatedAtMs).UTC().Format(time.RFC3339Nano), + AutoStopSeconds: p.AutoStopSeconds, + } +} + +// ErrBadNotifyPayload reports a NOTIFY payload that cannot be trusted; the +// bridge logs and ignores it rather than delivering a malformed frame. +var ErrBadNotifyPayload = errors.New("realtime: malformed notify payload") + +// DeliverNotifyPayload parses one NOTIFY payload and broadcasts the frame. It +// is the unit-testable half of the bridge (no PostgreSQL needed). +func (h *Hub) DeliverNotifyPayload(payload []byte) (AlarmFrame, bool, error) { + var p NotifyPayload + if err := json.Unmarshal(payload, &p); err != nil { + return AlarmFrame{}, false, ErrBadNotifyPayload + } + alertID, err := uuid.Parse(p.AlertID) + if err != nil { + return AlarmFrame{}, false, ErrBadNotifyPayload + } + if _, err := uuid.Parse(p.EventID); err != nil { + return AlarmFrame{}, false, ErrBadNotifyPayload + } + if !alertstate.ValidKind(p.Action) { + return AlarmFrame{}, false, ErrBadNotifyPayload + } + f := p.Frame() + h.Broadcast(alertID, f) + return f, true, nil +} + +// RunNotifyBridge listens on the alarm_events channel and fans every payload +// out to the hub, reconnecting with exponential backoff. It returns nil when +// ctx is cancelled. Run it as `go realtime.RunNotifyBridge(ctx, hub, dsn)`. +func RunNotifyBridge(ctx context.Context, hub *Hub, dsn string) error { + backoff := 500 * time.Millisecond + const maxBackoff = 10 * time.Second + + for { + if ctx.Err() != nil { + return nil + } + err := listenOnce(ctx, hub, dsn) + if ctx.Err() != nil { + return nil + } + hub.opts.Logf("notify: bridge disconnected (%v); reconnecting in %s", err, backoff) + select { + case <-ctx.Done(): + return nil + case <-time.After(backoff): + } + if backoff < maxBackoff { + backoff *= 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + } + } +} + +func listenOnce(ctx context.Context, hub *Hub, dsn string) error { + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + return err + } + defer func() { _ = conn.Close(context.Background()) }() + + if _, err := conn.Exec(ctx, "LISTEN "+NotifyChannel); err != nil { + return err + } + hub.opts.Logf("notify: listening on channel %s", NotifyChannel) + + for { + n, err := conn.WaitForNotification(ctx) + if err != nil { + return err + } + if _, ok, err := hub.DeliverNotifyPayload([]byte(n.Payload)); err != nil { + hub.opts.Logf("notify: ignored payload: %v", err) + } else if ok { + hub.opts.Logf("notify: delivered event from channel %s", NotifyChannel) + } + } +} diff --git a/src/realtime/register.go b/src/realtime/register.go new file mode 100644 index 0000000..bf3ef44 --- /dev/null +++ b/src/realtime/register.go @@ -0,0 +1,96 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package realtime + +import ( + "context" + "net/http" + "sync" + + "git.dcntrlized.org/Dcntrlized/Alarm/db" + "git.dcntrlized.org/Dcntrlized/Alarm/logger" +) + +// current is the hub built by Register, read by the action layer for the +// in-process broadcast fast path. It is written once during startup, before the +// listener accepts traffic, and never mutated afterwards. +var current *Hub + +// Current returns the process-wide hub, or nil when the realtime layer was +// never registered (unit tests, or a deliberately API-only process). +func Current() *Hub { return current } + +// Register installs GET /ws/alarm on mux, starts the hub and the database +// notify bridge, and returns a drain function to be called once during +// shutdown. It does not block: the hub goroutine runs until the returned drain +// function is called or its context expires. +// +// The route is registered on the OUTER mux, never behind the API chain: the +// upgrade is hijacked, and the subscribe token rides the query string, so the +// request must not pass through the logging middleware. +// +// `database` is the service's single pool handle. The hub only needs the DSN +// for the LISTEN bridge, but accepting the handle keeps one pool per process +// and makes the dependency explicit. +func Register(mux *http.ServeMux, database *db.DB) func(ctx context.Context) { + hub := NewHub(Options{ + Authenticate: authenticateWS, + Touch: db.TouchSubscription, + Logf: func(format string, v ...any) { + logger.Warn(format, v...) + }, + }) + current = hub + + // The hub's lifetime is owned here, not by the caller: the drain function + // cancels it, which makes Run stop accepting and drain its clients. + hubCtx, cancelHub := context.WithCancel(context.Background()) + go hub.Run(hubCtx) + + dsn := db.DatabaseURL() + if database != nil && database.Pool != nil { + // Prefer the pool's own config so an injected DSN is honoured. + dsn = database.Pool.Config().ConnString() + } + hub.StartNotifyBridge(hubCtx, dsn) + + mux.Handle("/ws/alarm", hub.Handler()) + + var once sync.Once + return func(ctx context.Context) { + once.Do(func() { + cancelHub() + if err := hub.Shutdown(ctx); err != nil && ctx.Err() == nil { + logger.Warn("realtime: hub drain incomplete: %v", err) + } + }) + } +} + +// StartNotifyBridge subscribes this instance to the database's alarm_events +// channel so a trigger raised on ANY instance reaches this one's subscribers. +// It returns immediately; the listener reconnects with backoff until ctx ends. +func (h *Hub) StartNotifyBridge(ctx context.Context, dsn string) { + if dsn == "" { + logger.Warn("realtime: no database url, cross-instance notify bridge disabled") + return + } + go func() { + if err := RunNotifyBridge(ctx, h, dsn); err != nil && ctx.Err() == nil { + logger.Error("realtime: notify bridge stopped: %v", err) + } + }() +} diff --git a/src/types/types.go b/src/types/types.go new file mode 100644 index 0000000..45a9128 --- /dev/null +++ b/src/types/types.go @@ -0,0 +1,54 @@ +// Copyright (C) 2026 Dcntrlized +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package types + +import () + +type Manifest struct { + Name string `json:"name"` + Version *string `json:"version,omitempty"` + Author string `json:"author"` + Capabilities []string `json:"capabilities"` + HasAuth bool `json:"has_auth"` + PublicKey string `json:"public_key"` + Icon *string `json:"icon,omitempty"` +} + +type UserCredentials struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type UserRegistration struct { + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` +} + +type AuthResponse struct { + AccessToken *string `json:"accessToken,omitempty"` + RefreshToken *string `json:"refreshToken,omitempty"` + User interface{} `json:"user,omitempty"` +} + +type ActionRequest struct { + Payload interface{} `json:"payload,omitempty"` +} + +type ActionResponse struct { + Status *string `json:"status,omitempty"` + Data interface{} `json:"data,omitempty"` +}