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
3 changes: 2 additions & 1 deletion surfsense_backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion surfsense_backend/app/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
23 changes: 17 additions & 6 deletions surfsense_backend/app/sunset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -38,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"})

Expand All @@ -58,10 +65,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


Expand Down
12 changes: 12 additions & 0 deletions surfsense_backend/tests/unit/test_sunset_flag.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,31 @@ 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


@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

Expand All @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions surfsense_backend/tests/unit/test_sunset_write_block.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)


Expand All @@ -66,6 +68,7 @@ def client() -> TestClient:

@pytest.fixture
def sunset_on(monkeypatch):
monkeypatch.setenv("DEPLOYMENT_MODE", "cloud")
monkeypatch.setenv("SUNSET_MODE", "1")


Expand All @@ -92,13 +95,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

Expand Down Expand Up @@ -175,20 +192,41 @@ 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
# --------------------------------------------------------------------------


@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


@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
10 changes: 9 additions & 1 deletion surfsense_local/backend/modules/llm/models.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions surfsense_local/backend/tests/unit/shared/test_secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading