Personalisierung: Kiosk abbauen, Anmeldebildschirm nach Kontoänderung neu (ADR-0041, 24.10.2026)

Rolle personalisierung baut bei present und absent Konto, Kiosk, PAM-Zeile,
Wächter und LightDM-Konfiguration ab; Wrapper erst nach Neustart von
LightDM. persoenliche-konten.py startet LightDM nach angelegtem,
gesperrtem, entsperrtem oder entferntem Konto neu (nur ohne
Benutzersitzung); Hilfsskript wird immer abgelegt, damit der Schnellweg
auch beim ersten Konto greift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-24 11:15:14 +02:00
1 parent 82b68dc64f
commit f975938e3c
10 files changed
+144 -809

No files matched your search

@@ -32,6 +32,44 @@ GRUENER_HINTERGRUND = "/usr/share/backgrounds/linuxmint/sele_ring_green.jpg"
VEYON_MASTER_GRUPPE = "tuxflotte-veyon-master"
# Ändert sich, welche Konten der Anmeldebildschirm zeigt (angelegt, gesperrt,
# entsperrt, entfernt), startet das Skript LightDM am Ende neu, aber nur ohne
# laufende Benutzersitzung. slick-greeter übernimmt solche Änderungen nicht
# live (Liebherr-84, 18.10.2026). Seit dem vereinfachten Ablauf (ADR-0041,
# Ergänzung 24.10.2026) wartet der Benutzer nach dem Neustart am Gerät auf
# sein Konto; ohne den Neustart des Anmeldebildschirms stünde es erst nach
# dem nächsten Start des Geräts in der Liste. Hier und nicht in der Rolle:
# Der Agent ruft das Skript schon direkt nach dem Check-in auf.
_anmeldebildschirm_geaendert = False
def anmeldebildschirm_vormerken():
global _anmeldebildschirm_geaendert
_anmeldebildschirm_geaendert = True
def benutzersitzung_laeuft():
ergebnis = ausfuehren("loginctl", "list-sessions", "--no-legend")
for zeile in (ergebnis.stdout or "").splitlines():
teile = zeile.split()
if not teile:
continue
klasse = ausfuehren("loginctl", "show-session", teile[0], "--property=Class", "--value").stdout.strip()
if klasse == "user":
return True
return False
def anmeldebildschirm_auffrischen():
# try-restart: Läuft LightDM noch nicht (früh beim Start) oder gar nicht
# (GDM übernimmt neue Konten selbst), passiert nichts.
if benutzersitzung_laeuft():
print("Anmeldebildschirm: Benutzersitzung läuft, Änderung erscheint nach dem Abmelden")
return
if ausfuehren("systemctl", "is-active", "--quiet", "lightdm.service").returncode == 0:
ausfuehren("systemctl", "try-restart", "lightdm.service")
print("Anmeldebildschirm neu gestartet")
def ausfuehren(*befehl, eingabe=None):
return subprocess.run(befehl, input=eingabe, capture_output=True, text=True)
@@ -178,6 +216,7 @@ def anlegen(konto):
os.makedirs(MARKER_DIR, mode=0o700, exist_ok=True)
with open(marker_pfad(name), "w", encoding="utf-8") as f:
f.write(konto_id + "\n")
anmeldebildschirm_vormerken()
return "angelegt", None
@@ -188,6 +227,7 @@ def aktiv(konto):
ausfuehren("chage", "--expiredate", "-1", name)
if im_anmeldebildschirm(name, True):
ausfuehren("systemctl", "try-restart", "accounts-daemon.service")
anmeldebildschirm_vormerken()
return "aktiv", None
@@ -200,6 +240,7 @@ def sperren(konto):
sitzungen_beenden(name)
if im_anmeldebildschirm(name, False):
ausfuehren("systemctl", "try-restart", "accounts-daemon.service")
anmeldebildschirm_vormerken()
return "gesperrt", None
@@ -216,6 +257,7 @@ def entfernen(konto):
# 12: Home konnte nicht ganz entfernt werden, das Konto ist weg.
if ergebnis.returncode not in (0, 12):
return "fehler", f"userdel: {ergebnis.stderr.strip()[:300]}"
anmeldebildschirm_vormerken()
try:
os.remove(os.path.join(ACCOUNTS_DIR, name))
except FileNotFoundError:
@@ -332,6 +374,10 @@ def main():
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(ergebnisse, f)
# Erst nach dem Ergebnis: der Neustart darf die Meldung nicht aufhalten.
if _anmeldebildschirm_geaendert:
anmeldebildschirm_auffrischen()
if __name__ == "__main__":
main()
+12 -2
View File
@@ -7,12 +7,22 @@
# Ein Konto, das nicht in der Liste steht, fasst die Rolle nie an - auch
# nicht bei leerer Liste (ein älterer Server oder Agent schickt keine).
# Immer ablegen, auch ohne Konten: Der Agent ruft das Skript direkt nach dem
# Check-in auf (Schnellweg), aber nur, wenn es schon da ist. Sonst entstünde
# das allererste Konto eines Geräts erst mitten im Ansible-Lauf, und die
# zugesagten 2 Minuten nach dem Neustart reichten nicht (ADR-0041, Ergänzung
# 24.10.2026).
- name: Ensure helper script directory exists
ansible.builtin.file:
path: /usr/local/lib/tuxflotte
state: directory
mode: "0755"
- name: Install helper for personal accounts
ansible.builtin.copy:
src: persoenliche-konten.py
dest: /usr/local/lib/tuxflotte/persoenliche-konten.py
mode: "0755"
when: tuxflotte_persoenliche_konten | length > 0
- name: Write desired personal accounts (root only)
ansible.builtin.copy:
@@ -31,7 +41,7 @@
- /run/tuxflotte/agent/persoenliche-konten-soll.json
- /run/tuxflotte/agent/persoenliche-konten.json
register: tuxflotte_konten_lauf
changed_when: tuxflotte_konten_lauf.stdout | regex_search('-> (angelegt|gesperrt|entfernt)|Passwort zurückgesetzt|Hintergrund gesetzt') is not none
changed_when: tuxflotte_konten_lauf.stdout | regex_search('-> (angelegt|gesperrt|entfernt)|Passwort zurückgesetzt|Hintergrund gesetzt|Anmeldebildschirm neu gestartet') is not none
when: tuxflotte_persoenliche_konten | length > 0
- name: Remove desired personal accounts file
-13
View File
@@ -1,14 +1 @@
tuxflotte_state: present
# Formular der Personalisierung in der Kundenplattform (ADR-0041); kiosk=1
# blendet dort "Abmelden" ein. Der Kiosk lässt nur Navigation innerhalb
# dieser Herkunft zu.
tuxflotte_personalisierung_url: "https://flotten-management.tuxflotte.de/personalisierung?kiosk=1"
# "Passwort vergessen?" (Eintrag nach der Personalisierung); Gerät und
# Kiosk-Token hängt die Rolle aus den Check-in-Optionen an.
tuxflotte_personalisierung_vergessen_url: "https://flotten-management.tuxflotte.de/personalisierung/vergessen?kiosk=1"
# Ohne Eingabe endet die Kiosk-Sitzung nach dieser Zeit.
tuxflotte_personalisierung_leerlauf_minuten: 15
# Rückfall für "Passwort vergessen?" nach Fehleingabe: Anmeldebildschirm
# neu starten, weil slick-greeter neu eingeblendete Einträge nicht live
# übernimmt (auf Liebherr-84 beobachtet, 18.10.2026).
tuxflotte_personalisierung_greeter_neustart: true
@@ -1,116 +0,0 @@
#!/bin/sh
# Verwaltet von Tuxflotte (Rolle personalisierung)
#
# Gibt die Anmeldung am Konto "Personalisierung" frei oder nimmt sie weg
# (ADR-0041, Ergänzungen 18.10.2026). Frei nur, wenn beides gilt:
# - der Server bietet sie an (Merkerdatei, von der Rolle gesetzt; Inhalt
# ist der Modus: "personalisieren" ohne persönliches Konto oder bei
# laufendem Code, "passwort" mit Konto - dann heißt der Eintrag
# "Passwort vergessen?" und der Kiosk öffnet die Seite dafür; in diesem
# Modus nur, solange nach einer Fehleingabe die Frist in AUFGEDECKT
# läuft, gesetzt von personalisierung-wacht), und
# - das laufende LightDM nutzt schon den Kiosk-Wrapper. LightDM liest
# seine Konfiguration nur beim Start; vorher bekäme das passwortlose
# Konto den vollen Desktop.
# Sonst: keine passwortlose PAM-Freigabe, Shell nologin und in
# AccountsService SystemAccount=true. Nur die Shell reichte nicht: auf
# Liebherr-84 (Mint 22.3) stand das Konto danach weiter im Greeter, weil
# AccountsService es als bekanntes Konto im Cache hält. SystemAccount ist
# derselbe Weg, mit dem persoenliche-konten gesperrte Konten ausblendet.
#
# Aufruf: "start" aus ExecStartPre von lightdm.service (LightDM startet
# gleich mit der aktuellen Konfiguration), ohne Argument von der Rolle.
# Ausgabe: "frei" oder "gesperrt", bei einer Änderung mit " geaendert".
set -eu
KONF=/etc/lightdm/lightdm.conf.d/85-tuxflotte-personalisierung.conf
MERKER=/etc/tuxflotte/personalisierung-anbieten
PAM=/etc/pam.d/lightdm
ACCOUNTS=/var/lib/AccountsService/users/personalisierung
AUFGEDECKT=/run/tuxflotte/personalisierung-aufgedeckt-bis
ZEILE='auth sufficient pam_succeed_if.so user = personalisierung'
MUSTER='^auth[[:space:]]+sufficient[[:space:]]+pam_succeed_if\.so[[:space:]]+user[[:space:]]+=[[:space:]]+personalisierung[[:space:]]*$'
wrapper_aktiv() {
[ -f "$KONF" ] || return 1
[ "${1:-}" = start ] && return 0
pid=$(systemctl show -p MainPID --value lightdm.service 2>/dev/null || true)
[ "${pid:-0}" -gt 0 ] 2>/dev/null || return 1
laufzeit=$(ps -o etimes= -p "$pid" | tr -d ' ')
[ -n "$laufzeit" ] || return 1
[ $(( $(date +%s) - laufzeit )) -gt "$(stat -c %Y "$KONF")" ]
}
modus=$(head -n 1 "$MERKER" 2>/dev/null || true)
soll=gesperrt
if [ -f "$MERKER" ] && wrapper_aktiv "${1:-}"; then
if [ "$modus" = passwort ]; then
bis=$(head -n 1 "$AUFGEDECKT" 2>/dev/null || echo 0)
if [ "${bis:-0}" -gt "$(date +%s)" ] 2>/dev/null; then
soll=frei
fi
else
soll=frei
fi
fi
if [ "$modus" = passwort ]; then
anzeigename='Passwort vergessen?'
else
anzeigename='Personalisierung'
fi
geaendert=""
if [ "$soll" = frei ]; then
if [ -f "$PAM" ] && ! grep -Eq "$MUSTER" "$PAM"; then
sed -i "1i $ZEILE" "$PAM"
geaendert=" geaendert"
fi
shell=/bin/bash
else
if [ -f "$PAM" ] && grep -Eq "$MUSTER" "$PAM"; then
sed -i -E "/$MUSTER/d" "$PAM"
geaendert=" geaendert"
fi
shell=/usr/sbin/nologin
fi
if getent passwd personalisierung >/dev/null; then
if [ "$(getent passwd personalisierung | cut -d: -f7)" != "$shell" ]; then
usermod -s "$shell" personalisierung
geaendert=" geaendert"
fi
# Anzeigename im Greeter (GECOS); AccountsService liest ihn neu ein.
if [ "$(getent passwd personalisierung | cut -d: -f5)" != "$anzeigename" ]; then
usermod -c "$anzeigename" personalisierung
geaendert=" geaendert"
accounts_geaendert=1
fi
fi
# AccountsService: SystemAccount=true blendet aus, ohne den Schlüssel zeigt
# der Greeter das Konto. Andere Einträge der Datei bleiben stehen.
if getent passwd personalisierung >/dev/null; then
if [ "$soll" = frei ]; then
if [ -f "$ACCOUNTS" ] && grep -q '^SystemAccount=' "$ACCOUNTS"; then
sed -i '/^SystemAccount=/d' "$ACCOUNTS"
accounts_geaendert=1
fi
elif ! { [ -f "$ACCOUNTS" ] && grep -qx 'SystemAccount=true' "$ACCOUNTS"; }; then
mkdir -p "$(dirname "$ACCOUNTS")"
if [ -f "$ACCOUNTS" ] && grep -q '^\[User\]' "$ACCOUNTS"; then
sed -i '/^SystemAccount=/d' "$ACCOUNTS"
sed -i '/^\[User\]/a SystemAccount=true' "$ACCOUNTS"
else
printf '[User]\nSystemAccount=true\n' >> "$ACCOUNTS"
fi
accounts_geaendert=1
fi
if [ -n "${accounts_geaendert:-}" ]; then
geaendert=" geaendert"
# Beim Start von LightDM (ExecStartPre) läuft accounts-daemon meist
# noch nicht und liest die Datei selbst; try-restart tut dann nichts.
systemctl try-restart accounts-daemon.service 2>/dev/null || true
fi
fi
echo "$soll$geaendert"
@@ -1,153 +0,0 @@
#!/usr/bin/python3
# Verwaltet von Tuxflotte (Rolle personalisierung)
#
# "Passwort vergessen?" erst nach einer Fehleingabe (ADR-0041, Ergänzung
# 18.10.2026): Der Eintrag ist im Modus "passwort" ausgeblendet. Dieser
# Dienst liest das Journal von LightDM mit. Schlägt die Anmeldung eines
# persönlichen Kontos fehl, blendet er den Eintrag für SICHTBAR_SEKUNDEN ein;
# nach einer erfolgreichen Anmeldung eines persönlichen Kontos oder nach
# Ablauf der Zeit wieder aus. Geschaltet wird über personalisierung-anmeldung
# (liest AUFGEDECKT_PATH); an der PAM-Konfiguration ändert sich nichts.
#
# Rückfall (--greeter-neustart): Übernimmt slick-greeter einen neu
# eingeblendeten Eintrag nicht in die laufende Liste, startet der Dienst
# LightDM neu - nur, wenn keine Benutzersitzung läuft.
import argparse
import os
import re
import select
import subprocess
import sys
import time
AUFGEDECKT_PATH = "/run/tuxflotte/personalisierung-aufgedeckt-bis"
SCHALTER = "/usr/local/lib/tuxflotte/personalisierung-anmeldung"
MERKER = "/etc/tuxflotte/personalisierung-anbieten"
KONTEN_DIR = "/var/lib/tuxflotte/persoenliche-konten"
SICHTBAR_SEKUNDEN = 15 * 60
PRUEF_ABSTAND_SEKUNDEN = 30
FEHLER_MUSTER = re.compile(r"pam_unix\(lightdm:auth\): authentication failure;.*\buser=([a-z][a-z0-9._-]*)")
ERFOLG_MUSTER = re.compile(r"pam_unix\(lightdm:session\): session opened for user ([a-z][a-z0-9._-]*)")
def log(meldung):
print(meldung, flush=True)
def persoenliche_konten():
"""Anmeldenamen aus den Markern der Rolle persoenliche-konten."""
try:
return {n for n in os.listdir(KONTEN_DIR) if "." not in n}
except OSError:
return set()
def modus():
try:
with open(MERKER, encoding="utf-8") as f:
return f.readline().strip()
except OSError:
return ""
def aufgedeckt_bis():
try:
with open(AUFGEDECKT_PATH, encoding="utf-8") as f:
return int(f.read().strip() or 0)
except (OSError, ValueError):
return 0
def entscheiden(zeile, konten, jetzt, bis):
"""
Reine Entscheidung je Journalzeile: ("aufdecken", name), ("verbergen",
name) oder None. Getrennt von den Seiteneffekten, damit sie sich ohne
Gerät prüfen lässt.
"""
treffer = FEHLER_MUSTER.search(zeile)
if treffer and treffer.group(1) in konten:
return ("aufdecken", treffer.group(1))
treffer = ERFOLG_MUSTER.search(zeile)
if treffer and treffer.group(1) in konten and bis > jetzt:
return ("verbergen", treffer.group(1))
return None
def schalten():
ergebnis = subprocess.run([SCHALTER], capture_output=True, text=True)
return (ergebnis.stdout or "").strip()
def benutzersitzung_laeuft():
ergebnis = subprocess.run(["loginctl", "list-sessions", "--no-legend"], capture_output=True, text=True)
for zeile in (ergebnis.stdout or "").splitlines():
sitzung = zeile.split()[0] if zeile.split() else ""
if not sitzung:
continue
klasse = subprocess.run(
["loginctl", "show-session", sitzung, "--property=Class", "--value"],
capture_output=True, text=True,
).stdout.strip()
if klasse == "user":
return True
return False
def aufdecken(name, greeter_neustart):
os.makedirs(os.path.dirname(AUFGEDECKT_PATH), exist_ok=True)
with open(AUFGEDECKT_PATH, "w", encoding="utf-8") as f:
f.write(f"{int(time.time()) + SICHTBAR_SEKUNDEN}\n")
log(f"Fehleingabe für {name}: Passwort vergessen? eingeblendet ({schalten()})")
if greeter_neustart and not benutzersitzung_laeuft():
# Kurz warten, damit die Fehlermeldung am Bildschirm zu sehen war.
time.sleep(2)
subprocess.run(["systemctl", "restart", "lightdm.service"])
log("Anmeldebildschirm neu gestartet")
def verbergen(grund):
try:
os.remove(AUFGEDECKT_PATH)
except FileNotFoundError:
return
log(f"{grund}: Passwort vergessen? ausgeblendet ({schalten()})")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--greeter-neustart", action="store_true")
args = parser.parse_args()
# Ohne Filter auf die Kennung: unter welcher SYSLOG_IDENTIFIER die
# PAM-Meldungen von LightDM landen, ist nicht auf jeder Distribution
# gleich; die Muster prüfen "lightdm:auth"/"lightdm:session" selbst.
journal = subprocess.Popen(
["journalctl", "--follow", "--lines=0", "--output=cat"],
stdout=subprocess.PIPE, text=True, bufsize=1,
)
while True:
bereit, _, _ = select.select([journal.stdout], [], [], PRUEF_ABSTAND_SEKUNDEN)
jetzt = int(time.time())
if bereit:
zeile = journal.stdout.readline()
if not zeile:
log("journalctl beendet")
sys.exit(1)
if modus() != "passwort":
continue
aktion = entscheiden(zeile, persoenliche_konten(), jetzt, aufgedeckt_bis())
if aktion and aktion[0] == "aufdecken":
aufdecken(aktion[1], args.greeter_neustart)
elif aktion and aktion[0] == "verbergen":
verbergen(f"Anmeldung von {aktion[1]}")
bis = aufgedeckt_bis()
if bis and bis <= jetzt:
verbergen("Zeit abgelaufen")
if __name__ == "__main__":
main()
@@ -1,184 +0,0 @@
#!/usr/bin/python3
# Verwaltet von Tuxflotte (Rolle personalisierung)
#
# Kiosk für das Konto "Personalisierung" (ADR-0041, Ergänzung 14.10.2026):
# zeigt das Personalisierungsformular im Vollbild, ohne Fenstermanager. Es
# gibt keine Adresszeile, keine neuen Fenster, keine Dateidialoge, keine
# Downloads und kein Kontextmenü. Navigiert wird nur innerhalb der Herkunft der
# Startadresse (Schema, Host, Port). Daten bleiben nur im Speicher.
#
# Beenden: die Seite verlinkt "tuxflotte:abmelden", oder nach der
# Leerlaufzeit ohne Eingabe. Mit dem Programm endet die Sitzung, LightDM
# kehrt zum Anmeldebildschirm zurück.
import argparse
import html
import time
from urllib.parse import urlsplit
import gi
gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0")
gi.require_version("WebKit2", "4.1")
from gi.repository import Gdk, GLib, Gtk, WebKit2 # noqa: E402
ABMELDEN = "tuxflotte:abmelden"
def herkunft(uri):
teile = urlsplit(uri)
port = teile.port or {"https": 443, "http": 80}.get(teile.scheme)
return (teile.scheme, teile.hostname, port)
class Kiosk:
def __init__(self, url, leerlauf_minuten):
self.start_url = url
self.erlaubt = herkunft(url)
self.leerlauf = leerlauf_minuten * 60
self.letzte_eingabe = time.monotonic()
kontext = WebKit2.WebContext.new_ephemeral()
kontext.set_preferred_languages(["de-DE", "de"])
kontext.connect("download-started", self.on_download)
self.view = WebKit2.WebView.new_with_context(kontext)
settings = self.view.get_settings()
settings.set_enable_developer_extras(False)
settings.set_javascript_can_open_windows_automatically(False)
settings.set_allow_file_access_from_file_urls(False)
settings.set_allow_universal_access_from_file_urls(False)
settings.set_enable_write_console_messages_to_stdout(False)
self.view.connect("decide-policy", self.on_policy)
self.view.connect("create", lambda *_: None)
self.view.connect("context-menu", lambda *_: True)
self.view.connect("run-file-chooser", lambda *_: True)
self.view.connect("print", lambda *_: True)
self.view.connect("permission-request", self.on_permission)
self.view.connect("authenticate", self.on_authenticate)
self.view.connect("load-failed", self.on_load_failed)
self.view.connect("load-failed-with-tls-errors", self.on_tls_error)
self.fenster = Gtk.Window()
self.fenster.set_decorated(False)
self.fenster.add(self.view)
self.fenster.connect("destroy", lambda *_: Gtk.main_quit())
for signal in ("key-press-event", "button-press-event", "motion-notify-event", "scroll-event"):
self.fenster.connect(signal, self.on_eingabe)
self.view.connect(signal, self.on_eingabe)
# Ohne Fenstermanager wirkt fullscreen() nicht zuverlässig: Größe
# und Lage selbst auf den Hauptbildschirm setzen.
anzeige = Gdk.Display.get_default()
monitor = anzeige.get_primary_monitor() or anzeige.get_monitor(0)
geo = monitor.get_geometry()
self.fenster.move(geo.x, geo.y)
self.fenster.set_default_size(geo.width, geo.height)
self.fenster.fullscreen()
GLib.timeout_add_seconds(30, self.pruefe_leerlauf)
self.view.load_uri(self.start_url)
self.fenster.show_all()
self.fenster.present()
self.view.grab_focus()
def beenden(self):
Gtk.main_quit()
def on_eingabe(self, *_):
self.letzte_eingabe = time.monotonic()
return False
def pruefe_leerlauf(self):
if time.monotonic() - self.letzte_eingabe > self.leerlauf:
self.beenden()
return False
return True
def on_policy(self, _view, entscheidung, art):
if art == WebKit2.PolicyDecisionType.NEW_WINDOW_ACTION:
entscheidung.ignore()
return True
if art == WebKit2.PolicyDecisionType.NAVIGATION_ACTION:
uri = entscheidung.get_navigation_action().get_request().get_uri()
if uri == ABMELDEN:
entscheidung.ignore()
GLib.idle_add(self.beenden)
return True
if herkunft(uri) != self.erlaubt:
entscheidung.ignore()
return True
return False
if art == WebKit2.PolicyDecisionType.RESPONSE:
if not entscheidung.is_mime_type_supported():
entscheidung.ignore()
return True
return False
def on_download(self, _kontext, download):
download.cancel()
def on_permission(self, _view, anfrage):
anfrage.deny()
return True
def on_authenticate(self, _view, anfrage):
anfrage.cancel()
return True
def fehlerseite(self, text):
start = html.escape(self.start_url, quote=True)
self.view.load_alternate_html(
f"""<!doctype html><html lang="de"><head><meta charset="utf-8">
<style>
body {{ font-family: sans-serif; background: #f4f4f4; color: #222;
display: flex; align-items: center; justify-content: center;
height: 100vh; margin: 0; }}
main {{ max-width: 34em; text-align: center; }}
a {{ display: inline-block; margin: 1em .5em; padding: .7em 1.4em;
background: #2a6db0; color: #fff; text-decoration: none; border-radius: 4px; }}
</style></head><body><main>
<h1>Keine Verbindung</h1>
<p>{html.escape(text)}</p>
<a href="{start}">Erneut versuchen</a>
<a href="{ABMELDEN}">Abmelden</a>
</main></body></html>""",
self.start_url,
None,
)
def on_load_failed(self, _view, _ereignis, _uri, fehler):
# Von uns selbst abgelehnte oder abgebrochene Ladevorgänge sind keine
# Störung.
if fehler.domain == "WebKitPolicyError":
return True
if fehler.domain == "WebKitNetworkError" and fehler.code == WebKit2.NetworkError.CANCELLED:
return True
self.fehlerseite(
"Die Personalisierung ist gerade nicht erreichbar. Bitte prüfe die "
"Netzwerkverbindung und versuche es noch einmal."
)
return True
def on_tls_error(self, *_):
self.fehlerseite(
"Die Verbindung zu Tuxflotte ist nicht vertrauenswürdig. Bitte wende "
"dich an die Verwaltung deiner Einrichtung."
)
return True
def main():
parser = argparse.ArgumentParser(description="Tuxflotte-Kiosk")
parser.add_argument("--url", required=True)
parser.add_argument("--leerlauf-minuten", type=int, default=15)
args = parser.parse_args()
Kiosk(args.url, args.leerlauf_minuten)
Gtk.main()
if __name__ == "__main__":
main()
+84 -315
View File
@@ -1,319 +1,38 @@
# Merkmal "Personalisierung möglich" (ADR-0041, Ergänzung 14.10.2026):
# richtet das passwortlose Konto "Personalisierung" am Anmeldebildschirm ein,
# das nur einen Kiosk mit dem Personalisierungsformular startet. Die
# Rolle personalisiert niemanden; das persönliche Konto legt später eine
# gerätespezifische Rolle an, nachdem ein Aktivierungscode eingelöst ist.
# Abwählen entfernt nur das Konto "Personalisierung" und den Kiosk, nicht
# bereits angelegte persönliche Konten.
#
# Nur LightDM (Mint, Debian/Xfce). GDM (Ubuntu) kennt keinen
# session-wrapper, dort folgt ein eigener Weg; bis dahin wird übersprungen.
- name: Ensure marker directory exists
ansible.builtin.file:
path: /run/tuxflotte/agent/applied
state: directory
mode: "0755"
# Merkmal "Personalisierung möglich" (ADR-0041). Seit dem vereinfachten
# Ablauf (Ergänzung 24.10.2026) richtet das Merkmal am Gerät nichts mehr ein:
# Es legt nur fest, ob der Kunden-Admin beim Gerät eine Adresse eintragen
# darf, alles Weitere läuft über Mail-Link und Webseite. Die Rolle baut
# deshalb bei present wie bei absent ab, was frühere Stände eingerichtet
# haben: das passwortlose Konto "personalisierung" mit Kiosk am
# Anmeldebildschirm, die PAM-Freigabe, den Wächter für "Passwort vergessen?"
# und die LightDM-Konfiguration. Persönliche Konten bleiben unberührt, sie
# gehören zur Rolle persoenliche-konten.
- name: Check for LightDM PAM configuration
ansible.builtin.stat:
path: /etc/pam.d/lightdm
register: tuxflotte_pers_lightdm
# Den Wrapper der Distribution ermitteln, in LightDMs Lesereihenfolge (die
# letzte Angabe gilt), ohne unsere eigene Datei. Debian setzt
# /etc/X11/Xsession, Mint/Ubuntu verlassen sich auf den eingebauten Standard
# lightdm-session.
- name: Determine the distribution's LightDM session wrapper
ansible.builtin.shell: |
set -eu
wrapper=$(
for f in /usr/share/lightdm/lightdm.conf.d/*.conf \
/usr/local/share/lightdm/lightdm.conf.d/*.conf \
/etc/xdg/lightdm/lightdm.conf.d/*.conf \
/etc/lightdm/lightdm.conf.d/*.conf \
/etc/lightdm/lightdm.conf; do
[ -f "$f" ] || continue
[ "$f" = /etc/lightdm/lightdm.conf.d/85-tuxflotte-personalisierung.conf ] && continue
sed -n 's/^[[:space:]]*session-wrapper[[:space:]]*=[[:space:]]*//p' "$f"
done | tail -n 1
)
wrapper=${wrapper:-lightdm-session}
case "$wrapper" in
/*) ;;
*) wrapper=$(PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin command -v "$wrapper" || true) ;;
esac
if [ -n "$wrapper" ] && [ -x "$wrapper" ] && [ "$wrapper" != /usr/local/lib/tuxflotte/lightdm-sitzung ]; then
echo "$wrapper"
fi
register: tuxflotte_pers_wrapper
changed_when: false
when: tuxflotte_state == "present" and tuxflotte_pers_lightdm.stat.exists
- name: Decide whether the kiosk login can be set up here
ansible.builtin.set_fact:
tuxflotte_pers_aktiv: >-
{{ tuxflotte_state == "present" and tuxflotte_pers_lightdm.stat.exists
and (tuxflotte_pers_wrapper.stdout | default('')) != '' }}
tuxflotte_personalisierung_wrapper_original: "{{ tuxflotte_pers_wrapper.stdout | default('') }}"
- name: Report skipped setup (no LightDM or no usable session wrapper)
ansible.builtin.debug:
msg: "Personalisierung: kein LightDM oder kein Sitzungs-Wrapper gefunden, Einrichtung übersprungen"
when: tuxflotte_state == "present" and not (tuxflotte_pers_aktiv | bool)
# --- present -----------------------------------------------------------------
- name: Install kiosk dependencies
ansible.builtin.apt:
name:
- python3-gi
- gir1.2-gtk-3.0
- gir1.2-webkit2-4.1
- x11-xserver-utils
state: present
when: tuxflotte_pers_aktiv | bool
# Kein Passwort (gesperrt), Anmeldung nur über die PAM-Freigabe von
# personalisierung-anmeldung und nur am Anmeldebildschirm. Shell und
# Kommentar (= Anzeigename im Greeter, "Personalisierung" oder "Passwort
# vergessen?") setzt ebenfalls das Skript; der Kommentar kennzeichnet das
# Konto zugleich als unseres (siehe Abwählen).
- name: Ensure personalisierung account exists
ansible.builtin.user:
name: personalisierung
create_home: true
password_lock: true
when: tuxflotte_pers_aktiv | bool
- name: Ensure helper script directory exists
ansible.builtin.file:
path: /usr/local/lib/tuxflotte
state: directory
mode: "0755"
when: tuxflotte_pers_aktiv | bool
- name: Install kiosk program
ansible.builtin.copy:
src: tuxflotte-kiosk.py
dest: /usr/local/lib/tuxflotte/tuxflotte-kiosk.py
mode: "0755"
when: tuxflotte_pers_aktiv | bool
- name: Install kiosk session
ansible.builtin.template:
src: personalisierung-sitzung.j2
dest: /usr/local/lib/tuxflotte/personalisierung-sitzung
mode: "0755"
when: tuxflotte_pers_aktiv | bool
- name: Install LightDM session wrapper
ansible.builtin.template:
src: lightdm-sitzung.j2
dest: /usr/local/lib/tuxflotte/lightdm-sitzung
mode: "0755"
when: tuxflotte_pers_aktiv | bool
- name: Ensure LightDM drop-in directory exists
ansible.builtin.file:
path: /etc/lightdm/lightdm.conf.d
state: directory
mode: "0755"
when: tuxflotte_pers_aktiv | bool
# Wirkt ab dem nächsten Start von LightDM (Neustart des Geräts); LightDM wird
# nicht im laufenden Betrieb neu gestartet, das beendete eine Sitzung.
- name: Use the Tuxflotte session wrapper in LightDM
ansible.builtin.copy:
dest: /etc/lightdm/lightdm.conf.d/85-tuxflotte-personalisierung.conf
content: |
# Verwaltet von Tuxflotte (Rolle personalisierung)
[Seat:*]
session-wrapper=/usr/local/lib/tuxflotte/lightdm-sitzung
mode: "0644"
when: tuxflotte_pers_aktiv | bool
# Passwortlose Anmeldung: eigene PAM-Freigabe nur für dieses Konto, nicht über
# die Gruppe nopasswdlogin (guest-session entfernt deren Regel beim Abwählen
# vollständig). Gesetzt und entfernt von personalisierung-anmeldung, nicht
# hier: frei nur, solange die Verwaltung das Konto anbietet (Check-in-Option
# anmeldung_anbieten, ADR-0041 Ergänzung 18.10.2026) und LightDM schon mit
# dem Kiosk-Wrapper läuft. Beim ersten Einrichten also erst nach dem
# nächsten Start von LightDM, vorher bekäme das Konto den vollen Desktop.
- name: Ensure Tuxflotte config directory exists
ansible.builtin.file:
path: /etc/tuxflotte
state: directory
mode: "0755"
when: tuxflotte_pers_aktiv | bool
# Modus vom Server (ADR-0041, Ergänzungen 18.10.2026): personalisieren,
# passwort ("Passwort vergessen?") oder aus. Ältere Server schicken nur
# anmeldung_anbieten.
- name: Determine the mode of the personalisierung entry
ansible.builtin.set_fact:
tuxflotte_pers_modus: >-
{{ tuxflotte_optionen.modus
| default('personalisieren' if (tuxflotte_optionen.anmeldung_anbieten | default(true) | bool) else 'aus') }}
when: tuxflotte_pers_aktiv | bool
- name: Record the mode (first line, read by personalisierung-anmeldung)
ansible.builtin.copy:
dest: /etc/tuxflotte/personalisierung-anbieten
content: "{{ tuxflotte_pers_modus }}\n# Verwaltet von Tuxflotte (Rolle personalisierung)\n"
mode: "0644"
when: (tuxflotte_pers_aktiv | bool) and tuxflotte_pers_modus in ['personalisieren', 'passwort']
- name: Record that the account is not offered
ansible.builtin.file:
path: /etc/tuxflotte/personalisierung-anbieten
state: absent
when: (tuxflotte_pers_aktiv | bool) and tuxflotte_pers_modus not in ['personalisieren', 'passwort']
# Lesbar für das Kiosk-Konto. Das Token erlaubt nur, dem Besitzer einen Link
# an seine hinterlegte Adresse zu schicken (vom Server begrenzt).
- name: Record the "Passwort vergessen?" address
ansible.builtin.copy:
dest: /etc/tuxflotte/personalisierung-vergessen-url
content: "{{ tuxflotte_personalisierung_vergessen_url }}&geraet={{ tuxflotte_optionen.geraet | urlencode }}&t={{ tuxflotte_optionen.kiosk_token | urlencode }}\n"
mode: "0644"
when: (tuxflotte_pers_aktiv | bool) and (tuxflotte_optionen.kiosk_token | default('')) != ''
- name: Remove the "Passwort vergessen?" address (no token)
ansible.builtin.file:
path: /etc/tuxflotte/personalisierung-vergessen-url
state: absent
when: (tuxflotte_pers_aktiv | bool) and (tuxflotte_optionen.kiosk_token | default('')) == ''
- name: Install login switch
ansible.builtin.copy:
src: personalisierung-anmeldung
dest: /usr/local/lib/tuxflotte/personalisierung-anmeldung
mode: "0755"
when: tuxflotte_pers_aktiv | bool
# "Passwort vergessen?" erst nach einer Fehleingabe (ADR-0041, Ergänzung
# 18.10.2026): liest das LightDM-Journal mit und blendet den Eintrag für
# 15 Minuten ein. Mit tuxflotte_personalisierung_greeter_neustart startet er
# danach den Anmeldebildschirm neu (Rückfall, falls slick-greeter neue
# Einträge nicht live übernimmt).
- name: Install watcher for failed logins
ansible.builtin.copy:
src: personalisierung-wacht.py
dest: /usr/local/lib/tuxflotte/personalisierung-wacht.py
mode: "0755"
register: tuxflotte_pers_wacht_skript
when: tuxflotte_pers_aktiv | bool
- name: Install watcher service
ansible.builtin.copy:
dest: /etc/systemd/system/tuxflotte-personalisierung-wacht.service
content: |
# Verwaltet von Tuxflotte (Rolle personalisierung)
[Unit]
Description=Tuxflotte: "Passwort vergessen?" nach Fehleingabe einblenden
After=systemd-journald.service
[Service]
ExecStart=/usr/bin/python3 /usr/local/lib/tuxflotte/personalisierung-wacht.py{{ ' --greeter-neustart' if tuxflotte_personalisierung_greeter_neustart | bool else '' }}
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
mode: "0644"
register: tuxflotte_pers_wacht_unit
when: tuxflotte_pers_aktiv | bool
- name: Run watcher service
ansible.builtin.systemd:
name: tuxflotte-personalisierung-wacht.service
enabled: true
state: "{{ 'restarted' if (tuxflotte_pers_wacht_skript is changed or tuxflotte_pers_wacht_unit is changed) else 'started' }}"
daemon_reload: "{{ tuxflotte_pers_wacht_unit is changed }}"
when: tuxflotte_pers_aktiv | bool
- name: Ensure lightdm.service drop-in directory exists
ansible.builtin.file:
path: /etc/systemd/system/lightdm.service.d
state: directory
mode: "0755"
when: tuxflotte_pers_aktiv | bool
# Beim Start von LightDM (Neustart des Geräts) gilt die neue Konfiguration
# sofort; "-": ein fehlendes Skript hält LightDM nicht auf.
- name: Apply the login switch when LightDM starts
ansible.builtin.copy:
dest: /etc/systemd/system/lightdm.service.d/85-tuxflotte-personalisierung.conf
content: |
# Verwaltet von Tuxflotte (Rolle personalisierung)
[Service]
ExecStartPre=-/usr/local/lib/tuxflotte/personalisierung-anmeldung start
mode: "0644"
register: tuxflotte_pers_dropin
when: tuxflotte_pers_aktiv | bool
- name: Reload systemd for the lightdm.service drop-in
ansible.builtin.systemd:
daemon_reload: true
when: (tuxflotte_pers_aktiv | bool) and tuxflotte_pers_dropin is changed
- name: Apply the login switch now
ansible.builtin.command: /usr/local/lib/tuxflotte/personalisierung-anmeldung
register: tuxflotte_pers_anmeldung
changed_when: "'geaendert' in tuxflotte_pers_anmeldung.stdout"
when: tuxflotte_pers_aktiv | bool
# Kein Prozess des Kiosk-Kontos überlebt das Abmelden. Eigene Datei, nicht
# die der Gastsitzung (die verschwindet beim Abwählen von guest-session);
# logind führt die KillOnlyUsers-Listen beider Dateien zusammen.
- name: Ensure logind drop-in directory exists
ansible.builtin.file:
path: /etc/systemd/logind.conf.d
state: directory
mode: "0755"
when: tuxflotte_pers_aktiv | bool
- name: Kill leftover personalisierung processes on logout (logind)
ansible.builtin.copy:
dest: /etc/systemd/logind.conf.d/50-tuxflotte-personalisierung.conf
content: |
# Verwaltet von Tuxflotte (Rolle personalisierung)
[Login]
KillUserProcesses=yes
KillOnlyUsers=personalisierung
mode: "0644"
when: tuxflotte_pers_aktiv | bool
- name: Record that the personalisierung role ran
ansible.builtin.copy:
dest: /run/tuxflotte/agent/applied/personalisierung.marker
content: "{{ ansible_date_time.iso8601 }}\n"
mode: "0644"
when: tuxflotte_pers_aktiv | bool
# --- absent (Auftrag abgewählt) ------------------------------------------------
- name: Remove passwordless login for personalisierung (Auftrag abgewählt)
- name: Remove passwordless login for personalisierung
ansible.builtin.lineinfile:
path: /etc/pam.d/lightdm
regexp: '^auth\s+sufficient\s+pam_succeed_if\.so\s+user\s+=\s+personalisierung\s*$'
state: absent
when: tuxflotte_state == "absent" and tuxflotte_pers_lightdm.stat.exists
when: tuxflotte_pers_lightdm.stat.exists
# lightdm-sitzung bleibt bewusst liegen: ein laufendes LightDM liest seine
# Konfiguration erst beim nächsten Start neu und ruft bis dahin weiter
# diesen Wrapper auf. Ohne die Datei wäre bis zum Neustart keine Anmeldung
# möglich. Ohne Konto "personalisierung" reicht er nur noch durch.
- name: Stop watcher service (Auftrag abgewählt)
- name: Stop watcher service
ansible.builtin.systemd:
name: tuxflotte-personalisierung-wacht.service
enabled: false
state: stopped
failed_when: false
when: tuxflotte_state == "absent"
- name: Remove kiosk login files (Auftrag abgewählt)
# lightdm-sitzung fehlt hier bewusst: Ein laufendes LightDM liest seine
# Konfiguration erst beim nächsten Start neu und ruft bis dahin weiter diesen
# Wrapper auf. Ohne die Datei wäre bis zum Neustart keine Anmeldung möglich.
# Ohne Konto "personalisierung" reicht er nur noch durch; entfernt wird er
# unten, sobald LightDM neu gestartet ist.
- name: Remove kiosk login files
ansible.builtin.file:
path: "{{ item }}"
state: absent
@@ -329,48 +48,98 @@
- /etc/systemd/system/tuxflotte-personalisierung-wacht.service
- /usr/local/lib/tuxflotte/personalisierung-wacht.py
- /run/tuxflotte/personalisierung-aufgedeckt-bis
- /run/tuxflotte/agent/applied/personalisierung.marker
register: tuxflotte_pers_entfernt
when: tuxflotte_state == "absent"
- name: Reload systemd after removing the drop-in (Auftrag abgewählt)
- name: Reload systemd after removing units and drop-ins
ansible.builtin.systemd:
daemon_reload: true
when: tuxflotte_state == "absent" and tuxflotte_pers_entfernt is changed
when: tuxflotte_pers_entfernt is changed
- name: Read the personalisierung account (Auftrag abgewählt)
- name: Read the personalisierung account
ansible.builtin.getent:
database: passwd
key: personalisierung
register: tuxflotte_pers_konto
failed_when: false
when: tuxflotte_state == "absent"
# Nur das Konto entfernen, das diese Rolle angelegt hat (Kommentarfeld,
# je nach Modus einer der beiden Anzeigenamen).
- name: Decide whether the account is ours (Auftrag abgewählt)
# Nur das Konto entfernen, das diese Rolle angelegt hat (Kommentarfeld, je
# nach früherem Modus einer der beiden Anzeigenamen).
- name: Decide whether the account is ours
ansible.builtin.set_fact:
tuxflotte_pers_konto_unser: >-
{{ (tuxflotte_pers_konto.ansible_facts.getent_passwd.personalisierung | default([]) | length > 3)
and tuxflotte_pers_konto.ansible_facts.getent_passwd.personalisierung[3] in ["Personalisierung", "Passwort vergessen?"] }}
when: tuxflotte_state == "absent"
- name: End a running kiosk session (Auftrag abgewählt)
- name: End a running kiosk session
ansible.builtin.command: loginctl terminate-user personalisierung
register: tuxflotte_pers_terminate
changed_when: tuxflotte_pers_terminate.rc == 0
failed_when: false
when: tuxflotte_state == "absent" and (tuxflotte_pers_konto_unser | bool)
when: tuxflotte_pers_konto_unser | bool
- name: Remove personalisierung account and home (Auftrag abgewählt)
- name: Remove personalisierung account and home
ansible.builtin.user:
name: personalisierung
state: absent
remove: true
force: true
when: tuxflotte_state == "absent" and (tuxflotte_pers_konto_unser | bool)
register: tuxflotte_pers_konto_entfernt
when: tuxflotte_pers_konto_unser | bool
- name: Remove personalisierung marker (Auftrag abgewählt)
# AccountsService hielt das Konto auf Liebherr-84 im Cache (ADR-0041,
# Ergänzung 18.10.2026); mit dem Konto verschwindet auch sein Eintrag.
- name: Remove AccountsService entry of the personalisierung account
ansible.builtin.file:
path: /run/tuxflotte/agent/applied/personalisierung.marker
path: /var/lib/AccountsService/users/personalisierung
state: absent
when: tuxflotte_state == "absent"
when: tuxflotte_pers_konto_unser | bool
# slick-greeter übernimmt das verschwundene Konto nicht live; ohne Neustart
# stünde der Eintrag bis zum nächsten Start des Geräts da. Nur ohne laufende
# Benutzersitzung, sonst beendete der Neustart sie (wie bisher beim Wächter).
- name: Restart the login screen so the entry disappears
ansible.builtin.shell: |
set -eu
for sitzung in $(loginctl list-sessions --no-legend | awk '{print $1}'); do
if [ "$(loginctl show-session "$sitzung" --property=Class --value)" = user ]; then
echo "Benutzersitzung läuft"
exit 0
fi
done
systemctl try-restart lightdm.service
echo "neu gestartet"
register: tuxflotte_pers_greeter
changed_when: "'neu gestartet' in tuxflotte_pers_greeter.stdout"
when: tuxflotte_pers_konto_entfernt is changed
# Den Wrapper erst entfernen, wenn das laufende LightDM nach dem Abbau seiner
# Konfiguration gestartet wurde. Der Zeitpunkt, ab dem das gilt, steht in der
# Merkerdatei (beim ersten Lauf nach dem Abbau gesetzt). Läuft kein LightDM
# (GDM oder gerade gestoppt), liest ein späterer Start die Konfiguration ohne
# den Wrapper.
- name: Remove the old session wrapper once LightDM no longer uses it
ansible.builtin.shell: |
set -eu
WRAPPER=/usr/local/lib/tuxflotte/lightdm-sitzung
SEIT=/var/lib/tuxflotte/personalisierung-wrapper-ungenutzt-seit
if [ ! -e "$WRAPPER" ]; then
rm -f "$SEIT"
exit 0
fi
if [ ! -s "$SEIT" ]; then
mkdir -p "$(dirname "$SEIT")"
date +%s > "$SEIT"
echo "vorgemerkt"
exit 0
fi
pid=$(systemctl show -p MainPID --value lightdm.service 2>/dev/null || echo 0)
if [ "${pid:-0}" -gt 0 ] 2>/dev/null; then
laufzeit=$(ps -o etimes= -p "$pid" | tr -d ' ')
[ -n "$laufzeit" ] || exit 0
[ $(( $(date +%s) - laufzeit )) -gt "$(head -n 1 "$SEIT")" ] || exit 0
fi
rm -f "$WRAPPER" "$SEIT"
echo "entfernt"
register: tuxflotte_pers_wrapper
changed_when: "'entfernt' in tuxflotte_pers_wrapper.stdout"
@@ -1,12 +0,0 @@
#!/bin/sh
# Verwaltet von Tuxflotte (Rolle personalisierung)
#
# LightDM-session-wrapper: das Konto "personalisierung" bekommt statt der
# gewählten Sitzung den Kiosk, alle anderen laufen unverändert über den
# Wrapper der Distribution. Der Kiosk wird dabei wie ein Sitzungskommando
# übergeben, damit die übliche Sitzungsumgebung (Profil, D-Bus,
# Xsession.d) auch für ihn gilt.
if [ "$(id -un)" = "personalisierung" ]; then
set -- /usr/local/lib/tuxflotte/personalisierung-sitzung
fi
exec {{ tuxflotte_personalisierung_wrapper_original }} "$@"
@@ -1,13 +0,0 @@
#!/bin/sh
# Verwaltet von Tuxflotte (Rolle personalisierung)
xset s off -dpms 2>/dev/null || true
# Mit eingerichtetem persönlichem Konto ist der Eintrag "Passwort vergessen?"
# und der Kiosk öffnet die Seite dafür (Adresse mit Gerät und Kiosk-Token).
url={{ tuxflotte_personalisierung_url | quote }}
if [ "$(head -n 1 /etc/tuxflotte/personalisierung-anbieten 2>/dev/null)" = passwort ] &&
[ -s /etc/tuxflotte/personalisierung-vergessen-url ]; then
url=$(head -n 1 /etc/tuxflotte/personalisierung-vergessen-url)
fi
exec /usr/bin/python3 /usr/local/lib/tuxflotte/tuxflotte-kiosk.py \
--url "$url" \
--leerlauf-minuten {{ tuxflotte_personalisierung_leerlauf_minuten | int }}
+2 -1
View File
@@ -91,7 +91,8 @@
tags: persoenliche-konten
tags: persoenliche-konten
# Konto "Personalisierung" mit Kiosk am Anmeldebildschirm (ADR-0041).
# Merkmal "Personalisierung möglich" (ADR-0041): baut seit 24.10.2026
# nur noch Konto "Personalisierung" und Kiosk früherer Stände ab.
- include_role:
name: personalisierung
apply: