feat(web): Seite „Schlüssel“ und Schlüsselauswahl in der Anbindung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-10 23:43:40 +02:00
co-authored by Claude Opus 5.5
parent 686542ff6f
commit fb975f1eae
6 changed files with 234 additions and 9 deletions
+102 -6
View File
@@ -45,6 +45,7 @@ from netmover.admin import (
from netmover.app_state import NetMover from netmover.app_state import NetMover
from netmover.config import Settings, get_settings from netmover.config import Settings, get_settings
from netmover.db import session_scope from netmover.db import session_scope
from netmover.keys import delete_key, generate_key, import_key, list_keys, rename_key
from netmover.logging_setup import event, get_logger from netmover.logging_setup import event, get_logger
from netmover.models import ( from netmover.models import (
CollisionPolicy, CollisionPolicy,
@@ -392,10 +393,21 @@ async def mounts_page(request: Request) -> Response:
pubkeys = { pubkeys = {
m.id: m.credential.public_key m.id: m.credential.public_key
for m in mounts for m in mounts
if m.credential is not None and m.credential.public_key if m.credential is not None and m.credential.public_key and not m.credential.managed
}
key_names = {
m.id: m.credential.name
for m in mounts
if m.credential is not None and m.credential.managed
} }
return render( return render(
request, "mounts.html", active="mounts", mounts=mounts, reports=reports, pubkeys=pubkeys request,
"mounts.html",
active="mounts",
mounts=mounts,
reports=reports,
pubkeys=pubkeys,
key_names=key_names,
) )
@@ -409,6 +421,15 @@ def _report_data(raw: str | None) -> dict[str, Any]:
return data return data
def _key_options(request: Request) -> list[tuple[str, str]]:
with nm(request).session_factory() as s:
keys = list_keys(s)
return [("new", "neuer Schlüssel nur für diese Anbindung")] + [
(str(k.id), f"{k.name} ({k.key_type.removeprefix('ssh-')}, {k.fingerprint[7:19]}…)")
for k in keys
]
def _mount_form( def _mount_form(
request: Request, request: Request,
data: MountInput, data: MountInput,
@@ -416,7 +437,11 @@ def _mount_form(
mount: Mount | None = None, mount: Mount | None = None,
status_code: int = 200, status_code: int = 200,
) -> Response: ) -> Response:
pub = mount.credential.public_key if mount is not None and mount.credential else None pub = (
mount.credential.public_key
if mount is not None and mount.credential and not mount.credential.managed
else None
)
return render( return render(
request, request,
"mount_form.html", "mount_form.html",
@@ -426,7 +451,10 @@ def _mount_form(
errors=errors, errors=errors,
mount=mount, mount=mount,
public_key=pub, public_key=pub,
has_secret=bool(mount is not None and mount.credential is not None), has_secret=bool(
mount is not None and mount.credential is not None and not mount.credential.managed
),
key_options=_key_options(request),
) )
@@ -487,7 +515,7 @@ async def mount_test_form(request: Request) -> Response:
existing_id = int(form["mount_id"]) if form.get("mount_id", "").isdigit() else None existing_id = int(form["mount_id"]) if form.get("mount_id", "").isdigit() else None
with state.session_factory() as s: with state.session_factory() as s:
existing = s.get(Mount, existing_id) if existing_id else None existing = s.get(Mount, existing_id) if existing_id else None
cred = credential_for_test(data, existing, state.secrets) cred = credential_for_test(data, existing, state.secrets, s)
cfg = data.config() cfg = data.config()
if data.type is MountType.SFTP and existing is not None: if data.type is MountType.SFTP and existing is not None:
cfg = dataclasses.replace(cfg, host_key=existing.host_key) cfg = dataclasses.replace(cfg, host_key=existing.host_key)
@@ -503,7 +531,7 @@ async def mount_test(request: Request, mount_id: int) -> Response:
if m is None: if m is None:
return HTMLResponse("Anbindung existiert nicht.", status_code=404) return HTMLResponse("Anbindung existiert nicht.", status_code=404)
data = mount_input_from_model(m) data = mount_input_from_model(m)
cred = credential_for_test(data, m, state.secrets) cred = credential_for_test(data, m, state.secrets, s)
from netmover.mounting.manager import MountConfig from netmover.mounting.manager import MountConfig
cfg = MountConfig.from_model(m) cfg = MountConfig.from_model(m)
@@ -563,6 +591,74 @@ async def mount_reset_hostkey(request: Request, mount_id: int) -> Response:
return redirect(f"/mounts#mount-{mount_id}") return redirect(f"/mounts#mount-{mount_id}")
# -- Schlüsselverwaltung ----------------------------------------------------------------------
@protected.get("/keys", response_class=HTMLResponse)
async def keys_page(request: Request) -> Response:
with nm(request).session_factory() as s:
return render(request, "keys.html", active="keys", keys=list_keys(s))
async def _key_action(request: Request, work: Callable[[Any], str]) -> Response:
state = nm(request)
try:
with session_scope(state.session_factory) as s:
flash(request, work(s))
except AdminError as exc:
flash(request, str(exc), "error")
return redirect("/keys")
@protected.post("/keys/generate")
async def key_generate(request: Request) -> Response:
form = _form_dict(await request.form())
secrets = nm(request).secrets
def work(s: Any) -> str:
key = generate_key(s, secrets, form.get("name", ""))
return f"Schlüssel „{key.name}“ erzeugt."
return await _key_action(request, work)
@protected.post("/keys/import")
async def key_import(request: Request) -> Response:
form = _form_dict(await request.form())
secrets = nm(request).secrets
def work(s: Any) -> str:
key = import_key(
s,
secrets,
form.get("name", ""),
form.get("private_key", ""),
form.get("passphrase", ""),
)
return f"Schlüssel „{key.name}“ importiert."
return await _key_action(request, work)
@protected.post("/keys/{key_id}/rename")
async def key_rename(request: Request, key_id: int) -> Response:
form = _form_dict(await request.form())
def work(s: Any) -> str:
rename_key(s, key_id, form.get("name", ""))
return "Schlüssel umbenannt."
return await _key_action(request, work)
@protected.post("/keys/{key_id}/delete")
async def key_delete(request: Request, key_id: int) -> Response:
def work(s: Any) -> str:
return f"Schlüssel „{delete_key(s, key_id)}“ gelöscht."
return await _key_action(request, work)
# -- Regeln ----------------------------------------------------------------------------------- # -- Regeln -----------------------------------------------------------------------------------
+1
View File
@@ -17,6 +17,7 @@
<nav class="main"> <nav class="main">
<a href="/" class="{{ 'active' if active == 'status' }}">Status</a> <a href="/" class="{{ 'active' if active == 'status' }}">Status</a>
<a href="/mounts" class="{{ 'active' if active == 'mounts' }}">Anbindungen</a> <a href="/mounts" class="{{ 'active' if active == 'mounts' }}">Anbindungen</a>
<a href="/keys" class="{{ 'active' if active == 'keys' }}">Schlüssel</a>
<a href="/rules" class="{{ 'active' if active == 'rules' }}">Regeln</a> <a href="/rules" class="{{ 'active' if active == 'rules' }}">Regeln</a>
<a href="/log" class="{{ 'active' if active == 'log' }}">Protokoll</a> <a href="/log" class="{{ 'active' if active == 'log' }}">Protokoll</a>
<a href="/settings" class="{{ 'active' if active == 'settings' }}">Einstellungen</a> <a href="/settings" class="{{ 'active' if active == 'settings' }}">Einstellungen</a>
+56
View File
@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% from "macros.html" import csrf_field %}
{% block title %}Schlüssel{% endblock %}
{% block content %}
<h1>SSH-Schlüssel</h1>
<p class="muted small">Gespeicherte Schlüssel für SFTP-Anbindungen. Ein Schlüssel kann von mehreren
Anbindungen benutzt werden; den Benutzernamen legt jede Anbindung selbst fest. Private Schlüssel
liegen verschlüsselt in der Datenbank und werden nie angezeigt.</p>
{% if not keys %}<p class="muted">Noch keine Schlüssel gespeichert.</p>{% endif %}
<div class="stack">
{% for k in keys %}
<div class="card" id="key-{{ k.id }}">
<div class="row spread">
<div><strong>{{ k.name }}</strong> <span class="badge">{{ k.key_type.removeprefix('ssh-') }}</span>
{% if k.used_by %}<span class="badge ok">benutzt von {{ k.used_by|join(', ') }}</span>{% else %}<span class="badge">unbenutzt</span>{% endif %}</div>
<div class="row">
<form class="inline row" method="post" action="/keys/{{ k.id }}/rename">{{ csrf_field(csrf) }}
<input type="text" name="name" value="{{ k.name }}" aria-label="Neuer Name" required maxlength="100">
<button class="small">Umbenennen</button></form>
<form class="inline" method="post" action="/keys/{{ k.id }}/delete" data-confirm="Schlüssel „{{ k.name }}“ löschen?">{{ csrf_field(csrf) }}
<button class="small danger" {{ 'disabled' if k.used_by }}>Löschen</button></form>
</div>
</div>
<div class="small muted">Fingerabdruck <span class="mono">{{ k.fingerprint }}</span> · angelegt {{ k.created_at|localdt }}</div>
<details class="small"><summary>Öffentlicher Schlüssel / authorized_keys</summary>
<div class="field"><label>Zeile für <code>~/.ssh/authorized_keys</code> (nur SFTP erlaubt, empfohlen)</label>
<pre class="mono card" id="auth-{{ k.id }}">{{ k.authorized_keys_line }}</pre>
<button class="small" type="button" data-copy="auth-{{ k.id }}">kopieren</button></div>
<div class="field"><label>Öffentlicher Schlüssel</label>
<pre class="mono card" id="pub-{{ k.id }}">{{ k.public_key }}</pre>
<button class="small" type="button" data-copy="pub-{{ k.id }}">kopieren</button></div>
</details>
</div>
{% endfor %}
</div>
<h2>Neuen Schlüssel erzeugen</h2>
<form class="form card row" method="post" action="/keys/generate">{{ csrf_field(csrf) }}
<input type="text" name="name" placeholder="Name, z. B. paperless-lxc" aria-label="Name" required maxlength="100">
<button class="primary">Ed25519-Schlüssel erzeugen</button>
</form>
<h2>Vorhandenen Schlüssel importieren</h2>
<form class="form card stack" method="post" action="/keys/import">{{ csrf_field(csrf) }}
<div class="fields">
<div class="field"><label for="k-name">Name</label><input id="k-name" type="text" name="name" required maxlength="100"></div>
<div class="field"><label for="k-pass">Passphrase (falls vorhanden)</label><input id="k-pass" type="password" name="passphrase" autocomplete="off"></div>
<div class="field wide"><label for="k-priv">Privater Schlüssel</label>
<textarea id="k-priv" name="private_key" class="mono" rows="8" autocomplete="off" spellcheck="false"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;…&#10;-----END OPENSSH PRIVATE KEY-----" required></textarea>
<span class="hint">Ed25519, ECDSA oder RSA (mind. 2048 Bit), OpenSSH- oder PEM-Format. Eine Passphrase wird nur zum Entschlüsseln benutzt.</span></div>
</div>
<button>Importieren</button>
</form>
{% endblock %}
+5 -2
View File
@@ -23,7 +23,10 @@
</div></fieldset> </div></fieldset>
<fieldset data-show-for="type:cifs sftp"><legend>Anmeldung</legend><div class="fields"> <fieldset data-show-for="type:cifs sftp"><legend>Anmeldung</legend><div class="fields">
<div data-show-for="type:sftp">{{ select('auth', 'Anmeldeart', [('ssh_key', 'SSH-Schlüssel (NetMover erzeugt ihn)'), ('password', 'Passwort')], d.auth.value, errors=errors) }}</div> <div data-show-for="type:sftp">{{ select('auth', 'Anmeldeart', [('ssh_key', 'SSH-Schlüssel'), ('password', 'Passwort')], d.auth.value, errors=errors) }}</div>
<div data-show-for="type:sftp"><div data-show-for="auth:ssh_key">
{{ select('key_id', 'Schlüssel', key_options, d.key_id, errors=errors, hint='Gespeicherte Schlüssel verwalten unter „Schlüssel“.') }}
</div></div>
{{ field('username', 'Benutzername', d.username, errors=errors, attrs='autocomplete="off"', hint='Bei CIFS leer lassen für Gastzugang') }} {{ field('username', 'Benutzername', d.username, errors=errors, attrs='autocomplete="off"', hint='Bei CIFS leer lassen für Gastzugang') }}
<div data-show-for="type:cifs">{{ field('domain', 'Domäne (optional)', d.domain, errors=errors) }}</div> <div data-show-for="type:cifs">{{ field('domain', 'Domäne (optional)', d.domain, errors=errors) }}</div>
<div class="field{{ ' has-error' if errors.get('password') }}" id="pw-field"> <div class="field{{ ' has-error' if errors.get('password') }}" id="pw-field">
@@ -33,7 +36,7 @@
<span class="hint">{{ 'Leer lassen = unverändert. ' if has_secret }}Bei SFTP mit Schlüssel leer lassen.</span> <span class="hint">{{ 'Leer lassen = unverändert. ' if has_secret }}Bei SFTP mit Schlüssel leer lassen.</span>
</div> </div>
</div> </div>
{% if public_key %}<div class="field wide"><label>Öffentlicher Schlüssel (in ~/.ssh/authorized_keys des Zielbenutzers eintragen)</label> {% if public_key %}<div class="field wide"><label>Öffentlicher Schlüssel dieser Anbindung (in ~/.ssh/authorized_keys des Zielbenutzers eintragen)</label>
<pre class="mono card" id="pubkey">{{ public_key }}</pre><button class="small" type="button" data-copy="pubkey">kopieren</button></div>{% endif %} <pre class="mono card" id="pubkey">{{ public_key }}</pre><button class="small" type="button" data-copy="pubkey">kopieren</button></div>{% endif %}
</fieldset> </fieldset>
+3
View File
@@ -32,6 +32,9 @@
<div class="small">Hostschlüssel: {% if m.host_key %}<span class="mono">{{ m.host_key.split()[0] }}</span> festgehalten <div class="small">Hostschlüssel: {% if m.host_key %}<span class="mono">{{ m.host_key.split()[0] }}</span> festgehalten
<form class="inline" method="post" action="/mounts/{{ m.id }}/reset-hostkey" data-confirm="Hostschlüssel zurücksetzen? Nur tun, wenn der Server neu aufgesetzt wurde.">{{ csrf_field(csrf) }}<button class="small">zurücksetzen</button></form> <form class="inline" method="post" action="/mounts/{{ m.id }}/reset-hostkey" data-confirm="Hostschlüssel zurücksetzen? Nur tun, wenn der Server neu aufgesetzt wurde.">{{ csrf_field(csrf) }}<button class="small">zurücksetzen</button></form>
{% else %}<span class="text-warn">noch nicht bestätigt – bitte „Testen“</span>{% endif %}</div> {% else %}<span class="text-warn">noch nicht bestätigt – bitte „Testen“</span>{% endif %}</div>
{% if key_names.get(m.id) %}
<div class="small">Anmeldung mit gespeichertem Schlüssel <a href="/keys">„{{ key_names[m.id] }}“</a></div>
{% endif %}
{% if pubkeys.get(m.id) %} {% if pubkeys.get(m.id) %}
<details class="small"><summary>Öffentlicher Schlüssel für authorized_keys</summary> <details class="small"><summary>Öffentlicher Schlüssel für authorized_keys</summary>
<pre class="mono" id="pub-{{ m.id }}">{{ pubkeys[m.id] }}</pre> <pre class="mono" id="pub-{{ m.id }}">{{ pubkeys[m.id] }}</pre>
+67 -1
View File
@@ -154,7 +154,16 @@ def test_security_headers(client: TestClient) -> None:
def test_no_inline_scripts_or_styles(client: TestClient) -> None: def test_no_inline_scripts_or_styles(client: TestClient) -> None:
for url in ("/", "/mounts", "/mounts/new", "/rules", "/rules/new", "/log", "/settings"): for url in (
"/",
"/mounts",
"/mounts/new",
"/keys",
"/rules",
"/rules/new",
"/log",
"/settings",
):
html = client.get(url).text html = client.get(url).text
assert "style=" not in html, url assert "style=" not in html, url
assert not re.search(r"<script(?![^>]*\bsrc=)", html), url assert not re.search(r"<script(?![^>]*\bsrc=)", html), url
@@ -555,3 +564,60 @@ def test_export_excludes_secrets_and_import_roundtrip(
files={"file": ("export.json", text, "application/json")}, files={"file": ("export.json", text, "application/json")},
) )
assert "Übersprungen" in c2.get("/settings").text assert "Übersprungen" in c2.get("/settings").text
# -- Schlüsselverwaltung -------------------------------------------------------------------
def test_keys_page_generate_import_select_delete(client: TestClient, state: NetMover) -> None:
from netmover.crypto import generate_ssh_keypair
r = post(client, "/keys/generate", {"name": "paperless-lxc"})
assert r.status_code == 303
private, public = generate_ssh_keypair()
r = post(client, "/keys/import", {"name": "alt", "private_key": private, "passphrase": ""})
assert r.status_code == 303
page = client.get("/keys").text
assert "paperless-lxc" in page and "alt" in page
assert public.split()[1] in page # öffentlicher Teil sichtbar
for line in private.splitlines()[1:-1]: # keine Zeile des privaten Schlüssels
assert line not in page
assert "restrict,command=&#34;/usr/lib/openssh/sftp-server&#34;" in page
with state.session_factory() as s:
key_id = s.scalar(select(Credential.id).where(Credential.name == "alt"))
form = client.get("/mounts/new").text
assert f'value="{key_id}"' in form and "neuer Schlüssel nur für diese Anbindung" in form
r = post(
client,
"/mounts/new",
{
"name": "paperless",
"type": "sftp",
"server": "10.0.0.5",
"share": "/consume",
"port": "22",
"auth": "ssh_key",
"key_id": str(key_id),
"username": "root",
"enabled": "on",
},
)
assert r.status_code == 303, r.text
mounts = client.get("/mounts").text
assert "gespeichertem Schlüssel" in mounts and "„alt“" in mounts
edit = client.get(f"/mounts/{_mount_id(state, 'paperless')}/edit").text
assert f'<option value="{key_id}" selected>' in edit and 'value="root"' in edit
post(client, f"/keys/{key_id}/delete")
assert "noch benutzt" in client.get("/keys").text
post(client, f"/mounts/{_mount_id(state, 'paperless')}/delete")
post(client, f"/keys/{key_id}/delete")
page = client.get("/keys").text
assert "gelöscht" in page and ">alt<" not in page
def test_key_import_error_is_shown(client: TestClient) -> None:
post(client, "/keys/import", {"name": "x", "private_key": "kein schlüssel", "passphrase": ""})
assert "Kein lesbarer privater Schlüssel" in client.get("/keys").text