From e65537c6debbe57ce3e4ae9abcd5f387a9f75fa1 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Tue, 19 May 2026 14:37:38 +0200 Subject: [PATCH] (feature) Add Django-managed SSH credentials Add SSH credentials as first-class Django data so backup keys can be uploaded through the control panel instead of mounted into containers. Credentials can be selected globally or overridden per host. At runtime the selected key is materialized inside the container with restrictive file permissions and injected into the rsync SSH command via IdentityFile. Known hosts entries are handled the same way when configured. Add control panel views for creating and listing SSH keys, expose the fields in config forms and admin, document the workflow, and cover global and host credential selection with tests. --- README.md | 9 +++ src/pobsync_backend/admin.py | 25 +++++++- src/pobsync_backend/config_source.py | 61 ++++++++++++++++++- src/pobsync_backend/forms.py | 24 +++++++- .../migrations/0006_ssh_credentials.py | 51 ++++++++++++++++ src/pobsync_backend/models.py | 28 +++++++++ .../templates/pobsync_backend/base.html | 3 +- .../pobsync_backend/host_detail.html | 1 + .../pobsync_backend/ssh_credential_form.html | 32 ++++++++++ .../pobsync_backend/ssh_credentials.html | 40 ++++++++++++ .../tests/test_django_config_source.py | 61 ++++++++++++++++++- src/pobsync_backend/tests/test_views.py | 30 ++++++++- src/pobsync_backend/views.py | 31 +++++++++- src/pobsync_server/urls.py | 2 + 14 files changed, 388 insertions(+), 10 deletions(-) create mode 100644 src/pobsync_backend/migrations/0006_ssh_credentials.py create mode 100644 src/pobsync_backend/templates/pobsync_backend/ssh_credential_form.html create mode 100644 src/pobsync_backend/templates/pobsync_backend/ssh_credentials.html diff --git a/README.md b/README.md index f9da799..cd84b6f 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,15 @@ POBSYNC_BACKUP_ROOT=/mnt/backups/pobsync docker compose up --build web scheduler The Django setup UI keeps the backup root fixed at `/backups`; only the Docker mount decides which host directory that points to. +## Django-Managed SSH Keys + +SSH keys can be managed from the Django UI at `/ssh-credentials/`. Add a private key there, optionally paste +`known_hosts` entries, and select the credential either as the global default or as a per-host override. + +When a backup starts, the worker writes the selected key to `/opt/pobsync/state/ssh-credentials//identity` +inside the container with `0600` permissions and injects `IdentityFile` into the rsync SSH command. If `known_hosts` +is configured, the worker also writes a matching `known_hosts` file and injects `UserKnownHostsFile`. + ## Docker With MariaDB ``` diff --git a/src/pobsync_backend/admin.py b/src/pobsync_backend/admin.py index c3b0ab9..5798df7 100644 --- a/src/pobsync_backend/admin.py +++ b/src/pobsync_backend/admin.py @@ -6,7 +6,26 @@ from django.urls import reverse from django.utils.html import format_html from django.utils.http import urlencode -from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord +from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential + + +@admin.register(SshCredential) +class SshCredentialAdmin(admin.ModelAdmin): + list_display = ("name", "has_public_key", "has_known_hosts", "updated_at") + readonly_fields = ("created_at", "updated_at") + search_fields = ("name", "notes") + fieldsets = ( + (None, {"fields": ("name", "private_key", "public_key", "known_hosts", "notes")}), + ("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}), + ) + + @admin.display(boolean=True, description="Public key") + def has_public_key(self, obj: SshCredential) -> bool: + return bool(obj.public_key.strip()) + + @admin.display(boolean=True, description="Known hosts") + def has_known_hosts(self, obj: SshCredential) -> bool: + return bool(obj.known_hosts.strip()) @admin.register(GlobalConfig) @@ -15,7 +34,7 @@ class GlobalConfigAdmin(admin.ModelAdmin): readonly_fields = ("created_at", "updated_at") fieldsets = ( (None, {"fields": ("name", "backup_root", "pobsync_home")}), - ("SSH", {"fields": ("ssh_user", "ssh_port", "ssh_options")}), + ("SSH", {"fields": ("default_ssh_credential", "ssh_user", "ssh_port", "ssh_options")}), ( "Rsync", { @@ -52,7 +71,7 @@ class HostConfigAdmin(admin.ModelAdmin): readonly_fields = ("created_at", "updated_at") fieldsets = ( (None, {"fields": ("host", "address", "enabled")}), - ("SSH override", {"fields": ("ssh_user", "ssh_port")}), + ("SSH override", {"fields": ("ssh_credential", "ssh_user", "ssh_port")}), ("Source", {"fields": ("source_root", "includes", "excludes_add", "excludes_replace")}), ("Rsync override", {"fields": ("rsync_extra_args",)}), ("Retention", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}), diff --git a/src/pobsync_backend/config_source.py b/src/pobsync_backend/config_source.py index a80f070..c916699 100644 --- a/src/pobsync_backend/config_source.py +++ b/src/pobsync_backend/config_source.py @@ -1,12 +1,71 @@ from __future__ import annotations +import os +from pathlib import Path from typing import Any +from django.conf import settings + from pobsync.config.merge import build_effective_config +from pobsync.paths import PobsyncPaths from .config_repository import global_config_data, host_config_data +from .models import GlobalConfig, HostConfig, SshCredential class DjangoConfigSource: def effective_config_for_host(self, host: str) -> dict[str, Any]: - return build_effective_config(global_config_data(), host_config_data(host)) + config = build_effective_config(global_config_data(), host_config_data(host)) + credential = _credential_for_host(host) + if credential is not None: + _attach_credential_options(config, credential) + return config + + +def _credential_for_host(host: str) -> SshCredential | None: + host_config = HostConfig.objects.select_related("ssh_credential").get(host=host, enabled=True) + if host_config.ssh_credential_id: + return host_config.ssh_credential + + global_config = GlobalConfig.objects.select_related("default_ssh_credential").get(name="default") + return global_config.default_ssh_credential + + +def _attach_credential_options(config: dict[str, Any], credential: SshCredential) -> None: + ssh = config.setdefault("ssh", {}) + options = list(ssh.get("options") or []) + paths = _materialize_credential(credential) + if not _has_ssh_option(options, "IdentityFile"): + options.append(f"-oIdentityFile={paths['identity_file']}") + if paths.get("known_hosts") and not _has_ssh_option(options, "UserKnownHostsFile"): + options.append(f"-oUserKnownHostsFile={paths['known_hosts']}") + ssh["options"] = options + + +def _materialize_credential(credential: SshCredential) -> dict[str, str]: + paths = PobsyncPaths(home=Path(settings.POBSYNC_HOME)) + credential_dir = paths.state_dir / "ssh-credentials" / str(credential.pk) + credential_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + os.chmod(credential_dir, 0o700) + + identity_file = credential_dir / "identity" + identity_file.write_text(_with_trailing_newline(credential.private_key), encoding="utf-8") + os.chmod(identity_file, 0o600) + + result = {"identity_file": str(identity_file)} + if credential.known_hosts.strip(): + known_hosts = credential_dir / "known_hosts" + known_hosts.write_text(_with_trailing_newline(credential.known_hosts), encoding="utf-8") + os.chmod(known_hosts, 0o600) + result["known_hosts"] = str(known_hosts) + return result + + +def _has_ssh_option(options: list[str], name: str) -> bool: + prefix = f"-o{name}=" + spaced = f"-o{name} " + return any(option == name or option.startswith(prefix) or option.startswith(spaced) for option in options) + + +def _with_trailing_newline(value: str) -> str: + return value if value.endswith("\n") else f"{value}\n" diff --git a/src/pobsync_backend/forms.py b/src/pobsync_backend/forms.py index 31987ca..3328d9b 100644 --- a/src/pobsync_backend/forms.py +++ b/src/pobsync_backend/forms.py @@ -3,7 +3,7 @@ from __future__ import annotations from django import forms from django.conf import settings -from .models import GlobalConfig, HostConfig, ScheduleConfig +from .models import GlobalConfig, HostConfig, ScheduleConfig, SshCredential from .scheduler import parse_cron_expr @@ -46,6 +46,7 @@ class HostConfigForm(forms.ModelForm): fields = ( "address", "enabled", + "ssh_credential", "ssh_user", "ssh_port", "source_root", @@ -59,6 +60,7 @@ class HostConfigForm(forms.ModelForm): "retention_yearly", ) help_texts = { + "ssh_credential": "Optional. Overrides the global SSH credential for this host.", "ssh_user": "Leave empty to use the global SSH user.", "ssh_port": "Leave empty to use the global SSH port.", "source_root": "Leave empty to use the global default source root.", @@ -84,6 +86,7 @@ class GlobalConfigForm(forms.ModelForm): model = GlobalConfig fields = ( "name", + "default_ssh_credential", "ssh_user", "ssh_port", "ssh_options", @@ -102,6 +105,7 @@ class GlobalConfigForm(forms.ModelForm): ) help_texts = { "name": "Usually 'default'. The backup engine currently reads the default config.", + "default_ssh_credential": "Optional. Used by hosts without their own SSH credential.", "default_source_root": "Used by hosts without a custom source root.", "default_destination_subdir": "Optional subdirectory below each snapshot.", } @@ -136,6 +140,24 @@ class ManualBackupForm(forms.Form): ) +class SshCredentialForm(forms.ModelForm): + private_key = forms.CharField( + widget=forms.Textarea, + help_text="Private key used by the worker container for SSH backups.", + ) + public_key = forms.CharField(widget=forms.Textarea, required=False) + known_hosts = forms.CharField( + widget=forms.Textarea, + required=False, + help_text="Optional known_hosts entries. When set, StrictHostKeyChecking can stay enabled.", + ) + notes = forms.CharField(widget=forms.Textarea, required=False) + + class Meta: + model = SshCredential + fields = ("name", "private_key", "public_key", "known_hosts", "notes") + + class RetentionApplyForm(forms.Form): kind = forms.ChoiceField(choices=(("scheduled", "Scheduled"), ("manual", "Manual"), ("all", "All"))) protect_bases = forms.BooleanField(required=False) diff --git a/src/pobsync_backend/migrations/0006_ssh_credentials.py b/src/pobsync_backend/migrations/0006_ssh_credentials.py new file mode 100644 index 0000000..90d9533 --- /dev/null +++ b/src/pobsync_backend/migrations/0006_ssh_credentials.py @@ -0,0 +1,51 @@ +# Generated by Django 5.2.14 on 2026-05-19 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pobsync_backend", "0005_snapshotrecord_base"), + ] + + operations = [ + migrations.CreateModel( + name="SshCredential", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=128, unique=True)), + ("private_key", models.TextField()), + ("public_key", models.TextField(blank=True)), + ("known_hosts", models.TextField(blank=True)), + ("notes", models.TextField(blank=True)), + ], + options={ + "ordering": ["name"], + }, + ), + migrations.AddField( + model_name="globalconfig", + name="default_ssh_credential", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="global_configs", + to="pobsync_backend.sshcredential", + ), + ), + migrations.AddField( + model_name="hostconfig", + name="ssh_credential", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="hosts", + to="pobsync_backend.sshcredential", + ), + ), + ] diff --git a/src/pobsync_backend/models.py b/src/pobsync_backend/models.py index 294a476..d2f1a8f 100644 --- a/src/pobsync_backend/models.py +++ b/src/pobsync_backend/models.py @@ -15,6 +15,13 @@ class GlobalConfig(TimestampedModel): name = models.CharField(max_length=64, default="default", unique=True) backup_root = models.CharField(max_length=512) pobsync_home = models.CharField(max_length=512, default="/opt/pobsync") + default_ssh_credential = models.ForeignKey( + "SshCredential", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="global_configs", + ) ssh_user = models.CharField(max_length=64, default="root") ssh_port = models.PositiveIntegerField(default=22) ssh_options = models.JSONField(default=list, blank=True) @@ -44,6 +51,13 @@ class HostConfig(TimestampedModel): host = models.CharField(max_length=255, unique=True) address = models.CharField(max_length=255) enabled = models.BooleanField(default=True) + ssh_credential = models.ForeignKey( + "SshCredential", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="hosts", + ) ssh_user = models.CharField(max_length=64, blank=True) ssh_port = models.PositiveIntegerField(null=True, blank=True) source_root = models.CharField(max_length=512, blank=True) @@ -64,6 +78,20 @@ class HostConfig(TimestampedModel): return self.host +class SshCredential(TimestampedModel): + name = models.CharField(max_length=128, unique=True) + private_key = models.TextField() + public_key = models.TextField(blank=True) + known_hosts = models.TextField(blank=True) + notes = models.TextField(blank=True) + + class Meta: + ordering = ["name"] + + def __str__(self) -> str: + return self.name + + class BackupRun(models.Model): class RunType(models.TextChoices): SCHEDULED = "scheduled", "Scheduled" diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 02896b7..560153d 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -130,7 +130,7 @@ .form-grid { display: grid; gap: 14px; max-width: 680px; } .field { display: grid; gap: 5px; } .field label { font-weight: 650; } - .field input[type="text"], .field input[type="number"], .field textarea { + .field input[type="text"], .field input[type="number"], .field select, .field textarea { border: 1px solid var(--border); border-radius: 6px; font: inherit; @@ -169,6 +169,7 @@