(feature) Generate filesystem-backed SSH credentials

Add filesystem-backed SSH credentials for the native systemd deployment
path. Generated keys are stored below POBSYNC_HOME with 0600
permissions, while Django keeps the public key, fingerprint, path, and
selection metadata.

Add a Django SSH key generation view, delete action for unused generated
keys, and a management command used by the installer to ensure a default
backup key exists.

Update runtime config to use generated key paths directly as IdentityFile,
extend host checks to verify key readability, and keep legacy uploaded
keys available for compatibility.
This commit is contained in:
2026-05-19 19:41:40 +02:00
parent ccacad3d37
commit df3dcc47c9
18 changed files with 483 additions and 24 deletions

View File

@@ -7,6 +7,7 @@ from pobsync.snapshot_meta import resolve_host_root
from .models import GlobalConfig, HostConfig
from .self_check import SelfCheck
from .ssh_keys import identity_path
HOST_BACKUP_SUBDIRS = ("scheduled", "manual", ".incomplete")
@@ -43,13 +44,15 @@ def collect_host_checks(host: HostConfig, global_config: GlobalConfig | None = N
)
credential = host.ssh_credential or global_config.default_ssh_credential
checks.append(
SelfCheck(
"Host SSH credential",
"ok" if credential else "warning",
str(credential) if credential else "No host or global SSH credential selected.",
)
)
if credential is None:
checks.append(SelfCheck("Host SSH credential", "warning", "No host or global SSH credential selected."))
else:
checks.append(SelfCheck("Host SSH credential", "ok", str(credential)))
if credential.key_path:
key_path = identity_path(credential)
checks.append(
_host_path_check("Host SSH key file", key_path, must_exist=True, must_be_writable=False, must_be_readable=True)
)
host_root = resolve_host_root(global_config.backup_root, host.host)
checks.append(_host_path_check("Host backup root", host_root, must_exist=True, must_be_writable=True))
@@ -58,7 +61,14 @@ def collect_host_checks(host: HostConfig, global_config: GlobalConfig | None = N
return checks
def _host_path_check(name: str, path: Path, *, must_exist: bool, must_be_writable: bool) -> SelfCheck:
def _host_path_check(
name: str,
path: Path,
*,
must_exist: bool,
must_be_writable: bool,
must_be_readable: bool = False,
) -> SelfCheck:
if must_exist and not path.exists():
return SelfCheck(name, "failed", f"{path} does not exist.")
target = path if path.exists() else path.parent
@@ -66,4 +76,6 @@ def _host_path_check(name: str, path: Path, *, must_exist: bool, must_be_writabl
return SelfCheck(name, "failed", f"{target} does not exist.")
if must_be_writable and not os.access(target, os.W_OK):
return SelfCheck(name, "failed", f"{target} is not writable by this process.")
if must_be_readable and not os.access(target, os.R_OK):
return SelfCheck(name, "failed", f"{target} is not readable by this process.")
return SelfCheck(name, "ok", str(path))