diff --git a/.env.example b/.env.example index 2a43c40..b1cc24d 100644 --- a/.env.example +++ b/.env.example @@ -38,3 +38,20 @@ LOKI_TIMEOUT_SECONDS=5 API_PORT=8443 DB_POOL_MIN_SIZE=2 DB_POOL_MAX_SIZE=10 + +# --- Web UI --- +WEBUI_PORT=8080 +# Initial admin, created only when the users table is empty on first +# start. Leave ADMIN_PASSWORD empty to get the default admin/admin (the +# UI then forces a password change on first login). +ADMIN_USERNAME=admin +ADMIN_PASSWORD= + +# --- Web UI (access control) --- +# Port for the web UI (bound to 127.0.0.1). +WEBUI_PORT=8080 +# Initial admin, created only when the users table is empty. +# Leave ADMIN_PASSWORD empty to use the default admin/admin (the UI then +# forces a password change on first login). +ADMIN_USERNAME=admin +ADMIN_PASSWORD= diff --git a/README.md b/README.md index 8943686..26f83d3 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,41 @@ flowchart LR | ------------ | -------------------------------------------------------------- | | `timescaledb`| Time-series database storing telemetry as hypertables. | | `mqtt-client`| Subscribes to MQTTS topics, writes payloads to the database. | -| `api` | FastAPI service serving telemetry over HTTPS with mTLS. | +| `api` | FastAPI service serving telemetry over HTTPS (mTLS or session).| +| `webui` | Web UI for users, groups, and topic access rules. | | `loki` | Optional log aggregation (enabled via the `telemetry` profile).| +## Running + +Start the core stack: + +```bash +docker compose up -d +``` + +Start with Loki enabled: + +```bash +docker compose --profile telemetry up -d +``` + +Or use the helper scripts (`scripts/`), which load `.env`, check the +certificate files, and print the service URLs: + +```bash +# PowerShell +.\scripts\start.ps1 [-Profiles telemetry] +.\scripts\stop.ps1 [-Profiles telemetry] +.\scripts\prune.ps1 [-Volumes] [-All] # -Volumes deletes ALL data! + +# bash +./scripts/start.sh [telemetry] +./scripts/stop.sh [telemetry] +./scripts/prune.sh [--volumes] [--all] +``` + +The web UI listens on http://localhost:8080 (bound to 127.0.0.1). + ## Prerequisites - Docker and Docker Compose v2. @@ -56,6 +88,9 @@ Key variables: | `LOKI_URL` | Loki push URL; leave empty to disable. | | `LOKI_FLUSH_SECONDS`| Seconds between batched Loki pushes (default `5`). | | `API_PORT` | Host port for the HTTPS API (default `8443`). | +| `WEBUI_PORT` | Host port for the web UI (default `8080`). | +| `ADMIN_USERNAME` | Initial admin username (default `admin`; created only when no users exist). | +| `ADMIN_PASSWORD` | Initial admin password. Empty = default `admin`; the UI then forces a change on first login. | ## Certificates @@ -123,24 +158,15 @@ psk_hint mqtt-ingester psk_file /mosquitto/psk_file # line: ingester:0123456789abcdef ``` -## Running - -Start the core stack: - -```bash -docker compose up -d -``` - -Start with Loki enabled: - -```bash -docker compose --profile telemetry up -d -``` - ## API -The API is served over HTTPS with mutual TLS. Clients must present a -certificate signed by the configured CA. +The API is served over HTTPS and accepts two authentication methods: + +- **Mutual TLS**: clients present a certificate signed by the configured CA. + Certificate holders are treated as services with full read access. +- **Session cookie**: a login in the web UI (http://localhost:8080) issues a + `mqtt_session` cookie that also authenticates API requests from the same + browser, subject to that user's access rules. ```bash curl --cacert certs/ca.crt \ @@ -154,6 +180,7 @@ Endpoints: | Method | Path | Description | | ------ | -------------------- | ---------------------------------------- | | GET | `/health` | Health check. | +| GET | `/whoami` | Shows the authenticated principal and effective access rules. | | GET | `/telemetry` | List telemetry (filters: `topic`, `device_id`, `limit`). | | GET | `/telemetry/latest` | Latest record (filters: `topic`, `device_id`). | @@ -164,6 +191,64 @@ curl --cacert certs/ca.crt --cert certs/client.crt --key certs/client.key \ "https://localhost:8443/telemetry?topic=sensors/temp&limit=10" ``` +## Web UI and Access Control + +The web UI (http://localhost:8080) manages **users**, **groups**, and +**topic access rules** for the API. Users and groups are stored in +TimescaleDB (`db/init/002_access_control.sql`); the API and web UI share +the same session store, so one login works for both. + +### Users and roles + +- Every user has a username, an argon2-hashed password, and a role: + `user` or `admin`. Admins manage users/groups/rules and implicitly have + access to all data; regular users see only what their rules allow. +- Users can be disabled (which also kills their sessions) and deleted. +- On first start — and only when the users table is empty — an initial + admin is created from `ADMIN_USERNAME`/`ADMIN_PASSWORD`, defaulting to + `admin`/`admin`. The default password is flagged for a forced change + at first login. Restarts never reset or recreate the admin. +- Login is throttled: 5 failed attempts per source IP within 15 minutes + triggers a 5-minute lockout. + +### Access rules + +A rule is an **allow** or **deny** entry with a glob **pattern** matched +against the full tag name (e.g. `11001.KF1.BF1_Value`): + +| Pattern | Matches | +| ------------------- | -------------------------------------------- | +| `*` | everything | +| `11001.*` | everything prefixed `11001.` | +| `11001.*_Value` | `11001.`-prefixed and `_Value`-suffixed tags | +| `11001.KF1.*` | everything under the `KF1` function | + +Semantics: + +- `*` is the only wildcard and matches any character sequence + (including `.`). Everything else — `.`, `_`, digits — is literal. + This is deliberately **not** regex: patterns stay writable for plant + engineers, translate directly to SQL `LIKE` (so filtering is pushed + down into the database and indexes still work), and cannot + accidentally match sibling systems the way `^11001.*` would. +- A user's effective rules are the **union** of personal rules and the + rules of every group they belong to. +- **Deny wins over allow**: a tag is readable only if at least one + allow rule matches it and no deny rule does. This is what lets a + personal deny override a group allow, and vice versa. +- Users with no allow rules see nothing (deny-by-default). + +Rules are enforced in SQL (`topic LIKE ANY(...)` + `NOT (...)`), so +`limit` behaves correctly and there is no filtering in application code. +Example: a group grants `11001.*`, but a user's personal rule denies +`11001.*_Raw` — the user sees the `Value` tags but not the `Raw` ones. + +### Sessions + +Sessions are 12-hour random tokens stored as SHA-256 hashes. The cookie +is HttpOnly, SameSite=Lax. All mutating web UI forms require a CSRF +token bound to the session. + ## Logging The MQTT client logs in JSON format to: @@ -198,8 +283,24 @@ Telemetry is stored in the `telemetry` hypertable: | `device_id`| `TEXT` | Extracted from payload if present.| | `payload` | `JSONB` | Full message payload. | +Access control (see `db/init/002_access_control.sql`): + +| Table | Purpose | +| -------------- | ---------------------------------------------------------- | +| `users` | Accounts: username, argon2 password hash, role, disabled flag. | +| `groups` | Named collections of access rules. | +| `user_groups` | Group membership. | +| `access_rules`| Allow/deny glob patterns owned by a user OR a group. | +| `sessions` | Web sessions (SHA-256 token hashes, 12h expiry); shared with the API. | +| `login_attempts` | Per-IP login throttling state. | + ## Security Notes - Change `POSTGRES_PASSWORD` before deploying. +- Set `ADMIN_PASSWORD` in `.env` or change the default `admin`/`admin` + immediately after first login (the UI forces it). - Use a real CA and rotate certificates regularly. - Restrict the API port with a firewall or reverse proxy. +- The web UI is bound to `127.0.0.1`. If you expose it remotely, put it + behind a TLS-terminating reverse proxy; the cookie has no `Secure` flag + because the UI itself serves plain HTTP. diff --git a/api/Dockerfile b/api/Dockerfile index 812dc98..8015db5 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -2,10 +2,18 @@ FROM python:3.12-slim WORKDIR /app -COPY requirements.txt . +# Build context is the repo root (see docker-compose.yml) so shared/ is +# copyable; shared/ holds the access-control model used by api + webui. +COPY api/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY . . +COPY shared /app/shared +COPY api /app/api + +WORKDIR /app/api + +# shared/ is installed at /app/shared; make it importable from /app/api. +ENV PYTHONPATH=/app RUN useradd --system --no-create-home api USER api diff --git a/api/main.py b/api/main.py index 899d629..a969421 100644 --- a/api/main.py +++ b/api/main.py @@ -16,6 +16,8 @@ from psycopg import sql from psycopg.rows import dict_row from psycopg_pool import ConnectionPool +from shared.access import COOKIE_NAME, Identity, hash_token, load_identity, topic_conditions + _SELECT_SQL: Final = "SELECT time, topic, device_id, payload FROM telemetry" # Connections use row_factory=dict_row, so each row is a dict. @@ -63,17 +65,33 @@ def get_pool() -> ConnectionPool[_DictConnection]: return pool -@app.middleware("http") -async def require_client_cert(request: Request, call_next): +def _request_identity(request: Request) -> Optional[Identity]: + """Resolve the request principal: mTLS cert, session cookie, or None.""" # uvicorn populates the client cert into the transport when mTLS is enabled. cert = getattr(request.scope.get("transport"), "get_extra_info", lambda _: None)( "peercert" ) - if cert is None: - # Return a response directly: HTTPException raised from middleware is - # not handled by the (inner) exception handlers and would surface - # as a 500. - return JSONResponse(status_code=401, content={"detail": "Client certificate required"}) + if cert is not None: + return Identity.service(cert) + token = request.cookies.get(COOKIE_NAME) + if not token: + return None + conn = get_pool().getconn() + try: + return load_identity(conn, hash_token(token)) + finally: + get_pool().putconn(conn) + + +@app.middleware("http") +async def require_client_cert(request: Request, call_next): + # Return a response directly: HTTPException raised from middleware is + # not handled by the (inner) exception handlers and would surface + # as a 500. + identity = _request_identity(request) + if identity is None: + return JSONResponse(status_code=401, content={"detail": "Authentication required"}) + request.state.identity = identity return await call_next(request) @@ -82,15 +100,35 @@ def health() -> dict[str, str]: return {"status": "ok"} +@app.get("/whoami") +def whoami(request: Request) -> dict[str, Any]: + """Show the authenticated principal and effective access rules. + + Useful to verify which auth path a request took (client certificate + vs session cookie) and which topics the effective rules allow. + """ + identity: Identity = request.state.identity + return { + "username": identity.username, + "role": identity.role, + "rules": {"allow": list(identity.rules.allow), "deny": list(identity.rules.deny)}, + } + + def _telemetry_query( - topic: Optional[str], device_id: Optional[str], limit: int + identity: Identity, + topic: Optional[str], + device_id: Optional[str], + limit: int, ) -> tuple[sql.Composed, list[Any]]: """Build the SELECT for the requested filters as composed SQL. The statement is composed with psycopg.sql, and all values are bound as parameters, so nothing is interpolated into the query string. + Access rules are folded into the WHERE clause (deny wins over allow), + so filtering happens in the database and LIMIT stays correct. """ - conditions: list[sql.Composable] = [] + conditions, param_groups = topic_conditions(identity) params: list[Any] = [] if topic: conditions.append(sql.SQL("topic = %s")) @@ -98,6 +136,7 @@ def _telemetry_query( if device_id: conditions.append(sql.SQL("device_id = %s")) params.append(device_id) + params.extend(p for group in param_groups for p in group) query = sql.SQL(_SELECT_SQL) if conditions: query += sql.SQL(" WHERE ") + sql.SQL(" AND ").join(conditions) @@ -108,11 +147,13 @@ def _telemetry_query( @app.get("/telemetry") def list_telemetry( + request: Request, topic: Optional[str] = Query(default=None), device_id: Optional[str] = Query(default=None), limit: int = Query(default=100, le=1000), ) -> list[dict[str, Any]]: - query, params = _telemetry_query(topic, device_id, limit) + identity: Identity = request.state.identity + query, params = _telemetry_query(identity, topic, device_id, limit) with get_pool().connection() as conn, conn.cursor() as cur: cur.execute(query, params) return cur.fetchall() @@ -120,10 +161,12 @@ def list_telemetry( @app.get("/telemetry/latest") def latest_telemetry( + request: Request, topic: Optional[str] = Query(default=None), device_id: Optional[str] = Query(default=None), ) -> Optional[dict[str, Any]]: - query, params = _telemetry_query(topic, device_id, 1) + identity: Identity = request.state.identity + query, params = _telemetry_query(identity, topic, device_id, 1) with get_pool().connection() as conn, conn.cursor() as cur: cur.execute(query, params) row = cur.fetchone() diff --git a/db/init/002_access_control.sql b/db/init/002_access_control.sql new file mode 100644 index 0000000..8452560 --- /dev/null +++ b/db/init/002_access_control.sql @@ -0,0 +1,65 @@ +-- Access control for the API and web UI: users, groups, and glob-based +-- topic access rules (allow/deny; see shared/access.py for semantics). + +-- NOTE: user/group administration happens in the web UI (or SQL). Rule +-- patterns are globs matched against the full topic/tag name, where +-- '*' matches any character sequence (including '.') and everything +-- else is literal. Example: '11001.*_Value' grants everything under +-- '11001.' ending in '_Value'; user rules can override group rules via +-- deny, which always wins over allow. + +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user' + CHECK (role IN ('user', 'admin')), + disabled_at TIMESTAMPTZ, + must_change_password BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS groups ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS user_groups ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + group_id BIGINT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, group_id) +); + +CREATE TABLE IF NOT EXISTS access_rules ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT REFERENCES users(id) ON DELETE CASCADE, + group_id BIGINT REFERENCES groups(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('allow', 'deny')), + pattern TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK ((user_id IS NULL) <> (group_id IS NULL)) -- exactly one owner +); + +CREATE INDEX IF NOT EXISTS idx_access_rules_user ON access_rules (user_id); +CREATE INDEX IF NOT EXISTS idx_access_rules_group ON access_rules (group_id); + +-- Sessions issued by the web UI; the API validates the same table, so a +-- web login is also an API login. Only a SHA-256 of the token is stored. +CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + csrf_token TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + seen_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions (user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions (expires_at); + +-- Login attempt throttling for the web UI. +CREATE TABLE IF NOT EXISTS login_attempts ( + ip TEXT PRIMARY KEY, + attempts INTEGER NOT NULL DEFAULT 0, + last_seen TIMESTAMPTZ NOT NULL DEFAULT now() +); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index f9ec6f1..cbde40d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,9 @@ services: retries: 5 mqtt-client: - build: ./mqtt-client + build: + context: . + dockerfile: mqtt-client/Dockerfile container_name: mqtt-client restart: unless-stopped depends_on: @@ -57,7 +59,9 @@ services: - mqtt-logs:/var/log/mqtt-client api: - build: ./api + build: + context: . + dockerfile: api/Dockerfile container_name: mqtt-api restart: unless-stopped depends_on: @@ -81,6 +85,26 @@ services: volumes: - ./certs:/certs:ro + webui: + build: + context: . + dockerfile: webui/Dockerfile + container_name: mqtt-webui + restart: unless-stopped + depends_on: + timescaledb: + condition: service_healthy + environment: + DB_HOST: timescaledb + DB_PORT: 5432 + DB_NAME: ${POSTGRES_DB:-telemetry} + DB_USER: ${POSTGRES_USER:-ingester} + DB_PASSWORD: ${POSTGRES_PASSWORD:-changeme} + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + ports: + - "127.0.0.1:${WEBUI_PORT:-8080}:8080" + loki: image: grafana/loki:3.4.2 container_name: mqtt-loki diff --git a/mqtt-client/Dockerfile b/mqtt-client/Dockerfile index 6780ce5..3da0cbe 100644 --- a/mqtt-client/Dockerfile +++ b/mqtt-client/Dockerfile @@ -4,10 +4,11 @@ FROM python:3.13-slim WORKDIR /app -COPY requirements.txt . +# Build context is the repo root (see docker-compose.yml). +COPY mqtt-client/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -COPY . . +COPY mqtt-client/ /app/ RUN mkdir -p /var/log/mqtt-client \ && useradd --system --no-create-home mqtt \ diff --git a/scripts/prune.ps1 b/scripts/prune.ps1 new file mode 100644 index 0000000..4859ce8 --- /dev/null +++ b/scripts/prune.ps1 @@ -0,0 +1,51 @@ +<# +.SYNOPSIS + Removes stack data: volumes and orphans, optionally build artifacts. + +.DESCRIPTION + DANGER: this deletes the TimescaleDB data volume (all telemetry data), + the MQTT log volume, and the Loki volume. Users/groups/sessions stored + in Postgres are deleted with it. + + -Volumes remove the named volumes (data loss!) + -All additionally remove images and build cache +#> +[CmdletBinding()] +param( + [switch]$Volumes, + [switch]$All +) + +$ErrorActionPreference = "Stop" + +$StackRoot = Split-Path -Parent $PSScriptRoot +Set-Location $StackRoot + +if ($Volumes) { + Write-Warning "This deletes ALL stored data (telemetry, users, groups, sessions, logs)." + $confirm = Read-Host "Type 'delete' to continue" + if ($confirm -ne "delete") { + Write-Host "Aborted." + exit 0 + } +} + +Write-Host "Removing stack containers..." -ForegroundColor Cyan +docker compose down --remove-orphans +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +if ($Volumes) { + Write-Host "Removing volumes..." -ForegroundColor Cyan + docker compose down --volumes + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} + +if ($All) { + Write-Host "Removing images and build cache..." -ForegroundColor Cyan + docker compose down --rmi all + docker image prune -f + docker builder prune -f + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} + +Write-Host "Done." -ForegroundColor Green \ No newline at end of file diff --git a/scripts/prune.sh b/scripts/prune.sh new file mode 100644 index 0000000..5a9c378 --- /dev/null +++ b/scripts/prune.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Removes stack data: volumes and orphans, optionally build artifacts. +# +# DANGER: deletes the TimescaleDB data volume (all telemetry data), the +# MQTT log volume, and the Loki volume. Users/groups/sessions stored in +# Postgres are deleted with it. +# +# Usage: +# ./scripts/prune.sh # containers only +# ./scripts/prune.sh --volumes # also delete all data (destructive) +# ./scripts/prune.sh --all # also remove images and build cache + +set -euo pipefail + +StackRoot="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$StackRoot" + +Volumes=0 +All=0 +for arg in "$@"; do + case "$arg" in + --volumes|-v) Volumes=1 ;; + --all|-a) All=1 ;; + *) echo "Unknown option: $arg" >&2; exit 1 ;; + esac +done + +if [ "$Volumes" -eq 1 ]; then + echo "This deletes ALL stored data (telemetry, users, groups, sessions, logs)." + read -r -p "Type 'delete' to continue: " confirm + [ "$confirm" = "delete" ] || { echo "Aborted."; exit 0; } +fi + +echo "Removing stack containers..." +docker compose down --remove-orphans + +if [ "$Volumes" -eq 1 ]; then + echo "Removing volumes..." + docker compose down --volumes +fi + +if [ "$All" -eq 1 ]; then + echo "Removing images and build cache..." + docker compose down --rmi all + docker image prune -f + docker builder prune -f +fi + +echo "Done." \ No newline at end of file diff --git a/scripts/start.ps1 b/scripts/start.ps1 new file mode 100644 index 0000000..ad14bb5 --- /dev/null +++ b/scripts/start.ps1 @@ -0,0 +1,84 @@ +<# +.SYNOPSIS + Starts the mqtt-ingester stack on local Docker. + +.DESCRIPTION + Loads .env (values already set in the environment take precedence), + verifies that the certificate files exist, then runs docker compose up + with the requested profiles. + +.PARAMETER Profiles + Comma-separated compose profiles to enable (e.g. "telemetry"). + +.EXAMPLE + .\scripts\start.ps1 + .\scripts\start.ps1 -Profiles telemetry +#> +[CmdletBinding()] +param( + [string]$Profiles = "" +) + +$ErrorActionPreference = "Stop" + +# Stack root = parent of the scripts directory. +$StackRoot = Split-Path -Parent $PSScriptRoot +Set-Location $StackRoot + +if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + Write-Error "docker not found in PATH" +} + +# Load .env if present; existing environment variables win. +if (Test-Path ".env") { + Get-Content ".env" | ForEach-Object { + $line = $_.Trim() + if ($line -and -not $line.StartsWith("#")) { + $parts = $line -split "=", 2 + if ($parts.Length -eq 2) { + $name = $parts[0].Trim() + if (-not (Test-Path "Env:$name")) { + Set-Item -Path "Env:$name" -Value $parts[1].Trim() + } + } + else { + Write-Warning "Ignoring malformed .env line: $line" + } + } + } +} + +# Required certificates (see README). +$requiredCerts = @("ca.crt", "client.crt", "client.key", "server.crt", "server.key") +$missing = @() +foreach ($cert in $requiredCerts) { + if (-not (Test-Path (Join-Path "certs" $cert))) { + $missing += $cert + } +} +if ($missing.Count -gt 0) { + Write-Warning "Missing certificates in certs/: $($missing -join ', ')" + Write-Warning "The API will fail to start. See the 'Certificates' section of README.md." +} + +# MQTT_BROKER_HOST must be defined somewhere. +if (-not $env:MQTT_BROKER_HOST) { + Write-Warning "MQTT_BROKER_HOST is not set; mqtt-client will exit with an error." +} + +$composeArgs = @("compose", "up", "-d", "--build") +if ($Profiles) { + $composeArgs += @("--profile", $Profiles) +} + +Write-Host "Starting stack..." -ForegroundColor Cyan +docker @composeArgs +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host "" +Write-Host "Services:" -ForegroundColor Cyan +docker compose ps +Write-Host "" +Write-Host "Web UI: http://localhost:8080 (default login admin/admin)" -ForegroundColor Green +Write-Host "API: https://localhost:8443 (mTLS or session cookie)" -ForegroundColor Green +Write-Host "Logs: docker compose logs -f " -ForegroundColor Gray \ No newline at end of file diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100644 index 0000000..9269fc7 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Starts the mqtt-ingester stack on local Docker. +# +# Loads .env if present (existing environment variables take precedence), +# warns about missing certificates, then runs docker compose up with the +# requested profiles. +# +# Usage: +# ./scripts/start.sh [profiles...] +# ./scripts/start.sh telemetry + +set -euo pipefail + +StackRoot="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$StackRoot" + +Profiles="${*:-}" + +if ! command -v docker >/dev/null 2>&1; then + echo "docker not found in PATH" >&2 + exit 1 +fi + +# Load .env if present; existing environment variables win. +if [ -f .env ]; then + while IFS='=' read -r name value; do + # Skip comments and blank lines. + case "$name" in ''|\#*) continue ;; esac + if [ -z "${!name:-}" ]; then + export "$name=$value" + fi + done < <(grep -v -e '^[[:space:]]*$' -e '^[[:space:]]*#' .env) +fi + +# Required certificates (see README). +missing=() +for cert in ca.crt client.crt client.key server.crt server.key; do + [ -f "certs/$cert" ] || missing+=("$cert") +done +if [ ${#missing[@]} -gt 0 ]; then + echo "WARNING: missing certificates in certs/: ${missing[*]}" >&2 + echo "WARNING: the API will fail to start. See README.md 'Certificates'." >&2 +fi + +if [ -z "${MQTT_BROKER_HOST:-}" ]; then + echo "WARNING: MQTT_BROKER_HOST is not set; mqtt-client will exit with an error." >&2 +fi + +ComposeArgs=(compose up -d --build) +if [ -n "$Profiles" ]; then + ComposeArgs+=(--profile "$Profiles") +fi + +echo "Starting stack..." +docker "${ComposeArgs[@]}" + +echo +echo "Services:" +docker compose ps +echo +echo "Web UI: http://localhost:8080 (default login admin/admin)" +echo "API: https://localhost:8443 (mTLS or session cookie)" +echo "Logs: docker compose logs -f " \ No newline at end of file diff --git a/scripts/stop.ps1 b/scripts/stop.ps1 new file mode 100644 index 0000000..ee8c417 --- /dev/null +++ b/scripts/stop.ps1 @@ -0,0 +1,31 @@ +<# +.SYNOPSIS + Stops the mqtt-ingester stack. + +.DESCRIPTION + Stops and removes containers and the default network. Volumes + (timescale-data, mqtt-logs, loki-data) are preserved; use prune.ps1 + to remove them. + +.PARAMETER Profiles + Comma-separated compose profiles (must match the ones used at start + so the same services are addressed). +#> +[CmdletBinding()] +param( + [string]$Profiles = "" +) + +$ErrorActionPreference = "Stop" + +$StackRoot = Split-Path -Parent $PSScriptRoot +Set-Location $StackRoot + +$composeArgs = @("compose", "down", "--remove-orphans") +if ($Profiles) { + $composeArgs += @("--profile", $Profiles) +} + +Write-Host "Stopping stack..." -ForegroundColor Cyan +docker @composeArgs +exit $LASTEXITCODE \ No newline at end of file diff --git a/scripts/stop.sh b/scripts/stop.sh new file mode 100644 index 0000000..60ca434 --- /dev/null +++ b/scripts/stop.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Stops the mqtt-ingester stack. +# +# Stops and removes containers and the default network. Volumes +# (timescale-data, mqtt-logs, loki-data) are preserved; use prune.sh to +# remove them. +# +# Usage: +# ./scripts/stop.sh [profiles...] + +set -euo pipefail + +StackRoot="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$StackRoot" + +Profiles="${*:-}" + +ComposeArgs=(compose down --remove-orphans) +if [ -n "$Profiles" ]; then + ComposeArgs+=(--profile "$Profiles") +fi + +echo "Stopping stack..." +docker "${ComposeArgs[@]}" \ No newline at end of file diff --git a/shared/__init__.py b/shared/__init__.py new file mode 100644 index 0000000..c92fa25 --- /dev/null +++ b/shared/__init__.py @@ -0,0 +1 @@ +"""Code shared by the api and webui services.""" \ No newline at end of file diff --git a/shared/access.py b/shared/access.py new file mode 100644 index 0000000..d4a896c --- /dev/null +++ b/shared/access.py @@ -0,0 +1,218 @@ +"""Shared access-control model used by both the API and the web UI. + +Both services authenticate against the same tables (users, groups, +access_rules, sessions — see db/init/002_access_control.sql), so the +meaning of a rule must be defined in exactly one place: here. + +Semantics: + - A pattern is a glob matched against the FULL topic / tag name + (e.g. ``11001.KF1.BF1_Value``). ``*`` is the only wildcard and + matches any character sequence, including ``.``. Everything else + (``.``, ``_``, digits, ...) is literal — deliberately NOT regex: + the audience is plant engineers, and globs translate to SQL LIKE + (with ``_`` / ``%`` / backslash escaped) so authorization can be + pushed down into telemetry queries. + - A user's effective rules are the union of its personal rules and + the rules of every group it belongs to. + - deny wins over allow: a topic is readable only if at least one + allow rule matches it AND no deny rule does. This is what lets a + user rule override a group grant (via a user deny) and vice versa. + - Admin users and certificate-authenticated services bypass rule + evaluation and see everything. +""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass, field +from functools import lru_cache +from typing import Any, Mapping, Optional + +import psycopg +from psycopg import sql +from psycopg.rows import tuple_row + +#: Cookie carrying the session token. Shared by the web UI (which sets +#: it) and the API (which accepts it), so a web login is also an API login. +COOKIE_NAME: str = "mqtt_session" + + +# -- Pattern translation ------------------------------------------------- + + +def escape_like(value: str) -> str: + """Escape LIKE special characters so ``value`` matches literally.""" + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + +def glob_to_like(pattern: str) -> str: + """Translate an access-control glob to a SQL LIKE pattern. + + ``*`` becomes ``%``; ``_``, ``%`` and ``\\`` in the pattern are + literal and must be escaped — ``11001.*_Value`` must not let ``_`` + match an arbitrary character. + """ + if pattern == "*": + return "%" + return escape_like(pattern).replace("*", "%") + + +@lru_cache(maxsize=1024) +def _compile_glob(pattern: str) -> re.Pattern[str]: + parts = pattern.split("*") + return re.compile("^" + ".*".join(re.escape(part) for part in parts) + "$", re.DOTALL) + + +def _glob_match(pattern: str, topic: str) -> bool: + return _compile_glob(pattern).match(topic) is not None + + +# -- Rule model ------------------------------------------------------------ + + +@dataclass(frozen=True) +class Rules: + """Allow/deny glob patterns; combined user+group rules are a union.""" + + allow: tuple[str, ...] = () + deny: tuple[str, ...] = () + + @property + def allow_all(self) -> bool: + return "*" in self.allow + + @property + def deny_all(self) -> bool: + return "*" in self.deny + + def allow_likes(self) -> list[str]: + """LIKE patterns for the allow side (``*`` handled separately).""" + return [glob_to_like(p) for p in self.allow if p != "*"] + + def deny_likes(self) -> list[str]: + return [glob_to_like(p) for p in self.deny if p != "*"] + + def topic_allowed(self, topic: str) -> bool: + """In-Python check for a single topic (deny wins over allow).""" + if self.deny_all: + return False + for pattern in self.deny: + if _glob_match(pattern, topic): + return False + if self.allow_all: + return True + return any(_glob_match(p, topic) for p in self.allow) + + +@dataclass(frozen=True) +class Identity: + """The authenticated principal for one request.""" + + user_id: Optional[int] + username: str + role: str # 'admin' | 'user' | 'service' + rules: Rules = field(default_factory=Rules) + must_change_password: bool = False + csrf_token: str = "" + + @classmethod + def service(cls, peercert: Mapping[str, Any]) -> "Identity": + """Identity for an mTLS-authenticated client certificate.""" + common_name = "" + for rdn in peercert.get("subject") or (): + for attr, value in rdn: + if attr == "commonName": + common_name = value + return cls( + user_id=None, + username=common_name or "client", + role="service", + rules=Rules(allow=("*",)), + ) + + @property + def is_admin(self) -> bool: + return self.role == "admin" + + @property + def full_access(self) -> bool: + return self.role in ("admin", "service") + + def topic_allowed(self, topic: str) -> bool: + return self.full_access or self.rules.topic_allowed(topic) + + +def topic_conditions(identity: Identity) -> tuple[list[sql.Composable], list[list[str]]]: + """WHERE fragments + params enforcing ``identity`` on telemetry.topic. + + Conditions are composed for psycopg.sql and ANDed into the query, so + filtering happens in the database and LIMIT stays correct. Users with + no allow rules match nothing (deny-by-default). + """ + conditions: list[sql.Composable] = [] + params: list[list[str]] = [] + if identity.full_access: + return conditions, params + rules = identity.rules + if rules.deny_all or not rules.allow: + conditions.append(sql.SQL("false")) + return conditions, params + if not rules.allow_all: + conditions.append(sql.SQL("topic LIKE ANY(%s)")) + params.append(rules.allow_likes()) + deny_likes = rules.deny_likes() + if deny_likes: + conditions.append(sql.SQL("NOT (topic LIKE ANY(%s))")) + params.append(deny_likes) + return conditions, params + + +# -- Session lookup --------------------------------------------------------- + +# Row factory is pinned to tuples so this works regardless of the calling +# service's pool row_factory. +_SESSION_USER_SQL = """ + SELECT u.id, u.username, u.role, u.must_change_password, s.csrf_token + FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.token_hash = %s + AND s.expires_at > now() + AND u.disabled_at IS NULL +""" + +_USER_RULES_SQL = """ + SELECT kind, pattern FROM access_rules WHERE user_id = %s + UNION ALL + SELECT ar.kind, ar.pattern + FROM access_rules ar + JOIN user_groups ug ON ug.group_id = ar.group_id + WHERE ug.user_id = %s +""" + + +def hash_token(token: str) -> str: + """Sessions store only a SHA-256 of the random token.""" + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def load_identity(conn: psycopg.Connection[Any], token_hash: str) -> Optional[Identity]: + """Resolve a session token hash to an Identity (None if invalid).""" + with conn.cursor(row_factory=tuple_row) as cur: + user_row = cur.execute(_SESSION_USER_SQL, (token_hash,)).fetchone() + if user_row is None: + return None + user_id, username, role, must_change, csrf_token = user_row + rule_rows = cur.execute(_USER_RULES_SQL, (user_id, user_id)).fetchall() + allow: list[str] = [] + deny: list[str] = [] + for kind, pattern in rule_rows: + (allow if kind == "allow" else deny).append(pattern) + return Identity( + user_id=user_id, + username=username, + role=role, + rules=Rules(allow=tuple(allow), deny=tuple(deny)), + must_change_password=must_change, + csrf_token=csrf_token, + ) \ No newline at end of file diff --git a/webui/Dockerfile b/webui/Dockerfile new file mode 100644 index 0000000..84af00c --- /dev/null +++ b/webui/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Build context is the repo root (see docker-compose.yml) so shared/ is +# copyable; shared/ holds the access-control model used by api + webui. +COPY webui/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY shared /app/shared +COPY webui /app/webui + +WORKDIR /app/webui + +# shared/ is installed at /app/shared; make it importable from /app/webui. +ENV PYTHONPATH=/app + +RUN useradd --system --no-create-home webui +USER webui + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] \ No newline at end of file diff --git a/webui/auth.py b/webui/auth.py new file mode 100644 index 0000000..c8501d6 --- /dev/null +++ b/webui/auth.py @@ -0,0 +1,174 @@ +"""Authentication for the web UI: password hashing, sessions, throttling. + +- Passwords are hashed with argon2id (passlib). +- Session cookies carry a random token; only its SHA-256 is stored. +- Sessions are persisted in Postgres, so a login in the web UI is also + valid against the API (shared/access.py resolves the same table). +- Login is throttled per source IP (5 attempts per 15 minutes, then a + 5-minute lockout) to blunt credential stuffing against the exposed + login form. +- On startup, an admin account is created only if the users table is + empty: username/password from ADMIN_USERNAME/ADMIN_PASSWORD if set, + otherwise the documented default admin/admin. The default account is + flagged must_change_password so the UI forces a change on first login. +""" + +import hmac +import os +import secrets +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +import psycopg +from passlib.context import CryptContext + +from shared.access import COOKIE_NAME, hash_token + +# Fixed session lifetime; a sliding window would need a write per request, +# so sessions simply expire after this span (users log in again). +SESSION_TTL = timedelta(hours=12) + +LOCKOUT_ATTEMPTS = 5 +LOCKOUT_WINDOW = timedelta(minutes=15) +LOCKOUT_DURATION = timedelta(minutes=5) + +_pwd: Optional[CryptContext] = None + + +def _crypt() -> CryptContext: + """Lazy argon2 init: passlib's runtime self-check is slow (~1s).""" + global _pwd + if _pwd is None: + _pwd = CryptContext(schemes=["argon2"]) + return _pwd + + +def hash_password(password: str) -> str: + return _crypt().hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + try: + return _crypt().verify(password, password_hash) + except Exception: # noqa: BLE001 - malformed hash must not 500 the login + return False + + +def _interval(td: timedelta) -> str: + return f"{int(td.total_seconds())} seconds" + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def create_session(conn: psycopg.Connection[Any], user_id: int) -> tuple[str, str]: + """Create a session; returns (cookie_token, csrf_token).""" + cookie_token = secrets.token_urlsafe(32) + csrf_token = secrets.token_urlsafe(32) + with conn.cursor() as cur: + cur.execute( + "INSERT INTO sessions (token_hash, user_id, csrf_token, expires_at)" + " VALUES (%s, %s, %s, now() + %s::interval)", + (hash_token(cookie_token), user_id, csrf_token, _interval(SESSION_TTL)), + ) + return cookie_token, csrf_token + + +def delete_session(conn: psycopg.Connection[Any], token: str) -> None: + with conn.cursor() as cur: + cur.execute("DELETE FROM sessions WHERE token_hash = %s", (hash_token(token),)) + + +def bootstrap_admin(conn: psycopg.Connection[Any]) -> None: + """Create the initial admin from ENV or default admin/admin. + + Runs only when the users table is completely empty — restarting the + stack must never reset or resurrect an administrator. + """ + with conn.cursor() as cur: + count = cur.execute("SELECT count(*) FROM users").fetchone()[0] + if count and count > 0: + return + username = os.getenv("ADMIN_USERNAME", "admin") + password = os.getenv("ADMIN_PASSWORD", "admin") + from_env = bool(os.getenv("ADMIN_PASSWORD")) + # A default (non-ENV) password must always be changed at first + # login; an ENV-provided one is treated as provisioned. + must_change = not from_env or username == "admin" + cur.execute( + "INSERT INTO users (username, password_hash, role, must_change_password)" + " VALUES (%s, %s, 'admin', %s)", + (username, hash_password(password), must_change), + ) + print( + f"Bootstrapped initial admin '{username}'" + + (" (default credentials; change on first login)" if must_change else "") + ) + + +def _attempt_key(ip: str) -> str: + # login_attempts.ip is TEXT PRIMARY KEY; keep long IPv6 strings sane. + return ip[:64] if ip else "unknown" + + +def check_throttle(conn: psycopg.Connection[Any], ip: str) -> tuple[bool, int]: + """Return (allowed, attempts). Locks out after LOCKOUT_ATTEMPTS tries.""" + key = _attempt_key(ip) + with conn.cursor() as cur: + row = cur.execute( + "SELECT attempts, last_seen FROM login_attempts WHERE ip = %s", (key,) + ).fetchone() + if row is None: + return True, 0 + attempts, last_seen = row + if attempts >= LOCKOUT_ATTEMPTS: + if last_seen < _utcnow() - LOCKOUT_DURATION: + # Lockout expired: start a fresh window. + cur.execute("DELETE FROM login_attempts WHERE ip = %s", (key,)) + return True, 0 + return False, attempts + return True, attempts + + +def record_failed_login(conn: psycopg.Connection[Any], ip: str) -> None: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO login_attempts (ip, attempts, last_seen) + VALUES (%s, 1, now()) + ON CONFLICT (ip) DO UPDATE + SET attempts = CASE + WHEN login_attempts.last_seen < now() - %s::interval + THEN 1 + ELSE login_attempts.attempts + 1 + END, + last_seen = now() + """, + (_attempt_key(ip), _interval(LOCKOUT_WINDOW)), + ) + + +def clear_failed_logins(conn: psycopg.Connection[Any], ip: str) -> None: + with conn.cursor() as cur: + cur.execute("DELETE FROM login_attempts WHERE ip = %s", (_attempt_key(ip),)) + + +def set_cookie(response: Any, token: str) -> None: + """Attach the session cookie (HttpOnly; the webui is same-origin).""" + response.set_cookie( + COOKIE_NAME, + token, + httponly=True, + samesite="lax", + path="/", + max_age=int(SESSION_TTL.total_seconds()), + ) + + +def clear_cookie(response: Any) -> None: + response.delete_cookie(COOKIE_NAME, path="/") + + +def constant_time_eq(a: str, b: str) -> bool: + return hmac.compare_digest(a.encode(), b.encode()) \ No newline at end of file diff --git a/webui/main.py b/webui/main.py new file mode 100644 index 0000000..108e0ed --- /dev/null +++ b/webui/main.py @@ -0,0 +1,572 @@ +"""Web UI for access control: users, groups, topic access rules. + +Serves the login page, the admin pages (users, groups), and a telemetry +browser so the effect of access rules can be verified directly. Sessions +are shared with the API via the mqtt_session cookie, so a web login also +authenticates API requests from the same browser. + +User/group management is admin-only. Access-rule semantics (globs, deny +wins over allow, user rules override group rules) live in shared/access.py. +""" + +import hmac +import os +from contextlib import asynccontextmanager +from typing import Any, Optional + +import psycopg +from fastapi import FastAPI, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from psycopg import sql +from psycopg.rows import dict_row + +import auth +from shared.access import ( + COOKIE_NAME, + Identity, + escape_like, + hash_token, + load_identity, + topic_conditions, +) + +_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates") +_STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") +templates = Jinja2Templates(directory=_TEMPLATES_DIR) + +_DictConnection = psycopg.Connection[dict[str, Any]] +pool: Optional[_DictConnection] = None + + +def _connect_kwargs() -> dict[str, Any]: + return { + "host": os.getenv("DB_HOST", "timescaledb"), + "port": int(os.getenv("DB_PORT", "5432")), + "dbname": os.getenv("DB_NAME", "telemetry"), + "user": os.getenv("DB_USER", "ingester"), + "password": os.getenv("DB_PASSWORD", "changeme"), + "row_factory": dict_row, + } + + +@asynccontextmanager +async def lifespan(app: FastAPI): + global pool + # Single long-lived autocommit connection; the web UI's load is tiny + # compared to the API's, so a pool would be overkill. + conn = psycopg.connect(**_connect_kwargs()) + pool = conn + try: + # First run: create the initial admin (ENV or default admin/admin). + auth.bootstrap_admin(conn) + yield + finally: + pool = None + conn.close() + + +app = FastAPI(title="MQTT Ingester Web UI", lifespan=lifespan) +app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static") + + +def get_conn() -> _DictConnection: + if pool is None: + raise RuntimeError("Database connection not initialized") + return pool + + +def current_identity(request: Request) -> Optional[Identity]: + token = request.cookies.get(COOKIE_NAME) + if not token: + return None + return load_identity(get_conn(), hash_token(token)) + + +def require_login(request: Request) -> Identity: + identity = current_identity(request) + if identity is None: + raise HTTPException(status_code=303, headers={"Location": "/login"}) + # A default/admin-set password must be changed before anything else. + if identity.must_change_password and request.url.path not in ( + "/password", + "/logout", + ): + raise HTTPException(status_code=303, headers={"Location": "/password"}) + return identity + + +def require_admin(request: Request) -> Identity: + identity = require_login(request) + if not identity.is_admin: + raise HTTPException(status_code=403, detail="Admin access required") + return identity + + +async def require_csrf(request: Request, identity: Identity) -> None: + """Validate the CSRF token for a mutating (form) request.""" + form = await request.form() + submitted = form.get("csrf_token", "") + if not isinstance(submitted, str) or not hmac.compare_digest( + submitted, identity.csrf_token + ): + raise HTTPException(status_code=400, detail="Invalid CSRF token") + + +def _telemetry_rows(identity: Identity, topic: Optional[str]) -> list[dict[str, Any]]: + """Recent telemetry filtered by the identity's access rules in SQL.""" + conditions, param_groups = topic_conditions(identity) + params: list[Any] = [] + if topic: + conditions.append(sql.SQL("topic LIKE %s")) + params.append(escape_like(topic)) + params.extend(p for group in param_groups for p in group) + query = sql.SQL("SELECT time, topic, payload FROM telemetry") + if conditions: + query += sql.SQL(" WHERE ") + sql.SQL(" AND ").join(conditions) + query += sql.SQL(" ORDER BY time DESC LIMIT 100") + with get_conn().cursor() as cur: + cur.execute(query, params) + return list(cur.fetchall()) + + +# -- Login / logout --------------------------------------------------------- + + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + if current_identity(request) is None: + return RedirectResponse("/login", status_code=303) + return RedirectResponse("/data", status_code=303) + + +@app.get("/login", response_class=HTMLResponse) +async def login_page(request: Request, error: Optional[str] = None): + if current_identity(request) is not None: + return RedirectResponse("/", status_code=303) + return templates.TemplateResponse(request, "login.html", {"error": error}) + + +@app.post("/login") +async def login(request: Request, username: str = Form(...), password: str = Form(...)): + conn = get_conn() + ip = request.client.host if request.client else "unknown" + allowed, attempts = auth.check_throttle(conn, ip) + if not allowed: + return templates.TemplateResponse( + request, + "login.html", + {"error": "Too many failed attempts; try again in a few minutes."}, + status_code=429, + ) + with conn.cursor() as cur: + row = cur.execute( + "SELECT id, password_hash FROM users" + " WHERE username = %s AND disabled_at IS NULL", + (username,), + ).fetchone() + if row is None or not auth.verify_password(password, row[1]): + auth.record_failed_login(conn, ip) + return templates.TemplateResponse( + request, "login.html", {"error": "Invalid username or password"}, status_code=401 + ) + auth.clear_failed_logins(conn, ip) + cookie_token, _csrf = auth.create_session(conn, row[0]) + resp = RedirectResponse("/data", status_code=303) + auth.set_cookie(resp, cookie_token) + return resp + + +@app.post("/logout") +async def logout(request: Request): + identity = current_identity(request) + if identity is not None: + await require_csrf(request, identity) + token = request.cookies.get(COOKIE_NAME, "") + if token: + auth.delete_session(get_conn(), token) + resp = RedirectResponse("/login", status_code=303) + auth.clear_cookie(resp) + return resp + + +# -- Account (self-service) -------------------------------------------------- + + +@app.get("/password", response_class=HTMLResponse) +async def password_page(request: Request, error: Optional[str] = None): + identity = require_login(request) + return templates.TemplateResponse( + request, "password.html", {"identity": identity, "error": error} + ) + + +@app.post("/password") +async def password_change( + request: Request, + current_password: str = Form(...), + new_password: str = Form(...), + confirm_password: str = Form(...), +): + identity = require_login(request) + await require_csrf(request, identity) + conn = get_conn() + + def fail(message: str, status: int): + return templates.TemplateResponse( + request, + "password.html", + {"identity": identity, "error": message}, + status_code=status, + ) + + with conn.cursor() as cur: + row = cur.execute( + "SELECT password_hash FROM users WHERE id = %s", (identity.user_id,) + ).fetchone() + if row is None or not auth.verify_password(current_password, row[0]): + return fail("Current password is incorrect", 401) + if new_password != confirm_password: + return fail("New passwords do not match", 400) + if len(new_password) < 8: + return fail("New password must be at least 8 characters", 400) + with conn.cursor() as cur: + cur.execute( + "UPDATE users SET password_hash = %s, must_change_password = FALSE" + " WHERE id = %s", + (auth.hash_password(new_password), identity.user_id), + ) + return RedirectResponse("/data", status_code=303) + + +# -- Users (admin) ----------------------------------------------------------- + + +@app.get("/users", response_class=HTMLResponse) +async def users_page(request: Request): + identity = require_admin(request) + with get_conn().cursor() as cur: + users = cur.execute( + """ + SELECT u.id, u.username, u.role, u.disabled_at, u.must_change_password, + coalesce( + (SELECT array_agg(g.name ORDER BY g.name) + FROM user_groups ug JOIN groups g ON g.id = ug.group_id + WHERE ug.user_id = u.id), '{}') AS groups + FROM users u ORDER BY u.username + """ + ).fetchall() + groups = cur.execute("SELECT id, name FROM groups ORDER BY name").fetchall() + return templates.TemplateResponse( + request, "users.html", {"identity": identity, "users": users, "groups": groups} + ) + + +@app.post("/users") +async def create_user( + request: Request, + username: str = Form(...), + password: str = Form(...), + role: str = Form("user"), + group_ids: list[str] = Form(default=[]), +): + identity = require_admin(request) + await require_csrf(request, identity) + conn = get_conn() + username = username.strip() + if not username: + raise HTTPException(status_code=400, detail="Username is required") + if role not in ("user", "admin"): + raise HTTPException(status_code=400, detail="Invalid role") + if len(password) < 8: + raise HTTPException(status_code=400, detail="Password must be at least 8 characters") + with conn.cursor() as cur: + try: + cur.execute( + "INSERT INTO users (username, password_hash, role) VALUES (%s, %s, %s)", + (username, auth.hash_password(password), role), + ) + except psycopg.errors.UniqueViolation: + raise HTTPException(status_code=409, detail="Username already exists") + if group_ids: + user_id = cur.execute( + "SELECT id FROM users WHERE username = %s", (username,) + ).fetchone()[0] + cur.executemany( + "INSERT INTO user_groups (user_id, group_id) VALUES (%s, %s)", + [(user_id, int(gid)) for gid in group_ids], + ) + return RedirectResponse("/users", status_code=303) + + +@app.get("/users/{user_id}", response_class=HTMLResponse) +async def user_detail(request: Request, user_id: int): + identity = require_admin(request) + with get_conn().cursor() as cur: + user = cur.execute( + "SELECT id, username, role, disabled_at, must_change_password" + " FROM users WHERE id = %s", + (user_id,), + ).fetchone() + if user is None: + raise HTTPException(status_code=404, detail="User not found") + rules = cur.execute( + "SELECT id, kind, pattern FROM access_rules WHERE user_id = %s" + " ORDER BY kind, pattern", + (user_id,), + ).fetchall() + member_of = cur.execute( + "SELECT g.id, g.name FROM groups g" + " JOIN user_groups ug ON ug.group_id = g.id WHERE ug.user_id = %s", + (user_id,), + ).fetchall() + all_groups = cur.execute("SELECT id, name FROM groups ORDER BY name").fetchall() + return templates.TemplateResponse( + request, + "user_detail.html", + { + "identity": identity, + "user": user, + "rules": rules, + "member_of": member_of, + "all_groups": all_groups, + }, + ) + + +@app.post("/users/{user_id}/password") +async def set_user_password(request: Request, user_id: int, password: str = Form(...)): + identity = require_admin(request) + await require_csrf(request, identity) + if len(password) < 8: + raise HTTPException(status_code=400, detail="Password must be at least 8 characters") + with get_conn().cursor() as cur: + cur.execute( + "UPDATE users SET password_hash = %s, must_change_password = TRUE" + " WHERE id = %s", + (auth.hash_password(password), user_id), + ) + cur.execute("DELETE FROM sessions WHERE user_id = %s", (user_id,)) + return RedirectResponse(f"/users/{user_id}", status_code=303) + + +@app.post("/users/{user_id}/groups") +async def set_user_groups( + request: Request, user_id: int, group_ids: list[str] = Form(default=[]) +): + identity = require_admin(request) + await require_csrf(request, identity) + with get_conn().cursor() as cur: + cur.execute("DELETE FROM user_groups WHERE user_id = %s", (user_id,)) + if group_ids: + cur.executemany( + "INSERT INTO user_groups (user_id, group_id) VALUES (%s, %s)", + [(user_id, int(gid)) for gid in group_ids], + ) + return RedirectResponse(f"/users/{user_id}", status_code=303) + + +@app.post("/users/{user_id}/disable") +async def disable_user(request: Request, user_id: int): + identity = require_admin(request) + await require_csrf(request, identity) + with get_conn().cursor() as cur: + cur.execute( + "UPDATE users SET disabled_at = now() WHERE id = %s AND disabled_at IS NULL", + (user_id,), + ) + cur.execute("DELETE FROM sessions WHERE user_id = %s", (user_id,)) + return RedirectResponse("/users", status_code=303) + + +@app.post("/users/{user_id}/enable") +async def enable_user(request: Request, user_id: int): + identity = require_admin(request) + await require_csrf(request, identity) + with get_conn().cursor() as cur: + cur.execute("UPDATE users SET disabled_at = NULL WHERE id = %s", (user_id,)) + return RedirectResponse("/users", status_code=303) + + +@app.post("/users/{user_id}/delete") +async def delete_user(request: Request, user_id: int): + identity = require_admin(request) + await require_csrf(request, identity) + if user_id == identity.user_id: + raise HTTPException(status_code=400, detail="Cannot delete your own account") + with get_conn().cursor() as cur: + cur.execute("DELETE FROM users WHERE id = %s", (user_id,)) + return RedirectResponse("/users", status_code=303) + + +@app.post("/users/{user_id}/rules") +async def add_user_rule( + request: Request, user_id: int, kind: str = Form(...), pattern: str = Form(...) +): + identity = require_admin(request) + await require_csrf(request, identity) + if kind not in ("allow", "deny"): + raise HTTPException(status_code=400, detail="Invalid kind") + pattern = pattern.strip() + if not pattern: + raise HTTPException(status_code=400, detail="Pattern is required") + with get_conn().cursor() as cur: + cur.execute( + "INSERT INTO access_rules (user_id, kind, pattern) VALUES (%s, %s, %s)", + (user_id, kind, pattern), + ) + return RedirectResponse(f"/users/{user_id}", status_code=303) + + +@app.post("/users/{user_id}/rules/{rule_id}/delete") +async def delete_user_rule(request: Request, user_id: int, rule_id: int): + identity = require_admin(request) + await require_csrf(request, identity) + with get_conn().cursor() as cur: + cur.execute( + "DELETE FROM access_rules WHERE id = %s AND user_id = %s", (rule_id, user_id) + ) + return RedirectResponse(f"/users/{user_id}", status_code=303) + + +# -- Groups (admin) ----------------------------------------------------------- + + +@app.get("/groups", response_class=HTMLResponse) +async def groups_page(request: Request): + identity = require_admin(request) + with get_conn().cursor() as cur: + groups = cur.execute( + """ + SELECT g.id, g.name, + (SELECT count(*) FROM user_groups ug WHERE ug.group_id = g.id) + AS member_count, + (SELECT count(*) FROM access_rules ar WHERE ar.group_id = g.id) + AS rule_count + FROM groups g ORDER BY g.name + """ + ).fetchall() + return templates.TemplateResponse( + request, "groups.html", {"identity": identity, "groups": groups} + ) + + +@app.post("/groups") +async def create_group(request: Request, name: str = Form(...)): + identity = require_admin(request) + await require_csrf(request, identity) + name = name.strip() + if not name: + raise HTTPException(status_code=400, detail="Group name is required") + with get_conn().cursor() as cur: + try: + cur.execute("INSERT INTO groups (name) VALUES (%s)", (name,)) + except psycopg.errors.UniqueViolation: + raise HTTPException(status_code=409, detail="Group name already exists") + return RedirectResponse("/groups", status_code=303) + + +@app.get("/groups/{group_id}", response_class=HTMLResponse) +async def group_detail(request: Request, group_id: int): + identity = require_admin(request) + with get_conn().cursor() as cur: + group = cur.execute( + "SELECT id, name FROM groups WHERE id = %s", (group_id,) + ).fetchone() + if group is None: + raise HTTPException(status_code=404, detail="Group not found") + rules = cur.execute( + "SELECT id, kind, pattern FROM access_rules WHERE group_id = %s" + " ORDER BY kind, pattern", + (group_id,), + ).fetchall() + members = cur.execute( + "SELECT u.id, u.username FROM users u" + " JOIN user_groups ug ON ug.user_id = u.id WHERE ug.group_id = %s", + (group_id,), + ).fetchall() + all_users = cur.execute( + "SELECT id, username FROM users ORDER BY username" + ).fetchall() + return templates.TemplateResponse( + request, + "group_detail.html", + { + "identity": identity, + "group": group, + "rules": rules, + "members": members, + "all_users": all_users, + }, + ) + + +@app.post("/groups/{group_id}/rules") +async def add_group_rule( + request: Request, group_id: int, kind: str = Form(...), pattern: str = Form(...) +): + identity = require_admin(request) + await require_csrf(request, identity) + if kind not in ("allow", "deny"): + raise HTTPException(status_code=400, detail="Invalid kind") + pattern = pattern.strip() + if not pattern: + raise HTTPException(status_code=400, detail="Pattern is required") + with get_conn().cursor() as cur: + cur.execute( + "INSERT INTO access_rules (group_id, kind, pattern) VALUES (%s, %s, %s)", + (group_id, kind, pattern), + ) + return RedirectResponse(f"/groups/{group_id}", status_code=303) + + +@app.post("/groups/{group_id}/rules/{rule_id}/delete") +async def delete_group_rule(request: Request, group_id: int, rule_id: int): + identity = require_admin(request) + await require_csrf(request, identity) + with get_conn().cursor() as cur: + cur.execute( + "DELETE FROM access_rules WHERE id = %s AND group_id = %s", + (rule_id, group_id), + ) + return RedirectResponse(f"/groups/{group_id}", status_code=303) + + +@app.post("/groups/{group_id}/members") +async def set_group_members( + request: Request, group_id: int, user_ids: list[str] = Form(default=[]) +): + identity = require_admin(request) + await require_csrf(request, identity) + with get_conn().cursor() as cur: + cur.execute("DELETE FROM user_groups WHERE group_id = %s", (group_id,)) + if user_ids: + cur.executemany( + "INSERT INTO user_groups (user_id, group_id) VALUES (%s, %s)", + [(int(uid), group_id) for uid in user_ids], + ) + return RedirectResponse(f"/groups/{group_id}", status_code=303) + + +@app.post("/groups/{group_id}/delete") +async def delete_group(request: Request, group_id: int): + identity = require_admin(request) + await require_csrf(request, identity) + with get_conn().cursor() as cur: + cur.execute("DELETE FROM groups WHERE id = %s", (group_id,)) + return RedirectResponse("/groups", status_code=303) + + +# -- Data browser ------------------------------------------------------------- + + +@app.get("/data", response_class=HTMLResponse) +async def data_page(request: Request, topic: Optional[str] = None): + identity = require_login(request) + rows = _telemetry_rows(identity, topic) + return templates.TemplateResponse( + request, + "data.html", + {"identity": identity, "rows": rows, "topic": topic or ""}, + ) \ No newline at end of file diff --git a/webui/requirements.txt b/webui/requirements.txt new file mode 100644 index 0000000..90f9b26 --- /dev/null +++ b/webui/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +psycopg[binary,pool]==3.2.3 +jinja2==3.1.5 +passlib[argon2]==1.7.4 +python-multipart==0.0.20 \ No newline at end of file diff --git a/webui/static/style.css b/webui/static/style.css new file mode 100644 index 0000000..7b9ead7 --- /dev/null +++ b/webui/static/style.css @@ -0,0 +1,198 @@ +:root { + --bg: #f4f5f7; + --card: #ffffff; + --text: #1f2430; + --muted: #6b7280; + --accent: #2563eb; + --accent-dark: #1e40af; + --danger: #dc2626; + --ok: #16a34a; + --warn: #d97706; + --border: #e2e5ea; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + font-size: 15px; +} + +header { + background: #101828; + color: #fff; +} + +nav { + display: flex; + align-items: center; + gap: 1rem; + max-width: 960px; + margin: 0 auto; + padding: 0.6rem 1rem; +} + +nav a { color: #cbd5e1; text-decoration: none; padding: 0.25rem 0.5rem; border-radius: 4px; } +nav a:hover { color: #fff; } +nav a.active { color: #fff; background: rgba(255, 255, 255, 0.12); } +nav a.brand { font-weight: 700; color: #fff; margin-right: 0.5rem; } +nav .spacer { flex: 1; } +nav .user { color: #94a3b8; font-size: 0.9em; } +form.inline { display: inline; } + +main { + max-width: 960px; + margin: 1.5rem auto; + padding: 0 1rem; +} + +h1 { font-size: 1.5rem; margin: 0 0 1rem; } +h2 { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; } + +.banner { + background: #fef3c7; + border: 1px solid #fcd34d; + color: #92400e; + padding: 0.5rem 0.75rem; + border-radius: 6px; + margin-bottom: 1rem; +} + +.error { + background: #fee2e2; + border: 1px solid #fecaca; + color: #991b1b; + padding: 0.5rem 0.75rem; + border-radius: 6px; +} + +.hint { color: var(--muted); font-size: 0.88em; } + +table { + width: 100%; + border-collapse: collapse; + background: var(--card); + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} +th, td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--border); } +th { background: #f9fafb; font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.03em; color: var(--muted); } +tr:last-child td { border-bottom: none; } +td.actions { display: flex; gap: 0.5rem; } +td code, .inline-form code { font-size: 0.9em; } + +.tag { + display: inline-block; + font-size: 0.75em; + padding: 0.1rem 0.5rem; + border-radius: 999px; + background: #e5e7eb; + color: #374151; +} +.tag.allow, .tag.ok { background: #dcfce7; color: #166534; } +.tag.deny, .tag.disabled { background: #fee2e2; color: #991b1b; } +.tag.warn { background: #fef3c7; color: #92400e; } + +form.stack { + display: flex; + flex-direction: column; + gap: 0.75rem; + background: var(--card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 1rem; + max-width: 480px; +} + +form.stack label, form.stack fieldset legend { font-size: 0.85em; color: var(--muted); } +form.stack input[type="text"], form.stack input[type="password"], form.stack input[type="search"], +form.inline-form input[type="text"], form.filter input[type="search"] { + width: 100%; + padding: 0.45rem 0.6rem; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.95em; + margin-top: 0.2rem; +} +form.stack select, form.inline-form select { + padding: 0.45rem 0.6rem; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.95em; +} + +form.inline-form { + display: flex; + gap: 0.5rem; + align-items: center; + margin-top: 0.75rem; + flex-wrap: wrap; +} +form.inline-form input[type="text"] { width: auto; flex: 1; min-width: 200px; margin-top: 0; } + +button { + background: var(--accent); + color: #fff; + border: none; + padding: 0.45rem 1rem; + border-radius: 6px; + font-size: 0.9em; + cursor: pointer; +} +button:hover { background: var(--accent-dark); } + +button.link { + background: none; + color: var(--accent); + padding: 0.1rem 0.3rem; + font-size: 0.85em; + text-decoration: underline; +} +button.link:hover { background: none; color: var(--accent-dark); } +button.link.danger { color: var(--danger); } +button.link.danger:hover { color: #991b1b; } + +form.filter { + display: flex; + gap: 0.5rem; + margin-bottom: 0.5rem; +} +form.filter input[type="search"] { flex: 1; } + +label.check { display: flex; gap: 0.5rem; align-items: center; font-size: 0.95em; } +fieldset { border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; } + +section { margin-top: 2rem; } + +/* Login page */ +body.login-body { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: #101828; +} +.login-card { + background: var(--card); + padding: 2rem; + border-radius: 8px; + width: 340px; + display: flex; + flex-direction: column; + gap: 1rem; +} +.login-card h1 { margin: 0; font-size: 1.2rem; } +.login-card form { display: flex; flex-direction: column; gap: 0.75rem; } +.login-card label { font-size: 0.85em; color: var(--muted); } +.login-card input { + width: 100%; + padding: 0.45rem 0.6rem; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.95em; + margin-top: 0.2rem; +} \ No newline at end of file diff --git a/webui/templates/base.html b/webui/templates/base.html new file mode 100644 index 0000000..1fc92b8 --- /dev/null +++ b/webui/templates/base.html @@ -0,0 +1,36 @@ + + + + + + {% block title %}MQTT Ingester{% endblock %} + + + +
+ +
+
+ {% if identity and identity.must_change_password %} + + {% endif %} + {% block content %}{% endblock %} +
+ + \ No newline at end of file diff --git a/webui/templates/data.html b/webui/templates/data.html new file mode 100644 index 0000000..d0aec69 --- /dev/null +++ b/webui/templates/data.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Data — MQTT Ingester{% endblock %} +{% block content %} +

Recent telemetry

+
+ + +
+

Showing the 100 most recent measurements you have access to. Patterns follow the access rules: * matches any sequence.

+{% if rows %} + + + + {% for row in rows %} + + + + + + {% endfor %} + +
TimeTopicPayload
{{ row.time }}{{ row.topic }}{{ row.payload | tojson }}
+{% else %} +

No telemetry visible (nothing ingested yet, or your access rules match no topics).

+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/webui/templates/group_detail.html b/webui/templates/group_detail.html new file mode 100644 index 0000000..d7c9baa --- /dev/null +++ b/webui/templates/group_detail.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% block title %}{{ group.name }} — MQTT Ingester{% endblock %} +{% block content %} +

{{ group.name }}

+ +
+

Access rules

+

Glob patterns matched against the full tag name. * matches any sequence (including .). Deny wins over allow. Group rules combine with users' personal rules.

+ + + + {% for r in rules %} + + + + + + {% endfor %} + +
KindPattern
{{ r.kind }}{{ r.pattern }} +
+ + +
+
+
+ + + + +
+
+ +
+

Members

+
+ +
+ {% for u in all_users %} + + {% endfor %} +
+ +
+
+{% endblock %} \ No newline at end of file diff --git a/webui/templates/groups.html b/webui/templates/groups.html new file mode 100644 index 0000000..025efeb --- /dev/null +++ b/webui/templates/groups.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Groups — MQTT Ingester{% endblock %} +{% block content %} +

Groups

+

Create group

+
+ + + +
+ +

Existing groups

+{% if groups %} + + + + {% for g in groups %} + + + + + + + {% endfor %} + +
NameMembersRules
{{ g.name }}{{ g.member_count }}{{ g.rule_count }} +
+ + +
+
+{% else %} +

No groups yet.

+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/webui/templates/login.html b/webui/templates/login.html new file mode 100644 index 0000000..a9ac789 --- /dev/null +++ b/webui/templates/login.html @@ -0,0 +1,24 @@ + + + + + + Log in — MQTT Ingester + + + +
+

MQTT Ingester

+ {% if error %}

{{ error }}

{% endif %} +
+ + + +
+
+ + \ No newline at end of file diff --git a/webui/templates/password.html b/webui/templates/password.html new file mode 100644 index 0000000..4c0f746 --- /dev/null +++ b/webui/templates/password.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}Password — MQTT Ingester{% endblock %} +{% block content %} +

Change password

+{% if error %}

{{ error }}

{% endif %} +{% if identity.must_change_password %} + +{% endif %} +
+ + + + + +
+{% endblock %} \ No newline at end of file diff --git a/webui/templates/user_detail.html b/webui/templates/user_detail.html new file mode 100644 index 0000000..f5f2beb --- /dev/null +++ b/webui/templates/user_detail.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% block title %}{{ user.username }} — MQTT Ingester{% endblock %} +{% block content %} +

{{ user.username }} {{ user.role }}

+{% if user.disabled_at %}{% endif %} + +
+

Groups

+
+ +
+ {% for g in all_groups %} + + {% else %} +

No groups exist yet — create one under Groups.

+ {% endfor %} +
+ +
+
+ +
+

Personal access rules

+

Glob patterns matched against the full tag name. * matches any sequence (including .). Deny wins over allow. Personal rules combine with group rules; use a personal deny to override a group allow, and vice versa.

+ + + + {% for r in rules %} + + + + + + {% endfor %} + +
KindPattern
{{ r.kind }}{{ r.pattern }} +
+ + +
+
+
+ + + + +
+
+ +
+

Set new password

+
+ + + +
+

The user must change it at next login.

+
+{% endblock %} \ No newline at end of file diff --git a/webui/templates/users.html b/webui/templates/users.html new file mode 100644 index 0000000..df4d1d1 --- /dev/null +++ b/webui/templates/users.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% block title %}Users — MQTT Ingester{% endblock %} +{% block content %} +

Users

+

Create user

+
+ + + + +
+ Groups + {% for g in groups %} + + {% endfor %} +
+ +
+ +

Existing users

+ + + + {% for u in users %} + + + + + + + + {% endfor %} + +
UsernameRoleGroupsStatus
{{ u.username }}{{ u.role }}{{ u.groups | join(', ') if u.groups else '—' }} + {% if u.disabled_at %}disabled + {% elif u.must_change_password %}must change pw + {% else %}active{% endif %} + +
+ + +
+
+ + +
+
+{% endblock %} \ No newline at end of file