From 419b70b62129aeda1080ed03c49e1c551ed0a86a Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Sat, 12 Sep 2026 14:48:23 +0200 Subject: [PATCH 1/3] Native HTTPS on Linux frames: TLS in the runtime, Caddy gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Linux frame — Raspberry Pi OS and the three Buildroot images alike — now serves HTTPS from inside the runtime's own HTTP server, from the same per-frame certificate the backend mints. No Caddy process any more, neither for the API (tls_proxy.nim, deleted) nor for the hotspot portal (setup_proxy.nim, deleted). ESP32 firmware already did this natively; Linux frames match. Plan and removal list: docs/native-https.md. Runtime - mummy is now the FrameOS/mummy fork (pinned by commit like pixie): TLS listeners with OpenSSL inside the epoll loop, newTlsConfig() loading the PEM material from memory (the private key never touches the file system), addListener/removeListener from any thread while serving, Request.secure. OpenSSL is already linked on every image (hub_client wss, http_client, logger), so no base image rebuild. - server/listeners.nim plans the sockets from the config alone: plain framePort (on loopback with exposeOnlyPort, on bindHost when set) plus TLS on httpsProxy.port when enabled and certificate material is present; otherwise tls:default_cert as before. A certificate the runtime cannot load or a port it cannot bind logs tls:config_error / tls:start_error and the frame stays reachable over plain HTTP; the plain listener failing is fatal, as it always was. - server/hotspot_listener.nim: while the hotspot is up and the plain listener is not on every interface, a plain listener on 10.42.0.1: (every interface when that address is not up yet, which is what the Caddy setup proxy always bound), removed with the AP. hotspotSetupPort reads it for the QR code and the captive-portal redirect. - Request.secure feeds the same-origin guard, the Secure cookie flag and the cloud link's recorded origin alongside X-Forwarded-Proto, so an owner's own reverse proxy keeps working. - mummy's own log lines reach the frame logger (http:error, http:info; http:debug with the per-handshake timing only when debug is on). - frameos.service.unprivileged carries CAP_NET_BIND_SERVICE next to CAP_SYS_TTY_CONFIG for frames configured on a port below 1024. Backend, frontend, deploy - The three Buildroot gates from bde54b2f are gone (force-off in ensure_buildroot_frame_defaults, the 400, the disabled switch). A Buildroot frame keeps its HTTPS setting; a new one gets HTTPS on by default like every other frame. - The caddy apt package is no longer planned or installed. - Upgrades from a Caddy-era release: the caddy.service probe in the full deploy plan is version-gated on the deploy baseline's device-reported frameos_version — frame_may_still_run_caddy() is true only for an unknown version or one at or below LAST_CADDY_FRAMEOS_VERSION (2026.9.13, the last release that shipped Caddy). The first full deploy past it disables a leftover caddy.service; a frame already reporting something newer is never asked again. The setup scripts keep their disable line for one release. - Copy: "HTTPS proxy via Caddy" -> "HTTPS API", the section is "HTTPS" on every platform. docs/api-triality.md is unchanged (the https_proxy shape stays). Tests - Fork: tests/test_tls.nim (GET/POST, plain and TLS side by side, a 4 MB response, keep-alive, WebSocket over TLS, a client stalled mid-handshake, plain text on the TLS port, listeners added/removed while serving). - Runtime: test_listeners (planning), test_hotspot_listener and test_tls_listener (a TLS listener next to the harness's plain one through the real router: Secure cookie without a proxy header, the origin guard's 443 default, a 200 KB POST, removal while serving). The harness's stopServer now runs GC_fullCollect() first: the router handler closure is built on the test thread and freed on mummy's serving thread, and ORC's cycle roots are per thread — it only passed before by allocation-churn luck. - Backend: test_frame_may_still_run_caddy, test_post_deploy_plan_probes_caddy_only_for_caddy_era_frames, test_api_frame_update_buildroot_keeps_https_proxy. Bench on uus2w, Cloud-5, Cloud-W and one upgraded Raspbian Pi is still to do; see the doc's Bench section. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018eY8yWLDyHKssZ31wnkB54 --- backend/app/api/frame_bootstrap.py | 4 +- backend/app/api/frame_sync.py | 2 +- backend/app/api/frames.py | 17 -- backend/app/api/tests/test_frames.py | 36 ++-- backend/app/tasks/buildroot_image.py | 4 - backend/app/tasks/embedded_firmware.py | 4 +- backend/app/tasks/frame_deploy_workflow.py | 48 +++-- .../tasks/tests/test_buildroot_privileges.py | 5 +- .../tasks/tests/test_frame_deploy_workflow.py | 57 +++++- backend/app/utils/tls.py | 10 +- backend/app/utils/versions.py | 19 ++ docs/buildroot-privileges.md | 6 +- docs/native-https.md | 121 ++++++++++++ embedded/esp32/README.md | 4 +- frameos/frameos.nimble | 5 +- frameos/frameos.service.unprivileged | 8 +- frameos/nimble.lock | 8 +- frameos/src/frameos/frameos.nim | 13 +- frameos/src/frameos/portal.nim | 12 +- frameos/src/frameos/server/auth.nim | 4 + .../src/frameos/server/hotspot_listener.nim | 76 ++++++++ frameos/src/frameos/server/listeners.nim | 64 +++++++ frameos/src/frameos/server/origin.nim | 9 +- .../server/routes/cloud_api_routes.nim | 2 +- frameos/src/frameos/server/server.nim | 87 +++++++-- .../server/tests/helpers/http_harness.nim | 7 + .../server/tests/test_hotspot_listener.nim | 85 +++++++++ .../frameos/server/tests/test_listeners.nim | 94 ++++++++++ .../server/tests/test_tls_listener.nim | 167 +++++++++++++++++ frameos/src/frameos/setup_proxy.nim | 106 ----------- .../src/frameos/tests/test_setup_proxy.nim | 95 ---------- frameos/src/frameos/tests/test_tls_proxy.nim | 117 ------------ frameos/src/frameos/tls_proxy.nim | 176 ------------------ frameos/src/frameos/utils/url.nim | 15 +- frontend/src/scenes/frame/frameLogic.ts | 2 +- .../FrameSettings/frameSettingsSurface.ts | 3 +- .../sections/connectivitySections.tsx | 48 ++--- scripts/frameos-setup.sh | 4 +- 38 files changed, 900 insertions(+), 644 deletions(-) create mode 100644 docs/native-https.md create mode 100644 frameos/src/frameos/server/hotspot_listener.nim create mode 100644 frameos/src/frameos/server/listeners.nim create mode 100644 frameos/src/frameos/server/tests/test_hotspot_listener.nim create mode 100644 frameos/src/frameos/server/tests/test_listeners.nim create mode 100644 frameos/src/frameos/server/tests/test_tls_listener.nim delete mode 100644 frameos/src/frameos/setup_proxy.nim delete mode 100644 frameos/src/frameos/tests/test_setup_proxy.nim delete mode 100644 frameos/src/frameos/tests/test_tls_proxy.nim delete mode 100644 frameos/src/frameos/tls_proxy.nim diff --git a/backend/app/api/frame_bootstrap.py b/backend/app/api/frame_bootstrap.py index 7a9ab09d7..ba0d8ef2b 100644 --- a/backend/app/api/frame_bootstrap.py +++ b/backend/app/api/frame_bootstrap.py @@ -447,7 +447,9 @@ def _frame_bootstrap_script(db: Session, frame: Frame) -> str: artifact_root="${{frameos_binary%/*}}" install_packages hostapd -install_optional_packages caddy +# HTTPS is served by the runtime itself. A Pi set up before 2026.9.14 got the +# caddy apt package, whose own caddy.service must not sit on :8443; drop this +# line once every frame has been through a newer release. systemctl disable --now caddy.service >/dev/null 2>&1 || true install -m 0755 "$frameos_binary" "$frameos_release_dir/frameos" diff --git a/backend/app/api/frame_sync.py b/backend/app/api/frame_sync.py index 6f4f3f850..665c40120 100644 --- a/backend/app/api/frame_sync.py +++ b/backend/app/api/frame_sync.py @@ -201,7 +201,7 @@ async def store_frame_sync_hint_headers(redis: Redis, frame_id: int, frame_heade "frame_json": "frame.json", "scenes_json": "scenes.json", "frame_admin_auth": "Frame admin auth", - "https_proxy": "HTTPS proxy", + "https_proxy": "HTTPS", "server_send_logs": "Send logs to backend", "device_config": "Device config", "timezone_updater": "Timezone updater", diff --git a/backend/app/api/frames.py b/backend/app/api/frames.py index a46daf7a3..f61c30476 100644 --- a/backend/app/api/frames.py +++ b/backend/app/api/frames.py @@ -3868,23 +3868,6 @@ async def api_frame_update_endpoint( detail="The admin login is the only way this backend reaches the frame (no SSH key, " "password or FrameOS Remote on it) — it cannot be disabled or left blank", ) - # The Buildroot images ship no Caddy, so ensure_buildroot_frame_defaults - # turns the HTTPS proxy off on every save of a Buildroot frame. The form - # disables the switch and says why; a caller asking for it anyway is told - # rather than silently overruled (the toggle "never stuck", 2026-09-12). - # A frame changing mode in this save keeps the quiet reset: the proxy it - # had as rpios simply does not exist on the card it becomes. - https_proxy_update = update_data.get("https_proxy") - if ( - isinstance(https_proxy_update, dict) - and https_proxy_update.get("enable") - and (frame.mode or "rpios") == "buildroot" - and update_data.get("mode", "buildroot") == "buildroot" - ): - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail="The HTTPS proxy is not available on Buildroot frames: the image ships no Caddy", - ) # The schedule becomes " root " in /etc/cron.d on the # next deploy; refuse what cron cannot parse or what would add a line. reboot_update = update_data.get("reboot") diff --git a/backend/app/api/tests/test_frames.py b/backend/app/api/tests/test_frames.py index abbeee198..8d60142cd 100644 --- a/backend/app/api/tests/test_frames.py +++ b/backend/app/api/tests/test_frames.py @@ -1328,10 +1328,11 @@ async def test_api_frame_update_name(async_client, db, redis): @pytest.mark.asyncio -async def test_api_frame_update_buildroot_rejects_https_proxy(async_client, db, redis): - # The Buildroot images ship no Caddy. The save used to accept enable=true - # and ensure_buildroot_frame_defaults put it back off, so the settings - # switch flipped on and immediately off again. +async def test_api_frame_update_buildroot_keeps_https_proxy(async_client, db, redis): + # HTTPS is served by the runtime itself (OpenSSL is in every Buildroot + # image), so a Buildroot frame keeps the setting it is given. Until + # 2026-09-12 ensure_buildroot_frame_defaults forced it off on every save + # because the images shipped no Caddy. frame = await new_frame(db, redis, 'BuildrootFrame', 'frame.local', 'backend.local') frame.mode = 'buildroot' frame.buildroot = {'platform': 'raspberry-pi-64', 'compilationMode': 'precompiled'} @@ -1342,24 +1343,22 @@ async def test_api_frame_update_buildroot_rejects_https_proxy(async_client, db, proxy = dict(frame.https_proxy or {}) resp = await async_client.post(f'/api/frames/{frame.id}', json={"https_proxy": {**proxy, "enable": True}}) - assert resp.status_code == 400 - assert "Buildroot" in resp.json()["detail"] + assert resp.status_code == 200 + db.expire_all() + stored = db.get(Frame, frame.id) + assert stored.mode == "buildroot" + assert stored.https_proxy["enable"] is True - resp = await async_client.post(f'/api/frames/{frame.id}', json={"https_proxy": {**proxy, "enable": False}}) + # A later save that touches something else does not turn it off again. + resp = await async_client.post(f'/api/frames/{frame.id}', json={"name": "Still Buildroot"}) assert resp.status_code == 200 db.expire_all() - assert db.get(Frame, frame.id).https_proxy["enable"] is False + assert db.get(Frame, frame.id).https_proxy["enable"] is True - # Leaving Buildroot in the same save is not asking for the proxy on a - # Buildroot card: the mode switch goes through. - resp = await async_client.post( - f'/api/frames/{frame.id}', json={"mode": "rpios", "https_proxy": {**proxy, "enable": True}} - ) + resp = await async_client.post(f'/api/frames/{frame.id}', json={"https_proxy": {**proxy, "enable": False}}) assert resp.status_code == 200 db.expire_all() - updated = db.get(Frame, frame.id) - assert updated.mode == "rpios" - assert updated.https_proxy["enable"] is True + assert db.get(Frame, frame.id).https_proxy["enable"] is False @pytest.mark.asyncio @@ -2186,7 +2185,10 @@ async def test_api_frame_new_buildroot_defaults(async_client): assert frame['frame_host'] == f"frame{frame['id']}.local" assert frame['ssh_user'] == 'root' assert frame['assets_path'] == '/srv/assets' - assert frame['https_proxy']['enable'] is False + # HTTPS is on by default like on every other frame: the runtime serves + # it itself, OpenSSL is in every Buildroot image. + assert frame['https_proxy']['enable'] is True + assert frame['https_proxy']['certs']['server'] assert frame['buildroot']['platform'] == 'raspberry-pi-64' assert frame['timezone'] == 'Europe/Brussels' assert frame['network']['wifiSSID'] == 'Test WiFi' diff --git a/backend/app/tasks/buildroot_image.py b/backend/app/tasks/buildroot_image.py index 7f0c9cc83..3a61a77be 100644 --- a/backend/app/tasks/buildroot_image.py +++ b/backend/app/tasks/buildroot_image.py @@ -1202,10 +1202,6 @@ def ensure_buildroot_frame_defaults(frame: Frame, platform: str | None = None) - if not getattr(frame, "log_to_file", None): frame.log_to_file = "/srv/frameos/logs/frameos-{date}.log" - https_proxy = dict(frame.https_proxy or {}) - https_proxy["enable"] = False - frame.https_proxy = https_proxy - # Only the shared secret is guaranteed here. Whether FrameOS Remote is # enabled at all is the user's call, made when the frame is added (see # buildroot_agent_defaults) and editable afterwards in frame settings — diff --git a/backend/app/tasks/embedded_firmware.py b/backend/app/tasks/embedded_firmware.py index c59023442..f37b75b8a 100644 --- a/backend/app/tasks/embedded_firmware.py +++ b/backend/app/tasks/embedded_firmware.py @@ -1523,8 +1523,8 @@ def ensure_embedded_frame_defaults(frame: Frame, platform: str | None = None) -> frame.frame_port = 80 # No SSH or agent on a microcontroller. HTTPS uses the same frame - # certificate model as Pi frames, but is served natively by ESP-IDF instead - # of through Caddy. + # certificate model as Pi frames, served natively by ESP-IDF (the Linux + # runtime serves it natively too, with OpenSSL). frame.https_proxy = normalize_https_proxy(frame.https_proxy) agent = dict(frame.agent or {}) agent["agentEnabled"] = False diff --git a/backend/app/tasks/frame_deploy_workflow.py b/backend/app/tasks/frame_deploy_workflow.py index 9350adbff..9503b7d8c 100644 --- a/backend/app/tasks/frame_deploy_workflow.py +++ b/backend/app/tasks/frame_deploy_workflow.py @@ -58,9 +58,17 @@ from app.utils.remote_exec import upload_file from app.utils.ssh_authorized_keys import _install_authorized_keys from app.utils.ssh_key_utils import normalize_ssh_keys, select_ssh_keys_for_frame -from app.utils.versions import current_frameos_version, current_remote_version +from app.utils.versions import current_frameos_version, current_remote_version, version_tuple REMOTE_RUNTIME_APT_PACKAGES = ("hostapd",) +# The last FrameOS release whose runtime ran Caddy as its HTTPS proxy. Newer +# runtimes terminate TLS themselves (docs/native-https.md) and the `caddy` +# apt package is no longer installed. A Pi that ran Caddy can still have the +# package's own caddy.service enabled next to the runtime, so the first full +# deploy that takes such a frame past this version disables it; a frame +# whose deploy baseline already says it runs something newer is never probed +# again. +LAST_CADDY_FRAMEOS_VERSION = "2026.9.13" REMOTE_BUILD_APT_PACKAGES = ("build-essential",) HELPER_ENSURE_NTP = "ensure_ntp" @@ -220,6 +228,17 @@ def _mountpoints_enabled(frame: Frame) -> bool: return False +def frame_may_still_run_caddy(previous_frameos_version: str | None) -> bool: + """Whether the frame's last known FrameOS version is one that shipped + with Caddy. Unknown or unparseable versions count as "may": the probe is + one `systemctl is-enabled`, a stray Caddy on :8443 is a frame that + silently serves the wrong thing.""" + previous = version_tuple(previous_frameos_version) + if previous is None: + return True + return previous <= version_tuple(LAST_CADDY_FRAMEOS_VERSION) + + def _is_buildroot_frame(frame: Frame) -> bool: return (getattr(frame, "mode", None) or "rpios") == "buildroot" @@ -838,15 +857,6 @@ async def _plan_full(self, *, frame_dict: dict[str, Any], previous_frameos_versi remote_build_fallback_package_plans.append( await self._plan_package("libssl-dev", "OpenSSL headers if cross-compilation falls back to an on-device build") ) - if not is_buildroot: - package_plans.append( - await self._plan_package( - "caddy", - "FrameOS TLS proxy support", - run_after_install="sudo -n systemctl disable --now caddy.service", - ) - ) - if not is_buildroot and _mountpoints_enabled(self.frame): package_plans.append(await self._plan_package("cifs-utils", "Samba/CIFS mountpoint support")) @@ -928,7 +938,9 @@ async def _plan_full(self, *, frame_dict: dict[str, Any], previous_frameos_versi "will be deployed before the FrameOS full deploy." ) - post_deploy = await self._plan_post_deploy_cleanup(drivers=drivers, low_memory=low_memory) + post_deploy = await self._plan_post_deploy_cleanup( + drivers=drivers, low_memory=low_memory, previous_frameos_version=previous_frameos_version + ) return FrameDeployPlan( mode="full", @@ -1876,7 +1888,10 @@ async def _run_post_deploy_cleanup_writes(self, *, post_deploy: dict[str, Any], await self.deployer.exec_command("sudo -n systemctl disable userconfig || true") if post_deploy.get("disable_caddy_service"): - await self.deployer.log("stdout", f"{icon} Disabling system-managed Caddy service (managed by FrameOS tls_proxy)") + await self.deployer.log( + "stdout", + f"{icon} Disabling the caddy.service an older FrameOS installed (HTTPS is served by the runtime now)", + ) await self.deployer.exec_command("sudo -n systemctl disable --now caddy.service", raise_on_error=False) setup_json_reset_path = setup_json_reset_file_path(self.frame) @@ -1948,7 +1963,9 @@ async def _remove_setup_json_reset_helper(self) -> None: raise_on_error=False, ) - async def _plan_post_deploy_cleanup(self, *, drivers: dict[str, Any], low_memory: bool) -> dict[str, Any]: + async def _plan_post_deploy_cleanup( + self, *, drivers: dict[str, Any], low_memory: bool, previous_frameos_version: str | None = None + ) -> dict[str, Any]: boot_config = "/boot/config.txt" if await self._command_succeeds( "test -f /boot/config.txt && grep -Eq '^(kernel=Image|start_file=|fixup_file=)' /boot/config.txt" @@ -2017,7 +2034,10 @@ async def _plan_post_deploy_cleanup(self, *, drivers: dict[str, Any], low_memory disable_userconfig = last_successful_deploy_at is None and await self._command_succeeds( "systemctl is-enabled userconfig >/dev/null 2>&1" ) - disable_caddy_service = await self._command_succeeds( + # Only a frame coming from a Caddy-era FrameOS (or one of unknown + # version) is probed for a leftover caddy.service; see + # LAST_CADDY_FRAMEOS_VERSION. + disable_caddy_service = frame_may_still_run_caddy(previous_frameos_version) and await self._command_succeeds( "systemctl is-enabled caddy.service >/dev/null 2>&1 || systemctl is-active caddy.service >/dev/null 2>&1" ) must_reboot = ( diff --git a/backend/app/tasks/tests/test_buildroot_privileges.py b/backend/app/tasks/tests/test_buildroot_privileges.py index e79d2e3d5..8b63105da 100644 --- a/backend/app/tasks/tests/test_buildroot_privileges.py +++ b/backend/app/tasks/tests/test_buildroot_privileges.py @@ -40,7 +40,10 @@ def test_unprivileged_unit_carries_the_hardening_block(): assert "NoNewPrivileges=yes\n" in service assert "ProtectSystem=strict\n" in service assert "ReadWritePaths=/srv/frameos /srv/assets\n" in service - assert "CapabilityBoundingSet=CAP_SYS_TTY_CONFIG\n" in service + # KDSETMODE for the framebuffer driver, and ports below 1024 for the + # runtime's own HTTP/HTTPS listeners when a frame is configured that way. + assert "CapabilityBoundingSet=CAP_SYS_TTY_CONFIG CAP_NET_BIND_SERVICE\n" in service + assert "AmbientCapabilities=CAP_SYS_TTY_CONFIG CAP_NET_BIND_SERVICE\n" in service assert "ExecStartPre=+/bin/sh -c 'for n in /dev/gpiochip*" in service assert "chgrp frameos" in service assert "Wants=NetworkManager.service\nAfter=network.target NetworkManager.service\n" in service diff --git a/backend/app/tasks/tests/test_frame_deploy_workflow.py b/backend/app/tasks/tests/test_frame_deploy_workflow.py index c286447b3..5d17a1ca0 100644 --- a/backend/app/tasks/tests/test_frame_deploy_workflow.py +++ b/backend/app/tasks/tests/test_frame_deploy_workflow.py @@ -11,6 +11,7 @@ EmbeddedFullDeployPlan, FrameDeployPlan, FrameDeployWorkflow, + frame_may_still_run_caddy, FullDeployPlan, HelperActionPlan, PackagePlan, @@ -823,7 +824,9 @@ async def plan_build(self, **kwargs) -> FrameBinaryPlan: ] assert plan.full_deploy is not None assert plan.full_deploy.target["distro"] == "ubuntu" - assert {pkg.name for pkg in plan.full_deploy.package_plans} >= {"hostapd", "build-essential", "caddy"} + assert {pkg.name for pkg in plan.full_deploy.package_plans} >= {"hostapd", "build-essential"} + # HTTPS is served by the runtime; the caddy package is no longer planned. + assert "caddy" not in {pkg.name for pkg in plan.full_deploy.package_plans} assert deployer.logs == [("stdinfo", "🔷 Detected ubuntu; updating frame deployment mode from buildroot to rpios")] @@ -980,7 +983,7 @@ async def test_full_plan_reports_installed_state_and_remote_build_dependencies(m assert "libatomic-ops-dev" not in package_map assert "libicu-dev" not in package_map assert "zlib1g-dev" not in package_map - assert package_map["caddy"].installed is False + assert "caddy" not in package_map assert package_map["python3-pip"].installed is True assert plan.full_deploy.package_alternatives[0].installed_package == "ntp" assert plan.full_deploy.dependency_helper_plans == [] @@ -1227,6 +1230,56 @@ async def test_post_deploy_plan_prefers_buildroot_active_boot_config(): assert post_deploy["boot_config_path"] == "/boot/config.txt" +CADDY_PROBE = "systemctl is-enabled caddy.service >/dev/null 2>&1 || systemctl is-active caddy.service >/dev/null 2>&1" + + +@pytest.mark.parametrize( + "previous_version, expected", + [ + (None, True), + ("", True), + ("unknown", True), + ("2026.9.10", True), + ("2026.9.13", True), + ("v2026.9.13+abcdef", True), + ("2026.9.14", False), + ("2026.10.1", False), + ("2027.1.1", False), + ], +) +def test_frame_may_still_run_caddy(previous_version, expected): + assert frame_may_still_run_caddy(previous_version) is expected + + +@pytest.mark.asyncio +async def test_post_deploy_plan_probes_caddy_only_for_caddy_era_frames(): + # A frame upgraded from a release that ran Caddy gets its leftover + # caddy.service disabled once; a frame already past that release is not + # asked about Caddy on every deploy. + async def plan_for(previous_version: str | None): + frame = SimpleNamespace(id=9, name="CaddyFrame", reboot=None, last_successful_deploy_at=None) + deployer = RecordingDeployer() # answers every probe with success and records it + workflow = FrameDeployWorkflow( + db=None, redis=None, frame=frame, deployer=deployer, temp_dir="", binary_builder=FakeBinaryBuilder() + ) + post_deploy = await workflow._plan_post_deploy_cleanup( + drivers={}, low_memory=False, previous_frameos_version=previous_version + ) + return post_deploy, deployer + + post_deploy, deployer = await plan_for("2026.9.13") + assert post_deploy["disable_caddy_service"] is True + assert CADDY_PROBE in deployer.commands + + post_deploy, deployer = await plan_for(None) + assert post_deploy["disable_caddy_service"] is True + assert CADDY_PROBE in deployer.commands + + post_deploy, deployer = await plan_for("2026.9.14") + assert post_deploy["disable_caddy_service"] is False + assert CADDY_PROBE not in deployer.commands + + @pytest.mark.asyncio async def test_post_deploy_plan_normalizes_legacy_reboot_crontab(): frame = SimpleNamespace( diff --git a/backend/app/utils/tls.py b/backend/app/utils/tls.py index eda570d50..812303eac 100644 --- a/backend/app/utils/tls.py +++ b/backend/app/utils/tls.py @@ -65,9 +65,10 @@ def generate_frame_tls_material(frame_host: str) -> dict[str, str]: # PEM, and on the 4 MB / 8 MB flash layouts NVS is 16 KB (~378 usable # 32-byte entries). An RSA-2048 cert + key is ~2.9 KB of PEM (~100 # entries); a P-256 pair is ~1 KB (~35), which is what left the Wi-Fi - # driver room to store its own state on a bare C3 (2026-09-05). Caddy on - # the Pi and mbedTLS on the ESP32 (ECDSA + secp256r1 are on in every - # ESP-IDF build) both serve EC keys. The CA stays RSA: it never leaves the + # driver room to store its own state on a bare C3 (2026-09-05). OpenSSL + # in the Linux runtime and mbedTLS on the ESP32 (ECDSA + secp256r1 are on + # in every ESP-IDF build) both serve EC keys, and a P-256 handshake is + # cheap on a Pi Zero's ARM1176. The CA stays RSA: it never leaves the # backend, and browsers/curl trust it as a plain self-signed root either # way. Frames issued before this keep their RSA pair until the owner # regenerates it; the cert and key always travel together (frame @@ -112,7 +113,8 @@ def generate_frame_tls_material(frame_host: str) -> dict[str, str]: return { # TraditionalOpenSSL gives "BEGIN EC PRIVATE KEY" (SEC 1), the form - # mbedtls_pk_parse_key and Caddy read without a PKCS#8 wrapper. + # mbedtls_pk_parse_key and PEM_read_bio_PrivateKey read without a + # PKCS#8 wrapper. "server_key": server_key.private_bytes( serialization.Encoding.PEM, serialization.PrivateFormat.TraditionalOpenSSL, diff --git a/backend/app/utils/versions.py b/backend/app/utils/versions.py index 5fc7aea81..2a62375b5 100644 --- a/backend/app/utils/versions.py +++ b/backend/app/utils/versions.py @@ -24,6 +24,25 @@ def current_frameos_version() -> str | None: return version.split("+")[0] +def version_tuple(version: str | None) -> tuple[int, ...] | None: + """``"2026.9.13"`` (also ``"v2026.9.13+"``) as ``(2026, 9, 13)`` for + comparisons; ``None`` for anything that is not dotted numbers.""" + if not isinstance(version, str): + return None + cleaned = version.strip() + if cleaned.startswith("v"): + cleaned = cleaned[1:] + cleaned = cleaned.split("+", 1)[0] + if not cleaned: + return None + parts: list[int] = [] + for part in cleaned.split("."): + if not part.isdigit(): + return None + parts.append(int(part)) + return tuple(parts) + + def current_remote_version() -> str | None: version = get_versions().get("remote") or get_versions().get("agent") if not isinstance(version, str): diff --git a/docs/buildroot-privileges.md b/docs/buildroot-privileges.md index de163c62b..54535f565 100644 --- a/docs/buildroot-privileges.md +++ b/docs/buildroot-privileges.md @@ -182,7 +182,7 @@ FrameOS genuinely needs privilege — for narrow, enumerable things: | `reboot` | `device_setup.nim` | rare | | NetworkManager / wpa_supplicant profiles, hotspot | `network/` | setup, portal | | SPI/I²C/GPIO, framebuffer, `/dev/fb0`, evdev | drivers | every render | -| Bind :80/:443 | only with `https_proxy.enable` (off on Buildroot) | boot | +| Bind :80/:443 | only when the frame port or HTTPS port is set below 1024 (defaults 8787/8443 need nothing) | boot | Everything else — rendering, QuickJS, HTTP, the cloud client, assets, logs — is ordinary userspace work on `/srv`. @@ -249,7 +249,9 @@ user (uid/gid 990, fixed, `/bin/false`). The unit is rendered from `ProtectControlGroups`, `RestrictSUIDSGID`, `RestrictRealtime`, `RestrictNamespaces`, `LockPersonality`, `SystemCallArchitectures=native`, `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK`, and a bounding -set of exactly `CAP_SYS_TTY_CONFIG` (the framebuffer driver's `KDSETMODE`). +set of exactly `CAP_SYS_TTY_CONFIG` (the framebuffer driver's `KDSETMODE`) and +`CAP_NET_BIND_SERVICE` (the runtime's own HTTP/HTTPS listeners on a port below +1024, when a frame is configured that way; the 8787/8443 defaults need it not). `SupplementaryGroups=video input` covers the nodes udev already groups; an `ExecStartPre=+` step (root) hands `/dev/spidev*`, `/dev/gpiochip*`, `/dev/i2c-*`, `/dev/vchiq`, `/dev/fb*`, `/dev/gpiomem`, `/dev/tty0-1` and the diff --git a/docs/native-https.md b/docs/native-https.md new file mode 100644 index 000000000..bae43e11b --- /dev/null +++ b/docs/native-https.md @@ -0,0 +1,121 @@ +# Native HTTPS on Linux frames (Caddy replaced) + +Status: implemented 2026-09-12 (this PR), bench pending (see "Bench"). + +## What changed + +Every Linux frame — Raspberry Pi OS and the three Buildroot images alike — +serves HTTPS from inside the runtime's own HTTP server. There is no Caddy +process any more, neither for the API (`tls_proxy.nim`, deleted) nor for the +hotspot portal (`setup_proxy.nim`, deleted). ESP32 firmware already served +HTTPS natively (`httpd_ssl_start`); Linux frames now match. + +- **`FrameOS/mummy` fork** (pinned by commit in `frameos.nimble`, as pixie): + TLS listeners with OpenSSL inside the epoll loop, `newTlsConfig(certPem, + keyPem)` loading the material from memory (the private key never touches + the file system; Caddy needed `frameos-tls-key.pem` on disk), + `addListener`/`removeListener` from any thread while serving, and + `Request.secure`. Everything TLS is under `when defined(ssl)`; FrameOS + builds with `-d:ssl` on every Linux target already. +- **Runtime.** `server/listeners.nim` decides the sockets from the config + alone (`planListeners`): plain `framePort` on `0.0.0.0` (on `127.0.0.1` + with `exposeOnlyPort`, on `bindHost` when set) plus TLS on + `httpsProxy.port` (8443) when `enable` and both `serverCert`/`serverKey` + are present — otherwise `tls:default_cert` is logged and the frame stays + plain, as before. A certificate the runtime cannot load or a port it + cannot bind logs `tls:config_error` / `tls:start_error` and HTTPS stays + off; the plain listener failing is fatal, as it always was. mummy's own + log lines reach the frame logger (`http:error`, `http:info`; `http:debug` + with the per-handshake timing only when `debug` is on). +- **Hotspot.** `server/hotspot_listener.nim`: when the plain listener is not + on every interface, `finishStartedHotspot` adds a plain listener on + `10.42.0.1:` (every interface when that address + is not up yet, which is what the Caddy proxy always bound) and `stopAp` + removes it. `hotspotSetupPort` reads that port for the QR code and the + captive-portal redirect. +- **`Request.secure`** feeds the same-origin guard (`origin.nim`, the Host + header's default port), the `Secure` cookie flag (`auth.nim`) and the + cloud link's recorded origin (`cloud_api_routes.nim`) alongside + `X-Forwarded-Proto`, so an owner's own reverse proxy keeps working. +- **Ports below 1024 as uid 990**: `frameos.service.unprivileged` carries + `CAP_NET_BIND_SERVICE` next to `CAP_SYS_TTY_CONFIG`. The defaults + (8787/8443) need nothing. +- **Certificate changes** keep today's behaviour: `tls_settings_changed` + makes the deploy restart the runtime; no hot reload. +- Nothing changes for `frameosEmbedded` / `frameosWasm` (mummy is not + compiled there). + +## Backend, frontend, deploy + +- The three Buildroot gates from commit bde54b2f are gone: the force-off in + `ensure_buildroot_frame_defaults`, the 400 in `api_frame_update_endpoint`, + the disabled switch in `HttpsProxySection`. A Buildroot frame keeps the + HTTPS setting it is given, and a new one gets HTTPS on by default like + every other frame. +- The `caddy` apt package is no longer planned (`frame_deploy_workflow.py`) + or installed (`frameos-setup.sh`, `frame_bootstrap.py`). +- **Upgrades from a Caddy-era release.** A Pi set up before this had the + `caddy` package, whose own `caddy.service` was disabled at install and on + every full deploy that found it enabled. That probe is now version-gated: + `frame_may_still_run_caddy(previous_frameos_version)` — the deploy + baseline's `frameos_version`, which the device itself keeps current + (`remember_device_reported_frameos_version`) — is true only for an unknown + version or one at or below `LAST_CADDY_FRAMEOS_VERSION = "2026.9.13"`, the + last release that shipped Caddy. So the first full deploy that takes a + frame past 2026.9.13 disables a leftover `caddy.service`; a frame already + reporting something newer is never asked again. The setup scripts keep + their `systemctl disable --now caddy.service` line for one release, then + both go. +- Copy: "HTTPS proxy via Caddy" → "HTTPS API", the section is "HTTPS" on + every platform; `docs/api-triality.md` is unchanged (the `https_proxy` + shape stays, it is the API's name for the setting). +- No base image rebuild: OpenSSL (3.5.6, `libssl.so.3`) is already in every + Buildroot image and the runtime linked it before this change + (`hub_client` wss, `http_client`, `logger`). + +## Why native, not Caddy in Buildroot + +- Caddy in the Buildroot images needs the Go host toolchain in the base + build, a ~40 MB binary and 30–50 MB RSS next to the runtime on a 512 MB + Zero, base rebuilds for all three platforms, and stays a second process + with its own monitor thread. It would work on ARMv6 (`GOARM=6`), but + everything above applies twice as hard there. +- An in-process byte-pump terminator (`std/net` `wrapConnectedSocket`, two + threads per connection to `127.0.0.1:8787`) is half the code, but the + loopback hop stays, the server cannot tell TLS from plain + (`X-Forwarded-Proto` would have to be forged into the stream), and the + hotspot proxy remains a separate thing. +- stunnel / nginx / lighttpd from Buildroot: the same second-process shape, + more packages, configuration files on a read-only rootfs. +- The server key is P-256 (`generate_frame_tls_material`), so handshakes + are cheap even on the ARM1176 in a Zero W; the fork's test log shows + 0–1 ms on a laptop. + +## Tests + +- Fork: `nim c -r -d:ssl tests/test_tls.nim` — GET/POST, plain and TLS side + by side, a 4 MB response (partial `SSL_write`), keep-alive, WebSocket + over TLS, a client stalled mid-handshake, plain text on the TLS port, + listeners added and removed while serving. The upstream suite passes + with and without `-d:ssl`. +- Runtime: `server/tests/test_listeners.nim` (planning), + `test_hotspot_listener.nim` and `test_tls_listener.nim` (a TLS listener + next to the harness's plain one, through the real router: same routes on + both, `Secure` cookie without a proxy header, the origin guard's 443 + default, a 200 KB POST, removal while serving). +- Backend: `test_frame_may_still_run_caddy`, + `test_post_deploy_plan_probes_caddy_only_for_caddy_era_frames`, + `test_api_frame_update_buildroot_keeps_https_proxy`. + +## Bench (to do after merge) + +- uus2w (`raspberry-pi-64`, uid 990) and Cloud-5: enable HTTPS from the + backend, expect the admin page on `https://:8443` with the frame CA + imported, backend calls over https, WebSocket log stream over wss, + `exposeOnlyPort` hides 8787, hotspot portal still answers on + `http://10.42.0.1:`. +- Cloud-W (`raspberry-pi-32`, ARMv6, root): same, plus handshake time in + the log with `debug` on (expect tens of ms with P-256). +- One Raspbian Pi upgraded from 2026.9.13: deploy, confirm the plan disables + `caddy.service`, that Caddy is neither installed fresh nor running, and + HTTPS still works. Deploy again: no Caddy probe in the plan. diff --git a/embedded/esp32/README.md b/embedded/esp32/README.md index 2e004b452..049896fa3 100644 --- a/embedded/esp32/README.md +++ b/embedded/esp32/README.md @@ -201,8 +201,8 @@ Unprovisioned devices start a captive portal: join the `FrameOS-XXXX` Wi-Fi netw and any page redirects to the setup form (Wi-Fi, backend URL, frame ID/API key, panel, render mode). A board flashed from a FrameOS backend or the cloud is provisioned over the USB console instead (below) and never sees the portal; -the frame's HTTPS certificate — the same per-frame material Raspberry Pi -Caddy proxies use — arrives with its first `/embedded/settings` pull. +the frame's HTTPS certificate — the same per-frame material the Linux +runtime serves with OpenSSL — arrives with its first `/embedded/settings` pull. The serial console (115200) is always available and quicker for development. It answers on whichever USB port the board brings out: the chip's own diff --git a/frameos/frameos.nimble b/frameos/frameos.nimble index ed7a7c41b..08cc39e0d 100644 --- a/frameos/frameos.nimble +++ b/frameos/frameos.nimble @@ -17,7 +17,10 @@ requires "chrono >= 0.3.1" requires "checksums >= 0.2.1" requires "nim >= 2.2.4" requires "https://github.com/FrameOS/pixie#28a9cc32e013b4d7dd72c830f4a25008cb7259d4" -requires "mummy >= 0.4.7" +# FrameOS/mummy: upstream mummy plus TLS listeners (OpenSSL inside the epoll +# loop), listeners added/removed while serving, and Request.secure. HTTPS on +# Linux frames terminates here; see docs/native-https.md. +requires "https://github.com/FrameOS/mummy#14990300e2bae8bb1f214deaec2db03fb903e669" requires "linuxfb >= 0.1.0" requires "QRgen >= 3.1.0" requires "jsony >= 1.1.5" diff --git a/frameos/frameos.service.unprivileged b/frameos/frameos.service.unprivileged index 857b0ee6f..e208de1cf 100644 --- a/frameos/frameos.service.unprivileged +++ b/frameos/frameos.service.unprivileged @@ -30,6 +30,8 @@ LockPersonality=yes SystemCallArchitectures=native RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK # KDSETMODE on the framebuffer console (drivers/frameBuffer) is the one ioctl -# that still needs a capability without a controlling tty. -CapabilityBoundingSet=CAP_SYS_TTY_CONFIG -AmbientCapabilities=CAP_SYS_TTY_CONFIG +# that still needs a capability without a controlling tty. CAP_NET_BIND_SERVICE +# lets the runtime's own HTTP/HTTPS listeners take a port below 1024 (:443) +# when the frame is configured that way; the defaults (8787/8443) need nothing. +CapabilityBoundingSet=CAP_SYS_TTY_CONFIG CAP_NET_BIND_SERVICE +AmbientCapabilities=CAP_SYS_TTY_CONFIG CAP_NET_BIND_SERVICE diff --git a/frameos/nimble.lock b/frameos/nimble.lock index f6bbcc237..40a622cd3 100644 --- a/frameos/nimble.lock +++ b/frameos/nimble.lock @@ -146,9 +146,9 @@ } }, "mummy": { - "version": "0.4.7", - "vcsRevision": "a6662e94cd50df7b88b5cae79f04fdb4e967cd36", - "url": "https://github.com/guzba/mummy", + "version": "0.4.8", + "vcsRevision": "14990300e2bae8bb1f214deaec2db03fb903e669", + "url": "https://github.com/FrameOS/mummy", "downloadMethod": "git", "dependencies": [ "zippy", @@ -156,7 +156,7 @@ "crunchy" ], "checksums": { - "sha1": "d30a9eb8a7df829e6db71c6861e374bfa14a0f36" + "sha1": "ef06ca32b5fa78cbb7b0c88090606ebf178210d9" } }, "pixie": { diff --git a/frameos/src/frameos/frameos.nim b/frameos/src/frameos/frameos.nim index 45bee76c2..d708a23cc 100644 --- a/frameos/src/frameos/frameos.nim +++ b/frameos/src/frameos/frameos.nim @@ -19,8 +19,6 @@ import frameos/utils/memory import frameos/portal as netportal from frameos/upgrade import reconcileInterruptedUpgradeStatus, installedFrameOSVersion import frameos/cloud/hub_client -import frameos/tls_proxy -import frameos/setup_proxy import frameos/boot_guard import frameos/utils/image import frameos/utils/status_screen @@ -377,14 +375,9 @@ proc start*(self: FrameOS) {.async.} = # frame is standalone or backend-managed. startCloudManagement(self.frameConfig) - startTlsProxy(self.frameConfig, self.logger) - - try: - ## This call never returns - self.server.startServer() - finally: - stopSetupProxy() - stopTlsProxy(self.logger) + ## This call never returns. HTTPS is one more listener on the same server + ## (server/listeners.nim); there is no proxy process to babysit. + self.server.startServer() proc startFrameOS*() {.async.} = # Tell systemd (Type=notify) we are up before any slow driver or scene diff --git a/frameos/src/frameos/portal.nim b/frameos/src/frameos/portal.nim index bf39e41e3..d1459f95c 100644 --- a/frameos/src/frameos/portal.nim +++ b/frameos/src/frameos/portal.nim @@ -4,7 +4,7 @@ import frameos/config import frameos/types import frameos/scenes import frameos/channels -import frameos/setup_proxy +import frameos/server/hotspot_listener import frameos/utils/process import frameos/utils/system import frameos/network/backend as netbackend @@ -1320,15 +1320,17 @@ proc stopAp*(frameOS: FrameOS) {.gcsafe.} = discard run("sudo nmcli connection down " & shQuote(nmHotspotName) & " || true") discard run("sudo nmcli connection delete " & shQuote(nmHotspotName) & " || true") frameOS.network.hotspotStatus = HotspotStatus.disabled - stopSetupProxy() + if frameOS.server != nil: + stopHotspotListener(frameOS.server.mummy) pLog("portal:stopAp:done") proc finishStartedHotspot(frameOS: FrameOS): bool {.gcsafe.} = ## Shared by both backends: once the AP is up the rest of the portal - ## (setup proxy, hotspot scene, auto-timeout) behaves identically. + ## (hotspot listener, hotspot scene, auto-timeout) behaves identically. frameOS.network.hotspotStatus = HotspotStatus.enabled - startSetupProxy(frameOS.frameConfig) - pLog("portal:startAp:setupProxy", %*{"port": setupProxyPort()}) + if frameOS.server != nil: + let (port, address) = startHotspotListener(frameOS.server.mummy, frameOS.frameConfig) + pLog("portal:startAp:listener", %*{"port": port, "address": address}) let hotspotStarted = getMonoTime() frameOS.network.hotspotStartedAt = epochTime() pLog("portal:startAp:done") diff --git a/frameos/src/frameos/server/auth.nim b/frameos/src/frameos/server/auth.nim index 8dcb87d7f..f2408ace1 100644 --- a/frameos/src/frameos/server/auth.nim +++ b/frameos/src/frameos/server/auth.nim @@ -377,6 +377,10 @@ proc adminSessionCookieValue*(): string {.gcsafe.} = createAdminSession() proc shouldUseSecureCookie*(request: Request): bool {.gcsafe.} = + # Served by the runtime's own HTTPS listener, or by a reverse proxy that + # says so. + if request.secure: + return true let forwardedProto = getHeaderValue(request, "x-forwarded-proto").split(",", 1)[0].strip().toLowerAscii() if forwardedProto == "https": return true diff --git a/frameos/src/frameos/server/hotspot_listener.nim b/frameos/src/frameos/server/hotspot_listener.nim new file mode 100644 index 000000000..283405646 --- /dev/null +++ b/frameos/src/frameos/server/hotspot_listener.nim @@ -0,0 +1,76 @@ +## The setup hotspot's own HTTP listener. +## +## While the hotspot is up the phone that joined it talks to 10.42.0.1. When +## the frame's plain listener is not on every interface (`exposeOnlyPort` +## binds it to loopback so only HTTPS is reachable; `bindHost` pins it to +## one address) the captive portal would be unreachable exactly when it +## matters, so the runtime adds a second plain listener for the hotspot and +## removes it when the AP goes down. Caddy used to play this part as a third +## process (`setup_proxy.nim`); now it is one more listener on the same +## mummy server, added and removed while it serves. +## +## The port is the first free one from 8000, as before, so the URL on the +## hotspot scene stays short. Binding 10.42.0.1 itself keeps the listener off +## the LAN; when that address is not on the interface (a NetworkManager +## backend that has not assigned it yet, a test machine) the listener falls +## back to every interface, which is what the Caddy proxy always did. +import std/locks +import mummy +from std/net import Port +import frameos/types +import ./listeners + +const + hotspotAddress* = "10.42.0.1" + hotspotListenerFirstPort = 8000 + hotspotListenerLastPort = 8099 + +var hotspotListenerLock: Lock +initLock(hotspotListenerLock) +var activeListener: Listener = nil +var activePort = 0 + +proc hotspotListenerPort*(): int {.gcsafe.} = + ## The port the hotspot listener answers on, 0 when there is none. + withLock hotspotListenerLock: + {.cast(gcsafe).}: + result = activePort + +proc stopHotspotListenerLocked(server: mummy.Server) = + if activeListener != nil and server != nil: + server.removeListener(activeListener) + activeListener = nil + activePort = 0 + +proc stopHotspotListener*(server: mummy.Server) {.gcsafe.} = + withLock hotspotListenerLock: + {.cast(gcsafe).}: + stopHotspotListenerLocked(server) + +proc tryAddListener(server: mummy.Server, address: string): Listener = + for port in hotspotListenerFirstPort .. hotspotListenerLastPort: + try: + return server.addListener(Port(port), address) + except MummyError: + continue + nil + +proc startHotspotListener*(server: mummy.Server, frameConfig: FrameConfig): tuple[port: int, address: string] {.gcsafe.} = + ## Adds the hotspot listener when the config needs one. Returns the port + ## and address it listens on, or port 0 when none was started (not needed, + ## no server, or no free port). + withLock hotspotListenerLock: + {.cast(gcsafe).}: + stopHotspotListenerLocked(server) + if server == nil or not hotspotNeedsOwnListener(frameConfig): + return (0, "") + var address = hotspotAddress + var listener = tryAddListener(server, address) + if listener == nil: + address = "0.0.0.0" + listener = tryAddListener(server, address) + if listener == nil: + return (0, "") + activeListener = listener + activePort = listener.port.int + result = (activePort, address) diff --git a/frameos/src/frameos/server/listeners.nim b/frameos/src/frameos/server/listeners.nim new file mode 100644 index 000000000..70e87dea1 --- /dev/null +++ b/frameos/src/frameos/server/listeners.nim @@ -0,0 +1,64 @@ +## Which sockets the on-device HTTP server listens on, decided from the +## frame config alone so it can be unit-tested without binding anything. +## +## - The plain HTTP listener is always there: `framePort` (8787) on every +## interface, or on `bindHost` when set, or on loopback when the HTTPS +## listener is meant to be the only one reachable (`exposeOnlyPort`). +## - The HTTPS listener (`httpsProxy.port`, 8443) exists when HTTPS is +## enabled AND the backend has minted the frame its certificate and key. +## TLS terminates inside the runtime (a FrameOS/mummy fork with OpenSSL +## listeners); there is no separate proxy process any more. +## +## The `httpsProxy` name in the config is kept for the API's sake +## (docs/api-triality.md): the shape is the same on Pi, Buildroot and ESP32. +import frameos/types + +type + ListenerSpec* = object + address*: string + port*: int + tls*: bool + +proc serverPort*(frameConfig: FrameConfig): int = + if frameConfig.framePort == 0: 8787 else: frameConfig.framePort + +proc httpsPort*(frameConfig: FrameConfig): int = + if frameConfig.httpsProxy != nil and frameConfig.httpsProxy.port > 0: + frameConfig.httpsProxy.port + else: + 8443 + +proc httpsEnabled*(frameConfig: FrameConfig): bool = + frameConfig.httpsProxy != nil and frameConfig.httpsProxy.enable + +proc hasTlsMaterial*(frameConfig: FrameConfig): bool = + frameConfig.httpsProxy != nil and + frameConfig.httpsProxy.serverCert.len > 0 and + frameConfig.httpsProxy.serverKey.len > 0 + +proc serverBindAddress*(frameConfig: FrameConfig): string = + if frameConfig.bindHost.len > 0: + frameConfig.bindHost + elif httpsEnabled(frameConfig) and frameConfig.httpsProxy.exposeOnlyPort: + "127.0.0.1" + else: + "0.0.0.0" + +proc httpsBindAddress*(frameConfig: FrameConfig): string = + if frameConfig.bindHost.len > 0: frameConfig.bindHost else: "0.0.0.0" + +proc planListeners*(frameConfig: FrameConfig): seq[ListenerSpec] = + ## The listeners the server should open, plain first. An enabled HTTPS + ## setting without certificate material yields no TLS listener; the + ## caller logs that (`tls:default_cert`) rather than serving a made-up + ## certificate. + result.add(ListenerSpec(address: serverBindAddress(frameConfig), port: serverPort(frameConfig), tls: false)) + if httpsEnabled(frameConfig) and hasTlsMaterial(frameConfig): + result.add(ListenerSpec(address: httpsBindAddress(frameConfig), port: httpsPort(frameConfig), tls: true)) + +proc hotspotNeedsOwnListener*(frameConfig: FrameConfig): bool = + ## The setup hotspot hands phones 10.42.0.1; a plain listener that is not + ## on every interface (loopback for `exposeOnlyPort`, or a `bindHost`) + ## cannot be reached from there, so the portal gets a listener of its own + ## for as long as the hotspot is up. + serverBindAddress(frameConfig) != "0.0.0.0" diff --git a/frameos/src/frameos/server/origin.nim b/frameos/src/frameos/server/origin.nim index 77167aac8..6119fd52e 100644 --- a/frameos/src/frameos/server/origin.nim +++ b/frameos/src/frameos/server/origin.nim @@ -6,8 +6,9 @@ ## send none. So a state-changing request (anything but GET/HEAD/OPTIONS) or ## a WebSocket upgrade that carries an Origin must name the host the request ## was addressed to: the page that issued it was served by this frame, -## directly or through the setup TLS proxy (caddy forwards `Host` unchanged -## and adds `X-Forwarded-Host`). A page on another site that POSTs at the +## directly (plain or its own HTTPS listener) or through a reverse proxy of +## the owner's (which forwards `Host` unchanged and adds `X-Forwarded-Host`). +## A page on another site that POSTs at the ## frame's LAN address is refused with 403 — classic CSRF, which ## `SameSite=Lax` only covers for cookie-holding sessions, not a `public` or ## `protected` frame that needs no cookie. @@ -99,10 +100,12 @@ proc sameOriginAllowed*(request: Request): bool = ## route's own authentication. if not needsSameOrigin(request) or not request.headers.contains("Origin"): return true + # `request.secure` is the runtime's own HTTPS listener; the forwarded + # header keeps working for a reverse proxy in front of the frame. let forwardedProto = request.headers["X-Forwarded-Proto"].split(',', 1)[0].strip().toLowerAscii() originMatchesHost( request.headers["Origin"], request.headers["Host"], forwardedHost = request.headers["X-Forwarded-Host"], - requestIsHttps = forwardedProto == "https", + requestIsHttps = request.secure or forwardedProto == "https", ) diff --git a/frameos/src/frameos/server/routes/cloud_api_routes.nim b/frameos/src/frameos/server/routes/cloud_api_routes.nim index ed6107df9..1dad07fcb 100644 --- a/frameos/src/frameos/server/routes/cloud_api_routes.nim +++ b/frameos/src/frameos/server/routes/cloud_api_routes.nim @@ -100,7 +100,7 @@ proc localOrigin*(request: Request): string = ## redirect target of a later sign-in — see /api/cloud/login/start. let forwardedProto = requestHeader(request, "x-forwarded-proto") .split(",", 1)[0].strip().toLowerAscii() - let scheme = if forwardedProto == "https": "https" else: "http" + let scheme = if request.secure or forwardedProto == "https": "https" else: "http" var host = requestHeader(request, "host") if host.len == 0: host = "localhost" diff --git a/frameos/src/frameos/server/server.nim b/frameos/src/frameos/server/server.nim index 0aa434c51..e2f9bc331 100644 --- a/frameos/src/frameos/server/server.nim +++ b/frameos/src/frameos/server/server.nim @@ -13,7 +13,9 @@ import ./state import ./auth import ./routes import ./workers +import ./listeners export workers.httpWorkerThreads +export listeners proc shouldLogHttpRequest*(path: string): bool = if path == "/ws" or path == "/ws/admin": @@ -28,6 +30,23 @@ proc shouldLogHttpRequest*(path: string): bool = return false true +proc mummyLogHandler(level: LogLevel, args: varargs[string]) {.gcsafe.} = + ## mummy's own log lines (a handler exception, a dropped connection, a TLS + ## handshake) go through the frame logger instead of stdout. Its debug + ## level is per-connection noise, kept for frames with `debug` on. + var message = "" + for arg in args: + message.add(arg) + case level: + of ErrorLevel: + log(%*{"event": "http:error", "message": message}) + of InfoLevel: + log(%*{"event": "http:info", "message": message}) + of DebugLevel: + {.gcsafe.}: + if globalFrameConfig != nil and globalFrameConfig.debug: + log(%*{"event": "http:debug", "message": message}) + proc makeWebsocketHandler(publicState: ConnectionsState, adminState: ConnectionsState): WebSocketHandler = result = proc(websocket: WebSocket, event: WebSocketEvent, message: Message) {.closure, gcsafe.} = case event: @@ -101,6 +120,7 @@ proc newServer*(frameOS: FrameOS): types.Server = let mummyServer = mummy.newServer( loggingHandler, makeWebsocketHandler(connectionsState, adminConnectionsState), + logHandler = mummyLogHandler, workerThreads = workerThreads, maxBodyLen = MAX_HTTP_BODY_LEN ) @@ -113,27 +133,70 @@ proc newServer*(frameOS: FrameOS): types.Server = connectionsState: connectionsState, ) -proc serverPort*(frameConfig: FrameConfig): int = - if frameConfig.framePort == 0: 8787 else: frameConfig.framePort - -proc serverBindAddress*(frameConfig: FrameConfig): string = - if frameConfig.bindHost.len > 0: - frameConfig.bindHost - elif frameConfig.httpsProxy.enable and frameConfig.httpsProxy.exposeOnlyPort: - "127.0.0.1" +proc addTlsListener(self: types.Server, spec: ListenerSpec): bool = + ## The HTTPS listener is best effort: a certificate the runtime cannot + ## load or a port it cannot bind is logged and the frame stays reachable + ## over plain HTTP, exactly as when the Caddy proxy failed to start. + when defined(ssl): + var tls: TlsConfig + try: + tls = newTlsConfig(self.frameConfig.httpsProxy.serverCert, self.frameConfig.httpsProxy.serverKey) + except MummyError as e: + log(%*{ + "event": "tls:config_error", + "message": "Could not load the frame's TLS certificate or key, HTTPS stays off", + "error": e.msg, + }) + return false + try: + discard self.mummy.addListener(Port(spec.port), spec.address, tls) + except MummyError as e: + log(%*{ + "event": "tls:start_error", + "message": "Could not open the HTTPS listener", + "error": e.msg, + "port": spec.port, + "address": spec.address, + }) + return false + log(%*{ + "event": "tls:start", + "message": "Serving HTTPS", + "port": spec.port, + "address": spec.address, + }) + true else: - "0.0.0.0" + log(%*{ + "event": "tls:start_error", + "message": "This build has no OpenSSL support, HTTPS stays off", + "port": spec.port, + }) + false proc startServer*(self: types.Server) = + let specs = planListeners(self.frameConfig) log(%*{ "event": "http:start", "message": "Starting web server", "workerThreads": self.httpWorkerThreads, + "listeners": %*(specs), }) # mummy.serve blocks this thread, so run render notifications in a background thread. createThread(renderThread, listenForRenderThread, (self.connectionsState, globalAdminConnectionsState)) createThread(logThread, listenForLogThread, globalAdminConnectionsState) - let port = serverPort(self.frameConfig).Port - let bindAddr = serverBindAddress(self.frameConfig) - self.mummy.serve(port = port, address = bindAddr) + if httpsEnabled(self.frameConfig) and not hasTlsMaterial(self.frameConfig): + log(%*{ + "event": "tls:default_cert", + "message": "No TLS certificate provided, can't enable HTTPS", + }) + + for spec in specs: + if spec.tls: + discard self.addTlsListener(spec) + else: + # The plain listener is not optional: failing to bind it is fatal, as + # it always was, and systemd restarts the runtime. + discard self.mummy.addListener(Port(spec.port), spec.address) + self.mummy.serve() diff --git a/frameos/src/frameos/server/tests/helpers/http_harness.nim b/frameos/src/frameos/server/tests/helpers/http_harness.nim index 5f3ad1705..12ad5a01d 100644 --- a/frameos/src/frameos/server/tests/helpers/http_harness.nim +++ b/frameos/src/frameos/server/tests/helpers/http_harness.nim @@ -126,6 +126,13 @@ proc startRouterServer*(port: int): TestServer = sleep(150) proc stopServer*(testServer: var TestServer) = + # The router handler is a closure built on this thread; mummy frees it on + # its serving thread when the server is destroyed. ORC's cycle-candidate + # roots are per thread, so a closure environment still registered as a + # root here crashes that thread in unregisterCycle. A collection now + # clears this thread's registrations (it did so by luck in tests with + # enough allocation churn; the TLS listener test had too little). + GC_fullCollect() testServer.server.close() joinThread(testServer.thread) diff --git a/frameos/src/frameos/server/tests/test_hotspot_listener.nim b/frameos/src/frameos/server/tests/test_hotspot_listener.nim new file mode 100644 index 000000000..d2567153c --- /dev/null +++ b/frameos/src/frameos/server/tests/test_hotspot_listener.nim @@ -0,0 +1,85 @@ +import std/[net, os, unittest] + +import ./helpers/http_harness +import ../hotspot_listener +import ../../types +import ../../utils/url + +var server = startRouterServer(19338) + +proc exposeOnlyConfig(): FrameConfig = + result = defaultFrameConfig() + result.httpsProxy = HttpsProxyConfig(enable: true, port: 8443, exposeOnlyPort: true) + +proc connectionRefused(port: int): bool = + let probe = newSocket() + try: + probe.connect("127.0.0.1", Port(port), timeout = 1000) + probe.close() + false + except OSError: + true + +suite "hotspot listener": + setup: + drainEventChannel() + stopHotspotListener(server.server) + + teardown: + stopHotspotListener(server.server) + + test "no listener when plain HTTP already reaches every interface": + let (port, address) = startHotspotListener(server.server, defaultFrameConfig()) + check port == 0 + check address == "" + check hotspotListenerPort() == 0 + check hotspotSetupPort(defaultFrameConfig()) == 8787 + + test "expose-only HTTPS gets a listener the portal can reach": + let config = exposeOnlyConfig() + configureServerState(config, hotspotActive = true) + let (port, address) = startHotspotListener(server.server, config) + check port >= 8000 and port <= 8099 + # 10.42.0.1 is not on this machine, so the listener fell back to every + # interface, as the Caddy setup proxy always bound. + check address in ["10.42.0.1", "0.0.0.0"] + check hotspotListenerPort() == port + check hotspotSetupPort(config) == port + + # The removal is applied by the serving thread; give it a moment. + var response: TestResponse + for attempt in 0 ..< 50: + try: + response = httpRequest(port, "GET", "/setup/status") + break + except OSError: + sleep(20) + check response.status == 200 + + test "starting again replaces the listener, stopping frees the port": + let config = exposeOnlyConfig() + configureServerState(config, hotspotActive = true) + let (first, _) = startHotspotListener(server.server, config) + check first > 0 + let (second, _) = startHotspotListener(server.server, config) + check second > 0 + check hotspotListenerPort() == second + + stopHotspotListener(server.server) + check hotspotListenerPort() == 0 + check hotspotSetupPort(config) == 8787 + var refused = false + for attempt in 0 ..< 100: + if connectionRefused(second): + refused = true + break + sleep(20) + check refused + + test "a nil server is tolerated": + let (port, _) = startHotspotListener(nil, exposeOnlyConfig()) + check port == 0 + stopHotspotListener(nil) + check hotspotListenerPort() == 0 + +stopServer(server) diff --git a/frameos/src/frameos/server/tests/test_listeners.nim b/frameos/src/frameos/server/tests/test_listeners.nim new file mode 100644 index 000000000..9b9745fb6 --- /dev/null +++ b/frameos/src/frameos/server/tests/test_listeners.nim @@ -0,0 +1,94 @@ +import std/unittest + +import ../listeners +import ../../types + +proc makeConfig( + framePort = 8787, + bindHost = "", + httpsEnabled = false, + httpsPort = 8443, + exposeOnlyPort = false, + serverCert = "", + serverKey = "" +): FrameConfig = + FrameConfig( + framePort: framePort, + bindHost: bindHost, + httpsProxy: HttpsProxyConfig( + enable: httpsEnabled, + port: httpsPort, + exposeOnlyPort: exposeOnlyPort, + serverCert: serverCert, + serverKey: serverKey, + ), + ) + +suite "listener planning": + test "plain HTTP on every interface by default": + let specs = planListeners(makeConfig()) + check specs == @[ListenerSpec(address: "0.0.0.0", port: 8787, tls: false)] + + test "frame port zero means 8787": + check serverPort(makeConfig(framePort = 0)) == 8787 + check planListeners(makeConfig(framePort = 0))[0].port == 8787 + + test "bindHost pins the plain listener": + let specs = planListeners(makeConfig(bindHost = "192.168.1.20", framePort = 9000)) + check specs == @[ListenerSpec(address: "192.168.1.20", port: 9000, tls: false)] + + test "HTTPS with certificate material adds a TLS listener": + let specs = planListeners(makeConfig(httpsEnabled = true, httpsPort = 9443, serverCert = "cert", serverKey = "key")) + check specs == @[ + ListenerSpec(address: "0.0.0.0", port: 8787, tls: false), + ListenerSpec(address: "0.0.0.0", port: 9443, tls: true), + ] + + test "HTTPS port zero means 8443": + let specs = planListeners(makeConfig(httpsEnabled = true, httpsPort = 0, serverCert = "cert", serverKey = "key")) + check specs[1].port == 8443 + + test "HTTPS without certificate material stays plain only": + # The runtime logs tls:default_cert for this case instead of serving a + # made-up certificate. + let config = makeConfig(httpsEnabled = true, serverCert = "cert") + check httpsEnabled(config) + check not hasTlsMaterial(config) + check planListeners(config) == @[ListenerSpec(address: "0.0.0.0", port: 8787, tls: false)] + + test "HTTPS disabled ignores certificate material": + let config = makeConfig(httpsEnabled = false, serverCert = "cert", serverKey = "key") + check planListeners(config).len == 1 + + test "exposeOnlyPort keeps plain HTTP on loopback and TLS public": + let specs = planListeners(makeConfig(httpsEnabled = true, exposeOnlyPort = true, serverCert = "cert", serverKey = "key")) + check specs == @[ + ListenerSpec(address: "127.0.0.1", port: 8787, tls: false), + ListenerSpec(address: "0.0.0.0", port: 8443, tls: true), + ] + + test "exposeOnlyPort without HTTPS does not hide the plain listener": + let specs = planListeners(makeConfig(httpsEnabled = false, exposeOnlyPort = true)) + check specs[0].address == "0.0.0.0" + + test "bindHost applies to both listeners": + let specs = planListeners(makeConfig(bindHost = "10.0.0.5", httpsEnabled = true, serverCert = "cert", serverKey = "key")) + check specs[0].address == "10.0.0.5" + check specs[1].address == "10.0.0.5" + + test "a missing httpsProxy block is plain HTTP": + let config = FrameConfig(framePort: 8787) + check not httpsEnabled(config) + check not hasTlsMaterial(config) + check planListeners(config) == @[ListenerSpec(address: "0.0.0.0", port: 8787, tls: false)] + +suite "hotspot listener need": + test "not needed when plain HTTP is on every interface": + check not hotspotNeedsOwnListener(makeConfig()) + check not hotspotNeedsOwnListener(makeConfig(httpsEnabled = true, serverCert = "cert", serverKey = "key")) + check not hotspotNeedsOwnListener(makeConfig(httpsEnabled = false, exposeOnlyPort = true)) + + test "needed when the plain listener is on loopback or pinned": + check hotspotNeedsOwnListener(makeConfig(httpsEnabled = true, exposeOnlyPort = true)) + check hotspotNeedsOwnListener(makeConfig(bindHost = "127.0.0.1")) + check not hotspotNeedsOwnListener(makeConfig(bindHost = "0.0.0.0")) diff --git a/frameos/src/frameos/server/tests/test_tls_listener.nim b/frameos/src/frameos/server/tests/test_tls_listener.nim new file mode 100644 index 000000000..116e37474 --- /dev/null +++ b/frameos/src/frameos/server/tests/test_tls_listener.nim @@ -0,0 +1,167 @@ +## The frame's own HTTPS listener, end to end through the real router: a +## TLS listener added next to the harness's plain one, a std/net TLS client, +## and the two places that must know the request was secure without an +## X-Forwarded-Proto header — the Secure cookie flag and the same-origin +## guard's default port. +import std/[json, net, os, strutils, tables, unittest] +import mummy + +import ./helpers/http_harness +import ../../types + +const + tlsPort = 19443 + # Self-signed P-256 for localhost/127.0.0.1, valid until 2126, in the + # SEC 1 "EC PRIVATE KEY" form the backend mints (tls.py). + testCert = """-----BEGIN CERTIFICATE----- +MIIBmjCCAUGgAwIBAgIUZamAN7dinEGglG7utfMsGMaoY5swCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDkxMjEyMjAwMFoYDzIxMjYwODE5 +MTIyMDAwWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwWTATBgcqhkjOPQIBBggqhkjO +PQMBBwNCAASMI/JAkpfQaY6MDcq29H17JILDjmU+ewB91LvQhsxdpaE5OaszaSUA +ZNK9QzwNkdAzyY1K0TAToIc5i46mQGm7o28wbTAdBgNVHQ4EFgQUJ4P/LWfu8ZcB +SLelGeRgm5ioawQwHwYDVR0jBBgwFoAUJ4P/LWfu8ZcBSLelGeRgm5ioawQwDwYD +VR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SHBH8AAAEwCgYIKoZI +zj0EAwIDRwAwRAIgId13ZagrbcVFwPpJKQoawNrBB0m0zXb9UAKYErVrt+gCIBvt +ed4IFxd0P+pnRIL9P6rkTp35FXAiA3YX696h4QuJ +-----END CERTIFICATE----- +""" + testKey = """-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIJyAmtKAVs7XPLTMJD7guygpiNJd3O9y2MtywgoTnARroAoGCCqGSM49 +AwEHoUQDQgAEjCPyQJKX0GmOjA3KtvR9eySCw45lPnsAfdS70IbMXaWhOTmrM2kl +AGTSvUM8DZHQM8mNStEwE6CHOYuOpkBpuw== +-----END EC PRIVATE KEY----- +""" + +var server = startRouterServer(19337) +let tls = newTlsConfig(testCert, testKey) +let tlsListener = server.server.addListener(Port(tlsPort), "127.0.0.1", tls) + +proc httpsRequest( + httpMethod: string, + path: string, + headers: openArray[(string, string)] = [], + body = "" + ): TestResponse = + ## Like the harness's httpRequest, over TLS. + var socket = newSocket() + let ctx = newContext(verifyMode = CVerifyNone) + ctx.wrapSocket(socket) + var connected = false + for attempt in 0 ..< 50: + try: + socket.connect("127.0.0.1", Port(tlsPort)) + connected = true + break + except OSError: + # The listener is registered by the serving thread's next iteration + sleep(20) + doAssert connected, "TLS listener never came up" + + var requestLines = @[httpMethod & " " & path & " HTTP/1.1"] + requestLines.add("Host: 127.0.0.1:" & $tlsPort) + requestLines.add("Connection: close") + for (name, value) in headers: + requestLines.add(name & ": " & value) + if body.len > 0: + requestLines.add("Content-Length: " & $body.len) + requestLines.add("") + requestLines.add(body) + socket.send(requestLines.join("\c\L")) + + var raw = "" + while true: + var chunk = "" + try: + chunk = socket.recv(4096, timeout = 5000) + except CatchableError: + break + if chunk.len == 0: + break + raw.add(chunk) + socket.close() + + let headerEnd = raw.find("\c\L\c\L") + doAssert headerEnd >= 0, "no HTTP response over TLS: " & raw + let head = raw[0 ..< headerEnd] + result.body = if headerEnd + 4 <= raw.high: raw[(headerEnd + 4) .. raw.high] else: "" + let lines = head.split("\c\L") + result.status = parseInt(lines[0].split(" ")[1]) + for i in 1 ..< lines.len: + let splitAt = lines[i].find(':') + if splitAt <= 0: + continue + result.headers[lines[i][0 ..< splitAt].strip().toLowerAscii()] = lines[i][(splitAt + 1) .. ^1].strip() + +suite "HTTPS listener": + setup: + drainEventChannel() + configureServerState(defaultFrameConfig()) + + test "the listener reports itself as TLS": + check tlsListener.secure + check tlsListener.port == Port(tlsPort) + + test "the same route answers on both listeners": + let plain = httpRequest(server.port, "GET", "/setup/status") + let secure = httpsRequest("GET", "/setup/status") + check plain.status == 200 + check secure.status == 200 + check parseJson(secure.body).hasKey("hotspot") + + test "cookies set over the frame's own HTTPS carry Secure without a proxy header": + let plain = httpRequest(server.port, "GET", "/?k=test-key") + check plain.status == 302 + check plain.header("set-cookie").contains("frame_access_key=test-key") + check "Secure" notin plain.header("set-cookie") + + let secure = httpsRequest("GET", "/?k=test-key") + check secure.status == 302 + check secure.header("set-cookie").contains("frame_access_key=test-key") + check secure.header("set-cookie").contains("Secure") + + test "the same-origin guard fills in 443 for an https Origin on the TLS listener": + # Admin auth is off in the default config: reaching the route is a 401 + # with the route's own message, the guard's refusal is a 403. + let own = httpsRequest( + "POST", + "/api/admin/login", + headers = [("Origin", "https://127.0.0.1:" & $tlsPort), ("Content-Type", "application/json")], + body = $(%*{"username": "admin", "password": "secret"}), + ) + check own.status == 401 + + let foreign = httpsRequest( + "POST", + "/api/admin/login", + headers = [("Origin", "https://evil.example"), ("Content-Type", "application/json")], + body = $(%*{"username": "admin", "password": "secret"}), + ) + check foreign.status == 403 + + test "a POST body arrives intact over TLS": + let body = "x".repeat(200_000) + let response = httpsRequest( + "POST", + "/api/admin/login", + headers = [("Content-Type", "application/json")], + body = body, + ) + # Not JSON, but the whole body was read before the route answered + check response.status in [400, 401] + + test "the listener can be removed while serving": + server.server.removeListener(tlsListener) + var refused = false + for attempt in 0 ..< 100: + let probe = newSocket() + try: + probe.connect("127.0.0.1", Port(tlsPort), timeout = 1000) + probe.close() + sleep(20) + except OSError: + refused = true + break + check refused + check httpRequest(server.port, "GET", "/setup/status").status == 200 + +stopServer(server) diff --git a/frameos/src/frameos/setup_proxy.nim b/frameos/src/frameos/setup_proxy.nim deleted file mode 100644 index 2a0a5cb04..000000000 --- a/frameos/src/frameos/setup_proxy.nim +++ /dev/null @@ -1,106 +0,0 @@ -import net -import os -import osproc -import strformat -import locks -import frameos/types -import frameos/utils/process - -var setupProxyLock: Lock -initLock(setupProxyLock) -var setupProxyProcess: Process = nil -var setupProxyActivePort: int = 0 - -proc setupProxyPort*(): int {.gcsafe.} = - withLock setupProxyLock: - result = setupProxyActivePort - -proc stopSetupProxyLocked() = - ## Stops caddy via the Process handle: signalling a stored PID could hit an - ## unrelated process after PID reuse, and never reaping the child left a - ## zombie behind every hotspot start/stop cycle. - if setupProxyProcess == nil: - setupProxyActivePort = 0 - return - - setupProxyProcess.stopProcess() - try: - close(setupProxyProcess) - except CatchableError: - discard - setupProxyProcess = nil - setupProxyActivePort = 0 - -proc stopSetupProxy*() {.gcsafe.} = - withLock setupProxyLock: - {.cast(gcsafe).}: - stopSetupProxyLocked() - -proc findFreePort(startPort: int): int = - for port in startPort..65535: - var socket: Socket = nil - try: - socket = newSocket() - socket.bindAddr(Port(port)) - close(socket) - return port - except CatchableError: - if socket != nil: - try: - close(socket) - except CatchableError: - discard - return 0 - -proc startSetupProxy*(frameConfig: FrameConfig) {.gcsafe.} = - withLock setupProxyLock: - {.cast(gcsafe).}: - stopSetupProxyLocked() - - if not frameConfig.httpsProxy.enable or not frameConfig.httpsProxy.exposeOnlyPort: - return - - let proxyPort = findFreePort(8000) - if proxyPort == 0: - return - - let upstreamPort = if frameConfig.framePort > 0: frameConfig.framePort else: 8787 - let caddyVendorPath = getEnv("FRAMEOS_TLS_PROXY_VENDOR_PATH", "/srv/frameos/vendor/caddy") - let caddyfilePath = caddyVendorPath / "frameos-setup-proxy-caddyfile" - - try: - if not dirExists(caddyVendorPath): - createDir(caddyVendorPath) - except CatchableError: - return - - let caddyfileContents = fmt"""{{ - admin off - auto_https off -}} -:{proxyPort} {{ - reverse_proxy 127.0.0.1:{upstreamPort} -}} -""" - - try: - writeFile(caddyfilePath, caddyfileContents) - except CatchableError: - return - - try: - setupProxyProcess = startProcessSerialized( - "caddy", - args = @[ - "run", - "--config", - caddyfilePath, - "--adapter", - "caddyfile", - ], - options = {poUsePath, poParentStreams} - ) - setupProxyActivePort = proxyPort - except CatchableError: - setupProxyProcess = nil - setupProxyActivePort = 0 diff --git a/frameos/src/frameos/tests/test_setup_proxy.nim b/frameos/src/frameos/tests/test_setup_proxy.nim deleted file mode 100644 index 9e7793dcd..000000000 --- a/frameos/src/frameos/tests/test_setup_proxy.nim +++ /dev/null @@ -1,95 +0,0 @@ -import std/[os, osproc, sequtils, strutils, times, unittest] - -import ../setup_proxy -import ../types - -proc makeConfig(enable: bool, exposeOnly: bool, framePort = 8787): FrameConfig = - FrameConfig( - framePort: framePort, - httpsProxy: HttpsProxyConfig( - enable: enable, - port: 8443, - exposeOnlyPort: exposeOnly, - ), - ) - -proc makeFakeCaddyBin(dirPath: string) = - let scriptPath = dirPath / "caddy" - writeFile(scriptPath, "#!/bin/sh\nif [ -n \"$FRAMEOS_FAKE_CADDY_PIDS\" ]; then\n echo \"$$\" >> \"$FRAMEOS_FAKE_CADDY_PIDS\"\nfi\ntrap 'exit 0' TERM INT\nwhile :; do\n sleep 1\ndone\n") - discard execCmdEx("chmod +x " & quoteShell(scriptPath)) - -# Spawning the fake caddy is a fork+exec of /bin/sh; on a CI runner that is -# compiling three other test files at the same time that has been seen to -# take well over a second. The poll returns the moment the condition holds, -# so a long ceiling only costs time when something is actually wrong. -proc waitFor(condition: proc(): bool {.closure.}, timeoutMs = 15000, pollMs = 20): bool = - let deadline = epochTime() + (float(timeoutMs) / 1000.0) - while epochTime() < deadline: - if condition(): - return true - sleep(pollMs) - condition() - -suite "setup proxy lifecycle": - let tempRoot = "tmp/frameos-setup-proxy-tests" - let vendorPath = tempRoot / "vendor" - let binPath = tempRoot / "bin" - let pidsPath = tempRoot / "pids.txt" - let oldPath = getEnv("PATH", "") - - setup: - stopSetupProxy() - if not dirExists(tempRoot): - createDir(tempRoot) - if not dirExists(vendorPath): - createDir(vendorPath) - if not dirExists(binPath): - createDir(binPath) - makeFakeCaddyBin(binPath) - if fileExists(pidsPath): - removeFile(pidsPath) - - putEnv("FRAMEOS_TLS_PROXY_VENDOR_PATH", vendorPath) - putEnv("FRAMEOS_FAKE_CADDY_PIDS", pidsPath) - putEnv("PATH", binPath & ":" & oldPath) - - teardown: - stopSetupProxy() - putEnv("PATH", oldPath) - - test "start with expose-only mode sets active port": - startSetupProxy(makeConfig(enable = true, exposeOnly = true, framePort = 9123)) - let activePort = setupProxyPort() - check activePort >= 0 - - let caddyfile = vendorPath / "frameos-setup-proxy-caddyfile" - if activePort > 0: - check fileExists(caddyfile) - let caddyConf = readFile(caddyfile) - check caddyConf.contains("reverse_proxy 127.0.0.1:9123") - else: - check not fileExists(caddyfile) - - test "restarting proxy terminates previous process": - startSetupProxy(makeConfig(enable = true, exposeOnly = true)) - if setupProxyPort() > 0: - check waitFor(proc(): bool = fileExists(pidsPath)) - startSetupProxy(makeConfig(enable = true, exposeOnly = true)) - check waitFor(proc(): bool = readFile(pidsPath).splitLines().filterIt(it.len > 0).len >= 2) - - let lines = readFile(pidsPath).splitLines().filterIt(it.len > 0) - check lines.len >= 2 - if lines.len >= 2: - let firstPid = parseInt(lines[0]) - let secondPid = parseInt(lines[1]) - check firstPid != secondPid - check setupProxyPort() >= 8000 - else: - check setupProxyPort() == 0 - - test "disabled proxy does not keep active port": - startSetupProxy(makeConfig(enable = false, exposeOnly = true)) - check setupProxyPort() == 0 - - startSetupProxy(makeConfig(enable = true, exposeOnly = false)) - check setupProxyPort() == 0 diff --git a/frameos/src/frameos/tests/test_tls_proxy.nim b/frameos/src/frameos/tests/test_tls_proxy.nim deleted file mode 100644 index 750525c17..000000000 --- a/frameos/src/frameos/tests/test_tls_proxy.nim +++ /dev/null @@ -1,117 +0,0 @@ -import json -import os -import strutils -import sequtils -import times -import unittest - -import ../tls_proxy -import ../types - -var loggedEvents: seq[string] = @[] - -proc testLogger(): Logger = - Logger( - log: proc(payload: JsonNode) = - loggedEvents.add(payload{"event"}.getStr("")) - ) - -proc tlsConfig(port: int): FrameConfig = - FrameConfig( - httpsProxy: HttpsProxyConfig( - enable: true, - port: port, - exposeOnlyPort: true, - serverCert: "test-cert", - serverKey: "test-key", - ), - framePort: 8787, - ) - -proc waitUntil(predicate: proc(): bool {.closure.}, timeoutMs = 2000, stepMs = 50): bool = - let startedAt = epochTime() - while (epochTime() - startedAt) * 1000 < timeoutMs.float: - if predicate(): - return true - sleep(stepMs) - predicate() - -proc writeFakeCaddy(tempDir, pidFile, stoppedFile, vendorDir: string) = - let fakeCaddyPath = tempDir / "caddy" - writeFile(fakeCaddyPath, """#!/bin/sh -set -eu -printf '%s\n' "$$" >> "$TLS_PROXY_TEST_PID_FILE" -trap 'printf "%s\n" "$$" >> "$TLS_PROXY_TEST_STOPPED_FILE"; exit 0' TERM INT -while true; do sleep 1; done -""") - setFilePermissions(fakeCaddyPath, {fpUserRead, fpUserWrite, fpUserExec}) - putEnv("TLS_PROXY_TEST_PID_FILE", pidFile) - putEnv("TLS_PROXY_TEST_STOPPED_FILE", stoppedFile) - putEnv("FRAMEOS_TLS_PROXY_VENDOR_PATH", vendorDir) - -suite "TLS proxy lifecycle": - test "starting when one is already running stops previous process": - loggedEvents = @[] - let logger = testLogger() - let oldPath = getEnv("PATH") - let tempDir = getTempDir() / "frameos-test-tls-proxy-restart" - createDir(tempDir) - let pidFile = tempDir / "pids.txt" - let stoppedFile = tempDir / "stopped.txt" - if fileExists(pidFile): - removeFile(pidFile) - if fileExists(stoppedFile): - removeFile(stoppedFile) - let vendorDir = tempDir / "vendor" - writeFakeCaddy(tempDir, pidFile, stoppedFile, vendorDir) - putEnv("PATH", tempDir & ":" & oldPath) - - startTlsProxy(tlsConfig(18443), logger) - check waitUntil(proc(): bool = fileExists(pidFile)) - let firstPid = readFile(pidFile).splitLines().filterIt(it.len > 0)[0] - - startTlsProxy(tlsConfig(18443), logger) - - check waitUntil(proc(): bool = fileExists(stoppedFile)) - let stoppedPids = readFile(stoppedFile).splitLines().filterIt(it.len > 0) - check firstPid in stoppedPids - - check waitUntil(proc(): bool = - fileExists(pidFile) and readFile(pidFile).splitLines().filterIt(it.len > 0).len >= 2 - ) - let startedPids = readFile(pidFile).splitLines().filterIt(it.len > 0) - check startedPids.len >= 2 - - stopTlsProxy(logger) - putEnv("PATH", oldPath) - delEnv("FRAMEOS_TLS_PROXY_VENDOR_PATH") - - test "starting with TLS disabled stops existing proxy": - loggedEvents = @[] - let logger = testLogger() - let oldPath = getEnv("PATH") - let tempDir = getTempDir() / "frameos-test-tls-proxy-disable" - createDir(tempDir) - let pidFile = tempDir / "pids.txt" - let stoppedFile = tempDir / "stopped.txt" - if fileExists(pidFile): - removeFile(pidFile) - if fileExists(stoppedFile): - removeFile(stoppedFile) - let vendorDir = tempDir / "vendor" - writeFakeCaddy(tempDir, pidFile, stoppedFile, vendorDir) - putEnv("PATH", tempDir & ":" & oldPath) - - startTlsProxy(tlsConfig(19443), logger) - check waitUntil(proc(): bool = fileExists(pidFile)) - let firstPid = readFile(pidFile).splitLines().filterIt(it.len > 0)[0] - - startTlsProxy(FrameConfig(httpsProxy: HttpsProxyConfig(enable: false)), logger) - - check waitUntil(proc(): bool = fileExists(stoppedFile)) - let stoppedPids = readFile(stoppedFile).splitLines().filterIt(it.len > 0) - check firstPid in stoppedPids - - stopTlsProxy(logger) - putEnv("PATH", oldPath) - delEnv("FRAMEOS_TLS_PROXY_VENDOR_PATH") diff --git a/frameos/src/frameos/tls_proxy.nim b/frameos/src/frameos/tls_proxy.nim deleted file mode 100644 index 6e7c6ba3a..000000000 --- a/frameos/src/frameos/tls_proxy.nim +++ /dev/null @@ -1,176 +0,0 @@ -import json -import locks -import os -import osproc -import strformat -import frameos/types -import frameos/utils/process -import frameos/utils/system - -# Guards the process handle and desired-state flags: the monitor thread, -# the main thread (start/stop on boot/shutdown) and the runner ("reload") -# all touch them. -var tlsProxyLock: Lock -initLock(tlsProxyLock) - -var tlsProxyProcess: Process -var tlsProxyDesired = false -var monitorThread: Thread[void] -var monitorStarted = false -var monitorConfig: FrameConfig -var monitorLogger: Logger - -const - TlsProxyMonitorIntervalMs = 10_000 - TlsProxyRestartMinIntervalMs = 30_000 - -proc stopTlsProxyLocked(logger: Logger) = - if tlsProxyProcess == nil: - return - - tlsProxyProcess.stopProcess() - - try: - close(tlsProxyProcess) - except CatchableError: - discard - - tlsProxyProcess = nil - - if logger != nil: - logger.log(%*{ - "event": "tls:stop", - "message": "Stopped Caddy TLS proxy", - }) - -proc startTlsProxyLocked(frameConfig: FrameConfig, logger: Logger) = - stopTlsProxyLocked(logger) - - if not frameConfig.httpsProxy.enable: - return - let hasCustomCert = frameConfig.httpsProxy.serverCert.len > 0 and frameConfig.httpsProxy.serverKey.len > 0 - if not hasCustomCert: - logger.log(%*{ - "event": "tls:default_cert", - "message": "No custom TLS certificate provided, can't enable Caddy TLS proxy", - }) - return - - let tlsPort = if frameConfig.httpsProxy.port > 0: frameConfig.httpsProxy.port else: 8443 - let upstreamPort = if frameConfig.framePort > 0: frameConfig.framePort else: 8787 - let caddyVendorPath = getEnv("FRAMEOS_TLS_PROXY_VENDOR_PATH", "/srv/frameos/vendor/caddy") - let certPath = caddyVendorPath / "frameos-tls-cert.pem" - let keyPath = caddyVendorPath / "frameos-tls-key.pem" - let caddyfilePath = caddyVendorPath / "frameos-caddyfile" - let readmePath = caddyVendorPath / "README" - - try: - if not dirExists(caddyVendorPath): - createDir(caddyVendorPath) - writeFile(readmePath, "This directory is manged by FrameOS on start if the TLS config changes. Do not modify any files here yourself. ") - except CatchableError as error: - logger.log(%*{ - "event": "tls:vendor_dir_error", - "message": "Failed to create Caddy vendor directory", - "error": error.msg, - "path": caddyVendorPath, - }) - return - - try: - writeFile(certPath, frameConfig.httpsProxy.serverCert) - # The private key: 0600 from creation, never a world-readable window. - writePrivateFile(keyPath, frameConfig.httpsProxy.serverKey) - except CatchableError as error: - logger.log(%*{ - "event": "tls:cert_write_error", - "message": "Failed to write custom TLS certificate files", - "error": error.msg, - }) - return - - let caddyfileContents = fmt"""{{ - admin off - auto_https off -}} -:{tlsPort} {{ - reverse_proxy 127.0.0.1:{upstreamPort} - tls {certPath} {keyPath} -}} -""" - - try: - writeFile(caddyfilePath, caddyfileContents) - except CatchableError as error: - logger.log(%*{ - "event": "tls:caddyfile_error", - "message": "Failed to write Caddyfile", - "error": error.msg, - "path": caddyfilePath, - }) - return - - logger.log(%*{ - "event": "tls:start", - "message": "Starting Caddy TLS proxy", - "port": tlsPort, - "customCert": hasCustomCert, - }) - - try: - tlsProxyProcess = startProcessSerialized( - "caddy", - args = @["run", "--config", caddyfilePath, "--adapter", "caddyfile"], - options = {poUsePath, poParentStreams} - ) - except CatchableError as error: - logger.log(%*{ - "event": "tls:start_error", - "message": "Failed to start Caddy TLS proxy", - "error": error.msg, - }) - -proc monitorLoop() {.thread.} = - ## If caddy dies on its own, HTTPS access used to stay dead until the next - ## full service restart. Reap the corpse and restart it, at most once per - ## TlsProxyRestartMinIntervalMs. - while true: - sleep(TlsProxyMonitorIntervalMs) - {.gcsafe.}: - withLock tlsProxyLock: - if not tlsProxyDesired or tlsProxyProcess == nil: - continue - var alive = true - try: - alive = tlsProxyProcess.running() - except CatchableError: - alive = false - if alive: - continue - if monitorLogger != nil: - monitorLogger.log(%*{ - "event": "tls:crashed", - "message": "Caddy TLS proxy exited unexpectedly, restarting", - }) - try: - close(tlsProxyProcess) - except CatchableError: - discard - tlsProxyProcess = nil - startTlsProxyLocked(monitorConfig, monitorLogger) - sleep(TlsProxyRestartMinIntervalMs - TlsProxyMonitorIntervalMs) - -proc stopTlsProxy*(logger: Logger = nil) = - withLock tlsProxyLock: - tlsProxyDesired = false - stopTlsProxyLocked(logger) - -proc startTlsProxy*(frameConfig: FrameConfig, logger: Logger) = - withLock tlsProxyLock: - monitorConfig = frameConfig - monitorLogger = logger - tlsProxyDesired = frameConfig.httpsProxy.enable - startTlsProxyLocked(frameConfig, logger) - if tlsProxyDesired and not monitorStarted: - monitorStarted = true - createThread(monitorThread, monitorLoop) diff --git a/frameos/src/frameos/utils/url.nim b/frameos/src/frameos/utils/url.nim index 6a4fea971..ee8c29307 100644 --- a/frameos/src/frameos/utils/url.nim +++ b/frameos/src/frameos/utils/url.nim @@ -2,9 +2,9 @@ import strformat import strutils import frameos/types when not defined(frameosEmbedded) and not defined(frameosWasm): - # setup_proxy pulls std/net + OpenSSL; the embedded firmware and the wasm - # bundle have no hotspot setup proxy. - import frameos/setup_proxy + # The hotspot listener lives on the mummy server, which the embedded + # firmware and the wasm bundle do not compile. + import frameos/server/hotspot_listener import frameos/utils/http_client proc publicScheme*(config: FrameConfig): string = @@ -20,11 +20,12 @@ proc publicHost*(config: FrameConfig): string = if config.frameHost.len > 0: config.frameHost else: "localhost" proc hotspotSetupPort*(config: FrameConfig): int = + ## The port the hotspot scene and captive portal send the phone to: the + ## hotspot's own listener while one is up, the frame port otherwise. when not defined(frameosEmbedded) and not defined(frameosWasm): - if config.httpsProxy != nil and config.httpsProxy.enable and config.httpsProxy.exposeOnlyPort: - let port = setupProxyPort() - if port > 0: - return port + let port = hotspotListenerPort() + if port > 0: + return port if config.framePort > 0: config.framePort else: 8787 proc publicBaseUrl*(config: FrameConfig): string = diff --git a/frontend/src/scenes/frame/frameLogic.ts b/frontend/src/scenes/frame/frameLogic.ts index 58528f3e5..f90f08a23 100644 --- a/frontend/src/scenes/frame/frameLogic.ts +++ b/frontend/src/scenes/frame/frameLogic.ts @@ -497,7 +497,7 @@ const FRAME_KEY_LABELS: Partial> = { frame_access_key: 'Frame access key', frame_access: 'Frame access', frame_admin_auth: 'Frame admin auth', - https_proxy: 'HTTPS proxy', + https_proxy: 'HTTPS', ssh_user: 'SSH user', ssh_pass: 'SSH password', ssh_port: 'SSH port', diff --git a/frontend/src/scenes/frame/panels/FrameSettings/frameSettingsSurface.ts b/frontend/src/scenes/frame/panels/FrameSettings/frameSettingsSurface.ts index a02b82fc8..5a1ec718f 100644 --- a/frontend/src/scenes/frame/panels/FrameSettings/frameSettingsSurface.ts +++ b/frontend/src/scenes/frame/panels/FrameSettings/frameSettingsSurface.ts @@ -269,10 +269,9 @@ export const frameSettingsSections: readonly FrameSettingsSectionSpec[] = [ }, { key: 'https-proxy', - title: 'HTTPS proxy', + title: 'HTTPS', anchor: 'frame-http-proxy-section', surfaces: ['backend', 'frameAdmin'], - conditions: 'Buildroot: the switch renders disabled, the image ships no Caddy.', }, { key: 'network', diff --git a/frontend/src/scenes/frame/panels/FrameSettings/sections/connectivitySections.tsx b/frontend/src/scenes/frame/panels/FrameSettings/sections/connectivitySections.tsx index 3fa083efe..1b103edbe 100644 --- a/frontend/src/scenes/frame/panels/FrameSettings/sections/connectivitySections.tsx +++ b/frontend/src/scenes/frame/panels/FrameSettings/sections/connectivitySections.tsx @@ -398,7 +398,7 @@ export function HttpApiSection(): JSX.Element {

{isEmbeddedMode ? 'Embedded firmware keeps HTTP available for provisioning and recovery. Enable HTTPS below for backend-to-frame traffic.' - : 'Traffic on this port is UNSECURED! Please also enable the HTTPS proxy service for secure communication.'} + : 'Traffic on this port is UNSECURED! Enable the HTTPS API below for secure communication.'}

} @@ -564,13 +564,18 @@ export function FrameAdminPanelSection(): JSX.Element { ) } -/** TLS: Caddy in front of the API on a Pi, native HTTPS on an ESP32. */ +/** + * TLS: the frame serves HTTPS itself on every platform — OpenSSL inside the + * runtime's HTTP server on a Pi (Raspberry Pi OS and Buildroot alike), + * mbedTLS on an ESP32 — from the per-frame certificate the backend mints. + * The `https_proxy` key is the API's name for the setting, kept from the + * days a Caddy proxy did the job. + */ export function HttpsProxySection(): JSX.Element { const { frame, frameForm, frameFormTouches, - isBuildrootMode, isEmbeddedMode, tlsEnabled, generateTlsCertificates, @@ -579,34 +584,23 @@ export function HttpsProxySection(): JSX.Element { return ( <> - {isEmbeddedMode ? 'HTTPS on frame' : 'HTTPS proxy'}{' '} - (backend → frame) + HTTPS (backend → frame) {({ value, onChange }) => ( { - if (isBuildrootMode) { - return - } if (enableTls) { verifyTlsCertificates() } @@ -623,11 +617,7 @@ export function HttpsProxySection(): JSX.Element { label="HTTPS port" tooltip={
-

- {isEmbeddedMode - ? "The port the frame's HTTPS server listens on." - : 'The port Caddy listens on for HTTPS connections.'} -

+

The port the frame's HTTPS server listens on.

It's best if this ends with *443.

} @@ -638,7 +628,7 @@ export function HttpsProxySection(): JSX.Element { @@ -664,11 +654,7 @@ export function HttpsProxySection(): JSX.Element { HTTPS frame private key} - tooltip={ - isEmbeddedMode - ? 'PEM private key baked into the firmware for native HTTPS on this frame. Keep this secret.' - : 'PEM private key used by Caddy for HTTPS on this frame. Keep this secret.' - } + tooltip="PEM private key for the certificate above. It stays in the frame's config and is never written to a separate file. Keep this secret." secret={!frameFormTouches['https_proxy.certs.server_key'] && !!frameForm.https_proxy?.certs?.server_key} >