From 1d0ac78bbdeeb2eeb5d61d64ed17f930b1aceee3 Mon Sep 17 00:00:00 2001 From: AlexsandrSnytkin Date: Thu, 26 Mar 2026 05:10:01 +0700 Subject: [PATCH] user_accaunt_restrictions --- .env | 6 + .env.template | 6 + __pycache__/service.cpython-311.pyc | Bin 79346 -> 119507 bytes ...e_integration.cpython-311-pytest-8.2.2.pyc | Bin 89330 -> 117002 bytes .../test_service_integration.cpython-311.pyc | Bin 46503 -> 63848 bytes config.template.json | 17 + cookies-admin.txt | 4 + cookies-user.txt | 4 + docker-compose.yml | 22 +- docker/config.docker.test.json | 21 +- docker/keycloak/Dockerfile | 12 + docker/keycloak/entrypoint.sh | 23 + .../triangulation-realm.template.json | 95 ++ service.py | 882 ++++++++++++++++-- test_service_integration.py | 262 ++++++ web/app.js | 496 +++++++++- web/index.html | 267 ++++-- web/styles.css | 163 +++- 18 files changed, 2046 insertions(+), 234 deletions(-) create mode 100644 .env create mode 100644 .env.template create mode 100644 cookies-admin.txt create mode 100644 cookies-user.txt create mode 100644 docker/keycloak/Dockerfile create mode 100644 docker/keycloak/entrypoint.sh create mode 100644 docker/keycloak/triangulation-realm.template.json diff --git a/.env b/.env new file mode 100644 index 0000000..11b880f --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=admin +TRIANGULATION_ADMIN_USERNAME=admin_ui +TRIANGULATION_ADMIN_PASSWORD=admin123 +TRIANGULATION_VIEWER_USERNAME=viewer +TRIANGULATION_VIEWER_PASSWORD=viewer123 diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..11b880f --- /dev/null +++ b/.env.template @@ -0,0 +1,6 @@ +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=admin +TRIANGULATION_ADMIN_USERNAME=admin_ui +TRIANGULATION_ADMIN_PASSWORD=admin123 +TRIANGULATION_VIEWER_USERNAME=viewer +TRIANGULATION_VIEWER_PASSWORD=viewer123 diff --git a/__pycache__/service.cpython-311.pyc b/__pycache__/service.cpython-311.pyc index a3053a09f301e2dc33a3a83e41e14849f824e782..5f94a4f9d0aa1cdef50df6c5e1826efa03cd838c 100644 GIT binary patch delta 51147 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 G!T%RI3mH=Y delta 20153 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 diff --git a/__pycache__/test_service_integration.cpython-311-pytest-8.2.2.pyc b/__pycache__/test_service_integration.cpython-311-pytest-8.2.2.pyc index d16edb26f56400528d62a4ef90fc7198120f1841..2942534aade57b1d147f18a27ce96ea8f09a67f4 100644 GIT binary patch delta 28819 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 delta 9951 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 OB|E?TwM!B2nEwOS9zG@j diff --git a/config.template.json b/config.template.json index 9c14179..9464d8e 100644 --- a/config.template.json +++ b/config.template.json @@ -28,6 +28,23 @@ "ip": "" } }, + "auth": { + "enabled": false, + "provider": "keycloak", + "session_ttl_s": 43200, + "cookie_name": "triangulation_session", + "keycloak": { + "base_url": "http://keycloak:8080", + "realm": "triangulation", + "client_id": "triangulation-ui", + "client_secret": "", + "admin_client_id": "triangulation-admin", + "admin_client_secret": "", + "user_role": "triangulation_user", + "admin_role": "triangulation_admin", + "admin_console_url": "http://127.0.0.1:38083/admin/" + } + }, "input": { "mode": "http_sources", "aggregation": "median", diff --git a/cookies-admin.txt b/cookies-admin.txt new file mode 100644 index 0000000..c31d989 --- /dev/null +++ b/cookies-admin.txt @@ -0,0 +1,4 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + diff --git a/cookies-user.txt b/cookies-user.txt new file mode 100644 index 0000000..c31d989 --- /dev/null +++ b/cookies-user.txt @@ -0,0 +1,4 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + diff --git a/docker-compose.yml b/docker-compose.yml index 54bb61c..f72ec63 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,6 @@ services: triangulation-test: build: . - container_name: triangulation-test command: ["python", "service.py", "--config", "docker/config.docker.test.json"] ports: - "127.0.0.1:38081:8081" @@ -10,11 +9,26 @@ services: - receiver-r1 - receiver-r2 - output-sink + - keycloak + profiles: ["test"] + + keycloak: + build: + context: . + dockerfile: docker/keycloak/Dockerfile + environment: + KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} + TRIANGULATION_ADMIN_USERNAME: ${TRIANGULATION_ADMIN_USERNAME:-admin_ui} + TRIANGULATION_ADMIN_PASSWORD: ${TRIANGULATION_ADMIN_PASSWORD:-admin123} + TRIANGULATION_VIEWER_USERNAME: ${TRIANGULATION_VIEWER_USERNAME:-viewer} + TRIANGULATION_VIEWER_PASSWORD: ${TRIANGULATION_VIEWER_PASSWORD:-viewer123} + ports: + - "127.0.0.1:38083:8080" profiles: ["test"] receiver-r0: build: . - container_name: receiver-r0 command: ["python", "docker/mock_receiver.py", "--receiver-id", "r0", "--port", "9000", "--base-rssi", "-61.0"] expose: - "9000" @@ -22,7 +36,6 @@ services: receiver-r1: build: . - container_name: receiver-r1 command: ["python", "docker/mock_receiver.py", "--receiver-id", "r1", "--port", "9000", "--base-rssi", "-64.0"] expose: - "9000" @@ -30,7 +43,6 @@ services: receiver-r2: build: . - container_name: receiver-r2 command: ["python", "docker/mock_receiver.py", "--receiver-id", "r2", "--port", "9000", "--base-rssi", "-63.0"] expose: - "9000" @@ -38,7 +50,6 @@ services: output-sink: build: . - container_name: output-sink command: ["python", "docker/mock_output_sink.py", "--port", "8080"] expose: - "8080" @@ -46,7 +57,6 @@ services: triangulation-prod: build: . - container_name: triangulation-prod command: ["python", "service.py", "--config", "/app/config.json"] ports: - "127.0.0.1:38082:8081" diff --git a/docker/config.docker.test.json b/docker/config.docker.test.json index b636250..05b3045 100644 --- a/docker/config.docker.test.json +++ b/docker/config.docker.test.json @@ -19,8 +19,8 @@ "write_api_token": "", "output_servers": [ { - "name": "output_sink_main", - "ip": "output-sink" + "name": "output_sink_main", + "ip": "output-sink" } ], "output_server": { @@ -28,6 +28,23 @@ "ip": "output-sink" } }, + "auth": { + "enabled": true, + "provider": "keycloak", + "session_ttl_s": 43200, + "cookie_name": "triangulation_session", + "keycloak": { + "base_url": "http://keycloak:8080", + "realm": "triangulation", + "client_id": "triangulation-ui", + "client_secret": "triangulation-ui-secret", + "admin_client_id": "triangulation-admin", + "admin_client_secret": "triangulation-admin-secret", + "user_role": "triangulation_user", + "admin_role": "triangulation_admin", + "admin_console_url": "http://127.0.0.1:38083/admin/" + } + }, "input": { "mode": "http_sources", "aggregation": "median", diff --git a/docker/keycloak/Dockerfile b/docker/keycloak/Dockerfile new file mode 100644 index 0000000..d3232e9 --- /dev/null +++ b/docker/keycloak/Dockerfile @@ -0,0 +1,12 @@ +FROM keycloak/keycloak:22.0.1 + +COPY docker/keycloak/triangulation-realm.template.json /opt/keycloak/templates/triangulation-realm.template.json +COPY docker/keycloak/entrypoint.sh /opt/keycloak/bin/triangulation-entrypoint.sh + +USER root +RUN chmod +x /opt/keycloak/bin/triangulation-entrypoint.sh \ + && mkdir -p /opt/keycloak/templates /opt/keycloak/data/import \ + && chown -R keycloak:root /opt/keycloak/templates /opt/keycloak/data/import +USER keycloak + +ENTRYPOINT ["/opt/keycloak/bin/triangulation-entrypoint.sh"] diff --git a/docker/keycloak/entrypoint.sh b/docker/keycloak/entrypoint.sh new file mode 100644 index 0000000..cc6f9ca --- /dev/null +++ b/docker/keycloak/entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail + +template="/opt/keycloak/templates/triangulation-realm.template.json" +target="/opt/keycloak/data/import/triangulation-realm.json" + +escape_sed() { + printf '%s' "$1" | sed -e 's/[\/&|]/\\&/g' +} + +admin_username_escaped="$(escape_sed "${TRIANGULATION_ADMIN_USERNAME:-admin_ui}")" +admin_password_escaped="$(escape_sed "${TRIANGULATION_ADMIN_PASSWORD:-admin123}")" +viewer_username_escaped="$(escape_sed "${TRIANGULATION_VIEWER_USERNAME:-viewer}")" +viewer_password_escaped="$(escape_sed "${TRIANGULATION_VIEWER_PASSWORD:-viewer123}")" + +sed \ + -e "s|__TRIANGULATION_ADMIN_USERNAME__|${admin_username_escaped}|g" \ + -e "s|__TRIANGULATION_ADMIN_PASSWORD__|${admin_password_escaped}|g" \ + -e "s|__TRIANGULATION_VIEWER_USERNAME__|${viewer_username_escaped}|g" \ + -e "s|__TRIANGULATION_VIEWER_PASSWORD__|${viewer_password_escaped}|g" \ + "$template" > "$target" + +exec /opt/keycloak/bin/kc.sh start-dev --import-realm diff --git a/docker/keycloak/triangulation-realm.template.json b/docker/keycloak/triangulation-realm.template.json new file mode 100644 index 0000000..b0dab06 --- /dev/null +++ b/docker/keycloak/triangulation-realm.template.json @@ -0,0 +1,95 @@ +{ + "realm": "triangulation", + "enabled": true, + "displayName": "Triangulation", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "rememberMe": true, + "roles": { + "realm": [ + { + "name": "triangulation_admin", + "description": "Administrator access to the triangulation console" + }, + { + "name": "triangulation_user", + "description": "Read-only access to triangulation results" + } + ] + }, + "clients": [ + { + "clientId": "triangulation-ui", + "name": "Triangulation UI", + "enabled": true, + "publicClient": false, + "secret": "triangulation-ui-secret", + "directAccessGrantsEnabled": true, + "standardFlowEnabled": false, + "serviceAccountsEnabled": false, + "protocol": "openid-connect" + }, + { + "clientId": "triangulation-admin", + "name": "Triangulation Admin Service", + "enabled": true, + "publicClient": false, + "secret": "triangulation-admin-secret", + "directAccessGrantsEnabled": false, + "standardFlowEnabled": false, + "serviceAccountsEnabled": true, + "protocol": "openid-connect" + } + ], + "users": [ + { + "username": "__TRIANGULATION_ADMIN_USERNAME__", + "enabled": true, + "emailVerified": true, + "firstName": "System", + "lastName": "Admin", + "credentials": [ + { + "type": "password", + "value": "__TRIANGULATION_ADMIN_PASSWORD__", + "temporary": false + } + ], + "realmRoles": [ + "triangulation_admin" + ] + }, + { + "username": "__TRIANGULATION_VIEWER_USERNAME__", + "enabled": true, + "emailVerified": true, + "firstName": "Read", + "lastName": "Only", + "credentials": [ + { + "type": "password", + "value": "__TRIANGULATION_VIEWER_PASSWORD__", + "temporary": false + } + ], + "realmRoles": [ + "triangulation_user" + ] + }, + { + "username": "service-account-triangulation-admin", + "enabled": true, + "serviceAccountClientId": "triangulation-admin", + "clientRoles": { + "realm-management": [ + "manage-users", + "query-users", + "view-users", + "view-realm" + ] + } + } + ] +} diff --git a/service.py b/service.py index 3da9ed3..40115eb 100644 --- a/service.py +++ b/service.py @@ -1,13 +1,16 @@ from __future__ import annotations import argparse +import base64 import hmac import itertools import json import math import mimetypes +import secrets import statistics import threading +import time from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -25,6 +28,9 @@ from triangulation import ( Point3D = Tuple[float, float, float] MAX_CONFIG_BODY_BYTES = 1_000_000 # 1 MB guardrail for /config POST. HZ_IN_MHZ = 1_000_000.0 +DEFAULT_AUTH_SESSION_TTL_S = 43_200 +DEFAULT_AUTH_COOKIE_NAME = "triangulation_session" +DEFAULT_AUTH_PROVIDER = "keycloak" def _utc_now_iso_seconds() -> str: @@ -43,6 +49,167 @@ def _load_json(path: str) -> Dict[str, object]: return data +def _base64url_json(segment: str) -> Dict[str, object]: + raw = str(segment or "").strip() + if not raw: + return {} + padding = "=" * (-len(raw) % 4) + try: + decoded = base64.urlsafe_b64decode(raw + padding).decode("utf-8") + payload = json.loads(decoded) + except Exception: + return {} + return payload if isinstance(payload, dict) else {} + + +def _decode_jwt_payload_unverified(token: str) -> Dict[str, object]: + parts = str(token or "").split(".") + if len(parts) < 2: + return {} + return _base64url_json(parts[1]) + + +def _parse_auth_config(config: Dict[str, object]) -> Dict[str, object]: + auth_obj = config.get("auth", {}) + if auth_obj is None: + auth_obj = {} + if not isinstance(auth_obj, dict): + raise ValueError("auth must be object.") + + enabled = bool(auth_obj.get("enabled", False)) + provider = str(auth_obj.get("provider", DEFAULT_AUTH_PROVIDER)).strip().lower() + if provider not in ("keycloak",): + raise ValueError("auth.provider must be 'keycloak'.") + session_ttl_s = int(auth_obj.get("session_ttl_s", DEFAULT_AUTH_SESSION_TTL_S)) + if session_ttl_s <= 0: + raise ValueError("auth.session_ttl_s must be > 0.") + + cookie_name = str(auth_obj.get("cookie_name", DEFAULT_AUTH_COOKIE_NAME)).strip() + if not cookie_name: + raise ValueError("auth.cookie_name must be non-empty.") + + keycloak_obj = auth_obj.get("keycloak", {}) + if keycloak_obj is None: + keycloak_obj = {} + if not isinstance(keycloak_obj, dict): + raise ValueError("auth.keycloak must be object.") + + base_url = str(keycloak_obj.get("base_url", "")).strip().rstrip("/") + realm = str(keycloak_obj.get("realm", "")).strip() + client_id = str(keycloak_obj.get("client_id", "")).strip() + client_secret = str(keycloak_obj.get("client_secret", "")).strip() + admin_client_id = str(keycloak_obj.get("admin_client_id", "")).strip() + admin_client_secret = str(keycloak_obj.get("admin_client_secret", "")).strip() + user_role = str(keycloak_obj.get("user_role", "triangulation_user")).strip() + admin_role = str(keycloak_obj.get("admin_role", "triangulation_admin")).strip() + admin_console_url = str(keycloak_obj.get("admin_console_url", "")).strip() + + if enabled: + if not base_url: + raise ValueError("auth.keycloak.base_url must be non-empty when auth.enabled=true.") + if not realm: + raise ValueError("auth.keycloak.realm must be non-empty when auth.enabled=true.") + if not client_id: + raise ValueError("auth.keycloak.client_id must be non-empty when auth.enabled=true.") + if not client_secret: + raise ValueError("auth.keycloak.client_secret must be non-empty when auth.enabled=true.") + if not admin_client_id: + raise ValueError( + "auth.keycloak.admin_client_id must be non-empty when auth.enabled=true." + ) + if not admin_client_secret: + raise ValueError( + "auth.keycloak.admin_client_secret must be non-empty when auth.enabled=true." + ) + if not user_role or not admin_role: + raise ValueError("auth.keycloak.user_role/admin_role must be non-empty.") + + return { + "enabled": enabled, + "provider": provider, + "session_ttl_s": session_ttl_s, + "cookie_name": cookie_name, + "keycloak": { + "base_url": base_url, + "realm": realm, + "client_id": client_id, + "client_secret": client_secret, + "admin_client_id": admin_client_id, + "admin_client_secret": admin_client_secret, + "user_role": user_role, + "admin_role": admin_role, + "admin_console_url": admin_console_url, + }, + } + + +def _public_config_view(config: Dict[str, object], write_api_token_set: bool) -> Dict[str, object]: + public_config = json.loads(json.dumps(config)) + + runtime_obj = public_config.get("runtime") + if isinstance(runtime_obj, dict): + if "write_api_token" in runtime_obj: + runtime_obj["write_api_token"] = "" + runtime_obj["write_api_token_set"] = bool(write_api_token_set) + + auth_obj = public_config.get("auth") + if isinstance(auth_obj, dict): + keycloak_obj = auth_obj.get("keycloak") + if isinstance(keycloak_obj, dict): + source_auth_obj = config.get("auth", {}) + source_keycloak_obj = ( + source_auth_obj.get("keycloak", {}) + if isinstance(source_auth_obj, dict) + else {} + ) + if "client_secret" in keycloak_obj: + keycloak_obj["client_secret"] = "" + keycloak_obj["client_secret_set"] = bool( + str( + source_keycloak_obj.get("client_secret", "") + if isinstance(source_keycloak_obj, dict) + else "" + ).strip() + ) + if "admin_client_secret" in keycloak_obj: + keycloak_obj["admin_client_secret"] = "" + keycloak_obj["admin_client_secret_set"] = bool( + str( + source_keycloak_obj.get("admin_client_secret", "") + if isinstance(source_keycloak_obj, dict) + else "" + ).strip() + ) + + return public_config + + +def _preserve_sensitive_config_values( + current_service: "AutoService", + new_config: Dict[str, object], +) -> None: + runtime_obj = new_config.get("runtime") + if isinstance(runtime_obj, dict) and current_service.write_api_token: + incoming_token = str(runtime_obj.get("write_api_token", "")).strip() + if not incoming_token: + runtime_obj["write_api_token"] = current_service.write_api_token + + auth_obj = new_config.get("auth") + if not isinstance(auth_obj, dict): + return + keycloak_obj = auth_obj.get("keycloak") + if not isinstance(keycloak_obj, dict): + return + if current_service.keycloak_client_secret and not str( + keycloak_obj.get("client_secret", "") + ).strip(): + keycloak_obj["client_secret"] = current_service.keycloak_client_secret + if current_service.keycloak_admin_client_secret and not str( + keycloak_obj.get("admin_client_secret", "") + ).strip(): + keycloak_obj["admin_client_secret"] = current_service.keycloak_admin_client_secret + + def _center_from_obj(obj: Dict[str, object]) -> Point3D: center = obj.get("center") if not isinstance(center, dict): @@ -440,14 +607,24 @@ def _http_json_request( url: str, method: str = "GET", payload: Optional[Dict[str, object]] = None, + form: Optional[Dict[str, object]] = None, + headers: Optional[Dict[str, str]] = None, timeout_s: float = 2.0, ) -> Tuple[int, Dict[str, object], str]: - headers = {"Accept": "application/json"} + request_headers = {"Accept": "application/json"} body: Optional[bytes] = None if payload is not None: - headers["Content-Type"] = "application/json" + request_headers["Content-Type"] = "application/json" body = json.dumps(payload, ensure_ascii=False).encode("utf-8") - req = request.Request(url=url, method=method, headers=headers, data=body) + elif form is not None: + request_headers["Content-Type"] = "application/x-www-form-urlencoded" + body = parse.urlencode( + {str(key): str(value) for key, value in form.items()}, + doseq=True, + ).encode("utf-8") + if isinstance(headers, dict): + request_headers.update({str(key): str(value) for key, value in headers.items()}) + req = request.Request(url=url, method=method, headers=request_headers, data=body) try: with request.urlopen(req, timeout=timeout_s) as response: text = response.read().decode("utf-8", errors="replace") @@ -477,6 +654,169 @@ def _output_control_urls(output_server: Dict[str, object]) -> Tuple[str, str]: return f"{base}/control", f"{base}/status" +def _keycloak_admin_request( + service: "AutoService", + path: str, + method: str = "GET", + payload: Optional[Dict[str, object]] = None, + json_body: Optional[object] = None, + timeout_s: float = 5.0, +) -> Tuple[int, object, str]: + access_token = service.get_admin_access_token() + url = f"{service.keycloak_admin_api_base()}{path}" + headers = { + "Accept": "application/json", + "Authorization": f"Bearer {access_token}", + } + body: Optional[bytes] = None + if json_body is not None: + headers["Content-Type"] = "application/json" + body = json.dumps(json_body, ensure_ascii=False).encode("utf-8") + elif payload is not None: + headers["Content-Type"] = "application/json" + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + + req = request.Request(url=url, method=method, headers=headers, data=body) + try: + with request.urlopen(req, timeout=timeout_s) as response: + raw = response.read().decode("utf-8", errors="replace") + parsed = json.loads(raw) if raw.strip() else {} + return int(response.status), parsed, "" + except error.HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + try: + parsed = json.loads(raw) if raw.strip() else {} + except json.JSONDecodeError: + parsed = {"raw": raw} + return int(exc.code), parsed, "" + except Exception as exc: # pragma: no cover - network/IO branches + return 0, {}, str(exc) + + +def _keycloak_get_user_id_by_username(service: "AutoService", username: str) -> Optional[str]: + status_code, payload, request_error = _keycloak_admin_request( + service, + f"/users?username={parse.quote(str(username or '').strip())}&exact=true", + ) + if request_error or status_code < 200 or status_code >= 300 or not isinstance(payload, list): + return None + for row in payload: + if not isinstance(row, dict): + continue + row_username = str(row.get("username", "")).strip() + if row_username.casefold() == str(username or "").strip().casefold(): + user_id = str(row.get("id", "")).strip() + if user_id: + return user_id + return None + + +def _keycloak_get_role_representation( + service: "AutoService", + role_name: str, +) -> Dict[str, object]: + status_code, payload, request_error = _keycloak_admin_request( + service, + f"/roles/{parse.quote(role_name)}", + ) + if request_error: + raise RuntimeError(f"Keycloak role request failed: {request_error}") + if status_code < 200 or status_code >= 300 or not isinstance(payload, dict): + raise RuntimeError( + f"Keycloak role request failed: {payload.get('errorMessage') if isinstance(payload, dict) else status_code}" + ) + return payload + + +def _keycloak_list_users(service: "AutoService") -> List[Dict[str, object]]: + status_code, payload, request_error = _keycloak_admin_request(service, "/users?max=200") + if request_error: + raise RuntimeError(f"Keycloak users request failed: {request_error}") + if status_code < 200 or status_code >= 300 or not isinstance(payload, list): + raise RuntimeError( + f"Keycloak users request failed: {payload.get('errorMessage') if isinstance(payload, dict) else status_code}" + ) + + rows: List[Dict[str, object]] = [] + for row in payload: + if not isinstance(row, dict): + continue + username = str(row.get("username", "")).strip() + user_id = str(row.get("id", "")).strip() + if not username or not user_id or username.startswith("service-account-"): + continue + + role_status, role_payload, role_error = _keycloak_admin_request( + service, + f"/users/{parse.quote(user_id)}/role-mappings/realm", + ) + if role_error: + raise RuntimeError(f"Keycloak role mappings request failed: {role_error}") + if role_status < 200 or role_status >= 300 or not isinstance(role_payload, list): + raise RuntimeError("Keycloak role mappings request failed.") + role_names = { + str(role_row.get("name", "")).strip() + for role_row in role_payload + if isinstance(role_row, dict) + } + if service.keycloak_admin_role in role_names: + app_role = "admin" + elif service.keycloak_user_role in role_names: + app_role = "user" + else: + app_role = "" + + rows.append( + { + "id": user_id, + "username": username, + "enabled": bool(row.get("enabled", True)), + "first_name": str(row.get("firstName", "")).strip(), + "last_name": str(row.get("lastName", "")).strip(), + "role": app_role or "user", + } + ) + return sorted(rows, key=lambda item: str(item.get("username", "")).casefold()) + + +def _keycloak_apply_user_role(service: "AutoService", user_id: str, role: str) -> None: + target_role = service.keycloak_admin_role if role == "admin" else service.keycloak_user_role + other_role = service.keycloak_user_role if role == "admin" else service.keycloak_admin_role + target_repr = _keycloak_get_role_representation(service, target_role) + other_repr = _keycloak_get_role_representation(service, other_role) + + status_code, _, request_error = _keycloak_admin_request( + service, + f"/users/{parse.quote(user_id)}/role-mappings/realm", + method="POST", + json_body=[target_repr], + ) + if request_error or status_code < 200 or status_code >= 300: + raise RuntimeError("Failed to assign Keycloak role.") + + _keycloak_admin_request( + service, + f"/users/{parse.quote(user_id)}/role-mappings/realm", + method="DELETE", + json_body=[other_repr], + ) + + +def _keycloak_reset_password(service: "AutoService", user_id: str, password: str) -> None: + status_code, _, request_error = _keycloak_admin_request( + service, + f"/users/{parse.quote(user_id)}/reset-password", + method="PUT", + json_body={ + "type": "password", + "temporary": False, + "value": password, + }, + ) + if request_error or status_code < 200 or status_code >= 300: + raise RuntimeError("Failed to update Keycloak password.") + + def _receiver_configured_frequencies_mhz(receiver: Dict[str, object]) -> List[float]: configured_hz = receiver.get("configured_frequencies_hz") if not isinstance(configured_hz, list): @@ -697,6 +1037,26 @@ class AutoService: self.config = config self.config_path = config_path self.model = _parse_model(config) + auth_config = _parse_auth_config(config) + self.auth_enabled = bool(auth_config["enabled"]) + self.auth_provider = str(auth_config["provider"]) + self.auth_session_ttl_s = int(auth_config["session_ttl_s"]) + self.auth_cookie_name = str(auth_config["cookie_name"]) + keycloak_config = auth_config["keycloak"] + self.keycloak_base_url = str(keycloak_config["base_url"]) + self.keycloak_realm = str(keycloak_config["realm"]) + self.keycloak_client_id = str(keycloak_config["client_id"]) + self.keycloak_client_secret = str(keycloak_config["client_secret"]) + self.keycloak_admin_client_id = str(keycloak_config["admin_client_id"]) + self.keycloak_admin_client_secret = str(keycloak_config["admin_client_secret"]) + self.keycloak_user_role = str(keycloak_config["user_role"]) + self.keycloak_admin_role = str(keycloak_config["admin_role"]) + self.keycloak_admin_console_url = str(keycloak_config["admin_console_url"]) + self.keycloak_admin_token_cache: Dict[str, object] = { + "access_token": "", + "expires_at": 0.0, + } + self.keycloak_admin_token_lock = threading.Lock() solver_obj = config.get("solver", {}) runtime_obj = config.get("runtime", {}) @@ -864,6 +1224,73 @@ class AutoService: if self.poll_thread.is_alive(): self.poll_thread.join(timeout=2.0) + def keycloak_openid_base(self) -> str: + return ( + f"{self.keycloak_base_url}/realms/{self.keycloak_realm}" + "/protocol/openid-connect" + ) + + def keycloak_token_url(self) -> str: + return f"{self.keycloak_openid_base()}/token" + + def keycloak_userinfo_url(self) -> str: + return f"{self.keycloak_openid_base()}/userinfo" + + def keycloak_admin_api_base(self) -> str: + return f"{self.keycloak_base_url}/admin/realms/{self.keycloak_realm}" + + def role_from_token(self, access_token: str) -> Optional[str]: + payload = _decode_jwt_payload_unverified(access_token) + realm_access = payload.get("realm_access", {}) + if not isinstance(realm_access, dict): + return None + roles = realm_access.get("roles", []) + if not isinstance(roles, list): + return None + normalized_roles = {str(role).strip() for role in roles if str(role).strip()} + if self.keycloak_admin_role in normalized_roles: + return "admin" + if self.keycloak_user_role in normalized_roles: + return "user" + return None + + def get_admin_access_token(self) -> str: + if not self.auth_enabled: + raise RuntimeError("Authentication is disabled.") + + with self.keycloak_admin_token_lock: + cached_token = str(self.keycloak_admin_token_cache.get("access_token", "")) + expires_at = float(self.keycloak_admin_token_cache.get("expires_at", 0.0) or 0.0) + if cached_token and expires_at > time.time() + 15.0: + return cached_token + + status_code, payload, request_error = _http_json_request( + self.keycloak_token_url(), + method="POST", + form={ + "grant_type": "client_credentials", + "client_id": self.keycloak_admin_client_id, + "client_secret": self.keycloak_admin_client_secret, + }, + timeout_s=5.0, + ) + if request_error: + raise RuntimeError(f"Keycloak admin token request failed: {request_error}") + if status_code < 200 or status_code >= 300: + raise RuntimeError( + f"Keycloak admin token request failed: {payload.get('error_description') or payload.get('error') or status_code}" + ) + + access_token = str(payload.get("access_token", "")).strip() + expires_in = float(payload.get("expires_in", 60.0) or 60.0) + if not access_token: + raise RuntimeError("Keycloak admin token response did not include access_token.") + self.keycloak_admin_token_cache = { + "access_token": access_token, + "expires_at": time.time() + max(30.0, expires_in), + } + return access_token + def refresh_once(self) -> None: receiver_payloads: List[Dict[str, object]] = [] grouped_by_receiver: List[Dict[float, List[Tuple[float, float]]]] = [] @@ -1237,17 +1664,19 @@ class AutoService: def _make_handler(service: AutoService): service_holder = {"current": service} service_swap_lock = threading.Lock() + auth_sessions: Dict[str, Dict[str, object]] = {} + auth_sessions_lock = threading.Lock() class ServiceHandler(BaseHTTPRequestHandler): @staticmethod def _current_service() -> AutoService: return service_holder["current"] - def _is_write_authorized(self) -> bool: + def _api_token_authorized(self) -> bool: service_obj = self._current_service() expected_token = service_obj.write_api_token if not expected_token: - return True + return False header_token = self.headers.get("X-API-Token", "") if hmac.compare_digest(header_token, expected_token): @@ -1260,6 +1689,149 @@ def _make_handler(service: AutoService): return True return False + def _parse_cookies(self) -> Dict[str, str]: + raw_cookie = str(self.headers.get("Cookie", "")).strip() + cookies: Dict[str, str] = {} + if not raw_cookie: + return cookies + for part in raw_cookie.split(";"): + if "=" not in part: + continue + key, value = part.split("=", 1) + cookies[key.strip()] = value.strip() + return cookies + + def _current_session(self) -> Optional[Dict[str, object]]: + service_obj = self._current_service() + if not service_obj.auth_enabled: + return { + "username": "local-admin", + "role": "admin", + "expires_at": None, + } + + session_id = self._parse_cookies().get(service_obj.auth_cookie_name, "") + if not session_id: + return None + + with auth_sessions_lock: + session_row = auth_sessions.get(session_id) + if not isinstance(session_row, dict): + return None + expires_at = float(session_row.get("expires_at", 0.0) or 0.0) + if expires_at <= time.time(): + auth_sessions.pop(session_id, None) + return None + refreshed = dict(session_row) + refreshed["expires_at"] = time.time() + service_obj.auth_session_ttl_s + auth_sessions[session_id] = refreshed + return refreshed + + def _auth_payload(self, session_row: Optional[Dict[str, object]]) -> Dict[str, object]: + service_obj = self._current_service() + authenticated = isinstance(session_row, dict) + role = str(session_row.get("role", "")) if authenticated else "" + is_admin = role == "admin" + if not authenticated and service_obj.auth_enabled: + visible_sections: List[str] = [] + elif role == "user": + visible_sections = [ + "overview", + "frequencies", + "io", + "history", + ] + else: + visible_sections = [ + "overview", + "frequencies", + "io", + "history", + "servers", + "json", + ] + return { + "enabled": bool(service_obj.auth_enabled), + "provider": service_obj.auth_provider, + "authenticated": authenticated, + "username": "" if not authenticated else str(session_row.get("username", "")), + "role": role, + "capabilities": { + "view_result": authenticated or not service_obj.auth_enabled, + "view_frequencies": is_admin or role == "user" or not service_obj.auth_enabled, + "admin": is_admin or not service_obj.auth_enabled, + "manage_users": is_admin or not service_obj.auth_enabled, + "manage_system": is_admin or not service_obj.auth_enabled, + }, + "visible_sections": visible_sections, + "admin_console_url": service_obj.keycloak_admin_console_url, + } + + def _require_authenticated(self) -> bool: + service_obj = self._current_service() + if not service_obj.auth_enabled: + return True + if self._current_session() is not None: + return True + self._write_json(401, {"status": "error", "error": "authentication required"}) + return False + + def _require_admin(self) -> bool: + service_obj = self._current_service() + token_matches = self._api_token_authorized() + if token_matches: + return True + if service_obj.write_api_token and not service_obj.auth_enabled: + self._write_json( + 401, + {"status": "error", "error": "unauthorized: missing or invalid API token"}, + ) + return False + if not service_obj.auth_enabled: + return True + session_row = self._current_session() + if session_row is None: + self._write_json(401, {"status": "error", "error": "authentication required"}) + return False + if str(session_row.get("role", "")) != "admin": + self._write_json(403, {"status": "error", "error": "admin role required"}) + return False + return True + + def _issue_session(self, username: str, role: str) -> str: + service_obj = self._current_service() + session_id = secrets.token_urlsafe(32) + with auth_sessions_lock: + auth_sessions[session_id] = { + "username": str(username), + "role": str(role), + "expires_at": time.time() + service_obj.auth_session_ttl_s, + } + return session_id + + def _drop_session(self) -> None: + service_obj = self._current_service() + session_id = self._parse_cookies().get(service_obj.auth_cookie_name, "") + if not session_id: + return + with auth_sessions_lock: + auth_sessions.pop(session_id, None) + + def _session_cookie_headers(self, session_id: str = "", clear: bool = False) -> Dict[str, str]: + service_obj = self._current_service() + if clear: + return { + "Set-Cookie": ( + f"{service_obj.auth_cookie_name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0" + ) + } + return { + "Set-Cookie": ( + f"{service_obj.auth_cookie_name}={session_id}; Path=/; HttpOnly; SameSite=Lax; " + f"Max-Age={service_obj.auth_session_ttl_s}" + ) + } + def _write_bytes( self, status_code: int, @@ -1276,14 +1848,49 @@ def _make_handler(service: AutoService): self.end_headers() self.wfile.write(content) - def _write_json(self, status_code: int, payload: Dict[str, object]) -> None: + def _write_json( + self, + status_code: int, + payload: Dict[str, object], + extra_headers: Optional[Dict[str, str]] = None, + ) -> None: raw = json.dumps(payload, ensure_ascii=False).encode("utf-8") self._write_bytes( status_code=status_code, content=raw, content_type="application/json; charset=utf-8", + extra_headers=extra_headers, ) + def _read_json_body( + self, + *, + max_bytes: int = MAX_CONFIG_BODY_BYTES, + empty_body_is_object: bool = False, + ) -> Tuple[Optional[Dict[str, object]], Optional[str], int]: + try: + content_length = int(self.headers.get("Content-Length", "0")) + except ValueError: + return None, "Invalid Content-Length", 400 + if content_length <= 0: + if empty_body_is_object: + return {}, None, 200 + return None, "Empty request body", 400 + if content_length > max_bytes: + return ( + None, + f"Config payload too large: {content_length} bytes, max is {max_bytes}", + 413, + ) + body = self.rfile.read(content_length) + try: + parsed = json.loads(body.decode("utf-8")) + except json.JSONDecodeError as exc: + return None, f"Invalid JSON: {exc}", 400 + if not isinstance(parsed, dict): + return None, "JSON body must be object", 400 + return parsed, None, 200 + def _write_static(self, relative_path: str) -> None: web_root = Path(__file__).resolve().parent / "web" file_path = (web_root / relative_path).resolve() @@ -1324,6 +1931,7 @@ def _make_handler(service: AutoService): path = parse.urlparse(self.path).path service_obj = self._current_service() snapshot = service_obj.snapshot() + session_row = self._current_session() if path == "/" or path == "/ui": self._write_static("index.html") @@ -1333,6 +1941,10 @@ def _make_handler(service: AutoService): self._write_static(path.removeprefix("/static/")) return + if path == "/auth/session": + self._write_json(200, {"status": "ok", "auth": self._auth_payload(session_row)}) + return + if path == "/health": status = "ok" if snapshot["payload"] else "warming_up" http_code = 200 if status == "ok" else 503 @@ -1347,6 +1959,8 @@ def _make_handler(service: AutoService): return if path == "/result": + if not self._require_authenticated(): + return payload = snapshot["payload"] if payload is None: self._write_json( @@ -1370,6 +1984,8 @@ def _make_handler(service: AutoService): return if path == "/frequencies": + if not self._require_authenticated(): + return payload = snapshot["payload"] if payload is None: self._write_json( @@ -1395,12 +2011,12 @@ def _make_handler(service: AutoService): return if path == "/config": - public_config = json.loads(json.dumps(service_obj.config)) - runtime_obj = public_config.get("runtime") - if isinstance(runtime_obj, dict): - if "write_api_token" in runtime_obj: - runtime_obj["write_api_token"] = "" - runtime_obj["write_api_token_set"] = bool(service_obj.write_api_token) + if service_obj.auth_enabled and not self._require_admin(): + return + public_config = _public_config_view( + config=service_obj.config, + write_api_token_set=bool(service_obj.write_api_token), + ) self._write_json( 200, { @@ -1412,58 +2028,115 @@ def _make_handler(service: AutoService): return if path == "/mock/controls": + if not self._require_authenticated(): + return self._write_json(200, _collect_mock_controls(service_obj)) return + if path == "/users": + if not self._require_admin(): + return + try: + users = _keycloak_list_users(service_obj) + except Exception as exc: + self._write_json(500, {"status": "error", "error": str(exc)}) + return + self._write_json(200, {"status": "ok", "users": users}) + return + self._write_json(404, {"error": "not_found"}) def do_POST(self) -> None: path = parse.urlparse(self.path).path - if not self._is_write_authorized(): - self._write_json( - 401, - {"status": "error", "error": "unauthorized: missing or invalid API token"}, - ) - return - if path == "/config": + if path == "/auth/login": service_obj = self._current_service() - try: - content_length = int(self.headers.get("Content-Length", "0")) - except ValueError: - self._write_json(400, {"status": "error", "error": "Invalid Content-Length"}) + if not service_obj.auth_enabled: + self._write_json(400, {"status": "error", "error": "authentication disabled"}) return - if content_length <= 0: - self._write_json(400, {"status": "error", "error": "Empty request body"}) + payload, read_error, read_status = self._read_json_body() + if read_error: + self._write_json(read_status, {"status": "error", "error": read_error}) return - if content_length > MAX_CONFIG_BODY_BYTES: + username = str(payload.get("username", "")).strip() if payload else "" + password = str(payload.get("password", "")).strip() if payload else "" + if not username or not password: self._write_json( - 413, + 400, + {"status": "error", "error": "username and password are required"}, + ) + return + status_code, token_payload, request_error = _http_json_request( + service_obj.keycloak_token_url(), + method="POST", + form={ + "grant_type": "password", + "client_id": service_obj.keycloak_client_id, + "client_secret": service_obj.keycloak_client_secret, + "username": username, + "password": password, + "scope": "openid profile email", + }, + timeout_s=5.0, + ) + if request_error: + self._write_json( + 502, + {"status": "error", "error": f"Keycloak login failed: {request_error}"}, + ) + return + if status_code < 200 or status_code >= 300: + self._write_json( + 401, { "status": "error", - "error": ( - f"Config payload too large: {content_length} bytes, " - f"max is {MAX_CONFIG_BODY_BYTES}" + "error": str( + token_payload.get("error_description") + or token_payload.get("error") + or "login failed" ), }, ) return - body = self.rfile.read(content_length) if content_length > 0 else b"" - try: - new_config = json.loads(body.decode("utf-8")) - except json.JSONDecodeError as exc: - self._write_json(400, {"status": "error", "error": f"Invalid JSON: {exc}"}) + access_token = str(token_payload.get("access_token", "")).strip() + role = service_obj.role_from_token(access_token) + if not role: + self._write_json( + 403, + { + "status": "error", + "error": "user does not have required Keycloak role", + }, + ) return - if not isinstance(new_config, dict): - self._write_json(400, {"status": "error", "error": "Config must be JSON object"}) + session_id = self._issue_session(username=username, role=role) + session_row = {"username": username, "role": role} + self._write_json( + 200, + {"status": "ok", "auth": self._auth_payload(session_row)}, + extra_headers=self._session_cookie_headers(session_id=session_id), + ) + return + + if path == "/auth/logout": + self._drop_session() + self._write_json( + 200, + {"status": "ok"}, + extra_headers=self._session_cookie_headers(clear=True), + ) + return + + if path == "/config": + if not self._require_admin(): + return + service_obj = self._current_service() + new_config, read_error, read_status = self._read_json_body() + if read_error: + self._write_json(read_status, {"status": "error", "error": read_error}) return - # Avoid accidental token wipe when /config GET response is redacted in clients. - runtime_obj = new_config.get("runtime") - if isinstance(runtime_obj, dict) and service_obj.write_api_token: - incoming_token = str(runtime_obj.get("write_api_token", "")).strip() - if not incoming_token: - runtime_obj["write_api_token"] = service_obj.write_api_token + _preserve_sensitive_config_values(service_obj, new_config) try: new_service = AutoService(new_config, config_path=service_obj.config_path) @@ -1520,20 +2193,12 @@ def _make_handler(service: AutoService): return if path == "/mock/control": - service_obj = self._current_service() - try: - content_length = int(self.headers.get("Content-Length", "0")) - except ValueError: - self._write_json(400, {"status": "error", "error": "Invalid Content-Length"}) - return - body = self.rfile.read(content_length) if content_length > 0 else b"{}" - try: - payload = json.loads(body.decode("utf-8")) - except json.JSONDecodeError: - self._write_json(400, {"status": "error", "error": "Invalid JSON"}) + if not self._require_admin(): return - if not isinstance(payload, dict): - self._write_json(400, {"status": "error", "error": "JSON body must be object"}) + service_obj = self._current_service() + payload, read_error, read_status = self._read_json_body(empty_body_is_object=True) + if read_error: + self._write_json(read_status, {"status": "error", "error": read_error}) return target = str(payload.get("target", "")).strip().lower() target_id = str(payload.get("id", "")).strip() @@ -1606,10 +2271,115 @@ def _make_handler(service: AutoService): self._write_json(200, response) return + if path == "/users": + if not self._require_admin(): + return + service_obj = self._current_service() + payload, read_error, read_status = self._read_json_body() + if read_error: + self._write_json(read_status, {"status": "error", "error": read_error}) + return + + action = str(payload.get("action", "")).strip().lower() + username = str(payload.get("username", "")).strip() + role = str(payload.get("role", "user")).strip().lower() + enabled = bool(payload.get("enabled", True)) + password = str(payload.get("password", "")).strip() + first_name = str(payload.get("first_name", "")).strip() + last_name = str(payload.get("last_name", "")).strip() + user_id = str(payload.get("user_id", "")).strip() + + if action not in ("create", "update", "set_password", "delete"): + self._write_json(400, {"status": "error", "error": "unsupported user action"}) + return + if not user_id and username: + user_id = _keycloak_get_user_id_by_username(service_obj, username) or "" + + try: + if action == "create": + if not username or not password: + raise ValueError("username and password are required") + if role not in ("admin", "user"): + raise ValueError("role must be admin or user") + status_code, _, request_error = _keycloak_admin_request( + service_obj, + "/users", + method="POST", + json_body={ + "username": username, + "enabled": enabled, + "firstName": first_name, + "lastName": last_name, + }, + ) + if request_error: + raise RuntimeError(request_error) + if status_code < 200 or status_code >= 300: + raise RuntimeError("failed to create user in Keycloak") + user_id = _keycloak_get_user_id_by_username(service_obj, username) or "" + if not user_id: + raise RuntimeError("created user not found in Keycloak") + _keycloak_reset_password(service_obj, user_id, password) + _keycloak_apply_user_role(service_obj, user_id, role) + elif action == "update": + if not user_id: + raise ValueError("user_id or username is required") + if role not in ("admin", "user"): + raise ValueError("role must be admin or user") + status_code, _, request_error = _keycloak_admin_request( + service_obj, + f"/users/{parse.quote(user_id)}", + method="PUT", + json_body={ + "enabled": enabled, + "firstName": first_name, + "lastName": last_name, + }, + ) + if request_error or status_code < 200 or status_code >= 300: + raise RuntimeError("failed to update user in Keycloak") + _keycloak_apply_user_role(service_obj, user_id, role) + elif action == "set_password": + if not user_id: + raise ValueError("user_id or username is required") + if not password: + raise ValueError("password is required") + _keycloak_reset_password(service_obj, user_id, password) + elif action == "delete": + if not user_id: + raise ValueError("user_id or username is required") + status_code, _, request_error = _keycloak_admin_request( + service_obj, + f"/users/{parse.quote(user_id)}", + method="DELETE", + ) + if request_error or status_code < 200 or status_code >= 300: + raise RuntimeError("failed to delete user in Keycloak") + except Exception as exc: + self._write_json(400, {"status": "error", "error": str(exc)}) + return + + try: + users = _keycloak_list_users(service_obj) + except Exception as exc: + self._write_json( + 200, + { + "status": "ok", + "message": f"action completed, but refresh failed: {exc}", + "users": [], + }, + ) + return + self._write_json(200, {"status": "ok", "users": users}) + return + if path != "/refresh": self._write_json(404, {"error": "not_found"}) return + if not self._require_admin(): + return try: self._current_service().refresh_once() except Exception as exc: diff --git a/test_service_integration.py b/test_service_integration.py index b625a36..2d4ab0a 100644 --- a/test_service_integration.py +++ b/test_service_integration.py @@ -1,5 +1,6 @@ import json import threading +import base64 from typing import Any, Dict, List from urllib import error, request as urllib_request @@ -23,6 +24,21 @@ class _FakeResponse: return None +def _jwt_for_role(role_name: str, username: str = "viewer") -> str: + def _seg(payload: Dict[str, object]) -> str: + raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=") + + header = _seg({"alg": "none", "typ": "JWT"}) + payload = _seg( + { + "preferred_username": username, + "realm_access": {"roles": [role_name]}, + } + ) + return f"{header}.{payload}.signature" + + def _base_config() -> Dict[str, object]: return { "model": { @@ -69,6 +85,28 @@ def _base_config() -> Dict[str, object]: } +def _auth_config() -> Dict[str, object]: + config = _base_config() + config["auth"] = { + "enabled": True, + "provider": "keycloak", + "session_ttl_s": 3600, + "cookie_name": "triangulation_session", + "keycloak": { + "base_url": "http://keycloak.local", + "realm": "triangulation", + "client_id": "triangulation-ui", + "client_secret": "ui-secret", + "admin_client_id": "triangulation-admin", + "admin_client_secret": "admin-secret", + "user_role": "triangulation_user", + "admin_role": "triangulation_admin", + "admin_console_url": "http://keycloak.local/admin/", + }, + } + return config + + def _install_urlopen(monkeypatch: pytest.MonkeyPatch, responses: Dict[str, object]) -> None: def _fake_urlopen(req: Any, timeout: float = 0.0): url = getattr(req, "full_url", str(req)) @@ -866,3 +904,227 @@ def test_receiver_configured_frequencies_limit_trilateration(monkeypatch: pytest assert payload is not None assert len(payload["frequency_table"]) == 1 assert payload["frequency_table"][0]["frequency_hz"] == pytest.approx(915e6) + + +def test_http_keycloak_login_creates_user_session(monkeypatch: pytest.MonkeyPatch): + config = _auth_config() + svc = service.AutoService(config) + svc.latest_payload = { + "selected_frequency_hz": 915e6, + "selected_frequency_mhz": 915.0, + "position": {"x": 1.0, "y": 2.0, "z": 3.0}, + "rmse_m": 0.12, + "frequency_table": [], + } + svc.updated_at_utc = "2026-03-16T12:00:00+00:00" + svc.last_error = "" + + def _fake_http_json_request(url: str, method: str = "GET", payload=None, form=None, headers=None, timeout_s: float = 2.0): + assert method == "POST" + assert form is not None + if form.get("username") == "viewer" and form.get("password") == "viewer123": + return 200, {"access_token": _jwt_for_role("triangulation_user", "viewer")}, "" + return 401, {"error": "invalid_grant"}, "" + + monkeypatch.setattr(service, "_http_json_request", _fake_http_json_request) + http_server, thread, base_url = _start_api_server_for_test(svc) + + try: + login_req = urllib_request.Request( + url=f"{base_url}/auth/login", + method="POST", + data=json.dumps({"username": "viewer", "password": "viewer123"}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + with urllib_request.urlopen(login_req) as response: + payload = json.loads(response.read().decode("utf-8")) + cookie = response.headers.get("Set-Cookie", "") + assert payload["auth"]["authenticated"] is True + assert payload["auth"]["role"] == "user" + assert "triangulation_session=" in cookie + + session_req = urllib_request.Request( + url=f"{base_url}/auth/session", + headers={"Cookie": cookie.split(";", 1)[0]}, + ) + with urllib_request.urlopen(session_req) as response: + session_payload = json.loads(response.read().decode("utf-8")) + assert session_payload["auth"]["authenticated"] is True + assert session_payload["auth"]["visible_sections"] == [ + "overview", + "frequencies", + "io", + "history", + ] + + result_req = urllib_request.Request( + url=f"{base_url}/result", + headers={"Cookie": cookie.split(";", 1)[0]}, + ) + with urllib_request.urlopen(result_req) as response: + result_payload = json.loads(response.read().decode("utf-8")) + assert result_payload["status"] == "ok" + assert result_payload["data"]["position"]["x"] == pytest.approx(1.0) + finally: + http_server.shutdown() + http_server.server_close() + thread.join(timeout=1.0) + + +def test_http_keycloak_user_can_view_status_but_not_admin_config(monkeypatch: pytest.MonkeyPatch): + config = _auth_config() + svc = service.AutoService(config) + + monkeypatch.setattr( + service, + "_http_json_request", + lambda *args, **kwargs: (200, {"access_token": _jwt_for_role("triangulation_user", "viewer")}, ""), + ) + http_server, thread, base_url = _start_api_server_for_test(svc) + + try: + login_req = urllib_request.Request( + url=f"{base_url}/auth/login", + method="POST", + data=json.dumps({"username": "viewer", "password": "viewer123"}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + with urllib_request.urlopen(login_req) as response: + cookie = response.headers.get("Set-Cookie", "") + + config_req = urllib_request.Request( + url=f"{base_url}/config", + headers={"Cookie": cookie.split(";", 1)[0]}, + ) + with pytest.raises(error.HTTPError) as exc_info: + urllib_request.urlopen(config_req) + assert exc_info.value.code == 403 + + controls_req = urllib_request.Request( + url=f"{base_url}/mock/controls", + headers={"Cookie": cookie.split(";", 1)[0]}, + ) + with urllib_request.urlopen(controls_req) as response: + controls_payload = json.loads(response.read().decode("utf-8")) + assert "inputs" in controls_payload + assert "outputs" in controls_payload + + users_req = urllib_request.Request( + url=f"{base_url}/users", + headers={"Cookie": cookie.split(";", 1)[0]}, + ) + with pytest.raises(error.HTTPError) as exc_info: + urllib_request.urlopen(users_req) + assert exc_info.value.code == 403 + finally: + http_server.shutdown() + http_server.server_close() + thread.join(timeout=1.0) + + +def test_http_keycloak_admin_can_open_redacted_config(monkeypatch: pytest.MonkeyPatch): + config = _auth_config() + svc = service.AutoService(config) + + monkeypatch.setattr( + service, + "_http_json_request", + lambda *args, **kwargs: (200, {"access_token": _jwt_for_role("triangulation_admin", "admin_ui")}, ""), + ) + http_server, thread, base_url = _start_api_server_for_test(svc) + + try: + login_req = urllib_request.Request( + url=f"{base_url}/auth/login", + method="POST", + data=json.dumps({"username": "admin_ui", "password": "admin123"}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + with urllib_request.urlopen(login_req) as response: + cookie = response.headers.get("Set-Cookie", "") + + config_req = urllib_request.Request( + url=f"{base_url}/config", + headers={"Cookie": cookie.split(";", 1)[0]}, + ) + with urllib_request.urlopen(config_req) as response: + payload = json.loads(response.read().decode("utf-8")) + assert payload["config"]["auth"]["keycloak"]["client_secret"] == "" + assert payload["config"]["auth"]["keycloak"]["admin_client_secret"] == "" + assert payload["config"]["auth"]["keycloak"]["client_secret_set"] is True + assert payload["config"]["auth"]["keycloak"]["admin_client_secret_set"] is True + finally: + http_server.shutdown() + http_server.server_close() + thread.join(timeout=1.0) + + +def test_http_keycloak_admin_can_list_users(monkeypatch: pytest.MonkeyPatch): + config = _auth_config() + svc = service.AutoService(config) + + monkeypatch.setattr( + service, + "_http_json_request", + lambda *args, **kwargs: (200, {"access_token": _jwt_for_role("triangulation_admin", "admin_ui")}, ""), + ) + monkeypatch.setattr(service.AutoService, "get_admin_access_token", lambda self: "admin-token") + + def _fake_keycloak_admin_request( + svc: service.AutoService, + path: str, + method: str = "GET", + payload=None, + json_body=None, + timeout_s: float = 5.0, + ): + if path == "/users?max=200": + return 200, [ + { + "id": "u1", + "username": "admin_ui", + "enabled": True, + "firstName": "System", + "lastName": "Admin", + }, + { + "id": "u2", + "username": "viewer", + "enabled": True, + "firstName": "Read", + "lastName": "Only", + }, + ], "" + if path == "/users/u1/role-mappings/realm": + return 200, [{"name": "triangulation_admin"}], "" + if path == "/users/u2/role-mappings/realm": + return 200, [{"name": "triangulation_user"}], "" + raise AssertionError(f"Unexpected path: {path}") + + monkeypatch.setattr(service, "_keycloak_admin_request", _fake_keycloak_admin_request) + http_server, thread, base_url = _start_api_server_for_test(svc) + + try: + login_req = urllib_request.Request( + url=f"{base_url}/auth/login", + method="POST", + data=json.dumps({"username": "admin_ui", "password": "admin123"}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + with urllib_request.urlopen(login_req) as response: + cookie = response.headers.get("Set-Cookie", "") + + users_req = urllib_request.Request( + url=f"{base_url}/users", + headers={"Cookie": cookie.split(";", 1)[0]}, + ) + with urllib_request.urlopen(users_req) as response: + payload = json.loads(response.read().decode("utf-8")) + assert payload["status"] == "ok" + assert [row["username"] for row in payload["users"]] == ["admin_ui", "viewer"] + assert payload["users"][0]["role"] == "admin" + assert payload["users"][1]["role"] == "user" + finally: + http_server.shutdown() + http_server.server_close() + thread.join(timeout=1.0) diff --git a/web/app.js b/web/app.js index 430b4d0..8defcc7 100644 --- a/web/app.js +++ b/web/app.js @@ -3,6 +3,8 @@ frequencies: null, health: null, config: null, + auth: null, + users: [], writeToken: "", activeSection: "overview", selectedReceiverIndex: 0, @@ -39,6 +41,7 @@ lastDeliveryStatus: "n/a", timezone: "local", uiDensity: "detailed", + uiTheme: "aurora", }; const HZ_IN_MHZ = 1_000_000; @@ -47,12 +50,14 @@ const MENU_GROUP_COLLAPSED_STORAGE_KEY = "triangulation.menu_group_collapsed"; const DATE_TIME_COLLAPSED_STORAGE_KEY = "triangulation.date_time_collapsed"; const TIMEZONE_STORAGE_KEY = "triangulation.timezone"; const UI_DENSITY_STORAGE_KEY = "triangulation.ui_density"; +const UI_THEME_STORAGE_KEY = "triangulation.ui_theme"; const AUTO_REFRESH_INTERVAL_STORAGE_KEY = "triangulation.auto_refresh_interval_ms"; const AUTO_REFRESH_MIN_MS = 1_000; const AUTO_REFRESH_MAX_MS = 120_000; const AUTO_REFRESH_DEFAULT_MS = 2_000; const IO_HISTORY_LIMIT = 60; const MENU_GROUP_KEYS = ["monitoring", "io", "config"]; +const UI_THEME_OPTIONS = ["aurora", "signal", "slate"]; const TIMEZONE_OPTIONS = [ { value: "local", label: "Локальный (браузер)" }, { value: "UTC", label: "UTC" }, @@ -108,6 +113,15 @@ function localizeStatus(value) { return mapping[status] || status; } +function localizeThemeName(value) { + const mapping = { + aurora: "свет", + signal: "сигнал", + slate: "графит", + }; + return mapping[String(value || "").toLowerCase()] || "свет"; +} + function localizeErrorMessage(message) { const text = String(message || "неизвестная ошибка"); const known = { @@ -115,6 +129,10 @@ function localizeErrorMessage(message) { "at least 1 output server is required": "необходим минимум 1 выходной сервер", Unauthorized: "доступ запрещён (проверьте токен)", "unauthorized: missing or invalid API token": "доступ запрещён: отсутствует или неверный API-токен", + "authentication required": "требуется вход в систему", + "admin role required": "требуются права администратора", + "username and password are required": "нужны логин и пароль", + "user does not have required Keycloak role": "у пользователя нет нужной роли Keycloak", warming_up: "прогрев", not_found: "не найдено", "no data yet": "данные пока не получены", @@ -198,6 +216,57 @@ function authHeaders() { }; } +function authEnabled() { + return Boolean(state.auth?.enabled); +} + +function isAuthenticated() { + return Boolean(state.auth?.authenticated) || !authEnabled(); +} + +function isAdmin() { + return String(state.auth?.role || "") === "admin" || !authEnabled(); +} + +function visibleSections() { + if (!authEnabled()) { + return ["overview", "frequencies", "io", "history", "servers", "json"]; + } + const sections = Array.isArray(state.auth?.visible_sections) ? state.auth.visible_sections : []; + return sections.length > 0 ? sections : []; +} + +function applyServerReadOnlyMode() { + const readOnly = !isAdmin(); + [ + "rx-id", + "rx-url", + "rx-frequencies", + "rx-center-x", + "rx-center-y", + "rx-center-z", + "shared-filter-enabled", + "shared-min-freq", + "shared-max-freq", + "shared-min-rssi", + "shared-max-rssi", + "out-name", + "out-ip", + "write-token", + ].forEach((id) => { + const el = byId(id); + if (!el) return; + el.disabled = readOnly; + if ("readOnly" in el && el.tagName === "INPUT") { + el.readOnly = readOnly; + } + }); + const stateBadge = byId("servers-state"); + if (stateBadge && readOnly) { + stateBadge.textContent = "узлы: только просмотр"; + } +} + function setActiveSection(section) { state.activeSection = section; document.querySelectorAll(".panel").forEach((panel) => { @@ -212,6 +281,102 @@ function setActiveSection(section) { } } +function ensureValidActiveSection() { + const allowed = visibleSections(); + if (allowed.includes(state.activeSection)) return; + state.activeSection = allowed[0] || "overview"; +} + +function applySectionAccess() { + const allowed = new Set(visibleSections()); + document.querySelectorAll(".menu-item").forEach((item) => { + const section = String(item.dataset.section || ""); + item.hidden = !allowed.has(section); + }); + document.querySelectorAll(".panel").forEach((panel) => { + const section = panel.id.replace("section-", ""); + panel.hidden = !allowed.has(section); + }); + const usersCard = byId("users-card"); + if (usersCard) { + usersCard.hidden = !isAdmin(); + } + document.querySelectorAll(".menu-group").forEach((group) => { + const hasVisibleItems = Array.from(group.querySelectorAll(".menu-item")).some((item) => !item.hidden); + group.hidden = !hasVisibleItems; + }); + const configGroup = document.querySelector('.menu-group[data-menu-group="config"]'); + if (configGroup && !isAdmin()) { + configGroup.hidden = true; + } + ensureValidActiveSection(); + setActiveSection(state.activeSection); +} + +function renderAuthUi() { + const overlay = byId("auth-overlay"); + const userChip = byId("auth-user-chip"); + const roleChip = byId("auth-role-chip"); + const logoutButton = byId("logout-button"); + const loginState = byId("login-state"); + const shell = byId("app-shell"); + const body = document.body; + + if (body) { + body.classList.remove("role-admin", "role-user", "role-guest"); + if (isAdmin()) { + body.classList.add("role-admin"); + } else if (isAuthenticated()) { + body.classList.add("role-user"); + } else { + body.classList.add("role-guest"); + } + } + + if (userChip) { + userChip.textContent = `пользователь: ${state.auth?.username || "гость"}`; + } + if (roleChip) { + roleChip.textContent = `роль: ${state.auth?.role || "-"}`; + } + if (logoutButton) { + logoutButton.hidden = !isAuthenticated() || !authEnabled(); + } + if (overlay) { + const shouldShow = authEnabled() && !isAuthenticated(); + overlay.classList.toggle("auth-overlay-hidden", !shouldShow); + } + if (shell) { + shell.classList.toggle("app-shell-locked", authEnabled() && !isAuthenticated()); + } + [ + "load-config", + "save-config", + "load-servers", + "save-servers", + "add-receiver", + "remove-receiver", + "add-output-server", + "remove-output-server", + ].forEach((id) => { + const el = byId(id); + if (el) el.hidden = !isAdmin(); + }); + const ioAdminControls = byId("io-admin-controls"); + if (ioAdminControls) { + ioAdminControls.hidden = !isAdmin(); + } + const historyAdminActions = byId("history-admin-actions"); + if (historyAdminActions) { + historyAdminActions.hidden = !isAdmin(); + } + if (loginState && authEnabled() && !isAuthenticated()) { + loginState.textContent = "авторизация: требуется вход"; + } + applySectionAccess(); + applyServerReadOnlyMode(); +} + function setMenuCollapsed(isCollapsed) { state.menuCollapsed = Boolean(isCollapsed); const sideNav = byId("side-nav"); @@ -320,8 +485,8 @@ function setDateTimeCollapsed(isCollapsed) { } if (toggle) { toggle.textContent = state.dateTimeCollapsed - ? "Показать служебную панель" - : "Скрыть служебную панель"; + ? "Показать панель" + : "Скрыть панель"; toggle.setAttribute("aria-expanded", String(!state.dateTimeCollapsed)); } @@ -373,6 +538,27 @@ function normalizeUiDensity(value) { return String(value || "").toLowerCase() === "compact" ? "compact" : "detailed"; } +function normalizeUiTheme(value) { + const next = String(value || "").toLowerCase(); + return UI_THEME_OPTIONS.includes(next) ? next : "aurora"; +} + +function saveUiThemePreference(value) { + try { + localStorage.setItem(UI_THEME_STORAGE_KEY, normalizeUiTheme(value)); + } catch { + // Ignore localStorage errors in restricted environments. + } +} + +function readUiThemePreference() { + try { + return normalizeUiTheme(localStorage.getItem(UI_THEME_STORAGE_KEY) || "aurora"); + } catch { + return "aurora"; + } +} + function saveUiDensityPreference(value) { try { localStorage.setItem(UI_DENSITY_STORAGE_KEY, normalizeUiDensity(value)); @@ -394,10 +580,20 @@ function applyUiDensity() { document.body.classList.toggle("ui-compact", compact); const toggle = byId("density-toggle"); if (toggle) { - toggle.textContent = compact ? "Режим: компактный" : "Режим: детальный"; + toggle.textContent = compact ? "Вид: компактный" : "Вид: детальный"; } } +function applyUiTheme() { + const nextTheme = normalizeUiTheme(state.uiTheme); + document.body.dataset.theme = nextTheme; + document.querySelectorAll(".theme-chip").forEach((button) => { + const isActive = normalizeUiTheme(button.dataset.themeValue) === nextTheme; + button.classList.toggle("theme-chip-active", isActive); + button.setAttribute("aria-pressed", String(isActive)); + }); +} + function setUiDensity(value, options = {}) { const { persist = true } = options; state.uiDensity = normalizeUiDensity(value); @@ -407,6 +603,15 @@ function setUiDensity(value, options = {}) { } } +function setUiTheme(value, options = {}) { + const { persist = true } = options; + state.uiTheme = normalizeUiTheme(value); + applyUiTheme(); + if (persist) { + saveUiThemePreference(state.uiTheme); + } +} + function normalizePollIntervalMs(valueMs) { const numeric = Number(valueMs); if (!Number.isFinite(numeric)) return AUTO_REFRESH_DEFAULT_MS; @@ -631,13 +836,13 @@ function renderInputFlow(data) { if (!root) return; const receivers = Array.isArray(data?.receivers) ? data.receivers : []; if (receivers.length === 0) { - root.innerHTML = '
Ожидание входных измерений от ресиверов.
'; + root.innerHTML = '
Ожидание данных от приемников.
'; return; } root.innerHTML = receivers .map((receiver) => { - const receiverId = escapeHtml(receiver?.receiver_id || "n/a"); + const receiverId = escapeHtml(receiver?.receiver_id || "н/д"); const sourceUrl = escapeHtml(receiver?.source_url || "-"); const center = receiver?.center || {}; const samples = Array.isArray(receiver?.samples) ? receiver.samples : []; @@ -741,7 +946,7 @@ function renderOutputFlow(data, delivery) { return `
-

${escapeHtml(server?.name || "output")}

+

${escapeHtml(server?.name || "выход")}

${escapeHtml(status)}
@@ -768,7 +973,7 @@ function buildIoHistoryRow(data, delivery) { const rssiValues = []; const inputItems = receivers.map((receiver) => { - const receiverId = String(receiver?.receiver_id || "n/a"); + const receiverId = String(receiver?.receiver_id || "н/д"); const sample = findByFrequency(receiver?.samples, selectedHz) || receiver?.samples?.[0] || null; const perFrequency = findByFrequency(receiver?.per_frequency, selectedHz) || receiver?.per_frequency?.[0] || null; @@ -790,7 +995,7 @@ function buildIoHistoryRow(data, delivery) { return [`X=${fmt(pos?.x, 3)} Y=${fmt(pos?.y, 3)} Z=${fmt(pos?.z, 3)}`]; } return servers.map((server) => { - const name = String(server?.name || "output"); + const name = String(server?.name || "выход"); const status = localizeStatus(server?.status); const code = server?.http_status ?? "-"; return `${name}: ${status} (${code})`; @@ -1108,13 +1313,13 @@ function renderOverviewSignalGrid(receivers, selectedHz) { const root = byId("ov-signal-grid"); if (!root) return; if (!Array.isArray(receivers) || receivers.length === 0) { - root.innerHTML = '
Сигналы ресиверов пока не получены.
'; + root.innerHTML = '
Сигналы от приемников еще не получены.
'; return; } root.innerHTML = receivers .map((receiver) => { - const receiverId = String(receiver?.receiver_id || "n/a"); + const receiverId = String(receiver?.receiver_id || "н/д"); const sample = findByFrequency(receiver?.samples, selectedHz) || receiver?.samples?.[0] || null; const row = findByFrequency(receiver?.per_frequency, selectedHz) || receiver?.per_frequency?.[0] || null; @@ -1188,28 +1393,28 @@ function renderOverviewPipeline(summary) { const stages = [ { - name: "Сбор входов", + name: "Прием", status: inputStatus, value: `${summary.inputOnline}/${summary.inputTotal}`, - note: "доступность входных серверов", + note: "доступность приемников", }, { name: "Решение", status: solveStatus, value: hasData ? `RMSE ${fmt(summary.data?.rmse_m, 2)} м` : "ожидание данных", - note: "оценка точности пересечения сфер", + note: "расчет координат", }, { - name: "Доставка", + name: "Отправка", status: summary.delivery?.status || "warming_up", value: `${summary.outputOnline}/${summary.outputTotal}`, - note: "доступность выходных серверов", + note: "доступность серверов отправки", }, { - name: "История", + name: "Журнал", status: summary.total > 0 ? (summary.success >= 80 ? "ok" : "partial") : "warming_up", value: `${summary.success}%`, - note: "успешность доставки по накопленной ленте", + note: "успех по накопленным событиям", }, ]; @@ -1225,7 +1430,7 @@ function renderOverviewPipeline(summary) { ${escapeHtml(localizeStatus(stage.status))}
- +
${escapeHtml(stage.value)} • ${escapeHtml(stage.note)}
@@ -1343,9 +1548,9 @@ function renderHistoryInsights() { monitorRoot.innerHTML = `
Сервис${escapeHtml(healthStatus)}
-
Доставка${escapeHtml(deliveryStatus)}
-
Входы online${summary.inputOnline}/${summary.inputTotal}
-
Выходы online${summary.outputOnline}/${summary.outputTotal}
+
Отправка${escapeHtml(deliveryStatus)}
+
Входы онлайн${summary.inputOnline}/${summary.inputTotal}
+
Выходы онлайн${summary.outputOnline}/${summary.outputTotal}
Проблемных событий${problemCount}
Успех доставки${summary.success}%
@@ -1470,7 +1675,7 @@ function renderIoHistory() { } if (filtered.length === 0) { - let text = "История пока пуста."; + let text = "Журнал пока пуст."; if (state.ioHistory.length > 0) { if (hasDateFilter && state.historyFilter !== "all") { text = "По статусу и диапазону времени записей нет."; @@ -1532,7 +1737,7 @@ function boolStateLabel(value, trueLabel, falseLabel) { function buildControlCardHtml(item, target) { const id = String(item?.id || ""); - const name = escapeHtml(item?.name || id || "n/a"); + const name = escapeHtml(item?.name || id || "н/д"); const reachable = Boolean(item?.reachable); const errorText = item?.error ? escapeHtml(item.error) : ""; const stateValue = target === "input" ? item?.enabled : item?.accept_writes; @@ -1554,7 +1759,7 @@ function buildControlCardHtml(item, target) { const statusKind = !reachable && stateValue === null ? "io-status-error" : isActive ? "io-status-ok" : "io-status-skipped"; const reachabilityKind = reachable ? "io-status-ok" : "io-status-error"; - const reachabilityText = reachable ? "online" : "offline"; + const reachabilityText = reachable ? "онлайн" : "офлайн"; const disabledAttr = !id ? "disabled" : ""; const liveFrequencies = Array.isArray(item?.frequencies_mhz) ? item.frequencies_mhz.map((value) => fmt(Number(value), 3)).join(", ") @@ -1634,26 +1839,26 @@ function renderErrorControls() { const inputHtml = inputs.length === 0 - ? '
Входные источники не обнаружены.
' + ? '
Приемники не найдены.
' : inputs.map((item) => buildControlCardHtml(item, "input")).join(""); const outputHtml = outputs.length === 0 - ? '
Выходные серверы не обнаружены.
' + ? '
Серверы отправки не найдены.
' : outputs.map((item) => buildControlCardHtml(item, "output")).join(""); root.innerHTML = `
-

Входные Потоки

+

Прием

${inputActiveCount}/${inputs.length} активны
${inputHtml}
-

Выходные Потоки

+

Отправка

${outputActiveCount}/${outputs.length} активны
${outputHtml}
@@ -1761,7 +1966,7 @@ function addReceiverDraft() { function removeReceiverDraft() { if (state.receiverDrafts.length <= 3) { - byId("servers-state").textContent = "серверы: необходимо минимум 3 входа"; + byId("servers-state").textContent = "узлы: нужно минимум 3 приемника"; return; } state.receiverDrafts.splice(state.selectedReceiverIndex, 1); @@ -1833,7 +2038,7 @@ function addOutputDraft() { function removeOutputDraft() { if (state.outputDrafts.length <= 1) { - byId("servers-state").textContent = "серверы: необходим минимум 1 выход"; + byId("servers-state").textContent = "узлы: нужен минимум 1 сервер отправки"; return; } state.outputDrafts.splice(state.selectedOutputIndex, 1); @@ -1846,6 +2051,10 @@ async function getJson(url) { const res = await fetch(url); const data = await res.json().catch(() => ({})); if (!res.ok) { + if (res.status === 401 && authEnabled() && url !== "/auth/session") { + state.auth = { ...(state.auth || {}), authenticated: false, username: "", role: "", visible_sections: [] }; + renderAuthUi(); + } throw new Error(data.error || data.status || `HTTP ${res.status}`); } return data; @@ -1859,11 +2068,141 @@ async function postJson(url, payload) { }); const data = await res.json().catch(() => ({})); if (!res.ok) { + if (res.status === 401 && authEnabled() && url !== "/auth/login") { + state.auth = { ...(state.auth || {}), authenticated: false, username: "", role: "", visible_sections: [] }; + renderAuthUi(); + } throw new Error(data.error || data.status || `HTTP ${res.status}`); } return data; } +function clearUserForm() { + const ids = [ + "user-id", + "user-username", + "user-first-name", + "user-last-name", + "user-password", + ]; + ids.forEach((id) => { + const input = byId(id); + if (input) input.value = ""; + }); + const userRole = byId("user-role"); + if (userRole) userRole.value = "user"; + const userEnabled = byId("user-enabled"); + if (userEnabled) userEnabled.value = "true"; +} + +function fillUserForm(user) { + byId("user-id").value = user?.id || ""; + byId("user-username").value = user?.username || ""; + byId("user-first-name").value = user?.first_name || ""; + byId("user-last-name").value = user?.last_name || ""; + byId("user-role").value = user?.role || "user"; + byId("user-enabled").value = String(Boolean(user?.enabled)); + byId("user-password").value = ""; +} + +function renderUsers() { + const tbody = byId("users-table")?.querySelector("tbody"); + if (!tbody) return; + tbody.innerHTML = (state.users || []) + .map( + (user) => ` + + ${escapeHtml(user.username || "")} + ${escapeHtml(user.role || "user")} + ${user.enabled ? "включён" : "отключён"} + ${escapeHtml([user.first_name, user.last_name].filter(Boolean).join(" ") || "-")} + ` + ) + .join(""); + tbody.querySelectorAll("tr").forEach((row) => { + row.addEventListener("click", () => { + const userId = row.dataset.userId; + const user = state.users.find((item) => String(item.id) === String(userId)); + if (user) fillUserForm(user); + }); + }); +} + +async function loadUsers() { + if (!isAdmin()) return; + try { + const payload = await getJson("/users"); + state.users = Array.isArray(payload.users) ? payload.users : []; + renderUsers(); + byId("users-state").textContent = `пользователи: ${state.users.length}`; + } catch (err) { + byId("users-state").textContent = `пользователи: ${localizeErrorMessage(err.message)}`; + } +} + +async function submitUserAction(action) { + if (!isAdmin()) return; + const payload = { + action, + user_id: byId("user-id").value.trim(), + username: byId("user-username").value.trim(), + first_name: byId("user-first-name").value.trim(), + last_name: byId("user-last-name").value.trim(), + role: byId("user-role").value, + enabled: byId("user-enabled").value === "true", + password: byId("user-password").value, + }; + try { + const response = await postJson("/users", payload); + state.users = Array.isArray(response.users) ? response.users : state.users; + renderUsers(); + byId("users-state").textContent = "пользователи: сохранено"; + if (action !== "update") clearUserForm(); + } catch (err) { + byId("users-state").textContent = `пользователи: ${localizeErrorMessage(err.message)}`; + } +} + +async function loadAuthSession() { + const payload = await getJson("/auth/session"); + state.auth = payload.auth || null; + renderAuthUi(); +} + +async function login() { + const username = byId("login-username").value.trim(); + const password = byId("login-password").value; + try { + const payload = await postJson("/auth/login", { username, password }); + state.auth = payload.auth || null; + byId("login-password").value = ""; + byId("login-state").textContent = "авторизация: выполнена"; + renderAuthUi(); + await loadAll(); + if (isAdmin()) { + await loadConfig(); + await loadUsers(); + } + } catch (err) { + byId("login-state").textContent = `авторизация: ${localizeErrorMessage(err.message)}`; + } +} + +async function logout() { + try { + await postJson("/auth/logout", {}); + } catch { + // Ignore local logout errors and still reset the client state. + } + state.auth = authEnabled() + ? { ...(state.auth || {}), authenticated: false, username: "", role: "", visible_sections: [] } + : null; + state.config = null; + state.users = []; + renderUsers(); + renderAuthUi(); +} + function render() { const data = state.result?.data; const delivery = state.result?.output_delivery || state.frequencies?.output_delivery; @@ -1920,39 +2259,47 @@ function render() { } async function loadAll() { - const [healthRes, resultRes, freqRes, controlsRes] = await Promise.allSettled([ - getJson("/health"), - getJson("/result"), - getJson("/frequencies"), - getJson("/mock/controls"), - ]); - state.health = healthRes.status === "fulfilled" ? healthRes.value : { status: "error" }; - state.result = resultRes.status === "fulfilled" ? resultRes.value : null; - state.frequencies = freqRes.status === "fulfilled" ? freqRes.value : null; - state.mockControls = controlsRes.status === "fulfilled" ? controlsRes.value : null; + const requests = [getJson("/health")]; + if (isAuthenticated()) { + requests.push(getJson("/result")); + requests.push(getJson("/frequencies")); + requests.push(getJson("/mock/controls")); + } + const results = await Promise.allSettled(requests); + state.health = results[0]?.status === "fulfilled" ? results[0].value : { status: "error" }; + state.result = results[1]?.status === "fulfilled" ? results[1].value : null; + state.frequencies = results[2]?.status === "fulfilled" ? results[2].value : null; + state.mockControls = results[3]?.status === "fulfilled" ? results[3].value : null; render(); } async function refreshNow() { + if (!isAdmin()) { + await loadAll(); + return; + } await postJson("/refresh", {}); await loadAll(); } async function loadConfig() { + if (!isAdmin()) return; try { const config = await getJson("/config"); state.config = config.config || null; byId("config-editor").value = JSON.stringify(config.config, null, 2); fillServerForm(); byId("config-state").textContent = "конфиг: загружен"; - byId("servers-state").textContent = "серверы: загружены"; + byId("servers-state").textContent = "узлы: загружены"; + applyServerReadOnlyMode(); } catch (err) { byId("config-state").textContent = `конфиг: ${localizeErrorMessage(err.message)}`; - byId("servers-state").textContent = `серверы: ${localizeErrorMessage(err.message)}`; + byId("servers-state").textContent = `узлы: ${localizeErrorMessage(err.message)}`; } } async function saveConfig() { + if (!isAdmin()) return; const raw = byId("config-editor").value.trim(); try { const parsed = JSON.parse(raw); @@ -2005,6 +2352,7 @@ function fillServerForm() { } async function saveServers() { + if (!isAdmin()) return; try { if (!state.config) { await loadConfig(); @@ -2064,19 +2412,36 @@ async function saveServers() { ? result.mock_input_frequency_sync_errors.filter((item) => String(item || "").trim() !== "") : []; byId("servers-state").textContent = result.restart_required - ? `серверы: сохранены, требуется перезапуск${saveSuffix}` - : `серверы: сохранены${saveSuffix}`; + ? `узлы: сохранены, требуется перезапуск${saveSuffix}` + : `узлы: сохранены${saveSuffix}`; if (syncErrors.length > 0) { showToast(`Частоты тестовых входов синхронизированы не полностью: ${syncErrors.join("; ")}`, "error"); } else if (result.mock_input_frequency_sync_enabled) { showToast("Частоты тестовых входов обновлены и сохранены.", "success"); } } catch (err) { - byId("servers-state").textContent = `серверы: ${localizeErrorMessage(err.message)}`; + byId("servers-state").textContent = `узлы: ${localizeErrorMessage(err.message)}`; } } function bindUi() { + const loginSubmit = byId("login-submit"); + if (loginSubmit) { + loginSubmit.addEventListener("click", login); + } + const loginPassword = byId("login-password"); + if (loginPassword) { + loginPassword.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + login(); + } + }); + } + const logoutButton = byId("logout-button"); + if (logoutButton) { + logoutButton.addEventListener("click", logout); + } byId("refresh-now").addEventListener("click", refreshNow); const timezoneSelect = byId("timezone-select"); if (timezoneSelect) { @@ -2103,6 +2468,20 @@ function bindUi() { byId("save-config").addEventListener("click", saveConfig); byId("load-servers").addEventListener("click", loadConfig); byId("save-servers").addEventListener("click", saveServers); + const loadUsersButton = byId("load-users"); + if (loadUsersButton) loadUsersButton.addEventListener("click", loadUsers); + const createUserButton = byId("create-user"); + if (createUserButton) createUserButton.addEventListener("click", () => submitUserAction("create")); + const updateUserButton = byId("update-user"); + if (updateUserButton) updateUserButton.addEventListener("click", () => submitUserAction("update")); + const resetPasswordButton = byId("reset-user-password"); + if (resetPasswordButton) { + resetPasswordButton.addEventListener("click", () => submitUserAction("set_password")); + } + const deleteUserButton = byId("delete-user"); + if (deleteUserButton) deleteUserButton.addEventListener("click", () => submitUserAction("delete")); + const clearUserFormButton = byId("clear-user-form"); + if (clearUserFormButton) clearUserFormButton.addEventListener("click", clearUserForm); byId("add-receiver").addEventListener("click", addReceiverDraft); byId("remove-receiver").addEventListener("click", removeReceiverDraft); @@ -2124,9 +2503,12 @@ function bindUi() { state.writeToken = event.target.value; }); - byId("menu-toggle").addEventListener("click", () => { - setMenuCollapsed(!state.menuCollapsed); - }); + const menuToggle = byId("menu-toggle"); + if (menuToggle) { + menuToggle.addEventListener("click", () => { + setMenuCollapsed(!state.menuCollapsed); + }); + } document.querySelectorAll(".menu-group-toggle").forEach((toggle) => { toggle.addEventListener("click", () => { @@ -2158,6 +2540,15 @@ function bindUi() { }); } + document.querySelectorAll(".theme-chip").forEach((button) => { + button.addEventListener("click", () => { + const nextTheme = normalizeUiTheme(button.dataset.themeValue); + if (nextTheme === state.uiTheme) return; + setUiTheme(nextTheme, { persist: true }); + showToast(`Тема интерфейса: ${localizeThemeName(nextTheme)}.`, "info"); + }); + }); + const historyFilter = byId("history-filter"); if (historyFilter) { historyFilter.addEventListener("change", (event) => { @@ -2263,13 +2654,20 @@ async function boot() { state.menuGroupCollapsed = readMenuGroupCollapsed(); applyAllMenuGroupsCollapsed(); setUiDensity(readUiDensityPreference(), { persist: false }); + setUiTheme(readUiThemePreference(), { persist: false }); setPollIntervalMs(readPollIntervalPreference(), { persist: false, restartPolling: false }); updateHistoryRecordingUi(); setMenuCollapsed(readMenuCollapsed()); setDateTimeCollapsed(readDateTimeCollapsed()); updateRefreshUi(); + await loadAuthSession(); setActiveSection(state.activeSection); - await loadConfig(); + if (isAdmin()) { + await loadConfig(); + } + if (isAdmin()) { + await loadUsers(); + } await loadAll(); startPolling(); } diff --git a/web/index.html b/web/index.html index 5b4a831..32528fd 100644 --- a/web/index.html +++ b/web/index.html @@ -3,18 +3,42 @@ - Панель Триангуляции + Радиотрекинг - +
+
+
+

Вход в систему

+

Авторизация выполняется через Keycloak.

+ + +
+ + авторизация: ожидание +
+
+
+
@@ -77,19 +101,19 @@ aria-controls="menu-group-config" aria-expanded="true" > - Конфигурация + Настройки н/д @@ -105,9 +129,9 @@ aria-controls="meta-panel" aria-expanded="true" > - Скрыть служебную панель + Скрыть панель - +
@@ -129,8 +153,8 @@
-

Панель Радиопозиционирования

-

Мониторинг и управление расчётом 3D триангуляции.

+

Радиотрекинг

+

Координаты по RSSI и частотам.

@@ -144,18 +168,18 @@
-

Итоговая Позиция

+

Координаты

-
Выбранная частота: -
+
Частота: -
X: -
Y: -
Z: -
-
СКО (RMSE): -
+
Ошибка: -
-

Оперативный Мониторинг

+

Статус

Сервис: н/д Доставка: н/д @@ -166,26 +190,26 @@
- Входы online + Входы онлайн 0/0
- Выходы online + Выходы онлайн 0/0
- События в истории + События 0
- Успех доставки + Успех отправки 0%
-

Состояние Потоков

+

Потоки

Входные потоки 0/0 @@ -201,7 +225,7 @@
- Успешная доставка + Успех отправки 0%
@@ -215,7 +239,7 @@
-

Таблица По Частотам

+

Решения по частотам

@@ -234,18 +258,18 @@
-

Аналитика Частот

+

Аналитика

-

Профиль Частот

+

Диапазон

-

Лидеры Частот

+

Лучшие частоты

- - + + @@ -380,7 +408,7 @@
-

Мониторинг Истории

+

Сводка

Событий @@ -399,18 +427,18 @@ 0
- Последнее событие + Последнее н/д
-

Диагностика Мониторинга

+

Диагностика

-

Тренды Метрик

+

Тренды

@@ -420,15 +448,15 @@
-

Настройка Серверов

-

Доступы входа и выхода задаются отдельно. Общий фильтр входа автоматически применяется ко всем входным серверам.

+

Узлы системы

+

Приёмники, фильтр и адреса отправки.

-

Входные Серверы

-

Управление ресиверами и их геометрией.

+

Приёмники

+

Адреса, частоты и координаты.

@@ -444,30 +472,30 @@
@@ -476,7 +504,7 @@
-

Общий Фильтр Входа

+

Общий фильтр

Применяется автоматически ко всем входным серверам.

@@ -512,8 +540,8 @@
-

Выходные Серверы

-

Минимальные параметры для доставки результата.

+

Серверы отправки

+

Адреса для передачи координат.

@@ -529,16 +557,16 @@
@@ -548,18 +576,89 @@
- - серверы: н/д + + узлы: н/д +
+
+ +
+
+

Пользователи

+

Управление учётными записями и ролями Keycloak.

+
+
+
+
Время Частота (МГц)Вход (RSSI/Радиусы)Передано На ВыходВходВыход Статус
+ + + + + + + + + +
ЛогинРольСостояниеИмя
+
+
+ + +
+ + +
+
+ + +
+ +
+ + + + + + +
+ пользователи: н/д +
-

Конфигурация

+

Конфиг

- + конфиг: н/д
@@ -567,8 +666,8 @@
-

Редактор JSON

-

Точный режим настройки для прод-конфигурации.

+

JSON

+

Полный файл настроек.

@@ -581,16 +680,16 @@
-

Памятка По Полям

-

Краткая структура и контрольные точки перед сохранением.

+

Структура

+

Основные разделы конфигурации.

-

input.receivers[]
входные ресиверы: координаты, URL, частоты.

-

runtime.output_servers[]
список серверов, получающих координаты.

+

input.receivers[]
приемники: координаты, URL и частоты.

+

runtime.output_servers[]
серверы отправки координат.

input.default_input_filter
общий фильтр частот и RSSI.

system
системные таймеры, лимиты и автообновление.

-

Советы

+

Подсказки

  • Поддерживайте уникальные `receiver_id` для каждого входа.
  • Согласуйте диапазоны частот между входными серверами.
  • @@ -602,7 +701,7 @@
- +
diff --git a/web/styles.css b/web/styles.css index f929208..2c72778 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1,4 +1,4 @@ -@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap"); :root { --bg-main: #f5f7fb; @@ -48,6 +48,87 @@ body { overflow-x: hidden; } +body.role-user .menu-group[data-menu-group="config"], +body.role-guest .menu-group[data-menu-group="config"] { + display: none !important; +} + +.auth-overlay { + position: fixed; + inset: 0; + z-index: 30; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(18, 28, 45, 0.34); + backdrop-filter: blur(10px); +} + +.auth-overlay-hidden { + display: none; +} + +.auth-dialog { + width: min(460px, 100%); + display: grid; + gap: 12px; +} + +.auth-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + justify-content: flex-end; + margin-left: auto; +} + +.app-shell-locked { + pointer-events: none; + user-select: none; +} + +.users-layout { + display: grid; + grid-template-columns: minmax(280px, 1.2fr) minmax(320px, 1fr); + gap: 14px; + align-items: start; +} + +.users-form { + display: grid; + gap: 12px; +} + +.users-table-wrap { + max-height: 420px; +} + +#users-table tbody tr { + cursor: pointer; +} + +#users-table tbody tr:hover { + background: rgba(36, 107, 255, 0.08); +} + +#user-id { + background: rgba(36, 107, 255, 0.04); +} + +@media (max-width: 1100px) { + .auth-summary { + width: 100%; + justify-content: flex-start; + margin-left: 0; + } + + .users-layout { + grid-template-columns: 1fr; + } +} + .app-shell { width: 100%; margin: 0; @@ -112,14 +193,14 @@ body { z-index: 5; display: grid; gap: 10px; - text-align: center; - justify-items: center; + text-align: left; + justify-items: stretch; } .nav-head { display: flex; align-items: center; - justify-content: center; + justify-content: flex-start; gap: 10px; width: 100%; } @@ -127,7 +208,7 @@ body { .brand-block { display: grid; gap: 2px; - justify-items: center; + justify-items: start; } .kicker { @@ -172,8 +253,8 @@ body { } .hero { - text-align: center; - justify-items: center; + text-align: left; + justify-items: stretch; } .hero h2 { @@ -212,7 +293,7 @@ body { } .hero-actions { - justify-content: center; + justify-content: flex-start; } .btn { @@ -521,14 +602,14 @@ body { width: 100%; display: grid; gap: 8px; - justify-items: center; + justify-items: stretch; min-width: 0; } .datetime-panel-controls { width: 100%; display: flex; - justify-content: center; + justify-content: flex-start; flex-wrap: wrap; gap: 8px; } @@ -537,7 +618,7 @@ body { width: 100%; display: grid; gap: 8px; - justify-items: center; + justify-items: stretch; max-height: 240px; opacity: 1; transform: translateY(0); @@ -564,7 +645,7 @@ body { flex-wrap: wrap; gap: 8px; align-items: center; - justify-content: center; + justify-content: flex-start; } .meta-pill { @@ -576,14 +657,14 @@ body { line-height: 1.2; color: #30486f; white-space: normal; - text-align: center; + text-align: left; overflow-wrap: anywhere; } .timezone-picker { display: grid; gap: 4px; - text-align: center; + text-align: left; font-size: 0.78rem; color: #3d4f70; min-width: 220px; @@ -663,15 +744,15 @@ body { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; - text-align: center; - width: min(960px, 100%); - margin: 0 auto; + text-align: left; + width: 100%; + margin: 0; } /* Overview panel: force vertical flow for key info blocks. */ #section-overview .result-box { grid-template-columns: 1fr; - width: min(560px, 100%); + width: 100%; gap: 8px; } @@ -718,7 +799,7 @@ body { display: flex; flex-wrap: wrap; gap: 8px; - justify-content: center; + justify-content: flex-start; } .monitor-headline .io-chip b { @@ -743,7 +824,7 @@ body { .monitor-panel h3 { margin: 0; - text-align: center; + text-align: left; font-size: 0.92rem; } @@ -1294,7 +1375,7 @@ body { } .history-head-card { - text-align: center; + text-align: left; } .history-layout { @@ -1312,7 +1393,7 @@ body { .history-data-card > h2, .history-monitor-card > h2 { margin: 0 0 10px; - text-align: center; + text-align: left; } .history-data-card .history-toolbar { @@ -1402,6 +1483,13 @@ body { flex: 999 1 700px; } +.history-admin-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + .history-pager { display: flex; align-items: center; @@ -1431,7 +1519,7 @@ body { .insight-panel h3 { margin: 0 0 8px; font-size: 0.94rem; - text-align: center; + text-align: left; } .history-monitor { @@ -1569,12 +1657,12 @@ body { .server-grid label, .server-actions-row, .editor-actions { - text-align: center; + text-align: left; } .server-actions-row, .editor-actions { - justify-content: center; + justify-content: flex-start; } .muted { @@ -1656,7 +1744,7 @@ tbody tr:hover { } .servers-head-card { - text-align: center; + text-align: left; } .servers-layout { @@ -1692,7 +1780,7 @@ tbody tr:hover { .server-grid label { display: grid; - justify-items: center; + justify-items: stretch; gap: 6px; font-size: 0.88rem; color: #34425c; @@ -1707,7 +1795,7 @@ tbody tr:hover { font-size: 0.9rem; background: #fff; color: var(--text); - text-align: center; + text-align: left; font-family: inherit; transition: border-color var(--anim-fast) ease, box-shadow var(--anim-fast) ease; width: 100%; @@ -1723,7 +1811,7 @@ tbody tr:hover { } .config-head-card { - text-align: center; + text-align: left; } .config-layout { @@ -1740,7 +1828,7 @@ tbody tr:hover { .config-editor-card h3 { margin: 0 0 8px; - text-align: center; + text-align: left; } .config-help-card { @@ -1752,7 +1840,7 @@ tbody tr:hover { .config-help-card h3 { margin: 0 0 8px; - text-align: center; + text-align: left; } .config-hints { @@ -1808,6 +1896,7 @@ tbody tr:hover { .server-card-head .muted { margin: 0; font-size: 0.8rem; + text-align: left; } .server-card-body { @@ -1922,12 +2011,12 @@ tbody tr:hover { .config-section-head h3 { margin: 0; - text-align: center; + text-align: left; } .config-section-head .muted { margin: 4px 0 0; - text-align: center; + text-align: left; font-size: 0.8rem; } @@ -2148,9 +2237,9 @@ body.ui-compact td { } .nav-head { - align-items: center; + align-items: stretch; flex-direction: column; - text-align: center; + text-align: left; } .menu-toggle { @@ -2169,7 +2258,7 @@ body.ui-compact td { } .side-meta { - justify-items: center; + justify-items: stretch; } .timezone-picker { @@ -2356,7 +2445,7 @@ body.ui-compact td { } .monitor-headline { - justify-content: stretch; + justify-content: flex-start; } .monitor-headline .io-chip {