Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d90c27b8f8 | ||
|
|
8577cd24cd | ||
|
|
877903bd77 |
+125
-19
@@ -4224,6 +4224,7 @@ def settings_import_export():
|
|||||||
"settings_import_export.html",
|
"settings_import_export.html",
|
||||||
export_sections=EXPORT_SECTIONS,
|
export_sections=EXPORT_SECTIONS,
|
||||||
admin_only_sections=IMPORT_EXPORT_ADMIN_ONLY_SECTIONS,
|
admin_only_sections=IMPORT_EXPORT_ADMIN_ONLY_SECTIONS,
|
||||||
|
export_section_hints=EXPORT_SECTION_HINTS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -5435,16 +5436,27 @@ def _merge_known_hosts_content(new_content):
|
|||||||
EXPORT_SECTIONS = [
|
EXPORT_SECTIONS = [
|
||||||
("devices", "Clients"),
|
("devices", "Clients"),
|
||||||
("switches", "Switche"),
|
("switches", "Switche"),
|
||||||
("credentials", "Zugangsdaten (inkl. bekannter SSH-Host-Keys aus known_hosts)"),
|
("credentials", "Zugangsdaten"),
|
||||||
("users", "Benutzer (lokal)"),
|
("users", "Benutzer"),
|
||||||
("groups", "Custom-Gruppen"),
|
("groups", "Gruppen"),
|
||||||
("ldap", "LDAP/AD-Einstellungen"),
|
("ldap", "LDAP/AD"),
|
||||||
("dhcp", "DHCP-Einstellungen"),
|
("dhcp", "DHCP"),
|
||||||
("nginx", "NGINX-Einstellungen (Domain, Ports, SSL/HSTS — ohne Zertifikat/Schlüssel)"),
|
("nginx", "NGINX"),
|
||||||
("logs", "Auditlog (alle aktuell in der Datenbank vorhandenen Einträge)"),
|
("logs", "Auditlog"),
|
||||||
]
|
]
|
||||||
EXPORT_SECTION_LABELS = dict(EXPORT_SECTIONS)
|
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.",
|
||||||
|
"ldap": "Gesichert werden Server-/Bind-Einstellungen, die automatischen AD-Gruppen- und Fileshare-Zuordnungen sowie für bereits bekannte AD-Benutzer deren Sperrstatus und individuelle Gruppenzuweisungen — damit ein gesperrtes AD-Konto auf dem Zielsystem gesperrt bleibt und manuell vergebene Rechte erhalten bleiben, statt beim nächsten Login verloren zu gehen.",
|
||||||
|
"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"}
|
IMPORT_EXPORT_ADMIN_ONLY_SECTIONS = {"users", "groups", "ldap"}
|
||||||
|
|
||||||
LDAP_SETTING_KEYS = [
|
LDAP_SETTING_KEYS = [
|
||||||
@@ -5526,9 +5538,18 @@ def _export_ldap(conn):
|
|||||||
(frische Installation vergibt Gruppen-IDs neu). Exportiert wird daher
|
(frische Installation vergibt Gruppen-IDs neu). Exportiert wird daher
|
||||||
der Gruppenname (analog _export_switches()' credential_name), _import_ldap
|
der Gruppenname (analog _export_switches()' credential_name), _import_ldap
|
||||||
löst ihn auf dem Zielsystem wieder zur dortigen ID auf -- gleiches
|
löst ihn auf dem Zielsystem wieder zur dortigen ID auf -- gleiches
|
||||||
"admin"/g.name-Mapping wie in _ldap_group_mappings() weiter oben."""
|
"admin"/g.name-Mapping wie in _ldap_group_mappings() weiter oben.
|
||||||
|
|
||||||
|
ad_user_states: Sperrstatus + aktuelle Gruppenzugehörigkeit (inkl.
|
||||||
|
Admin-Sentinel) je bereits bekanntem AD-Benutzer -- bewusst hier bei
|
||||||
|
LDAP statt bei "users" (das AD-Konten komplett ausschließt, siehe
|
||||||
|
_export_users()), da es explizit um AD-Konten geht: ein gesperrtes
|
||||||
|
AD-Konto soll auf dem Zielsystem gesperrt bleiben (nicht durch die
|
||||||
|
Migration wieder hereinkommen), und individuell/manuell vergebene
|
||||||
|
Rechte sollen erhalten bleiben statt beim nächsten Login verloren zu
|
||||||
|
gehen."""
|
||||||
settings_data = {k: get_setting(k) for k in LDAP_SETTING_KEYS if get_setting(k) not in (None, "")}
|
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": [], "fileshare_mappings": []}
|
data = {"settings": settings_data, "group_mappings": [], "fileshare_mappings": [], "ad_user_states": []}
|
||||||
bind = conn.execute(
|
bind = conn.execute(
|
||||||
"SELECT username, password FROM service_accounts WHERE purpose=?", (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,)
|
"SELECT username, password FROM service_accounts WHERE purpose=?", (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@@ -5543,7 +5564,19 @@ def _export_ldap(conn):
|
|||||||
data["fileshare_mappings"] = [dict(m) for m in conn.execute(
|
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"
|
"SELECT ad_group_dn, ad_group_name, share_label, share_unc FROM ldap_fileshare_mappings"
|
||||||
).fetchall()]
|
).fetchall()]
|
||||||
if not (settings_data or "bind_username" in data or data["group_mappings"] or data["fileshare_mappings"]):
|
ad_users = conn.execute(
|
||||||
|
"SELECT id, username, is_admin, is_locked FROM users WHERE auth_source='ldap' AND deleted_at IS NULL"
|
||||||
|
).fetchall()
|
||||||
|
for u in ad_users:
|
||||||
|
groups = [g["name"] for g in conn.execute(
|
||||||
|
"SELECT g.name FROM user_groups ug JOIN groups g ON g.id=ug.group_id "
|
||||||
|
"WHERE ug.user_id=? AND g.deleted_at IS NULL", (u["id"],)
|
||||||
|
).fetchall()]
|
||||||
|
if u["is_admin"]:
|
||||||
|
groups.append("Admin")
|
||||||
|
data["ad_user_states"].append({"username": u["username"], "is_locked": u["is_locked"], "groups": groups})
|
||||||
|
if not (settings_data or "bind_username" in data or data["group_mappings"] or data["fileshare_mappings"]
|
||||||
|
or data["ad_user_states"]):
|
||||||
return None
|
return None
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -5580,17 +5613,31 @@ NGINX_SETTING_KEYS = ["nginx_server_name", "nginx_http_port", "nginx_https_port"
|
|||||||
|
|
||||||
|
|
||||||
def _export_nginx(conn):
|
def _export_nginx(conn):
|
||||||
"""Exportiert nur portable Konfiguration (Domain, Ports, SSL/HSTS-
|
"""Exportiert Domain/Ports/SSL-HSTS-Schalter sowie, falls vorhanden,
|
||||||
Schalter) -- bewusst OHNE ssl_source, TLS-Zertifikat/Schlüssel oder
|
das aktuell hinterlegte TLS-Zertifikat+Schlüssel selbst (Base64, da
|
||||||
Let's-Encrypt-/certbot-Zustand: das sind entweder host-gebundene reale
|
JSON keine Binärdaten kann) -- anders als bei avatar_filename ist das
|
||||||
Dateien (TESM_SSL_CERT_PATH/TESM_SSL_KEY_PATH) oder eine ACME-
|
hier sinnvoll: Zertifikat/Schlüssel liegen bereits vollständig auf
|
||||||
Registrierung für genau diesen Host/diese Domain, kein portabler
|
diesem Host vor und lassen sich 1:1 auf ein Zielsystem übertragen,
|
||||||
Einstellungswert (gleiche Begründung wie bei avatar_filename in
|
genau wie andere Geheimnisse (Zugangsdaten-Passwörter, LDAP-Bind-
|
||||||
_export_users())."""
|
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, "")}
|
settings_data = {k: get_setting(k) for k in NGINX_SETTING_KEYS if get_setting(k) not in (None, "")}
|
||||||
if not settings_data:
|
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 None
|
||||||
return {"settings": settings_data}
|
return data
|
||||||
|
|
||||||
|
|
||||||
def _export_logs(conn):
|
def _export_logs(conn):
|
||||||
@@ -5775,6 +5822,39 @@ def _import_ldap(conn, data):
|
|||||||
(m["ad_group_dn"], m["ad_group_name"], m["share_label"], m["share_unc"]),
|
(m["ad_group_dn"], m["ad_group_name"], m["share_label"], m["share_unc"]),
|
||||||
)
|
)
|
||||||
n += 1
|
n += 1
|
||||||
|
for u in data.get("ad_user_states", []):
|
||||||
|
groups = u.get("groups", [])
|
||||||
|
is_admin = 1 if "Admin" in groups else 0
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM users WHERE username=? AND auth_source='ldap'", (u["username"],)
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
user_id = existing["id"]
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET is_locked=?, is_admin=? WHERE id=?",
|
||||||
|
(u.get("is_locked", 0), is_admin, user_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Konto hat sich auf dem Zielsystem noch nie eingeloggt -- ein
|
||||||
|
# Platzhalter-Passwort-Hash wird nie geprüft (der Login-Zweig
|
||||||
|
# für auth_source='ldap' authentifiziert immer gegen AD, siehe
|
||||||
|
# login()), er füllt nur das NOT-NULL-Feld. Wichtig ist einzig,
|
||||||
|
# dass is_locked schon VOR dem ersten Login greift.
|
||||||
|
placeholder_hash = bcrypt.generate_password_hash(secrets.token_hex(32)).decode("utf-8")
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO users (username, password, is_admin, auth_source, is_locked) VALUES (?, ?, ?, 'ldap', ?)",
|
||||||
|
(u["username"], placeholder_hash, is_admin, u.get("is_locked", 0)),
|
||||||
|
)
|
||||||
|
user_id = cur.lastrowid
|
||||||
|
for gname in groups:
|
||||||
|
if gname == "Admin":
|
||||||
|
continue
|
||||||
|
grow = conn.execute(
|
||||||
|
"SELECT id FROM groups WHERE name=? AND deleted_at IS NULL", (gname,)
|
||||||
|
).fetchone()
|
||||||
|
if grow:
|
||||||
|
conn.execute("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", (user_id, grow["id"]))
|
||||||
|
n += 1
|
||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
@@ -5835,6 +5915,31 @@ def _import_nginx(conn, data):
|
|||||||
(key, value),
|
(key, value),
|
||||||
)
|
)
|
||||||
n += 1
|
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
|
return n
|
||||||
|
|
||||||
|
|
||||||
@@ -5983,6 +6088,7 @@ def settings_import_preview(token):
|
|||||||
import_preview={"token": token, "sections": preview_sections},
|
import_preview={"token": token, "sections": preview_sections},
|
||||||
export_sections=EXPORT_SECTIONS,
|
export_sections=EXPORT_SECTIONS,
|
||||||
admin_only_sections=IMPORT_EXPORT_ADMIN_ONLY_SECTIONS,
|
admin_only_sections=IMPORT_EXPORT_ADMIN_ONLY_SECTIONS,
|
||||||
|
export_section_hints=EXPORT_SECTION_HINTS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
data-confirm="Ausgewählte Kategorien wirklich importieren? Bestehende Einträge mit gleichem Namen/Hostname/MAC/Benutzernamen werden überschrieben.">
|
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 }}">
|
<input type="hidden" name="import_token" value="{{ import_preview.token }}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<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>
|
<label>Was importieren?</label>
|
||||||
<div class="check-list">
|
<div class="check-list">
|
||||||
{% for s in import_preview.sections %}
|
{% for s in import_preview.sections %}
|
||||||
<label class="check-row">
|
<label class="check-row">
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
{% if s.key not in admin_only_sections or current_user.is_admin %}checked{% endif %}
|
{% if s.key not in admin_only_sections or current_user.is_admin %}checked{% endif %}
|
||||||
{% if s.admin_only and not current_user.is_admin %}disabled{% endif %}>
|
{% if s.admin_only and not current_user.is_admin %}disabled{% endif %}>
|
||||||
{{ s.label }} <span class="text-faint">({{ s.count }})</span>
|
{{ s.label }} <span class="text-faint">({{ s.count }})</span>
|
||||||
{% if s.admin_only %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %}
|
{% if export_section_hints and s.key in export_section_hints %}{{ hi.hint_icon(export_section_hints[s.key], s.label) }}{% endif %}
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
{% if current_user.has_permission('settings_importexport.export') %}
|
{% if current_user.has_permission('settings_importexport.export') %}
|
||||||
<form method="post" action="{{ url_for('export_data') }}">
|
<form method="post" action="{{ url_for('export_data') }}">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<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>
|
<label>Was exportieren?</label>
|
||||||
<div class="check-list">
|
<div class="check-list">
|
||||||
{% for key, label in export_sections %}
|
{% for key, label in export_sections %}
|
||||||
<label class="check-row">
|
<label class="check-row">
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
{% if key not in admin_only_sections or current_user.is_admin %}checked{% endif %}
|
{% 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 %}>
|
{% if key in admin_only_sections and not current_user.is_admin %}disabled{% endif %}>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
{% if key in admin_only_sections %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %}
|
{% if export_section_hints and key in export_section_hints %}{{ hi.hint_icon(export_section_hints[key], label) }}{% endif %}
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user