Files
edgeguard-native/management-ui/src/pages/Backups/RemoteTargets.tsx
Debian 27ac7b53fc 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>
2026-05-13 18:49:02 +02:00

315 lines
12 KiB
TypeScript

import { useState } from 'react'
import {
Alert, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Tooltip, Typography, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import {
ExperimentOutlined, PlusOutlined,
} from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
import apiClient, { isEnvelope } from '../../api/client'
const { Text } = Typography
interface RemoteTarget {
id: number
name: string
kind: 's3' | 'sftp'
target_url: string
settings: Record<string, unknown>
active: boolean
last_upload_at?: string
last_error?: string
}
interface FormValues {
name: string
kind: 's3' | 'sftp'
target_url: string
active: boolean
// S3
endpoint?: string
region?: string
bucket?: string
access_key?: string
secret_key?: string
path_prefix?: string
use_ssl?: boolean
// SFTP
host?: string
port?: number
username?: string
password?: string
private_key?: string
remote_dir?: string
host_key_fingerprint?: string
}
function buildPayload(v: FormValues): RemoteTarget {
const settings: Record<string, unknown> = {}
if (v.kind === 's3') {
if (v.endpoint) settings.endpoint = v.endpoint
if (v.region) settings.region = v.region
if (v.bucket) settings.bucket = v.bucket
if (v.access_key) settings.access_key = v.access_key
if (v.secret_key && v.secret_key !== '***SET***') settings.secret_key = v.secret_key
else if (v.secret_key === '***SET***') settings.secret_key = '***SET***'
if (v.path_prefix) settings.path_prefix = v.path_prefix
settings.use_ssl = !!v.use_ssl
} else {
if (v.host) settings.host = v.host
if (v.port) settings.port = v.port
if (v.username) settings.username = v.username
if (v.password && v.password !== '***SET***') settings.password = v.password
else if (v.password === '***SET***') settings.password = '***SET***'
if (v.private_key && v.private_key !== '***SET***') settings.private_key = v.private_key
else if (v.private_key === '***SET***') settings.private_key = '***SET***'
if (v.remote_dir) settings.remote_dir = v.remote_dir
if (v.host_key_fingerprint) settings.host_key_fingerprint = v.host_key_fingerprint
}
return {
id: 0,
name: v.name,
kind: v.kind,
target_url: v.target_url,
settings,
active: v.active,
}
}
export default function RemoteTargetsTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const [msg, msgCtx] = message.useMessage()
const list = useQuery({
queryKey: ['backup-remotes'],
queryFn: async () => {
const r = await apiClient.get('/backup-remotes')
return isEnvelope(r.data) ? (r.data.data as { remotes: RemoteTarget[] }).remotes : []
},
})
const [edit, setEdit] = useState<RemoteTarget | null>(null)
const [creating, setCreating] = useState(false)
const [form] = Form.useForm<FormValues>()
const kind = Form.useWatch('kind', form)
const create = useMutation({
mutationFn: async (v: FormValues) => { await apiClient.post('/backup-remotes', buildPayload(v)) },
onSuccess: () => {
msg.success(t('common.save')); setCreating(false); form.resetFields()
qc.invalidateQueries({ queryKey: ['backup-remotes'] })
},
onError: (e: Error) => msg.error(e.message),
})
const update = useMutation({
mutationFn: async ({ id, v }: { id: number; v: FormValues }) => {
await apiClient.put(`/backup-remotes/${id}`, buildPayload(v))
},
onSuccess: () => {
msg.success(t('common.save')); setEdit(null); form.resetFields()
qc.invalidateQueries({ queryKey: ['backup-remotes'] })
},
onError: (e: Error) => msg.error(e.message),
})
const del = useMutation({
mutationFn: async (id: number) => { await apiClient.delete(`/backup-remotes/${id}`) },
onSuccess: () => { qc.invalidateQueries({ queryKey: ['backup-remotes'] }) },
onError: (e: Error) => msg.error(e.message),
})
const test = useMutation({
mutationFn: async (id: number) => { await apiClient.post(`/backup-remotes/${id}/test`) },
onSuccess: () => msg.success(t('remotes.testOk')),
onError: (e: Error) => msg.error(t('remotes.testFailed') + ': ' + e.message),
})
const columns: ColumnsType<RemoteTarget> = [
{ title: t('remotes.col.name'), dataIndex: 'name' },
{
title: t('remotes.col.kind'), dataIndex: 'kind', width: 80,
render: (v: string) =>
<Tag color={v === 's3' ? 'blue' : 'purple'}>{v.toUpperCase()}</Tag>,
},
{
title: t('remotes.col.target'), dataIndex: 'target_url',
render: (v: string) =>
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>{v}</Text>,
},
{
title: t('remotes.col.lastUpload'), dataIndex: 'last_upload_at', width: 200,
render: (v?: string, row?) => {
if (!v) return <Text type="secondary"></Text>
const failed = !!row?.last_error
return (
<Space size={4}>
<Text type={failed ? 'danger' : undefined} style={{ fontSize: 12 }}>
{dayjs(v).format('YYYY-MM-DD HH:mm:ss')}
</Text>
{failed && <Tooltip title={row?.last_error}><Tag color="red">FAIL</Tag></Tooltip>}
</Space>
)
},
},
{
title: t('remotes.col.active'), dataIndex: 'active', width: 80,
render: (v: boolean) => v ? <Tag color="green">an</Tag> : <Tag>aus</Tag>,
},
{
title: t('common.actions'), key: 'a', width: 240,
render: (_, r) => (
<Space size={4}>
<Button size="small" icon={<ExperimentOutlined />}
loading={test.isPending}
onClick={() => test.mutate(r.id)}>
{t('remotes.test')}
</Button>
<Button size="small" onClick={() => {
setEdit(r); form.resetFields()
const settings = (r.settings ?? {}) as Record<string, unknown>
form.setFieldsValue({
name: r.name, kind: r.kind, target_url: r.target_url, active: r.active,
endpoint: settings.endpoint as string,
region: settings.region as string,
bucket: settings.bucket as string,
access_key: settings.access_key as string,
secret_key: settings.secret_key as string,
path_prefix: settings.path_prefix as string,
use_ssl: settings.use_ssl as boolean,
host: settings.host as string,
port: settings.port as number,
username: settings.username as string,
password: settings.password as string,
private_key: settings.private_key as string,
remote_dir: settings.remote_dir as string,
host_key_fingerprint: settings.host_key_fingerprint as string,
})
}}>{t('common.edit')}</Button>
<Popconfirm title={t('remotes.confirmDelete', { name: r.name })}
onConfirm={() => del.mutate(r.id)}>
<Button size="small" danger>{t('common.delete')}</Button>
</Popconfirm>
</Space>
),
},
]
return (
<div>
{msgCtx}
<Alert
type="info"
showIcon
className="mb-16"
message={t('remotes.scopeTitle')}
description={t('remotes.scopeDesc')}
/>
<Card size="small" extra={
<Button type="primary" size="small" icon={<PlusOutlined />}
onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ kind: 's3', active: true, use_ssl: true, port: 22 })
}}>
{t('remotes.add')}
</Button>
}>
<Table size="small" rowKey="id" loading={list.isFetching}
dataSource={list.data ?? []} columns={columns}
pagination={false}
locale={{ emptyText: t('remotes.empty') }} />
</Card>
<Modal
title={edit ? t('remotes.editTitle') : t('remotes.addTitle')}
open={edit !== null || creating}
onCancel={() => { setEdit(null); setCreating(false); form.resetFields() }}
onOk={() => form.submit()}
confirmLoading={create.isPending || update.isPending}
width={640}
>
<Form form={form} layout="vertical"
onFinish={(v) => edit ? update.mutate({ id: edit.id, v }) : create.mutate(v)}>
<Form.Item label={t('remotes.col.name')} name="name" rules={[{ required: true }]}>
<Input placeholder="MinIO offsite / Hetzner Storage Box" />
</Form.Item>
<Form.Item label={t('remotes.col.kind')} name="kind" rules={[{ required: true }]}>
<Select options={[
{ value: 's3', label: 'S3 (AWS / MinIO / R2 / B2 / Hetzner Object)' },
{ value: 'sftp', label: 'SFTP (SSH)' },
]} />
</Form.Item>
<Form.Item label={t('remotes.col.target')} name="target_url" rules={[{ required: true }]}
extra={t('remotes.targetExtra')}>
<Input placeholder={kind === 's3' ? 's3://my-bucket' : 'sftp://backup@host.example.com'} />
</Form.Item>
{kind === 's3' && (
<>
<Form.Item label="Endpoint" name="endpoint" rules={[{ required: true }]}
extra="s3.amazonaws.com / minio.example.com:9000 / fsn1.your-objectstorage.com">
<Input />
</Form.Item>
<Form.Item label="Region" name="region">
<Input placeholder="eu-central-1 / auto (R2)" />
</Form.Item>
<Form.Item label="Bucket" name="bucket" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label="Access Key" name="access_key" rules={[{ required: true }]}>
<Input autoComplete="off" />
</Form.Item>
<Form.Item label="Secret Key" name="secret_key" rules={[{ required: !edit }]}>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item label="Path Prefix" name="path_prefix"
extra="z.B. edgeguard/utm-1 — wird vor jedem Filename gesetzt">
<Input />
</Form.Item>
<Form.Item label="HTTPS (use_ssl)" name="use_ssl" valuePropName="checked">
<Switch />
</Form.Item>
</>
)}
{kind === 'sftp' && (
<>
<Form.Item label="Host" name="host" rules={[{ required: true }]}>
<Input placeholder="backup.example.com" />
</Form.Item>
<Form.Item label="Port" name="port" rules={[{ required: true }]}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="Username" name="username" rules={[{ required: true }]}>
<Input autoComplete="off" />
</Form.Item>
<Form.Item label="Password" name="password"
extra="Entweder Password ODER Private-Key.">
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item label="Private Key (OpenSSH, base64)" name="private_key">
<Input.TextArea rows={3} autoComplete="off"
placeholder="base64-encoded OpenSSH private key" />
</Form.Item>
<Form.Item label="Remote Dir" name="remote_dir" rules={[{ required: true }]}
extra="z.B. /backups/edgeguard">
<Input />
</Form.Item>
<Form.Item label="Host-Key-Fingerprint" name="host_key_fingerprint"
extra="optional, SHA256:... — wenn leer wird TOFU verwendet (unsicher gegen MitM)">
<Input placeholder="SHA256:abc123..." />
</Form.Item>
</>
)}
<Form.Item label={t('remotes.col.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</div>
)
}