b135f11557
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>
106 lines
3.8 KiB
Python
106 lines
3.8 KiB
Python
"""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}
|