Security hardening and first-run/portability improvements

Security (P0):
- Remove committed session-secret default; auto-generate and persist a
  random secret to the config volume when SESSION_SECRET is unset
  (prevents forgeable session cookies / auth bypass).
- Validate playlist names to a safe charset and render sldl configs via
  literal Python substitution instead of sed (closes a command-injection
  and path-traversal path through playlist names).
- shlex-quote credential values written to shell-sourced env files, and
  strip newlines from values patched into .conf files.
- Render playlist .conf files 0600; warn at startup if the master key is
  co-located with the config volume; document keeping it separate.

Portability:
- Configurable timezone via TZ (default UTC) instead of hardcoded Edmonton.
- Remove personal defaults (navidrome user "andrew", ephemeral.club URLs).
- Ship generic example seeds; move the cross-album dedup keep-list and the
  legacy playlist import to editable config files; drop the personal
  _upgrade.csv.
- Generic VPN reference in docker-compose.snippet.yml.

First-run experience:
- Redirect to /setup instead of 500 when OIDC is unconfigured; surface a
  missing master key inline; entrypoint exits with an actionable message
  when the config folder is not writable.
- Add unauthenticated /health (JSON) and /setup (checklist) diagnostics.

Docs:
- Write docs/ARCHITECTURE.md and docs/MIGRATION.md (previously referenced
  but missing); expand README with ownership, backups, advanced settings,
  and migration guidance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
andrew
2026-07-09 14:25:55 -06:00
parent 2a19f84575
commit b135f11557
40 changed files with 759 additions and 600 deletions
+11 -3
View File
@@ -1,4 +1,5 @@
import re
import shlex
import time
from pathlib import Path
@@ -30,7 +31,7 @@ SCOPE_DESCRIPTIONS = {
"navidrome": "Triggers a library rescan after downloads/imports/edits, and lets the daily status report check Navidrome's scan health. Required.",
"bandcamp": "Pulls new purchases from your Bandcamp collection, imports them automatically, and stamps a buy link into the file tag.",
"qobuz": "A buy-link source (alongside Bandcamp) for tracks you didn't purchase there -- DRM-free hi-res links when available, written directly into the file tag.",
"azuracast": "Not a buy-link source itself (that's Bandcamp/Qobuz, written straight to the file tag) -- this lets alembic tell AzuraCast (ephemeral.club's backend) to immediately reprocess touched files or fix a playlist assignment, instead of waiting for its periodic scan.",
"azuracast": "Only relevant if you run an AzuraCast radio station off the same library. Not a buy-link source itself (that's Bandcamp/Qobuz, written straight to the file tag) -- this lets alembic tell AzuraCast to immediately reprocess touched files or fix a playlist assignment, instead of waiting for its periodic scan.",
"telegram": "Sends the daily pipeline health digest to a chat instead of you having to check the dashboard.",
}
@@ -99,6 +100,9 @@ def _patch_conf_field(path: Path, key: str, value: str) -> None:
every other line (including PLAYLIST_NAME/SPOTIFY_URL substitutions
regen.sh already applied) untouched."""
text = path.read_text()
# A newline in the value would inject an extra `key = value` line into the
# sldl config. Credentials never legitimately contain one, so strip any.
value = value.replace("\r", "").replace("\n", "")
pattern = re.compile(rf"^{re.escape(key)}\s*=.*$", re.MULTILINE)
new_line = f"{key} = {value}"
if pattern.search(text):
@@ -114,7 +118,11 @@ def _every_playlist_conf() -> list[Path]:
def _write_env_file(path: Path, values: dict[str, str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
lines = [f"{k}='{v}'" for k, v in values.items()]
# shlex.quote so a credential value containing a quote, space, or shell
# metacharacter (e.g. a password like `p'a$(id)ss`) stays a single literal
# value when a pipeline script `source`s this file, instead of breaking out
# and executing. shlex.quote also handles the empty string correctly ('').
lines = [f"{k}={shlex.quote(v)}" for k, v in values.items()]
path.write_text("\n".join(lines) + "\n")
path.chmod(0o600)
@@ -147,7 +155,7 @@ def render_scope(db: Session, scope: str) -> None:
settings.pipeline_config_dir / "navidrome" / "admin.env",
{
"ND_BASE": values.get("base_url", "http://navidrome:4533"),
"ND_USER": values.get("admin_user", "andrew"),
"ND_USER": values.get("admin_user", ""),
"ND_PASS": values.get("admin_pass", ""),
},
)
+105
View File
@@ -0,0 +1,105 @@
"""Setup and health checks.
These answer "is this install configured enough to work?" without needing a
login, so a fresh operator can see what is missing before the point where a
missing piece would otherwise surface as an opaque 500. Nothing here reads or
exposes a secret value; it only reports presence and reachability.
"""
import os
from app.settings import settings
# Checks whose failure means the app cannot function (can't log in, can't
# persist state). Everything else is informational.
CRITICAL = {"oidc", "master_key", "config_writable"}
def _config_writable() -> bool:
probe = settings.alembic_config_dir / ".write-probe"
try:
probe.write_text("ok")
probe.unlink()
return True
except OSError:
return False
def oidc_configured() -> bool:
return bool(
settings.pocketid_issuer
and settings.pocketid_client_id
and settings.pocketid_client_secret
)
def master_key_present() -> bool:
return settings.encryption_key_file.exists()
def _key_colocated() -> bool:
try:
key = settings.encryption_key_file.resolve()
cfg = settings.alembic_config_dir.resolve()
except OSError:
return False
return cfg == key or cfg in key.parents
def checks() -> list[dict]:
"""Return one dict per check: key, ok, critical, detail."""
results = [
{
"key": "oidc",
"ok": oidc_configured(),
"detail": "Login provider configured."
if oidc_configured()
else "Set POCKETID_ISSUER, POCKETID_CLIENT_ID and POCKETID_CLIENT_SECRET. Without them, login cannot work.",
},
{
"key": "master_key",
"ok": master_key_present(),
"detail": f"Encryption key found at {settings.encryption_key_file}."
if master_key_present()
else f"No encryption key at {settings.encryption_key_file}. Generate one with `openssl rand -base64 32` and mount it as the alembic_master_key secret. Saving any credential will fail until then.",
},
{
"key": "config_writable",
"ok": _config_writable(),
"detail": f"Config directory {settings.alembic_config_dir} is writable."
if _config_writable()
else f"Cannot write to {settings.alembic_config_dir}. Fix ownership on the host so the container user (uid 1000) can write: chown -R 1000:1000 <your config folder>.",
},
{
"key": "music_dir",
"ok": os.path.isdir(settings.music_data_dir),
"detail": f"Music directory {settings.music_data_dir} is present."
if os.path.isdir(settings.music_data_dir)
else f"Music directory {settings.music_data_dir} is not mounted.",
},
{
"key": "beets_library",
"ok": True, # absence is normal before the first import
"detail": "Library database present."
if settings.beets_db_path.exists()
else "No library database yet. This is normal until your first playlist sync or import.",
},
]
if master_key_present() and _key_colocated():
results.append(
{
"key": "key_location",
"ok": True,
"detail": "Note: the master key sits inside the config directory. A single leaked backup would expose both the key and the encrypted credentials. Consider mounting it from a separate location.",
"warn": True,
}
)
for r in results:
r["critical"] = r["key"] in CRITICAL
return results
def summary() -> dict:
"""Overall status plus the per-check list, for /health."""
results = checks()
degraded = any(c["critical"] and not c["ok"] for c in results)
return {"status": "degraded" if degraded else "ok", "checks": results}
+46 -30
View File
@@ -1,4 +1,5 @@
import json
import re
import subprocess
import time
@@ -8,6 +9,26 @@ from sqlalchemy.orm import Session
from app.models import Playlist
from app.settings import settings
# A playlist name becomes a filename (<name>.conf), a directory under the
# Soulseek dropbox, an M3U path, and a log path, and it is substituted into
# rendered config files. Restrict it to a small safe charset so it can never
# escape those paths (../) or inject config/shell content. Kept deliberately
# tight; if a user wants a fancier display name that's a separate field to add
# later, not a reason to loosen the on-disk identifier.
_VALID_NAME = re.compile(r"^[A-Za-z0-9 _-]{1,64}$")
def validate_name(name: str) -> str:
"""Return the name unchanged if it is a safe on-disk identifier, else
raise ValueError with a message suitable for showing to the user."""
cleaned = (name or "").strip()
if not _VALID_NAME.match(cleaned):
raise ValueError(
"Playlist name must be 1-64 characters using only letters, "
"numbers, spaces, hyphens and underscores."
)
return cleaned
def cron_to_time(cron_expr: str | None) -> str:
"""'30 1 * * *' -> '01:30'. Every playlist schedule today is a simple
@@ -41,32 +62,6 @@ def time_to_cron(time_str: str | None) -> str | None:
return None
return f"{minute} {hour} * * *"
# The 17-entry array this project is migrating off of (was hardcoded in
# /opt/sldl/configs/regen.sh). Used once by seed_legacy() during Stage 1/2
# of the migration; the DB is the source of truth from then on. cron_expr
# values are the exact staggered slots from /etc/cron.d/sldl-maintenance,
# preserved so cutover doesn't change anyone's download schedule.
LEGACY_PLAYLISTS = {
"digicore": ("https://open.spotify.com/playlist/6tWHtPBECZTkZHPKFA3Fq4", "0 0 * * *", False),
"techno": ("https://open.spotify.com/playlist/5GlAkczmLWJFIJO4FvkLEZ", "0 1 * * *", False),
"house": ("https://open.spotify.com/playlist/2XF8ye6O5xqzT4NSfIXdfJ", "30 1 * * *", False),
"lofi": ("https://open.spotify.com/playlist/0qnyF6TAmuT3U6hFH2fiF1", "0 2 * * *", False),
"y2k": ("https://open.spotify.com/playlist/0iaA7ZJS00aRxhMwZn2GCU", "30 2 * * *", False),
"weeb": ("https://open.spotify.com/playlist/0599AbpsKRjsp2rlJI8jhf", "0 3 * * *", False),
"shoegaze": ("https://open.spotify.com/playlist/3R4S3tmbVKjXQ1RPAmryC5", "30 3 * * *", False),
"bass": ("https://open.spotify.com/playlist/2zkF4S16efaMmmzdIOe5w2", "0 4 * * *", False),
"hiphop": ("https://open.spotify.com/playlist/42JxTtOLJ7gG1ybfyy7Vmi", "30 4 * * *", False),
"modular": ("https://open.spotify.com/playlist/2kOfP2EM8dzdMYC6fXvlLb", "0 5 * * *", False),
"hotdog": ("https://open.spotify.com/playlist/53HrOL0qw47G9ywYZz3kgX", "30 5 * * *", False),
"kpop": ("https://open.spotify.com/playlist/31e2V512TIqr5JIfgkyseo", "0 6 * * *", False),
"jungle": ("https://open.spotify.com/playlist/4LjLeXg6ElneQiTaIcAHhE", "30 6 * * *", False),
"goldenera": ("https://open.spotify.com/playlist/57vArhigysJfgwB6CY4VXR", "0 7 * * *", False),
"botanica": ("https://open.spotify.com/playlist/5KgQT9YWz3EqhIN4Jh69IU", "30 7 * * *", False),
"hardcore": ("https://open.spotify.com/playlist/1J1lbzlQKligzr2LWaq9Ex", "0 23 * * *", False),
"liked": ("https://open.spotify.com/playlist/4Z3qCYuU1sjNeNYO3Amzeo", "30 23 * * *", True),
}
def list_all(db: Session) -> list[Playlist]:
return list(db.execute(select(Playlist).order_by(Playlist.name)).scalars())
@@ -88,6 +83,7 @@ def create(
notes: str | None = None,
cron_expr: str | None = None,
) -> Playlist:
name = validate_name(name)
now = time.time()
playlist = Playlist(
name=name,
@@ -141,13 +137,33 @@ def delete(db: Session, playlist_id: int) -> None:
def seed_legacy(db: Session) -> int:
"""One-time import of the legacy hardcoded array (migration Stage 1/2).
Skips any name that already exists. Returns the number of rows created."""
"""One-time import of playlists from an optional legacy-playlists.json in
the config dir (migration helper). This is not personal data baked into
the code: point it at your own export. Each entry is an object with
"name", "spotify_url", and optional "cron_expr" and "no_m3u". Skips any
name that already exists. Returns the number of rows created, or 0 if the
file is absent.
Example legacy-playlists.json:
[{"name": "techno", "spotify_url": "https://open.spotify.com/playlist/...",
"cron_expr": "0 1 * * *", "no_m3u": false}]
"""
legacy_path = settings.pipeline_config_dir / "legacy-playlists.json"
if not legacy_path.exists():
return 0
entries = json.loads(legacy_path.read_text())
created = 0
for name, (url, cron_expr, no_m3u) in LEGACY_PLAYLISTS.items():
for entry in entries:
name = entry["name"]
if get_by_name(db, name) is not None:
continue
create(db, name=name, spotify_url=url, no_m3u=no_m3u, cron_expr=cron_expr)
create(
db,
name=name,
spotify_url=entry["spotify_url"],
no_m3u=entry.get("no_m3u", False),
cron_expr=entry.get("cron_expr"),
)
created += 1
return created
+18 -1
View File
@@ -1,4 +1,6 @@
import logging
import time
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
@@ -9,7 +11,22 @@ from app.models import Playlist, ScheduledJob
from app.services import dedup_review_service, genre_review_service, pipeline_runner
from app.settings import settings
TIMEZONE = "America/Edmonton"
log = logging.getLogger("alembic")
def _resolve_timezone(name: str) -> str:
"""Validate the configured timezone, falling back to UTC if it isn't a
real IANA zone. Returned as a string because that's what APScheduler and
CronTrigger accept directly."""
try:
ZoneInfo(name)
return name
except (ZoneInfoNotFoundError, ValueError):
log.warning("Unknown timezone %r, falling back to UTC.", name)
return "UTC"
TIMEZONE = _resolve_timezone(settings.timezone)
# ---------------------------------------------------------------------------
# Maintenance jobs, ported 1:1 from /etc/cron.d/sldl-maintenance. Each entry