From b269adfe99e83b7182dd00703fff5f73ad619b40 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 27 Sep 2026 09:06:37 +0200 Subject: [PATCH 1/2] security(core): serve only the /go links on the tools server's port In port mode the tools server's published port (42000) is bound to all interfaces and answered document search, photo search, container logs and server status without authentication. Docker's port forwarding gives every request through that port the same source address, so the server could not tell this Mac from any other device on the network. The server now listens twice: - an internal listener (8000), never published, which serves everything to the stack's own containers; Open WebUI's tool calls keep using it unchanged - the published listener (8001, still reached as 42000), which serves the `/go` links and answers every other path with 404 Caddy forwards the `/go` links to the published listener. Upgrade: run `./stack restart core` to apply. --- docs/admin-guide.md | 1 + docs/agent/ops.md | 1 + stacklets/core/caddy.snippet | 10 ++-- stacklets/core/docker-compose.yml | 10 +++- stacklets/core/tools-server/Dockerfile | 6 +- stacklets/core/tools-server/access.py | 31 ++++++++++ stacklets/core/tools-server/server.py | 36 ++++++++++++ .../unit/stacklets/test_core_tools_access.py | 58 +++++++++++++++++++ 8 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 stacklets/core/tools-server/access.py create mode 100644 tests/unit/stacklets/test_core_tools_access.py diff --git a/docs/admin-guide.md b/docs/admin-guide.md index cf57c731..477af704 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -127,6 +127,7 @@ famstack uses the 420xx range to avoid conflicts with anything you might already | Port | Service | |------|---------| +| 42000 | core (`/go` links) | | 42010 | photos (Immich) | | 42020 | docs (Paperless-ngx) | | 42030 | messages (Element web) | diff --git a/docs/agent/ops.md b/docs/agent/ops.md index 16b07923..d1d53007 100644 --- a/docs/agent/ops.md +++ b/docs/agent/ops.md @@ -81,6 +81,7 @@ Refuse without explicit, scoped human approval: | Port | Stacklet | Service | |---|---|---| +| 42000 | core | `/go` links. The tools server's search, logs and status are for the stack's containers only | | 42010 | photos | Immich web + API | | 42020 | docs | Paperless-ngx | | 42030 | messages | Element web | diff --git a/stacklets/core/caddy.snippet b/stacklets/core/caddy.snippet index 714f4569..d59e561d 100644 --- a/stacklets/core/caddy.snippet +++ b/stacklets/core/caddy.snippet @@ -4,17 +4,17 @@ # persistent-link resolver under /go/. A dashboard / welcome page can take # over `/` later; for now home just answers a friendly placeholder. # -# Only /go/* is forwarded to the tools server. That server ALSO exposes ops -# and AI-tool endpoints (/logs, /tools/*) that must never be reachable from -# the public home — they stay internal (container-to-container, plus localhost -# in port mode). Scoping the route here is what keeps them off the front door. +# Only /go/* is forwarded to the tools server, and to its public listener +# (8001), which serves the links and nothing else. Its internal listener +# (8000) serves search, logs and status to the stack's containers and must +# never be the target of a route from outside. # # The /go prefix mirrors core's LINK_PREFIX env (default "go"); if you move # the namespace, change both. {$STACK_DOMAIN} { handle /go/* { - reverse_proxy stack-core-tools:8000 + reverse_proxy stack-core-tools:8001 } handle { respond "famstack — home server" 200 diff --git a/stacklets/core/docker-compose.yml b/stacklets/core/docker-compose.yml index a25cf567..b4d0f138 100644 --- a/stacklets/core/docker-compose.yml +++ b/stacklets/core/docker-compose.yml @@ -78,13 +78,21 @@ services: - "host.docker.internal:host-gateway" env_file: - .env + # Started by server.py itself, which opens both listeners (access.py). + # Set here as well as in the Dockerfile so an existing image picks it + # up without a rebuild. + command: ["python", "server.py"] ports: - - "${PORT_BIND_IP:-127.0.0.1}:42000:8000" + # Only the public listener is published, and it serves only the /go + # links. The stack's containers use the internal one, + # stack-core-tools:8000, which serves everything. + - "${PORT_BIND_IP:-127.0.0.1}:42000:8001" volumes: # Live like the bot-runner: the Dockerfile COPYs these so the image # runs standalone, but a bind mount means an edit + restart takes # effect without a rebuild. Keep in sync with tools-server/Dockerfile. - ./tools-server/server.py:/app/server.py:ro + - ./tools-server/access.py:/app/access.py:ro - ./tools-server/resolver.py:/app/resolver.py:ro - ./tools-server/capture_index.py:/app/capture_index.py:ro # The brain projection, read-only: `/go/capture/` finds where a diff --git a/stacklets/core/tools-server/Dockerfile b/stacklets/core/tools-server/Dockerfile index a8dd444c..24395f82 100644 --- a/stacklets/core/tools-server/Dockerfile +++ b/stacklets/core/tools-server/Dockerfile @@ -6,6 +6,7 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY server.py . +COPY access.py . COPY resolver.py . COPY capture_index.py . @@ -13,5 +14,6 @@ COPY capture_index.py . RUN adduser --disabled-password --gecos "" --uid 1000 tools USER tools -# Uvicorn on port 8000 inside the container, mapped to 42000 on host -CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"] +# Two listeners (access.py): 8000 for the stack's containers, never +# published, and 8001, published as 42000, which serves only the links. +CMD ["python", "server.py"] diff --git a/stacklets/core/tools-server/access.py b/stacklets/core/tools-server/access.py new file mode 100644 index 00000000..de957128 --- /dev/null +++ b/stacklets/core/tools-server/access.py @@ -0,0 +1,31 @@ +"""What the tools server answers on which listener. + +The server listens twice. The internal listener is never published: only the +stack's containers reach it, over the Docker network, and it serves +everything. The public listener is the published port (42000). Docker's port +forwarding gives every request through it the same source address, whether +it comes from this Mac or from a phone on the Wi-Fi, so an address says +nothing about who is calling. The listener does. + +On the public listener only the persistent links (`//...`) exist, +because family members' browsers open them. Search, logs and status are for +the stack's containers. + +Pure: no web framework, so the rule is tested on its own. +""" + +from __future__ import annotations + +INTERNAL_PORT = 8000 +PUBLIC_PORT = 8001 + + +def serves(listener_port: int | None, path: str, link_prefix: str = "go") -> bool: + """Whether a request on `listener_port` for `path` is answered. + + Only the internal listener is trusted by name; any other port counts as + the network, so a listener added later is closed until said otherwise. + """ + if listener_port == INTERNAL_PORT: + return True + return path.startswith(f"/{link_prefix}/") diff --git a/stacklets/core/tools-server/server.py b/stacklets/core/tools-server/server.py index 54825a92..3a9731bb 100644 --- a/stacklets/core/tools-server/server.py +++ b/stacklets/core/tools-server/server.py @@ -7,6 +7,9 @@ Each tool wraps an existing service API (Paperless, Immich) or the famstack TCP API for host-side CLI control. No business logic here — just translation between what the LLM needs and what the services provide. + +It listens twice (see access.py): an internal port for the stack's own +containers, and the published port, which serves only the persistent links. """ import json @@ -18,6 +21,7 @@ from fastapi import FastAPI from fastapi.responses import JSONResponse, RedirectResponse +from access import INTERNAL_PORT, PUBLIC_PORT, serves from capture_index import find_capture from resolver import build_redirect @@ -59,6 +63,7 @@ BRAIN_DIR = Path("/brain") + app = FastAPI( title="famstack Tools", description="Family server tools for AI assistants — search documents, find photos, check server status.", @@ -66,6 +71,19 @@ ) +@app.middleware("http") +async def links_only_from_the_network(request, call_next): + """Answer the published port with the links and nothing else. A path + that is not served there gets the same 404 as one that does not exist, + so the network cannot tell which endpoints the server has. The listener + a request arrived on is in the ASGI scope; without one it counts as the + network.""" + listener = (request.scope.get("server") or (None, None))[1] + if not serves(listener, request.url.path, LINK_PREFIX): + return JSONResponse({"detail": "Not Found"}, status_code=404) + return await call_next(request) + + def _error(msg: str, status: int = 503) -> JSONResponse: return JSONResponse({"error": msg}, status_code=status) @@ -310,3 +328,21 @@ async def search_photos(query: str, limit: int = 10): "description": asset.get("exifInfo", {}).get("description", ""), }) return {"query": query, "count": len(results), "results": results} + + +# ── Serving ────────────────────────────────────────────────────────────── + +def _listen(port: int) -> socket.socket: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("0.0.0.0", port)) + return sock + + +if __name__ == "__main__": + # One server on both sockets, so each request's scope names the + # listener it came in on, which is what the access rule reads. + import uvicorn + + uvicorn.Server(uvicorn.Config(app, log_level="info")).run( + sockets=[_listen(INTERNAL_PORT), _listen(PUBLIC_PORT)]) diff --git a/tests/unit/stacklets/test_core_tools_access.py b/tests/unit/stacklets/test_core_tools_access.py new file mode 100644 index 00000000..7075c1ce --- /dev/null +++ b/tests/unit/stacklets/test_core_tools_access.py @@ -0,0 +1,58 @@ +"""What the core tools server answers, and to whom. + +The tools server answers document search, photo search, logs and server +status for the stack's own containers (Open WebUI's tool calls), and the +family's persistent links (`/go/...`) for every browser in the house. Its +published port is on the LAN in port mode, and Docker's port forwarding +shows every request through it with the same address, whether it comes +from this Mac or from any phone on the Wi-Fi. So the server tells callers +apart by the listener a request arrives on, never by address: + +- the internal listener is reachable only over the Docker network and + serves everything +- the published listener serves the links and nothing else +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "core" / "tools-server")) + +from access import INTERNAL_PORT, PUBLIC_PORT, serves # noqa: E402 + +PRIVATE = ("/tools/documents/search", "/tools/photos/search", "/tools/stack/status", + "/logs", "/openapi.json", "/docs", "/") + + +def test_the_stacks_own_containers_get_everything(): + for path in PRIVATE: + assert serves(INTERNAL_PORT, path) + + +def test_the_network_gets_the_family_links(): + for path in ("/go/docs/42", "/go/topic/family/camping/todo", "/go/person/lisa"): + assert serves(PUBLIC_PORT, path) + + +def test_the_network_gets_nothing_else(): + for path in PRIVATE: + assert not serves(PUBLIC_PORT, path) + + +def test_a_path_that_only_starts_like_a_link_is_not_one(): + assert not serves(PUBLIC_PORT, "/gopher") + assert not serves(PUBLIC_PORT, "/go") # the bare prefix resolves nothing + + +def test_the_link_prefix_follows_its_setting(): + assert serves(PUBLIC_PORT, "/l/docs/42", link_prefix="l") + assert not serves(PUBLIC_PORT, "/go/docs/42", link_prefix="l") + + +def test_an_unknown_listener_is_treated_as_the_network(): + # Fail closed: only the internal listener is trusted by name. + assert not serves(9999, "/tools/stack/status") + assert not serves(None, "/logs") From 131777615fc1b4f0d0157af94efced6d29712853 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 27 Sep 2026 10:45:18 +0200 Subject: [PATCH 2/2] docs: name the service on port 42000 in the port tables The rows added with the tools server fix read as sentences and named a path instead of a service. They now follow the tables: the service, with a qualifier in parentheses. --- docs/admin-guide.md | 2 +- docs/agent/ops.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 477af704..c3ee312f 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -127,7 +127,7 @@ famstack uses the 420xx range to avoid conflicts with anything you might already | Port | Service | |------|---------| -| 42000 | core (`/go` links) | +| 42000 | core (link resolver) | | 42010 | photos (Immich) | | 42020 | docs (Paperless-ngx) | | 42030 | messages (Element web) | diff --git a/docs/agent/ops.md b/docs/agent/ops.md index d1d53007..2afdf650 100644 --- a/docs/agent/ops.md +++ b/docs/agent/ops.md @@ -81,7 +81,7 @@ Refuse without explicit, scoped human approval: | Port | Stacklet | Service | |---|---|---| -| 42000 | core | `/go` links. The tools server's search, logs and status are for the stack's containers only | +| 42000 | core | Link resolver for `/go/*` (tool endpoints are internal to the `stack` network) | | 42010 | photos | Immich web + API | | 42020 | docs | Paperless-ngx | | 42030 | messages | Element web |