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.
+
+
+
+
+ авторизация: ожидание
+
+
+
+