From 73202b19663b16abf75225d2ea5e547b1b32727b Mon Sep 17 00:00:00 2001 From: "Thierry CH." Date: Sat, 19 Sep 2026 13:41:30 +0200 Subject: [PATCH 1/3] fix(local): read a key encrypted with a lost secret as no key Reminting secret.bin leaves every stored provider key undecryptable. Fernet raised InvalidToken out of the api_key property, so model discovery 500'd and the boot gate, which resolves the selected model through that connection, left the window stuck on "SurfSense could not start". Drop the dead ciphertext and report no key, so the app boots and asks for it again. --- surfsense_local/backend/modules/llm/models.py | 10 +++++++++- .../backend/tests/unit/shared/test_secrets.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/surfsense_local/backend/modules/llm/models.py b/surfsense_local/backend/modules/llm/models.py index 3731ea6824..0727336e38 100644 --- a/surfsense_local/backend/modules/llm/models.py +++ b/surfsense_local/backend/modules/llm/models.py @@ -1,6 +1,7 @@ import enum from datetime import datetime +from cryptography.fernet import InvalidToken from sqlalchemy import CheckConstraint, ForeignKey, String, UniqueConstraint, func from sqlalchemy.orm import Mapped, mapped_column @@ -91,7 +92,14 @@ class ProviderConnection(Base): def api_key(self) -> str | None: if self.api_key_ciphertext is None: return None - return decrypt(self.api_key_ciphertext) + try: + return decrypt(self.api_key_ciphertext) + except InvalidToken: + # ponytail: SURFSENSE_LOCAL_SECRET rotated (secret.bin reminted). + # Drop the unreadable blob so the UI asks for a new key; a 500 + # here takes down model discovery for an otherwise healthy API. + self.api_key_ciphertext = None + return None @api_key.setter def api_key(self, value: str | None) -> None: diff --git a/surfsense_local/backend/tests/unit/shared/test_secrets.py b/surfsense_local/backend/tests/unit/shared/test_secrets.py index 2b4a60fc56..fcc6c0524d 100644 --- a/surfsense_local/backend/tests/unit/shared/test_secrets.py +++ b/surfsense_local/backend/tests/unit/shared/test_secrets.py @@ -25,3 +25,21 @@ def test_tampered_ciphertext_is_rejected() -> None: token[-1] ^= 0xFF with pytest.raises(InvalidToken): decrypt(bytes(token)) + + +def test_rotated_secret_reads_as_no_key(monkeypatch: pytest.MonkeyPatch) -> None: + """secret.bin reminted: the old ciphertext is gone, not a 500.""" + connection = ProviderConnection( + label="x", provider="openai_compatible", base_url="http://h", api_key="sk-1" + ) + token = connection.api_key_ciphertext + monkeypatch.setenv("SURFSENSE_LOCAL_SECRET", "a-different-secret") + from shared.secrets import _fernet + + _fernet.cache_clear() + stale = ProviderConnection( + label="y", provider="openai_compatible", base_url="http://h" + ) + stale.api_key_ciphertext = token + assert stale.api_key is None + assert stale.api_key_ciphertext is None From aae178d294a089ffa327bfdaa43357f95bc46505 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:53:42 +0530 Subject: [PATCH 2/3] fix(sunset): gate SUNSET_MODE on DEPLOYMENT_MODE=cloud so self-hosted instances can never be sunset-blocked --- surfsense_backend/.env.example | 3 ++- surfsense_backend/app/config/__init__.py | 2 +- surfsense_backend/app/sunset.py | 17 ++++++++----- .../tests/unit/test_sunset_flag.py | 12 ++++++++++ .../tests/unit/test_sunset_write_block.py | 24 +++++++++++++++++++ 5 files changed, 50 insertions(+), 8 deletions(-) diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index 31dae76406..4b09896faf 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -106,7 +106,8 @@ NEXT_FRONTEND_URL=http://localhost:3000 # clients read once at startup to load the live /sunset page instead of their # bundled frontend (plans/community-local/contracts/04-sunset-flag.md). # Read per request, so the flip needs no redeploy. Accepts 1 / true / yes / on. -# Self-hosters leave this unset -- unset means "not sunset". +# Only takes effect when DEPLOYMENT_MODE=cloud -- self-hosted instances ignore +# this flag entirely, so leaving it unset (or set) makes no difference there. SUNSET_MODE= # Where those clients are sent. Defaults to https://surfsense.com/sunset. SUNSET_URL= diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 42e92865a9..24840dae2e 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -473,7 +473,7 @@ class Config: # Deployment Mode (self-hosted or cloud) # self-hosted: Full access to local file system connectors (Obsidian, etc.) # cloud: Only cloud-based connectors available - DEPLOYMENT_MODE = os.getenv("SURFSENSE_DEPLOYMENT_MODE", "self-hosted") + DEPLOYMENT_MODE = os.getenv("DEPLOYMENT_MODE", "self-hosted") ENABLE_DESKTOP_LOCAL_FILESYSTEM = ( os.getenv("ENABLE_DESKTOP_LOCAL_FILESYSTEM", "FALSE").upper() == "TRUE" ) diff --git a/surfsense_backend/app/sunset.py b/surfsense_backend/app/sunset.py index 445c98eb59..b8d541359e 100644 --- a/surfsense_backend/app/sunset.py +++ b/surfsense_backend/app/sunset.py @@ -6,9 +6,10 @@ Two properties matter more than anything else here: -* **Self-hosters run this same code forever with the flag unset.** Every path - through this module has to be a no-op in that case, which is why the flag is - checked before anything else and defaults to off. +* **Self-hosters can never hit this, even by accident.** ``SUNSET_MODE`` alone + is not enough -- the flag only takes effect when ``DEPLOYMENT_MODE=cloud``, + so a stray ``SUNSET_MODE=1`` in a self-hosted ``.env`` (copied from a hosted + template, say) is a no-op rather than an outage. * **Signing in has to keep working.** Export is behind a session, so blocking authentication would lock people out of the one action still available and make the 30-day window meaningless. @@ -58,10 +59,14 @@ def is_sunset_mode() -> bool: """Whether the hosted service is winding down. - Read from the environment on every call rather than through ``config``, - which resolves at import: contract 4 requires that flipping the flag take - effect without a deploy. + Only ever true for a cloud deployment. ``SUNSET_MODE`` is read from the + environment on every call rather than cached, so flipping it takes effect + without a deploy; ``DEPLOYMENT_MODE`` is the safety net that keeps a + self-hosted instance immune to that flag regardless of what its own + ``.env`` sets. """ + if os.getenv("DEPLOYMENT_MODE", "self-hosted") != "cloud": + return False return os.getenv("SUNSET_MODE", "").strip().lower() in _TRUTHY diff --git a/surfsense_backend/tests/unit/test_sunset_flag.py b/surfsense_backend/tests/unit/test_sunset_flag.py index 4551f0be48..0808ffa6bd 100644 --- a/surfsense_backend/tests/unit/test_sunset_flag.py +++ b/surfsense_backend/tests/unit/test_sunset_flag.py @@ -44,6 +44,7 @@ def test_every_documented_spelling_turns_the_flag_on(client, monkeypatch, value) Both have to work. The cost of a spelling that silently reads as false is a sunset day where nothing happens and nothing says why. """ + monkeypatch.setenv("DEPLOYMENT_MODE", "cloud") monkeypatch.setenv("SUNSET_MODE", value) assert _health(client)["sunset"] is True @@ -51,13 +52,23 @@ def test_every_documented_spelling_turns_the_flag_on(client, monkeypatch, value) @pytest.mark.parametrize("value", ["", "0", "false", "no", "off", " "]) def test_anything_else_leaves_the_flag_off(client, monkeypatch, value): + monkeypatch.setenv("DEPLOYMENT_MODE", "cloud") monkeypatch.setenv("SUNSET_MODE", value) assert _health(client)["sunset"] is False +def test_self_hosted_stays_off_even_if_sunset_mode_is_set(client, monkeypatch): + """A stray ``SUNSET_MODE=1`` in a self-hosted ``.env`` must be a no-op.""" + monkeypatch.delenv("DEPLOYMENT_MODE", raising=False) + monkeypatch.setenv("SUNSET_MODE", "1") + + assert _health(client)["sunset"] is False + + def test_the_flag_is_read_per_request(client, monkeypatch): """No restart beyond the flag change, so it cannot be cached at import.""" + monkeypatch.setenv("DEPLOYMENT_MODE", "cloud") monkeypatch.delenv("SUNSET_MODE", raising=False) assert _health(client)["sunset"] is False @@ -71,6 +82,7 @@ def test_sunset_is_a_json_boolean_not_a_string(client, monkeypatch): ``is True`` rather than ``== True`` on purpose: ``1 == True`` in Python, so equality would pass on the raw environment value this once returned. """ + monkeypatch.setenv("DEPLOYMENT_MODE", "cloud") monkeypatch.setenv("SUNSET_MODE", "1") assert isinstance(_health(client)["sunset"], bool) diff --git a/surfsense_backend/tests/unit/test_sunset_write_block.py b/surfsense_backend/tests/unit/test_sunset_write_block.py index 07a5911d7c..e03b560bde 100644 --- a/surfsense_backend/tests/unit/test_sunset_write_block.py +++ b/surfsense_backend/tests/unit/test_sunset_write_block.py @@ -66,6 +66,7 @@ def client() -> TestClient: @pytest.fixture def sunset_on(monkeypatch): + monkeypatch.setenv("DEPLOYMENT_MODE", "cloud") monkeypatch.setenv("SUNSET_MODE", "1") @@ -92,13 +93,27 @@ def test_nothing_is_blocked_while_the_flag_is_unset(client, sunset_off, path, me @pytest.mark.parametrize("value", ["", "0", "false", "no", "off", " "]) def test_a_flag_that_does_not_mean_yes_blocks_nothing(client, monkeypatch, value): """Anything short of an explicit yes leaves the service fully writable.""" + monkeypatch.setenv("DEPLOYMENT_MODE", "cloud") monkeypatch.setenv("SUNSET_MODE", value) assert client.post("/api/v1/documents").status_code == 200 +def test_self_hosted_ignores_a_stray_sunset_flag(client, monkeypatch): + """``DEPLOYMENT_MODE`` defaults to self-hosted, which must win over SUNSET_MODE. + + A ``.env`` copied from a hosted template can carry ``SUNSET_MODE=1`` by + accident; a self-hosted instance must stay fully writable regardless. + """ + monkeypatch.delenv("DEPLOYMENT_MODE", raising=False) + monkeypatch.setenv("SUNSET_MODE", "1") + + assert client.post("/api/v1/documents").status_code == 200 + + def test_the_flag_is_read_per_request(client, monkeypatch): """Sunset is thrown by changing the environment, not by redeploying.""" + monkeypatch.setenv("DEPLOYMENT_MODE", "cloud") monkeypatch.delenv("SUNSET_MODE", raising=False) assert client.post("/api/v1/documents").status_code == 200 @@ -182,6 +197,7 @@ def test_the_scraper_api_keeps_running(client, sunset_on): @pytest.mark.parametrize("value", ["1", "true", "TRUE", "True", "yes", "on"]) def test_every_documented_spelling_means_yes(monkeypatch, value): + monkeypatch.setenv("DEPLOYMENT_MODE", "cloud") monkeypatch.setenv("SUNSET_MODE", value) assert is_sunset_mode() is True @@ -189,6 +205,14 @@ def test_every_documented_spelling_means_yes(monkeypatch, value): @pytest.mark.parametrize("value", ["", "0", "false", "no", "off", " ", "1 "]) def test_anything_else_means_no(monkeypatch, value): + monkeypatch.setenv("DEPLOYMENT_MODE", "cloud") monkeypatch.setenv("SUNSET_MODE", value) assert is_sunset_mode() is (value.strip() == "1") + + +def test_self_hosted_means_no_regardless_of_sunset_mode(monkeypatch): + monkeypatch.delenv("DEPLOYMENT_MODE", raising=False) + monkeypatch.setenv("SUNSET_MODE", "1") + + assert is_sunset_mode() is False From 774ae8a8a5d88f2b70d81d484734c768d640e994 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:01:15 +0530 Subject: [PATCH 3/3] fix(sunset): keep PAT create/revoke working under SUNSET_MODE, matching the "PATs survive until T+30" contract --- surfsense_backend/app/sunset.py | 6 ++++++ .../tests/unit/test_sunset_write_block.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/surfsense_backend/app/sunset.py b/surfsense_backend/app/sunset.py index b8d541359e..b8945bdd3b 100644 --- a/surfsense_backend/app/sunset.py +++ b/surfsense_backend/app/sunset.py @@ -39,10 +39,16 @@ # signing in, and the legacy desktop client authenticates through # ``/auth/desktop/*``. Registration is the one exception -- the service is # winding down, so there is nobody new to sign up. +# +# PATs keep working unchanged through the tail (00d-pivot-plan.md, "Existing +# MCP users"): they die at the T+30 purge, not at T-0, so create/revoke has to +# stay open here or every PAT-holding client -- MCP included -- loses the only +# credential it has before the purge actually happens. _ALLOWED_PREFIXES = ( "/auth/", "/api/v1/license/", "/api/v1/stripe/webhook", + "/api/v1/pats", ) _BLOCKED_PATHS = frozenset({"/auth/register"}) diff --git a/surfsense_backend/tests/unit/test_sunset_write_block.py b/surfsense_backend/tests/unit/test_sunset_write_block.py index e03b560bde..9d6b5bfaa3 100644 --- a/surfsense_backend/tests/unit/test_sunset_write_block.py +++ b/surfsense_backend/tests/unit/test_sunset_write_block.py @@ -40,6 +40,8 @@ "/api/v1/export", "/api/v1/workspaces/7/scrapers/reddit/search", "/api/v1/workspaces/7/scrapers/capabilities", + "/api/v1/pats", + "/api/v1/pats/42", ) @@ -190,6 +192,18 @@ def test_the_scraper_api_keeps_running(client, sunset_on): assert client.post("/api/v1/workspaces/7/scrapers/reddit/search").status_code == 200 +@pytest.mark.parametrize("method", ["post", "delete"]) +def test_pats_keep_working_through_the_tail(client, sunset_on, method): + """PATs die at the T+30 purge, not at T-0 (00d-pivot-plan.md, "Existing MCP users"). + + Losing create/revoke at T-0 would strand every PAT-holding client -- MCP + included -- long before the purge that is actually supposed to end it. + """ + path = "/api/v1/pats" if method == "post" else "/api/v1/pats/42" + + assert getattr(client, method)(path).status_code == 200 + + # -------------------------------------------------------------------------- # The flag itself # --------------------------------------------------------------------------