Compare commits
3
Commits
v1.1.10
...
8577cd24cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8577cd24cd | ||
|
|
877903bd77 | ||
|
|
2b05d012ee |
+1
-1
@@ -1 +1 @@
|
||||
1.1.10
|
||||
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"))
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
{% 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 %}
|
||||
|
||||
Reference in New Issue
Block a user