feat(backup): Off-Site-Upload nach S3 + SFTP

Schutz gegen Box-Total-Loss — lokale Backups in /var/backups/edgeguard
helfen nicht, wenn die Disk stirbt oder die Box brennt. Nach jedem
erfolgreichen lokalen Backup wird die tar.gz an alle aktiven
Off-Site-Ziele hochgeladen.

Migration 0022: backup_remotes (kind=s3|sftp, target_url, settings
JSONB, active, last_upload_at, last_error) + backups.remote_uploads
JSONB (per-Target-Result).

internal/services/backup/remote/:
  - UploadAll() — pro aktivem Target ein Upload, Failures non-fatal
  - S3 via minio-go/v7 — funktioniert mit AWS, MinIO, Backblaze B2,
    Cloudflare R2, Hetzner Object Storage (alle S3-API-kompatibel)
  - SFTP via golang.org/x/crypto/ssh + pkg/sftp. Password + Private-
    Key (OpenSSH, base64-encoded) als Auth. Optional host_key_
    fingerprint-Pinning (SHA256:...); leer = TOFU (unsicher vs MitM,
    OK für initial setup).
  - Test() lädt eine 1KB-Probe + löscht sie wieder — Operator-UI hat
    einen „Verbindung testen"-Button.

backup.Service.RemoteUploader-Interface: nach erfolgreichem
recordSuccess() läuft UploadAll, Results landen in backups.remote_
uploads JSONB. last_upload_at/last_error in backup_remotes pro Target
gepflegt. API + Scheduler injizieren beide den Adapter.

internal/handlers/backup_remotes.go: CRUD + POST /:id/test. Sensitive
Felder (secret_key, password, private_key) werden in GET-Responses
durch ***SET*** maskiert; UpdateChannel merged das zurück damit der
Operator bei Edit ohne Re-Eingabe speichern kann.

UI: Backups-Page jetzt mit Tabs "Sicherungen" + "Off-Site-Ziele".
Tab 2 hat CRUD-Tabelle mit kind-konditionalem Form (S3-Felder oder
SFTP-Felder), Test-Button pro Row, last_upload-Status mit FAIL-Tag
bei Errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-13 18:49:02 +02:00
parent 81a8217493
commit 27ac7b53fc
16 changed files with 1520 additions and 329 deletions

View File

@@ -35,6 +35,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backends"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backendservers"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backup"
backupremote "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote"
dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall"
@@ -53,7 +54,7 @@ import (
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
)
var version = "1.0.74"
var version = "1.0.75"
func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR")
@@ -204,7 +205,10 @@ func main() {
// /backups — manueller Trigger + Liste + Download. Scheduled-
// Jobs laufen im edgeguard-scheduler.
handlers.NewBackupHandler(backup.New(pool), auditRepo, nodeID, version).Register(authed)
backupSvc := backup.New(pool)
backupSvc.RemoteUploader = newBackupRemoteAdapter(backupremote.New(pool))
handlers.NewBackupHandler(backupSvc, auditRepo, nodeID, version).Register(authed)
handlers.NewBackupRemotesHandler(pool, auditRepo, nodeID).Register(authed)
handlers.NewDiagnosticsHandler().Register(authed)
handlers.NewAlertsHandler(alerts.New(pool), auditRepo, nodeID).Register(authed)
handlers.NewTLSCertsHandler(tlsRepo, auditRepo, nodeID, acmeService).Register(authed)
@@ -399,6 +403,31 @@ func stripTrailingNewline(s string) string {
// /var/lib/edgeguard isn't writable. Tokens issued with this secret
// die on restart — production reads/writes the persistent file via
// session.NewSignerFromPath.
// backupRemoteAdapter überbrückt backup.RemoteUploader (Interface)
// und remote.Service. Die Field-Names sind gleich; nur der Type ist
// verschieden weil sonst Import-Cycle backup→remote→backup entstehen
// würde.
type backupRemoteAdapter struct{ s *backupremote.Service }
func newBackupRemoteAdapter(s *backupremote.Service) backup.RemoteUploader {
return backupRemoteAdapter{s: s}
}
func (a backupRemoteAdapter) UploadAll(ctx context.Context, localPath string) ([]backup.RemoteUploadInfo, error) {
res, err := a.s.UploadAll(ctx, localPath)
out := make([]backup.RemoteUploadInfo, len(res))
for i, r := range res {
out[i] = backup.RemoteUploadInfo{
RemoteID: r.RemoteID,
RemoteName: r.RemoteName,
OK: r.OK,
SizeBytes: r.SizeBytes,
DurationMs: r.DurationMs,
Error: r.Error,
}
}
return out, err
}
func randomEphemeralSecret() []byte {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {

View File

@@ -9,7 +9,7 @@ import (
"os"
)
var version = "1.0.74"
var version = "1.0.75"
const usage = `edgeguard-ctl — EdgeGuard CLI

View File

@@ -25,13 +25,14 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backup"
backupremote "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/certrenewer"
licsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/license"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
)
var version = "1.0.74"
var version = "1.0.75"
const (
// renewTickInterval — how often we re-evaluate expiring certs.
@@ -92,6 +93,7 @@ func main() {
slog.Info("scheduler: license re-verify enabled", "tick", licenseTickInterval)
backupSvc := backup.New(pool)
backupSvc.RemoteUploader = newSchedRemoteAdapter(backupremote.New(pool))
slog.Info("scheduler: daily backup enabled", "tick", backupTickInterval,
"dir", backupSvc.BackupDir, "keep_n", backup.DefaultKeepN)
@@ -301,3 +303,27 @@ func runRenewer(ctx context.Context, r *certrenewer.Service, a *alerts.Service,
res.Checked, res.Renewed, res.Failed, res.Skipped))
}
}
// schedRemoteAdapter ist die scheduler-seitige Kopie des Adapters
// aus edgeguard-api — gleicher Code, separater Type damit kein
// Cross-Binary-Import nötig wird.
type schedRemoteAdapter struct{ s *backupremote.Service }
func newSchedRemoteAdapter(s *backupremote.Service) backup.RemoteUploader {
return schedRemoteAdapter{s: s}
}
func (a schedRemoteAdapter) UploadAll(ctx context.Context, localPath string) ([]backup.RemoteUploadInfo, error) {
res, err := a.s.UploadAll(ctx, localPath)
out := make([]backup.RemoteUploadInfo, len(res))
for i, r := range res {
out[i] = backup.RemoteUploadInfo{
RemoteID: r.RemoteID,
RemoteName: r.RemoteName,
OK: r.OK,
SizeBytes: r.SizeBytes,
DurationMs: r.DurationMs,
Error: r.Error,
}
}
return out, err
}