(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

@@ -163,6 +163,45 @@ class ViewTests(TestCase):
self.assertEqual(credential.private_key, "UPLOADED PRIVATE KEY\n")
self.assertEqual(credential.public_key, "DERIVED PUBLIC KEY")
def test_ssh_credentials_view_generates_filesystem_key(self) -> None:
self.client.force_login(self.staff_user)
with TemporaryDirectory() as tmp, override_settings(POBSYNC_HOME=str(Path(tmp) / "home")):
response = self.client.post(
reverse("generate_ssh_credential"),
{
"name": "generated-key",
"key_type": "ed25519",
"set_global_default": "",
"known_hosts": "",
"notes": "generated",
},
follow=True,
)
self.assertRedirects(response, reverse("ssh_credentials"))
self.assertContains(response, "SSH key generated for generated-key.")
credential = SshCredential.objects.get(name="generated-key")
self.assertTrue(credential.generated)
self.assertEqual(credential.private_key, "")
self.assertTrue(credential.public_key.startswith("ssh-ed25519 "))
self.assertTrue(Path(credential.key_path).exists())
def test_ssh_credentials_view_deletes_unused_generated_key(self) -> None:
self.client.force_login(self.staff_user)
with TemporaryDirectory() as tmp, override_settings(POBSYNC_HOME=str(Path(tmp) / "home")):
credential = SshCredential.objects.create(name="generated-key")
from pobsync_backend.ssh_keys import generate_ssh_key
generate_ssh_key(credential)
key_path = Path(credential.key_path)
response = self.client.post(reverse("delete_ssh_credential", args=[credential.id]), follow=True)
self.assertRedirects(response, reverse("ssh_credentials"))
self.assertContains(response, "SSH key deleted: generated-key.")
self.assertFalse(SshCredential.objects.exists())
self.assertFalse(key_path.exists())
def test_ssh_credentials_view_rejects_invalid_key(self) -> None:
self.client.force_login(self.staff_user)
@@ -284,6 +323,15 @@ class ViewTests(TestCase):
self.assertEqual(config.retention_daily, 7)
self.assertEqual(config.retention_yearly, 1)
def test_global_config_form_defaults_to_first_generated_key(self) -> None:
self.client.force_login(self.staff_user)
credential = SshCredential.objects.create(name="default", key_path="/var/lib/pobsync/state/ssh-credentials/1/identity")
response = self.client.get(reverse("edit_global_config"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, f'value="{credential.id}" selected')
def test_global_config_form_renders_static_container_backup_root_on_edit(self) -> None:
self.client.force_login(self.staff_user)
GlobalConfig.objects.create(