(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

@@ -11,11 +11,12 @@ from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, Snapsho
@admin.register(SshCredential)
class SshCredentialAdmin(admin.ModelAdmin):
list_display = ("name", "has_public_key", "has_known_hosts", "updated_at")
readonly_fields = ("created_at", "updated_at")
list_display = ("name", "key_type", "generated", "has_public_key", "has_known_hosts", "updated_at")
readonly_fields = ("created_at", "updated_at", "fingerprint")
search_fields = ("name", "notes")
fieldsets = (
(None, {"fields": ("name", "private_key", "public_key", "known_hosts", "notes")}),
(None, {"fields": ("name", "key_type", "generated", "key_path", "fingerprint")}),
("Key material", {"fields": ("private_key", "public_key", "known_hosts", "notes")}),
("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
)

View File

@@ -11,6 +11,7 @@ from pobsync.paths import PobsyncPaths
from .config_repository import global_config_data, host_config_data
from .models import GlobalConfig, HostConfig, SshCredential
from .ssh_keys import identity_path
class DjangoConfigSource:
@@ -48,9 +49,12 @@ def _materialize_credential(credential: SshCredential) -> dict[str, str]:
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)
identity_file = identity_path(credential)
if credential.key_path:
os.chmod(identity_file, 0o600)
else:
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():

View File

@@ -186,7 +186,9 @@ class SshCredentialForm(forms.ModelForm):
raw_private_key = self.cleaned_data.get("private_key", "")
if not raw_private_key.strip():
raise forms.ValidationError("Paste a private key or upload a private key file.")
if self.instance and self.instance.pk and self.instance.key_path:
return self.instance.private_key
raise forms.ValidationError("Paste a private key, upload a private key file, or generate a key from Django.")
private_key = normalize_private_key(raw_private_key)
public_key = validate_ssh_private_key(private_key)
@@ -198,6 +200,8 @@ class SshCredentialForm(forms.ModelForm):
provided_public_key = normalize_public_key(cleaned_data.get("public_key", ""))
if provided_public_key:
cleaned_data["public_key"] = provided_public_key
elif self.instance and self.instance.pk and self.instance.key_path:
cleaned_data["public_key"] = self.instance.public_key
if cleaned_data.get("private_key") and provided_public_key and hasattr(self, "derived_public_key"):
if public_key_identity(provided_public_key) != public_key_identity(self.derived_public_key):
@@ -210,6 +214,32 @@ class SshCredentialForm(forms.ModelForm):
return cleaned_data
class SshCredentialGenerateForm(forms.Form):
name = forms.CharField(max_length=128)
key_type = forms.ChoiceField(
choices=(("ed25519", "ed25519"), ("rsa", "rsa")),
initial="ed25519",
help_text="ed25519 is recommended unless you need RSA for an older target.",
)
set_global_default = forms.BooleanField(
required=False,
initial=True,
help_text="Use this key as the global default when the default global config exists.",
)
known_hosts = forms.CharField(
widget=forms.Textarea(attrs={"spellcheck": "false", "autocomplete": "off"}),
required=False,
help_text="Optional known_hosts entries. This can also be filled later.",
)
notes = forms.CharField(widget=forms.Textarea, required=False)
def clean_name(self) -> str:
name = self.cleaned_data["name"].strip()
if SshCredential.objects.filter(name=name).exists():
raise forms.ValidationError("An SSH credential with this name already exists.")
return name
class RetentionApplyForm(forms.Form):
kind = forms.ChoiceField(choices=(("scheduled", "Scheduled"), ("manual", "Manual"), ("all", "All")))
protect_bases = forms.BooleanField(required=False)

View File

@@ -7,6 +7,7 @@ from pobsync.snapshot_meta import resolve_host_root
from .models import GlobalConfig, HostConfig
from .self_check import SelfCheck
from .ssh_keys import identity_path
HOST_BACKUP_SUBDIRS = ("scheduled", "manual", ".incomplete")
@@ -43,13 +44,15 @@ def collect_host_checks(host: HostConfig, global_config: GlobalConfig | None = N
)
credential = host.ssh_credential or global_config.default_ssh_credential
checks.append(
SelfCheck(
"Host SSH credential",
"ok" if credential else "warning",
str(credential) if credential else "No host or global SSH credential selected.",
)
)
if credential is None:
checks.append(SelfCheck("Host SSH credential", "warning", "No host or global SSH credential selected."))
else:
checks.append(SelfCheck("Host SSH credential", "ok", str(credential)))
if credential.key_path:
key_path = identity_path(credential)
checks.append(
_host_path_check("Host SSH key file", key_path, must_exist=True, must_be_writable=False, must_be_readable=True)
)
host_root = resolve_host_root(global_config.backup_root, host.host)
checks.append(_host_path_check("Host backup root", host_root, must_exist=True, must_be_writable=True))
@@ -58,7 +61,14 @@ def collect_host_checks(host: HostConfig, global_config: GlobalConfig | None = N
return checks
def _host_path_check(name: str, path: Path, *, must_exist: bool, must_be_writable: bool) -> SelfCheck:
def _host_path_check(
name: str,
path: Path,
*,
must_exist: bool,
must_be_writable: bool,
must_be_readable: bool = False,
) -> SelfCheck:
if must_exist and not path.exists():
return SelfCheck(name, "failed", f"{path} does not exist.")
target = path if path.exists() else path.parent
@@ -66,4 +76,6 @@ def _host_path_check(name: str, path: Path, *, must_exist: bool, must_be_writabl
return SelfCheck(name, "failed", f"{target} does not exist.")
if must_be_writable and not os.access(target, os.W_OK):
return SelfCheck(name, "failed", f"{target} is not writable by this process.")
if must_be_readable and not os.access(target, os.R_OK):
return SelfCheck(name, "failed", f"{target} is not readable by this process.")
return SelfCheck(name, "ok", str(path))

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from django.core.management.base import BaseCommand, CommandError
from pobsync_backend.models import GlobalConfig, SshCredential
from pobsync_backend.ssh_keys import SshKeyError, generate_ssh_key
class Command(BaseCommand):
help = "Ensure a filesystem-backed SSH key exists for pobsync backups."
def add_arguments(self, parser):
parser.add_argument("--name", default="default", help="Credential name to create or reuse.")
parser.add_argument("--key-type", default="ed25519", choices=("ed25519", "rsa"))
parser.add_argument(
"--set-global-default",
action="store_true",
help="Set this key as default on the default global config when it exists.",
)
def handle(self, *args, **options):
name = options["name"]
credential, created = SshCredential.objects.get_or_create(
name=name,
defaults={
"key_type": options["key_type"],
"notes": "Generated by pobsync installer.",
},
)
if not credential.key_path and not credential.private_key:
try:
generate_ssh_key(credential, key_type=options["key_type"])
except SshKeyError as exc:
raise CommandError(str(exc)) from exc
created = True
if options["set_global_default"]:
global_config = GlobalConfig.objects.filter(name="default").first()
if global_config is not None and global_config.default_ssh_credential_id is None:
global_config.default_ssh_credential = credential
global_config.save(update_fields=["default_ssh_credential", "updated_at"])
action = "created" if created else "exists"
self.stdout.write(self.style.SUCCESS(f"SSH credential {action}: {credential.name}"))
if credential.public_key:
self.stdout.write(credential.public_key)

View File

@@ -0,0 +1,35 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pobsync_backend", "0006_ssh_credentials"),
]
operations = [
migrations.AlterField(
model_name="sshcredential",
name="private_key",
field=models.TextField(blank=True, default=""),
),
migrations.AddField(
model_name="sshcredential",
name="key_path",
field=models.CharField(blank=True, max_length=1024),
),
migrations.AddField(
model_name="sshcredential",
name="key_type",
field=models.CharField(default="ed25519", max_length=32),
),
migrations.AddField(
model_name="sshcredential",
name="fingerprint",
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name="sshcredential",
name="generated",
field=models.BooleanField(default=False),
),
]

View File

@@ -80,8 +80,12 @@ class HostConfig(TimestampedModel):
class SshCredential(TimestampedModel):
name = models.CharField(max_length=128, unique=True)
private_key = models.TextField()
private_key = models.TextField(blank=True, default="")
public_key = models.TextField(blank=True)
key_path = models.CharField(max_length=1024, blank=True)
key_type = models.CharField(max_length=32, default="ed25519")
fingerprint = models.CharField(max_length=255, blank=True)
generated = models.BooleanField(default=False)
known_hosts = models.TextField(blank=True)
notes = models.TextField(blank=True)

View File

@@ -0,0 +1,110 @@
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
from django.conf import settings
from .models import SshCredential
class SshKeyError(RuntimeError):
pass
def credential_dir(credential: SshCredential) -> Path:
return Path(settings.POBSYNC_HOME) / "state" / "ssh-credentials" / str(credential.pk)
def identity_path(credential: SshCredential) -> Path:
if credential.key_path:
return Path(credential.key_path)
return credential_dir(credential) / "identity"
def generate_ssh_key(credential: SshCredential, *, key_type: str = "ed25519", force: bool = False) -> SshCredential:
if credential.pk is None:
raise SshKeyError("Credential must be saved before generating an SSH key.")
if shutil.which("ssh-keygen") is None:
raise SshKeyError("ssh-keygen is not available.")
key_dir = credential_dir(credential)
key_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(key_dir, 0o700)
private_key = key_dir / "identity"
public_key_file = key_dir / "identity.pub"
if force:
private_key.unlink(missing_ok=True)
public_key_file.unlink(missing_ok=True)
elif private_key.exists() or public_key_file.exists():
raise SshKeyError(f"SSH key already exists for {credential.name}.")
result = subprocess.run(
[
"ssh-keygen",
"-t",
key_type,
"-N",
"",
"-C",
f"pobsync:{credential.name}",
"-f",
str(private_key),
],
check=False,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=15,
)
if result.returncode != 0:
raise SshKeyError(result.stderr.strip() or "ssh-keygen failed.")
os.chmod(private_key, 0o600)
public_key = public_key_file.read_text(encoding="utf-8").strip()
fingerprint = fingerprint_for_key(private_key)
credential.private_key = ""
credential.public_key = public_key
credential.key_path = str(private_key)
credential.key_type = key_type
credential.fingerprint = fingerprint
credential.generated = True
credential.save(update_fields=["private_key", "public_key", "key_path", "key_type", "fingerprint", "generated", "updated_at"])
return credential
def fingerprint_for_key(private_key: Path) -> str:
result = subprocess.run(
["ssh-keygen", "-lf", str(private_key)],
check=False,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=5,
)
if result.returncode != 0:
raise SshKeyError(result.stderr.strip() or "Could not fingerprint SSH key.")
return result.stdout.strip()
def delete_generated_key_files(credential: SshCredential) -> None:
path = identity_path(credential)
allowed_root = (Path(settings.POBSYNC_HOME) / "state" / "ssh-credentials").resolve()
try:
resolved = path.resolve()
except FileNotFoundError:
resolved = path
if allowed_root not in resolved.parents:
raise SshKeyError(f"Refusing to delete key outside {allowed_root}.")
path.unlink(missing_ok=True)
path.with_suffix(path.suffix + ".pub").unlink(missing_ok=True)
if path.name == "identity":
(path.parent / "identity.pub").unlink(missing_ok=True)

View File

@@ -11,6 +11,18 @@
<section class="panel">
<h2>{% if credential %}Edit SSH Credential{% else %}Create SSH Credential{% endif %}</h2>
{% if credential and credential.public_key %}
<div class="field">
<label>Public key</label>
<pre><code>{{ credential.public_key }}</code></pre>
</div>
{% endif %}
{% if credential and credential.key_path %}
<p class="muted">Private key path: <code>{{ credential.key_path }}</code></p>
{% endif %}
{% if credential and credential.fingerprint %}
<p class="muted">Fingerprint: <code>{{ credential.fingerprint }}</code></p>
{% endif %}
<form method="post" enctype="multipart/form-data" class="form-grid">
{% csrf_token %}
{{ form.non_field_errors }}
@@ -29,4 +41,14 @@
</div>
</form>
</section>
{% if credential %}
<section class="panel">
<h2>Delete SSH Key</h2>
<form method="post" action="{% url 'delete_ssh_credential' credential.id %}">
{% csrf_token %}
<button type="submit" class="danger">Delete SSH key</button>
</form>
</section>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,32 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Generate SSH Key | pobsync{% endblock %}
{% block content %}
<h1>Generate 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 Key Pair</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">Generate SSH key</button>
</div>
</form>
</section>
{% endblock %}

View File

@@ -6,7 +6,8 @@
<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" href="{% url 'generate_ssh_credential' %}">Generate SSH key</a>
<a class="button-link secondary" href="{% url 'create_ssh_credential' %}">Add existing key</a>
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</section>
@@ -16,7 +17,9 @@
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Public key</th>
<th>Fingerprint</th>
<th>Known hosts</th>
<th>Hosts</th>
<th>Updated</th>
@@ -26,13 +29,15 @@
{% for credential in credentials %}
<tr>
<td><a href="{% url 'edit_ssh_credential' credential.id %}">{{ credential.name }}</a></td>
<td>{{ credential.public_key|yesno:"yes,no" }}</td>
<td>{{ credential.key_type }}</td>
<td>{% if credential.public_key %}<code>{{ credential.public_key|truncatechars:44 }}</code>{% else %}no{% endif %}</td>
<td>{% if credential.fingerprint %}<code>{{ credential.fingerprint }}</code>{% else %}<span class="muted">unknown</span>{% endif %}</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>
<tr><td colspan="7" class="muted">No SSH credentials configured yet.</td></tr>
{% endfor %}
</tbody>
</table>

View File

@@ -115,3 +115,21 @@ class DjangoConfigSourceTests(TestCase):
self.assertFalse(global_identity_file.exists())
self.assertIn(f"-oIdentityFile={host_identity_file}", cfg["ssh"]["options"])
def test_filesystem_ssh_credential_uses_existing_key_path(self) -> None:
with TemporaryDirectory() as tmp:
identity_file = Path(tmp) / "identity"
identity_file.write_text("PRIVATE KEY\n", encoding="utf-8")
identity_file.chmod(0o600)
credential = SshCredential.objects.create(name="backup-key", key_path=str(identity_file), public_key="PUBLIC")
GlobalConfig.objects.create(
name="default",
backup_root="/backups",
pobsync_home="/opt/pobsync",
default_ssh_credential=credential,
)
HostConfig.objects.create(host="web-01", address="web-01.example.test")
cfg = DjangoConfigSource().effective_config_for_host("web-01")
self.assertIn(f"-oIdentityFile={identity_file}", cfg["ssh"]["options"])

View File

@@ -5,9 +5,11 @@ from pathlib import Path
from tempfile import TemporaryDirectory
from django import forms
from django.test import SimpleTestCase
from django.core.management import call_command
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
class SshCredentialValidationTests(SimpleTestCase):
@@ -38,3 +40,23 @@ class SshCredentialValidationTests(SimpleTestCase):
validate_ssh_private_key("-----BEGIN RSA PRIVATE KEY-----\nabc\n-----END RSA PRIVATE KEY-----")
self.assertIn("PEM private keys are not supported", str(exc.exception))
class SshCredentialManagementTests(TestCase):
def test_ensure_ssh_key_command_generates_default_key(self) -> None:
with TemporaryDirectory() as tmp, override_settings(POBSYNC_HOME=str(Path(tmp) / "home")):
call_command("ensure_pobsync_ssh_key", "--name", "default")
credential = SshCredential.objects.get(name="default")
self.assertTrue(credential.generated)
self.assertTrue(Path(credential.key_path).exists())
self.assertTrue(credential.public_key.startswith("ssh-ed25519 "))
def test_ensure_ssh_key_command_sets_global_default_when_available(self) -> None:
global_config = GlobalConfig.objects.create(name="default", backup_root="/backups")
with TemporaryDirectory() as tmp, override_settings(POBSYNC_HOME=str(Path(tmp) / "home")):
call_command("ensure_pobsync_ssh_key", "--name", "default", "--set-global-default")
global_config.refresh_from_db()
self.assertEqual(global_config.default_ssh_credential.name, "default")

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(

View File

@@ -21,6 +21,7 @@ from .forms import (
HostConfigForm,
ManualBackupForm,
RetentionApplyForm,
SshCredentialGenerateForm,
ScheduleConfigForm,
SshCredentialForm,
)
@@ -29,6 +30,7 @@ from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, Snapsho
from .retention import run_sql_retention_apply, run_sql_retention_plan
from .self_check import collect_self_checks, summarize_self_checks
from .snapshot_discovery import discover_snapshots, inspect_snapshot_discovery
from .ssh_keys import SshKeyError, delete_generated_key_files, generate_ssh_key
@staff_member_required
@@ -110,6 +112,42 @@ def create_ssh_credential(request):
)
@staff_member_required
def generate_ssh_credential(request):
if request.method == "POST":
form = SshCredentialGenerateForm(request.POST)
if form.is_valid():
credential = SshCredential.objects.create(
name=form.cleaned_data["name"],
key_type=form.cleaned_data["key_type"],
known_hosts=form.cleaned_data["known_hosts"],
notes=form.cleaned_data["notes"],
)
try:
credential = generate_ssh_key(credential, key_type=form.cleaned_data["key_type"])
except SshKeyError as exc:
credential.delete()
form.add_error(None, str(exc))
else:
if form.cleaned_data["set_global_default"]:
global_config = GlobalConfig.objects.filter(name="default").first()
if global_config is not None:
global_config.default_ssh_credential = credential
global_config.save(update_fields=["default_ssh_credential", "updated_at"])
messages.success(request, f"SSH key generated for {credential.name}.")
return redirect("ssh_credentials")
else:
form = SshCredentialGenerateForm()
return render(
request,
"pobsync_backend/ssh_credential_generate.html",
{
"form": form,
},
)
@staff_member_required
def edit_ssh_credential(request, credential_id: int):
credential = get_object_or_404(SshCredential, id=credential_id)
@@ -132,6 +170,27 @@ def edit_ssh_credential(request, credential_id: int):
)
@staff_member_required
@require_POST
def delete_ssh_credential(request, credential_id: int):
credential = get_object_or_404(SshCredential, id=credential_id)
if credential.hosts.exists() or credential.global_configs.exists():
messages.error(request, f"SSH key {credential.name} is still in use and cannot be deleted.")
return redirect("edit_ssh_credential", credential_id=credential.id)
name = credential.name
try:
if credential.generated or credential.key_path:
delete_generated_key_files(credential)
except SshKeyError as exc:
messages.error(request, f"Could not delete SSH key files for {name}: {exc}")
return redirect("edit_ssh_credential", credential_id=credential.id)
credential.delete()
messages.success(request, f"SSH key deleted: {name}.")
return redirect("ssh_credentials")
@staff_member_required
def edit_global_config(request):
global_config = GlobalConfig.objects.filter(name="default").first()
@@ -434,6 +493,7 @@ def _default_schedule_initial() -> dict[str, object]:
def _default_global_initial() -> dict[str, object]:
return {
"name": "default",
"default_ssh_credential": SshCredential.objects.order_by("name").first(),
"ssh_user": "root",
"ssh_port": 22,
"rsync_binary": "rsync",