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: 1 addition & 2 deletions docs/architecture/egress.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Nothing is meant to leave the machine until the user allows where it goes. Befor

There is one row per host by construction: the key is the hostname, so a destination cannot exist twice under two names, and the user is asked about the host rather than about each errand sent to it. `host:huggingface.co` is built in (`BUILT_IN` in `modules/egress/service.py`), so it is listed before any connection names it. Its dialog names every errand, search first because it is the widest: searching sends what the user types, as they type it; downloading sends the name of the model they chose; both send their IP address, and neither sends chats or documents.

State is one table, `egress_destinations`: a row per destination with `enabled`, false by default, and `last_call_at`, which every allowed call stamps. `GET /egress` lists `host:huggingface.co` and one `host:` row per non-loopback host among the stored connections, off when never allowed. `PUT /egress/{destination}` turns one on or off, and answers 422 for anything that is not a `host:` name.
State is one table, `egress_destinations`: a row per destination with `enabled`, false by default, and `last_call_at`, which every allowed call stamps. `GET /egress` lists `host:huggingface.co` and one `host:` row per non-loopback host among the stored connections, off when never allowed. `PUT /egress/{destination}` turns one on or off, and answers 422 for anything that is not a `host:` name. Deleting the last connection to a host deletes that host's row (`forget_if_unused()`), so a later connection there is asked about again; a host another connection still uses keeps its grant, and a built-in destination is never dropped.

`host_destination()` returns no destination for `localhost` or a loopback address, so a model server on the same machine needs no consent and has no row. A server elsewhere on the LAN is a destination like any other.

Expand Down Expand Up @@ -67,5 +67,4 @@ Settings › Network lists the App updates row and every destination with its ho
- `_download_image()` in `modules/llm/providers/openai_compatible/image.py` fetches an image URL the remote model returns with no egress decision; it checks the scheme, rejects embedded credentials, bounds the body and withholds the endpoint's bearer token.
- Electron's Chromium spellchecker is not configured in `electron/src/main/index.ts`. Electron's type definitions say it downloads Hunspell dictionaries from the Chromium CDN by default, and `spellcheck` is on unless turned off, so on Windows and Linux it likely makes that call; nobody has checked at runtime.
- The Office Studio formats run model-written code in the worker, and that code can open connections of its own ([studio](studio.md)).
- Deleting a connection leaves its host's grant in `egress_destinations`, hidden from Settings › Network, so a later connection to the same host is allowed without asking.
- Grants stored under the earlier destination names, `model_download`, `model_search` and `image_model_pull`, are not carried over to `host:huggingface.co`. Nothing reads them any more, so someone who had allowed model downloads is asked again, and the old rows stay in the table; revision 0012 still turns an Ollama-era `ollama_pull` grant into `model_download`.
24 changes: 21 additions & 3 deletions surfsense_local/backend/modules/egress/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,31 @@ def set_enabled(session: Session, destination: str, enabled: bool) -> EgressDest
return row


def list_destinations(session: Session) -> list[EgressDestination]:
"""Includes destinations never allowed, so the panel can show them off."""
hosts = {
def _connection_hosts(session: Session) -> set[str]:
return {
destination
for base_url in session.scalars(select(ProviderConnection.base_url))
if (destination := host_destination(base_url)) is not None
}


def forget_if_unused(session: Session, destination: str | None) -> None:
"""Drop a grant once no stored connection reaches its host, so the next
connection there asks again. Built-in destinations are never dropped.
"""
if destination is None or destination in BUILT_IN:
return
if destination in _connection_hosts(session):
return
row = session.get(EgressDestination, destination)
if row is not None:
session.delete(row)
session.flush()


def list_destinations(session: Session) -> list[EgressDestination]:
"""Includes destinations never allowed, so the panel can show them off."""
hosts = _connection_hosts(session)
rows = {row.destination: row for row in session.scalars(select(EgressDestination))}
return [
rows.get(destination)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ def delete_connection(connection_id: int, session: SessionDep) -> Response:
raise HTTPException(status.HTTP_404_NOT_FOUND, "connection not found")
session.delete(connection)
session.flush()
egress.forget_if_unused(session, egress.host_destination(connection.base_url))
return Response(status_code=status.HTTP_204_NO_CONTENT)


Expand Down
66 changes: 66 additions & 0 deletions surfsense_local/backend/tests/integration/llm/test_egress.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,69 @@ async def test_one_grant_covers_everything_sent_to_huggingface(
assert {refused.json()["detail"]["destination"] for refused in refusals} == {
"host:huggingface.co"
}


def _store(engine: Engine, *connections: dict) -> None:
with create_session_factory(engine)() as session:
session.add_all(ProviderConnection(**connection) for connection in connections)
session.commit()


async def _allow(client: AsyncClient, destination: str) -> None:
reply = await client.put(f"/egress/{destination}", json={"enabled": True})
assert reply.status_code == 200, reply.text


async def test_deleting_one_of_two_connections_to_a_host_keeps_its_grant(
client: AsyncClient, engine: Engine
) -> None:
"""The other connection is still there, and the user still consented to it."""
_store(engine, REMOTE, {**REMOTE, "label": "Cloud images"})
await _allow(client, "host:api.provider.example")

assert (await client.delete("/llm/connections/1")).status_code == 204

assert (await _destinations(client))["host:api.provider.example"]["enabled"]


async def test_deleting_the_last_connection_to_a_host_withdraws_its_grant(
client: AsyncClient, engine: Engine
) -> None:
"""A later connection to the same host asks again instead of inheriting it."""
_store(engine, REMOTE)
await _allow(client, "host:api.provider.example")

assert (await client.delete("/llm/connections/1")).status_code == 204

assert set(await _destinations(client)) == {"host:huggingface.co"}
refused = await client.post("/llm/connections", json=REMOTE)
assert refused.status_code == 403
assert refused.json()["detail"]["destination"] == "host:api.provider.example"


async def test_deleting_a_loopback_connection_touches_no_grant(
client: AsyncClient, openai_server: str
) -> None:
"""A provider on this machine had no destination, so none is left behind."""
created = await client.post(
"/llm/connections",
json={**REMOTE, "label": "Local", "base_url": openai_server},
)
assert created.status_code == 201, created.text

deleted = await client.delete(f"/llm/connections/{created.json()['id']}")

assert deleted.status_code == 204
assert set(await _destinations(client)) == {"host:huggingface.co"}


async def test_deleting_a_connection_to_huggingface_keeps_the_built_in_grant(
client: AsyncClient, engine: Engine
) -> None:
"""Model downloads reach huggingface.co with or without a connection there."""
_store(engine, {**REMOTE, "base_url": "https://huggingface.co/v1"})
await _allow(client, "host:huggingface.co")

assert (await client.delete("/llm/connections/1")).status_code == 204

assert (await _destinations(client))["host:huggingface.co"]["enabled"]