(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:
@@ -1,9 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.test import TestCase
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from pobsync_backend.config_source import DjangoConfigSource
|
||||
from pobsync_backend.models import GlobalConfig, HostConfig
|
||||
from pobsync_backend.models import GlobalConfig, HostConfig, SshCredential
|
||||
|
||||
|
||||
class DjangoConfigSourceTests(TestCase):
|
||||
@@ -58,3 +62,56 @@ class DjangoConfigSourceTests(TestCase):
|
||||
self.assertEqual(cfg["address"], "web-01.example.test")
|
||||
self.assertEqual(cfg["excludes_effective"], ["/proc/***", "/tmp/***"])
|
||||
self.assertEqual(cfg["rsync"]["args_effective"], ["--archive", "--numeric-ids", "--delete"])
|
||||
|
||||
def test_materializes_global_ssh_credential_for_runtime_config(self) -> None:
|
||||
credential = SshCredential.objects.create(
|
||||
name="backup-key",
|
||||
private_key="PRIVATE KEY",
|
||||
known_hosts="web-01.example.test ssh-ed25519 AAAATEST",
|
||||
)
|
||||
GlobalConfig.objects.create(
|
||||
name="default",
|
||||
backup_root="/backups",
|
||||
pobsync_home="/opt/pobsync",
|
||||
default_ssh_credential=credential,
|
||||
ssh_options=["-oBatchMode=yes"],
|
||||
)
|
||||
HostConfig.objects.create(host="web-01", address="web-01.example.test")
|
||||
|
||||
with TemporaryDirectory() as tmp, override_settings(POBSYNC_HOME=str(Path(tmp) / "home")):
|
||||
cfg = DjangoConfigSource().effective_config_for_host("web-01")
|
||||
|
||||
identity_file = Path(tmp) / "home" / "state" / "ssh-credentials" / str(credential.pk) / "identity"
|
||||
known_hosts = identity_file.parent / "known_hosts"
|
||||
self.assertEqual(identity_file.read_text(encoding="utf-8"), "PRIVATE KEY\n")
|
||||
self.assertEqual(known_hosts.read_text(encoding="utf-8"), "web-01.example.test ssh-ed25519 AAAATEST\n")
|
||||
self.assertEqual(stat.S_IMODE(identity_file.stat().st_mode), 0o600)
|
||||
|
||||
self.assertIn("-oBatchMode=yes", cfg["ssh"]["options"])
|
||||
self.assertIn(f"-oIdentityFile={identity_file}", cfg["ssh"]["options"])
|
||||
self.assertIn(f"-oUserKnownHostsFile={known_hosts}", cfg["ssh"]["options"])
|
||||
|
||||
def test_host_ssh_credential_overrides_global_credential(self) -> None:
|
||||
global_credential = SshCredential.objects.create(name="global-key", private_key="GLOBAL")
|
||||
host_credential = SshCredential.objects.create(name="host-key", private_key="HOST")
|
||||
GlobalConfig.objects.create(
|
||||
name="default",
|
||||
backup_root="/backups",
|
||||
pobsync_home="/opt/pobsync",
|
||||
default_ssh_credential=global_credential,
|
||||
)
|
||||
HostConfig.objects.create(
|
||||
host="web-01",
|
||||
address="web-01.example.test",
|
||||
ssh_credential=host_credential,
|
||||
)
|
||||
|
||||
with TemporaryDirectory() as tmp, override_settings(POBSYNC_HOME=str(Path(tmp) / "home")):
|
||||
cfg = DjangoConfigSource().effective_config_for_host("web-01")
|
||||
|
||||
host_identity_file = Path(tmp) / "home" / "state" / "ssh-credentials" / str(host_credential.pk) / "identity"
|
||||
global_identity_file = Path(tmp) / "home" / "state" / "ssh-credentials" / str(global_credential.pk) / "identity"
|
||||
self.assertEqual(host_identity_file.read_text(encoding="utf-8"), "HOST\n")
|
||||
self.assertFalse(global_identity_file.exists())
|
||||
|
||||
self.assertIn(f"-oIdentityFile={host_identity_file}", cfg["ssh"]["options"])
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user