Files
stackpilot/backend/services/device_service.py
T
menzeljandClaude Opus 5 60a7ccff93
CI / check (push) Successful in 7m40s
CI / build-and-push (push) Successful in 1m55s
Add a test suite, a linter and a CI gate in front of the build (0.45.0)
The repo had no tests, no lint config, and a CI that went straight from push
to docker push. That is the reason F1 could ship: authorization lives in the
routers, each of 171 routes independently picks require_admin or
get_current_user, and nothing checked the choice was right.

670 tests, no Docker daemon needed. The app is driven through TestClient
without entering it as a context manager, which skips the lifespan — no
background loops, no socket — and conftest points DATA_DIR/STACKS_DIR at a
temp directory before anything is imported.

test_route_authorization.py is the load-bearing one. Rather than 171 implied
decisions it states the policy once — every route requires admin unless it is
listed in USER_READABLE or PUBLIC — and fails on any route that disagrees. A
new route defaults to admin, which is the safe direction; what it catches is a
route written with get_current_user that nobody weighed against "can this
return a credential". Writing the allowlist meant auditing all 53 user-readable
routes, which turned up one more leak: GET /api/templates/{id} returns a
template's env, and "save stack as template" snapshots the stack's real .env
into it. Now admin-only; the listing stays open.

test_agent_authorization.py pins the same invariant on the agent, where the
whole access model is one shared token declared per route and a single
forgotten Depends(verify_token) would hand over the host.

Both were checked by reintroducing the bug: re-opening /api/files/read fails
three tests with actionable messages, dropping a token guard fails two.

test_bundled_templates.py covers the 83 templates — parse, image per service,
.env.example in sync with what compose reads, every bind-mounted file actually
shipped, and no working default password. It found one on its first run:
authentik shipped PG_PASS=change-me and AUTHENTIK_SECRET_KEY=change-me against
a compose that marks both required, so the stack would have come up with a
known password instead of refusing to start. Fixed.

The rest ports the ad-hoc harnesses from 0.44.0 into permanent tests (crypto
round-trip incl. plaintext passthrough and key-loss handling, the browse
sandbox) and covers compose_service's slug/status/file handling and
secret_service's name validation.

ruff is configured as a floor, not a style bar: F, E9 and B only. Import
sorting is deliberately out — it is style, and enabling it would rewrite the
imports of nine files that have nothing else wrong. The 12 findings it did have
are fixed here (unused imports, an unused local, four raise-without-from that
were swallowing exception context).

CI now runs check (ruff, pytest, tsc) and only builds if it passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
2026-08-31 13:16:41 +02:00

168 lines
5.6 KiB
Python

"""Host device detection (USB, serial/TTY, DRI) and host filesystem browser."""
from __future__ import annotations
import glob
import os
from dataclasses import asdict, dataclass
from config import settings
@dataclass
class HostDevice:
path: str
kind: str # "usb" | "tty" | "dri" | "other"
name: str = ""
def to_dict(self) -> dict:
return asdict(self)
def _read(path: str) -> str:
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
return fh.read().strip()
except OSError:
return ""
def _usb_devices() -> list[HostDevice]:
devices: list[HostDevice] = []
# /dev/bus/usb/<bus>/<dev>
for path in sorted(glob.glob("/dev/bus/usb/*/*")):
name = ""
# Best-effort name lookup via sysfs is non-trivial to map; leave generic.
devices.append(HostDevice(path=path, kind="usb", name=name or "USB device"))
return devices
def _usb_names_from_sysfs() -> list[HostDevice]:
"""Richer USB list from sysfs with product/manufacturer strings."""
out: list[HostDevice] = []
for dev in sorted(glob.glob("/sys/bus/usb/devices/*")):
busnum = _read(os.path.join(dev, "busnum"))
devnum = _read(os.path.join(dev, "devnum"))
if not busnum or not devnum:
continue
product = _read(os.path.join(dev, "product"))
manufacturer = _read(os.path.join(dev, "manufacturer"))
label = " ".join(p for p in (manufacturer, product) if p) or "USB device"
path = f"/dev/bus/usb/{int(busnum):03d}/{int(devnum):03d}"
out.append(HostDevice(path=path, kind="usb", name=label))
return out
def _tty_devices() -> list[HostDevice]:
devices: list[HostDevice] = []
patterns = ["/dev/ttyUSB*", "/dev/ttyACM*", "/dev/ttyAMA*", "/dev/serial/by-id/*"]
for pattern in patterns:
for path in sorted(glob.glob(pattern)):
base = os.path.basename(path)
driver = _read(f"/sys/class/tty/{base}/device/driver/module/name") or ""
devices.append(
HostDevice(path=path, kind="tty", name=driver or "Serial device")
)
return devices
def _dri_devices() -> list[HostDevice]:
return [
HostDevice(path=p, kind="dri", name="GPU render node")
for p in sorted(glob.glob("/dev/dri/*"))
]
def detect_devices() -> dict:
usb = _usb_names_from_sysfs() or _usb_devices()
return {
"usb": [d.to_dict() for d in usb],
"tty": [d.to_dict() for d in _tty_devices()],
"dri": [d.to_dict() for d in _dri_devices()],
}
# --------------------------------------------------------------------------- #
# Host filesystem browser (sandboxed)
# --------------------------------------------------------------------------- #
class BrowseError(Exception):
pass
def _real_root(path: str) -> str:
"""Map a logical host path into the container view (HOST_ROOT_PREFIX).
Refuses anything that resolves inside StackPilot's own ``DATA_DIR``. That
directory holds ``stackpilot.db`` — users, password hashes, agent tokens and
backup-destination credentials — and the API deliberately never hands those
out (``AgentRead.token_set`` is a bool, destination secrets come back
masked). Without this the file browser would be a way around that, for
admins too. Note this only bites when ``HOST_ROOT_PREFIX`` is empty: with a
prefix set, no logical path can reach the container's own ``/data`` at all.
"""
prefix = settings.HOST_ROOT_PREFIX.rstrip("/")
real = prefix + path if prefix else path
data_dir = os.path.normpath(settings.DATA_DIR)
norm = os.path.normpath(real)
if norm == data_dir or norm.startswith(data_dir + os.sep):
raise BrowseError("Path is inside StackPilot's own data directory")
return real
def _is_allowed(path: str) -> bool:
norm = os.path.normpath(path)
for root in settings.ALLOWED_BROWSE_ROOTS:
root = os.path.normpath(root)
if root == "/" or norm == root or norm.startswith(root + os.sep):
return True
return False
def browse(path: str = "/", show_hidden: bool = False) -> dict:
path = os.path.normpath(path or "/")
if not path.startswith("/"):
raise BrowseError("Path must be absolute")
if not _is_allowed(path):
raise BrowseError("Path is outside the allowed browse roots")
real = _real_root(path)
if not os.path.isdir(real):
raise BrowseError(f"Not a directory: {path}")
entries = []
try:
names = os.listdir(real)
except PermissionError as exc:
raise BrowseError(f"Permission denied: {path}") from exc
for name in sorted(names):
if not show_hidden and name.startswith("."):
continue
full_real = os.path.join(real, name)
try:
st = os.lstat(full_real)
is_dir = os.path.isdir(full_real)
entries.append(
{
"name": name,
"type": "dir" if is_dir else "file",
"size": st.st_size if not is_dir else None,
"permissions": oct(st.st_mode & 0o777),
"mtime": st.st_mtime,
"symlink": os.path.islink(full_real),
}
)
except OSError:
continue
# Sort: dirs first, then files, both alphabetical.
entries.sort(key=lambda e: (e["type"] != "dir", e["name"].lower()))
parent = os.path.dirname(path) if path != "/" else None
return {
"path": path,
"parent": parent,
"roots": settings.ALLOWED_BROWSE_ROOTS,
"entries": entries,
}