(feature) Add host key scanning for SSH credentials

Add a host detail action that scans the target SSH host key with
ssh-keyscan and stores it on the selected SSH credential.

Merge scanned known_hosts entries without duplicates and let the
existing runtime config pass them through as UserKnownHostsFile for
unattended rsync over SSH.

Extend host checks to warn when the selected credential has no known_hosts
entries, making host key verification failures actionable from Django.
This commit is contained in:
2026-05-19 19:55:40 +02:00
parent 25d2a5b1a7
commit d3ffca1843
7 changed files with 104 additions and 1 deletions

View File

@@ -10,6 +10,7 @@ from django.test import SimpleTestCase, TestCase, override_settings
from pobsync_backend.forms import normalize_private_key, validate_ssh_private_key
from pobsync_backend.models import GlobalConfig, SshCredential
from pobsync_backend.ssh_keys import merge_known_hosts
class SshCredentialValidationTests(SimpleTestCase):
@@ -60,3 +61,14 @@ class SshCredentialManagementTests(TestCase):
global_config.refresh_from_db()
self.assertEqual(global_config.default_ssh_credential.name, "default")
def test_merge_known_hosts_appends_unique_entries(self) -> None:
merged = merge_known_hosts(
"web-01.example.test ssh-ed25519 AAAAOLD\n",
"web-01.example.test ssh-ed25519 AAAAOLD\nweb-01.example.test ssh-rsa AAAANEW\n",
)
self.assertEqual(
merged,
"web-01.example.test ssh-ed25519 AAAAOLD\nweb-01.example.test ssh-rsa AAAANEW\n",
)

View File

@@ -522,6 +522,24 @@ class ViewTests(TestCase):
self.assertTrue((backup_root / host.host / "manual").is_dir())
self.assertTrue((backup_root / host.host / ".incomplete").is_dir())
def test_scan_host_known_key_action_updates_selected_credential(self) -> None:
self.client.force_login(self.staff_user)
credential = SshCredential.objects.create(name="default-key", key_path="/var/lib/pobsync/state/ssh-credentials/1/identity")
GlobalConfig.objects.create(name="default", backup_root="/backups", default_ssh_credential=credential, ssh_port=2222)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
with patch(
"pobsync_backend.views.scan_known_host",
return_value="web-01.example.test ssh-ed25519 AAAASCANNED",
) as scan:
response = self.client.post(reverse("scan_host_known_key", args=[host.host]), follow=True)
self.assertRedirects(response, reverse("host_detail", args=[host.host]))
self.assertContains(response, "Stored SSH host key for web-01")
scan.assert_called_once_with("web-01.example.test", port=2222)
credential.refresh_from_db()
self.assertEqual(credential.known_hosts, "web-01.example.test ssh-ed25519 AAAASCANNED\n")
def test_host_detail_surfaces_active_backup_run(self) -> None:
self.client.force_login(self.staff_user)
GlobalConfig.objects.create(name="default", backup_root="/backups")