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 a3053a0..5f94a4f 100644 Binary files a/__pycache__/service.cpython-311.pyc and b/__pycache__/service.cpython-311.pyc differ 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 d16edb2..2942534 100644 Binary files a/__pycache__/test_service_integration.cpython-311-pytest-8.2.2.pyc and b/__pycache__/test_service_integration.cpython-311-pytest-8.2.2.pyc differ diff --git a/__pycache__/test_service_integration.cpython-311.pyc b/__pycache__/test_service_integration.cpython-311.pyc index 73ec649..cbe918b 100644 Binary files a/__pycache__/test_service_integration.cpython-311.pyc and b/__pycache__/test_service_integration.cpython-311.pyc differ 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 {