diff --git a/docs/admin-guide.md b/docs/admin-guide.md index cf57c731..c3ee312f 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 (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 16b07923..2afdf650 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 | 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 | 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")