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
1 change: 1 addition & 0 deletions docs/admin-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
1 change: 1 addition & 0 deletions docs/agent/ops.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
10 changes: 5 additions & 5 deletions stacklets/core/caddy.snippet
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion stacklets/core/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>` finds where a
Expand Down
6 changes: 4 additions & 2 deletions stacklets/core/tools-server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ 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 .

# --gecos "" suppresses the interactive Full Name/Room/Phone prompts
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"]
31 changes: 31 additions & 0 deletions stacklets/core/tools-server/access.py
Original file line number Diff line number Diff line change
@@ -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 (`/<prefix>/...`) 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}/")
36 changes: 36 additions & 0 deletions stacklets/core/tools-server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -59,13 +63,27 @@
BRAIN_DIR = Path("/brain")



app = FastAPI(
title="famstack Tools",
description="Family server tools for AI assistants — search documents, find photos, check server status.",
version="0.2.1",
)


@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)

Expand Down Expand Up @@ -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)])
58 changes: 58 additions & 0 deletions tests/unit/stacklets/test_core_tools_access.py
Original file line number Diff line number Diff line change
@@ -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")
Loading