Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/app/api/frame_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion backend/app/api/frame_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 0 additions & 17 deletions backend/app/api/frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<schedule> root <command>" 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")
Expand Down
36 changes: 19 additions & 17 deletions backend/app/api/tests/test_frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'}
Expand All @@ -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
Expand Down Expand Up @@ -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'
Expand Down
4 changes: 0 additions & 4 deletions backend/app/tasks/buildroot_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
4 changes: 2 additions & 2 deletions backend/app/tasks/embedded_firmware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 36 additions & 14 deletions backend/app/tasks/frame_deploy_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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"))

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 = (
Expand Down
5 changes: 4 additions & 1 deletion backend/app/tasks/tests/test_buildroot_privileges.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 55 additions & 2 deletions backend/app/tasks/tests/test_frame_deploy_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
EmbeddedFullDeployPlan,
FrameDeployPlan,
FrameDeployWorkflow,
frame_may_still_run_caddy,
FullDeployPlan,
HelperActionPlan,
PackagePlan,
Expand Down Expand Up @@ -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")]


Expand Down Expand Up @@ -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 == []
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 6 additions & 4 deletions backend/app/utils/tls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions backend/app/utils/versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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+<sha>"``) 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):
Expand Down
Loading
Loading