(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:
@@ -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")}),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
51
src/pobsync_backend/migrations/0006_ssh_credentials.py
Normal file
51
src/pobsync_backend/migrations/0006_ssh_credentials.py
Normal file
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -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 @@
|
||||
<nav>
|
||||
<strong><a href="{% url 'dashboard' %}">pobsync</a></strong>
|
||||
<a href="{% url 'admin:index' %}">Admin</a>
|
||||
<a href="{% url 'ssh_credentials' %}">SSH Keys</a>
|
||||
<a href="/api/status/">Status API</a>
|
||||
<span class="spacer"></span>
|
||||
<span class="muted">{{ request.user.username }}</span>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<div class="stack">
|
||||
<div><strong>Address:</strong> {{ host.address }}</div>
|
||||
<div><strong>Enabled:</strong> {{ host.enabled|yesno:"yes,no" }}</div>
|
||||
<div><strong>SSH key:</strong> {{ host.ssh_credential|default:"global default" }}</div>
|
||||
<div><strong>SSH:</strong> {{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</div>
|
||||
<div><strong>Source:</strong> {{ host.source_root|default:"global default" }}</div>
|
||||
<div><strong>Retention:</strong> daily {{ host.retention_daily }}, weekly {{ host.retention_weekly }}, monthly {{ host.retention_monthly }}, yearly {{ host.retention_yearly }}</div>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "pobsync_backend/base.html" %}
|
||||
|
||||
{% block title %}New SSH Key | pobsync{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>New SSH Key</h1>
|
||||
|
||||
<section class="actions" aria-label="SSH key form actions">
|
||||
<a class="button-link" href="{% url 'ssh_credentials' %}">Back to SSH keys</a>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Create SSH Credential</h2>
|
||||
<form method="post" class="form-grid">
|
||||
{% csrf_token %}
|
||||
{{ form.non_field_errors }}
|
||||
|
||||
{% for field in form %}
|
||||
<div class="field">
|
||||
{{ field.errors }}
|
||||
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}<div class="helptext">{{ field.help_text }}</div>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">Save SSH key</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,40 @@
|
||||
{% extends "pobsync_backend/base.html" %}
|
||||
|
||||
{% block title %}SSH Keys | pobsync{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>SSH Keys</h1>
|
||||
|
||||
<section class="actions" aria-label="SSH key actions">
|
||||
<a class="button-link" href="{% url 'create_ssh_credential' %}">New SSH key</a>
|
||||
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Credentials</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Public key</th>
|
||||
<th>Known hosts</th>
|
||||
<th>Hosts</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for credential in credentials %}
|
||||
<tr>
|
||||
<td>{{ credential.name }}</td>
|
||||
<td>{{ credential.public_key|yesno:"yes,no" }}</td>
|
||||
<td>{{ credential.known_hosts|yesno:"yes,no" }}</td>
|
||||
<td>{{ credential.hosts.count }}</td>
|
||||
<td>{{ credential.updated_at }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="muted">No SSH credentials configured yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -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"])
|
||||
|
||||
@@ -20,8 +20,9 @@ from .forms import (
|
||||
ManualBackupForm,
|
||||
RetentionApplyForm,
|
||||
ScheduleConfigForm,
|
||||
SshCredentialForm,
|
||||
)
|
||||
from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord
|
||||
from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord, SshCredential
|
||||
from .retention import run_sql_retention_apply, run_sql_retention_plan
|
||||
from .snapshot_discovery import discover_snapshots, inspect_snapshot_discovery
|
||||
|
||||
@@ -57,6 +58,34 @@ def dashboard(request):
|
||||
return render(request, "pobsync_backend/dashboard.html", context)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def ssh_credentials(request):
|
||||
context = {
|
||||
"credentials": SshCredential.objects.order_by("name"),
|
||||
}
|
||||
return render(request, "pobsync_backend/ssh_credentials.html", context)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def create_ssh_credential(request):
|
||||
if request.method == "POST":
|
||||
form = SshCredentialForm(request.POST)
|
||||
if form.is_valid():
|
||||
credential = form.save()
|
||||
messages.success(request, f"SSH credential saved for {credential.name}.")
|
||||
return redirect("ssh_credentials")
|
||||
else:
|
||||
form = SshCredentialForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"pobsync_backend/ssh_credential_form.html",
|
||||
{
|
||||
"form": form,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@staff_member_required
|
||||
def edit_global_config(request):
|
||||
global_config = GlobalConfig.objects.filter(name="default").first()
|
||||
|
||||
@@ -9,6 +9,8 @@ from pobsync_backend import api, views
|
||||
urlpatterns = [
|
||||
path("", views.dashboard, name="dashboard"),
|
||||
path("config/global/", views.edit_global_config, name="edit_global_config"),
|
||||
path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"),
|
||||
path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),
|
||||
path("hosts/new/", views.create_host_config, name="create_host_config"),
|
||||
path("hosts/<str:host>/", views.host_detail, name="host_detail"),
|
||||
path("hosts/<str:host>/config/", views.edit_host_config, name="edit_host_config"),
|
||||
|
||||
Reference in New Issue
Block a user