(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.
This commit is contained in:
2026-05-19 14:37:38 +02:00
parent 91ce7ad4c5
commit e65537c6de
14 changed files with 388 additions and 10 deletions

View File

@@ -9,7 +9,7 @@ from django.test import TestCase, override_settings
from django.urls import reverse
from pobsync.util import write_yaml_atomic
from pobsync_backend.models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord
from pobsync_backend.models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential
class ViewTests(TestCase):
@@ -82,13 +82,37 @@ class ViewTests(TestCase):
self.assertContains(response, reverse("create_host_config"))
self.assertContains(response, "Add first host")
def test_ssh_credentials_view_creates_key(self) -> None:
self.client.force_login(self.staff_user)
response = self.client.post(
reverse("create_ssh_credential"),
{
"name": "backup-key",
"private_key": "PRIVATE KEY",
"public_key": "PUBLIC KEY",
"known_hosts": "web-01.example.test ssh-ed25519 AAAATEST",
"notes": "production backup key",
},
follow=True,
)
self.assertRedirects(response, reverse("ssh_credentials"))
self.assertContains(response, "SSH credential saved for backup-key.")
self.assertContains(response, "backup-key")
credential = SshCredential.objects.get(name="backup-key")
self.assertEqual(credential.private_key, "PRIVATE KEY")
self.assertEqual(credential.public_key, "PUBLIC KEY")
def test_global_config_form_creates_default_config(self) -> None:
self.client.force_login(self.staff_user)
credential = SshCredential.objects.create(name="backup-key", private_key="PRIVATE KEY")
response = self.client.post(
reverse("edit_global_config"),
{
"name": "default",
"default_ssh_credential": str(credential.id),
"ssh_user": "backup",
"ssh_port": "2222",
"ssh_options": "StrictHostKeyChecking=no\nBatchMode=yes",
@@ -113,6 +137,7 @@ class ViewTests(TestCase):
config = GlobalConfig.objects.get(name="default")
self.assertEqual(config.backup_root, "/backups")
self.assertEqual(config.pobsync_home, "/opt/pobsync")
self.assertEqual(config.default_ssh_credential, credential)
self.assertEqual(config.ssh_user, "backup")
self.assertEqual(config.ssh_port, 2222)
self.assertEqual(config.ssh_options, ["StrictHostKeyChecking=no", "BatchMode=yes"])
@@ -177,6 +202,7 @@ class ViewTests(TestCase):
def test_create_host_config_form_creates_host(self) -> None:
self.client.force_login(self.staff_user)
credential = SshCredential.objects.create(name="host-key", private_key="PRIVATE KEY")
response = self.client.post(
reverse("create_host_config"),
@@ -184,6 +210,7 @@ class ViewTests(TestCase):
"host": "web-01",
"address": "web-01.example.test",
"enabled": "on",
"ssh_credential": str(credential.id),
"ssh_user": "backup",
"ssh_port": "2222",
"source_root": "/srv",
@@ -203,6 +230,7 @@ class ViewTests(TestCase):
self.assertContains(response, "Host config created for web-01.")
host = HostConfig.objects.get(host="web-01")
self.assertEqual(host.address, "web-01.example.test")
self.assertEqual(host.ssh_credential, credential)
self.assertEqual(host.ssh_user, "backup")
self.assertEqual(host.includes, ["/srv/www", "/srv/db"])
self.assertEqual(host.excludes_add, ["*.tmp"])