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", ""),
},
)