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..4357d5016 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,19 @@ 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 + last_caddy = version_tuple(LAST_CADDY_FRAMEOS_VERSION) + assert last_caddy is not None + return previous <= last_caddy + + def _is_buildroot_frame(frame: Frame) -> bool: return (getattr(frame, "mode", None) or "rpios") == "buildroot" @@ -838,15 +859,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 +940,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 +1890,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 +1965,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 +2036,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/backend/mypy-baseline.txt b/backend/mypy-baseline.txt index b026614cb..36bd0fe25 100644 --- a/backend/mypy-baseline.txt +++ b/backend/mypy-baseline.txt @@ -345,7 +345,7 @@ app/tasks/tests/test_frame_deploy_workflow.py: Argument "binary_builder" to "Fra app/tasks/tests/test_frame_deploy_workflow.py: Argument "binary_builder" to "FrameDeployWorkflow" has incompatible type "FakeBinaryBuilder"; expected "FrameBinaryBuilder | None" [arg-type] app/tasks/tests/test_frame_deploy_workflow.py: Argument "binary_builder" to "FrameDeployWorkflow" has incompatible type "FakeBinaryBuilder"; expected "FrameBinaryBuilder | None" [arg-type] app/tasks/tests/test_frame_deploy_workflow.py: Argument "binary_builder" to "FrameDeployWorkflow" has incompatible type "FakeBinaryBuilder"; expected "FrameBinaryBuilder | None" [arg-type] -app/tasks/tests/test_frame_deploy_workflow.py: Argument "binary_builder" to "FrameDeployWorkflow" has incompatible type "FakePrecompiledBinaryBuilder"; expected "FrameBinaryBuilder | None" [arg-type] +app/tasks/tests/test_frame_deploy_workflow.py: Argument "binary_builder" to "FrameDeployWorkflow" has incompatible type "FakeBinaryBuilder"; expected "FrameBinaryBuilder | None" [arg-type] app/tasks/tests/test_frame_deploy_workflow.py: Argument "binary_builder" to "FrameDeployWorkflow" has incompatible type "FakePrecompiledBinaryBuilder"; expected "FrameBinaryBuilder | None" [arg-type] app/tasks/tests/test_frame_deploy_workflow.py: Argument "binary_builder" to "FrameDeployWorkflow" has incompatible type "FakePrecompiledBinaryBuilder"; expected "FrameBinaryBuilder | None" [arg-type] app/tasks/tests/test_frame_deploy_workflow.py: Argument "binary_builder" to "FrameDeployWorkflow" has incompatible type "FakePrecompiledBinaryBuilder"; expected "FrameBinaryBuilder | None" [arg-type] @@ -396,7 +396,6 @@ app/tasks/tests/test_frame_deploy_workflow.py: Argument "deployer" to "FrameDepl app/tasks/tests/test_frame_deploy_workflow.py: Argument "deployer" to "FrameDeployWorkflow" has incompatible type "FakeDeployer"; expected "FrameDeployer" [arg-type] app/tasks/tests/test_frame_deploy_workflow.py: Argument "deployer" to "FrameDeployWorkflow" has incompatible type "FakeDeployer"; expected "FrameDeployer" [arg-type] app/tasks/tests/test_frame_deploy_workflow.py: Argument "deployer" to "FrameDeployWorkflow" has incompatible type "FakeDeployer"; expected "FrameDeployer" [arg-type] -app/tasks/tests/test_frame_deploy_workflow.py: Argument "deployer" to "FrameDeployWorkflow" has incompatible type "FakeDeployer"; expected "FrameDeployer" [arg-type] app/tasks/tests/test_frame_deploy_workflow.py: Argument "deployer" to "FrameDeployWorkflow" has incompatible type "LowMemoryDeployer"; expected "FrameDeployer" [arg-type] app/tasks/tests/test_frame_deploy_workflow.py: Argument "deployer" to "FrameDeployWorkflow" has incompatible type "NoProbeDeployer"; expected "FrameDeployer" [arg-type] app/tasks/tests/test_frame_deploy_workflow.py: Argument "deployer" to "FrameDeployWorkflow" has incompatible type "RecordingDeployer"; expected "FrameDeployer" [arg-type] @@ -410,6 +409,7 @@ app/tasks/tests/test_frame_deploy_workflow.py: Argument "deployer" to "FrameDepl app/tasks/tests/test_frame_deploy_workflow.py: Argument "deployer" to "FrameDeployWorkflow" has incompatible type "RecordingDeployer"; expected "FrameDeployer" [arg-type] app/tasks/tests/test_frame_deploy_workflow.py: Argument "deployer" to "FrameDeployWorkflow" has incompatible type "RecordingDeployer"; expected "FrameDeployer" [arg-type] app/tasks/tests/test_frame_deploy_workflow.py: Argument "deployer" to "FrameDeployWorkflow" has incompatible type "RecordingDeployer"; expected "FrameDeployer" [arg-type] +app/tasks/tests/test_frame_deploy_workflow.py: Argument "deployer" to "FrameDeployWorkflow" has incompatible type "RecordingDeployer"; expected "FrameDeployer" [arg-type] app/tasks/tests/test_frame_deploy_workflow.py: Argument "deployer" to "FrameDeployWorkflow" has incompatible type "UbuntuDeployer"; expected "FrameDeployer" [arg-type] app/tasks/tests/test_frame_deploy_workflow.py: Argument "deployer" to "FrameDeployWorkflow" has incompatible type "UbuntuDeployer"; expected "FrameDeployer" [arg-type] app/tasks/tests/test_frame_deploy_workflow.py: Argument "fast_deploy" to "FrameDeployPlan" has incompatible type "SimpleNamespace"; expected "FastDeployPlan | None" [arg-type] @@ -519,9 +519,6 @@ app/tasks/tests/test_frame_deploy_workflow.py: Item "EmbeddedFullDeployPlan" of app/tasks/tests/test_frame_deploy_workflow.py: Item "EmbeddedFullDeployPlan" of "FullDeployPlan | EmbeddedFullDeployPlan" has no attribute "vendor_sync_plans" [union-attr] app/tasks/tests/test_frame_deploy_workflow.py: Item "EmbeddedFullDeployPlan" of "FullDeployPlan | EmbeddedFullDeployPlan" has no attribute "vendor_sync_plans" [union-attr] app/tasks/tests/test_frame_deploy_workflow.py: Item "EmbeddedFullDeployPlan" of "FullDeployPlan | EmbeddedFullDeployPlan" has no attribute "vendor_sync_plans" [union-attr] -app/tasks/tests/test_frame_deploy_workflow.py: Item "EmbeddedFullDeployPlan" of "FullDeployPlan | EmbeddedFullDeployPlan" has no attribute "vendor_sync_plans" [union-attr] -app/tasks/tests/test_frame_deploy_workflow.py: Item "EmbeddedFullDeployPlan" of "FullDeployPlan | EmbeddedFullDeployPlan" has no attribute "vendor_sync_plans" [union-attr] -app/tasks/tests/test_frame_deploy_workflow.py: Item "EmbeddedFullDeployPlan" of "FullDeployPlan | EmbeddedFullDeployPlan" has no attribute "vendor_sync_plans" [union-attr] app/tasks/tests/test_frame_deployer.py: Argument "db" to "FrameDeployer" has incompatible type "None"; expected "Session" [arg-type] app/tasks/tests/test_frame_deployer.py: Argument "db" to "FrameDeployer" has incompatible type "None"; expected "Session" [arg-type] app/tasks/tests/test_frame_deployer.py: Argument "db" to "FrameDeployer" has incompatible type "None"; expected "Session" [arg-type] 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..c9d7ea033 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#020dfe59fd112bef4b711684c41fe9f678f56889" 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..0ac0f5b20 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": "020dfe59fd112bef4b711684c41fe9f678f56889", + "url": "https://github.com/FrameOS/mummy", "downloadMethod": "git", "dependencies": [ "zippy", @@ -156,7 +156,7 @@ "crunchy" ], "checksums": { - "sha1": "d30a9eb8a7df829e6db71c6861e374bfa14a0f36" + "sha1": "7c431abc28bbc3e411c385000d7dadf3549af240" } }, "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} >