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:
@@ -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", ""),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user