diff --git a/install/install-player.sh b/install/install-player.sh index 56a25dd..329d5c8 100755 --- a/install/install-player.sh +++ b/install/install-player.sh @@ -111,6 +111,9 @@ $SUDO systemctl enable raspotify >/dev/null 2>&1 BT_MAC="" if [ $UPDATE = 0 ] && [ "$BLUETOOTH" = 1 ]; then schritt "Bluetooth-Lautsprecher" + # Auf frischen Systemen ist Bluetooth oft per rfkill gesperrt (z. B. ohne gesetztes WLAN-Land) + command -v rfkill >/dev/null && { $SUDO rfkill unblock bluetooth || true; } + $SUDO systemctl enable --now bluetooth >/dev/null 2>&1 || warnung "Bluetooth-Dienst ließ sich nicht starten." # Der Player soll nicht selbst als Bluetooth-Empfänger für fremde Handys dienen $SUDO systemctl disable --now bluealsa-aplay >/dev/null 2>&1 || true info "Lautsprecher jetzt in den Kopplungsmodus versetzen (meist Bluetooth-Taste halten, bis die LED blinkt)." diff --git a/install/install-server.sh b/install/install-server.sh index b68262e..21eea54 100755 --- a/install/install-server.sh +++ b/install/install-server.sh @@ -43,9 +43,11 @@ if [ $UPDATE = 0 ]; then warnung "Das sieht nicht nach einer Playlist aus – bitte später im Admin-Bereich eintragen." PLAYLIST_ID="" fi - SSH_PORT="$(${SUDO} sshd -T 2>/dev/null | awk '/^port /{print $2; exit}')" - SSH_PORT="${SSH_PORT:-22}" - ja_nein "Firewall (ufw) einrichten? Erlaubt SSH (Port $SSH_PORT), 80 und 443" j && FIREWALL=1 || FIREWALL=0 + # alle Ports, auf denen SSH lauscht, plus den der aktuellen Sitzung – damit sich niemand aussperrt + SSH_PORTS="$( { ${SUDO} sshd -T 2>/dev/null | awk '/^port /{print $2}'; [ -n "${SSH_CONNECTION:-}" ] && awk '{print $4}' <<<"$SSH_CONNECTION"; } | sort -un | tr '\n' ' ')" + SSH_PORTS="${SSH_PORTS:-22}" + SSH_PORT="${SSH_PORTS%% *}" # für fail2ban: erster Port + ja_nein "Firewall (ufw) einrichten? Erlaubt SSH (Port ${SSH_PORTS% }), 80 und 443" j && FIREWALL=1 || FIREWALL=0 ja_nein "fail2ban für SSH einrichten?" j && FAIL2BAN=1 || FAIL2BAN=0 ja_nein "Automatische Sicherheitsupdates aktivieren?" j && AUTOUPDATE=1 || AUTOUPDATE=0 export DOMAIN ACME_EMAIL SSH_PORT @@ -54,10 +56,10 @@ fi # ------------------------------------------------------------------ Pakete schritt "Pakete" $SUDO apt-get update -qq -PAKETE=(python3-venv sqlite3 caddy) +PAKETE=(python3-venv sqlite3 caddy curl) if [ $UPDATE = 0 ]; then [ $FIREWALL = 1 ] && PAKETE+=(ufw) - [ $FAIL2BAN = 1 ] && PAKETE+=(fail2ban) + [ $FAIL2BAN = 1 ] && PAKETE+=(fail2ban python3-systemd) [ $AUTOUPDATE = 1 ] && PAKETE+=(unattended-upgrades) fi pakete "${PAKETE[@]}" @@ -128,11 +130,11 @@ if [ $UPDATE = 0 ]; then schritt "Firewall (ufw)" $SUDO ufw default deny incoming >/dev/null $SUDO ufw default allow outgoing >/dev/null - $SUDO ufw allow "$SSH_PORT/tcp" comment "SSH" >/dev/null + for p in $SSH_PORTS; do $SUDO ufw allow "$p/tcp" comment "SSH" >/dev/null; done $SUDO ufw allow 80/tcp comment "HTTP (Caddy/ACME)" >/dev/null $SUDO ufw allow 443/tcp comment "HTTPS (Caddy)" >/dev/null $SUDO ufw --force enable >/dev/null - info "ufw aktiv: $SSH_PORT, 80, 443" + info "ufw aktiv: SSH ${SSH_PORTS% }, 80, 443" fi if [ $FAIL2BAN = 1 ]; then schritt "fail2ban" diff --git a/player/auth.py b/player/auth.py index 6d68204..5c33d7d 100755 --- a/player/auth.py +++ b/player/auth.py @@ -73,10 +73,17 @@ def env_setzen(pfad: Path, key: str, wert: str) -> None: if not gefunden: neu.append(f"{key}={wert}") + inhalt = "\n".join(neu) + "\n" + if not os.access(pfad.parent, os.W_OK): + # z. B. Server: /opt/audiofy gehört root, nur die .env selbst dem Dienst-Benutzer + fd = os.open(pfad, os.O_WRONLY | os.O_TRUNC) + with os.fdopen(fd, "w") as f: + f.write(inhalt) + return tmp = pfad.with_name(pfad.name + ".tmp") fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w") as f: - f.write("\n".join(neu) + "\n") + f.write(inhalt) os.replace(tmp, pfad) diff --git a/player/controller.py b/player/controller.py index 3f0636e..365e93d 100644 --- a/player/controller.py +++ b/player/controller.py @@ -14,7 +14,7 @@ Ist der Server nicht erreichbar, läuft einfach die Grund-Playlist weiter. Bedienung über die Spotify-App bleibt möglich: Fremde Titel werden erkannt und geduldet, eine Pause durch den Besitzer (App oder Admin-Bereich) wird respektiert. -Aufruf: .venv/bin/python controller.py +Aufruf: .venv/bin/python controller.py --env /opt/audiofy/config/.env """ import argparse @@ -247,7 +247,7 @@ class Controller: def naechster_eintrag(self) -> Eintrag: wunsch = self.server.naechster_wunsch() if self.server else None - if wunsch and (wunsch.priority or self.seit_wunsch >= self.n - 1): + if wunsch and (wunsch.priority or self.seit_wunsch >= self.n - 1) and self._abspielbar(wunsch): eintrag = Eintrag(wunsch.uri, "owner" if wunsch.priority else "wish", wunsch) else: # kein (fälliger) Wunsch → Playlist weiter, Zähler bleibt "fällig" @@ -265,6 +265,21 @@ class Controller: log.warning("Metadaten für %s nicht abrufbar (%s) – Vergleich nur per URI", eintrag.uri, e) return eintrag + def _abspielbar(self, wunsch: Wunsch) -> bool: + """Ein bei Spotify nicht (mehr) abspielbarer Wunsch würde den Controller blockieren → überspringen.""" + try: + titel = self.sp.titel(wunsch.uri) + except SpotifyFehler as e: + if e.status not in (400, 404): + return True # vorübergehender Fehler – Wunsch nicht verwerfen + titel = {"is_playable": False} + if titel.get("is_playable", True): + return True + log.warning("Wunsch %s ist bei Spotify nicht abspielbar – wird übersprungen", wunsch.uri) + if self.server: + self.server.wunsch_gespielt(wunsch) # aus der Warteschlange nehmen + return False + def geraet_id(self) -> str | None: for geraet in self.sp.geraete(): if geraet.get("name") == self.geraetename: @@ -703,7 +718,15 @@ def main() -> None: format="%(levelname)-7s %(message)s" if unter_systemd else "%(asctime)s %(levelname)-7s %(message)s") logging.getLogger("httpx").setLevel(logging.WARNING) # keine Request-URLs im Log - env = env_lesen(args.env) + # Ohne Spotify-Zugang nicht abstürzen (systemd würde endlos neu starten), sondern warten + while True: + env = env_lesen(args.env) + fehlend = [k for k in ("SPOTIFY_CLIENT_ID", "SPOTIFY_CLIENT_SECRET", "SPOTIFY_REFRESH_TOKEN") if not env.get(k)] + if not fehlend: + break + log.error("In %s fehlt %s – Spotify-Login nachholen: auth.py --profil player --env %s " + "(siehe README). Neuer Versuch in 60 s.", args.env, ", ".join(fehlend), args.env) + time.sleep(60) sp = SpotifyClient(env["SPOTIFY_CLIENT_ID"], env["SPOTIFY_CLIENT_SECRET"], env["SPOTIFY_REFRESH_TOKEN"], neuer_refresh_token=lambda t: env_setzen(args.env, "SPOTIFY_REFRESH_TOKEN", t)) # alte Namen (VPS_URL, PI_API_TOKEN) weiter akzeptieren @@ -714,7 +737,8 @@ def main() -> None: log.warning("SERVER_URL/PLAYER_API_TOKEN fehlen – nur Grund-Playlist, keine Wünsche") controller = Controller( sp, - Playlist(sp, env["SPOTIFY_PLAYLIST_ID"], int(env.get("PLAYLIST_REPEAT_PERCENT") or 50)), + # Playlist darf fehlen, wenn sie vom Server kommt + Playlist(sp, env.get("SPOTIFY_PLAYLIST_ID", ""), int(env.get("PLAYLIST_REPEAT_PERCENT") or 50)), server, n=int(env.get("WISH_EVERY_N") or 3), geraetename=env.get("DEVICE_NAME") or "Audiofy", diff --git a/player/tests/test_robustheit.py b/player/tests/test_robustheit.py new file mode 100644 index 0000000..9451774 --- /dev/null +++ b/player/tests/test_robustheit.py @@ -0,0 +1,80 @@ +"""Tests für Schutzmaßnahmen: nicht abspielbare Wünsche, .env in schreibgeschütztem Ordner.""" + +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from auth import env_lesen, env_setzen # noqa: E402 +from controller import Controller, Playlist # noqa: E402 +from server_client import Wunsch # noqa: E402 +from spotify_client import SpotifyFehler # noqa: E402 + +TITEL = [f"spotify:track:{i:022d}" for i in range(5)] +WUNSCH = "spotify:track:" + "W" * 22 + + +class FakeSpotify: + def __init__(self, wunsch_status="ok"): + self.wunsch_status = wunsch_status + + def playlist_titel(self, playlist_id): + return list(TITEL) + + def titel(self, uri): + if uri == WUNSCH and self.wunsch_status == "404": + raise SpotifyFehler(404, "nicht gefunden") + if uri == WUNSCH and self.wunsch_status == "503": + raise SpotifyFehler(503, "kurz weg") + return {"uri": uri, "name": "x", "artists": [{"name": "y"}], + "is_playable": not (uri == WUNSCH and self.wunsch_status == "gesperrt")} + + +class FakeServer: + def __init__(self): + self.gespielt = [] + + def naechster_wunsch(self): + return Wunsch(id=7, uri=WUNSCH) + + def wunsch_gespielt(self, wunsch): + self.gespielt.append(wunsch.id) + + +def controller(sp, server): + c = Controller(sp=sp, playlist=Playlist(sp, "liste"), server=server, n=1, geraetename="Audiofy", bt_mac=None) + return c + + +def test_abspielbarer_wunsch_wird_gespielt(): + server = FakeServer() + assert controller(FakeSpotify("ok"), server).naechster_eintrag().quelle == "wish" + assert server.gespielt == [] + + +def test_gesperrter_wunsch_wird_uebersprungen(): + for status in ("gesperrt", "404"): + server = FakeServer() + eintrag = controller(FakeSpotify(status), server).naechster_eintrag() + assert eintrag.quelle == "playlist" and eintrag.uri in TITEL + assert server.gespielt == [7], status # aus der Warteschlange genommen + + +def test_voruebergehender_fehler_verwirft_wunsch_nicht(): + server = FakeServer() + controller(FakeSpotify("503"), server).naechster_eintrag() + assert server.gespielt == [] + + +def test_env_in_schreibgeschuetztem_ordner(tmp_path): + """Wie auf dem Server: Ordner gehört root, nur die .env dem Dienst-Benutzer.""" + env = tmp_path / ".env" + env.write_text("SPOTIFY_CLIENT_ID=abc\n") + os.chmod(env, 0o600) + os.chmod(tmp_path, 0o500) + try: + env_setzen(env, "SPOTIFY_REFRESH_TOKEN", "neu") + assert env_lesen(env) == {"SPOTIFY_CLIENT_ID": "abc", "SPOTIFY_REFRESH_TOKEN": "neu"} + assert oct(env.stat().st_mode & 0o777) == "0o600" + finally: + os.chmod(tmp_path, 0o700) diff --git a/server/app/player_api.py b/server/app/player_api.py index b05c7c2..cb628dd 100644 --- a/server/app/player_api.py +++ b/server/app/player_api.py @@ -83,7 +83,7 @@ class Quittung(BaseModel): @router.get("/config") -def pi_config(db: sqlite3.Connection = Depends(get_db)) -> dict: +def player_config(db: sqlite3.Connection = Depends(get_db)) -> dict: lebenszeichen(db) e = einstellungen(db) return {