Durchsicht: Robustheit bei Neuinstallationen
- auth.py: .env auch in schreibgeschütztem Ordner aktualisieren (Server-Login) - Controller: ohne Spotify-Login warten statt abstürzen; Playlist darf fehlen; nicht abspielbare Wünsche überspringen statt hängen zu bleiben - install-server.sh: alle SSH-Ports freigeben (kein Aussperren), curl und python3-systemd (fail2ban) installieren - install-player.sh: Bluetooth per rfkill entsperren und Dienst starten Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -111,6 +111,9 @@ $SUDO systemctl enable raspotify >/dev/null 2>&1
|
|||||||
BT_MAC=""
|
BT_MAC=""
|
||||||
if [ $UPDATE = 0 ] && [ "$BLUETOOTH" = 1 ]; then
|
if [ $UPDATE = 0 ] && [ "$BLUETOOTH" = 1 ]; then
|
||||||
schritt "Bluetooth-Lautsprecher"
|
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
|
# 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
|
$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)."
|
info "Lautsprecher jetzt in den Kopplungsmodus versetzen (meist Bluetooth-Taste halten, bis die LED blinkt)."
|
||||||
|
|||||||
@@ -43,9 +43,11 @@ if [ $UPDATE = 0 ]; then
|
|||||||
warnung "Das sieht nicht nach einer Playlist aus – bitte später im Admin-Bereich eintragen."
|
warnung "Das sieht nicht nach einer Playlist aus – bitte später im Admin-Bereich eintragen."
|
||||||
PLAYLIST_ID=""
|
PLAYLIST_ID=""
|
||||||
fi
|
fi
|
||||||
SSH_PORT="$(${SUDO} sshd -T 2>/dev/null | awk '/^port /{print $2; exit}')"
|
# alle Ports, auf denen SSH lauscht, plus den der aktuellen Sitzung – damit sich niemand aussperrt
|
||||||
SSH_PORT="${SSH_PORT:-22}"
|
SSH_PORTS="$( { ${SUDO} sshd -T 2>/dev/null | awk '/^port /{print $2}'; [ -n "${SSH_CONNECTION:-}" ] && awk '{print $4}' <<<"$SSH_CONNECTION"; } | sort -un | tr '\n' ' ')"
|
||||||
ja_nein "Firewall (ufw) einrichten? Erlaubt SSH (Port $SSH_PORT), 80 und 443" j && FIREWALL=1 || FIREWALL=0
|
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 "fail2ban für SSH einrichten?" j && FAIL2BAN=1 || FAIL2BAN=0
|
||||||
ja_nein "Automatische Sicherheitsupdates aktivieren?" j && AUTOUPDATE=1 || AUTOUPDATE=0
|
ja_nein "Automatische Sicherheitsupdates aktivieren?" j && AUTOUPDATE=1 || AUTOUPDATE=0
|
||||||
export DOMAIN ACME_EMAIL SSH_PORT
|
export DOMAIN ACME_EMAIL SSH_PORT
|
||||||
@@ -54,10 +56,10 @@ fi
|
|||||||
# ------------------------------------------------------------------ Pakete
|
# ------------------------------------------------------------------ Pakete
|
||||||
schritt "Pakete"
|
schritt "Pakete"
|
||||||
$SUDO apt-get update -qq
|
$SUDO apt-get update -qq
|
||||||
PAKETE=(python3-venv sqlite3 caddy)
|
PAKETE=(python3-venv sqlite3 caddy curl)
|
||||||
if [ $UPDATE = 0 ]; then
|
if [ $UPDATE = 0 ]; then
|
||||||
[ $FIREWALL = 1 ] && PAKETE+=(ufw)
|
[ $FIREWALL = 1 ] && PAKETE+=(ufw)
|
||||||
[ $FAIL2BAN = 1 ] && PAKETE+=(fail2ban)
|
[ $FAIL2BAN = 1 ] && PAKETE+=(fail2ban python3-systemd)
|
||||||
[ $AUTOUPDATE = 1 ] && PAKETE+=(unattended-upgrades)
|
[ $AUTOUPDATE = 1 ] && PAKETE+=(unattended-upgrades)
|
||||||
fi
|
fi
|
||||||
pakete "${PAKETE[@]}"
|
pakete "${PAKETE[@]}"
|
||||||
@@ -128,11 +130,11 @@ if [ $UPDATE = 0 ]; then
|
|||||||
schritt "Firewall (ufw)"
|
schritt "Firewall (ufw)"
|
||||||
$SUDO ufw default deny incoming >/dev/null
|
$SUDO ufw default deny incoming >/dev/null
|
||||||
$SUDO ufw default allow outgoing >/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 80/tcp comment "HTTP (Caddy/ACME)" >/dev/null
|
||||||
$SUDO ufw allow 443/tcp comment "HTTPS (Caddy)" >/dev/null
|
$SUDO ufw allow 443/tcp comment "HTTPS (Caddy)" >/dev/null
|
||||||
$SUDO ufw --force enable >/dev/null
|
$SUDO ufw --force enable >/dev/null
|
||||||
info "ufw aktiv: $SSH_PORT, 80, 443"
|
info "ufw aktiv: SSH ${SSH_PORTS% }, 80, 443"
|
||||||
fi
|
fi
|
||||||
if [ $FAIL2BAN = 1 ]; then
|
if [ $FAIL2BAN = 1 ]; then
|
||||||
schritt "fail2ban"
|
schritt "fail2ban"
|
||||||
|
|||||||
+8
-1
@@ -73,10 +73,17 @@ def env_setzen(pfad: Path, key: str, wert: str) -> None:
|
|||||||
if not gefunden:
|
if not gefunden:
|
||||||
neu.append(f"{key}={wert}")
|
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")
|
tmp = pfad.with_name(pfad.name + ".tmp")
|
||||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
with os.fdopen(fd, "w") as f:
|
with os.fdopen(fd, "w") as f:
|
||||||
f.write("\n".join(neu) + "\n")
|
f.write(inhalt)
|
||||||
os.replace(tmp, pfad)
|
os.replace(tmp, pfad)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+27
-3
@@ -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
|
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.
|
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
|
import argparse
|
||||||
@@ -247,7 +247,7 @@ class Controller:
|
|||||||
|
|
||||||
def naechster_eintrag(self) -> Eintrag:
|
def naechster_eintrag(self) -> Eintrag:
|
||||||
wunsch = self.server.naechster_wunsch() if self.server else None
|
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)
|
eintrag = Eintrag(wunsch.uri, "owner" if wunsch.priority else "wish", wunsch)
|
||||||
else:
|
else:
|
||||||
# kein (fälliger) Wunsch → Playlist weiter, Zähler bleibt "fällig"
|
# 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)
|
log.warning("Metadaten für %s nicht abrufbar (%s) – Vergleich nur per URI", eintrag.uri, e)
|
||||||
return eintrag
|
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:
|
def geraet_id(self) -> str | None:
|
||||||
for geraet in self.sp.geraete():
|
for geraet in self.sp.geraete():
|
||||||
if geraet.get("name") == self.geraetename:
|
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")
|
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
|
logging.getLogger("httpx").setLevel(logging.WARNING) # keine Request-URLs im Log
|
||||||
|
|
||||||
|
# Ohne Spotify-Zugang nicht abstürzen (systemd würde endlos neu starten), sondern warten
|
||||||
|
while True:
|
||||||
env = env_lesen(args.env)
|
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"],
|
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))
|
neuer_refresh_token=lambda t: env_setzen(args.env, "SPOTIFY_REFRESH_TOKEN", t))
|
||||||
# alte Namen (VPS_URL, PI_API_TOKEN) weiter akzeptieren
|
# 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")
|
log.warning("SERVER_URL/PLAYER_API_TOKEN fehlen – nur Grund-Playlist, keine Wünsche")
|
||||||
controller = Controller(
|
controller = Controller(
|
||||||
sp,
|
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,
|
server,
|
||||||
n=int(env.get("WISH_EVERY_N") or 3),
|
n=int(env.get("WISH_EVERY_N") or 3),
|
||||||
geraetename=env.get("DEVICE_NAME") or "Audiofy",
|
geraetename=env.get("DEVICE_NAME") or "Audiofy",
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -83,7 +83,7 @@ class Quittung(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/config")
|
@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)
|
lebenszeichen(db)
|
||||||
e = einstellungen(db)
|
e = einstellungen(db)
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user