This commit is contained in:
Jonas Bengtsson
2026-08-30 11:30:09 +02:00
parent 47714fa7e3
commit c139b30f6f
28 changed files with 2100 additions and 34 deletions
+21
View File
@@ -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
View File
@@ -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
View File
@@ -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 ""},
)
+6
View File
@@ -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
+198
View File
@@ -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;
}
+36
View File
@@ -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>
+26
View File
@@ -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 %}
+53
View File
@@ -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 %}
+35
View File
@@ -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 %}
+24
View File
@@ -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>
+22
View File
@@ -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 %}
+66
View File
@@ -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 %}
+53
View File
@@ -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 %}