diff --git a/.env.example b/.env.example index 13d0e3b..8e7fab9 100644 --- a/.env.example +++ b/.env.example @@ -23,11 +23,13 @@ NOTIFY_WEBHOOKS= # Throwaway image used to read/write named-volume contents during backups. BACKUP_HELPER_IMAGE=alpine:latest -# File browser (sidebar) + volume host-path picker. +# File browser (sidebar) + volume host-path picker. Admin-only. # ALLOWED_BROWSE_ROOTS: comma-separated paths the browser may reach (sandbox). +# A single "/" in this list disables the sandbox -- it makes every path +# allowed. StackPilot's own DATA_DIR is refused regardless of this setting. # HOST_ROOT_PREFIX: where the host filesystem is mounted inside the backend # container. Leave empty to browse the container's own filesystem. To browse # the real host, uncomment the "/:/host_root" volume in docker-compose.yml and # set HOST_ROOT_PREFIX=/host_root here (mount without :ro to allow edits). -ALLOWED_BROWSE_ROOTS=/,/mnt,/media,/srv,/opt +ALLOWED_BROWSE_ROOTS=/mnt,/media,/srv,/opt,/home HOST_ROOT_PREFIX= diff --git a/README.md b/README.md index 54c237c..cc050a2 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,33 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. > (Auto-update) + Phase 23 (Secrets & configs) + Phase 24 (Design System v2) > complete. +## Upgrading to 0.44.0 — two defaults changed + +0.44.0 closes a privilege-escalation hole and tightens two defaults. Both +changes can affect an existing install: + +1. **The `user` role loses read access to secrets.** The file browser (page and + `/api/files/*`), the host-path picker, the audit log, `GET /api/stacks/{id}/export` + and a stack's `.env` are now admin-only. Previously any logged-in account + could download `stackpilot.db`, every `.env` and every `.secrets/*` file — + and none of it was audit-logged. If you gave someone a `user` account so they + could look at stacks, they still can; they just no longer get the + credentials. Nothing changes for admins. +2. **`/` is no longer a default browse root.** The new default is + `/mnt,/media,/srv,/opt,/home`. A `/` entry makes the sandbox allow every + path, which is why it is gone — if you relied on it, set + `ALLOWED_BROWSE_ROOTS` explicitly in your `.env`. StackPilot's own `DATA_DIR` + is refused either way. + +Two things also get fixed without any action on your part: the backend now runs +uvicorn with `--proxy-headers`, so the login rate limit works per client IP +instead of globally and the audit log records real IPs; and backup-destination +credentials are encrypted at rest, with existing rows migrated on first start. +That encryption is keyed off `SECRET_KEY`, which is now persisted to +`${DATA_DIR}/secret_key` when you have not set one — so restarts no longer log +everyone out. **If you have never set `SECRET_KEY`, do not delete that file**; +it is what your saved destination credentials are encrypted with. + ## What works today (Phase 1) - **File-first stacks** — every stack is a plain `compose.yaml` (+ optional `.env`) @@ -116,7 +143,7 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. still supported for generic endpoints. - **Settings page**: tune the update-check interval, manage webhooks, and manage users (create/disable/delete, promote/demote, with last-admin safeguards). -- **Audit log page**: searchable, paginated view of all recorded actions. +- **Audit log page** (admin): searchable, paginated view of all recorded actions. - **Mobile-responsive layout**: off-canvas sidebar + adaptive spacing. ### Phase 5 — Multi-host @@ -366,13 +393,22 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. - **View & edit**: clicking a text file opens it in a Monaco editor (with syntax highlighting picked from the extension). Binary and oversized files are detected and offered as a download instead. Admins can edit and **Save**. +- **Admin only**: the whole page, including listing, viewing and downloading. + Reads are not less sensitive than writes here — the browser reaches whatever + the backend container can see, which includes every stack's `.env` and + `.secrets/*`. Reading and downloading a file are audit-logged (`file.read`, + `file.download`); directory listing is not, because the page polls it. - **Manage** (admin): create folders/files, rename, delete (recursive for folders), upload files **or whole folders** (the directory tree is recreated server-side), and download any file. **Copy/cut & paste** moves files and folders between directories (clipboard bar + per-row copy/cut, with an overwrite prompt on conflict). Every mutation is audit-logged. - **Sandboxed**: all access is confined to `ALLOWED_BROWSE_ROOTS`; path - traversal and deleting a browse root are refused. To reach the real host + traversal and deleting a browse root are refused. StackPilot's own `DATA_DIR` + is refused regardless of the setting — it holds `stackpilot.db` with password + hashes, agent tokens and backup-destination credentials, none of which the API + itself ever hands out. Note that a single `/` entry in `ALLOWED_BROWSE_ROOTS` + switches the sandbox off entirely; it is no longer part of the default. To reach the real host filesystem, mount it into the backend and set `HOST_ROOT_PREFIX` (see the commented `/:/host_root` volume in `docker-compose.yml`). Endpoints live under `/api/files/*` (`list`, `read`, `write`, `mkdir`, `touch`, `rename`, `copy`, diff --git a/agent/Dockerfile b/agent/Dockerfile index 48068da..fe9deb6 100644 --- a/agent/Dockerfile +++ b/agent/Dockerfile @@ -11,4 +11,7 @@ EXPOSE 5010 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ CMD curl -fsS http://localhost:5010/agent/health || exit 1 -CMD ["uvicorn", "agent_app:app", "--host", "0.0.0.0", "--port", "5010"] +# Wie beim Backend: falls jemand einen TLS-Proxy vor den Agent setzt, soll die +# echte Client-IP in den Logs stehen. +CMD ["uvicorn", "agent_app:app", "--host", "0.0.0.0", "--port", "5010", \ + "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/backend/Dockerfile b/backend/Dockerfile index fe49f83..83eb9ad 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -27,4 +27,15 @@ EXPOSE 5008 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ CMD curl -fsS http://localhost:5008/api/health || exit 1 -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5008"] +# --proxy-headers: nginx setzt X-Forwarded-For, ohne dieses Flag ignoriert +# uvicorn den Header und request.client.host ist fuer JEDE Anfrage die IP des +# Frontend-Containers -- was das Login-Rate-Limit global statt pro IP wirken +# laesst und die IP-Spalte im Audit-Log wertlos macht. +# +# forwarded-allow-ips=* vertraut dem Header von jedem Absender. Das ist hier +# richtig, weil der Backend-Port nur im Docker-Netz erreichbar ist (siehe +# "expose" statt "ports" in docker-compose.yml). Wer 5008 direkt nach aussen +# gibt, muss den Wert auf die IP des eigenen Proxys einschraenken -- sonst +# kann ein Client seine eigene Herkunfts-IP faelschen. +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5008", \ + "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/backend/config.py b/backend/config.py index 618e9e3..612ca1d 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,11 +1,13 @@ """Application settings, loaded from environment variables.""" from __future__ import annotations +import os import secrets +import stat from functools import lru_cache from typing import Annotated -from pydantic import field_validator +from pydantic import ValidationInfo, field_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict @@ -17,7 +19,8 @@ class Settings(BaseSettings): DATA_DIR: str = "/opt/stackpilot/data" # Security - SECRET_KEY: str = "" # Auto-generated if empty (dev only); set in prod. + # Auto-generated and persisted to ${DATA_DIR}/secret_key when left empty. + SECRET_KEY: str = "" ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 REFRESH_TOKEN_EXPIRE_DAYS: int = 30 @@ -39,9 +42,11 @@ class Settings(BaseSettings): # Only used when running the agent app (agent_app:app). AGENT_TOKEN: str = "" - # Host browser sandbox roots + # Host browser sandbox roots. Deliberately does NOT contain "/": that entry + # makes _is_allowed() wave through every path, i.e. it switches the sandbox + # off. Add it back explicitly if you really want the whole filesystem. ALLOWED_BROWSE_ROOTS: Annotated[list[str], NoDecode] = [ - "/", "/mnt", "/media", "/srv", "/opt", + "/mnt", "/media", "/srv", "/opt", "/home", ] HOST_ROOT_PREFIX: str = "" # e.g. "/host_root" when host / is bind-mounted @@ -55,8 +60,37 @@ class Settings(BaseSettings): @field_validator("SECRET_KEY", mode="after") @classmethod - def _ensure_secret(cls, v: str) -> str: - return v or secrets.token_urlsafe(48) + def _ensure_secret(cls, v: str, info: ValidationInfo) -> str: + """Return the configured key, or a persisted auto-generated one. + + Generating a fresh key per process (the old behaviour) silently + invalidated every session on each restart, and would now also make the + encrypted backup-destination credentials undecryptable. So the + generated key is written next to the database instead, mode 0600, and + read back on the next start. An explicitly configured SECRET_KEY always + wins and nothing is written. + """ + if v: + return v + data_dir = info.data.get("DATA_DIR") or "/opt/stackpilot/data" + key_file = os.path.join(data_dir, "secret_key") + try: + with open(key_file, "r", encoding="utf-8") as fh: + if existing := fh.read().strip(): + return existing + except OSError: + pass + generated = secrets.token_urlsafe(48) + try: + os.makedirs(data_dir, exist_ok=True) + with open(key_file, "w", encoding="utf-8") as fh: + fh.write(generated + "\n") + os.chmod(key_file, stat.S_IRUSR | stat.S_IWUSR) + except OSError: + # Read-only data dir: fall back to the old per-process behaviour + # rather than refusing to boot. Sessions won't survive a restart. + pass + return generated @field_validator( "NOTIFY_WEBHOOKS", "ALLOWED_BROWSE_ROOTS", "CORS_ORIGINS", mode="before" diff --git a/backend/main.py b/backend/main.py index cb1e6a5..01a2c71 100644 --- a/backend/main.py +++ b/backend/main.py @@ -36,7 +36,12 @@ from routers import ( volumes, ws, ) -from services import schedule_service, template_service, update_service +from services import ( + backup_destination_service, + schedule_service, + template_service, + update_service, +) logging.basicConfig(level=logging.INFO) logger = logging.getLogger("stackpilot") @@ -51,6 +56,15 @@ async def lifespan(app: FastAPI): stacks.sync_discovered_stacks(session) except Exception as exc: # noqa: BLE001 logger.warning("Stack discovery failed: %s", exc) + # One-off: encrypt backup-destination credentials written before they were + # stored encrypted (see services/crypto_service.py). + try: + with Session(engine) as session: + encrypted = backup_destination_service.migrate_plaintext_configs(session) + if encrypted: + logger.info("Encrypted %d backup destination config(s) at rest", encrypted) + except Exception as exc: # noqa: BLE001 + logger.warning("Destination config encryption migration failed: %s", exc) try: moved = template_service.migrate_legacy_db_templates() if moved: diff --git a/backend/requirements.txt b/backend/requirements.txt index 0097896..4daf80e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,6 +5,8 @@ sqlmodel==0.0.22 pydantic==2.10.4 pydantic-settings==2.7.1 python-jose[cryptography]==3.3.0 +# Direct dependency: services/crypto_service encrypts DB-stored secrets. +cryptography==44.0.0 passlib[bcrypt]==1.7.4 bcrypt==4.2.1 python-multipart==0.0.20 diff --git a/backend/routers/agents.py b/backend/routers/agents.py index e8ff166..dd9d23b 100644 --- a/backend/routers/agents.py +++ b/backend/routers/agents.py @@ -245,10 +245,14 @@ async def agent_stack_detail( agent_id: int, stack_id: str, session: Session = Depends(get_session), - _user: User = Depends(get_current_user), + user: User = Depends(get_current_user), ) -> dict: agent = _get_or_404(session, agent_id) data = await _proxy(session, agent, "GET", f"/agent/stacks/{stack_id}") + # Withhold the .env from the read-only role, exactly as the local + # GET /api/stacks/{id} does. + if user.role != "admin" and isinstance(data, dict): + data["env"] = "" data["agent_id"] = agent.id data["agent_name"] = agent.name return data @@ -796,7 +800,7 @@ async def agent_files_list( path: str = Query("/"), show_hidden: bool = Query(False), session: Session = Depends(get_session), - _user: User = Depends(get_current_user), + _admin: User = Depends(require_admin), ) -> dict: agent = _get_or_404(session, agent_id) return await _proxy( @@ -808,22 +812,33 @@ async def agent_files_list( @router.get("/{agent_id}/files/read") async def agent_files_read( agent_id: int, + request: Request, path: str = Query(...), session: Session = Depends(get_session), - _user: User = Depends(get_current_user), + user: User = Depends(require_admin), ) -> dict: agent = _get_or_404(session, agent_id) - return await _proxy(session, agent, "GET", "/agent/files/read", params={"path": path}) + result = await _proxy(session, agent, "GET", "/agent/files/read", params={"path": path}) + audit_service.record( + session, user=user.username, action="file.read", + target=f"{agent.name}:{path}", ip=_ip(request), + ) + return result @router.get("/{agent_id}/files/download") async def agent_files_download( agent_id: int, + request: Request, path: str = Query(...), session: Session = Depends(get_session), - _user: User = Depends(get_current_user), + user: User = Depends(require_admin), ): agent = _get_or_404(session, agent_id) + audit_service.record( + session, user=user.username, action="file.download", + target=f"{agent.name}:{path}", ip=_ip(request), + ) # Stream the agent's response straight through (works for single files and # for on-the-fly folder zips), so nothing is staged to disk and the # download starts immediately. Pull the first chunk eagerly so a failed diff --git a/backend/routers/audit.py b/backend/routers/audit.py index 09e8c34..91e47f2 100644 --- a/backend/routers/audit.py +++ b/backend/routers/audit.py @@ -1,4 +1,9 @@ -"""Audit log query endpoint.""" +"""Audit log query endpoint. + +Admin-only: the log is security telemetry (who did what, from which IP, +including every administrator's activity) and has no business being readable +by an account with the ``user`` role. +""" from __future__ import annotations from typing import Optional @@ -6,7 +11,7 @@ from typing import Optional from fastapi import APIRouter, Depends, Query from sqlmodel import Session, select -from auth import get_current_user +from auth import require_admin from database import get_session from models.audit import AuditLog from models.user import User @@ -20,7 +25,7 @@ def list_audit( offset: int = 0, stack_id: Optional[str] = None, session: Session = Depends(get_session), - _user: User = Depends(get_current_user), + _admin: User = Depends(require_admin), ) -> list[AuditLog]: stmt = select(AuditLog).order_by(AuditLog.timestamp.desc()) if stack_id: diff --git a/backend/routers/destinations.py b/backend/routers/destinations.py index 20368ac..a364344 100644 --- a/backend/routers/destinations.py +++ b/backend/routers/destinations.py @@ -2,12 +2,11 @@ from __future__ import annotations import asyncio -import json from fastapi import APIRouter, Depends, HTTPException, Request from sqlmodel import Session, select -from auth import get_current_user, require_admin +from auth import require_admin from database import get_session from models.backup_destination import ( DESTINATION_TYPES, @@ -66,7 +65,9 @@ def create_destination( ) -> DestinationRead: if body.type not in DESTINATION_TYPES: raise HTTPException(status_code=400, detail=f"Unknown type '{body.type}'") - d = BackupDestination(name=body.name, type=body.type, config=json.dumps(body.config)) + d = BackupDestination( + name=body.name, type=body.type, config=dest_service.dump_config(body.config) + ) session.add(d) session.commit() session.refresh(d) @@ -95,7 +96,7 @@ def update_destination( if k in SECRET_KEYS and (v == "" or v == "••••••"): continue # keep existing secret existing[k] = v - d.config = json.dumps(existing) + d.config = dest_service.dump_config(existing) session.add(d) session.commit() session.refresh(d) diff --git a/backend/routers/files.py b/backend/routers/files.py index 8fb6554..9bb7479 100644 --- a/backend/routers/files.py +++ b/backend/routers/files.py @@ -1,7 +1,10 @@ """Full host filesystem browser: list, read, edit, manage, up/download. -Listing and reads require an authenticated user; every mutating operation -(write, mkdir, rename, delete, upload) requires admin and is audit-logged. +Every operation requires admin. Reads are not less dangerous than writes here: +the browser reaches whatever the backend container can see, which includes +every stack's ``.env`` and ``.secrets/*``. Reading a file and downloading one +are audit-logged just like the mutating operations; directory listing is not, +because the Files page polls it and would drown the log. All paths are sandboxed by :mod:`services.file_service`. """ from __future__ import annotations @@ -23,7 +26,7 @@ from fastapi.responses import FileResponse, StreamingResponse from pydantic import BaseModel from sqlmodel import Session -from auth import get_current_user, require_admin +from auth import require_admin from database import get_session from models.user import User from services import audit_service, device_service, file_service @@ -57,24 +60,35 @@ def _guard(fn, *args, **kwargs): def list_dir( path: str = Query("/"), show_hidden: bool = Query(False), - _user: User = Depends(get_current_user), + _admin: User = Depends(require_admin), ) -> dict: return _guard(device_service.browse, path, show_hidden) @router.get("/read") def read_file( + request: Request, path: str = Query(...), - _user: User = Depends(get_current_user), + session: Session = Depends(get_session), + user: User = Depends(require_admin), ) -> dict: - return _guard(file_service.read_file, path) + result = _guard(file_service.read_file, path) + audit_service.record( + session, user=user.username, action="file.read", target=path, ip=_ip(request) + ) + return result @router.get("/download") def download( + request: Request, path: str = Query(...), - _user: User = Depends(get_current_user), + session: Session = Depends(get_session), + user: User = Depends(require_admin), ): + audit_service.record( + session, user=user.username, action="file.download", target=path, ip=_ip(request) + ) if _guard(file_service.is_dir, path): filename, chunks = _guard(file_service.open_archive, path) # Stream the zip as it's built so the response starts immediately diff --git a/backend/routers/stacks.py b/backend/routers/stacks.py index 019b720..384a6c5 100644 --- a/backend/routers/stacks.py +++ b/backend/routers/stacks.py @@ -155,7 +155,7 @@ def stacks_updates(_user: User = Depends(get_current_user)) -> dict: def get_stack( stack_id: str, session: Session = Depends(get_session), - _user: User = Depends(get_current_user), + user: User = Depends(get_current_user), ) -> dict: stack = _get_stack_or_404(session, stack_id) try: @@ -171,7 +171,10 @@ def get_stack( "description": stack.description, "status": status, "yaml": compose_service.read_compose(stack_id), - "env": compose_service.read_env(stack_id), + # The .env is where credentials live by convention, so it is withheld + # from the read-only role — same reasoning as the admin-only file + # browser. Non-admins still get status, services and the compose file. + "env": compose_service.read_env(stack_id) if user.role == "admin" else "", "containers": containers, "created_at": stack.created_at, "updated_at": stack.updated_at, @@ -380,8 +383,10 @@ async def service_logs( def export_stack( stack_id: str, session: Session = Depends(get_session), - _user: User = Depends(get_current_user), + _admin: User = Depends(require_admin), ): + """Download the whole stack folder as a tarball. Admin only: the archive + contains the ``.env`` and every ``.secrets/*`` file verbatim.""" import io import tarfile import tempfile diff --git a/backend/routers/volumes.py b/backend/routers/volumes.py index ea44ee1..8165f33 100644 --- a/backend/routers/volumes.py +++ b/backend/routers/volumes.py @@ -98,8 +98,11 @@ def generate_yaml( def host_paths( path: str = Query("/"), show_hidden: bool = Query(False), - _user: User = Depends(get_current_user), + _admin: User = Depends(require_admin), ) -> dict: + """Directory picker for the volume wizard. Same browse() as the file + browser, so it carries the same admin requirement — and only admins can + create a volume with the result anyway.""" try: return device_service.browse(path, show_hidden) except device_service.BrowseError as exc: diff --git a/backend/services/backup_destination_service.py b/backend/services/backup_destination_service.py index 8bcaff4..52018e1 100644 --- a/backend/services/backup_destination_service.py +++ b/backend/services/backup_destination_service.py @@ -1,8 +1,11 @@ """Push/pull stack backups to remote destinations (SFTP, S3-compatible or NFS). All operations are synchronous (paramiko / boto3 / docker); async callers -should wrap them with ``asyncio.to_thread``. Destination config is a plain -dict parsed from the ``BackupDestination.config`` JSON column. +should wrap them with ``asyncio.to_thread``. Destination config is a dict +stored in the ``BackupDestination.config`` column as JSON, encrypted at rest +(:mod:`services.crypto_service`) because it carries SFTP passwords, SSH keys +and S3 secret keys. Always go through :func:`parse_config` / :func:`dump_config` +— never touch the column directly. """ from __future__ import annotations @@ -17,6 +20,7 @@ import tempfile from typing import Any from models.backup_destination import BackupDestination +from services import crypto_service logger = logging.getLogger("stackpilot.backup_dest") @@ -26,12 +30,48 @@ class DestinationError(Exception): def parse_config(dest: BackupDestination) -> dict: + """Decrypt and parse a destination's config. + + Tolerates plaintext (pre-encryption rows) and returns ``{}`` rather than + raising if the value can't be decrypted — a destination whose key is gone + should show up as unconfigured in the UI, not take the whole list down with + a 500. The failure is logged with the destination name so it's findable. + """ try: - return json.loads(dest.config or "{}") + raw = crypto_service.decrypt(dest.config or "{}") + except crypto_service.DecryptError as exc: + logger.error("Destination '%s': %s", dest.name, exc) + return {} + try: + return json.loads(raw or "{}") except json.JSONDecodeError: return {} +def dump_config(config: dict) -> str: + """Serialise and encrypt a config dict for storage.""" + return crypto_service.encrypt(json.dumps(config or {})) + + +def migrate_plaintext_configs(session) -> int: + """Encrypt destination configs written before encryption existed. + + Runs once at startup. Returns how many rows were rewritten. + """ + from sqlmodel import select + + migrated = 0 + for dest in session.exec(select(BackupDestination)).all(): + if crypto_service.is_encrypted(dest.config): + continue + dest.config = crypto_service.encrypt(dest.config or "{}") + session.add(dest) + migrated += 1 + if migrated: + session.commit() + return migrated + + # --------------------------------------------------------------------------- # # SFTP (paramiko) # --------------------------------------------------------------------------- # diff --git a/backend/services/crypto_service.py b/backend/services/crypto_service.py new file mode 100644 index 0000000..49a710a --- /dev/null +++ b/backend/services/crypto_service.py @@ -0,0 +1,76 @@ +"""Symmetric encryption for secrets that have to live in the database. + +Most of StackPilot's secrets are files on disk (``.env``, ``.secrets/*``) where +filesystem permissions are the right control. A few can't be: backup +destination credentials and agent tokens are needed by background jobs, so they +sit in ``stackpilot.db``. This module encrypts those at rest. + +The key is derived from ``SECRET_KEY`` rather than being a second thing to +configure — which is exactly why ``SECRET_KEY`` is now persisted (see +``config._ensure_secret``): a key that changed on every restart would take the +ciphertext with it. + +Ciphertext is stored with an ``enc:v1:`` prefix so plaintext rows written by +older versions stay recognisable and can be migrated in place. +""" +from __future__ import annotations + +import base64 +import hashlib +import logging +from typing import Optional + +from cryptography.fernet import Fernet, InvalidToken + +from config import settings + +logger = logging.getLogger("stackpilot.crypto") + +PREFIX = "enc:v1:" +_INFO = b"stackpilot-db-field-encryption-v1" + + +class DecryptError(Exception): + """Ciphertext could not be decrypted (usually: SECRET_KEY changed).""" + + +def _fernet() -> Fernet: + """Fernet built from a 32-byte key derived from SECRET_KEY. + + Not cached: SECRET_KEY is fixed for the process lifetime, and building a + Fernet is a hash plus a base64 encode — cheap enough not to bother. + """ + digest = hashlib.blake2b( + settings.SECRET_KEY.encode("utf-8"), key=_INFO, digest_size=32 + ).digest() + return Fernet(base64.urlsafe_b64encode(digest)) + + +def is_encrypted(value: Optional[str]) -> bool: + return bool(value) and value.startswith(PREFIX) + + +def encrypt(plaintext: str) -> str: + """Encrypt a string. Already-encrypted input is returned unchanged.""" + if is_encrypted(plaintext): + return plaintext + token = _fernet().encrypt((plaintext or "").encode("utf-8")) + return PREFIX + token.decode("ascii") + + +def decrypt(value: str) -> str: + """Decrypt a value written by :func:`encrypt`. + + Plaintext (no prefix) is passed straight through, so rows written before + encryption existed keep working until the startup migration rewrites them. + """ + if not is_encrypted(value): + return value or "" + try: + return _fernet().decrypt(value[len(PREFIX):].encode("ascii")).decode("utf-8") + except (InvalidToken, ValueError) as exc: + raise DecryptError( + "Could not decrypt a stored secret. This normally means SECRET_KEY " + "changed since it was saved — restore the old key, or re-enter the " + "affected credentials." + ) from exc diff --git a/backend/services/device_service.py b/backend/services/device_service.py index f8f2ca6..ce0d941 100644 --- a/backend/services/device_service.py +++ b/backend/services/device_service.py @@ -87,12 +87,28 @@ def detect_devices() -> dict: # --------------------------------------------------------------------------- # +class BrowseError(Exception): + pass + + def _real_root(path: str) -> str: - """Map a logical host path into the container view (HOST_ROOT_PREFIX).""" + """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("/") - if prefix: - return prefix + path - return path + 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: @@ -104,10 +120,6 @@ def _is_allowed(path: str) -> bool: return False -class BrowseError(Exception): - pass - - def browse(path: str = "/", show_hidden: bool = False) -> dict: path = os.path.normpath(path or "/") if not path.startswith("/"): diff --git a/backend/version.py b/backend/version.py index 6a55ffd..c3fc0f9 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.43.0" +APP_VERSION = "0.44.0" diff --git a/docker-compose.yml b/docker-compose.yml index 798a8a9..08f2ccc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,10 +14,12 @@ services: - NOTIFY_WEBHOOKS=${NOTIFY_WEBHOOKS:-} # Throwaway image used to snapshot named-volume contents during backups. - BACKUP_HELPER_IMAGE=${BACKUP_HELPER_IMAGE:-alpine:latest} - # File browser (sidebar) + volume host-path picker. ALLOWED_BROWSE_ROOTS - # limits which paths are reachable; HOST_ROOT_PREFIX is where the host + # File browser (sidebar) + volume host-path picker. Admin-only, and + # ALLOWED_BROWSE_ROOTS limits which paths are reachable -- note that a + # single "/" in that list disables the limit entirely. StackPilot's own + # DATA_DIR is always refused. HOST_ROOT_PREFIX is where the host # filesystem is mounted inside this container (see the volume below). - - ALLOWED_BROWSE_ROOTS=${ALLOWED_BROWSE_ROOTS:-/,/mnt,/media,/srv,/opt} + - ALLOWED_BROWSE_ROOTS=${ALLOWED_BROWSE_ROOTS:-/mnt,/media,/srv,/opt,/home} - HOST_ROOT_PREFIX=${HOST_ROOT_PREFIX:-} volumes: - /var/run/docker.sock:/var/run/docker.sock diff --git a/frontend/package.json b/frontend/package.json index 04068a4..0e97b36 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.43.0", + "version": "0.44.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/layout/TopNav.tsx b/frontend/src/components/layout/TopNav.tsx index a586207..2fb4317 100644 --- a/frontend/src/components/layout/TopNav.tsx +++ b/frontend/src/components/layout/TopNav.tsx @@ -23,15 +23,24 @@ import { useThemeStore } from "@/store/theme"; import { agentsApi } from "@/api/agents"; import { VersionBadge } from "./VersionBadge"; -export const NAV_ITEMS = [ +type NavItem = { + to: string; + label: string; + icon: typeof LayoutDashboard; + end?: boolean; + /** Hidden for non-admins — the matching API routes require the admin role. */ + adminOnly?: boolean; +}; + +export const NAV_ITEMS: NavItem[] = [ { to: "/", label: "Dashboard", icon: LayoutDashboard, end: true }, { to: "/stacks", label: "Stacks", icon: Boxes }, { to: "/networks", label: "Networks", icon: Network }, { to: "/images", label: "Images", icon: Image }, { to: "/volumes", label: "Volumes", icon: Database }, - { to: "/files", label: "Files", icon: FolderTree }, + { to: "/files", label: "Files", icon: FolderTree, adminOnly: true }, { to: "/templates", label: "Templates", icon: LayoutTemplate }, - { to: "/audit", label: "Audit", icon: ScrollText }, + { to: "/audit", label: "Audit", icon: ScrollText, adminOnly: true }, { to: "/settings", label: "Settings", icon: Settings }, ]; @@ -76,6 +85,7 @@ export function TopNav() { const menuRef = useRef(null); const navigate = useNavigate(); const user = useAuthStore((s) => s.user); + const navItems = NAV_ITEMS.filter((i) => !i.adminOnly || user?.role === "admin"); const logout = useAuthStore((s) => s.logout); const { theme, toggle } = useThemeStore(); @@ -125,7 +135,7 @@ export function TopNav() { aria-label="Primary" className="mx-auto hidden items-center gap-0.5 rounded-pill border border-sp-border bg-sp-surface p-1 lg:flex" > - {NAV_ITEMS.map(({ to, label, end }) => ( + {navItems.map(({ to, label, end }) => ( {({ isActive }) => (