From 278180c541bb00a5834f4fea2014b3cffef0bd0e Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 10 Oct 2026 18:46:50 +0200 Subject: [PATCH] feat: SFTP-Anbindung und Zugangsdatenart im Datenmodell Neuer Anbindungstyp sftp (share = entfernter Basispfad, port, host_key), credentials.kind (password|ssh_key) und public_key. Migration 0001 vor dem ersten Release angepasst. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 6 +++-- .../versions/0001_initial_schema.py | 18 +++++++++++++- src/netmover/models.py | 24 +++++++++++++++++-- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2ba78ab..746d8ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "pydantic-settings>=2.3", "itsdangerous>=2.2", "httpx>=0.27", + "paramiko>=3.4", ] [project.optional-dependencies] @@ -31,6 +32,7 @@ dev = [ "pytest>=8.2", "ruff>=0.6", "mypy>=1.11", + "types-paramiko", ] [project.scripts] @@ -52,14 +54,14 @@ target-version = "py312" src = ["src", "tests"] [tool.ruff.lint] -select = ["E", "F", "W", "I", "B", "UP", "S", "SIM", "RUF", "PTH"] +select = ["E", "F", "W", "I", "B", "UP", "S", "SIM", "RUF"] ignore = [ "S603", # subprocess ohne shell=True ist genau das gewünschte Muster "RUF001", "RUF002", "RUF003", # deutsche Texte mit Typografie ] [tool.ruff.lint.per-file-ignores] -"tests/**" = ["S101", "S105", "S106", "S108"] +"tests/**" = ["S101", "S105", "S106", "S107", "S108"] "src/netmover/migrations/**" = ["E501"] [tool.mypy] diff --git a/src/netmover/migrations/versions/0001_initial_schema.py b/src/netmover/migrations/versions/0001_initial_schema.py index 30376c6..52124a5 100644 --- a/src/netmover/migrations/versions/0001_initial_schema.py +++ b/src/netmover/migrations/versions/0001_initial_schema.py @@ -23,9 +23,22 @@ def upgrade() -> None: "credentials", sa.Column("id", sa.Integer(), nullable=False), sa.Column("name", sa.String(length=100), nullable=False), + sa.Column( + "kind", + sa.Enum( + "password", + "ssh_key", + name="credential_kind", + native_enum=False, + create_constraint=True, + length=24, + ), + nullable=False, + ), sa.Column("username", sa.String(length=255), nullable=False), sa.Column("domain", sa.String(length=255), nullable=True), sa.Column("secret_enc", sa.Text(), nullable=False), + sa.Column("public_key", sa.Text(), nullable=True), sa.Column("created_at", netmover.models.UTCDateTime(), nullable=False), sa.PrimaryKeyConstraint("id", name=op.f("pk_credentials")), sa.UniqueConstraint("name", name=op.f("uq_credentials_name")), @@ -47,6 +60,7 @@ def upgrade() -> None: "cifs", "nfs4", "nfs3", + "sftp", name="mount_type", native_enum=False, create_constraint=True, @@ -56,7 +70,9 @@ def upgrade() -> None: ), sa.Column("server", sa.String(length=255), nullable=False), sa.Column("share", sa.String(length=1024), nullable=False), - sa.Column("mountpoint", sa.String(length=1024), nullable=False), + sa.Column("port", sa.Integer(), nullable=True), + sa.Column("host_key", sa.Text(), nullable=True), + sa.Column("mountpoint", sa.String(length=1024), nullable=True), sa.Column("cred_id", sa.Integer(), nullable=True), sa.Column("uid", sa.Integer(), nullable=True), sa.Column("gid", sa.Integer(), nullable=True), diff --git a/src/netmover/models.py b/src/netmover/models.py index 9e22756..1c5c323 100644 --- a/src/netmover/models.py +++ b/src/netmover/models.py @@ -2,6 +2,10 @@ Abweichungen bzw. Ergänzungen gegenüber dem Mindestumfang aus der Spezifikation: +* Typ ``sftp``: Ziel per SSH/SFTP ohne Kernel-Mount (z. B. Paperless im LXC). + ``share`` ist dort der entfernte Basispfad, dazu ``port`` und ``host_key`` + (beim ersten Test festgehaltener Hostschlüssel, danach verbindlich). +* ``credentials.kind`` (``password`` | ``ssh_key``) und ``credentials.public_key``. * ``credentials.domain`` – Windows-/AD-Freigaben brauchen oft eine Domäne. * ``mounts.state_since`` – für „hängt seit …“ / „gestört seit …“ auf der Statusseite. * ``mounts.write_ok`` / ``mounts.root_squash`` / ``mounts.last_test_*`` – Ergebnis @@ -93,6 +97,16 @@ class MountType(enum.StrEnum): CIFS = "cifs" NFS4 = "nfs4" NFS3 = "nfs3" + SFTP = "sftp" + + @property + def is_kernel_mount(self) -> bool: + return self is not MountType.SFTP + + +class CredentialKind(enum.StrEnum): + PASSWORD = "password" # noqa: S105 – Art der Zugangsdaten, kein Passwort + SSH_KEY = "ssh_key" class MountState(enum.StrEnum): @@ -145,9 +159,13 @@ class Credential(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True) name: Mapped[str] = mapped_column(String(100), unique=True) + kind: Mapped[CredentialKind] = mapped_column( + _enum(CredentialKind, "credential_kind"), default=CredentialKind.PASSWORD + ) username: Mapped[str] = mapped_column(String(255)) domain: Mapped[str | None] = mapped_column(String(255)) - secret_enc: Mapped[str] = mapped_column(Text) + secret_enc: Mapped[str] = mapped_column(Text) # Passwort bzw. privater Schlüssel (Fernet) + public_key: Mapped[str | None] = mapped_column(Text) # nur ssh_key, OpenSSH-Format created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow) # passive_deletes="all": das ORM soll cred_id nicht still auf NULL setzen, @@ -164,7 +182,9 @@ class Mount(Base): type: Mapped[MountType] = mapped_column(_enum(MountType, "mount_type")) server: Mapped[str] = mapped_column(String(255)) share: Mapped[str] = mapped_column(String(1024)) - mountpoint: Mapped[str] = mapped_column(String(1024), unique=True) + port: Mapped[int | None] = mapped_column(Integer) # nur sftp + host_key: Mapped[str | None] = mapped_column(Text) # nur sftp, " " + mountpoint: Mapped[str | None] = mapped_column(String(1024), unique=True) # NULL bei sftp cred_id: Mapped[int | None] = mapped_column(ForeignKey("credentials.id", ondelete="RESTRICT")) uid: Mapped[int | None] = mapped_column(Integer) gid: Mapped[int | None] = mapped_column(Integer)