Compare commits
4
Commits
v1.1.9
..
8577cd24cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8577cd24cd | ||
|
|
877903bd77 | ||
|
|
2b05d012ee | ||
|
|
0f0fa99e53 |
+1
-1
@@ -1 +1 @@
|
||||
1.1.9
|
||||
1.1.11
|
||||
|
||||
+225
-35
@@ -4224,6 +4224,7 @@ def settings_import_export():
|
||||
"settings_import_export.html",
|
||||
export_sections=EXPORT_SECTIONS,
|
||||
admin_only_sections=IMPORT_EXPORT_ADMIN_ONLY_SECTIONS,
|
||||
export_section_hints=EXPORT_SECTION_HINTS,
|
||||
)
|
||||
|
||||
|
||||
@@ -5392,47 +5393,109 @@ def save_nav_order():
|
||||
|
||||
|
||||
|
||||
def _read_known_hosts_content():
|
||||
"""Für den Export: der Inhalt von known_hosts gehört inhaltlich zu den
|
||||
Zugangsdaten (jeder per SSH bereits bestätigte Host-Key eines Geräts/
|
||||
Switches, das über gespeicherte Zugangsdaten erreicht wurde) -- landet
|
||||
daher NICHT als eigene EXPORT_BUILDERS-Kategorie, sondern als
|
||||
zusätzlicher Payload-Schlüssel "known_hosts", sobald "credentials"
|
||||
mitexportiert wird (siehe export_data())."""
|
||||
try:
|
||||
with open(SSH_KNOWN_HOSTS_PATH, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
return content if content.strip() else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _merge_known_hosts_content(new_content):
|
||||
"""Für den Import: ergänzt die eigene known_hosts-Datei um die im
|
||||
Export enthaltenen Zeilen -- bewusst ergänzend statt überschreibend
|
||||
(die Zielumgebung kann bereits eigene, unabhängig bestätigte Host-Keys
|
||||
haben) und mit Deduplizierung nach vollständiger Zeile, damit ein
|
||||
wiederholter Import derselben Exportdatei nicht bei jedem Mal weitere
|
||||
Duplikate anhäuft. Gibt die Anzahl tatsächlich neu hinzugefügter
|
||||
Zeilen zurück."""
|
||||
try:
|
||||
try:
|
||||
with open(SSH_KNOWN_HOSTS_PATH, "r", encoding="utf-8") as f:
|
||||
existing_lines = set(f.read().splitlines())
|
||||
except OSError:
|
||||
existing_lines = set()
|
||||
new_lines = [ln for ln in new_content.splitlines() if ln.strip() and ln not in existing_lines]
|
||||
if not new_lines:
|
||||
return 0
|
||||
os.makedirs(os.path.dirname(SSH_KNOWN_HOSTS_PATH) or ".", exist_ok=True)
|
||||
with open(SSH_KNOWN_HOSTS_PATH, "a", encoding="utf-8") as f:
|
||||
f.write("\n".join(new_lines) + "\n")
|
||||
return len(new_lines)
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
EXPORT_SECTIONS = [
|
||||
("devices", "Clients"),
|
||||
("switches", "Switche"),
|
||||
("credentials", "Zugangsdaten"),
|
||||
("users", "Benutzer (lokal)"),
|
||||
("groups", "Custom-Gruppen"),
|
||||
("ldap", "LDAP/AD-Einstellungen"),
|
||||
("dhcp", "DHCP-Einstellungen"),
|
||||
("logs", f"Logs (letzte {500} Einträge)"),
|
||||
("users", "Benutzer"),
|
||||
("groups", "Gruppen"),
|
||||
("ldap", "LDAP/AD"),
|
||||
("dhcp", "DHCP"),
|
||||
("nginx", "NGINX"),
|
||||
("logs", "Auditlog"),
|
||||
]
|
||||
EXPORT_SECTION_LABELS = dict(EXPORT_SECTIONS)
|
||||
|
||||
# Zusätzlicher Hinweistext je Kategorie fürs "i"-Icon neben dem Label
|
||||
# (siehe _hint_icon.html) -- nur für Kategorien mit erklärungsbedürftigem
|
||||
# Zusatzverhalten, das über den reinen Namen hinausgeht.
|
||||
EXPORT_SECTION_HINTS = {
|
||||
"users": "Nur lokale Konten. AD/LDAP-Benutzer werden NICHT gesichert — sie werden über Active Directory verwaltet, nicht über diese App, und legen sich beim nächsten Login automatisch wieder an.",
|
||||
"credentials": "Enthält zusätzlich den Inhalt von known_hosts (bereits bestätigte SSH-Host-Keys) — wird beim Import ergänzend (nicht überschreibend) eingespielt.",
|
||||
"nginx": "Enthält Domain, Ports und SSL/HSTS-Schalter sowie — falls vorhanden — das aktuell hinterlegte TLS-Zertifikat und den privaten Schlüssel selbst.",
|
||||
"logs": "Alle aktuell in der Datenbank vorhandenen Einträge (bereits archivierte Auditlog-Tage liegen als eigene Dateien unter Verlauf und sind kein Teil dieses Exports).",
|
||||
}
|
||||
|
||||
IMPORT_EXPORT_ADMIN_ONLY_SECTIONS = {"users", "groups", "ldap"}
|
||||
|
||||
LOG_EXPORT_LIMIT = 500
|
||||
LDAP_SETTING_KEYS = [
|
||||
"ldap_enabled", "ldap_server", "ldap_port", "ldap_use_ssl", "ldap_tls_skip_verify",
|
||||
"ldap_base_dn", "ldap_user_filter_attr", "ldap_default_group",
|
||||
"ldap_base_dn", "ldap_user_filter_attr", "ldap_default_group", "ldap_required_login_group",
|
||||
]
|
||||
|
||||
|
||||
def _export_devices(conn):
|
||||
return [dict(r) for r in conn.execute(
|
||||
"SELECT mac, ip, port, name, switch_hostname, is_active FROM devices WHERE deleted_at IS NULL"
|
||||
).fetchall()]
|
||||
return [dict(r) for r in conn.execute("""
|
||||
SELECT devices.mac, devices.ip, devices.port, devices.name, devices.switch_hostname,
|
||||
devices.is_active, devices.ssh_port, credentials.name AS credential_name
|
||||
FROM devices LEFT JOIN credentials ON credentials.id = devices.credential_id
|
||||
WHERE devices.deleted_at IS NULL
|
||||
""").fetchall()]
|
||||
|
||||
|
||||
def _export_switches(conn):
|
||||
return [dict(r) for r in conn.execute("""
|
||||
SELECT switches.hostname, switches.ip, credentials.name AS credential_name
|
||||
SELECT switches.hostname, switches.ip, switches.ssh_port, credentials.name AS credential_name
|
||||
FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id
|
||||
WHERE switches.deleted_at IS NULL
|
||||
""").fetchall()]
|
||||
|
||||
|
||||
def _export_credentials(conn):
|
||||
rows = conn.execute("SELECT name, username, password FROM credentials WHERE deleted_at IS NULL").fetchall()
|
||||
return [{"name": r["name"], "username": r["username"], "password": decrypt_password(r["password"])} for r in rows]
|
||||
rows = conn.execute("SELECT name, username, password, category FROM credentials WHERE deleted_at IS NULL").fetchall()
|
||||
return [
|
||||
{"name": r["name"], "username": r["username"], "password": decrypt_password(r["password"]), "category": r["category"]}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _export_users(conn):
|
||||
"""avatar_filename wird bewusst NICHT exportiert -- das ist nur ein
|
||||
Dateiname, die eigentliche Bilddatei liegt unter static/uploads/avatars
|
||||
auf DIESEM Host und würde auf dem Zielsystem nicht mitkommen; ein
|
||||
exportierter Verweis auf eine dort nicht existierende Datei wäre
|
||||
schlimmer als gar keiner (kaputtes <img>, siehe auch die analoge
|
||||
Begründung bei _export_nginx() für TLS-Zertifikat/Schlüssel)."""
|
||||
rows = conn.execute(
|
||||
"SELECT id, username, password, first_name, last_name, email, is_admin, is_locked "
|
||||
"FROM users WHERE auth_source='local' AND deleted_at IS NULL"
|
||||
@@ -5453,7 +5516,7 @@ def _export_users(conn):
|
||||
|
||||
|
||||
def _export_groups(conn):
|
||||
rows = conn.execute("SELECT id, name FROM groups WHERE is_system=0 AND deleted_at IS NULL").fetchall()
|
||||
rows = conn.execute("SELECT id, name, is_default FROM groups WHERE is_system=0 AND deleted_at IS NULL").fetchall()
|
||||
result = []
|
||||
for g in rows:
|
||||
perms = [p["permission"] for p in conn.execute(
|
||||
@@ -5464,23 +5527,34 @@ def _export_groups(conn):
|
||||
"WHERE ug.group_id=? AND u.deleted_at IS NULL",
|
||||
(g["id"],),
|
||||
).fetchall()]
|
||||
result.append({"name": g["name"], "permissions": perms, "members": members})
|
||||
result.append({"name": g["name"], "is_default": g["is_default"], "permissions": perms, "members": members})
|
||||
return result
|
||||
|
||||
|
||||
def _export_ldap(conn):
|
||||
"""app_group_id ist eine rohe, lokale groups.id (oder der Sonderwert
|
||||
"admin") -- über eine Umgebungsgrenze hinweg garantiert NICHT stabil
|
||||
(frische Installation vergibt Gruppen-IDs neu). Exportiert wird daher
|
||||
der Gruppenname (analog _export_switches()' credential_name), _import_ldap
|
||||
löst ihn auf dem Zielsystem wieder zur dortigen ID auf -- gleiches
|
||||
"admin"/g.name-Mapping wie in _ldap_group_mappings() weiter oben."""
|
||||
settings_data = {k: get_setting(k) for k in LDAP_SETTING_KEYS if get_setting(k) not in (None, "")}
|
||||
data = {"settings": settings_data, "group_mappings": []}
|
||||
data = {"settings": settings_data, "group_mappings": [], "fileshare_mappings": []}
|
||||
bind = conn.execute(
|
||||
"SELECT username, password FROM service_accounts WHERE purpose=?", (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,)
|
||||
).fetchone()
|
||||
if bind:
|
||||
data["bind_username"] = bind["username"]
|
||||
data["bind_password"] = decrypt_password(bind["password"])
|
||||
data["group_mappings"] = [dict(m) for m in conn.execute(
|
||||
"SELECT ad_group_dn, ad_group_name, app_group_id FROM ldap_group_mappings"
|
||||
data["group_mappings"] = [dict(m) for m in conn.execute("""
|
||||
SELECT m.ad_group_dn, m.ad_group_name,
|
||||
CASE WHEN m.app_group_id = 'admin' THEN 'Admin' ELSE g.name END AS app_group_name
|
||||
FROM ldap_group_mappings m LEFT JOIN groups g ON g.id = m.app_group_id AND g.deleted_at IS NULL
|
||||
""").fetchall()]
|
||||
data["fileshare_mappings"] = [dict(m) for m in conn.execute(
|
||||
"SELECT ad_group_dn, ad_group_name, share_label, share_unc FROM ldap_fileshare_mappings"
|
||||
).fetchall()]
|
||||
if not (settings_data or "bind_username" in data or data["group_mappings"]):
|
||||
if not (settings_data or "bind_username" in data or data["group_mappings"] or data["fileshare_mappings"]):
|
||||
return None
|
||||
return data
|
||||
|
||||
@@ -5513,9 +5587,45 @@ def _export_dhcp(conn):
|
||||
return {"settings": settings_data, "subnets": subnets, "reservations": reservations, "options": options}
|
||||
|
||||
|
||||
NGINX_SETTING_KEYS = ["nginx_server_name", "nginx_http_port", "nginx_https_port", "ssl_enabled", "hsts_enabled"]
|
||||
|
||||
|
||||
def _export_nginx(conn):
|
||||
"""Exportiert Domain/Ports/SSL-HSTS-Schalter sowie, falls vorhanden,
|
||||
das aktuell hinterlegte TLS-Zertifikat+Schlüssel selbst (Base64, da
|
||||
JSON keine Binärdaten kann) -- anders als bei avatar_filename ist das
|
||||
hier sinnvoll: Zertifikat/Schlüssel liegen bereits vollständig auf
|
||||
diesem Host vor und lassen sich 1:1 auf ein Zielsystem übertragen,
|
||||
genau wie andere Geheimnisse (Zugangsdaten-Passwörter, LDAP-Bind-
|
||||
Passwort), die ebenfalls im Klartext in den -- als Ganzes
|
||||
passphrasenverschlüsselten -- Export wandern. Bewusst AUSGESCHLOSSEN
|
||||
bleibt trotzdem jeglicher Let's-Encrypt-/certbot-Zustand (ssl_source,
|
||||
ACME-Konto, Renewal-Timer): das ist an genau diesen Host und dessen
|
||||
Domain-Registrierung gebunden und kann nicht mitziehen -- ein Import
|
||||
setzt ssl_source deshalb immer auf "upload", siehe _import_nginx()."""
|
||||
settings_data = {k: get_setting(k) for k in NGINX_SETTING_KEYS if get_setting(k) not in (None, "")}
|
||||
data = {"settings": settings_data}
|
||||
try:
|
||||
if os.path.isfile(TESM_SSL_CERT_PATH) and os.path.isfile(TESM_SSL_KEY_PATH):
|
||||
with open(TESM_SSL_CERT_PATH, "rb") as f:
|
||||
data["cert_pem"] = base64.b64encode(f.read()).decode("ascii")
|
||||
with open(TESM_SSL_KEY_PATH, "rb") as f:
|
||||
data["key_pem"] = base64.b64encode(f.read()).decode("ascii")
|
||||
except OSError:
|
||||
pass
|
||||
if not (settings_data or "cert_pem" in data):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _export_logs(conn):
|
||||
"""Exportiert das komplette, aktuell in der DB stehende Auditlog --
|
||||
bereits archivierte Tage (siehe _archive_old_audit_log_rows) liegen
|
||||
als eigene Dateien unter AUDIT_ARCHIVE_DIR und sind bewusst NICHT Teil
|
||||
dieses Exports, das Auditlog-Export-Formular exportiert nur die
|
||||
Datenbank-Kategorien."""
|
||||
rows = conn.execute(
|
||||
"SELECT ts, username, action, target, details FROM audit_log ORDER BY id DESC LIMIT ?", (LOG_EXPORT_LIMIT,)
|
||||
"SELECT ts, username, action, target, details FROM audit_log ORDER BY id DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows] if rows else None
|
||||
|
||||
@@ -5523,19 +5633,28 @@ def _export_logs(conn):
|
||||
EXPORT_BUILDERS = {
|
||||
"devices": _export_devices, "switches": _export_switches, "credentials": _export_credentials,
|
||||
"users": _export_users, "groups": _export_groups, "ldap": _export_ldap, "dhcp": _export_dhcp,
|
||||
"logs": _export_logs,
|
||||
"nginx": _export_nginx, "logs": _export_logs,
|
||||
}
|
||||
|
||||
|
||||
def _import_devices(conn, items):
|
||||
n = 0
|
||||
for d in items:
|
||||
cred_id = None
|
||||
if d.get("credential_name"):
|
||||
row = conn.execute(
|
||||
"SELECT id FROM credentials WHERE name=? AND deleted_at IS NULL", (d["credential_name"],)
|
||||
).fetchone()
|
||||
cred_id = row["id"] if row else None
|
||||
conn.execute(
|
||||
"""INSERT INTO devices (mac, ip, port, name, switch_hostname, is_active) VALUES (?, ?, ?, ?, ?, ?)
|
||||
"""INSERT INTO devices (mac, ip, port, name, switch_hostname, is_active, ssh_port, credential_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(mac) DO UPDATE SET ip=excluded.ip, port=excluded.port, name=excluded.name,
|
||||
switch_hostname=excluded.switch_hostname, is_active=excluded.is_active
|
||||
switch_hostname=excluded.switch_hostname, is_active=excluded.is_active,
|
||||
ssh_port=excluded.ssh_port, credential_id=excluded.credential_id
|
||||
WHERE devices.deleted_at IS NULL""",
|
||||
(d["mac"], d["ip"], d.get("port"), d["name"], d.get("switch_hostname"), d.get("is_active", 1)),
|
||||
(d["mac"], d["ip"], d.get("port"), d["name"], d.get("switch_hostname"), d.get("is_active", 1),
|
||||
d.get("ssh_port"), cred_id),
|
||||
)
|
||||
n += 1
|
||||
return n
|
||||
@@ -5551,10 +5670,11 @@ def _import_switches(conn, items):
|
||||
).fetchone()
|
||||
cred_id = row["id"] if row else None
|
||||
conn.execute(
|
||||
"""INSERT INTO switches (hostname, ip, credential_id) VALUES (?, ?, ?)
|
||||
ON CONFLICT(hostname) DO UPDATE SET ip=excluded.ip, credential_id=excluded.credential_id
|
||||
"""INSERT INTO switches (hostname, ip, ssh_port, credential_id) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(hostname) DO UPDATE SET ip=excluded.ip, ssh_port=excluded.ssh_port,
|
||||
credential_id=excluded.credential_id
|
||||
WHERE switches.deleted_at IS NULL""",
|
||||
(s["hostname"], s["ip"], cred_id),
|
||||
(s["hostname"], s["ip"], s.get("ssh_port"), cred_id),
|
||||
)
|
||||
n += 1
|
||||
return n
|
||||
@@ -5564,10 +5684,11 @@ def _import_credentials(conn, items):
|
||||
n = 0
|
||||
for c in items:
|
||||
conn.execute(
|
||||
"""INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET username=excluded.username, password=excluded.password
|
||||
"""INSERT INTO credentials (name, username, password, category) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(name) DO UPDATE SET username=excluded.username, password=excluded.password,
|
||||
category=excluded.category
|
||||
WHERE credentials.deleted_at IS NULL""",
|
||||
(c["name"], c["username"], encrypt_password(c["password"])),
|
||||
(c["name"], c["username"], encrypt_password(c["password"]), c.get("category", "switch")),
|
||||
)
|
||||
n += 1
|
||||
return n
|
||||
@@ -5620,7 +5741,10 @@ def _import_groups(conn, items):
|
||||
if existing:
|
||||
group_id = existing["id"]
|
||||
else:
|
||||
cur = conn.execute("INSERT INTO groups (name, is_default, is_system) VALUES (?, 0, 0)", (g["name"],))
|
||||
cur = conn.execute(
|
||||
"INSERT INTO groups (name, is_default, is_system) VALUES (?, ?, 0)",
|
||||
(g["name"], g.get("is_default", 0)),
|
||||
)
|
||||
group_id = cur.lastrowid
|
||||
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
|
||||
valid_perms = [p for p in g.get("permissions", []) if p in ALL_PERMISSION_KEYS]
|
||||
@@ -5653,10 +5777,27 @@ def _import_ldap(conn, data):
|
||||
)
|
||||
n = 0
|
||||
for m in data.get("group_mappings", []):
|
||||
app_group_name = m.get("app_group_name")
|
||||
if app_group_name == "Admin":
|
||||
app_group_id = "admin"
|
||||
else:
|
||||
grow = conn.execute(
|
||||
"SELECT id FROM groups WHERE name=? AND deleted_at IS NULL", (app_group_name,)
|
||||
).fetchone()
|
||||
if not grow:
|
||||
continue # Rechtegruppe existiert auf dem Zielsystem (noch) nicht -- Zuordnung überspringen statt kaputt anzulegen
|
||||
app_group_id = grow["id"]
|
||||
conn.execute(
|
||||
"INSERT INTO ldap_group_mappings (ad_group_dn, ad_group_name, app_group_id) VALUES (?, ?, ?) "
|
||||
"ON CONFLICT(ad_group_dn) DO UPDATE SET ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id",
|
||||
(m["ad_group_dn"], m["ad_group_name"], m["app_group_id"]),
|
||||
(m["ad_group_dn"], m["ad_group_name"], app_group_id),
|
||||
)
|
||||
n += 1
|
||||
for m in data.get("fileshare_mappings", []):
|
||||
conn.execute(
|
||||
"INSERT INTO ldap_fileshare_mappings (ad_group_dn, ad_group_name, share_label, share_unc) VALUES (?, ?, ?, ?) "
|
||||
"ON CONFLICT(ad_group_dn, share_unc) DO UPDATE SET ad_group_name=excluded.ad_group_name, share_label=excluded.share_label",
|
||||
(m["ad_group_dn"], m["ad_group_name"], m["share_label"], m["share_unc"]),
|
||||
)
|
||||
n += 1
|
||||
return n
|
||||
@@ -5709,6 +5850,44 @@ def _import_dhcp(conn, data):
|
||||
return n
|
||||
|
||||
|
||||
def _import_nginx(conn, data):
|
||||
n = 0
|
||||
for key, value in data.get("settings", {}).items():
|
||||
if key in NGINX_SETTING_KEYS:
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
(key, value),
|
||||
)
|
||||
n += 1
|
||||
if data.get("cert_pem") and data.get("key_pem"):
|
||||
try:
|
||||
cert_bytes = base64.b64decode(data["cert_pem"])
|
||||
key_bytes = base64.b64decode(data["key_pem"])
|
||||
ok, _msg, _info = _validate_cert_key_pair(cert_bytes, key_bytes)
|
||||
if ok:
|
||||
os.makedirs(TESM_SSL_DIR, exist_ok=True)
|
||||
with open(TESM_SSL_CERT_PATH, "wb") as f:
|
||||
f.write(cert_bytes)
|
||||
with open(TESM_SSL_KEY_PATH, "wb") as f:
|
||||
f.write(key_bytes)
|
||||
os.chmod(TESM_SSL_KEY_PATH, 0o600)
|
||||
os.chmod(TESM_SSL_CERT_PATH, 0o644)
|
||||
# ACME-/certbot-Zustand kommt bewusst nie mit (siehe
|
||||
# _export_nginx) -- nach dem Import ist es technisch nur
|
||||
# noch ein hochgeladenes Zertifikat, keine automatische
|
||||
# Verlängerung mehr, auch wenn es ursprünglich per Let's
|
||||
# Encrypt ausgestellt wurde.
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES ('ssl_source', 'upload') "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value"
|
||||
)
|
||||
n += 1
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
return n
|
||||
|
||||
|
||||
def _import_logs(conn, items):
|
||||
conn.executemany(
|
||||
"INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)",
|
||||
@@ -5720,7 +5899,7 @@ def _import_logs(conn, items):
|
||||
IMPORT_APPLIERS = {
|
||||
"devices": _import_devices, "switches": _import_switches, "credentials": _import_credentials,
|
||||
"users": _import_users, "groups": _import_groups, "ldap": _import_ldap, "dhcp": _import_dhcp,
|
||||
"logs": _import_logs,
|
||||
"nginx": _import_nginx, "logs": _import_logs,
|
||||
}
|
||||
|
||||
_pending_imports = {}
|
||||
@@ -5766,6 +5945,11 @@ def export_data():
|
||||
flash("Keine Daten in den ausgewählten Kategorien vorhanden — nichts exportiert.", "danger")
|
||||
return redirect(url_for("settings_import_export"))
|
||||
|
||||
if "credentials" in payload:
|
||||
known_hosts_content = _read_known_hosts_content()
|
||||
if known_hosts_content:
|
||||
payload["known_hosts"] = known_hosts_content
|
||||
|
||||
salt = secrets.token_bytes(16)
|
||||
export_fernet = _derive_export_fernet(passphrase, salt)
|
||||
encrypted_payload = export_fernet.encrypt(json.dumps(payload).encode("utf-8"))
|
||||
@@ -5778,8 +5962,10 @@ def export_data():
|
||||
"payload": encrypted_payload.decode("utf-8"),
|
||||
}
|
||||
|
||||
summary = ", ".join(f"{EXPORT_SECTION_LABELS[k]}: {counts[k]}" for k in payload)
|
||||
log_action("data.export", ", ".join(EXPORT_SECTION_LABELS[k] for k in payload), summary)
|
||||
summary = ", ".join(f"{EXPORT_SECTION_LABELS[k]}: {counts[k]}" for k in payload if k in EXPORT_SECTION_LABELS)
|
||||
if "known_hosts" in payload:
|
||||
summary += " (inkl. known_hosts)"
|
||||
log_action("data.export", ", ".join(EXPORT_SECTION_LABELS[k] for k in payload if k in EXPORT_SECTION_LABELS), summary)
|
||||
|
||||
filename = f"tesm_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
response = jsonify(envelope)
|
||||
@@ -5847,6 +6033,7 @@ def settings_import_preview(token):
|
||||
import_preview={"token": token, "sections": preview_sections},
|
||||
export_sections=EXPORT_SECTIONS,
|
||||
admin_only_sections=IMPORT_EXPORT_ADMIN_ONLY_SECTIONS,
|
||||
export_section_hints=EXPORT_SECTION_HINTS,
|
||||
)
|
||||
|
||||
|
||||
@@ -5878,6 +6065,9 @@ def import_apply():
|
||||
conn.close()
|
||||
|
||||
summary = ", ".join(f"{EXPORT_SECTION_LABELS.get(k, k)}: {results[k]}" for k in selected)
|
||||
if "credentials" in selected and payload.get("known_hosts"):
|
||||
added = _merge_known_hosts_content(payload["known_hosts"])
|
||||
summary += f", known_hosts: {added} neue Zeile(n)"
|
||||
log_action("data.import", ", ".join(EXPORT_SECTION_LABELS.get(k, k) for k in selected), summary)
|
||||
flash(f"Import abgeschlossen: {summary}.", "success")
|
||||
return redirect(url_for("settings_import_export"))
|
||||
|
||||
@@ -968,6 +968,48 @@ select {
|
||||
flex-direction: column;
|
||||
animation: modal-in 0.16s ease;
|
||||
}
|
||||
|
||||
/* ---- Hinweis-Icons (Hover-Tooltip + Klick-Modal), siehe _hint_icon.html
|
||||
und initHintIcons() in app.js ---- */
|
||||
.hint-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--text-faint);
|
||||
background: transparent;
|
||||
color: var(--text-faint);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
line-height: 1;
|
||||
cursor: help;
|
||||
vertical-align: middle;
|
||||
margin-left: 6px;
|
||||
padding: 0;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.hint-icon:hover, .hint-icon:focus-visible { border-color: var(--accent); color: var(--accent); }
|
||||
|
||||
#hint-tooltip {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
max-width: 280px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
@keyframes modal-in {
|
||||
from { opacity: 0; transform: translateY(8px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
|
||||
@@ -285,6 +285,102 @@
|
||||
.catch(() => { content.textContent = "Fehler beim Laden des Logs."; });
|
||||
};
|
||||
|
||||
/* ---------------- Hinweis-Icons (Hover-Tooltip + Klick-Modal) ---------------- */
|
||||
/* Zentraler Mechanismus für ALLE "i"-Icons (siehe _hint_icon.html) app-weit
|
||||
-- ein einziges delegiertes Set von Listenern statt pro Icon eigener
|
||||
Handler, damit auch nachträglich per JS eingefügte Icons (z.B. in
|
||||
dynamisch nachgeladenen Tabellenzeilen) ohne weiteres Zutun funktionieren.
|
||||
Hover zeigt den Text als Tooltip neben dem Mauszeiger -- aber NUR auf
|
||||
Geräten, die tatsächlich sinnvoll hovern können (matchMedia-Check bei
|
||||
JEDEM mouseenter neu ausgewertet, nicht einmalig beim Laden gecacht,
|
||||
damit z.B. ein Convertible/Tablet beim Umschalten Maus/Touch korrekt
|
||||
reagiert). Auf reinen Touch-Geräten bleibt so nur Tap -> Modal übrig,
|
||||
da dort ohnehin kein echtes mouseenter/mousemove-Hover stattfindet. */
|
||||
function ensureHintTooltip() {
|
||||
if (document.getElementById("hint-tooltip")) return;
|
||||
const el = document.createElement("div");
|
||||
el.id = "hint-tooltip";
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
|
||||
function ensureHintModal() {
|
||||
if (document.getElementById("hint-modal")) return;
|
||||
const html = `
|
||||
<div class="modal-overlay" id="hint-modal">
|
||||
<div class="modal" style="max-width:440px;">
|
||||
<div class="modal-header">
|
||||
<h3 id="hint-modal-title">Hinweis</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="hint-modal-body" class="text-dim" style="margin:0; line-height:1.6;"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.insertAdjacentHTML("beforeend", html);
|
||||
document.querySelectorAll("#hint-modal [data-close-modal]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => closeModal(btn));
|
||||
});
|
||||
document.getElementById("hint-modal").addEventListener("click", (e) => {
|
||||
if (e.target.id === "hint-modal") closeModal(e.target);
|
||||
});
|
||||
}
|
||||
|
||||
function canHover() {
|
||||
return window.matchMedia && window.matchMedia("(hover: hover) and (pointer: fine)").matches;
|
||||
}
|
||||
|
||||
function initHintIcons() {
|
||||
if (!document.querySelector(".hint-icon")) return;
|
||||
ensureHintTooltip();
|
||||
const tooltip = document.getElementById("hint-tooltip");
|
||||
|
||||
function positionTooltip(e) {
|
||||
const offset = 14;
|
||||
const rect = tooltip.getBoundingClientRect();
|
||||
let x = e.clientX + offset;
|
||||
let y = e.clientY + offset;
|
||||
if (x + rect.width > window.innerWidth - 8) x = e.clientX - rect.width - offset;
|
||||
if (y + rect.height > window.innerHeight - 8) y = e.clientY - rect.height - offset;
|
||||
tooltip.style.left = Math.max(8, x) + "px";
|
||||
tooltip.style.top = Math.max(8, y) + "px";
|
||||
}
|
||||
|
||||
// mouseenter/mouseleave bubbeln nicht -- Delegation via Capture-Phase
|
||||
// auf document funktioniert dafür trotzdem zuverlässig.
|
||||
document.addEventListener("mouseenter", function (e) {
|
||||
const icon = e.target.closest && e.target.closest(".hint-icon");
|
||||
if (!icon || !canHover()) return;
|
||||
tooltip.textContent = icon.dataset.hint || "";
|
||||
tooltip.style.display = "block";
|
||||
positionTooltip(e);
|
||||
}, true);
|
||||
|
||||
document.addEventListener("mouseleave", function (e) {
|
||||
const icon = e.target.closest && e.target.closest(".hint-icon");
|
||||
if (!icon) return;
|
||||
tooltip.style.display = "none";
|
||||
}, true);
|
||||
|
||||
document.addEventListener("mousemove", function (e) {
|
||||
if (tooltip.style.display === "block") positionTooltip(e);
|
||||
});
|
||||
|
||||
document.addEventListener("click", function (e) {
|
||||
const icon = e.target.closest && e.target.closest(".hint-icon");
|
||||
if (!icon) return;
|
||||
e.preventDefault();
|
||||
tooltip.style.display = "none";
|
||||
ensureHintModal();
|
||||
document.getElementById("hint-modal-title").textContent = icon.dataset.hintTitle || "Hinweis";
|
||||
document.getElementById("hint-modal-body").textContent = icon.dataset.hint || "";
|
||||
openModal("hint-modal");
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */
|
||||
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
|
||||
Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/
|
||||
@@ -628,6 +724,7 @@
|
||||
initLeaseCountdowns();
|
||||
initUnsavedChangesGuard();
|
||||
initSortableTables();
|
||||
initHintIcons();
|
||||
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{#
|
||||
Gemeinsames "i"-Hinweis-Icon: Hover zeigt den Text als Tooltip neben dem
|
||||
Mauszeiger, Klick/Tap öffnet dasselbe (wiederverwendete) Modal mit dem
|
||||
vollen Text -- Mechanik lebt zentral in app.js (initHintIcons), hier wird
|
||||
nur das Icon mit seinen Daten-Attributen erzeugt. title = Kontext für die
|
||||
Modal-Überschrift (z.B. das zugehörige Feld-Label), text = der eigentliche
|
||||
Erklärungstext.
|
||||
#}
|
||||
{% macro hint_icon(text, title='Hinweis') %}<button type="button" class="hint-icon" data-hint="{{ text }}" data-hint-title="{{ title }}" aria-label="Hinweis: {{ title }}">i</button>{% endmacro %}
|
||||
@@ -8,6 +8,7 @@
|
||||
{% block page_sub %}<div class="topbar-sub">{{ credentials|length }} Zugangsdaten</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% import "_hint_icon.html" as hi %}
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
@@ -95,13 +96,12 @@
|
||||
<div class="field"><label>Username</label>
|
||||
<input type="text" name="username" required placeholder="z.B. admin">
|
||||
</div>
|
||||
<div class="field"><label>Kategorie</label>
|
||||
<div class="field"><label>Kategorie {{ hi.hint_icon("Bestimmt u.a., ob diese Zugangsdaten unter „Wartung“ für Bulk-Updates nutzbar sind.", "Kategorie") }}</label>
|
||||
<select name="category">
|
||||
{% for key, label in categories %}
|
||||
<option value="{{ key }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="hint">Bestimmt u.a., ob diese Zugangsdaten unter „Wartung“ für Bulk-Updates nutzbar sind.</div>
|
||||
</div>
|
||||
<div class="field"><label>Passwort</label>
|
||||
<input type="password" id="password_add" name="password" required>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% import "_hint_icon.html" as hi %}
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
@@ -125,9 +126,8 @@
|
||||
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
|
||||
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
|
||||
</div>
|
||||
<div class="field"><label>Switchport</label>
|
||||
<div class="field"><label>Switchport {{ hi.hint_icon("Physische Port-Nummer AM SWITCH (z.B. Port 3 von 48), an dem das Gerät eingesteckt ist — für den PoE-Neustart. Kein Netzwerk-/TCP-Port, keine SSH-Anmeldung.", "Switchport") }}</label>
|
||||
<input type="text" name="port" placeholder="z.B. 3">
|
||||
<div class="field-hint">Physische Port-Nummer AM SWITCH (z.B. Port 3 von 48), an dem das Gerät eingesteckt ist — für den PoE-Neustart. Kein Netzwerk-/TCP-Port, keine SSH-Anmeldung.</div>
|
||||
</div>
|
||||
<div class="field"><label>Switch (optional)</label>
|
||||
<select name="switch_hostname">
|
||||
@@ -136,9 +136,8 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div>
|
||||
<div class="field"><label>SSH-Port</label>
|
||||
<div class="field"><label>SSH-Port {{ hi.hint_icon("TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.", "SSH-Port") }}</label>
|
||||
<input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)">
|
||||
<div class="field-hint">TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Zugangsdaten</label>
|
||||
@@ -201,14 +200,12 @@
|
||||
<input type="text" name="mac" id="edit_mac" required>
|
||||
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
|
||||
</div>
|
||||
<div class="field"><label>Switchport</label>
|
||||
<div class="field"><label>Switchport {{ hi.hint_icon("Portnummer am zugeordneten Switch, für den PoE-Neustart. Hat nichts mit dem SSH-Port unten zu tun.", "Switchport") }}</label>
|
||||
<input type="text" name="port" id="edit_port">
|
||||
<div class="field-hint">Portnummer am zugeordneten Switch, für den PoE-Neustart. Hat nichts mit dem SSH-Port unten zu tun.</div>
|
||||
</div>
|
||||
<div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div>
|
||||
<div class="field"><label>SSH-Port</label>
|
||||
<div class="field"><label>SSH-Port {{ hi.hint_icon("TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.", "SSH-Port") }}</label>
|
||||
<input type="number" name="ssh_port" id="edit_ssh_port" min="1" max="65535" placeholder="22 (Standard)">
|
||||
<div class="field-hint">TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Zugangsdaten</label>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{% block page_sub %}<div class="topbar-sub">{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% import "_hint_icon.html" as hi %}
|
||||
|
||||
{% macro render_tree_node(share, path, name, expanded_map, active_path) %}
|
||||
<li class="tree-node" data-share="{{ share }}" data-path="{{ path }}">
|
||||
@@ -161,10 +162,9 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<label>Datei(en)</label>
|
||||
<label>Datei(en) {{ hi.hint_icon("Insgesamt maximal 15 MB pro Upload-Vorgang. Mehrfachauswahl möglich (auch mehrmals nacheinander — bereits hinzugefügte Dateien bleiben dabei erhalten).", "Datei(en)") }}</label>
|
||||
<input type="file" name="file" id="uploadFileInput" multiple required>
|
||||
<div id="uploadFileList" class="upload-file-list"></div>
|
||||
<div class="field-hint">Insgesamt maximal 15 MB pro Upload-Vorgang. Mehrfachauswahl möglich (auch mehrmals nacheinander — bereits hinzugefügte Dateien bleiben dabei erhalten).</div>
|
||||
</div>
|
||||
<div class="field-hint">Wird in „{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}“ hochgeladen. Eine bereits vorhandene Datei gleichen Namens wird überschrieben.</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{% block page_sub %}<div class="topbar-sub">{{ groups|length + 1 }} Gruppen · Rechteverwaltung</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% import "_hint_icon.html" as hi %}
|
||||
|
||||
{% macro permission_table(group, group_key, checked_keys, readonly) %}
|
||||
{% set row_types = group_row_types[group_key] %}
|
||||
@@ -69,11 +70,7 @@
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Gruppen</h2>
|
||||
<div class="hint">
|
||||
Rechte je Gruppe granular vergeben, Mitgliedschaft in mehreren Gruppen addiert sich.
|
||||
„Admin“ und „Benutzer“ sind feste Systemgruppen. Legende direkt bei den Rechten.
|
||||
</div>
|
||||
<h2 style="font-size:16px;">Gruppen {{ hi.hint_icon("Rechte je Gruppe granular vergeben, Mitgliedschaft in mehreren Gruppen addiert sich. „Admin“ und „Benutzer“ sind feste Systemgruppen. Legende direkt bei den Rechten.", "Gruppen") }}</h2>
|
||||
</div>
|
||||
{% if current_user.has_permission('groups.create') %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="addGroupModal">
|
||||
@@ -317,9 +314,8 @@
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="add_group" value="1">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<label>Name {{ hi.hint_icon("Mitglieder werden danach über die Gruppentabelle zugeordnet.", "Name") }}</label>
|
||||
<input type="text" name="name" required placeholder="z.B. Facility-Team">
|
||||
<div class="field-hint">Mitglieder werden danach über die Gruppentabelle zugeordnet.</div>
|
||||
</div>
|
||||
{{ permission_tree([], false) }}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{% block page_sub %}<div class="topbar-sub">{{ selected_file.range_label if selected_file else "kein Log" }}</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% import "_hint_icon.html" as hi %}
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
@@ -54,8 +55,7 @@
|
||||
|
||||
<div class="section-head" style="margin-top:28px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Archivierte Auditlog-Tage</h2>
|
||||
<div class="hint">Ältere Auditlog-Einträge, tageweise als eigene Datei ausgelagert, sobald die laufende Tabelle den Schwellenwert überschreitet — die Dateien selbst bleiben unbegrenzt erhalten, bis sie hier bewusst exportiert werden.</div>
|
||||
<h2 style="font-size:16px;">Archivierte Auditlog-Tage {{ hi.hint_icon("Ältere Auditlog-Einträge, tageweise als eigene Datei ausgelagert, sobald die laufende Tabelle den Schwellenwert überschreitet — die Dateien selbst bleiben unbegrenzt erhalten, bis sie hier bewusst exportiert werden.", "Archivierte Auditlog-Tage") }}</h2>
|
||||
</div>
|
||||
{% if current_user.can_manage_log_history and audit_files %}
|
||||
<form method="post" action="{{ url_for('logs_history_audit_export') }}"
|
||||
|
||||
@@ -4,15 +4,11 @@
|
||||
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Linux-Client{{ 's' if devices|length != 1 else '' }} mit SSH-Zugangsdaten</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% import "_hint_icon.html" as hi %}
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Wartung (SSH)</h2>
|
||||
<div class="hint">
|
||||
Bulk-Update (<code>apt update & upgrade -y</code>) und Neustart per SSH direkt auf dem Gerät —
|
||||
unabhängig vom PoE-Neustart über den Switch (siehe Dashboard). Nur Geräte mit hinterlegten
|
||||
SSH-Zugangsdaten der Kategorie „Linux-Client“ erscheinen hier.
|
||||
</div>
|
||||
<h2 style="font-size:16px;">Wartung (SSH) {{ hi.hint_icon("Bulk-Update (apt update & upgrade -y) und Neustart per SSH direkt auf dem Gerät — unabhängig vom PoE-Neustart über den Switch (siehe Dashboard). Nur Geräte mit hinterlegten SSH-Zugangsdaten der Kategorie „Linux-Client“ erscheinen hier.", "Wartung (SSH)") }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +83,9 @@
|
||||
|
||||
{% if can_run %}
|
||||
<div class="section-head" style="margin-top:16px;">
|
||||
<div class="hint">Ausgewählte Geräte aktualisieren (nicht-interaktiv, unbeaufsichtigte Konfig-Rückfragen werden automatisch mit den bisherigen Werten beantwortet) oder neu starten.</div>
|
||||
<div style="display:flex; align-items:center; gap:6px; font-size:12.5px; color:var(--text-faint);">
|
||||
Bulk-Aktionen {{ hi.hint_icon("Ausgewählte Geräte aktualisieren (nicht-interaktiv, unbeaufsichtigte Konfig-Rückfragen werden automatisch mit den bisherigen Werten beantwortet) oder neu starten.", "Bulk-Aktionen") }}
|
||||
</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button type="button" class="btn btn-secondary" onclick="bulkReboot()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{% block page_sub %}<div class="topbar-sub">Prüfintervall und Netzwerkkonfiguration dieses Hosts</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% import "_hint_icon.html" as hi %}
|
||||
<div class="settings-grid">
|
||||
|
||||
<div class="card card-pad">
|
||||
@@ -27,13 +28,12 @@
|
||||
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
|
||||
<form method="post">
|
||||
<div class="field">
|
||||
<label for="timezone">Zeitzone</label>
|
||||
<label for="timezone">Zeitzone {{ hi.hint_icon("Bestimmt die lokale Zeit in allen Logs und im Änderungsverlauf — wirkt sofort für diese laufende App-Instanz, ohne Dienst-Neustart.", "Zeitzone") }}</label>
|
||||
<select name="timezone" id="timezone" required>
|
||||
{% for tz in timezones %}
|
||||
<option value="{{ tz }}" {% if tz == current_timezone %}selected{% endif %}>{{ tz }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="field-hint">Bestimmt die lokale Zeit in allen Logs und im Änderungsverlauf — wirkt sofort für diese laufende App-Instanz, ohne Dienst-Neustart.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
@@ -43,9 +43,8 @@
|
||||
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
|
||||
<form method="post">
|
||||
<div class="field">
|
||||
<label for="interval">Prüfintervall (Minuten)</label>
|
||||
<label for="interval">Prüfintervall (Minuten) {{ hi.hint_icon("Wie oft Geräte auf Erreichbarkeit geprüft werden. Der Hintergrund-Dienst (tesm-check.service) wird nach dem Speichern automatisch neu gestartet.", "Prüfintervall (Minuten)") }}</label>
|
||||
<input type="number" name="interval" id="interval" value="{{ interval }}" min="1" required>
|
||||
<div class="field-hint">Wie oft Geräte auf Erreichbarkeit geprüft werden. Der Hintergrund-Dienst (tesm-check.service) wird nach dem Speichern automatisch neu gestartet.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
@@ -118,13 +117,12 @@
|
||||
{% elif net_backend != 'unknown' and current_user.has_permission('settings_system.edit') %}
|
||||
<form method="post" data-confirm="Netzwerkkonfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ net_revert_seconds }} Sekunden wiederhergestellt.">
|
||||
<input type="hidden" name="apply_network" value="1">
|
||||
<div class="field"><label>Interface</label>
|
||||
<div class="field"><label>Interface {{ hi.hint_icon("Jedes Interface hat seine eigene Konfiguration — ein Wechsel hier lädt unten dessen tatsächlichen Ist-Zustand, ändert aber noch nichts.", "Interface") }}</label>
|
||||
<select name="net_interface" id="netInterfaceSelect" onchange="loadNetworkState(this.value)">
|
||||
{% for iface in net_interfaces %}
|
||||
<option value="{{ iface }}" {% if iface == net_interface %}selected{% endif %}>{{ iface }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="field-hint">Jedes Interface hat seine eigene Konfiguration — ein Wechsel hier lädt unten dessen tatsächlichen Ist-Zustand, ändert aber noch nichts.</div>
|
||||
</div>
|
||||
<div class="field"><label>Modus</label>
|
||||
<select name="net_mode" id="netModeSelect" onchange="document.getElementById('netStaticFields').classList.toggle('hidden', this.value !== 'static')">
|
||||
@@ -143,9 +141,8 @@
|
||||
<input type="text" name="net_gateway" id="netGatewayInput" value="{{ net_state.gateway if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label>DNS-Server</label>
|
||||
<div class="field"><label>DNS-Server {{ hi.hint_icon("Kommagetrennt. Leer lassen, um die per DHCP zugewiesenen DNS-Server zu verwenden.", "DNS-Server") }}</label>
|
||||
<input type="text" name="net_dns" id="netDnsInput" value="{{ net_state.dns|join(', ') if net_state and net_state.dns else '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
|
||||
<div class="field-hint">Kommagetrennt. Leer lassen, um die per DHCP zugewiesenen DNS-Server zu verwenden.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
@@ -184,9 +181,8 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="log_rotation_keep">Aufbewahrung (Anzahl Rotationen)</label>
|
||||
<label for="log_rotation_keep">Aufbewahrung (Anzahl Rotationen) {{ hi.hint_icon("Standard: wöchentlich, 4 Rotationen (≈ 1 Monat Historie je Log).", "Aufbewahrung (Anzahl Rotationen)") }}</label>
|
||||
<input type="number" name="log_rotation_keep" id="log_rotation_keep" value="{{ log_rotation_keep }}" min="1" required>
|
||||
<div class="field-hint">Standard: wöchentlich, 4 Rotationen (≈ 1 Monat Historie je Log).</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
@@ -221,9 +217,8 @@
|
||||
{% if current_user.has_permission('settings_system.edit') %}
|
||||
<form method="post">
|
||||
<div class="field">
|
||||
<label for="trash_retention_days">Aufbewahrungsdauer (Tage)</label>
|
||||
<label for="trash_retention_days">Aufbewahrungsdauer (Tage) {{ hi.hint_icon("Danach werden Papierkorb-Einträge automatisch unwiderruflich gelöscht (AD/LDAP-Benutzer sind nie im Papierkorb, da sie sich beim nächsten Login automatisch neu anlegen).", "Aufbewahrungsdauer (Tage)") }}</label>
|
||||
<input type="number" name="trash_retention_days" id="trash_retention_days" value="{{ trash_retention_days }}" min="1" required>
|
||||
<div class="field-hint">Danach werden Papierkorb-Einträge automatisch unwiderruflich gelöscht (AD/LDAP-Benutzer sind nie im Papierkorb, da sie sich beim nächsten Login automatisch neu anlegen).</div>
|
||||
</div>
|
||||
<button type="submit" name="save_trash_retention" value="1" class="btn btn-primary btn-block">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{% block page_sub %}<div class="topbar-sub">Reservierungen und eigene Options aus den Client-Stammdaten (Kea DHCP)</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% import "_hint_icon.html" as hi %}
|
||||
<div class="settings-grid">
|
||||
|
||||
<div class="card card-pad">
|
||||
@@ -81,9 +82,8 @@
|
||||
<div class="field"><label>Lease-Zeit Maximum (Sek.)</label>
|
||||
<input type="number" name="dhcp_lease_max" value="{{ cfg.dhcp_lease_max }}">
|
||||
</div>
|
||||
<div class="field"><label>Ausgabepfad (Kea-Konfigurationsdatei)</label>
|
||||
<div class="field"><label>Ausgabepfad (Kea-Konfigurationsdatei) {{ hi.hint_icon("Standardmäßig die aktive Kea-Konfiguration. Läuft der Dienst gerade, schreibt „Speichern“ die Datei neu und startet ihn automatisch neu.", "Ausgabepfad (Kea-Konfigurationsdatei)") }}</label>
|
||||
<input type="text" name="dhcp_output_path" value="{{ cfg.dhcp_output_path }}" class="mono">
|
||||
<div class="field-hint">Standardmäßig die aktive Kea-Konfiguration. Läuft der Dienst gerade, schreibt „Speichern“ die Datei neu und startet ihn automatisch neu.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
@@ -98,11 +98,7 @@
|
||||
<div class="card card-pad" style="grid-column:1 / -1;">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Subnetze</h2>
|
||||
<div class="hint">
|
||||
Ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz bedienen — je Subnetz eigene Range,
|
||||
optional eigenes Gateway/DNS. Subnet/Netzmaske werden live vom Interface übernommen, nicht manuell gepflegt.
|
||||
</div>
|
||||
<h2 style="font-size:16px;">Subnetze {{ hi.hint_icon("Ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz bedienen — je Subnetz eigene Range, optional eigenes Gateway/DNS. Subnet/Netzmaske werden live vom Interface übernommen, nicht manuell gepflegt.", "Subnetze") }}</h2>
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="addSubnetModal">
|
||||
@@ -175,11 +171,7 @@
|
||||
<div class="card card-pad" style="grid-column:1 / -1;">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">DHCP-Options</h2>
|
||||
<div class="hint">
|
||||
Standard-Optionen (analog den "Predefined Options" eines Windows-DHCP-Servers) sind bereits vorbefüllt,
|
||||
eigene/herstellerspezifische Options lassen sich zusätzlich anlegen — beide global oder pro Client überschreibbar.
|
||||
</div>
|
||||
<h2 style="font-size:16px;">DHCP-Options {{ hi.hint_icon('Standard-Optionen (analog den "Predefined Options" eines Windows-DHCP-Servers) sind bereits vorbefüllt, eigene/herstellerspezifische Options lassen sich zusätzlich anlegen — beide global oder pro Client überschreibbar.', "DHCP-Options") }}</h2>
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="addOptionModal">
|
||||
@@ -217,7 +209,9 @@
|
||||
<input type="text" name="value" value="{{ option_values.get(d.id, {}).get('', '') }}" style="max-width:220px;" placeholder="(nicht gesetzt)">
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Speichern</button>
|
||||
</form>
|
||||
<div class="field-hint">Leeren Wert speichern, um eine Standard-Option wieder auszublenden.</div>
|
||||
<div class="text-faint" style="font-size:11.5px; display:flex; align-items:center; gap:6px;">
|
||||
Standard-Option zurücksetzen {{ hi.hint_icon("Leeren Wert speichern, um eine Standard-Option wieder auszublenden.", "Standard-Option zurücksetzen") }}
|
||||
</div>
|
||||
{% else %}
|
||||
{{ option_values.get(d.id, {}).get('', '—') }}
|
||||
{% endif %}
|
||||
@@ -272,11 +266,7 @@
|
||||
<div class="card card-pad" style="grid-column:1 / -1;">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Aktive Leases</h2>
|
||||
<div class="hint">
|
||||
Direkt aus Kea gelesen (nicht aus den Reservierungen) — zeigt auch Clients OHNE eigene Reservierung,
|
||||
die sich einfach eine freie IP aus dem Pool genommen haben.
|
||||
</div>
|
||||
<h2 style="font-size:16px;">Aktive Leases {{ hi.hint_icon("Direkt aus Kea gelesen (nicht aus den Reservierungen) — zeigt auch Clients OHNE eigene Reservierung, die sich einfach eine freie IP aus dem Pool genommen haben.", "Aktive Leases") }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -497,9 +487,8 @@
|
||||
<div class="field"><label>Code</label>
|
||||
<input type="number" name="code" min="1" max="254" required placeholder="z.B. 225">
|
||||
</div>
|
||||
<div class="field"><label>Name</label>
|
||||
<div class="field"><label>Name {{ hi.hint_icon("Nur Kleinbuchstaben, Ziffern und Bindestrich, muss mit einem Buchstaben beginnen.", "Name") }}</label>
|
||||
<input type="text" name="name" required placeholder="z.B. terminal-url" pattern="[a-z][a-z0-9-]*">
|
||||
<div class="field-hint">Nur Kleinbuchstaben, Ziffern und Bindestrich, muss mit einem Buchstaben beginnen.</div>
|
||||
</div>
|
||||
<div class="field"><label>Datentyp</label>
|
||||
<select name="type">
|
||||
@@ -539,13 +528,11 @@
|
||||
<div class="field"><label>MAC-Adresse</label>
|
||||
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
|
||||
</div>
|
||||
<div class="field"><label>IP-Adresse</label>
|
||||
<div class="field"><label>IP-Adresse {{ hi.hint_icon("Muss in einem der oben konfigurierten Subnetze liegen, sonst wird sie beim Schreiben übersprungen.", "IP-Adresse") }}</label>
|
||||
<input type="text" name="ip" required placeholder="z.B. 192.168.1.50">
|
||||
<div class="field-hint">Muss in einem der oben konfigurierten Subnetze liegen, sonst wird sie beim Schreiben übersprungen.</div>
|
||||
</div>
|
||||
<div class="field"><label>Name</label>
|
||||
<div class="field"><label>Name {{ hi.hint_icon("Wird zum Hostname der Reservierung. DHCP-Optionen (z.B. Hostname erzwingen) lassen sich danach über das Options-Symbol in der Tabelle setzen.", "Name") }}</label>
|
||||
<input type="text" name="name" required placeholder="z.B. Empfangsdrucker">
|
||||
<div class="field-hint">Wird zum Hostname der Reservierung. DHCP-Optionen (z.B. Hostname erzwingen) lassen sich danach über das Options-Symbol in der Tabelle setzen.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -575,16 +562,14 @@
|
||||
<div class="field"><label>Range Start</label>
|
||||
<input type="text" name="range_start" required placeholder="z.B. 192.168.1.100">
|
||||
</div>
|
||||
<div class="field"><label>Range Ende</label>
|
||||
<div class="field"><label>Range Ende {{ hi.hint_icon("Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.", "Range Ende") }}</label>
|
||||
<input type="text" name="range_end" required placeholder="z.B. 192.168.1.200">
|
||||
<div class="field-hint">Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.</div>
|
||||
</div>
|
||||
<div class="field"><label>Gateway (optional)</label>
|
||||
<input type="text" name="gateway" placeholder="Automatisch: erkanntes Gateway des Interfaces">
|
||||
</div>
|
||||
<div class="field"><label>DNS-Server (optional)</label>
|
||||
<div class="field"><label>DNS-Server (optional) {{ hi.hint_icon("Kommagetrennt, falls mehrere.", "DNS-Server (optional)") }}</label>
|
||||
<input type="text" name="dns" placeholder="z.B. 1.1.1.1, 8.8.8.8">
|
||||
<div class="field-hint">Kommagetrennt, falls mehrere.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -617,16 +602,14 @@
|
||||
<div class="field"><label>Range Start</label>
|
||||
<input type="text" name="range_start" value="{{ s.range_start }}" required>
|
||||
</div>
|
||||
<div class="field"><label>Range Ende</label>
|
||||
<div class="field"><label>Range Ende {{ hi.hint_icon("Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.", "Range Ende") }}</label>
|
||||
<input type="text" name="range_end" value="{{ s.range_end }}" required>
|
||||
<div class="field-hint">Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.</div>
|
||||
</div>
|
||||
<div class="field"><label>Gateway (optional)</label>
|
||||
<input type="text" name="gateway" value="{{ s.gateway or '' }}" placeholder="Automatisch: erkanntes Gateway des Interfaces">
|
||||
</div>
|
||||
<div class="field"><label>DNS-Server (optional)</label>
|
||||
<div class="field"><label>DNS-Server (optional) {{ hi.hint_icon("Kommagetrennt, falls mehrere.", "DNS-Server (optional)") }}</label>
|
||||
<input type="text" name="dns" value="{{ s.dns or '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
|
||||
<div class="field-hint">Kommagetrennt, falls mehrere.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{% block page_sub %}<div class="topbar-sub">Umzug auf eine neue Umgebung</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% import "_hint_icon.html" as hi %}
|
||||
<div class="settings-grid">
|
||||
|
||||
<div class="card card-pad">
|
||||
@@ -26,7 +27,7 @@
|
||||
data-confirm="Ausgewählte Kategorien wirklich importieren? Bestehende Einträge mit gleichem Namen/Hostname/MAC/Benutzernamen werden überschrieben.">
|
||||
<input type="hidden" name="import_token" value="{{ import_preview.token }}">
|
||||
<div class="field">
|
||||
<label>Was importieren?</label>
|
||||
<label>Was importieren? {{ hi.hint_icon("AD/LDAP-Benutzerkonten sind hiervon unberührt — sie werden über Active Directory/Windows verwaltet, nicht über diese App, und beim nächsten Login automatisch neu angelegt.", "Was importieren?") }}</label>
|
||||
<div class="check-list">
|
||||
{% for s in import_preview.sections %}
|
||||
<label class="check-row">
|
||||
@@ -38,10 +39,6 @@
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="field-hint">
|
||||
AD/LDAP-Benutzerkonten sind hiervon unberührt — sie werden über Active Directory/Windows verwaltet,
|
||||
nicht über diese App, und beim nächsten Login automatisch neu angelegt.
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
@@ -77,7 +74,7 @@
|
||||
{% if current_user.has_permission('settings_importexport.export') %}
|
||||
<form method="post" action="{{ url_for('export_data') }}">
|
||||
<div class="field">
|
||||
<label>Was exportieren?</label>
|
||||
<label>Was exportieren? {{ hi.hint_icon("AD/LDAP-Benutzerkonten werden nie mitexportiert — sie werden über Active Directory/Windows verwaltet (nicht über diese App) und legen sich beim nächsten Login automatisch wieder an. Beim LDAP-Export wird lediglich das Bind-Konto (verschlüsselt) sowie die Gruppenzuordnungen gesichert.", "Was exportieren?") }}</label>
|
||||
<div class="check-list">
|
||||
{% for key, label in export_sections %}
|
||||
<label class="check-row">
|
||||
@@ -85,20 +82,15 @@
|
||||
{% if key not in admin_only_sections or current_user.is_admin %}checked{% endif %}
|
||||
{% if key in admin_only_sections and not current_user.is_admin %}disabled{% endif %}>
|
||||
{{ label }}
|
||||
{% if export_section_hints and key in export_section_hints %}{{ hi.hint_icon(export_section_hints[key], label) }}{% endif %}
|
||||
{% if key in admin_only_sections %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="field-hint">
|
||||
AD/LDAP-Benutzerkonten werden nie mitexportiert — sie werden über Active Directory/Windows verwaltet
|
||||
(nicht über diese App) und legen sich beim nächsten Login automatisch wieder an. Beim LDAP-Export wird
|
||||
lediglich das Bind-Konto (verschlüsselt) sowie die Gruppenzuordnungen gesichert.
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="export_passphrase">Passphrase</label>
|
||||
<label for="export_passphrase">Passphrase {{ hi.hint_icon("Wird zum Verschlüsseln der Export-Datei benötigt — für den späteren Import dieselbe Passphrase erneut eingeben.", "Passphrase") }}</label>
|
||||
<input type="password" name="export_passphrase" id="export_passphrase" required>
|
||||
<div class="field-hint">Wird zum Verschlüsseln der Export-Datei benötigt — für den späteren Import dieselbe Passphrase erneut eingeben.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-secondary btn-block">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% import "_hint_icon.html" as hi %}
|
||||
<div class="settings-grid settings-grid--wide">
|
||||
|
||||
<div class="card card-pad">
|
||||
@@ -23,18 +24,16 @@
|
||||
<span>LDAP-Anmeldung aktivieren</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field"><label>Server</label>
|
||||
<div class="field"><label>Server {{ hi.hint_icon("DNS-Name oder IP-Adresse — beides wird genau so gespeichert und beim Verbinden verwendet.", "Server") }}</label>
|
||||
<input type="text" name="ldap_server" value="{{ ldap.server }}" placeholder="z.B. 192.168.1.1 oder dc01.firma.local">
|
||||
<div class="field-hint">DNS-Name oder IP-Adresse — beides wird genau so gespeichert und beim Verbinden verwendet.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||
<input type="checkbox" name="ldap_use_ssl" id="ldap_use_ssl" {% if ldap.use_ssl %}checked{% endif %}
|
||||
onchange="document.getElementById('ldap_port').value = this.checked ? 636 : 389;">
|
||||
<span class="track"></span>
|
||||
<span>LDAPS/TLS verwenden</span>
|
||||
<span>LDAPS/TLS verwenden {{ hi.hint_icon("Ohne LDAPS wird das Passwort unverschlüsselt übertragen — nur für interne Tests geeignet, vor Produktivbetrieb LDAPS auf dem Domain Controller einrichten. Stellt beim Umschalten den Port automatisch auf 636/389 — unten weiterhin manuell änderbar.", "LDAPS/TLS verwenden") }}</span>
|
||||
</label>
|
||||
<div class="field-hint">Ohne LDAPS wird das Passwort unverschlüsselt übertragen — nur für interne Tests geeignet, vor Produktivbetrieb LDAPS auf dem Domain Controller einrichten. Stellt beim Umschalten den Port automatisch auf 636/389 — unten weiterhin manuell änderbar.</div>
|
||||
</div>
|
||||
<div class="field"><label>Port</label>
|
||||
<input type="number" name="ldap_port" id="ldap_port" min="1" max="65535" value="{{ ldap.port }}">
|
||||
@@ -43,13 +42,11 @@
|
||||
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||
<input type="checkbox" name="ldap_tls_skip_verify" {% if ldap.tls_skip_verify %}checked{% endif %}>
|
||||
<span class="track"></span>
|
||||
<span>Zertifikatsprüfung überspringen (nur LDAPS)</span>
|
||||
<span>Zertifikatsprüfung überspringen (nur LDAPS) {{ hi.hint_icon("Akzeptiert jedes Server-Zertifikat, auch selbstsignierte/nicht vertrauenswürdige — praktisch für interne Tests, schützt dann aber nicht mehr vor einem gefälschten Server. Vor Produktivbetrieb ein echtes, vertrauenswürdiges Zertifikat einrichten und diese Option deaktivieren.", "Zertifikatsprüfung überspringen") }}</span>
|
||||
</label>
|
||||
<div class="field-hint">Akzeptiert jedes Server-Zertifikat, auch selbstsignierte/nicht vertrauenswürdige — praktisch für interne Tests, schützt dann aber nicht mehr vor einem gefälschten Server. Vor Produktivbetrieb ein echtes, vertrauenswürdiges Zertifikat einrichten und diese Option deaktivieren.</div>
|
||||
</div>
|
||||
<div class="field"><label>Bind-Konto (Service-Account)</label>
|
||||
<div class="field"><label>Bind-Konto (Service-Account) {{ hi.hint_icon("Ein normales, unprivilegiertes Domänenkonto reicht — es wird nur zum Suchen von Benutzern verwendet, keine Admin-Rechte nötig. Ein neu gespeichertes Konto ersetzt das bisherige vollständig.", "Bind-Konto (Service-Account)") }}</label>
|
||||
<input type="text" name="ldap_bind_dn" value="{{ ldap.bind_dn }}" placeholder="z.B. ldap@ad.firma.local">
|
||||
<div class="field-hint">Ein normales, unprivilegiertes Domänenkonto reicht — es wird nur zum Suchen von Benutzern verwendet, keine Admin-Rechte nötig. Ein neu gespeichertes Konto ersetzt das bisherige vollständig.</div>
|
||||
</div>
|
||||
<div class="field"><label>Bind-Passwort</label>
|
||||
<input type="password" name="ldap_bind_password" placeholder="{{ '(unverändert lassen)' if ldap.bind_password_enc else '' }}">
|
||||
@@ -57,18 +54,16 @@
|
||||
<div class="field"><label>Base-DN</label>
|
||||
<input type="text" name="ldap_base_dn" value="{{ ldap.base_dn }}" placeholder="Leer = automatisch ermitteln">
|
||||
</div>
|
||||
<div class="field"><label>Attribut für Benutzername</label>
|
||||
<div class="field"><label>Attribut für Benutzername {{ hi.hint_icon("Für Active Directory: sAMAccountName. Für generisches LDAP (z.B. OpenLDAP): meist uid. Anmeldung per userPrincipalName (E-Mail/UPN) funktioniert unabhängig davon immer zusätzlich.", "Attribut für Benutzername") }}</label>
|
||||
<input type="text" name="ldap_user_filter_attr" value="{{ ldap.filter_attr }}" placeholder="sAMAccountName">
|
||||
<div class="field-hint">Für Active Directory: sAMAccountName. Für generisches LDAP (z.B. OpenLDAP): meist uid. Anmeldung per userPrincipalName (E-Mail/UPN) funktioniert unabhängig davon immer zusätzlich.</div>
|
||||
</div>
|
||||
<div class="field"><label>Standardgruppe für neue AD-Benutzer</label>
|
||||
<div class="field"><label>Standardgruppe für neue AD-Benutzer {{ hi.hint_icon("Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.", "Standardgruppe für neue AD-Benutzer") }}</label>
|
||||
<select name="ldap_default_group">
|
||||
<option value="">Systemstandard ({{ ldap_groups|selectattr('is_default')|map(attribute='name')|first or 'Benutzer' }})</option>
|
||||
{% for g in ldap_groups %}
|
||||
<option value="{{ g['id'] }}" {% if ldap.default_group == g['id']|string %}selected{% endif %}>{{ g['name'] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="field-hint">Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.</div>
|
||||
</div>
|
||||
<div class="field"><label>Erforderliche AD-Gruppe für Login (optional)</label>
|
||||
<div class="flex gap-2">
|
||||
@@ -108,12 +103,7 @@
|
||||
<div class="card card-pad">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">AD-Gruppenzuordnungen</h2>
|
||||
<div class="hint">
|
||||
Ist ein AD-Benutzer (rekursiv, auch über verschachtelte Gruppen) Mitglied einer hier zugeordneten
|
||||
AD-Gruppe, erhält er beim Login zusätzlich die zugeordnete App-Rechtegruppe — additiv, mehrere
|
||||
Zuordnungen können gleichzeitig greifen. Wird keine Zuordnung getroffen, gilt die Standardgruppe oben.
|
||||
</div>
|
||||
<h2 style="font-size:16px;">AD-Gruppenzuordnungen {{ hi.hint_icon("Ist ein AD-Benutzer (rekursiv, auch über verschachtelte Gruppen) Mitglied einer hier zugeordneten AD-Gruppe, erhält er beim Login zusätzlich die zugeordnete App-Rechtegruppe — additiv, mehrere Zuordnungen können gleichzeitig greifen. Wird keine Zuordnung getroffen, gilt die Standardgruppe oben.", "AD-Gruppenzuordnungen") }}</h2>
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="addLdapMappingModal">
|
||||
@@ -163,13 +153,7 @@
|
||||
<div class="card card-pad">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Fileshare-Gruppen</h2>
|
||||
<div class="hint">
|
||||
Ist ein AD-Benutzer (rekursiv) Mitglied einer hier zugeordneten AD-Gruppe, wird die zugehörige
|
||||
Freigabe beim Login für ihn gemountet — sofern er zusätzlich das TESM-Recht „Dateifreigaben lesen“
|
||||
hat (siehe Gruppen → Rechte, Bereich „Dateifreigaben“). Fehlt das Recht, wird nicht gemountet und
|
||||
der Menüpunkt „Dateifreigaben“ erscheint nicht, unabhängig von der AD-Gruppenmitgliedschaft.
|
||||
</div>
|
||||
<h2 style="font-size:16px;">Fileshare-Gruppenzuordnung {{ hi.hint_icon("Ist ein AD-Benutzer (rekursiv) Mitglied einer hier zugeordneten AD-Gruppe, wird die zugehörige Freigabe beim Login für ihn gemountet — sofern er zusätzlich das TESM-Recht „Dateifreigaben lesen“ hat (siehe Gruppen → Rechte, Bereich „Dateifreigaben“). Fehlt das Recht, wird nicht gemountet und der Menüpunkt „Dateifreigaben“ erscheint nicht, unabhängig von der AD-Gruppenmitgliedschaft.", "Fileshare-Gruppenzuordnung") }}</h2>
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="addFileshareMappingModal">
|
||||
@@ -278,13 +262,11 @@
|
||||
<input type="hidden" name="fs_ad_group_name" id="fsMappingAdGroupName">
|
||||
<div class="field-hint" id="fsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
|
||||
</div>
|
||||
<div class="field"><label>Bezeichnung</label>
|
||||
<div class="field"><label>Bezeichnung {{ hi.hint_icon("Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.", "Bezeichnung") }}</label>
|
||||
<input type="text" name="fs_share_label" placeholder="z.B. Vertrieb" required>
|
||||
<div class="field-hint">Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.</div>
|
||||
</div>
|
||||
<div class="field"><label>Freigabe-Pfad (UNC)</label>
|
||||
<div class="field"><label>Freigabe-Pfad (UNC) {{ hi.hint_icon("Beide Schreibweisen funktionieren — \\\\server\\freigabe wird automatisch in das von Linux benötigte //server/freigabe umgewandelt.", "Freigabe-Pfad (UNC)") }}</label>
|
||||
<input type="text" name="fs_share_unc" placeholder="//fileserver/freigabe" required>
|
||||
<div class="field-hint">Beide Schreibweisen funktionieren — <code>\\server\freigabe</code> wird automatisch in das von Linux benötigte <code>//server/freigabe</code> umgewandelt.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -349,13 +331,11 @@
|
||||
<input type="hidden" name="fs_ad_group_name" id="editFsMappingAdGroupName">
|
||||
<div class="field-hint" id="editFsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
|
||||
</div>
|
||||
<div class="field"><label>Bezeichnung</label>
|
||||
<div class="field"><label>Bezeichnung {{ hi.hint_icon("Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.", "Bezeichnung") }}</label>
|
||||
<input type="text" name="fs_share_label" id="editFsMappingLabel" placeholder="z.B. Vertrieb" required>
|
||||
<div class="field-hint">Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.</div>
|
||||
</div>
|
||||
<div class="field"><label>Freigabe-Pfad (UNC)</label>
|
||||
<div class="field"><label>Freigabe-Pfad (UNC) {{ hi.hint_icon("Beide Schreibweisen funktionieren — \\\\server\\freigabe wird automatisch in das von Linux benötigte //server/freigabe umgewandelt.", "Freigabe-Pfad (UNC)") }}</label>
|
||||
<input type="text" name="fs_share_unc" id="editFsMappingUnc" placeholder="//fileserver/freigabe" required>
|
||||
<div class="field-hint">Beide Schreibweisen funktionieren — <code>\\server\freigabe</code> wird automatisch in das von Linux benötigte <code>//server/freigabe</code> umgewandelt.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% import "_hint_icon.html" as hi %}
|
||||
<div class="settings-grid settings-grid--wide">
|
||||
|
||||
<div class="card card-pad">
|
||||
@@ -38,9 +39,8 @@
|
||||
<form method="post"
|
||||
data-confirm="nginx-Konfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ nginx_revert_seconds }} Sekunden wiederhergestellt.">
|
||||
<div class="field">
|
||||
<label for="server_name">Domain (server_name)</label>
|
||||
<label for="server_name">Domain (server_name) {{ hi.hint_icon("\"_\" ist nginx' Catch-all (Standard für interne Instanzen ohne eigene Domain) — für Let's Encrypt muss hier die tatsächliche, öffentlich auflösbare Domain stehen.", "Domain (server_name)") }}</label>
|
||||
<input type="text" name="server_name" id="server_name" value="{{ server_name }}" placeholder="_ (kein bestimmter Hostname) oder z.B. tesm.example.com">
|
||||
<div class="field-hint">"_" ist nginx' Catch-all (Standard für interne Instanzen ohne eigene Domain) — für Let's Encrypt muss hier die tatsächliche, öffentlich auflösbare Domain stehen.</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="field field--half">
|
||||
@@ -48,14 +48,10 @@
|
||||
<input type="number" name="http_port" id="http_port" min="1" max="65535" value="{{ http_port }}" required>
|
||||
</div>
|
||||
<div class="field field--half">
|
||||
<label for="https_port">HTTPS-Port</label>
|
||||
<label for="https_port">HTTPS-Port {{ hi.hint_icon("Let's Encrypt validiert IMMER über Port 80 (protokollbedingt, unabhängig vom hier eingestellten HTTP-Port) — weicht der HTTP-Port von 80 ab, wird dafür automatisch zusätzlich ein minimaler Port-80-Block mitgeschrieben.", "HTTP-Port / HTTPS-Port") }}</label>
|
||||
<input type="number" name="https_port" id="https_port" min="1" max="65535" value="{{ https_port }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-hint" style="margin-top:-8px; margin-bottom:14px;">
|
||||
Let's Encrypt validiert IMMER über Port 80 (protokollbedingt, unabhängig vom hier eingestellten HTTP-Port) —
|
||||
weicht der HTTP-Port von 80 ab, wird dafür automatisch zusätzlich ein minimaler Port-80-Block mitgeschrieben.
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||
<input type="checkbox" name="ssl_enabled" id="ssl_enabled_check" {% if ssl_enabled %}checked{% endif %} {% if not cert_info %}disabled{% endif %}>
|
||||
@@ -68,9 +64,8 @@
|
||||
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||
<input type="checkbox" name="hsts_enabled" id="hsts_enabled_check" {% if hsts_enabled %}checked{% endif %} {% if not ssl_enabled %}disabled{% endif %}>
|
||||
<span class="track"></span>
|
||||
<span>HSTS (Strict-Transport-Security)</span>
|
||||
<span>HSTS (Strict-Transport-Security) {{ hi.hint_icon("Weist Browser an, diese Instanz künftig NUR noch über HTTPS aufzurufen — bleibt auch bei einem späteren Zurückschalten auf HTTP im Browser für 1 Jahr bestehen. Nur aktivieren, wenn das Zertifikat dauerhaft gepflegt wird.", "HSTS") }}</span>
|
||||
</label>
|
||||
<div class="field-hint">Weist Browser an, diese Instanz künftig NUR noch über HTTPS aufzurufen — bleibt auch bei einem späteren Zurückschalten auf HTTP im Browser für 1 Jahr bestehen. Nur aktivieren, wenn das Zertifikat dauerhaft gepflegt wird.</div>
|
||||
</div>
|
||||
<button type="submit" name="apply_nginx" value="1" class="btn btn-primary btn-block">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 12l2 2 4-4"/><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/></svg>
|
||||
@@ -165,9 +160,8 @@
|
||||
<input type="file" name="cert_file" id="cert_file" accept=".pem,.crt,.cer" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="key_file">Privater Schlüssel (PEM, unverschlüsselt)</label>
|
||||
<label for="key_file">Privater Schlüssel (PEM, unverschlüsselt) {{ hi.hint_icon("Ein passwortgeschütztes Schlüssel wird abgelehnt — nginx könnte ihn beim Start ohnehin nicht ohne manuelle Passworteingabe laden.", "Privater Schlüssel") }}</label>
|
||||
<input type="file" name="key_file" id="key_file" accept=".pem,.key" required>
|
||||
<div class="field-hint">Ein passwortgeschütztes Schlüssel wird abgelehnt — nginx könnte ihn beim Start ohnehin nicht ohne manuelle Passworteingabe laden.</div>
|
||||
</div>
|
||||
<button type="submit" name="upload_cert" value="1" class="btn btn-secondary btn-block">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
{% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% import "_hint_icon.html" as hi %}
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
@@ -102,9 +103,8 @@
|
||||
<input type="text" name="ip" value="{{ s['ip'] }}" required placeholder="z.B. 192.168.1.100">
|
||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||
</div>
|
||||
<div class="field"><label>SSH-Port</label>
|
||||
<div class="field"><label>SSH-Port {{ hi.hint_icon("Leer lassen, wenn der Switch den Standard-Port 22 verwendet.", "SSH-Port") }}</label>
|
||||
<input type="number" name="ssh_port" value="{{ s['ssh_port'] or '' }}" min="1" max="65535" placeholder="22 (Standard)">
|
||||
<div class="field-hint">Leer lassen, wenn der Switch den Standard-Port 22 verwendet.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Zugangsdaten</label>
|
||||
@@ -162,9 +162,8 @@
|
||||
<input type="text" name="ip" required placeholder="z.B. 192.168.1.100">
|
||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||
</div>
|
||||
<div class="field"><label>SSH-Port</label>
|
||||
<div class="field"><label>SSH-Port {{ hi.hint_icon("Leer lassen, wenn der Switch den Standard-Port 22 verwendet.", "SSH-Port") }}</label>
|
||||
<input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)">
|
||||
<div class="field-hint">Leer lassen, wenn der Switch den Standard-Port 22 verwendet.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Zugangsdaten</label>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{% block page_sub %}<div class="topbar-sub">{{ users|length }} Benutzer</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% import "_hint_icon.html" as hi %}
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
@@ -205,13 +206,12 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<label>Gruppe</label>
|
||||
<label>Gruppe {{ hi.hint_icon("Ersetzt die bisherige Gruppen-/Rollenzuordnung dieses Benutzers.", "Gruppe") }}</label>
|
||||
<select name="group_id" id="group_select">
|
||||
<option value="">Keine Gruppe</option>
|
||||
{% for g in all_groups %}<option value="{{ g['id'] }}">{{ g['name'] }}</option>{% endfor %}
|
||||
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
|
||||
</select>
|
||||
<div class="field-hint">Ersetzt die bisherige Gruppen-/Rollenzuordnung dieses Benutzers.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
Reference in New Issue
Block a user