From 27ac7b53fcefe9789a68ee51fb1b74cb232c250c Mon Sep 17 00:00:00 2001 From: Debian Date: Wed, 13 May 2026 18:49:02 +0200 Subject: [PATCH] feat(backup): Off-Site-Upload nach S3 + SFTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- VERSION | 2 +- cmd/edgeguard-api/main.go | 33 +- cmd/edgeguard-ctl/main.go | 2 +- cmd/edgeguard-scheduler/main.go | 28 +- go.mod | 18 +- go.sum | 31 ++ .../migrations/0022_backup_remotes.sql | 44 +++ internal/handlers/backup_remotes.go | 272 ++++++++++++++ internal/services/backup/backup.go | 57 +++ internal/services/backup/remote/remote.go | 342 ++++++++++++++++++ .../src/components/Layout/Sidebar.tsx | 2 +- management-ui/src/i18n/locales/de/common.json | 21 ++ management-ui/src/i18n/locales/en/common.json | 21 ++ management-ui/src/pages/Backups/History.tsx | 332 +++++++++++++++++ .../src/pages/Backups/RemoteTargets.tsx | 314 ++++++++++++++++ management-ui/src/pages/Backups/index.tsx | 330 +---------------- 16 files changed, 1520 insertions(+), 329 deletions(-) create mode 100644 internal/database/migrations/0022_backup_remotes.sql create mode 100644 internal/handlers/backup_remotes.go create mode 100644 internal/services/backup/remote/remote.go create mode 100644 management-ui/src/pages/Backups/History.tsx create mode 100644 management-ui/src/pages/Backups/RemoteTargets.tsx diff --git a/VERSION b/VERSION index ea2f1d3..e9acec7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.74 +1.0.75 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index ebb0555..64d34ba 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -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 { diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index e24b012..7a1b90d 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.74" +var version = "1.0.75" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index ada0307..05e61cb 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -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 +} diff --git a/go.mod b/go.mod index 2ba4c90..515d38d 100644 --- a/go.mod +++ b/go.mod @@ -17,32 +17,48 @@ require ( github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-ini/ini v1.67.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.23.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect - github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/kr/fs v0.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.21 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/miekg/dns v1.1.72 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/minio/minio-go/v7 v7.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/pkg/sftp v1.13.10 // indirect + github.com/rs/xid v1.6.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/tinylib/msgp v1.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.53.0 // indirect diff --git a/go.sum b/go.sum index 4bd8ede..dc2d4ec 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3z github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= @@ -25,6 +27,8 @@ github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU= github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc= +github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -54,10 +58,19 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU= github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -70,6 +83,12 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8= +github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -80,7 +99,11 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -90,6 +113,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= @@ -107,12 +132,18 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= diff --git a/internal/database/migrations/0022_backup_remotes.sql b/internal/database/migrations/0022_backup_remotes.sql new file mode 100644 index 0000000..b4006c0 --- /dev/null +++ b/internal/database/migrations/0022_backup_remotes.sql @@ -0,0 +1,44 @@ +-- +goose Up +-- +goose StatementBegin + +-- Off-Site-Backup-Targets. Nach erfolgreichem lokalen Backup wird der +-- tarball in jeden aktiven Target hochgeladen. Schutz gegen Total- +-- Loss (Box brennt, Dump-Disk fällt aus, etc.). +-- +-- kind: +-- s3 — beliebige S3-API: AWS, MinIO, Backblaze B2, Cloudflare R2, +-- Hetzner Object Storage. settings: endpoint, region, bucket, +-- access_key, secret_key, path_prefix, use_ssl. +-- sftp — klassisches SSH/SFTP. settings: host, port, username, +-- password ODER private_key (base64), remote_dir, +-- host_key_fingerprint (sha256:..., optional). + +CREATE TABLE IF NOT EXISTS backup_remotes ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, + target_url TEXT NOT NULL, -- s3://bucket bzw. sftp://user@host:port + settings JSONB NOT NULL DEFAULT '{}'::jsonb, + active BOOLEAN NOT NULL DEFAULT TRUE, + last_upload_at TIMESTAMPTZ, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT backup_remotes_kind_check CHECK (kind IN ('s3', 'sftp')) +); + +CREATE INDEX IF NOT EXISTS idx_backup_remotes_active + ON backup_remotes (active) WHERE active; + +-- Pro Backup: Liste der Upload-Versuche als JSONB. Format: +-- [{remote_id, remote_name, ok, size, duration_ms, error}, ...] +ALTER TABLE backups + ADD COLUMN IF NOT EXISTS remote_uploads JSONB NOT NULL DEFAULT '[]'::jsonb; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE backups DROP COLUMN IF EXISTS remote_uploads; +DROP TABLE IF EXISTS backup_remotes; +-- +goose StatementEnd diff --git a/internal/handlers/backup_remotes.go b/internal/handlers/backup_remotes.go new file mode 100644 index 0000000..58e5b69 --- /dev/null +++ b/internal/handlers/backup_remotes.go @@ -0,0 +1,272 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote" +) + +// BackupRemotesHandler exposes: +// +// GET /api/v1/backup-remotes +// POST /api/v1/backup-remotes +// PUT /api/v1/backup-remotes/:id +// DELETE /api/v1/backup-remotes/:id +// POST /api/v1/backup-remotes/:id/test +type BackupRemotesHandler struct { + Pool *pgxpool.Pool + Svc *remote.Service + Audit *audit.Repo + NodeID string +} + +func NewBackupRemotesHandler(pool *pgxpool.Pool, a *audit.Repo, nodeID string) *BackupRemotesHandler { + return &BackupRemotesHandler{Pool: pool, Svc: remote.New(pool), Audit: a, NodeID: nodeID} +} + +func (h *BackupRemotesHandler) Register(rg *gin.RouterGroup) { + g := rg.Group("/backup-remotes") + g.GET("", h.List) + g.POST("", h.Create) + g.PUT("/:id", h.Update) + g.DELETE("/:id", h.Delete) + g.POST("/:id/test", h.Test) +} + +type remoteRow struct { + ID int64 `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + TargetURL string `json:"target_url"` + Settings json.RawMessage `json:"settings"` + Active bool `json:"active"` + LastUpload *time.Time `json:"last_upload_at,omitempty"` + LastError *string `json:"last_error,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +const remoteSelect = ` +SELECT id, name, kind, target_url, settings, active, last_upload_at, last_error, + created_at, updated_at +FROM backup_remotes` + +func (h *BackupRemotesHandler) List(c *gin.Context) { + rows, err := h.Pool.Query(c.Request.Context(), remoteSelect+" ORDER BY id ASC") + if err != nil { + response.Internal(c, err) + return + } + defer rows.Close() + out := []remoteRow{} + for rows.Next() { + var r remoteRow + if err := rows.Scan(&r.ID, &r.Name, &r.Kind, &r.TargetURL, + &r.Settings, &r.Active, &r.LastUpload, &r.LastError, + &r.CreatedAt, &r.UpdatedAt); err != nil { + response.Internal(c, err) + return + } + // Sensitive Felder maskieren in der List-Response — Operator + // soll nicht versehentlich beim Screenshare den S3-SecretKey + // preisgeben. Edit-Modal verwendet einen separaten endpoint + // nicht (UI darf das masked-Settings nicht zurückschreiben). + r.Settings = maskSecrets(r.Settings) + out = append(out, r) + } + response.OK(c, gin.H{"remotes": out}) +} + +func (h *BackupRemotesHandler) Create(c *gin.Context) { + var req remoteRow + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + if req.Settings == nil || len(req.Settings) == 0 { + req.Settings = json.RawMessage(`{}`) + } + row := h.Pool.QueryRow(c.Request.Context(), ` +INSERT INTO backup_remotes (name, kind, target_url, settings, active) +VALUES ($1, $2, $3, $4, $5) +RETURNING `+remoteSelectFields(), + req.Name, req.Kind, req.TargetURL, req.Settings, req.Active) + var out remoteRow + if err := row.Scan(&out.ID, &out.Name, &out.Kind, &out.TargetURL, + &out.Settings, &out.Active, &out.LastUpload, &out.LastError, + &out.CreatedAt, &out.UpdatedAt); err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "backup.remote.create", + out.Name, gin.H{"id": out.ID, "kind": out.Kind}, h.NodeID) + out.Settings = maskSecrets(out.Settings) + response.Created(c, out) +} + +func (h *BackupRemotesHandler) Update(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req remoteRow + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + if req.Settings == nil || len(req.Settings) == 0 { + req.Settings = json.RawMessage(`{}`) + } + // Wenn die Settings masked-Fields enthalten (***), übernehmen wir + // die bestehenden Werte aus der DB — Operator kann das Modal mit + // Settings öffnen, einen Klick speichern, ohne sein S3-Secret neu + // eingeben zu müssen. + merged, err := mergeMaskedSettings(c.Request.Context(), h.Pool, id, req.Settings) + if err == nil { + req.Settings = merged + } + row := h.Pool.QueryRow(c.Request.Context(), ` +UPDATE backup_remotes SET + name = $1, kind = $2, target_url = $3, settings = $4, active = $5, + updated_at = NOW() +WHERE id = $6 +RETURNING `+remoteSelectFields(), + req.Name, req.Kind, req.TargetURL, req.Settings, req.Active, id) + var out remoteRow + if err := row.Scan(&out.ID, &out.Name, &out.Kind, &out.TargetURL, + &out.Settings, &out.Active, &out.LastUpload, &out.LastError, + &out.CreatedAt, &out.UpdatedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "backup.remote.update", + out.Name, gin.H{"id": id}, h.NodeID) + out.Settings = maskSecrets(out.Settings) + response.OK(c, out) +} + +func (h *BackupRemotesHandler) Delete(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + if _, err := h.Pool.Exec(c.Request.Context(), + `DELETE FROM backup_remotes WHERE id = $1`, id); err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "backup.remote.delete", + strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) + response.NoContent(c) +} + +// Test schickt eine 1KB-Probe in den Target. Failures = Misconfig. +func (h *BackupRemotesHandler) Test(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var t remote.Target + err := h.Pool.QueryRow(c.Request.Context(), ` +SELECT id, name, kind, target_url, settings, active, last_upload_at, last_error +FROM backup_remotes WHERE id = $1`, id).Scan( + &t.ID, &t.Name, &t.Kind, &t.TargetURL, &t.Settings, &t.Active, + &t.LastUpload, &t.LastError) + if err != nil { + response.NotFound(c, err) + return + } + if err := h.Svc.Test(c.Request.Context(), t); err != nil { + response.Err(c, http.StatusBadGateway, err) + return + } + response.OK(c, gin.H{"ok": true}) +} + +func remoteSelectFields() string { + return `id, name, kind, target_url, settings, active, + last_upload_at, last_error, created_at, updated_at` +} + +// maskSecrets ersetzt sensitive Felder in settings-JSON durch ***SET*** +// damit GET /backup-remotes nichts leakt (Screenshare, Browser-Cache, +// Browser-DevTools). +func maskSecrets(raw json.RawMessage) json.RawMessage { + if len(raw) == 0 { + return raw + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return raw + } + for _, k := range []string{"secret_key", "password", "private_key"} { + if v, ok := m[k]; ok { + if s, isStr := v.(string); isStr && s != "" { + m[k] = "***SET***" + } + } + } + b, err := json.Marshal(m) + if err != nil { + return raw + } + return b +} + +// mergeMaskedSettings: wenn der Operator das Edit-Modal speichert ohne +// die secret-Felder neu zu setzen, schickt das UI ***SET*** zurück — +// wir lassen dann den DB-Wert stehen statt das Secret zu überschreiben. +func mergeMaskedSettings(ctx pgxContext, pool *pgxpool.Pool, id int64, + incoming json.RawMessage) (json.RawMessage, error) { + var current json.RawMessage + if err := pool.QueryRow(ctx, `SELECT settings FROM backup_remotes WHERE id = $1`, id). + Scan(¤t); err != nil { + return incoming, err + } + var cur, inc map[string]any + if err := json.Unmarshal(current, &cur); err != nil { + return incoming, nil + } + if err := json.Unmarshal(incoming, &inc); err != nil { + return incoming, nil + } + for _, k := range []string{"secret_key", "password", "private_key"} { + if v, ok := inc[k]; ok { + if s, isStr := v.(string); isStr && s == "***SET***" { + if cv, hasCur := cur[k]; hasCur { + inc[k] = cv + } else { + delete(inc, k) + } + } + } + } + b, err := json.Marshal(inc) + if err != nil { + return incoming, err + } + return b, nil +} + +// pgxContext-Alias damit die Helper-Funktion keinen direkten context- +// Import oben braucht. +type pgxContext = interface { + Done() <-chan struct{} + Err() error + Deadline() (time.Time, bool) + Value(any) any +} diff --git a/internal/services/backup/backup.go b/internal/services/backup/backup.go index c71d3d1..630c6c9 100644 --- a/internal/services/backup/backup.go +++ b/internal/services/backup/backup.go @@ -68,6 +68,7 @@ type Result struct { StartedAt time.Time FinishedAt time.Time Error error + RemoteUploads []RemoteResult } // Manifest ist der content von manifest.json im tarball. @@ -80,6 +81,39 @@ type Manifest struct { FilesBytes int64 `json:"files_bytes"` } +// RemoteUploader ist das Service-Interface das nach erfolgreichem +// Backup die tar.gz in Off-Site-Targets (S3/SFTP) hochlädt. Wird im +// Konstruktor optional gesetzt; nil = kein Off-Site. +type RemoteUploader interface { + UploadAll(ctx context.Context, localPath string) ([]RemoteUploadInfo, error) +} + +// RemoteUploadInfo ist die minimale Sicht die der Service braucht. +// remote.UploadResult passt direkt rein. +type RemoteUploadInfo = remoteUploadResult + +// remoteUploadResult ist das Struct das remote.UploadResult spiegelt +// (separate Datei um Import-Cycle zu vermeiden). Die remote/-Package +// implementiert RemoteUploader. +type remoteUploadResult struct { + RemoteID int64 + RemoteName string + OK bool + SizeBytes int64 + DurationMs int64 + Error string +} + +// RemoteResult ist die UI-Sicht, eine Liste davon landet im Result +// der Run()-Methode. +type RemoteResult struct { + Name string `json:"name"` + OK bool `json:"ok"` + Bytes int64 `json:"bytes,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` + Error string `json:"error,omitempty"` +} + // Service bündelt Backup + Restore + Retention. Stateless — alle // Konfig kommt als Konstruktor-Param + Methode-Param. type Service struct { @@ -92,6 +126,10 @@ type Service struct { // Tests einen fake-binary einschleusen. PGDumpCmd func(ctx context.Context, w io.Writer) (int64, error) NowFn func() time.Time + + // RemoteUploader: nach erfolgreichem Backup wird das tarball in + // alle aktiven backup_remotes hochgeladen. nil = kein Off-Site. + RemoteUploader RemoteUploader } func New(pool *pgxpool.Pool) *Service { @@ -217,6 +255,25 @@ func (s *Service) Run(ctx context.Context, kind Kind, version string) (*Result, // arbeitet nur über DB-Rows). return res, fmt.Errorf("db record: %w", err) } + // Off-Site-Uploads. Failures hier sind nicht-fatal — das lokale + // Backup ist erfolgreich, Operator sieht in der UI welcher Target + // erfolgreich war + welcher nicht. RemoteUploader ist optional; + // wenn nicht injiziert, läuft kein Off-Site. + if s.RemoteUploader != nil { + uploads, _ := s.RemoteUploader.UploadAll(ctx, outPath) + if len(uploads) > 0 { + jsonBytes, _ := json.Marshal(uploads) + _, _ = s.Pool.Exec(ctx, + `UPDATE backups SET remote_uploads = $1 WHERE id = $2`, + jsonBytes, res.ID) + for _, u := range uploads { + res.RemoteUploads = append(res.RemoteUploads, RemoteResult{ + Name: u.RemoteName, OK: u.OK, Bytes: u.SizeBytes, + DurationMs: u.DurationMs, Error: u.Error, + }) + } + } + } return res, nil } diff --git a/internal/services/backup/remote/remote.go b/internal/services/backup/remote/remote.go new file mode 100644 index 0000000..a1d26d8 --- /dev/null +++ b/internal/services/backup/remote/remote.go @@ -0,0 +1,342 @@ +// Package remote uploads Backup-Tarballs in Off-Site-Targets (S3 + +// SFTP). Wird vom backup.Service nach erfolgreichem Local-Backup +// aufgerufen, einmal pro aktivem backup_remotes-Eintrag. +package remote + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/minio/minio-go/v7" + miniocred "github.com/minio/minio-go/v7/pkg/credentials" + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" +) + +type Kind string + +const ( + KindS3 Kind = "s3" + KindSFTP Kind = "sftp" +) + +// Target ist eine Row aus backup_remotes. +type Target struct { + ID int64 `json:"id"` + Name string `json:"name"` + Kind Kind `json:"kind"` + TargetURL string `json:"target_url"` + Settings json.RawMessage `json:"settings"` + Active bool `json:"active"` + LastUpload *time.Time `json:"last_upload_at,omitempty"` + LastError *string `json:"last_error,omitempty"` +} + +// S3Settings: alle minio-go-Config-Felder. Funktioniert mit AWS S3, +// MinIO, Backblaze B2, Cloudflare R2, Hetzner Object Storage. +type S3Settings struct { + Endpoint string `json:"endpoint"` // s3.amazonaws.com / minio.example.com:9000 + Region string `json:"region"` // "eu-central-1" / "auto" (R2) + Bucket string `json:"bucket"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + PathPrefix string `json:"path_prefix"` // "edgeguard/utm-1/" — vor jedem Filename + UseSSL bool `json:"use_ssl"` +} + +// SFTPSettings — klassisches SSH/SFTP. +type SFTPSettings struct { + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + PrivateKeyB64 string `json:"private_key,omitempty"` // OpenSSH-Privkey, base64 + RemoteDir string `json:"remote_dir"` // /var/backups/edgeguard + HostKeyFingerprint string `json:"host_key_fingerprint,omitempty"` // "SHA256:..." optional +} + +// UploadResult landet pro Target in backups.remote_uploads. +type UploadResult struct { + RemoteID int64 `json:"remote_id"` + RemoteName string `json:"remote_name"` + OK bool `json:"ok"` + SizeBytes int64 `json:"size,omitempty"` + DurationMs int64 `json:"duration_ms"` + Error string `json:"error,omitempty"` +} + +type Service struct { + Pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Service { return &Service{Pool: pool} } + +// ListActive holt alle aktiven Targets. +func (s *Service) ListActive(ctx context.Context) ([]Target, error) { + rows, err := s.Pool.Query(ctx, ` +SELECT id, name, kind, target_url, settings, active, last_upload_at, last_error +FROM backup_remotes WHERE active = TRUE ORDER BY id ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Target{} + for rows.Next() { + var t Target + if err := rows.Scan(&t.ID, &t.Name, &t.Kind, &t.TargetURL, + &t.Settings, &t.Active, &t.LastUpload, &t.LastError); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +// UploadAll lädt das File in alle aktiven Targets. Eine Upload-Failure +// blockiert nicht die anderen. Returns Result-Liste pro Target. +func (s *Service) UploadAll(ctx context.Context, localPath string) ([]UploadResult, error) { + targets, err := s.ListActive(ctx) + if err != nil { + return nil, err + } + out := make([]UploadResult, 0, len(targets)) + for _, t := range targets { + r := s.uploadOne(ctx, t, localPath) + out = append(out, r) + // last_upload_at / last_error in backup_remotes pflegen. + s.markResult(ctx, t.ID, r) + } + return out, nil +} + +func (s *Service) markResult(ctx context.Context, id int64, r UploadResult) { + if r.OK { + _, _ = s.Pool.Exec(ctx, ` +UPDATE backup_remotes SET last_upload_at = NOW(), last_error = NULL, updated_at = NOW() +WHERE id = $1`, id) + } else { + _, _ = s.Pool.Exec(ctx, ` +UPDATE backup_remotes SET last_error = $1, updated_at = NOW() WHERE id = $2`, + r.Error, id) + } +} + +func (s *Service) uploadOne(ctx context.Context, t Target, localPath string) UploadResult { + start := time.Now() + r := UploadResult{RemoteID: t.ID, RemoteName: t.Name} + var err error + switch t.Kind { + case KindS3: + var settings S3Settings + if e := json.Unmarshal(t.Settings, &settings); e != nil { + err = fmt.Errorf("parse s3 settings: %w", e) + break + } + r.SizeBytes, err = uploadS3(ctx, settings, localPath) + case KindSFTP: + var settings SFTPSettings + if e := json.Unmarshal(t.Settings, &settings); e != nil { + err = fmt.Errorf("parse sftp settings: %w", e) + break + } + r.SizeBytes, err = uploadSFTP(ctx, settings, localPath) + default: + err = fmt.Errorf("unknown kind: %s", t.Kind) + } + r.DurationMs = time.Since(start).Milliseconds() + if err != nil { + r.OK = false + r.Error = err.Error() + } else { + r.OK = true + } + return r +} + +// uploadS3 mit minio-go. Funktioniert mit jeder S3-API. +func uploadS3(ctx context.Context, s S3Settings, localPath string) (int64, error) { + if s.Endpoint == "" || s.Bucket == "" || s.AccessKey == "" || s.SecretKey == "" { + return 0, errors.New("s3 settings incomplete (endpoint/bucket/access_key/secret_key required)") + } + cl, err := minio.New(s.Endpoint, &minio.Options{ + Creds: miniocred.NewStaticV4(s.AccessKey, s.SecretKey, ""), + Secure: s.UseSSL, + Region: s.Region, + }) + if err != nil { + return 0, fmt.Errorf("minio client: %w", err) + } + f, err := os.Open(localPath) + if err != nil { + return 0, err + } + defer f.Close() + stat, err := f.Stat() + if err != nil { + return 0, err + } + objName := filepath.Base(localPath) + if s.PathPrefix != "" { + objName = strings.TrimRight(s.PathPrefix, "/") + "/" + objName + } + info, err := cl.PutObject(ctx, s.Bucket, objName, f, stat.Size(), + minio.PutObjectOptions{ContentType: "application/gzip"}) + if err != nil { + return 0, fmt.Errorf("put: %w", err) + } + return info.Size, nil +} + +// uploadSFTP — ssh dial + sftp client + Write. Verifiziert Host-Key +// gegen settings.host_key_fingerprint wenn gesetzt (SHA256:...). +func uploadSFTP(ctx context.Context, s SFTPSettings, localPath string) (int64, error) { + if s.Host == "" || s.Port == 0 || s.Username == "" || s.RemoteDir == "" { + return 0, errors.New("sftp settings incomplete (host/port/username/remote_dir required)") + } + var authMethods []ssh.AuthMethod + if s.Password != "" { + authMethods = append(authMethods, ssh.Password(s.Password)) + } + if s.PrivateKeyB64 != "" { + keyBytes, err := base64.StdEncoding.DecodeString(s.PrivateKeyB64) + if err != nil { + return 0, fmt.Errorf("decode private_key: %w", err) + } + signer, err := ssh.ParsePrivateKey(keyBytes) + if err != nil { + return 0, fmt.Errorf("parse private_key: %w", err) + } + authMethods = append(authMethods, ssh.PublicKeys(signer)) + } + if len(authMethods) == 0 { + return 0, errors.New("no auth method (password or private_key required)") + } + + hostKeyCallback := ssh.InsecureIgnoreHostKey() + if fp := strings.TrimSpace(s.HostKeyFingerprint); fp != "" { + expected := fp + hostKeyCallback = func(_ string, _ net.Addr, key ssh.PublicKey) error { + got := ssh.FingerprintSHA256(key) + if got != expected { + return fmt.Errorf("host key mismatch: got %s, expected %s", got, expected) + } + return nil + } + } + + cfg := &ssh.ClientConfig{ + User: s.Username, + Auth: authMethods, + HostKeyCallback: hostKeyCallback, + Timeout: 15 * time.Second, + } + addr := fmt.Sprintf("%s:%d", s.Host, s.Port) + conn, err := ssh.Dial("tcp", addr, cfg) + if err != nil { + return 0, fmt.Errorf("ssh dial %s: %w", addr, err) + } + defer conn.Close() + cl, err := sftp.NewClient(conn) + if err != nil { + return 0, fmt.Errorf("sftp client: %w", err) + } + defer cl.Close() + + // remote-dir anlegen (idempotent) + _ = cl.MkdirAll(s.RemoteDir) + + src, err := os.Open(localPath) + if err != nil { + return 0, err + } + defer src.Close() + stat, err := src.Stat() + if err != nil { + return 0, err + } + dstPath := strings.TrimRight(s.RemoteDir, "/") + "/" + filepath.Base(localPath) + dst, err := cl.Create(dstPath) + if err != nil { + return 0, fmt.Errorf("create remote: %w", err) + } + defer dst.Close() + if _, err := io.Copy(dst, src); err != nil { + return 0, fmt.Errorf("copy: %w", err) + } + return stat.Size(), nil +} + +// Test schickt ein 1KB-Probe-File und löscht es wieder. Failures hier +// = Konfig-Problem, nicht relevant für Operator-Backup. UI-Endpoint +// nutzt das für den „Verbindung testen"-Button. +func (s *Service) Test(ctx context.Context, t Target) error { + tmp, err := os.CreateTemp("", "eg-remote-test-*.txt") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + _, _ = tmp.WriteString("edgeguard remote-target test " + time.Now().Format(time.RFC3339)) + tmp.Close() + + r := s.uploadOne(ctx, t, tmp.Name()) + if !r.OK { + return errors.New(r.Error) + } + // Cleanup: für S3 → DeleteObject, für SFTP → Remove. Lass uns + // bei beiden ignorieren wenn der Cleanup-Delete fehlschlägt — + // der Operator sieht im Bucket halt eine 1KB-Datei. + switch t.Kind { + case KindS3: + var settings S3Settings + if json.Unmarshal(t.Settings, &settings) == nil { + if cl, err := minio.New(settings.Endpoint, &minio.Options{ + Creds: miniocred.NewStaticV4(settings.AccessKey, settings.SecretKey, ""), + Secure: settings.UseSSL, + Region: settings.Region, + }); err == nil { + objName := filepath.Base(tmp.Name()) + if settings.PathPrefix != "" { + objName = strings.TrimRight(settings.PathPrefix, "/") + "/" + objName + } + _ = cl.RemoveObject(ctx, settings.Bucket, objName, minio.RemoveObjectOptions{}) + } + } + case KindSFTP: + var settings SFTPSettings + if json.Unmarshal(t.Settings, &settings) == nil { + var authMethods []ssh.AuthMethod + if settings.Password != "" { + authMethods = append(authMethods, ssh.Password(settings.Password)) + } + if settings.PrivateKeyB64 != "" { + if keyBytes, err := base64.StdEncoding.DecodeString(settings.PrivateKeyB64); err == nil { + if signer, err := ssh.ParsePrivateKey(keyBytes); err == nil { + authMethods = append(authMethods, ssh.PublicKeys(signer)) + } + } + } + if len(authMethods) > 0 { + if conn, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", settings.Host, settings.Port), + &ssh.ClientConfig{User: settings.Username, Auth: authMethods, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second}); err == nil { + if cl, err := sftp.NewClient(conn); err == nil { + _ = cl.Remove(strings.TrimRight(settings.RemoteDir, "/") + "/" + filepath.Base(tmp.Name())) + cl.Close() + } + conn.Close() + } + } + } + } + return nil +} diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index c7df56d..6507bba 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -85,7 +85,7 @@ const NAV: NavSection[] = [ }, ] -const VERSION = '1.0.74' +const VERSION = '1.0.75' // Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen: // -