From 5fd9d4a6bae6190bbb20241d78191c6909175b9a Mon Sep 17 00:00:00 2001 From: Herklos Date: Mon, 20 Jul 2026 15:51:10 +0200 Subject: [PATCH 01/58] [Node][Sync] fix host-bound sync request signatures behind a reverse proxy Sync request signatures bind to the Host header; a proxy like tailscale serve rewrites it before it reaches uvicorn, so every sync pull/push failed with "bad request signature" while unsigned /api/v1 routes kept working. Add HostNormalizeMiddleware to force the reconstructed host to match what the client signed, and persist that external host as a config.json-backed node-api service setting (env var NODE_EXTERNAL_HOST takes precedence), editable from a new Sync host card on /app/settings. Co-Authored-By: Claude Opus 5 --- .../services/octobot_services/constants.py | 2 + packages/sync/octobot_sync/app.py | 40 ++++++- packages/sync/octobot_sync/server.py | 2 + packages/sync/tests/test_app_helpers.py | 36 +++++- .../node_api_interface/api/routes/nodes.py | 16 +++ .../Interfaces/node_api_interface/node_api.py | 5 +- .../tests/test_routes_nodes.py | 73 ++++++++++++ .../src/routes/_layout/settings.tsx | 108 ++++++++++++++++++ .../node_api_service/node_api.py | 21 ++++ .../tests/test_node_external_host.py | 64 +++++++++++ 10 files changed, 360 insertions(+), 7 deletions(-) create mode 100644 packages/tentacles/Services/Services_bases/node_api_service/tests/test_node_external_host.py diff --git a/packages/services/octobot_services/constants.py b/packages/services/octobot_services/constants.py index e8c0ec9bee..6b37c27b70 100644 --- a/packages/services/octobot_services/constants.py +++ b/packages/services/octobot_services/constants.py @@ -86,8 +86,10 @@ NODE_SQLITE_FILE = "node-sqlite-file" NODE_REDIS_URL = "node-redis-url" BACKEND_CORS_ALLOWED_ORIGINS = "backend-cors-allowed-origins" +NODE_EXTERNAL_HOST = "node-external-host" ENV_NODE_SQLITE_FILE = "ENV_NODE_SQLITE_FILE" ENV_NODE_POSTGRES_URL = "ENV_NODE_POSTGRES_URL" +ENV_NODE_EXTERNAL_HOST = "NODE_EXTERNAL_HOST" # Webhook CONFIG_WEBHOOK = "webhook" diff --git a/packages/sync/octobot_sync/app.py b/packages/sync/octobot_sync/app.py index 91ae4a16a0..27ef2e3b19 100644 --- a/packages/sync/octobot_sync/app.py +++ b/packages/sync/octobot_sync/app.py @@ -66,6 +66,38 @@ async def __call__(self, scope, receive, send): await self.app(scope, receive, send) +class HostNormalizeMiddleware: + """Rewrite the inbound ``Host`` header to a configured external host. + + The Starfish cap resolver binds each request signature to a host (the ``h`` + field of the canonical signing input) to stop a signed request from being + replayed against a different server. The client signs the host it dialed + (e.g. a tailnet hostname); when a reverse proxy in front of this app — such + as ``tailscale serve`` — terminates TLS and forwards to this process, the + ``Host`` header the ASGI server sees is the proxy's local target, not the + host the client signed, so every signature mismatches. When ``external_host`` + is set, force the header the resolver reads back to that value so it equals + what the client signed. No-op when unset (e.g. the community sync server, + which is not behind such a proxy). + """ + + def __init__(self, app, external_host: str | None): + self.app = app + self.external_host = external_host + + async def __call__(self, scope, receive, send): + if scope["type"] == "http" and self.external_host: + headers = [ + (name, value) + for name, value in scope.get("headers", []) + if name != b"host" + ] + headers.append((b"host", self.external_host.encode("latin-1"))) + scope = dict(scope) + scope["headers"] = headers + await self.app(scope, receive, send) + + def _build_role_resolver(is_allowed_user_id: Callable[[str], bool] | None): """Cap-cert role resolver (device caps), optionally gated by a userId allowlist. @@ -103,6 +135,7 @@ def create_app( is_allowed_user_id: Callable[[str], bool] | None = None, sync_config: SyncConfig | None = None, plugins: list[ServerPlugin] | None = None, + external_host: str | None = None, ): if sync_config is None: sync_config = sync.load_sync_config(collections_path) @@ -128,5 +161,8 @@ async def health(): # Always wrap: the cap resolver verifies the request signature against # request.url.path, which must equal the client-signed /v1/... path - # regardless of how this app is mounted (see SignedPathMiddleware). - return SignedPathMiddleware(app) + # regardless of how this app is mounted (see SignedPathMiddleware), and + # against request.url's host, which must equal the client-signed host even + # behind a reverse proxy that presents a different Host (see + # HostNormalizeMiddleware). + return HostNormalizeMiddleware(SignedPathMiddleware(app), external_host) diff --git a/packages/sync/octobot_sync/server.py b/packages/sync/octobot_sync/server.py index 7f38aa9ed2..48a41f2534 100644 --- a/packages/sync/octobot_sync/server.py +++ b/packages/sync/octobot_sync/server.py @@ -312,10 +312,12 @@ def build_object_store() -> AbstractObjectStore: def build_default_sync_app( is_allowed_user_id: Callable[[str], bool] | None = None, sync_config: SyncConfig | None = None, + external_host: str | None = None, ): return sync_app.create_app( build_object_store(), is_allowed_user_id=is_allowed_user_id, sync_config=sync_config, plugins=[user_actions_plugin], + external_host=external_host, ) diff --git a/packages/sync/tests/test_app_helpers.py b/packages/sync/tests/test_app_helpers.py index 4b803210d1..6f95d0ffa9 100644 --- a/packages/sync/tests/test_app_helpers.py +++ b/packages/sync/tests/test_app_helpers.py @@ -37,18 +37,48 @@ async def test_health_endpoint(app): async def test_create_app_returns_signed_path_middleware(app): - assert isinstance(app, sync_app.SignedPathMiddleware) + assert isinstance(app, sync_app.HostNormalizeMiddleware) + assert isinstance(app.app, sync_app.SignedPathMiddleware) async def test_create_app_with_custom_collections_path(): store = MemoryObjectStore() # Should not raise even with a non-existent collections path (falls back to default) created_app = sync_app.create_app(store, collections_path="/nonexistent/path.json") - assert isinstance(created_app, sync_app.SignedPathMiddleware) + assert isinstance(created_app, sync_app.HostNormalizeMiddleware) + assert isinstance(created_app.app, sync_app.SignedPathMiddleware) async def test_create_app_with_allowlist(): store = MemoryObjectStore() # is_allowed_user_id callable accepted without error created_app = sync_app.create_app(store, is_allowed_user_id=lambda uid: True) - assert isinstance(created_app, sync_app.SignedPathMiddleware) + assert isinstance(created_app, sync_app.HostNormalizeMiddleware) + assert isinstance(created_app.app, sync_app.SignedPathMiddleware) + + +async def _echo_host_app(scope, receive, send): + headers = dict(scope["headers"]) + await send({ + "type": "http.response.start", + "status": 200, + "headers": [(b"content-type", b"text/plain")], + }) + await send({ + "type": "http.response.body", + "body": headers.get(b"host", b""), + }) + + +async def test_host_normalize_middleware_rewrites_host(): + wrapped = sync_app.HostNormalizeMiddleware(_echo_host_app, "signed-host.example") + async with AsyncClient(transport=ASGITransport(app=wrapped), base_url="http://proxy-local:8000") as client: + resp = await client.get("/health") + assert resp.text == "signed-host.example" + + +async def test_host_normalize_middleware_noop_when_unset(): + wrapped = sync_app.HostNormalizeMiddleware(_echo_host_app, None) + async with AsyncClient(transport=ASGITransport(app=wrapped), base_url="http://proxy-local:8000") as client: + resp = await client.get("/health") + assert resp.text == "proxy-local:8000" diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/nodes.py b/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/nodes.py index f84ab54599..fbb7151aa9 100644 --- a/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/nodes.py +++ b/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/nodes.py @@ -15,11 +15,13 @@ # License along with OctoBot. If not, see . import logging +import os import threading import time import typing import pydantic +import octobot_services.constants as services_constants import octobot_services.interfaces.util as interfaces_util from fastapi import APIRouter, status @@ -39,12 +41,19 @@ except ImportError: context_based_file_handler = None +# Service_bases is only needed at runtime, not for build (see node_api.py). +try: + import tentacles.Services.Services_bases.node_api_service.node_api as node_api_service_module +except ImportError: + node_api_service_module = None + router = APIRouter(tags=["nodes"]) class NodeConfigUpdate(pydantic.BaseModel): node_type: typing.Optional[typing.Literal["standalone", "master"]] = None use_dedicated_log_file_per_automation: typing.Optional[bool] = None + external_host: typing.Optional[str] = None @router.get("/me", response_model=octobot_node.models.Node) @@ -60,6 +69,11 @@ def get_node_config(current_user: CurrentUser) -> typing.Any: "use_dedicated_log_file_per_automation": octobot_node.config.settings.USE_DEDICATED_LOG_FILE_PER_AUTOMATION, "tasks_encryption_enabled": octobot_node.config.settings.tasks_encryption_enabled, "server_encryption_env_vars": octobot_node.constants.TASKS_ENCRYPTION_ENV_VARS, + "external_host": ( + node_api_service_module.NodeApiService.instance().get_node_external_host() + if node_api_service_module else None + ), + "external_host_env_override": bool(os.getenv(services_constants.ENV_NODE_EXTERNAL_HOST)), } @@ -86,6 +100,8 @@ def update_node_config(config: NodeConfigUpdate, current_user: CurrentUser) -> t octobot_node.scheduler.scheduler.Scheduler._setup_workflow_logging() else: _remove_context_based_file_handlers() + if config.external_host is not None and node_api_service_module is not None: + node_api_service_module.NodeApiService.instance().set_node_external_host(config.external_host) return get_node_config(current_user) diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/node_api.py b/packages/tentacles/Services/Interfaces/node_api_interface/node_api.py index e293726382..3e19020025 100644 --- a/packages/tentacles/Services/Interfaces/node_api_interface/node_api.py +++ b/packages/tentacles/Services/Interfaces/node_api_interface/node_api.py @@ -102,7 +102,7 @@ async def _async_run(self) -> bool: scheduler.initialize_scheduler() host = self.host port = self.port - self.app = self.create_app() + self.app = self.create_app(external_host=self.node_api_service.get_node_external_host()) # Set CORS from service config cors_origins_str = self.node_api_service.get_backend_cors_origins() cors_origins = [i.strip() for i in cors_origins_str.split(",") if i.strip()] if cors_origins_str else [] @@ -151,7 +151,7 @@ def _open_node_ui_on_browser(self): ) @classmethod - def create_app(cls) -> FastAPI: + def create_app(cls, external_host: str | None = None) -> FastAPI: @asynccontextmanager async def lifespan(app: FastAPI): yield @@ -182,6 +182,7 @@ async def lifespan(app: FastAPI): sync_server.derive_user_id(w.private_key) == user_id for w in community_auth.CommunityAuthentication.instance().list_wallet_entries() ), + external_host=external_host, ), ) diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_nodes.py b/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_nodes.py index bfa44f1b21..589618f9ab 100644 --- a/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_nodes.py +++ b/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_nodes.py @@ -19,6 +19,9 @@ import time import mock +import pytest + +import octobot_node.config as octobot_node_config from .conftest import ADMIN_ADDRESS, ADMIN_PASSPHRASE, TENANT_ADDRESS, TENANT_PASSPHRASE @@ -78,3 +81,73 @@ def test_tenant_with_basic_auth_returns_403(self, client, mock_auth): headers=_auth_header(TENANT_ADDRESS, TENANT_PASSPHRASE), ) assert response.status_code == 403 + + +class TestNodeConfigExternalHost: + @pytest.fixture(autouse=True) + def _configured_node_settings(self, admin_client, monkeypatch): + # admin_client's unit_app fixture patches octobot_node.config.settings with a bare + # mock.Mock(); explicitly set the attributes get_node_config() reads so the response + # can be JSON-serialized. + monkeypatch.setattr(octobot_node_config.settings, "IS_MASTER_MODE", False) + monkeypatch.setattr(octobot_node_config.settings, "USE_DEDICATED_LOG_FILE_PER_AUTOMATION", False) + monkeypatch.setattr(octobot_node_config.settings, "tasks_encryption_enabled", False) + + def test_get_config_reflects_stored_and_env_override(self, admin_client, monkeypatch): + node_api_service = mock.Mock() + node_api_service.get_node_external_host.return_value = "node.example.com" + monkeypatch.setenv("NODE_EXTERNAL_HOST", "node.example.com") + with mock.patch( + "tentacles.Services.Interfaces.node_api_interface.api.routes.nodes.node_api_service_module" + ".NodeApiService.instance", + return_value=node_api_service, + ): + response = admin_client.get("/api/v1/nodes/config") + assert response.status_code == 200 + body = response.json() + assert body["external_host"] == "node.example.com" + assert body["external_host_env_override"] is True + + def test_get_config_without_env_override(self, admin_client, monkeypatch): + node_api_service = mock.Mock() + node_api_service.get_node_external_host.return_value = "stored.example.com" + monkeypatch.delenv("NODE_EXTERNAL_HOST", raising=False) + with mock.patch( + "tentacles.Services.Interfaces.node_api_interface.api.routes.nodes.node_api_service_module" + ".NodeApiService.instance", + return_value=node_api_service, + ): + response = admin_client.get("/api/v1/nodes/config") + assert response.status_code == 200 + body = response.json() + assert body["external_host"] == "stored.example.com" + assert body["external_host_env_override"] is False + + def test_patch_config_persists_external_host(self, admin_client, monkeypatch): + node_api_service = mock.Mock() + node_api_service.get_node_external_host.return_value = "new-host.example.com" + monkeypatch.delenv("NODE_EXTERNAL_HOST", raising=False) + with mock.patch( + "tentacles.Services.Interfaces.node_api_interface.api.routes.nodes.node_api_service_module" + ".NodeApiService.instance", + return_value=node_api_service, + ): + response = admin_client.patch( + "/api/v1/nodes/config", json={"external_host": "new-host.example.com"} + ) + assert response.status_code == 200 + node_api_service.set_node_external_host.assert_called_once_with("new-host.example.com") + assert response.json()["external_host"] == "new-host.example.com" + + def test_patch_config_without_external_host_does_not_set(self, admin_client, monkeypatch): + node_api_service = mock.Mock() + node_api_service.get_node_external_host.return_value = None + monkeypatch.delenv("NODE_EXTERNAL_HOST", raising=False) + with mock.patch( + "tentacles.Services.Interfaces.node_api_interface.api.routes.nodes.node_api_service_module" + ".NodeApiService.instance", + return_value=node_api_service, + ): + response = admin_client.patch("/api/v1/nodes/config", json={"node_type": "standalone"}) + assert response.status_code == 200 + node_api_service.set_node_external_host.assert_not_called() diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/_layout/settings.tsx b/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/_layout/settings.tsx index 5f055b5d5a..0ec55de9d5 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/_layout/settings.tsx +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/_layout/settings.tsx @@ -6,6 +6,7 @@ import { Download, Eye, EyeOff, + Globe, KeyRound, LogOut, Network, @@ -428,6 +429,112 @@ function NodeTypeCard() { ) } +function SyncHostCard() { + const [externalHost, setExternalHost] = useState("") + const [envOverride, setEnvOverride] = useState(false) + const [status, setStatus] = useState<"loading" | "ready" | "saving" | "saved" | "error">( + "loading", + ) + const [error, setError] = useState("") + const timerRef = useRef | null>(null) + + useEffect(() => { + void (async () => { + try { + const data = await fetchNodeConfig() + setExternalHost(data.external_host ?? "") + setEnvOverride(Boolean(data.external_host_env_override)) + setStatus("ready") + } catch { + setStatus("error") + setError("Failed to load sync host configuration.") + } + })() + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, []) + + const handleSave = async () => { + setStatus("saving") + setError("") + try { + const res = await fetch("/api/v1/nodes/config", { + method: "PATCH", + headers: { + Authorization: await buildAuthHeader(), + "Content-Type": "application/json", + }, + body: JSON.stringify({ external_host: externalHost }), + }) + if (!res.ok) throw new Error(await res.text()) + const data = await res.json() + setExternalHost(data.external_host ?? "") + setStatus("saved") + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => setStatus("ready"), 2000) + } catch (e) { + setStatus("error") + setError(e instanceof Error ? e.message : "Failed to save sync host") + } + } + + return ( + + + + + Sync host + + + Host your sync/mobile client uses to reach this node — required + behind a reverse proxy that rewrites the Host header (e.g.{" "} + tailscale serve). + + + + {status === "loading" ? ( +

Loading…

+ ) : envOverride ? ( + <> +
+ {externalHost || "(empty)"} +
+ + Set via the NODE_EXTERNAL_HOST environment + variable; remove it to edit this value here. + + + ) : ( + <> + setExternalHost(e.target.value)} + /> +
+ + {status === "error" && ( + {error} + )} +
+ + )} +
+
+ ) +} + function ClientEncryptionKeysCard() { const [keys, setKeys] = useState(emptyKeys) const [status, setStatus] = useState<"loading" | "ready" | "saved" | "error">( @@ -1157,6 +1264,7 @@ function Settings() {
+ diff --git a/packages/tentacles/Services/Services_bases/node_api_service/node_api.py b/packages/tentacles/Services/Services_bases/node_api_service/node_api.py index cc5d56461a..b576957775 100644 --- a/packages/tentacles/Services/Services_bases/node_api_service/node_api.py +++ b/packages/tentacles/Services/Services_bases/node_api_service/node_api.py @@ -39,6 +39,7 @@ def __init__(self): self.node_sqlite_file = None self.node_redis_url = None self.backend_cors_origins = None + self.node_external_host = None def get_fields_description(self): return { @@ -47,6 +48,12 @@ def get_fields_description(self): services_constants.NODE_SQLITE_FILE: "SQLite database file path for the Node scheduler.", services_constants.NODE_REDIS_URL: "Redis URI for the Node scheduler (optional).", services_constants.BACKEND_CORS_ALLOWED_ORIGINS: "Allowed CORS origins for the Node API backend.", + services_constants.NODE_EXTERNAL_HOST: "External host (and port, if non-default) the sync/mobile " + "client dials to reach this node. Required when this node " + "sits behind a reverse proxy that presents a different Host " + "header to the server than the one the client signed " + "(e.g. tailscale serve), otherwise sync requests fail " + "signature verification.", services_constants.CONFIG_AUTO_OPEN_IN_WEB_BROWSER: "When enabled, OctoBot will open the Node web UI " "in your browser upon startup.", commons_constants.CONFIG_ENABLED_OPTION: "Enable the Node API interface.", @@ -59,6 +66,7 @@ def get_default_value(self): services_constants.NODE_SQLITE_FILE: "tasks.db", services_constants.NODE_REDIS_URL: None, services_constants.BACKEND_CORS_ALLOWED_ORIGINS: services_constants.DEFAULT_BACKEND_CORS_ALLOWED_ORIGINS, + services_constants.NODE_EXTERNAL_HOST: None, services_constants.CONFIG_AUTO_OPEN_IN_WEB_BROWSER: True, commons_constants.CONFIG_ENABLED_OPTION: False, } @@ -109,11 +117,13 @@ async def prepare(self) -> None: self.node_sqlite_file = node_config.get(services_constants.NODE_SQLITE_FILE) self.node_redis_url = node_config.get(services_constants.NODE_REDIS_URL) self.backend_cors_origins = node_config.get(services_constants.BACKEND_CORS_ALLOWED_ORIGINS) + self.node_external_host = node_config.get(services_constants.NODE_EXTERNAL_HOST) except KeyError: self.node_api_url = None self.node_sqlite_file = None self.node_redis_url = None self.backend_cors_origins = None + self.node_external_host = None self._sync_config() if self.get_is_enabled(self.config) and not octobot_node.scheduler.is_initialized(): octobot_node.scheduler.initialize_scheduler() @@ -186,3 +196,14 @@ def get_backend_cors_origins(self): def get_backend_cors_origin_regex(self): return os.getenv(services_constants.ENV_BACKEND_CORS_ORIGIN_REGEX, "") + + def get_node_external_host(self): + return os.getenv(services_constants.ENV_NODE_EXTERNAL_HOST, self.node_external_host) + + def set_node_external_host(self, value): + self.node_external_host = value or None + self.save_service_config( + services_constants.CONFIG_NODE_API, + {services_constants.NODE_EXTERNAL_HOST: self.node_external_host}, + update=True, + ) diff --git a/packages/tentacles/Services/Services_bases/node_api_service/tests/test_node_external_host.py b/packages/tentacles/Services/Services_bases/node_api_service/tests/test_node_external_host.py new file mode 100644 index 0000000000..137dd73534 --- /dev/null +++ b/packages/tentacles/Services/Services_bases/node_api_service/tests/test_node_external_host.py @@ -0,0 +1,64 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +import mock + +import octobot_services.constants as services_constants +import tentacles.Services.Services_bases.node_api_service.node_api as node_api_service_module + + +class TestNodeApiServiceExternalHost: + def test_get_node_external_host_falls_back_to_config_value(self, monkeypatch): + monkeypatch.delenv(services_constants.ENV_NODE_EXTERNAL_HOST, raising=False) + service = node_api_service_module.NodeApiService() + service.node_external_host = "node.example.com" + assert service.get_node_external_host() == "node.example.com" + + def test_get_node_external_host_returns_none_by_default(self, monkeypatch): + monkeypatch.delenv(services_constants.ENV_NODE_EXTERNAL_HOST, raising=False) + service = node_api_service_module.NodeApiService() + service.node_external_host = None + assert service.get_node_external_host() is None + + def test_env_var_overrides_config_value(self, monkeypatch): + monkeypatch.setenv(services_constants.ENV_NODE_EXTERNAL_HOST, "env-host.example.com") + service = node_api_service_module.NodeApiService() + service.node_external_host = "config-host.example.com" + assert service.get_node_external_host() == "env-host.example.com" + + def test_set_node_external_host_persists_and_updates_getter(self, monkeypatch): + monkeypatch.delenv(services_constants.ENV_NODE_EXTERNAL_HOST, raising=False) + service = node_api_service_module.NodeApiService() + with mock.patch.object(service, "save_service_config") as save_mock: + service.set_node_external_host("new-host.example.com:8000") + save_mock.assert_called_once_with( + services_constants.CONFIG_NODE_API, + {services_constants.NODE_EXTERNAL_HOST: "new-host.example.com:8000"}, + update=True, + ) + assert service.get_node_external_host() == "new-host.example.com:8000" + + def test_set_node_external_host_empty_string_clears_value(self, monkeypatch): + monkeypatch.delenv(services_constants.ENV_NODE_EXTERNAL_HOST, raising=False) + service = node_api_service_module.NodeApiService() + with mock.patch.object(service, "save_service_config") as save_mock: + service.set_node_external_host("") + save_mock.assert_called_once_with( + services_constants.CONFIG_NODE_API, + {services_constants.NODE_EXTERNAL_HOST: None}, + update=True, + ) + assert service.get_node_external_host() is None From 4762c85012d364ff8ab844bff834299489de2465 Mon Sep 17 00:00:00 2001 From: Herklos Date: Mon, 20 Jul 2026 16:41:27 +0200 Subject: [PATCH 02/58] [Node] Redesign node web interface settings page and split into subcomponents Merges node type and sync host into a single "Node configuration" card, reworks the Support card into a "Help" card with Guides/Support/Developer sections, relocates the stop-node and log-out actions into a shared CardCornerButton, and switches wallet/encryption-key layouts to a denser grid. Splits the 1350+ line settings.tsx into per-card components under components/Settings/ to keep files small and focused. Co-Authored-By: Claude Sonnet 5 --- .../sync/tests/test_exception_handlers.py | 2 +- .../components/Settings/CardCornerButton.tsx | 40 + .../Settings/ClientEncryptionKeysCard.tsx | 279 ++++ .../Settings/NodeConfigurationCard.tsx | 230 +++ .../Settings/NodeManagementCard.tsx | 106 -- .../WalletManagement/AddWalletDialog.tsx | 226 +++ .../WalletManagement/ExportWalletDialog.tsx | 203 +++ .../WalletManagement/PairDeviceDialog.tsx | 100 ++ .../WalletManagement/RemoveWalletDialog.tsx | 100 ++ .../Settings/WalletManagement/WalletRow.tsx | 146 ++ .../Settings/WalletManagementCard.tsx | 74 + .../src/components/Support/SupportCard.tsx | 156 +- .../node_web_interface/src/lib/node-config.ts | 14 + .../src/routes/_layout/settings.tsx | 1258 +---------------- 14 files changed, 1515 insertions(+), 1419 deletions(-) create mode 100644 packages/tentacles/Services/Interfaces/node_web_interface/src/components/Settings/CardCornerButton.tsx create mode 100644 packages/tentacles/Services/Interfaces/node_web_interface/src/components/Settings/ClientEncryptionKeysCard.tsx create mode 100644 packages/tentacles/Services/Interfaces/node_web_interface/src/components/Settings/NodeConfigurationCard.tsx delete mode 100644 packages/tentacles/Services/Interfaces/node_web_interface/src/components/Settings/NodeManagementCard.tsx create mode 100644 packages/tentacles/Services/Interfaces/node_web_interface/src/components/Settings/WalletManagement/AddWalletDialog.tsx create mode 100644 packages/tentacles/Services/Interfaces/node_web_interface/src/components/Settings/WalletManagement/ExportWalletDialog.tsx create mode 100644 packages/tentacles/Services/Interfaces/node_web_interface/src/components/Settings/WalletManagement/PairDeviceDialog.tsx create mode 100644 packages/tentacles/Services/Interfaces/node_web_interface/src/components/Settings/WalletManagement/RemoveWalletDialog.tsx create mode 100644 packages/tentacles/Services/Interfaces/node_web_interface/src/components/Settings/WalletManagement/WalletRow.tsx create mode 100644 packages/tentacles/Services/Interfaces/node_web_interface/src/components/Settings/WalletManagementCard.tsx create mode 100644 packages/tentacles/Services/Interfaces/node_web_interface/src/lib/node-config.ts diff --git a/packages/sync/tests/test_exception_handlers.py b/packages/sync/tests/test_exception_handlers.py index 2909248d5f..86983b01c6 100644 --- a/packages/sync/tests/test_exception_handlers.py +++ b/packages/sync/tests/test_exception_handlers.py @@ -27,7 +27,7 @@ class TestSyncAppUnhandledExceptionHandler: async def test_create_app_registers_unhandled_exception_handler(self): store = mock.Mock() wrapped_app = sync_app.create_app(store) - inner_app = wrapped_app.app + inner_app = wrapped_app.app.app assert Exception in inner_app.exception_handlers def test_mounted_sync_unhandled_exception_is_logged(self): diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/components/Settings/CardCornerButton.tsx b/packages/tentacles/Services/Interfaces/node_web_interface/src/components/Settings/CardCornerButton.tsx new file mode 100644 index 0000000000..2dc2f11b82 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/components/Settings/CardCornerButton.tsx @@ -0,0 +1,40 @@ +import type { LucideIcon } from "lucide-react" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +export function CardCornerButton({ + icon: Icon, + label, + onClick, + variant = "default", +}: { + icon: LucideIcon + label: string + onClick: () => void + variant?: "default" | "destructive" +}) { + return ( +
+ + + + + {label} + +
+ ) +} diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/components/Settings/ClientEncryptionKeysCard.tsx b/packages/tentacles/Services/Interfaces/node_web_interface/src/components/Settings/ClientEncryptionKeysCard.tsx new file mode 100644 index 0000000000..3ae9f964f7 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/components/Settings/ClientEncryptionKeysCard.tsx @@ -0,0 +1,279 @@ +import { Check, KeyRound, ShieldCheck, TriangleAlert, X } from "lucide-react" +import { useEffect, useRef, useState } from "react" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import type { ClientKeys } from "@/lib/client-encryption" +import { + areClientKeysConfigured, + CLIENT_KEY_LABELS, + CLIENT_KEY_NAMES, + emptyKeys, +} from "@/lib/client-encryption" +import { + clearClientKeys, + hasStoredClientKeys, + loadClientKeys, + saveClientKeys, +} from "@/lib/device-key" +import { fetchNodeConfig } from "@/lib/node-config" + +function StatusIndicator({ enabled }: { enabled: boolean | null }) { + if (enabled === null) return null + return ( + + + {enabled ? ( + + + + ) : ( + + + + )} + + + {enabled ? "Enabled" : "Disabled"} + + + ) +} + +export function ClientEncryptionKeysCard() { + const [keys, setKeys] = useState(emptyKeys) + const [status, setStatus] = useState<"loading" | "ready" | "saved" | "error">( + "loading", + ) + const [hasStored, setHasStored] = useState(false) + const [editing, setEditing] = useState(false) + const [error, setError] = useState("") + const timerRef = useRef | null>(null) + const configured = areClientKeysConfigured(keys) + const [serverEnabled, setServerEnabled] = useState(null) + const [serverEnvVars, setServerEnvVars] = useState([]) + + useEffect(() => { + void (async () => { + try { + const data = await fetchNodeConfig() + setServerEnabled(data.tasks_encryption_enabled ?? false) + setServerEnvVars(data.server_encryption_env_vars ?? []) + } catch { + setServerEnabled(false) + } + })() + }, []) + + useEffect(() => { + ;(async () => { + const stored = await hasStoredClientKeys() + setHasStored(stored) + if (!stored) { + setStatus("ready") + return + } + try { + const loaded = await loadClientKeys() + if (loaded) setKeys(loaded as ClientKeys) + setStatus("ready") + } catch { + setStatus("error") + setError("Failed to decrypt stored keys.") + } + })() + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, []) + + const handleSave = async () => { + try { + await saveClientKeys(keys) + setHasStored(true) + setStatus("saved") + setEditing(false) + setError("") + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(() => setStatus("ready"), 2000) + } catch (e) { + setStatus("error") + setError(e instanceof Error ? e.message : "Encryption failed") + } + } + + const handleClear = async () => { + await clearClientKeys() + setHasStored(false) + setKeys(emptyKeys()) + setStatus("ready") + setError("") + } + + return ( + +
+ +
+ + + + Encryption keys + + + Server-side and browser-stored client keys for end-to-end task + encryption. + + + +
+ + Server keys + + {serverEnabled === null ? ( +

Loading…

+ ) : serverEnabled ? ( + + All server encryption keys + are configured. + + ) : ( +
+ + Set these environment variables to enable: + +
    + {serverEnvVars.map((v) => ( +
  • {v}
  • + ))} +
+
+ )} +
+
+
+ + Client keys + + {status === "error" ? ( +
+
+ + {error} +
+ +
+ ) : status === "loading" ? ( +

Decrypting…

+ ) : hasStored && !editing ? ( + <> +
+ {CLIENT_KEY_NAMES.map((k) => ( +
+ + {CLIENT_KEY_LABELS[k]} + +
+ {"•".repeat(24)} +
+
+ ))} +
+
+ {status === "saved" ? ( + + Saved + + ) : ( + + )} + +
+ + ) : ( + <> +
+ {CLIENT_KEY_NAMES.map((k) => ( +
+ +