(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:
14
README.md
14
README.md
@@ -46,6 +46,7 @@ The installer will, by default:
|
|||||||
- create `/var/lib/pobsync`, `/var/log/pobsync`, and the backup root
|
- create `/var/lib/pobsync`, `/var/log/pobsync`, and the backup root
|
||||||
- install Python dependencies
|
- install Python dependencies
|
||||||
- run migrations and collect static files
|
- run migrations and collect static files
|
||||||
|
- generate a default SSH key for the service user if one does not exist yet
|
||||||
- install and start `pobsync-web`, `pobsync-worker`, and `pobsync-scheduler`
|
- install and start `pobsync-web`, `pobsync-worker`, and `pobsync-scheduler`
|
||||||
- guide you through the first login and setup steps
|
- guide you through the first login and setup steps
|
||||||
|
|
||||||
@@ -141,16 +142,21 @@ The UI includes:
|
|||||||
|
|
||||||
## SSH Keys
|
## SSH Keys
|
||||||
|
|
||||||
SSH keys can be managed from `/ssh-credentials/`. Add a private key, optionally paste `known_hosts` entries, and select
|
SSH keys can be managed from `/ssh-credentials/`. The recommended flow is to generate keys from Django or during the
|
||||||
the credential either as the global default or as a per-host override.
|
installer. pobsync stores the private key on disk under `POBSYNC_HOME`, keeps the public key visible in the UI, and lets
|
||||||
|
you select a credential either as the global default or as a per-host override.
|
||||||
|
|
||||||
When a backup starts, the worker writes the selected key to:
|
Generated private keys are stored at:
|
||||||
|
|
||||||
```
|
```
|
||||||
$POBSYNC_HOME/state/ssh-credentials/<id>/identity
|
$POBSYNC_HOME/state/ssh-credentials/<id>/identity
|
||||||
```
|
```
|
||||||
|
|
||||||
The key file is written with `0600` permissions and injected into the rsync SSH command with `IdentityFile`.
|
The key file is written with `0600` permissions and injected into the rsync SSH command with `IdentityFile`. Copy the
|
||||||
|
public key shown in Django to the target host's `authorized_keys`.
|
||||||
|
|
||||||
|
Existing private keys can still be added manually, but generated filesystem keys are preferred for native systemd
|
||||||
|
production installs.
|
||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
|
|||||||
@@ -461,6 +461,7 @@ run_step "Install systemd units" install_units
|
|||||||
|
|
||||||
run_step "Reload systemd" systemctl daemon-reload
|
run_step "Reload systemd" systemctl daemon-reload
|
||||||
run_step "Run database migrations" "$VENV_DIR/bin/python" "$APP_DIR/manage.py" migrate --noinput
|
run_step "Run database migrations" "$VENV_DIR/bin/python" "$APP_DIR/manage.py" migrate --noinput
|
||||||
|
run_step "Ensure default SSH key" "$VENV_DIR/bin/python" "$APP_DIR/manage.py" ensure_pobsync_ssh_key --name default --set-global-default
|
||||||
run_step "Collect static files" "$VENV_DIR/bin/python" "$APP_DIR/manage.py" collectstatic --noinput --clear
|
run_step "Collect static files" "$VENV_DIR/bin/python" "$APP_DIR/manage.py" collectstatic --noinput --clear
|
||||||
run_step "Finalize state permissions" chown -R "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync
|
run_step "Finalize state permissions" chown -R "$SERVICE_USER:$SERVICE_GROUP" /var/lib/pobsync /var/log/pobsync
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, Snapsho
|
|||||||
|
|
||||||
@admin.register(SshCredential)
|
@admin.register(SshCredential)
|
||||||
class SshCredentialAdmin(admin.ModelAdmin):
|
class SshCredentialAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "has_public_key", "has_known_hosts", "updated_at")
|
list_display = ("name", "key_type", "generated", "has_public_key", "has_known_hosts", "updated_at")
|
||||||
readonly_fields = ("created_at", "updated_at")
|
readonly_fields = ("created_at", "updated_at", "fingerprint")
|
||||||
search_fields = ("name", "notes")
|
search_fields = ("name", "notes")
|
||||||
fieldsets = (
|
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",)}),
|
("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from pobsync.paths import PobsyncPaths
|
|||||||
|
|
||||||
from .config_repository import global_config_data, host_config_data
|
from .config_repository import global_config_data, host_config_data
|
||||||
from .models import GlobalConfig, HostConfig, SshCredential
|
from .models import GlobalConfig, HostConfig, SshCredential
|
||||||
|
from .ssh_keys import identity_path
|
||||||
|
|
||||||
|
|
||||||
class DjangoConfigSource:
|
class DjangoConfigSource:
|
||||||
@@ -48,7 +49,10 @@ def _materialize_credential(credential: SshCredential) -> dict[str, str]:
|
|||||||
credential_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
credential_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
os.chmod(credential_dir, 0o700)
|
os.chmod(credential_dir, 0o700)
|
||||||
|
|
||||||
identity_file = credential_dir / "identity"
|
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")
|
identity_file.write_text(_with_trailing_newline(credential.private_key), encoding="utf-8")
|
||||||
os.chmod(identity_file, 0o600)
|
os.chmod(identity_file, 0o600)
|
||||||
|
|
||||||
|
|||||||
@@ -186,7 +186,9 @@ class SshCredentialForm(forms.ModelForm):
|
|||||||
raw_private_key = self.cleaned_data.get("private_key", "")
|
raw_private_key = self.cleaned_data.get("private_key", "")
|
||||||
|
|
||||||
if not raw_private_key.strip():
|
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)
|
private_key = normalize_private_key(raw_private_key)
|
||||||
public_key = validate_ssh_private_key(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", ""))
|
provided_public_key = normalize_public_key(cleaned_data.get("public_key", ""))
|
||||||
if provided_public_key:
|
if provided_public_key:
|
||||||
cleaned_data["public_key"] = 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 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):
|
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
|
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):
|
class RetentionApplyForm(forms.Form):
|
||||||
kind = forms.ChoiceField(choices=(("scheduled", "Scheduled"), ("manual", "Manual"), ("all", "All")))
|
kind = forms.ChoiceField(choices=(("scheduled", "Scheduled"), ("manual", "Manual"), ("all", "All")))
|
||||||
protect_bases = forms.BooleanField(required=False)
|
protect_bases = forms.BooleanField(required=False)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from pobsync.snapshot_meta import resolve_host_root
|
|||||||
|
|
||||||
from .models import GlobalConfig, HostConfig
|
from .models import GlobalConfig, HostConfig
|
||||||
from .self_check import SelfCheck
|
from .self_check import SelfCheck
|
||||||
|
from .ssh_keys import identity_path
|
||||||
|
|
||||||
|
|
||||||
HOST_BACKUP_SUBDIRS = ("scheduled", "manual", ".incomplete")
|
HOST_BACKUP_SUBDIRS = ("scheduled", "manual", ".incomplete")
|
||||||
@@ -43,12 +44,14 @@ def collect_host_checks(host: HostConfig, global_config: GlobalConfig | None = N
|
|||||||
)
|
)
|
||||||
|
|
||||||
credential = host.ssh_credential or global_config.default_ssh_credential
|
credential = host.ssh_credential or global_config.default_ssh_credential
|
||||||
|
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(
|
checks.append(
|
||||||
SelfCheck(
|
_host_path_check("Host SSH key file", key_path, must_exist=True, must_be_writable=False, must_be_readable=True)
|
||||||
"Host SSH credential",
|
|
||||||
"ok" if credential else "warning",
|
|
||||||
str(credential) if credential else "No host or global SSH credential selected.",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
host_root = resolve_host_root(global_config.backup_root, host.host)
|
host_root = resolve_host_root(global_config.backup_root, host.host)
|
||||||
@@ -58,7 +61,14 @@ def collect_host_checks(host: HostConfig, global_config: GlobalConfig | None = N
|
|||||||
return checks
|
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():
|
if must_exist and not path.exists():
|
||||||
return SelfCheck(name, "failed", f"{path} does not exist.")
|
return SelfCheck(name, "failed", f"{path} does not exist.")
|
||||||
target = path if path.exists() else path.parent
|
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.")
|
return SelfCheck(name, "failed", f"{target} does not exist.")
|
||||||
if must_be_writable and not os.access(target, os.W_OK):
|
if must_be_writable and not os.access(target, os.W_OK):
|
||||||
return SelfCheck(name, "failed", f"{target} is not writable by this process.")
|
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))
|
return SelfCheck(name, "ok", str(path))
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -80,8 +80,12 @@ class HostConfig(TimestampedModel):
|
|||||||
|
|
||||||
class SshCredential(TimestampedModel):
|
class SshCredential(TimestampedModel):
|
||||||
name = models.CharField(max_length=128, unique=True)
|
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)
|
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)
|
known_hosts = models.TextField(blank=True)
|
||||||
notes = models.TextField(blank=True)
|
notes = models.TextField(blank=True)
|
||||||
|
|
||||||
|
|||||||
110
src/pobsync_backend/ssh_keys.py
Normal file
110
src/pobsync_backend/ssh_keys.py
Normal 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)
|
||||||
@@ -11,6 +11,18 @@
|
|||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>{% if credential %}Edit SSH Credential{% else %}Create SSH Credential{% endif %}</h2>
|
<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">
|
<form method="post" enctype="multipart/form-data" class="form-grid">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.non_field_errors }}
|
{{ form.non_field_errors }}
|
||||||
@@ -29,4 +41,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -6,7 +6,8 @@
|
|||||||
<h1>SSH Keys</h1>
|
<h1>SSH Keys</h1>
|
||||||
|
|
||||||
<section class="actions" aria-label="SSH key actions">
|
<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>
|
<a class="button-link secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -16,7 +17,9 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
<th>Public key</th>
|
<th>Public key</th>
|
||||||
|
<th>Fingerprint</th>
|
||||||
<th>Known hosts</th>
|
<th>Known hosts</th>
|
||||||
<th>Hosts</th>
|
<th>Hosts</th>
|
||||||
<th>Updated</th>
|
<th>Updated</th>
|
||||||
@@ -26,13 +29,15 @@
|
|||||||
{% for credential in credentials %}
|
{% for credential in credentials %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{% url 'edit_ssh_credential' credential.id %}">{{ credential.name }}</a></td>
|
<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.known_hosts|yesno:"yes,no" }}</td>
|
||||||
<td>{{ credential.hosts.count }}</td>
|
<td>{{ credential.hosts.count }}</td>
|
||||||
<td>{{ credential.updated_at }}</td>
|
<td>{{ credential.updated_at }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -115,3 +115,21 @@ class DjangoConfigSourceTests(TestCase):
|
|||||||
self.assertFalse(global_identity_file.exists())
|
self.assertFalse(global_identity_file.exists())
|
||||||
|
|
||||||
self.assertIn(f"-oIdentityFile={host_identity_file}", cfg["ssh"]["options"])
|
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"])
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ from pathlib import Path
|
|||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
from django import forms
|
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.forms import normalize_private_key, validate_ssh_private_key
|
||||||
|
from pobsync_backend.models import GlobalConfig, SshCredential
|
||||||
|
|
||||||
|
|
||||||
class SshCredentialValidationTests(SimpleTestCase):
|
class SshCredentialValidationTests(SimpleTestCase):
|
||||||
@@ -38,3 +40,23 @@ class SshCredentialValidationTests(SimpleTestCase):
|
|||||||
validate_ssh_private_key("-----BEGIN RSA PRIVATE KEY-----\nabc\n-----END RSA PRIVATE KEY-----")
|
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))
|
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")
|
||||||
|
|||||||
@@ -163,6 +163,45 @@ class ViewTests(TestCase):
|
|||||||
self.assertEqual(credential.private_key, "UPLOADED PRIVATE KEY\n")
|
self.assertEqual(credential.private_key, "UPLOADED PRIVATE KEY\n")
|
||||||
self.assertEqual(credential.public_key, "DERIVED PUBLIC KEY")
|
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:
|
def test_ssh_credentials_view_rejects_invalid_key(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
|
|
||||||
@@ -284,6 +323,15 @@ class ViewTests(TestCase):
|
|||||||
self.assertEqual(config.retention_daily, 7)
|
self.assertEqual(config.retention_daily, 7)
|
||||||
self.assertEqual(config.retention_yearly, 1)
|
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:
|
def test_global_config_form_renders_static_container_backup_root_on_edit(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
GlobalConfig.objects.create(
|
GlobalConfig.objects.create(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from .forms import (
|
|||||||
HostConfigForm,
|
HostConfigForm,
|
||||||
ManualBackupForm,
|
ManualBackupForm,
|
||||||
RetentionApplyForm,
|
RetentionApplyForm,
|
||||||
|
SshCredentialGenerateForm,
|
||||||
ScheduleConfigForm,
|
ScheduleConfigForm,
|
||||||
SshCredentialForm,
|
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 .retention import run_sql_retention_apply, run_sql_retention_plan
|
||||||
from .self_check import collect_self_checks, summarize_self_checks
|
from .self_check import collect_self_checks, summarize_self_checks
|
||||||
from .snapshot_discovery import discover_snapshots, inspect_snapshot_discovery
|
from .snapshot_discovery import discover_snapshots, inspect_snapshot_discovery
|
||||||
|
from .ssh_keys import SshKeyError, delete_generated_key_files, generate_ssh_key
|
||||||
|
|
||||||
|
|
||||||
@staff_member_required
|
@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
|
@staff_member_required
|
||||||
def edit_ssh_credential(request, credential_id: int):
|
def edit_ssh_credential(request, credential_id: int):
|
||||||
credential = get_object_or_404(SshCredential, id=credential_id)
|
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
|
@staff_member_required
|
||||||
def edit_global_config(request):
|
def edit_global_config(request):
|
||||||
global_config = GlobalConfig.objects.filter(name="default").first()
|
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]:
|
def _default_global_initial() -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"name": "default",
|
"name": "default",
|
||||||
|
"default_ssh_credential": SshCredential.objects.order_by("name").first(),
|
||||||
"ssh_user": "root",
|
"ssh_user": "root",
|
||||||
"ssh_port": 22,
|
"ssh_port": 22,
|
||||||
"rsync_binary": "rsync",
|
"rsync_binary": "rsync",
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ urlpatterns = [
|
|||||||
path("config/global/", views.edit_global_config, name="edit_global_config"),
|
path("config/global/", views.edit_global_config, name="edit_global_config"),
|
||||||
path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"),
|
path("ssh-credentials/", views.ssh_credentials, name="ssh_credentials"),
|
||||||
path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),
|
path("ssh-credentials/new/", views.create_ssh_credential, name="create_ssh_credential"),
|
||||||
|
path("ssh-credentials/generate/", views.generate_ssh_credential, name="generate_ssh_credential"),
|
||||||
path("ssh-credentials/<int:credential_id>/", views.edit_ssh_credential, name="edit_ssh_credential"),
|
path("ssh-credentials/<int:credential_id>/", views.edit_ssh_credential, name="edit_ssh_credential"),
|
||||||
|
path("ssh-credentials/<int:credential_id>/delete/", views.delete_ssh_credential, name="delete_ssh_credential"),
|
||||||
path("hosts/new/", views.create_host_config, name="create_host_config"),
|
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>/", views.host_detail, name="host_detail"),
|
||||||
path("hosts/<str:host>/config/", views.edit_host_config, name="edit_host_config"),
|
path("hosts/<str:host>/config/", views.edit_host_config, name="edit_host_config"),
|
||||||
|
|||||||
Reference in New Issue
Block a user