Files
alembic/app/services/diagnostics.py
T
andrew b135f11557 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>
2026-07-09 14:25:55 -06:00

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}