🚧 WIP
This commit is contained in:
@@ -38,3 +38,20 @@ LOKI_TIMEOUT_SECONDS=5
|
|||||||
API_PORT=8443
|
API_PORT=8443
|
||||||
DB_POOL_MIN_SIZE=2
|
DB_POOL_MIN_SIZE=2
|
||||||
DB_POOL_MAX_SIZE=10
|
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=
|
||||||
|
|||||||
@@ -19,9 +19,41 @@ flowchart LR
|
|||||||
| ------------ | -------------------------------------------------------------- |
|
| ------------ | -------------------------------------------------------------- |
|
||||||
| `timescaledb`| Time-series database storing telemetry as hypertables. |
|
| `timescaledb`| Time-series database storing telemetry as hypertables. |
|
||||||
| `mqtt-client`| Subscribes to MQTTS topics, writes payloads to the database. |
|
| `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).|
|
| `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
|
## Prerequisites
|
||||||
|
|
||||||
- Docker and Docker Compose v2.
|
- Docker and Docker Compose v2.
|
||||||
@@ -56,6 +88,9 @@ Key variables:
|
|||||||
| `LOKI_URL` | Loki push URL; leave empty to disable. |
|
| `LOKI_URL` | Loki push URL; leave empty to disable. |
|
||||||
| `LOKI_FLUSH_SECONDS`| Seconds between batched Loki pushes (default `5`). |
|
| `LOKI_FLUSH_SECONDS`| Seconds between batched Loki pushes (default `5`). |
|
||||||
| `API_PORT` | Host port for the HTTPS API (default `8443`). |
|
| `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
|
## Certificates
|
||||||
|
|
||||||
@@ -123,24 +158,15 @@ psk_hint mqtt-ingester
|
|||||||
psk_file /mosquitto/psk_file # line: ingester:0123456789abcdef
|
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
|
## API
|
||||||
|
|
||||||
The API is served over HTTPS with mutual TLS. Clients must present a
|
The API is served over HTTPS and accepts two authentication methods:
|
||||||
certificate signed by the configured CA.
|
|
||||||
|
- **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
|
```bash
|
||||||
curl --cacert certs/ca.crt \
|
curl --cacert certs/ca.crt \
|
||||||
@@ -154,6 +180,7 @@ Endpoints:
|
|||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | -------------------- | ---------------------------------------- |
|
| ------ | -------------------- | ---------------------------------------- |
|
||||||
| GET | `/health` | Health check. |
|
| 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` | List telemetry (filters: `topic`, `device_id`, `limit`). |
|
||||||
| GET | `/telemetry/latest` | Latest record (filters: `topic`, `device_id`). |
|
| 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"
|
"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
|
## Logging
|
||||||
|
|
||||||
The MQTT client logs in JSON format to:
|
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.|
|
| `device_id`| `TEXT` | Extracted from payload if present.|
|
||||||
| `payload` | `JSONB` | Full message payload. |
|
| `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
|
## Security Notes
|
||||||
|
|
||||||
- Change `POSTGRES_PASSWORD` before deploying.
|
- 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.
|
- Use a real CA and rotate certificates regularly.
|
||||||
- Restrict the API port with a firewall or reverse proxy.
|
- 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.
|
||||||
|
|||||||
+10
-2
@@ -2,10 +2,18 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
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
|
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
|
RUN useradd --system --no-create-home api
|
||||||
USER api
|
USER api
|
||||||
|
|||||||
+51
-8
@@ -16,6 +16,8 @@ from psycopg import sql
|
|||||||
from psycopg.rows import dict_row
|
from psycopg.rows import dict_row
|
||||||
from psycopg_pool import ConnectionPool
|
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"
|
_SELECT_SQL: Final = "SELECT time, topic, device_id, payload FROM telemetry"
|
||||||
|
|
||||||
# Connections use row_factory=dict_row, so each row is a dict.
|
# Connections use row_factory=dict_row, so each row is a dict.
|
||||||
@@ -63,17 +65,33 @@ def get_pool() -> ConnectionPool[_DictConnection]:
|
|||||||
return pool
|
return pool
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
def _request_identity(request: Request) -> Optional[Identity]:
|
||||||
async def require_client_cert(request: Request, call_next):
|
"""Resolve the request principal: mTLS cert, session cookie, or None."""
|
||||||
# uvicorn populates the client cert into the transport when mTLS is enabled.
|
# uvicorn populates the client cert into the transport when mTLS is enabled.
|
||||||
cert = getattr(request.scope.get("transport"), "get_extra_info", lambda _: None)(
|
cert = getattr(request.scope.get("transport"), "get_extra_info", lambda _: None)(
|
||||||
"peercert"
|
"peercert"
|
||||||
)
|
)
|
||||||
if cert is None:
|
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
|
# Return a response directly: HTTPException raised from middleware is
|
||||||
# not handled by the (inner) exception handlers and would surface
|
# not handled by the (inner) exception handlers and would surface
|
||||||
# as a 500.
|
# as a 500.
|
||||||
return JSONResponse(status_code=401, content={"detail": "Client certificate required"})
|
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)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
@@ -82,15 +100,35 @@ def health() -> dict[str, str]:
|
|||||||
return {"status": "ok"}
|
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(
|
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]]:
|
) -> tuple[sql.Composed, list[Any]]:
|
||||||
"""Build the SELECT for the requested filters as composed SQL.
|
"""Build the SELECT for the requested filters as composed SQL.
|
||||||
|
|
||||||
The statement is composed with psycopg.sql, and all values are bound
|
The statement is composed with psycopg.sql, and all values are bound
|
||||||
as parameters, so nothing is interpolated into the query string.
|
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] = []
|
params: list[Any] = []
|
||||||
if topic:
|
if topic:
|
||||||
conditions.append(sql.SQL("topic = %s"))
|
conditions.append(sql.SQL("topic = %s"))
|
||||||
@@ -98,6 +136,7 @@ def _telemetry_query(
|
|||||||
if device_id:
|
if device_id:
|
||||||
conditions.append(sql.SQL("device_id = %s"))
|
conditions.append(sql.SQL("device_id = %s"))
|
||||||
params.append(device_id)
|
params.append(device_id)
|
||||||
|
params.extend(p for group in param_groups for p in group)
|
||||||
query = sql.SQL(_SELECT_SQL)
|
query = sql.SQL(_SELECT_SQL)
|
||||||
if conditions:
|
if conditions:
|
||||||
query += sql.SQL(" WHERE ") + sql.SQL(" AND ").join(conditions)
|
query += sql.SQL(" WHERE ") + sql.SQL(" AND ").join(conditions)
|
||||||
@@ -108,11 +147,13 @@ def _telemetry_query(
|
|||||||
|
|
||||||
@app.get("/telemetry")
|
@app.get("/telemetry")
|
||||||
def list_telemetry(
|
def list_telemetry(
|
||||||
|
request: Request,
|
||||||
topic: Optional[str] = Query(default=None),
|
topic: Optional[str] = Query(default=None),
|
||||||
device_id: Optional[str] = Query(default=None),
|
device_id: Optional[str] = Query(default=None),
|
||||||
limit: int = Query(default=100, le=1000),
|
limit: int = Query(default=100, le=1000),
|
||||||
) -> list[dict[str, Any]]:
|
) -> 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:
|
with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
cur.execute(query, params)
|
cur.execute(query, params)
|
||||||
return cur.fetchall()
|
return cur.fetchall()
|
||||||
@@ -120,10 +161,12 @@ def list_telemetry(
|
|||||||
|
|
||||||
@app.get("/telemetry/latest")
|
@app.get("/telemetry/latest")
|
||||||
def latest_telemetry(
|
def latest_telemetry(
|
||||||
|
request: Request,
|
||||||
topic: Optional[str] = Query(default=None),
|
topic: Optional[str] = Query(default=None),
|
||||||
device_id: Optional[str] = Query(default=None),
|
device_id: Optional[str] = Query(default=None),
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> 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:
|
with get_pool().connection() as conn, conn.cursor() as cur:
|
||||||
cur.execute(query, params)
|
cur.execute(query, params)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
);
|
||||||
+26
-2
@@ -19,7 +19,9 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
mqtt-client:
|
mqtt-client:
|
||||||
build: ./mqtt-client
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: mqtt-client/Dockerfile
|
||||||
container_name: mqtt-client
|
container_name: mqtt-client
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -57,7 +59,9 @@ services:
|
|||||||
- mqtt-logs:/var/log/mqtt-client
|
- mqtt-logs:/var/log/mqtt-client
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build: ./api
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: api/Dockerfile
|
||||||
container_name: mqtt-api
|
container_name: mqtt-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -81,6 +85,26 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./certs:/certs:ro
|
- ./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:
|
loki:
|
||||||
image: grafana/loki:3.4.2
|
image: grafana/loki:3.4.2
|
||||||
container_name: mqtt-loki
|
container_name: mqtt-loki
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ FROM python:3.13-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
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
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY mqtt-client/ /app/
|
||||||
|
|
||||||
RUN mkdir -p /var/log/mqtt-client \
|
RUN mkdir -p /var/log/mqtt-client \
|
||||||
&& useradd --system --no-create-home mqtt \
|
&& useradd --system --no-create-home mqtt \
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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."
|
||||||
@@ -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 <service>" -ForegroundColor Gray
|
||||||
@@ -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 <service>"
|
||||||
@@ -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
|
||||||
@@ -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[@]}"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Code shared by the api and webui services."""
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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"]
|
||||||
+174
@@ -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())
|
||||||
+572
@@ -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 ""},
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}MQTT Ingester{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<a class="brand" href="/data">MQTT Ingester</a>
|
||||||
|
<a href="/data" {% if nav == 'data' %}class="active"{% endif %}>Data</a>
|
||||||
|
{% if identity and identity.is_admin %}
|
||||||
|
<a href="/users" {% if nav == 'users' %}class="active"{% endif %}>Users</a>
|
||||||
|
<a href="/groups" {% if nav == 'groups' %}class="active"{% endif %}>Groups</a>
|
||||||
|
{% endif %}
|
||||||
|
<span class="spacer"></span>
|
||||||
|
{% if identity %}
|
||||||
|
<span class="user">{{ identity.username }}{% if identity.is_admin %} (admin){% endif %}</span>
|
||||||
|
<a href="/password">Password</a>
|
||||||
|
<form method="post" action="/logout" class="inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||||
|
<button type="submit" class="link">Log out</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{% if identity and identity.must_change_password %}
|
||||||
|
<div class="banner">Default password in use — please <a href="/password">change your password</a>.</div>
|
||||||
|
{% endif %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Data — MQTT Ingester{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Recent telemetry</h1>
|
||||||
|
<form method="get" action="/data" class="filter">
|
||||||
|
<input type="search" name="topic" value="{{ topic }}" placeholder="Filter by topic, e.g. 11001.*">
|
||||||
|
<button type="submit">Filter</button>
|
||||||
|
</form>
|
||||||
|
<p class="hint">Showing the 100 most recent measurements you have access to. Patterns follow the access rules: <code>*</code> matches any sequence.</p>
|
||||||
|
{% if rows %}
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Time</th><th>Topic</th><th>Payload</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.time }}</td>
|
||||||
|
<td>{{ row.topic }}</td>
|
||||||
|
<td><code>{{ row.payload | tojson }}</code></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>No telemetry visible (nothing ingested yet, or your access rules match no topics).</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ group.name }} — MQTT Ingester{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ group.name }}</h1>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Access rules</h2>
|
||||||
|
<p class="hint">Glob patterns matched against the full tag name. <code>*</code> matches any sequence (including <code>.</code>). Deny wins over allow. Group rules combine with users' personal rules.</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Kind</th><th>Pattern</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rules %}
|
||||||
|
<tr>
|
||||||
|
<td><span class="tag {{ r.kind }}">{{ r.kind }}</span></td>
|
||||||
|
<td><code>{{ r.pattern }}</code></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/groups/{{ group.id }}/rules/{{ r.id }}/delete">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||||
|
<button type="submit" class="link danger">remove</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<form method="post" action="/groups/{{ group.id }}/rules" class="inline-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||||
|
<select name="kind">
|
||||||
|
<option value="allow">allow</option>
|
||||||
|
<option value="deny">deny</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" name="pattern" placeholder="e.g. 11001.*" required>
|
||||||
|
<button type="submit">Add rule</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Members</h2>
|
||||||
|
<form method="post" action="/groups/{{ group.id }}/members" class="stack">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||||
|
<fieldset>
|
||||||
|
{% for u in all_users %}
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" name="user_ids" value="{{ u.id }}"
|
||||||
|
{% if u.id in members | map(attribute='id') | list %}checked{% endif %}>
|
||||||
|
{{ u.username }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</fieldset>
|
||||||
|
<button type="submit">Save members</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Groups — MQTT Ingester{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Groups</h1>
|
||||||
|
<h2>Create group</h2>
|
||||||
|
<form method="post" action="/groups" class="stack">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||||
|
<label>Name <input type="text" name="name" required></label>
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2>Existing groups</h2>
|
||||||
|
{% if groups %}
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Name</th><th>Members</th><th>Rules</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for g in groups %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/groups/{{ g.id }}">{{ g.name }}</a></td>
|
||||||
|
<td>{{ g.member_count }}</td>
|
||||||
|
<td>{{ g.rule_count }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/groups/{{ g.id }}/delete">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||||
|
<button type="submit" class="link danger">delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>No groups yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Log in — MQTT Ingester</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="login-body">
|
||||||
|
<main class="login-card">
|
||||||
|
<h1>MQTT Ingester</h1>
|
||||||
|
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||||||
|
<form method="post" action="/login">
|
||||||
|
<label>Username
|
||||||
|
<input type="text" name="username" required autofocus autocomplete="username">
|
||||||
|
</label>
|
||||||
|
<label>Password
|
||||||
|
<input type="password" name="password" required autocomplete="current-password">
|
||||||
|
</label>
|
||||||
|
<button type="submit">Log in</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Password — MQTT Ingester{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Change password</h1>
|
||||||
|
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||||||
|
{% if identity.must_change_password %}
|
||||||
|
<p class="banner">You are using the default or admin-set password. Set a new one to continue — you will be redirected here until you do.</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/password" class="stack">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||||
|
<label>Current password
|
||||||
|
<input type="password" name="current_password" required>
|
||||||
|
</label>
|
||||||
|
<label>New password
|
||||||
|
<input type="password" name="new_password" required minlength="8" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<label>Confirm new password
|
||||||
|
<input type="password" name="confirm_password" required minlength="8" autocomplete="new-password">
|
||||||
|
</label>
|
||||||
|
<button type="submit">Change password</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ user.username }} — MQTT Ingester{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{{ user.username }} <span class="tag">{{ user.role }}</span></h1>
|
||||||
|
{% if user.disabled_at %}<p class="banner">User is disabled.</p>{% endif %}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Groups</h2>
|
||||||
|
<form method="post" action="/users/{{ user.id }}/groups" class="stack">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||||
|
<fieldset>
|
||||||
|
{% for g in all_groups %}
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" name="group_ids" value="{{ g.id }}"
|
||||||
|
{% if g.id in member_of | map(attribute='id') | list %}checked{% endif %}>
|
||||||
|
{{ g.name }}
|
||||||
|
</label>
|
||||||
|
{% else %}
|
||||||
|
<p class="hint">No groups exist yet — create one under Groups.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</fieldset>
|
||||||
|
<button type="submit">Save groups</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Personal access rules</h2>
|
||||||
|
<p class="hint">Glob patterns matched against the full tag name. <code>*</code> matches any sequence (including <code>.</code>). Deny wins over allow. Personal rules combine with group rules; use a personal deny to override a group allow, and vice versa.</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Kind</th><th>Pattern</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rules %}
|
||||||
|
<tr>
|
||||||
|
<td><span class="tag {{ r.kind }}">{{ r.kind }}</span></td>
|
||||||
|
<td><code>{{ r.pattern }}</code></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/users/{{ user.id }}/rules/{{ r.id }}/delete">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||||
|
<button type="submit" class="link danger">remove</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<form method="post" action="/users/{{ user.id }}/rules" class="inline-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||||
|
<select name="kind">
|
||||||
|
<option value="allow">allow</option>
|
||||||
|
<option value="deny">deny</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" name="pattern" placeholder="e.g. 11001.*_Value" required>
|
||||||
|
<button type="submit">Add rule</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Set new password</h2>
|
||||||
|
<form method="post" action="/users/{{ user.id }}/password" class="stack">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||||
|
<label>Password <input type="password" name="password" required minlength="8"></label>
|
||||||
|
<button type="submit">Set password</button>
|
||||||
|
</form>
|
||||||
|
<p class="hint">The user must change it at next login.</p>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Users — MQTT Ingester{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Users</h1>
|
||||||
|
<h2>Create user</h2>
|
||||||
|
<form method="post" action="/users" class="stack">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||||
|
<label>Username <input type="text" name="username" required></label>
|
||||||
|
<label>Password <input type="password" name="password" required minlength="8"></label>
|
||||||
|
<label>Role
|
||||||
|
<select name="role">
|
||||||
|
<option value="user">user</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Groups</legend>
|
||||||
|
{% for g in groups %}
|
||||||
|
<label class="check"><input type="checkbox" name="group_ids" value="{{ g.id }}"> {{ g.name }}</label>
|
||||||
|
{% endfor %}
|
||||||
|
</fieldset>
|
||||||
|
<button type="submit">Create</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2>Existing users</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Username</th><th>Role</th><th>Groups</th><th>Status</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in users %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/users/{{ u.id }}">{{ u.username }}</a></td>
|
||||||
|
<td>{{ u.role }}</td>
|
||||||
|
<td>{{ u.groups | join(', ') if u.groups else '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if u.disabled_at %}<span class="tag disabled">disabled</span>
|
||||||
|
{% elif u.must_change_password %}<span class="tag warn">must change pw</span>
|
||||||
|
{% else %}<span class="tag ok">active</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
<form method="post" action="/users/{{ u.id }}/{% if u.disabled_at %}enable{% else %}disable{% endif %}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||||
|
<button type="submit" class="link">{{ 'enable' if u.disabled_at else 'disable' }}</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/users/{{ u.id }}/delete">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ identity.csrf_token }}">
|
||||||
|
<button type="submit" class="link danger">delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user