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 @@