diff --git a/CHANGELOG.md b/CHANGELOG.md index 45eddb9..72c9a4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`. ## [Unreleased] +## [0.8.0] – 2026-10-12 + +### Hinzugefügt +- **Anbindungen kopieren** (Knopf „Kopieren“): Formular vorausgefüllt, Name + „… (Kopie)“. Eigene Zugangsdaten der Vorlage werden übernommen (eigenes Exemplar), + solange kein neues Passwort eingegeben wird; ein eigener SSH-Schlüssel wird + weiterverwendet; gespeicherte Zugangsdaten bleiben ausgewählt. „Testen“ im + Kopie-Formular nutzt die übernommenen Zugangsdaten. Der SFTP-Hostschlüssel wird nur + bei gleichem Server und Port übernommen. + ## [0.7.0] – 2026-10-12 ### Hinzugefügt @@ -259,7 +269,8 @@ Erste Version. - Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt. -[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.7.0...HEAD +[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.8.0...HEAD +[0.8.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.7.0...v0.8.0 [0.7.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.6.0...v0.7.0 [0.6.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.5.0...v0.6.0 [0.5.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.4.0...v0.5.0 diff --git a/DEVLOG.md b/DEVLOG.md index 08c730f..5ea60a9 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -21,10 +21,25 @@ Zeiten in Ortszeit (Europe/Berlin). | 2026-10-11 | 00:54 | 01:16 | 0:22 | – | SFTP-Quelle, Zugangsdaten, Anmeldung (0.5.0) | | 2026-10-11 | 08:43 | 08:55 | 0:12 | – | Anmeldung je Netz (0.6.0) | | 2026-10-12 | 00:14 | 00:34 | 0:20 | – | Archiv je Regel (0.7.0) | -| **Summe** | | | **3:49** | | | +| 2026-10-12 | 00:46 | 00:55 | 0:09 | – | Anbindungen kopieren (0.8.0) | +| **Summe** | | | **3:58** | | | --- +## 2026-10-12 · 00:46–00:55 · Anbindungen kopieren (0.8.0) + +**Erledigt** – Knopf „Kopieren“, Route `/mounts/{id}/copy` (GET Formular, POST +speichern), `save_mount(copy_from=…)`, Formular-Test mit Vorlage. 7 Tests (357 gesamt); +Gegenprobe ohne Übernahme der Zugangsdaten: 3 Tests schlagen an. + +**Entscheidungen** +- Eigene Zugangsdaten werden als eigenes Exemplar kopiert (nicht geteilt) – eigene + Zugangsdaten verschwinden mit ihrer Anbindung, eine geteilte Nutzung wäre + dafür der Fall „Zugangsdaten“. +- Eigenes Schlüsselpaar bleibt gleich (schon in `authorized_keys`). +- Hostschlüssel nur bei gleichem Server/Port übernehmen, sonst würde der Test an + einem anderen Server mit „Hostschlüssel geändert“ scheitern. + ## 2026-10-12 · 00:14–00:34 · Archiv je Regel (0.7.0) **Erledigt** diff --git a/ROADMAP.md b/ROADMAP.md index 0cbdaf5..51bde57 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,6 +28,7 @@ Legende: ✅ erledigt · 🔨 in Arbeit · 📋 geplant · 💡 Idee (noch nicht | Version | Thema | |---|---| +| 0.8.0 | Anbindungen kopieren inklusive Zugangsdaten | | 0.7.0 | Archiv je Regel: Kopie ins Ziel und ins Archiv, Quelle erst danach löschen | | 0.6.0 | Anmeldung je Netz: ohne Passwort / mit Passwort / abgewiesen, Schutz vor Aussperren | | 0.5.0 | SFTP als Quelle; gespeicherte Zugangsdaten für SMB/SFTP-Passwörter (Seite „Zugangsdaten“); Anmeldung abschaltbar (vertrauenswürdige Netze / ganz) | diff --git a/compose.yml b/compose.yml index 87131a3..38a3d17 100644 --- a/compose.yml +++ b/compose.yml @@ -4,7 +4,7 @@ services: netmover: build: . - image: netmover:0.7.0 + image: netmover:0.8.0 container_name: netmover restart: unless-stopped cap_add: diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 22cb682..8e8c114 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -72,6 +72,22 @@ Stabilitätsfenster dort mindestens 2 Sekunden betragen. | **Beim Start automatisch einhängen** | Nach einem Neustart des Servers wieder einhängen. Fällt das NAS aus, versucht NetMover es außerdem jede Minute erneut. | | **Aktiv** | Deaktivierte Anbindungen werden von Regeln nicht benutzt. | +### Kopieren {#anbindung-kopieren} + +Für eine ähnliche Anbindung – etwa eine zweite Freigabe auf demselben NAS – bei der +Vorlage auf **Kopieren** klicken. Das Formular ist mit allen Werten vorausgefüllt +(Name „… (Kopie)“); meist muss nur die Freigabe geändert werden. + +- **Zugangsdaten werden übernommen**, solange das Passwortfeld leer bleibt. Die Kopie + bekommt ein eigenes Exemplar – das Löschen der Vorlage betrifft sie nicht. + Gespeicherte Zugangsdaten sind einfach vorausgewählt. +- Ein **eigener SSH-Schlüssel** der Vorlage wird weiter benutzt; ein neuer Eintrag in + `authorized_keys` ist nicht nötig. +- **Testen** funktioniert im Kopie-Formular bereits mit den übernommenen Zugangsdaten. + +Tipp: Nutzen mehrere Anbindungen dieselben Zugangsdaten, lohnt sich ein Eintrag unter +[Zugangsdaten](#zugangsdaten) – dann gilt ein neues Passwort sofort überall. + ### Testen {#anbindung-testen} **Testen** prüft drei Dinge einzeln und sagt im Klartext, was nicht klappt: diff --git a/src/netmover/__init__.py b/src/netmover/__init__.py index b627107..b847c1a 100644 --- a/src/netmover/__init__.py +++ b/src/netmover/__init__.py @@ -1,3 +1,3 @@ """NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben.""" -__version__ = "0.7.0" +__version__ = "0.8.0" diff --git a/src/netmover/admin.py b/src/netmover/admin.py index 581e086..f64a648 100644 --- a/src/netmover/admin.py +++ b/src/netmover/admin.py @@ -294,7 +294,11 @@ def save_mount( mounts: MountManager, data: MountInput, mount_id: int | None = None, + copy_from: Mount | None = None, ) -> tuple[Mount | None, Errors]: + """``copy_from``: Vorlage beim Kopieren – eigene Zugangsdaten (Passwort bzw. + Schlüsselpaar) werden übernommen, solange kein neues Passwort eingegeben ist; + die Kopie bekommt ein eigenes Exemplar.""" errors: Errors = {} m = s.get(Mount, mount_id) if mount_id is not None else None if mount_id is not None and m is None: @@ -349,6 +353,14 @@ def save_mount( setattr(m, f, getattr(data, f)) if not data.type.is_kernel_mount: m.auto_mount = False + if ( + mount_id is None + and copy_from is not None + and data.type is MountType.SFTP + and copy_from.type is MountType.SFTP + and (copy_from.server, copy_from.port) == (data.server, data.port) + ): + m.host_key = copy_from.host_key # gleicher Server – Hostschlüssel bleibt gültig # Zugangsdaten: entweder ein gespeicherter Schlüssel aus der Schlüsselverwaltung # (geteilt, wird hier nie verändert) oder eigene Zugangsdaten nur dieser Anbindung. @@ -386,21 +398,32 @@ def save_mount( else: m.username = None cred = own + template = ( + copy_from.credential + if copy_from is not None + and copy_from.credential is not None + and not copy_from.credential.managed + and copy_from.credential.kind is data.auth + else None + ) if cred is None or cred.kind is not data.auth: - if data.auth is CredentialKind.SSH_KEY: - private, public = generate_ssh_keypair(f"netmover@{m.slug}") - secret, pub = private, public + if template is not None and (data.auth is CredentialKind.SSH_KEY or not data.password): + # Kopie: dasselbe Passwort bzw. Schlüsselpaar (steht schon in authorized_keys) + secret_enc, pub = template.secret_enc, template.public_key + elif data.auth is CredentialKind.SSH_KEY: + private, pub = generate_ssh_keypair(f"netmover@{m.slug}") + secret_enc = secrets.encrypt(private) else: if not data.password: errors["password"] = "Passwort fehlt." # noqa: S105 s.rollback() return None, errors - secret, pub = data.password, None + secret_enc, pub = secrets.encrypt(data.password), None if cred is None: cred = Credential(name=f"mount:{m.slug}", managed=False) s.add(cred) cred.kind = data.auth - cred.secret_enc = secrets.encrypt(secret) + cred.secret_enc = secret_enc cred.public_key = pub elif data.password and data.auth is CredentialKind.PASSWORD: cred.secret_enc = secrets.encrypt(data.password) @@ -421,6 +444,16 @@ def save_mount( return m, errors +def copy_name(s: Session, name: str) -> str: + """„NAS (Kopie)“, „NAS (Kopie 2)“, … – frei in der Datenbank.""" + base = f"{name} (Kopie)"[:100] + candidate, n = base, 1 + while s.scalar(select(Mount.id).where(Mount.name == candidate)) is not None: + n += 1 + candidate = f"{name} (Kopie {n})"[:100] + return candidate + + def delete_mount(s: Session, mounts: MountManager, mount_id: int) -> None: m = s.get(Mount, mount_id) if m is None: diff --git a/src/netmover/web/app.py b/src/netmover/web/app.py index 77821f7..44e05d8 100644 --- a/src/netmover/web/app.py +++ b/src/netmover/web/app.py @@ -38,6 +38,7 @@ from netmover.admin import ( AdminError, MountInput, RuleInput, + copy_name, credential_for_test, delete_mount, mount_input_from_model, @@ -591,10 +592,12 @@ def _mount_form( errors: dict[str, str], mount: Mount | None = None, status_code: int = 200, + copy_from: Mount | None = None, ) -> Response: + owner = mount or copy_from pub = ( - mount.credential.public_key - if mount is not None and mount.credential and not mount.credential.managed + owner.credential.public_key + if owner is not None and owner.credential and not owner.credential.managed else None ) return render( @@ -607,8 +610,9 @@ def _mount_form( mount=mount, public_key=pub, has_secret=bool( - mount is not None and mount.credential is not None and not mount.credential.managed + owner is not None and owner.credential is not None and not owner.credential.managed ), + copy_from=copy_from, key_options=_key_options(request), password_options=_password_options(request), ) @@ -628,16 +632,33 @@ async def mount_edit(request: Request, mount_id: int) -> Response: return _mount_form(request, mount_input_from_model(m), {}, m) +@protected.get("/mounts/{source_id}/copy", response_class=HTMLResponse) +async def mount_copy(request: Request, source_id: int) -> Response: + """Formular für eine neue Anbindung, vorausgefüllt aus einer bestehenden.""" + with nm(request).session_factory() as s: + src = s.get(Mount, source_id) + if src is None: + return redirect("/mounts") + data = mount_input_from_model(src) + data.name = copy_name(s, src.name) + return _mount_form(request, data, {}, copy_from=src) + + @protected.post("/mounts/new", response_class=HTMLResponse) @protected.post("/mounts/{mount_id}/edit", response_class=HTMLResponse) -async def mount_save(request: Request, mount_id: int | None = None) -> Response: +@protected.post("/mounts/{source_id}/copy", response_class=HTMLResponse) +async def mount_save( + request: Request, mount_id: int | None = None, source_id: int | None = None +) -> Response: state = nm(request) data, errors = parse_mount_form(_form_dict(await request.form())) with session_scope(state.session_factory) as s: - existing = s.get(Mount, mount_id) if mount_id is not None else None + copy_from = s.get(Mount, source_id) if source_id is not None else None if not errors: try: - m, errors = save_mount(s, state.secrets, state.mounts, data, mount_id) + m, errors = save_mount( + s, state.secrets, state.mounts, data, mount_id, copy_from=copy_from + ) except AdminError as exc: errors = {"_": str(exc)} m = None @@ -646,7 +667,10 @@ async def mount_save(request: Request, mount_id: int | None = None) -> Response: if errors or m is None: s.rollback() existing = s.get(Mount, mount_id) if mount_id is not None else None - return _mount_form(request, data, errors, existing, status_code=400) + copy_from = s.get(Mount, source_id) if source_id is not None else None + return _mount_form( + request, data, errors, existing, status_code=400, copy_from=copy_from + ) saved_id, name = m.id, m.name is_sftp_key = m.type is MountType.SFTP and data.auth is CredentialKind.SSH_KEY flash(request, f"Anbindung „{name}“ gespeichert.") @@ -668,12 +692,17 @@ async def mount_test_form(request: Request) -> Response: data, errors = parse_mount_form(form) if errors: return render(request, "partials/check_result.html", errors=errors, report=None) - existing_id = int(form["mount_id"]) if form.get("mount_id", "").isdigit() else None + ref = form.get("mount_id") or form.get("copy_from") or "" + existing_id = int(ref) if ref.isdigit() else None with state.session_factory() as s: existing = s.get(Mount, existing_id) if existing_id else None cred = credential_for_test(data, existing, state.secrets, s) 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 + and (existing.server, existing.port) == (data.server, data.port) + ): cfg = dataclasses.replace(cfg, host_key=existing.host_key) report = await run_in_threadpool(state.mounts.check, cfg, cred, None) return render(request, "partials/check_result.html", report=report, errors={}) diff --git a/src/netmover/web/templates/mount_form.html b/src/netmover/web/templates/mount_form.html index 552af92..4d347e3 100644 --- a/src/netmover/web/templates/mount_form.html +++ b/src/netmover/web/templates/mount_form.html @@ -1,12 +1,14 @@ {% extends "base.html" %} {% from "macros.html" import field, select, check %} -{% block title %}{{ 'Anbindung bearbeiten' if mount else 'Neue Anbindung' }}{% endblock %} +{% block title %}{{ 'Anbindung bearbeiten' if mount else ('Anbindung kopieren' if copy_from else 'Neue Anbindung') }}{% endblock %} {% block content %} -
Alle Werte stammen aus „{{ copy_from.name }}“. Zugangsdaten werden übernommen, solange du das Passwort leer lässt – die Kopie bekommt ihr eigenes Exemplar.{% if copy_from.credential and copy_from.credential.public_key and not copy_from.credential.managed %} Der SSH-Schlüssel bleibt derselbe, ein neuer Eintrag in authorized_keys ist nicht nötig.{% endif %}
{{ errors['_'] }}
{% endif %}