diff --git a/AGENTS.md b/AGENTS.md index eec1771..dd7054b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -216,6 +216,9 @@ Versorgung und Sicherheit im Plan. gemeinsames `data/` über `HALLOWEEN_STATE_DIR`. Nach Dienststopp Daten sichern; bei Start-/HTTP-Fehlern Code, Unit und Daten zurücksetzen. `--web-host` wählt Loopback oder ausdrücklich Heimnetz; gespeicherte Wahl bleibt erhalten. + Upload teilt eine SSH-Verbindung über privaten temporären Socket; ohne + Schlüssel typischerweise einmal SSH- und einmal sudo-Passwort. Aufräumen bei + Erfolg/Fehler, keine neue Passwortabfrage durch Slave-Verbindungen. Update mit genau einem sudo-Aufruf für Umschaltung und Rückfall; Paketsetup unprivilegiert, Python-/Unitprüfung per runuser als Pi-Benutzer. Passwort und sudoers/Zeitstempel bleiben unverändert; echte Passwortabfrage noch prüfen. diff --git a/PLAN.md b/PLAN.md index 9f2b858..f3d000f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -216,6 +216,9 @@ Versorgung und Sicherheit im Plan. gemeinsames `data/` über `HALLOWEEN_STATE_DIR`. Nach Dienststopp Daten sichern; bei Start-/HTTP-Fehlern Code, Unit und Daten zurücksetzen. `--web-host` wählt Loopback oder ausdrücklich Heimnetz; gespeicherte Wahl bleibt erhalten. + Upload teilt eine SSH-Verbindung über privaten temporären Socket; ohne + Schlüssel typischerweise einmal SSH- und einmal sudo-Passwort. Aufräumen bei + Erfolg/Fehler, keine neue Passwortabfrage durch Slave-Verbindungen. Update mit genau einem sudo-Aufruf für Umschaltung und Rückfall; Paketsetup unprivilegiert, Python-/Unitprüfung per runuser als Pi-Benutzer. Passwort und sudoers/Zeitstempel bleiben unverändert; echte Passwortabfrage noch prüfen. @@ -317,3 +320,14 @@ Bildsequenzen eines Blender-Schnittprojekts bei Weitergabe mitnehmen. zweiten sudo-Aufrufs, erfolgreichem Rückfall und abgelehnter sudo-Anmeldung. - Offen am Pi: aktualisierten Upload ausführen und tatsächliche Anzahl der Passwortabfragen sowie Dienst/Python-Benutzer prüfen; kein echter PAM-Test. + +## Mehrfache Passwortabfragen: gemeinsame SSH-Verbindung + +- Nutzer meldet weiterhin 4–5 Passwortabfragen. Skriptprüfung zeigt zusätzlich + zum einzigen sudo-Aufruf bislang drei separate SSH-Verbindungen. Die genaue + Beschriftung der gemeldeten Dialoge wurde nicht beobachtet. +- Erledigt: einmalige SSH-Master-Verbindung mit privatem temporärem Socket, + gemeinsame Nutzung durch mkdir/rsync/Aktivierung, BatchMode für Slaves und + Aufräumen bei Erfolg/Fehler. Ohne Schlüssel sind SSH und sudo zwei Anmeldungen. +- Lokal verifiziert: 15 Update-/Transport-Simulationstests bestanden, keine + Verbindung/Socketerzeugung bei Vorschau. Echte Passwortanzahl am Pi noch offen. diff --git a/SOFTWARE.md b/SOFTWARE.md index 5bf522e..2269ecd 100644 --- a/SOFTWARE.md +++ b/SOFTWARE.md @@ -34,7 +34,8 @@ direkt im WLAN sichtbar. Das versionierte Uploadskript richtet den systemd-Autostart jetzt bei jedem Update mit `--apply` ein und prüft den gestarteten Webdienst. Anleitung, Rückfall und Datensicherung stehen in [UPLOAD.md](UPLOAD.md). Der Updateablauf -verwendet einen einzigen sudo-Aufruf für Umschaltung und möglichen Rückfall; +verwendet eine gemeinsame SSH-Verbindung und einen einzigen sudo-Aufruf +für Umschaltung und möglichen Rückfall; das Passwort wird nicht gespeichert. Python-Prüfungen laufen als Pi-Benutzer. Der Dienst nutzt `current/.venv`, startet die WebUI beim Hochfahren und verweist diff --git a/UPLOAD.md b/UPLOAD.md index ab023ff..a22c5af 100644 --- a/UPLOAD.md +++ b/UPLOAD.md @@ -47,7 +47,22 @@ auf dem Laptop wird dadurch nicht automatisch geöffnet. beenden. Ein bestehender, vom Projekt verwalteter Dienst wird selbst gestoppt. Eine fremde gleichnamige systemd-Unit wird abgelehnt. -## Einmalige sudo-Abfrage beim Update +## Passwortabfragen beim Update + +Alle Upload-Schritte nutzen nun **eine gemeinsame SSH-Verbindung**. Mit korrekter +Passwortanmeldung ohne SSH-Schlüssel sind normalerweise insgesamt zwei Abfragen +nötig: einmal das SSH-Passwort für die Verbindung, einmal das sudo-Passwort für +den privilegierten Updateaufruf. Bei Schlüssel-/Agent-Anmeldung entfällt die +SSH-Passwortabfrage; bei gültigem sudo-Zeitstempel kann auch diese entfallen. +Ein erster Hostschlüssel-Dialog ist keine Passwortabfrage. + +Der Verbindungssocket liegt in einem privaten temporären Ordner (0700) unter +`/tmp` auf dem Laptop. Ordneranlage, rsync und Aktivierung verwenden denselben +Socket. Am Ende oder bei Fehlern wird die Master-Verbindung geschlossen und der +Socketordner entfernt. Bei Verbindungsverlust fragen spätere Schritte nicht +erneut nach einem Passwort (`BatchMode=yes`), sondern brechen bei fehlender +Anmeldemöglichkeit ab. SSH-Konfiguration, Schlüssel und Passwörter werden nicht +neu gespeichert oder geändert. Die Vorschau öffnet keine Verbindung. Das Uploadskript bereitet die neue Python-Umgebung als normaler Pi-Benutzer vor. Danach läuft die Umschaltung mit **einem einzigen sudo-Aufruf**: Unitprüfung, diff --git a/tests/instructions.md b/tests/instructions.md index dd42766..29a5607 100644 --- a/tests/instructions.md +++ b/tests/instructions.md @@ -59,3 +59,8 @@ Automatische Tests prüfen die aktuelle Software ohne Pi-Hardware. Rückfall unabhängig von Passwort-Tickets geprüft werden. Abgelehnte Anmeldung, unprivilegierten Aufruf des internen Rootteils und unveränderte Daten prüfen. Kein echter root-/PAM-/systemd- oder Pi-Nachweis aus diesen Tests. + +- UploadConnectionTest simuliert ssh/rsync und prüft genau einen Master, denselben + privaten Socket für alle Schritte, BatchMode auf Slaves, TTY für sudo sowie + Aufräumen bei Erfolg/Verbindungs-/Transfer-/Aktivierungsfehlern und pure Vorschau. + Dies bestätigt keine reale SSH-/Passwortanmeldung am Pi. diff --git a/tests/test_pi_update.py b/tests/test_pi_update.py index b7c4c72..64f8f95 100644 --- a/tests/test_pi_update.py +++ b/tests/test_pi_update.py @@ -2,6 +2,8 @@ No SSH, package downloads, real services or Pi hardware are used. """ import os +import json +import sys from pathlib import Path import shutil import subprocess @@ -235,5 +237,96 @@ exit 0 self.assertEqual((self.root / 'current').resolve(), self.old) +class UploadConnectionTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='halloween-ssh-test-') + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.bin = self.base / 'bin' + self.bin.mkdir() + self.env = dict(os.environ, PATH=f'{self.bin}:{os.environ["PATH"]}', TEST_SSH_BASE=str(self.base)) + ssh = self.bin / 'ssh' + ssh.write_text('#!' + sys.executable + '\n' + ''' +import json, os, pathlib, sys +base = pathlib.Path(os.environ['TEST_SSH_BASE']) +args = sys.argv[1:] +control = next((arg.split('=',1)[1] for arg in args if arg.startswith('ControlPath=')), None) +if '-S' in args: control = args[args.index('-S')+1] +with (base / 'calls.jsonl').open('a') as log: + log.write(json.dumps({'tool':'ssh','args':args,'control':control})+'\\n') +path = pathlib.Path(control) +if '-M' in args: + assert path.parent.stat().st_mode & 0o777 == 0o700 + if (base / 'fail-master').exists(): sys.exit(7) + path.touch() +elif '-O' in args: + assert args[args.index('-O')+1] == 'exit' + path.unlink(missing_ok=True) +else: + assert 'BatchMode=yes' in args + assert path.exists(), 'Shared connection must exist' + if '-tt' in args and (base / 'fail-activation').exists(): sys.exit(7) +''') + ssh.chmod(0o755) + rsync = self.bin / 'rsync' + rsync.write_text('#!' + sys.executable + '\n' + ''' +import json, os, pathlib, shlex, sys +base = pathlib.Path(os.environ['TEST_SSH_BASE']) +args = sys.argv[1:] +transport = shlex.split(args[args.index('-e')+1]) +control = next(arg.split('=',1)[1] for arg in transport if arg.startswith('ControlPath=')) +assert 'BatchMode=yes' in transport +assert pathlib.Path(control).exists() +with (base / 'calls.jsonl').open('a') as log: + log.write(json.dumps({'tool':'rsync','args':args,'control':control})+'\\n') +if (base / 'fail-transfer').exists(): sys.exit(7) +''') + rsync.chmod(0o755) + + def upload(self, apply=True): + command = ['bash', str(PROJECT / 'tools/upload_to_pi.sh'), '--user', 'piuser', + '--host', 'pi.local', '--remote-dir', '/home/piuser/halloween'] + if apply: command.append('--apply') + return subprocess.run(command, env=self.env, capture_output=True, text=True) + + def calls(self): + return [json.loads(line) for line in (self.base / 'calls.jsonl').read_text().splitlines()] + + def test_one_authenticated_connection_for_all_steps_and_cleanup(self): + result = self.upload() + self.assertEqual(result.returncode, 0, result.stderr) + calls = self.calls() + self.assertEqual(sum('-M' in call['args'] for call in calls), 1) + self.assertEqual([call['tool'] for call in calls], ['ssh','ssh','rsync','ssh','ssh']) + self.assertEqual(len({call['control'] for call in calls}), 1) + self.assertIn('-tt', calls[3]['args']) + self.assertIn('-O', calls[-1]['args']) + self.assertFalse(Path(calls[0]['control']).parent.exists()) + + def test_failure_closes_connection_and_stops_following_steps(self): + for failure in ('fail-master', 'fail-transfer', 'fail-activation'): + with self.subTest(failure=failure): + (self.base / failure).touch() + result = self.upload() + self.assertNotEqual(result.returncode, 0) + calls = self.calls() + self.assertEqual(sum('-M' in call['args'] for call in calls), 1) + self.assertFalse(Path(calls[0]['control']).parent.exists()) + if failure == 'fail-master': + self.assertEqual(len(calls), 1) + else: + self.assertIn('-O', calls[-1]['args']) + if failure == 'fail-transfer': + self.assertFalse(any('-tt' in call['args'] for call in calls)) + (self.base / failure).unlink() + (self.base / 'calls.jsonl').unlink() + + def test_preview_does_not_connect_or_create_session(self): + result = self.upload(apply=False) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse((self.base / 'calls.jsonl').exists()) + self.assertIn('halloween-ssh-VORSCHAU', result.stdout) + + if __name__ == '__main__': unittest.main() diff --git a/tools/instructions.md b/tools/instructions.md index dcd6037..1cde380 100644 --- a/tools/instructions.md +++ b/tools/instructions.md @@ -44,3 +44,11 @@ Schritte; Reihenfolge im zugehörigen Vorschau-README dokumentieren. - Neu erzeugte Backupordner, Unit-Sicherung, current-Link und service-host dem ursprünglichen Pi-Benutzer zuordnen; bestehende Datenbesitzer beim Kopieren bewahren. Abgelehnte sudo-Anmeldung darf keine Umschaltung/Dienstpause auslösen. + +- Upload baut einen SSH-ControlMaster pro angewendetem Aufruf auf; Ordneranlage, + rsync und Aktivierung teilen den Socket. Privater temporärer Ordner unter /tmp + (0700), ControlPersist=no und EXIT-/Signal-Aufräumen. Hostschlüsselprüfung bleibt + aktiv, keine Passwörter/Schlüssel/SSH-Konfiguration neu speichern. +- Slave-Verbindungen verwenden BatchMode=yes, damit bei Verlust der gemeinsamen + Verbindung keine weiteren Passwortdialoge entstehen. Ohne Schlüssel sind SSH + und sudo weiterhin zwei getrennte Anmeldungen. Vorschau erzeugt keinen Socket. diff --git a/tools/upload_to_pi.sh b/tools/upload_to_pi.sh index 39b96a8..857eeab 100755 --- a/tools/upload_to_pi.sh +++ b/tools/upload_to_pi.sh @@ -88,19 +88,47 @@ excludes=( --exclude='*.p12' --exclude='*.pfx' ) -ssh_cmd=(ssh -o StrictHostKeyChecking=ask "$target" "mkdir -p -- '$remote_dir/releases' && mkdir -- '$remote_dir/releases/$release'") +# One SSH authentication for the entire upload. The socket is private and only +# lives for this invocation; no persistent key or password is written. +control_dir= +control_path=/tmp/halloween-ssh-VORSCHAU/control +master_started=false +cleanup_connection() { + if $master_started; then + ssh -o BatchMode=yes -S "$control_path" -O exit "$target" >/dev/null 2>&1 || true + fi + if [[ -n $control_dir ]]; then + rm -f -- "$control_path" + rmdir -- "$control_dir" 2>/dev/null || true + fi +} +if $apply; then + control_dir=$(mktemp -d /tmp/halloween-ssh-XXXXXXXX) + chmod 700 "$control_dir" + control_path="$control_dir/control" + trap cleanup_connection EXIT + trap 'exit 130' INT + trap 'exit 143' TERM +fi +master_cmd=(ssh -M -N -f -o ControlPersist=no -o BatchMode=no + -o StrictHostKeyChecking=ask -o "ControlPath=$control_path" "$target") +# If the shared connection is lost, refuse password fallback on later steps. +ssh_options=(-o StrictHostKeyChecking=ask -o ControlMaster=no + -o "ControlPath=$control_path" -o BatchMode=yes) +ssh_cmd=(ssh "${ssh_options[@]}" "$target" "mkdir -p -- '$remote_dir/releases' && mkdir -- '$remote_dir/releases/$release'") rsync_cmd=( rsync -a --delay-updates --human-readable --info=stats2 "${excludes[@]}" - -e 'ssh -o StrictHostKeyChecking=ask' + -e "ssh -o StrictHostKeyChecking=ask -o ControlMaster=no -o ControlPath=$control_path -o BatchMode=yes" -- "$source_dir/" "$target:$remote_dir/releases/$release/" ) -service_cmd=(ssh -tt -o StrictHostKeyChecking=ask "$target" "bash '$remote_dir/releases/$release/tools/activate_pi_release.sh' '$remote_dir' '$release' '$bind_host'") +service_cmd=(ssh -tt "${ssh_options[@]}" "$target" "bash '$remote_dir/releases/$release/tools/activate_pi_release.sh' '$remote_dir' '$release' '$bind_host'") printf 'Quelle: %s\nZiel: %s:%s/\n' "$source_dir" "$target" "$remote_dir" printf 'Ausgelassen: .git, .venv, data, preview, references, __pycache__, .env, Schlüsseldateien\n' if ! $apply; then printf '\nVorschau, noch keine Verbindung und keine Übertragung:\n' + printf ' %q' "${master_cmd[@]}"; printf '\n' printf ' %q' "${ssh_cmd[@]}"; printf '\n' printf ' %q' "${rsync_cmd[@]}"; printf '\n' printf ' %q' "${service_cmd[@]}"; printf '\n' @@ -108,6 +136,9 @@ if ! $apply; then exit 0 fi +printf 'SSH-Verbindung einmal anmelden; danach folgt nur noch die sudo-Abfrage auf dem Pi.\n' +"${master_cmd[@]}" +master_started=true "${ssh_cmd[@]}" "${rsync_cmd[@]}" printf 'Übertragung abgeschlossen.\n'