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 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-10 18:46:50 +02:00
co-authored by Claude Opus 5.5
parent a60daf0039
commit 278180c541
3 changed files with 43 additions and 5 deletions
+4 -2
View File
@@ -24,6 +24,7 @@ dependencies = [
"pydantic-settings>=2.3", "pydantic-settings>=2.3",
"itsdangerous>=2.2", "itsdangerous>=2.2",
"httpx>=0.27", "httpx>=0.27",
"paramiko>=3.4",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -31,6 +32,7 @@ dev = [
"pytest>=8.2", "pytest>=8.2",
"ruff>=0.6", "ruff>=0.6",
"mypy>=1.11", "mypy>=1.11",
"types-paramiko",
] ]
[project.scripts] [project.scripts]
@@ -52,14 +54,14 @@ target-version = "py312"
src = ["src", "tests"] src = ["src", "tests"]
[tool.ruff.lint] [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 = [ ignore = [
"S603", # subprocess ohne shell=True ist genau das gewünschte Muster "S603", # subprocess ohne shell=True ist genau das gewünschte Muster
"RUF001", "RUF002", "RUF003", # deutsche Texte mit Typografie "RUF001", "RUF002", "RUF003", # deutsche Texte mit Typografie
] ]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101", "S105", "S106", "S108"] "tests/**" = ["S101", "S105", "S106", "S107", "S108"]
"src/netmover/migrations/**" = ["E501"] "src/netmover/migrations/**" = ["E501"]
[tool.mypy] [tool.mypy]
@@ -23,9 +23,22 @@ def upgrade() -> None:
"credentials", "credentials",
sa.Column("id", sa.Integer(), nullable=False), sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=100), 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("username", sa.String(length=255), nullable=False),
sa.Column("domain", sa.String(length=255), nullable=True), sa.Column("domain", sa.String(length=255), nullable=True),
sa.Column("secret_enc", sa.Text(), nullable=False), 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.Column("created_at", netmover.models.UTCDateTime(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_credentials")), sa.PrimaryKeyConstraint("id", name=op.f("pk_credentials")),
sa.UniqueConstraint("name", name=op.f("uq_credentials_name")), sa.UniqueConstraint("name", name=op.f("uq_credentials_name")),
@@ -47,6 +60,7 @@ def upgrade() -> None:
"cifs", "cifs",
"nfs4", "nfs4",
"nfs3", "nfs3",
"sftp",
name="mount_type", name="mount_type",
native_enum=False, native_enum=False,
create_constraint=True, create_constraint=True,
@@ -56,7 +70,9 @@ def upgrade() -> None:
), ),
sa.Column("server", sa.String(length=255), nullable=False), sa.Column("server", sa.String(length=255), nullable=False),
sa.Column("share", sa.String(length=1024), 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("cred_id", sa.Integer(), nullable=True),
sa.Column("uid", sa.Integer(), nullable=True), sa.Column("uid", sa.Integer(), nullable=True),
sa.Column("gid", sa.Integer(), nullable=True), sa.Column("gid", sa.Integer(), nullable=True),
+22 -2
View File
@@ -2,6 +2,10 @@
Abweichungen bzw. Ergänzungen gegenüber dem Mindestumfang aus der Spezifikation: 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. * ``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.state_since`` – für „hängt seit …“ / „gestört seit …“ auf der Statusseite.
* ``mounts.write_ok`` / ``mounts.root_squash`` / ``mounts.last_test_*`` – Ergebnis * ``mounts.write_ok`` / ``mounts.root_squash`` / ``mounts.last_test_*`` – Ergebnis
@@ -93,6 +97,16 @@ class MountType(enum.StrEnum):
CIFS = "cifs" CIFS = "cifs"
NFS4 = "nfs4" NFS4 = "nfs4"
NFS3 = "nfs3" 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): class MountState(enum.StrEnum):
@@ -145,9 +159,13 @@ class Credential(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(100), unique=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)) username: Mapped[str] = mapped_column(String(255))
domain: Mapped[str | None] = 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) created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
# passive_deletes="all": das ORM soll cred_id nicht still auf NULL setzen, # 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")) type: Mapped[MountType] = mapped_column(_enum(MountType, "mount_type"))
server: Mapped[str] = mapped_column(String(255)) server: Mapped[str] = mapped_column(String(255))
share: Mapped[str] = mapped_column(String(1024)) 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, "<typ> <base64>"
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")) cred_id: Mapped[int | None] = mapped_column(ForeignKey("credentials.id", ondelete="RESTRICT"))
uid: Mapped[int | None] = mapped_column(Integer) uid: Mapped[int | None] = mapped_column(Integer)
gid: Mapped[int | None] = mapped_column(Integer) gid: Mapped[int | None] = mapped_column(Integer)