WP-30/31/32: backup targets, strategies and execution
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

SMB (smbclient) and FTP/FTPS (curl with netrc) targets with encrypted
credentials and connection test; strategies with cron schedule, retention,
optional openssl AES-256 encryption; sources: PVC hostpath tar, pg_dumpall
in the Postgres pod, namespace manifests, host directory. Backup job
collects, encrypts, uploads, verifies size, records sha256 and applies
retention on the target; scheduler starts due strategies. Backups page
with target/strategy forms, run now and history. Restore guide in docs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 23:15:38 +02:00
parent 39af18b336
commit 6134a47ff2
31 changed files with 2718 additions and 87 deletions

View File

@ -14,3 +14,7 @@ COOKIE_SECURE=false
# Directory with the built frontend (served as SPA fallback) # Directory with the built frontend (served as SPA fallback)
FRONTEND_DIR=../frontend/dist FRONTEND_DIR=../frontend/dist
RUST_LOG=info,sqlx=warn RUST_LOG=info,sqlx=warn
# kubectl invocation used for backups (default: microk8s kubectl if present)
#KUBECTL=/snap/bin/microk8s kubectl
# scratch directory for backup archives (default: data/work)
#WORK_DIR=data/work

View File

@ -298,8 +298,8 @@ WP-02 remainder, WP-10, WP-11, WP-12. Verified against the real host: 430 packag
### Milestone 3 – Vulnerability management (delivered 2026-09-02) ### Milestone 3 – Vulnerability management (delivered 2026-09-02)
WP-20, WP-21. Trivy is installed by the deploy script. WP-20, WP-21. Trivy is installed by the deploy script.
### Milestone 4 – Backup management (planned) ### Milestone 4 – Backup management (delivered 2026-09-02)
WP-30, WP-31, WP-32. WP-30, WP-31, WP-32. Restore procedure in `docs/restore.md`.
### Milestone 5 – Hardening and release (planned) ### Milestone 5 – Hardening and release (planned)
WP-40, WP-41, WP-42. WP-40, WP-41, WP-42.
@ -360,9 +360,9 @@ The server is reachable via `ssh softvisor` (as root). Findings from the inspect
| WP-12 | M2 | done | 2026-09-02; overview, restart, scale, set image; upstream tag check not done | | WP-12 | M2 | done | 2026-09-02; overview, restart, scale, set image; upstream tag check not done |
| WP-20 | M3 | done | 2026-09-02; Trivy rootfs + image scans, diff with first/last seen, fixed detection | | WP-20 | M3 | done | 2026-09-02; Trivy rootfs + image scans, diff with first/last seen, fixed detection |
| WP-21 | M3 | done | 2026-09-02; findings UI, acknowledge, mail digest with severity threshold; dashboard widget in WP-40 | | WP-21 | M3 | done | 2026-09-02; findings UI, acknowledge, mail digest with severity threshold; dashboard widget in WP-40 |
| WP-30 | M4 | todo | | | WP-30 | M4 | done | 2026-09-02; SMB via smbclient, FTP/FTPS via curl, credentials encrypted |
| WP-31 | M4 | todo | | | WP-31 | M4 | done | 2026-09-02; sources: PVC hostpath, pg_dumpall, manifests, host dir; openssl encryption |
| WP-32 | M4 | todo | | | WP-32 | M4 | done | 2026-09-02; upload verify, retention, records; docs/restore.md; failure mail not yet |
| WP-40 | M5 | todo | | | WP-40 | M5 | todo | |
| WP-41 | M5 | todo | | | WP-41 | M5 | todo | |
| WP-42 | M5 | todo | | | WP-42 | M5 | todo | |

1
backend/Cargo.lock generated
View File

@ -1100,6 +1100,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"tempfile",
"tokio", "tokio",
"uuid", "uuid",
] ]

View File

@ -0,0 +1,350 @@
//! /api/backups: targets, strategies, records, manual runs.
use application::backup_service::StrategyStatus;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use domain::backup::{BackupRecord, BackupSource, BackupStrategy, BackupTarget, StorageKind};
use domain::jobs::JobKind;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::error::ApiError;
use crate::extract::{AdminUser, AuthUser};
use crate::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/targets", get(list_targets).post(create_target))
.route(
"/targets/{id}",
get(get_target).put(update_target).delete(delete_target),
)
.route("/targets/{id}/test", post(test_target))
.route("/strategies", get(list_strategies).post(create_strategy))
.route(
"/strategies/{id}",
get(get_strategy)
.put(update_strategy)
.delete(delete_strategy),
)
.route("/strategies/{id}/run", post(run_strategy))
.route("/strategies/{id}/records", get(records))
}
// ---- targets ----
#[derive(Serialize, ToSchema)]
pub struct TargetView {
pub id: Uuid,
pub name: String,
#[schema(value_type = String)]
pub kind: StorageKind,
pub host: String,
pub port: Option<u16>,
pub share: String,
pub path: String,
pub username: String,
pub tls: bool,
pub password_set: bool,
}
impl From<BackupTarget> for TargetView {
fn from(t: BackupTarget) -> Self {
Self {
id: t.id,
name: t.name,
kind: t.kind,
host: t.host,
port: t.port,
share: t.share,
path: t.path,
username: t.username,
tls: t.tls,
password_set: !t.password.is_empty(),
}
}
}
#[derive(Deserialize, ToSchema)]
pub struct TargetRequest {
pub name: String,
#[schema(value_type = String)]
pub kind: StorageKind,
pub host: String,
pub port: Option<u16>,
#[serde(default)]
pub share: String,
#[serde(default)]
pub path: String,
#[serde(default)]
pub username: String,
/// Empty keeps the stored password on update.
#[serde(default)]
pub password: String,
#[serde(default)]
pub tls: bool,
}
impl TargetRequest {
fn into_target(self, id: Uuid) -> BackupTarget {
BackupTarget {
id,
name: self.name.trim().into(),
kind: self.kind,
host: self.host.trim().into(),
port: self.port,
share: self.share.trim().into(),
path: self.path.trim().into(),
username: self.username,
password: self.password,
tls: self.tls,
}
}
}
#[utoipa::path(get, path = "/api/backups/targets", tag = "backups", security(("bearer" = [])), responses((status = 200, body = Vec<TargetView>)))]
async fn list_targets(
State(s): State<AppState>,
_: AuthUser,
) -> Result<Json<Vec<TargetView>>, ApiError> {
Ok(Json(
s.backups
.list_targets()
.await?
.into_iter()
.map(Into::into)
.collect(),
))
}
#[utoipa::path(get, path = "/api/backups/targets/{id}", tag = "backups", security(("bearer" = [])), responses((status = 200, body = TargetView), (status = 404)))]
async fn get_target(
State(s): State<AppState>,
_: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<TargetView>, ApiError> {
Ok(Json(s.backups.get_target(id).await?.into()))
}
#[utoipa::path(post, path = "/api/backups/targets", tag = "backups", security(("bearer" = [])), request_body = TargetRequest, responses((status = 201, body = TargetView), (status = 422)))]
async fn create_target(
State(s): State<AppState>,
_: AdminUser,
Json(req): Json<TargetRequest>,
) -> Result<(StatusCode, Json<TargetView>), ApiError> {
Ok((
StatusCode::CREATED,
Json(
s.backups
.create_target(req.into_target(Uuid::nil()))
.await?
.into(),
),
))
}
#[utoipa::path(put, path = "/api/backups/targets/{id}", tag = "backups", security(("bearer" = [])), request_body = TargetRequest, responses((status = 200, body = TargetView), (status = 404), (status = 422)))]
async fn update_target(
State(s): State<AppState>,
_: AdminUser,
Path(id): Path<Uuid>,
Json(req): Json<TargetRequest>,
) -> Result<Json<TargetView>, ApiError> {
Ok(Json(
s.backups.update_target(req.into_target(id)).await?.into(),
))
}
#[utoipa::path(delete, path = "/api/backups/targets/{id}", tag = "backups", security(("bearer" = [])), responses((status = 204), (status = 404), (status = 409)))]
async fn delete_target(
State(s): State<AppState>,
_: AdminUser,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
s.backups.delete_target(id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(post, path = "/api/backups/targets/{id}/test", tag = "backups", security(("bearer" = [])), responses((status = 204), (status = 404), (status = 502)))]
async fn test_target(
State(s): State<AppState>,
_: AdminUser,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
s.backups.test_target(id).await?;
Ok(StatusCode::NO_CONTENT)
}
// ---- strategies ----
#[derive(Serialize, ToSchema)]
pub struct StrategyView {
pub id: Uuid,
pub name: String,
#[schema(value_type = Object)]
pub source: BackupSource,
pub schedule: String,
pub target_id: Uuid,
pub retention: u32,
pub encrypted: bool,
pub enabled: bool,
}
impl From<BackupStrategy> for StrategyView {
fn from(s: BackupStrategy) -> Self {
Self {
id: s.id,
name: s.name,
source: s.source,
schedule: s.schedule,
target_id: s.target_id,
retention: s.retention,
encrypted: s.passphrase.is_some(),
enabled: s.enabled,
}
}
}
#[derive(Serialize, ToSchema)]
pub struct StrategyStatusView {
#[serde(flatten)]
pub strategy: StrategyView,
pub target_name: String,
#[schema(value_type = Option<Object>)]
pub last_backup: Option<BackupRecord>,
}
impl From<StrategyStatus> for StrategyStatusView {
fn from(s: StrategyStatus) -> Self {
Self {
strategy: s.strategy.into(),
target_name: s.target_name,
last_backup: s.last_backup,
}
}
}
#[derive(Deserialize, ToSchema)]
pub struct StrategyRequest {
pub name: String,
#[schema(value_type = Object)]
pub source: BackupSource,
pub schedule: String,
pub target_id: Uuid,
pub retention: u32,
/// `null` = no encryption; empty string keeps the stored passphrase on update.
#[serde(default)]
pub passphrase: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_true() -> bool {
true
}
impl StrategyRequest {
fn into_strategy(self, id: Uuid) -> BackupStrategy {
BackupStrategy {
id,
name: self.name.trim().into(),
source: self.source,
schedule: self.schedule.trim().into(),
target_id: self.target_id,
retention: self.retention,
passphrase: self.passphrase,
enabled: self.enabled,
}
}
}
#[utoipa::path(get, path = "/api/backups/strategies", tag = "backups", security(("bearer" = [])), responses((status = 200, body = Vec<StrategyStatusView>)))]
async fn list_strategies(
State(s): State<AppState>,
_: AuthUser,
) -> Result<Json<Vec<StrategyStatusView>>, ApiError> {
Ok(Json(
s.backups
.list_strategies()
.await?
.into_iter()
.map(Into::into)
.collect(),
))
}
#[utoipa::path(get, path = "/api/backups/strategies/{id}", tag = "backups", security(("bearer" = [])), responses((status = 200, body = StrategyView), (status = 404)))]
async fn get_strategy(
State(s): State<AppState>,
_: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<StrategyView>, ApiError> {
Ok(Json(s.backups.get_strategy(id).await?.into()))
}
#[utoipa::path(post, path = "/api/backups/strategies", tag = "backups", security(("bearer" = [])), request_body = StrategyRequest, responses((status = 201, body = StrategyView), (status = 404), (status = 422)))]
async fn create_strategy(
State(s): State<AppState>,
_: AdminUser,
Json(req): Json<StrategyRequest>,
) -> Result<(StatusCode, Json<StrategyView>), ApiError> {
Ok((
StatusCode::CREATED,
Json(
s.backups
.create_strategy(req.into_strategy(Uuid::nil()))
.await?
.into(),
),
))
}
#[utoipa::path(put, path = "/api/backups/strategies/{id}", tag = "backups", security(("bearer" = [])), request_body = StrategyRequest, responses((status = 200, body = StrategyView), (status = 404), (status = 422)))]
async fn update_strategy(
State(s): State<AppState>,
_: AdminUser,
Path(id): Path<Uuid>,
Json(req): Json<StrategyRequest>,
) -> Result<Json<StrategyView>, ApiError> {
Ok(Json(
s.backups
.update_strategy(req.into_strategy(id))
.await?
.into(),
))
}
#[utoipa::path(delete, path = "/api/backups/strategies/{id}", tag = "backups", security(("bearer" = [])), responses((status = 204), (status = 404)))]
async fn delete_strategy(
State(s): State<AppState>,
_: AdminUser,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
s.backups.delete_strategy(id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(post, path = "/api/backups/strategies/{id}/run", tag = "backups", security(("bearer" = [])), responses((status = 202, body = crate::jobs::JobRunDto), (status = 404), (status = 409)))]
async fn run_strategy(
State(s): State<AppState>,
AdminUser(admin): AdminUser,
Path(id): Path<Uuid>,
) -> Result<(StatusCode, Json<crate::jobs::JobRunDto>), ApiError> {
s.backups.get_strategy(id).await?;
let run = s
.jobs
.start(JobKind::Backup, Some(id.to_string()), &admin.email)
.await?;
Ok((StatusCode::ACCEPTED, Json(run.into())))
}
#[utoipa::path(get, path = "/api/backups/strategies/{id}/records", tag = "backups", security(("bearer" = [])), responses((status = 200, body = Vec<Object>)))]
async fn records(
State(s): State<AppState>,
_: AuthUser,
Path(id): Path<Uuid>,
) -> Result<Json<Vec<BackupRecord>>, ApiError> {
Ok(Json(s.backups.records(id).await?))
}

View File

@ -11,6 +11,10 @@ pub struct Config {
pub fake_host: bool, pub fake_host: bool,
/// Path to a kubeconfig; None infers. Defaults to the microk8s client config if present. /// Path to a kubeconfig; None infers. Defaults to the microk8s client config if present.
pub kubeconfig: Option<String>, pub kubeconfig: Option<String>,
/// kubectl invocation for backups, e.g. ["/snap/bin/microk8s", "kubectl"].
pub kubectl: Vec<String>,
/// Scratch directory for backup archives.
pub work_dir: std::path::PathBuf,
pub bind: SocketAddr, pub bind: SocketAddr,
pub bootstrap_admin: Option<(String, String)>, pub bootstrap_admin: Option<(String, String)>,
pub cookie_secure: bool, pub cookie_secure: bool,
@ -34,6 +38,18 @@ impl Config {
jwt_secret, jwt_secret,
master_key, master_key,
fake_host: env("FAKE_HOST").is_some_and(|v| v == "true" || v == "1"), fake_host: env("FAKE_HOST").is_some_and(|v| v == "true" || v == "1"),
kubectl: env("KUBECTL")
.map(|v| v.split_whitespace().map(String::from).collect())
.unwrap_or_else(|| {
if std::path::Path::new("/snap/bin/microk8s").exists() {
vec!["/snap/bin/microk8s".into(), "kubectl".into()]
} else {
vec!["kubectl".into()]
}
}),
work_dir: env("WORK_DIR")
.map(Into::into)
.unwrap_or_else(|| "data/work".into()),
kubeconfig: env("KUBECONFIG").or_else(|| { kubeconfig: env("KUBECONFIG").or_else(|| {
let microk8s = "/var/snap/microk8s/current/credentials/client.config"; let microk8s = "/var/snap/microk8s/current/credentials/client.config";
std::path::Path::new(microk8s) std::path::Path::new(microk8s)

View File

@ -1,5 +1,6 @@
//! HTTP API layer (axum). `build_app` is used by both the binary and the integration tests. //! HTTP API layer (axum). `build_app` is used by both the binary and the integration tests.
pub mod auth; pub mod auth;
pub mod backups;
pub mod cluster; pub mod cluster;
pub mod config; pub mod config;
pub mod error; pub mod error;
@ -17,18 +18,24 @@ use std::sync::Arc;
use application::scheduler::Scheduler; use application::scheduler::Scheduler;
use application::{ use application::{
AuthService, ClusterService, InventoryService, JobRunner, PackageRefreshJob, PackageUpgradeJob, AuthService, BackupDeps, BackupJob, BackupService, ClusterService, InventoryService, JobRunner,
SettingsService, UserService, VulnerabilityScanJob, VulnerabilityService, PackageRefreshJob, PackageUpgradeJob, SettingsService, UserService, VulnerabilityScanJob,
VulnerabilityService,
}; };
use axum::{routing::get, Json, Router}; use axum::{routing::get, Json, Router};
use domain::jobs::JobKind; use domain::jobs::JobKind;
use domain::ports::Mailer; use domain::ports::Mailer;
use domain::ports::{ClusterGateway, HostInspector, HostUpdater, VulnerabilityScanner}; use domain::ports::{
BackupCollector, BackupStorage, ClusterGateway, FileEncryptor, HostInspector, HostUpdater,
VulnerabilityScanner,
};
use infrastructure::{ use infrastructure::{
AesGcmCipher, Argon2Hasher, DbPool, DebianInspector, DebianUpdater, FakeClusterGateway, AesGcmCipher, Argon2Hasher, CommandBackupStorage, DbPool, DebianInspector, DebianUpdater,
FakeHostInspector, FakeHostUpdater, FakeScanner, JwtIssuer, KubeGateway, LettreMailer, DirBackupStorage, FakeBackupCollector, FakeClusterGateway, FakeHostInspector, FakeHostUpdater,
SqliteAuditLog, SqliteFindings, SqliteInventory, SqliteJobRuns, SqliteRefreshTokens, FakeScanner, JwtIssuer, KubeBackupCollector, KubeGateway, LettreMailer, OpensslEncryptor,
SqliteSettings, SqliteUsers, SystemCommandRunner, TrivyScanner, SqliteAuditLog, SqliteBackupRecords, SqliteBackupStrategies, SqliteBackupTargets,
SqliteFindings, SqliteInventory, SqliteJobRuns, SqliteRefreshTokens, SqliteSettings,
SqliteUsers, SystemCommandRunner, TrivyScanner,
}; };
use tower_http::services::{ServeDir, ServeFile}; use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
@ -42,6 +49,9 @@ pub struct Adapters {
pub updater: Arc<dyn HostUpdater>, pub updater: Arc<dyn HostUpdater>,
pub cluster: Arc<dyn ClusterGateway>, pub cluster: Arc<dyn ClusterGateway>,
pub scanner: Arc<dyn VulnerabilityScanner>, pub scanner: Arc<dyn VulnerabilityScanner>,
pub storage: Arc<dyn BackupStorage>,
pub collector: Arc<dyn BackupCollector>,
pub encryptor: Arc<dyn FileEncryptor>,
} }
#[derive(Clone)] #[derive(Clone)]
@ -54,6 +64,7 @@ pub struct AppState {
pub inventory: Arc<InventoryService>, pub inventory: Arc<InventoryService>,
pub cluster: Arc<ClusterService>, pub cluster: Arc<ClusterService>,
pub vulns: Arc<VulnerabilityService>, pub vulns: Arc<VulnerabilityService>,
pub backups: Arc<BackupService>,
pub login_limiter: Arc<rate_limit::RateLimiter>, pub login_limiter: Arc<rate_limit::RateLimiter>,
} }
@ -68,6 +79,9 @@ impl AppState {
updater: Arc::new(FakeHostUpdater), updater: Arc::new(FakeHostUpdater),
cluster: Arc::new(FakeClusterGateway), cluster: Arc::new(FakeClusterGateway),
scanner: Arc::new(FakeScanner), scanner: Arc::new(FakeScanner),
storage: Arc::new(DirBackupStorage::new(cfg.work_dir.join("fake-remote"))),
collector: Arc::new(FakeBackupCollector),
encryptor: Arc::new(OpensslEncryptor::new(runner.clone())),
} }
} else { } else {
Adapters { Adapters {
@ -75,7 +89,13 @@ impl AppState {
inspector: Arc::new(DebianInspector::new(runner.clone())), inspector: Arc::new(DebianInspector::new(runner.clone())),
updater: Arc::new(DebianUpdater::new(runner.clone())), updater: Arc::new(DebianUpdater::new(runner.clone())),
cluster: Arc::new(KubeGateway::new(cfg.kubeconfig.clone())), cluster: Arc::new(KubeGateway::new(cfg.kubeconfig.clone())),
scanner: Arc::new(TrivyScanner::new(runner)), scanner: Arc::new(TrivyScanner::new(runner.clone())),
storage: Arc::new(CommandBackupStorage::new(runner.clone())),
collector: Arc::new(KubeBackupCollector::new(
runner.clone(),
cfg.kubectl.clone(),
)),
encryptor: Arc::new(OpensslEncryptor::new(runner)),
} }
}; };
Self::with_adapters(cfg, pool, adapters, |r| r) Self::with_adapters(cfg, pool, adapters, |r| r)
@ -94,6 +114,9 @@ impl AppState {
updater, updater,
cluster, cluster,
scanner, scanner,
storage,
collector,
encryptor,
} = adapters; } = adapters;
let pool_for_findings = pool.clone(); let pool_for_findings = pool.clone();
let cluster_gateway = cluster.clone(); let cluster_gateway = cluster.clone();
@ -107,6 +130,16 @@ impl AppState {
Arc::new(JwtIssuer::new(&cfg.jwt_secret)), Arc::new(JwtIssuer::new(&cfg.jwt_secret)),
); );
let cipher = Arc::new(AesGcmCipher::from_hex(&cfg.master_key)?); let cipher = Arc::new(AesGcmCipher::from_hex(&cfg.master_key)?);
let backups = Arc::new(BackupService::new(BackupDeps {
targets: Arc::new(SqliteBackupTargets(pool.clone())),
strategies: Arc::new(SqliteBackupStrategies(pool.clone())),
records: Arc::new(SqliteBackupRecords(pool.clone())),
storage,
collector,
encryptor,
cipher: cipher.clone(),
work_dir: cfg.work_dir.clone(),
}));
let settings = Arc::new(SettingsService::new( let settings = Arc::new(SettingsService::new(
Arc::new(SqliteSettings(pool.clone())), Arc::new(SqliteSettings(pool.clone())),
cipher, cipher,
@ -135,10 +168,14 @@ impl AppState {
cluster_gateway, cluster_gateway,
settings.clone(), settings.clone(),
)); ));
let jobs = Arc::new(register_jobs(runner.register( let jobs = Arc::new(register_jobs(
JobKind::VulnerabilityScan, runner
Arc::new(VulnerabilityScanJob(vulns.clone())), .register(
))); JobKind::VulnerabilityScan,
Arc::new(VulnerabilityScanJob(vulns.clone())),
)
.register(JobKind::Backup, Arc::new(BackupJob(backups.clone()))),
));
Ok(Self { Ok(Self {
cfg, cfg,
auth: Arc::new(auth), auth: Arc::new(auth),
@ -148,6 +185,7 @@ impl AppState {
inventory, inventory,
cluster, cluster,
vulns, vulns,
backups,
login_limiter: Arc::new(rate_limit::RateLimiter::new( login_limiter: Arc::new(rate_limit::RateLimiter::new(
10, 10,
std::time::Duration::from_secs(60), std::time::Duration::from_secs(60),
@ -157,7 +195,11 @@ impl AppState {
/// Spawn the cron scheduler on the current runtime. /// Spawn the cron scheduler on the current runtime.
pub fn start_scheduler(&self) { pub fn start_scheduler(&self) {
tokio::spawn(Scheduler::new(self.jobs.clone(), self.settings.clone()).run()); tokio::spawn(
Scheduler::new(self.jobs.clone(), self.settings.clone())
.with_backups(self.backups.clone())
.run(),
);
} }
pub async fn bootstrap(&self) -> anyhow::Result<()> { pub async fn bootstrap(&self) -> anyhow::Result<()> {
@ -187,6 +229,7 @@ pub fn build_app(state: AppState) -> Router {
.nest("/api/system", system::router()) .nest("/api/system", system::router())
.nest("/api/cluster", cluster::router()) .nest("/api/cluster", cluster::router())
.nest("/api/vulnerabilities", vulnerabilities::router()) .nest("/api/vulnerabilities", vulnerabilities::router())
.nest("/api/backups", backups::router())
.fallback_service(spa) .fallback_service(spa)
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.with_state(state) .with_state(state)

View File

@ -16,6 +16,7 @@ async fn main() -> anyhow::Result<()> {
{ {
std::fs::create_dir_all(dir)?; std::fs::create_dir_all(dir)?;
} }
std::fs::create_dir_all(&cfg.work_dir)?;
let pool = infrastructure::connect(&cfg.database_url).await?; let pool = infrastructure::connect(&cfg.database_url).await?;
let state = AppState::new(cfg.clone(), pool)?; let state = AppState::new(cfg.clone(), pool)?;
state.bootstrap().await?; state.bootstrap().await?;

View File

@ -30,6 +30,10 @@ impl Modify for BearerAuth {
crate::cluster::overview, crate::cluster::restart, crate::cluster::scale, crate::cluster::set_image, crate::cluster::overview, crate::cluster::restart, crate::cluster::scale, crate::cluster::set_image,
crate::vulnerabilities::list, crate::vulnerabilities::summary, crate::vulnerabilities::targets, crate::vulnerabilities::set_status, crate::vulnerabilities::list, crate::vulnerabilities::summary, crate::vulnerabilities::targets, crate::vulnerabilities::set_status,
crate::settings::get_notifications, crate::settings::put_notifications, crate::settings::get_notifications, crate::settings::put_notifications,
crate::backups::list_targets, crate::backups::get_target, crate::backups::create_target, crate::backups::update_target,
crate::backups::delete_target, crate::backups::test_target, crate::backups::list_strategies, crate::backups::get_strategy,
crate::backups::create_strategy, crate::backups::update_strategy, crate::backups::delete_strategy,
crate::backups::run_strategy, crate::backups::records,
), ),
modifiers(&BearerAuth) modifiers(&BearerAuth)
)] )]

View File

@ -17,6 +17,8 @@ pub fn test_config() -> Config {
master_key: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f".into(), master_key: "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f".into(),
fake_host: true, fake_host: true,
kubeconfig: None, kubeconfig: None,
kubectl: vec!["kubectl".into()],
work_dir: std::env::temp_dir().join(format!("monitoring-test-{}", uuid::Uuid::new_v4())),
bind: "127.0.0.1:0".parse().unwrap(), bind: "127.0.0.1:0".parse().unwrap(),
bootstrap_admin: None, bootstrap_admin: None,
cookie_secure: false, cookie_secure: false,
@ -86,6 +88,13 @@ async fn build_test_app_with(cfg: Config) -> Router {
updater: Arc::new(infrastructure::FakeHostUpdater), updater: Arc::new(infrastructure::FakeHostUpdater),
cluster: Arc::new(infrastructure::FakeClusterGateway), cluster: Arc::new(infrastructure::FakeClusterGateway),
scanner: Arc::new(infrastructure::FakeScanner), scanner: Arc::new(infrastructure::FakeScanner),
storage: Arc::new(infrastructure::DirBackupStorage::new(
cfg.work_dir.join("remote"),
)),
collector: Arc::new(infrastructure::FakeBackupCollector),
encryptor: Arc::new(infrastructure::OpensslEncryptor::new(Arc::new(
infrastructure::SystemCommandRunner,
))),
}; };
let state = AppState::with_adapters(cfg, pool, adapters, register_test_jobs).expect("state"); let state = AppState::with_adapters(cfg, pool, adapters, register_test_jobs).expect("state");
state.bootstrap().await.expect("bootstrap"); state.bootstrap().await.expect("bootstrap");

View File

@ -58,17 +58,25 @@ async fn unknown_kind_is_404_and_run_is_admin_only() {
.status, .status,
StatusCode::UNPROCESSABLE_ENTITY StatusCode::UNPROCESSABLE_ENTITY
); );
assert_eq!( // every kind has a handler now; a backup without a strategy id starts and then fails
post( let run = post(
&app, &app,
"/api/jobs/run", "/api/jobs/run",
json!({"kind": "backup"}), json!({"kind": "backup"}),
Some(&admin) Some(&admin),
) )
.await .await;
.status, assert_eq!(run.status, StatusCode::ACCEPTED);
StatusCode::NOT_FOUND let id = run.json["id"].as_str().unwrap().to_string();
); for _ in 0..50 {
let r = get(&app, &format!("/api/jobs/{id}"), Some(&admin)).await;
if r.json["status"] != "running" {
assert_eq!(r.json["status"], "failed");
assert!(r.json["log"].as_str().unwrap().contains("strategy id"));
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
post(&app, "/api/users", json!({"email": "u@x.de", "display_name": "U", "password": "user-password-123", "role": "user"}), Some(&admin)).await; post(&app, "/api/users", json!({"email": "u@x.de", "display_name": "U", "password": "user-password-123", "role": "user"}), Some(&admin)).await;
let user = common::login(&app, "u@x.de", "user-password-123") let user = common::login(&app, "u@x.de", "user-password-123")
.await .await

View File

@ -10,9 +10,11 @@ use domain::ports::{
BackupTargetRepository, Cipher, FileEncryptor, BackupTargetRepository, Cipher, FileEncryptor,
}; };
use domain::DomainError; use domain::DomainError;
use sha2::{Digest, Sha256};
use uuid::Uuid; use uuid::Uuid;
use crate::jobs::{JobHandler, JobLog}; use crate::jobs::{JobHandler, JobLog};
use crate::scheduler::{is_due, validate_cron};
pub struct BackupDeps { pub struct BackupDeps {
pub targets: Arc<dyn BackupTargetRepository>, pub targets: Arc<dyn BackupTargetRepository>,
@ -42,65 +44,316 @@ impl BackupService {
Self { d: deps } Self { d: deps }
} }
fn decrypt_target(&self, mut t: BackupTarget) -> Result<BackupTarget, DomainError> {
if !t.password.is_empty() {
t.password = self.d.cipher.decrypt(&t.password)?;
}
Ok(t)
}
fn encrypt_target(&self, mut t: BackupTarget) -> Result<BackupTarget, DomainError> {
if !t.password.is_empty() {
t.password = self.d.cipher.encrypt(&t.password)?;
}
Ok(t)
}
fn decrypt_strategy(&self, mut s: BackupStrategy) -> Result<BackupStrategy, DomainError> {
if let Some(p) = &s.passphrase {
s.passphrase = Some(self.d.cipher.decrypt(p)?);
}
Ok(s)
}
fn encrypt_strategy(&self, mut s: BackupStrategy) -> Result<BackupStrategy, DomainError> {
if let Some(p) = &s.passphrase {
s.passphrase = Some(self.d.cipher.encrypt(p)?);
}
Ok(s)
}
// ---- targets ---- // ---- targets ----
pub async fn list_targets(&self) -> Result<Vec<BackupTarget>, DomainError> { pub async fn list_targets(&self) -> Result<Vec<BackupTarget>, DomainError> {
todo!() self.d
.targets
.list()
.await?
.into_iter()
.map(|t| self.decrypt_target(t))
.collect()
} }
pub async fn get_target(&self, _id: Uuid) -> Result<BackupTarget, DomainError> {
todo!() pub async fn get_target(&self, id: Uuid) -> Result<BackupTarget, DomainError> {
self.decrypt_target(self.d.targets.get(id).await?.ok_or(DomainError::NotFound)?)
} }
pub async fn create_target(&self, _t: BackupTarget) -> Result<BackupTarget, DomainError> {
todo!() pub async fn create_target(&self, mut t: BackupTarget) -> Result<BackupTarget, DomainError> {
t.validate()?;
t.id = Uuid::new_v4();
self.d
.targets
.insert(&self.encrypt_target(t.clone())?)
.await?;
Ok(t)
} }
/// Empty password keeps the stored one. /// Empty password keeps the stored one.
pub async fn update_target(&self, _t: BackupTarget) -> Result<BackupTarget, DomainError> { pub async fn update_target(&self, mut t: BackupTarget) -> Result<BackupTarget, DomainError> {
todo!() t.validate()?;
let current = self.get_target(t.id).await?;
if t.password.is_empty() {
t.password = current.password;
}
self.d
.targets
.update(&self.encrypt_target(t.clone())?)
.await?;
Ok(t)
} }
/// Fails with `Conflict` while a strategy still uses the target. /// Fails with `Conflict` while a strategy still uses the target.
pub async fn delete_target(&self, _id: Uuid) -> Result<(), DomainError> { pub async fn delete_target(&self, id: Uuid) -> Result<(), DomainError> {
todo!() if let Some(s) = self
.d
.strategies
.list()
.await?
.into_iter()
.find(|s| s.target_id == id)
{
return Err(DomainError::Conflict(format!(
"target is used by strategy '{}'",
s.name
)));
}
self.d.targets.delete(id).await
} }
pub async fn test_target(&self, _id: Uuid) -> Result<(), DomainError> {
todo!() pub async fn test_target(&self, id: Uuid) -> Result<(), DomainError> {
let t = self.get_target(id).await?;
self.d.storage.test(&t).await
} }
// ---- strategies ---- // ---- strategies ----
pub async fn list_strategies(&self) -> Result<Vec<StrategyStatus>, DomainError> { pub async fn list_strategies(&self) -> Result<Vec<StrategyStatus>, DomainError> {
todo!() let targets = self.d.targets.list().await?;
let mut out = Vec::new();
for s in self.d.strategies.list().await? {
let target_name = targets
.iter()
.find(|t| t.id == s.target_id)
.map(|t| t.name.clone())
.unwrap_or_default();
let last_backup = self.d.records.list_for(s.id, 1).await?.into_iter().next();
let mut strategy = self.decrypt_strategy(s)?;
// never expose the passphrase in listings
strategy.passphrase = strategy.passphrase.map(|_| String::new());
out.push(StrategyStatus {
strategy,
target_name,
last_backup,
});
}
Ok(out)
} }
pub async fn get_strategy(&self, _id: Uuid) -> Result<BackupStrategy, DomainError> {
todo!() pub async fn get_strategy(&self, id: Uuid) -> Result<BackupStrategy, DomainError> {
self.decrypt_strategy(
self.d
.strategies
.get(id)
.await?
.ok_or(DomainError::NotFound)?,
)
} }
pub async fn create_strategy(&self, _s: BackupStrategy) -> Result<BackupStrategy, DomainError> {
todo!() async fn validate_strategy(&self, s: &BackupStrategy) -> Result<(), DomainError> {
s.validate()?;
validate_cron(&s.schedule)?;
self.d
.targets
.get(s.target_id)
.await?
.ok_or(DomainError::NotFound)?;
Ok(())
} }
pub async fn create_strategy(
&self,
mut s: BackupStrategy,
) -> Result<BackupStrategy, DomainError> {
s.passphrase = s.passphrase.filter(|p| !p.is_empty());
self.validate_strategy(&s).await?;
s.id = Uuid::new_v4();
self.d
.strategies
.insert(&self.encrypt_strategy(s.clone())?)
.await?;
Ok(s)
}
/// Empty passphrase keeps the stored one; `None` removes it. /// Empty passphrase keeps the stored one; `None` removes it.
pub async fn update_strategy(&self, _s: BackupStrategy) -> Result<BackupStrategy, DomainError> { pub async fn update_strategy(
todo!() &self,
mut s: BackupStrategy,
) -> Result<BackupStrategy, DomainError> {
let current = self.get_strategy(s.id).await?;
if s.passphrase.as_deref() == Some("") {
s.passphrase = current.passphrase;
}
self.validate_strategy(&s).await?;
self.d
.strategies
.update(&self.encrypt_strategy(s.clone())?)
.await?;
Ok(s)
} }
pub async fn delete_strategy(&self, _id: Uuid) -> Result<(), DomainError> {
todo!() pub async fn delete_strategy(&self, id: Uuid) -> Result<(), DomainError> {
self.d.strategies.delete(id).await
} }
pub async fn records(&self, _strategy_id: Uuid) -> Result<Vec<BackupRecord>, DomainError> {
todo!() pub async fn records(&self, strategy_id: Uuid) -> Result<Vec<BackupRecord>, DomainError> {
self.d.records.list_for(strategy_id, 100).await
} }
/// Enabled strategies whose cron fired since their last backup. /// Enabled strategies whose cron fired since their last backup.
pub async fn due_strategies( pub async fn due_strategies(
&self, &self,
_now: DateTime<Utc>, now: DateTime<Utc>,
_grace_secs: i64, grace_secs: i64,
) -> Result<Vec<Uuid>, DomainError> { ) -> Result<Vec<Uuid>, DomainError> {
todo!() let mut due = Vec::new();
for s in self
.d
.strategies
.list()
.await?
.into_iter()
.filter(|s| s.enabled)
{
let last = self
.d
.records
.list_for(s.id, 1)
.await?
.first()
.map(|r| r.created_at);
if is_due(&s.schedule, last, now, grace_secs) {
due.push(s.id);
}
}
Ok(due)
} }
/// Collect, encrypt, upload, verify, record, apply retention. /// Collect, encrypt, upload, verify, record, apply retention.
pub async fn run_strategy( pub async fn run_strategy(
&self, &self,
_id: Uuid, id: Uuid,
_log: &dyn JobLog, log: &dyn JobLog,
) -> Result<BackupRecord, DomainError> { ) -> Result<BackupRecord, DomainError> {
todo!() let strategy = self.get_strategy(id).await?;
let target = self.get_target(strategy.target_id).await?;
let work = self.d.work_dir.join(format!("run-{}", Uuid::new_v4()));
std::fs::create_dir_all(&work)
.map_err(|e| DomainError::Storage(format!("work dir: {e}")))?;
let result = self.run_in(&strategy, &target, &work, log).await;
let _ = std::fs::remove_dir_all(&work);
result
}
async fn run_in(
&self,
strategy: &BackupStrategy,
target: &BackupTarget,
work: &std::path::Path,
log: &dyn JobLog,
) -> Result<BackupRecord, DomainError> {
let started = Utc::now();
log.line(&format!(
"strategy '{}' -> target '{}' ({})",
strategy.name,
target.name,
target.kind.as_str()
))
.await;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let sink = crate::upgrade_service::channel_sink(tx);
let collect = async {
let r = self
.d
.collector
.collect(&strategy.source, work, &sink)
.await;
drop(sink);
r
};
let drain = async {
while let Some(l) = rx.recv().await {
log.line(&l).await;
}
};
let (archive, _) = tokio::join!(collect, drain);
let mut file = archive?;
if let Some(pass) = &strategy.passphrase {
log.line("encrypting archive").await;
file = self.d.encryptor.encrypt(&file, pass).await?;
}
let bytes =
std::fs::read(&file).map_err(|e| DomainError::Storage(format!("read archive: {e}")))?;
let size_bytes = bytes.len() as u64;
let sha256 = hex::encode(Sha256::digest(&bytes));
drop(bytes);
let filename = strategy.filename(started);
log.line(&format!("uploading {filename} ({size_bytes} bytes)"))
.await;
self.d.storage.upload(target, &file, &filename).await?;
let remote = self.d.storage.list(target).await?;
match remote.iter().find(|f| f.name == filename) {
Some(f) if f.size_bytes == size_bytes => {
log.line(&format!("uploaded and verified {filename}")).await
}
Some(f) => {
return Err(DomainError::Unavailable(format!(
"size mismatch after upload: {} != {size_bytes}",
f.size_bytes
)))
}
None => {
return Err(DomainError::Unavailable(
"file not found on target after upload".into(),
))
}
}
let record = BackupRecord {
id: Uuid::new_v4(),
strategy_id: strategy.id,
filename: filename.clone(),
size_bytes,
sha256,
created_at: started,
};
self.d.records.insert(&record).await?;
// retention: keep the newest N files of this strategy on the target
let prefix = format!("{}_", strategy.slug());
let mut mine: Vec<String> = remote
.iter()
.map(|f| f.name.clone())
.filter(|n| n.starts_with(&prefix))
.collect();
mine.sort();
mine.reverse();
for old in mine.iter().skip(strategy.retention as usize) {
log.line(&format!("retention: deleting {old}")).await;
self.d.storage.delete(target, old).await?;
self.d.records.delete_by_filename(strategy.id, old).await?;
}
Ok(record)
} }
} }
@ -109,7 +362,22 @@ pub struct BackupJob(pub Arc<BackupService>);
#[async_trait] #[async_trait]
impl JobHandler for BackupJob { impl JobHandler for BackupJob {
async fn run(&self, _params: Option<String>, _log: &dyn JobLog) -> Result<(), String> { async fn run(&self, params: Option<String>, log: &dyn JobLog) -> Result<(), String> {
todo!() let id: Uuid = params
.as_deref()
.filter(|p| !p.is_empty())
.ok_or("backup job needs a strategy id as params")?
.parse()
.map_err(|e| format!("invalid strategy id: {e}"))?;
let record = self.0.run_strategy(id, log).await.map_err(|e| match e {
DomainError::NotFound => "strategy not found".to_string(),
e => e.to_string(),
})?;
log.line(&format!(
"backup complete: {} ({} bytes, sha256 {})",
record.filename, record.size_bytes, record.sha256
))
.await;
Ok(())
} }
} }

View File

@ -8,7 +8,7 @@ use cron::Schedule;
use domain::jobs::JobKind; use domain::jobs::JobKind;
use domain::DomainError; use domain::DomainError;
use crate::{JobRunner, SettingsService}; use crate::{BackupService, JobRunner, SettingsService};
/// Validate a 6-field cron expression (seconds first). /// Validate a 6-field cron expression (seconds first).
pub fn validate_cron(expr: &str) -> Result<(), DomainError> { pub fn validate_cron(expr: &str) -> Result<(), DomainError> {
@ -38,6 +38,7 @@ pub fn is_due(
pub struct Scheduler { pub struct Scheduler {
runner: Arc<JobRunner>, runner: Arc<JobRunner>,
settings: Arc<SettingsService>, settings: Arc<SettingsService>,
backups: Option<Arc<BackupService>>,
tick: Duration, tick: Duration,
} }
@ -46,10 +47,16 @@ impl Scheduler {
Self { Self {
runner, runner,
settings, settings,
backups: None,
tick: Duration::from_secs(30), tick: Duration::from_secs(30),
} }
} }
pub fn with_backups(mut self, backups: Arc<BackupService>) -> Self {
self.backups = Some(backups);
self
}
/// One pass: start every registered kind that is due. Returns the kinds started. /// One pass: start every registered kind that is due. Returns the kinds started.
pub async fn tick_once(&self, now: DateTime<Utc>) -> Vec<JobKind> { pub async fn tick_once(&self, now: DateTime<Utc>) -> Vec<JobKind> {
let mut started = Vec::new(); let mut started = Vec::new();
@ -70,6 +77,27 @@ impl Scheduler {
started.push(kind); started.push(kind);
} }
} }
if let Some(backups) = &self.backups {
match backups
.due_strategies(now, self.tick.as_secs() as i64 * 2)
.await
{
Ok(ids) => {
for id in ids {
// one backup at a time; the rest is picked up on a later tick
if self
.runner
.start(JobKind::Backup, Some(id.to_string()), "scheduler")
.await
.is_ok()
{
started.push(JobKind::Backup);
}
}
}
Err(e) => eprintln!("scheduler: cannot read backup strategies: {e}"),
}
}
started started
} }

View File

@ -748,7 +748,7 @@ impl BackupRecordRepository for MemRecords {
.filter(|r| r.strategy_id == strategy_id) .filter(|r| r.strategy_id == strategy_id)
.cloned() .cloned()
.collect(); .collect();
v.sort_by(|a, b| b.created_at.cmp(&a.created_at)); v.sort_by_key(|r| std::cmp::Reverse(r.created_at));
v.truncate(limit as usize); v.truncate(limit as usize);
Ok(v) Ok(v)
} }

View File

@ -46,7 +46,11 @@ pub struct PackageUpgradeJob {
} }
/// Bridges the synchronous `LineSink` of the updater to the async job log. /// Bridges the synchronous `LineSink` of the updater to the async job log.
struct ChannelSink(tokio::sync::mpsc::UnboundedSender<String>); pub struct ChannelSink(tokio::sync::mpsc::UnboundedSender<String>);
pub fn channel_sink(tx: tokio::sync::mpsc::UnboundedSender<String>) -> ChannelSink {
ChannelSink(tx)
}
impl LineSink for ChannelSink { impl LineSink for ChannelSink {
fn line(&self, text: &str) { fn line(&self, text: &str) {

View File

@ -23,6 +23,7 @@ kube = { version = "0.99", default-features = false, features = ["client", "rust
k8s-openapi = { version = "0.24", features = ["v1_32"] } k8s-openapi = { version = "0.24", features = ["v1_32"] }
[dev-dependencies] [dev-dependencies]
tempfile = "3"
tokio.workspace = true tokio.workspace = true
[lints] [lints]

View File

@ -0,0 +1,33 @@
CREATE TABLE backup_targets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('smb', 'ftp')),
host TEXT NOT NULL,
port INTEGER,
share TEXT NOT NULL DEFAULT '',
path TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '',
tls INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE backup_strategies (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
source TEXT NOT NULL,
schedule TEXT NOT NULL,
target_id TEXT NOT NULL REFERENCES backup_targets(id),
retention INTEGER NOT NULL,
passphrase TEXT,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE backup_records (
id TEXT PRIMARY KEY,
strategy_id TEXT NOT NULL REFERENCES backup_strategies(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
sha256 TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX backup_records_strategy ON backup_records(strategy_id, created_at DESC);

View File

@ -0,0 +1,735 @@
//! Backup adapters: remote storage via smbclient/curl, collectors via kubectl/tar, openssl encryption,
//! and file-based fakes for development.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use domain::backup::{BackupSource, BackupTarget, RemoteFile, StorageKind};
use domain::ports::{BackupCollector, BackupStorage, FileEncryptor, LineSink};
use domain::DomainError;
use crate::host::CommandRunner;
fn unavailable(what: &str, out: &crate::host::Output) -> DomainError {
let tail: Vec<&str> = out
.stderr
.lines()
.chain(out.stdout.lines())
.rev()
.take(3)
.collect();
DomainError::Unavailable(format!(
"{what}: {}",
tail.into_iter().rev().collect::<Vec<_>>().join(" | ")
))
}
fn join_path(dir: &str, name: &str) -> String {
let dir = dir.trim_matches('/');
if dir.is_empty() {
name.to_string()
} else {
format!("{dir}/{name}")
}
}
// ---------------------------------------------------------------- storage
pub struct CommandBackupStorage {
runner: Arc<dyn CommandRunner>,
}
impl CommandBackupStorage {
pub fn new(runner: Arc<dyn CommandRunner>) -> Self {
Self { runner }
}
async fn smb(
&self,
t: &BackupTarget,
command: &str,
) -> Result<crate::host::Output, DomainError> {
let service = format!("//{}/{}", t.host, t.share);
let mut args = vec![
service.as_str(),
"-U",
if t.username.is_empty() {
"guest"
} else {
t.username.as_str()
},
];
let port;
if let Some(p) = t.port {
port = p.to_string();
args.extend(["-p", port.as_str()]);
}
if t.username.is_empty() {
args.push("-N");
}
let dir;
if !t.path.trim_matches('/').is_empty() {
dir = t.path.trim_matches('/').to_string();
args.extend(["-D", dir.as_str()]);
}
args.extend(["-c", command]);
self.runner
.run_env("smbclient", &args, &[("PASSWD", t.password.as_str())])
.await
}
fn ftp_url(t: &BackupTarget, name: &str) -> String {
let port = t.port.map(|p| format!(":{p}")).unwrap_or_default();
let path = join_path(&t.path, name);
format!("ftp://{}{port}/{path}", t.host)
}
async fn curl(
&self,
t: &BackupTarget,
extra: &[&str],
) -> Result<crate::host::Output, DomainError> {
// credentials via a netrc file so they never appear in the process list
let netrc = std::env::temp_dir().join(format!("monitoring-netrc-{}", uuid::Uuid::new_v4()));
std::fs::write(
&netrc,
format!(
"machine {} login {} password {}\n",
t.host, t.username, t.password
),
)
.map_err(|e| DomainError::Storage(e.to_string()))?;
let _ =
std::fs::set_permissions(&netrc, std::os::unix::fs::PermissionsExt::from_mode(0o600));
let netrc_s = netrc.to_string_lossy().to_string();
let mut args = vec!["-sS", "--fail", "--netrc-file", netrc_s.as_str()];
if t.tls {
args.push("--ssl-reqd");
}
args.extend(extra);
let res = self.runner.run("curl", &args).await;
let _ = std::fs::remove_file(&netrc);
res
}
}
/// `smbclient -c ls` lines: ` name A 1234 Tue Sep 2 ...`
pub fn parse_smb_ls(out: &str) -> Vec<RemoteFile> {
out.lines()
.filter_map(|l| {
let l = l.trim_end();
if !l.starts_with(" ") || l.contains("blocks of size") {
return None;
}
// attributes column is a short uppercase token ("A", "D", "AH"...), size follows it
let parts: Vec<&str> = l.split_whitespace().collect();
let idx = parts
.iter()
.position(|p| p.len() <= 3 && p.chars().all(|c| c.is_ascii_uppercase()))?;
if idx == 0 {
return None;
}
let name = parts[..idx].join(" ");
if name == "." || name == ".." || parts[idx].contains('D') {
return None;
}
let size = parts.get(idx + 1)?.parse().ok()?;
Some(RemoteFile {
name,
size_bytes: size,
})
})
.collect()
}
/// Unix-style FTP `LIST` lines: `-rw-r--r-- 1 u g 1234 Sep 2 10:00 name`
pub fn parse_ftp_list(out: &str) -> Vec<RemoteFile> {
out.lines()
.filter_map(|l| {
let parts: Vec<&str> = l.split_whitespace().collect();
if parts.len() < 9 || !parts[0].starts_with('-') {
return None;
}
Some(RemoteFile {
name: parts[8..].join(" "),
size_bytes: parts[4].parse().ok()?,
})
})
.collect()
}
#[async_trait]
impl BackupStorage for CommandBackupStorage {
async fn test(&self, t: &BackupTarget) -> Result<(), DomainError> {
self.list(t).await.map(|_| ())
}
async fn upload(
&self,
t: &BackupTarget,
local: &Path,
remote_name: &str,
) -> Result<(), DomainError> {
let local_s = local.to_string_lossy().to_string();
let out = match t.kind {
StorageKind::Smb => {
self.smb(t, &format!("put \"{local_s}\" \"{remote_name}\""))
.await?
}
StorageKind::Ftp => {
self.curl(
t,
&[
"--ftp-create-dirs",
"-T",
&local_s,
&Self::ftp_url(t, remote_name),
],
)
.await?
}
};
out.success
.then_some(())
.ok_or_else(|| unavailable("upload failed", &out))
}
async fn list(&self, t: &BackupTarget) -> Result<Vec<RemoteFile>, DomainError> {
let out = match t.kind {
StorageKind::Smb => self.smb(t, "ls").await?,
StorageKind::Ftp => self.curl(t, &[&Self::ftp_url(t, "")]).await?,
};
if !out.success {
return Err(unavailable("listing failed", &out));
}
Ok(match t.kind {
StorageKind::Smb => parse_smb_ls(&out.stdout),
StorageKind::Ftp => parse_ftp_list(&out.stdout),
})
}
async fn delete(&self, t: &BackupTarget, remote_name: &str) -> Result<(), DomainError> {
let out = match t.kind {
StorageKind::Smb => self.smb(t, &format!("del \"{remote_name}\"")).await?,
StorageKind::Ftp => {
let dele = format!("DELE {}", join_path(&t.path, remote_name));
self.curl(
t,
&[
"-Q",
&dele,
&Self::ftp_url(
&BackupTarget {
path: String::new(),
..t.clone()
},
"",
),
],
)
.await?
}
};
out.success
.then_some(())
.ok_or_else(|| unavailable("delete failed", &out))
}
}
// ---------------------------------------------------------------- collector
pub struct KubeBackupCollector {
runner: Arc<dyn CommandRunner>,
/// kubectl invocation, e.g. `["/snap/bin/microk8s", "kubectl"]`.
kubectl: Vec<String>,
}
impl KubeBackupCollector {
pub fn new(runner: Arc<dyn CommandRunner>, kubectl: Vec<String>) -> Self {
Self { runner, kubectl }
}
async fn kubectl(&self, args: &[&str]) -> Result<crate::host::Output, DomainError> {
let mut all: Vec<&str> = self.kubectl.iter().skip(1).map(String::as_str).collect();
all.extend(args);
self.runner.run(&self.kubectl[0], &all).await
}
async fn kubectl_to_file(
&self,
args: &[&str],
file: &Path,
) -> Result<crate::host::Output, DomainError> {
let mut all: Vec<&str> = self.kubectl.iter().skip(1).map(String::as_str).collect();
all.extend(args);
self.runner.run_to_file(&self.kubectl[0], &all, file).await
}
async fn tar_dir(&self, dir: &str, file: &Path, out: &dyn LineSink) -> Result<(), DomainError> {
let f = file.to_string_lossy().to_string();
out.line(&format!("$ tar -czf {f} -C {dir} ."));
let res = self
.runner
.run("tar", &["-czf", &f, "-C", dir, "."])
.await?;
res.success
.then_some(())
.ok_or_else(|| unavailable("tar failed", &res))
}
async fn gzip(&self, file: &Path) -> Result<PathBuf, DomainError> {
let f = file.to_string_lossy().to_string();
let res = self.runner.run("gzip", &["-f", &f]).await?;
res.success
.then_some(file.with_extension(format!(
"{}.gz",
file.extension().and_then(|e| e.to_str()).unwrap_or("")
)))
.ok_or_else(|| unavailable("gzip failed", &res))
}
}
#[async_trait]
impl BackupCollector for KubeBackupCollector {
async fn collect(
&self,
source: &BackupSource,
work_dir: &Path,
out: &dyn LineSink,
) -> Result<PathBuf, DomainError> {
match source {
BackupSource::VolumeClaim { namespace, pvc } => {
out.line(&format!("resolving host path of pvc {namespace}/{pvc}"));
let pv = self
.kubectl(&[
"get",
"pvc",
"-n",
namespace,
pvc,
"-o",
"jsonpath={.spec.volumeName}",
])
.await?;
if !pv.success || pv.stdout.trim().is_empty() {
return Err(unavailable("pvc lookup failed", &pv));
}
let path = self
.kubectl(&[
"get",
"pv",
pv.stdout.trim(),
"-o",
"jsonpath={.spec.hostPath.path}",
])
.await?;
let dir = path.stdout.trim().to_string();
if !path.success || dir.is_empty() {
return Err(DomainError::Unavailable(
"pv has no hostPath (only hostpath volumes are supported)".into(),
));
}
let file = work_dir.join("volume.tar.gz");
self.tar_dir(&dir, &file, out).await?;
Ok(file)
}
BackupSource::PostgresDump { namespace, pod } => {
out.line(&format!(
"$ kubectl exec -n {namespace} {pod} -- pg_dumpall"
));
let raw = work_dir.join("dump.sql");
let res = self
.kubectl_to_file(&["exec", "-n", namespace, pod, "--", "sh", "-c", "PGPASSWORD=\"${POSTGRES_PASSWORD:-$POSTGRESQL_PASSWORD}\" pg_dumpall -U postgres"], &raw)
.await?;
if !res.success {
return Err(unavailable("pg_dumpall failed", &res));
}
self.gzip(&raw).await
}
BackupSource::KubernetesManifests { namespace } => {
out.line(&format!("$ kubectl get all,configmap,secret,ingress,pvc,serviceaccount -n {namespace} -o yaml"));
let raw = work_dir.join("manifests.yaml");
let res = self
.kubectl_to_file(
&[
"get",
"all,configmap,secret,ingress,pvc,serviceaccount",
"-n",
namespace,
"-o",
"yaml",
],
&raw,
)
.await?;
if !res.success {
return Err(unavailable("kubectl get failed", &res));
}
self.gzip(&raw).await
}
BackupSource::HostPath { path } => {
let file = work_dir.join("hostpath.tar.gz");
self.tar_dir(path, &file, out).await?;
Ok(file)
}
}
}
}
// ---------------------------------------------------------------- encryption
/// `openssl enc -aes-256-cbc -pbkdf2`; decrypt with
/// `openssl enc -d -aes-256-cbc -pbkdf2 -in FILE.enc -out FILE`.
pub struct OpensslEncryptor {
runner: Arc<dyn CommandRunner>,
}
impl OpensslEncryptor {
pub fn new(runner: Arc<dyn CommandRunner>) -> Self {
Self { runner }
}
}
#[async_trait]
impl FileEncryptor for OpensslEncryptor {
async fn encrypt(&self, input: &Path, passphrase: &str) -> Result<PathBuf, DomainError> {
let out = PathBuf::from(format!("{}.enc", input.to_string_lossy()));
let (i, o) = (
input.to_string_lossy().to_string(),
out.to_string_lossy().to_string(),
);
let res = self
.runner
.run_env(
"openssl",
&[
"enc",
"-aes-256-cbc",
"-pbkdf2",
"-salt",
"-in",
&i,
"-out",
&o,
"-pass",
"env:BACKUP_PASSPHRASE",
],
&[("BACKUP_PASSPHRASE", passphrase)],
)
.await?;
if !res.success {
return Err(unavailable("openssl enc failed", &res));
}
let _ = std::fs::remove_file(input);
Ok(out)
}
}
// ---------------------------------------------------------------- fakes
/// Stores "remote" files in a local directory per target (FAKE_HOST=true).
pub struct DirBackupStorage {
root: PathBuf,
}
impl DirBackupStorage {
pub fn new(root: PathBuf) -> Self {
Self { root }
}
fn dir(&self, t: &BackupTarget) -> Result<PathBuf, DomainError> {
let d = self.root.join(t.id.to_string());
std::fs::create_dir_all(&d).map_err(|e| DomainError::Storage(e.to_string()))?;
Ok(d)
}
}
#[async_trait]
impl BackupStorage for DirBackupStorage {
async fn test(&self, t: &BackupTarget) -> Result<(), DomainError> {
if t.host.contains("unreachable") {
return Err(DomainError::Unavailable("connection refused".into()));
}
self.dir(t).map(|_| ())
}
async fn upload(
&self,
t: &BackupTarget,
local: &Path,
remote_name: &str,
) -> Result<(), DomainError> {
std::fs::copy(local, self.dir(t)?.join(remote_name))
.map(|_| ())
.map_err(|e| DomainError::Storage(e.to_string()))
}
async fn list(&self, t: &BackupTarget) -> Result<Vec<RemoteFile>, DomainError> {
let mut v = Vec::new();
for e in std::fs::read_dir(self.dir(t)?)
.map_err(|e| DomainError::Storage(e.to_string()))?
.flatten()
{
if let Ok(m) = e.metadata() {
v.push(RemoteFile {
name: e.file_name().to_string_lossy().to_string(),
size_bytes: m.len(),
});
}
}
Ok(v)
}
async fn delete(&self, t: &BackupTarget, remote_name: &str) -> Result<(), DomainError> {
std::fs::remove_file(self.dir(t)?.join(remote_name))
.map_err(|e| DomainError::Storage(e.to_string()))
}
}
/// Produces a small archive describing the source (FAKE_HOST=true).
pub struct FakeBackupCollector;
#[async_trait]
impl BackupCollector for FakeBackupCollector {
async fn collect(
&self,
source: &BackupSource,
work_dir: &Path,
out: &dyn LineSink,
) -> Result<PathBuf, DomainError> {
out.line(&format!(
"fake: collecting {}",
serde_json::to_string(source).unwrap_or_default()
));
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let file = work_dir.join(format!("archive.{}", source.extension()));
std::fs::write(&file, format!("fake backup of {source:?}\n"))
.map_err(|e| DomainError::Storage(e.to_string()))?;
Ok(file)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[test]
fn parses_smbclient_listing() {
let out = " . D 0 Tue Sep 2 10:00:00 2026\n .. D 0 Tue Sep 2 10:00:00 2026\n gitea-db_20260902-020000.sql.gz A 12345 Tue Sep 2 02:00:05 2026\n my file.tar.gz A 99 Tue Sep 2 02:00:05 2026\n\n\t\t1234567 blocks of size 1024. 999 blocks available\n";
let files = parse_smb_ls(out);
assert_eq!(
files,
vec![
RemoteFile {
name: "gitea-db_20260902-020000.sql.gz".into(),
size_bytes: 12345
},
RemoteFile {
name: "my file.tar.gz".into(),
size_bytes: 99
}
]
);
}
#[test]
fn parses_ftp_listing() {
let out = "drwxr-xr-x 2 ftp ftp 4096 Sep 02 02:00 sub\n-rw-r--r-- 1 ftp ftp 12345 Sep 02 02:00 gitea-db_20260902-020000.sql.gz\n";
assert_eq!(
parse_ftp_list(out),
vec![RemoteFile {
name: "gitea-db_20260902-020000.sql.gz".into(),
size_bytes: 12345
}]
);
}
type Call = (String, Vec<String>, Vec<(String, String)>);
#[derive(Default)]
struct Rec(Mutex<Vec<Call>>);
#[async_trait]
impl CommandRunner for Rec {
async fn run(&self, p: &str, a: &[&str]) -> Result<crate::host::Output, DomainError> {
self.run_env(p, a, &[]).await
}
async fn run_env(
&self,
p: &str,
a: &[&str],
env: &[(&str, &str)],
) -> Result<crate::host::Output, DomainError> {
self.0.lock().unwrap().push((
p.into(),
a.iter().map(|s| s.to_string()).collect(),
env.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
));
Ok(crate::host::Output {
stdout: "pvc-123\n".into(),
success: true,
..Default::default()
})
}
async fn run_to_file(
&self,
p: &str,
a: &[&str],
f: &Path,
) -> Result<crate::host::Output, DomainError> {
std::fs::write(f, "data").unwrap();
self.run_env(p, a, &[]).await
}
async fn read_file(&self, _: &str) -> Result<Option<String>, DomainError> {
Ok(None)
}
async fn run_streaming(
&self,
_: &str,
_: &[&str],
_: &dyn LineSink,
) -> Result<bool, DomainError> {
Ok(true)
}
}
struct Sink;
impl LineSink for Sink {
fn line(&self, _: &str) {}
}
fn smb_target() -> BackupTarget {
BackupTarget {
id: uuid::Uuid::new_v4(),
name: "n".into(),
kind: StorageKind::Smb,
host: "nas".into(),
port: None,
share: "backups".into(),
path: "softvisor".into(),
username: "u".into(),
password: "p".into(),
tls: false,
}
}
#[tokio::test]
async fn smb_upload_passes_password_via_env_not_argv() {
let r = Arc::new(Rec::default());
CommandBackupStorage::new(r.clone())
.upload(&smb_target(), Path::new("/tmp/x.tar.gz"), "x.tar.gz")
.await
.unwrap();
let calls = r.0.lock().unwrap();
let (prog, args, env) = &calls[0];
assert_eq!(prog, "smbclient");
assert_eq!(args[0], "//nas/backups");
assert!(args.contains(&"-D".to_string()) && args.contains(&"softvisor".to_string()));
assert!(args.iter().any(|a| a.starts_with("put ")), "{args:?}");
assert!(!args.iter().any(|a| a.contains('p') && a.len() == 1));
assert_eq!(env[0], ("PASSWD".to_string(), "p".to_string()));
}
#[tokio::test]
async fn ftp_uses_curl_with_netrc_and_tls_flag() {
let r = Arc::new(Rec::default());
let mut t = smb_target();
t.kind = StorageKind::Ftp;
t.tls = true;
t.port = Some(2121);
CommandBackupStorage::new(r.clone())
.upload(&t, Path::new("/tmp/x"), "x")
.await
.unwrap();
let calls = r.0.lock().unwrap();
let (prog, args, _) = &calls[0];
assert_eq!(prog, "curl");
assert!(args.contains(&"--netrc-file".to_string()));
assert!(args.contains(&"--ssl-reqd".to_string()));
assert!(
args.last().unwrap().ends_with("ftp://nas:2121/softvisor/x"),
"{args:?}"
);
assert!(
!args.iter().any(|a| a.contains("u:p")),
"no credentials in argv"
);
}
#[tokio::test]
async fn collector_builds_kubectl_and_tar_commands() {
let r = Arc::new(Rec::default());
let c = KubeBackupCollector::new(
r.clone(),
vec!["/snap/bin/microk8s".into(), "kubectl".into()],
);
let work = tempfile::tempdir().unwrap();
let f = c
.collect(
&BackupSource::VolumeClaim {
namespace: "gitea".into(),
pvc: "data".into(),
},
work.path(),
&Sink,
)
.await
.unwrap();
assert!(f.ends_with("volume.tar.gz"));
let f = c
.collect(
&BackupSource::PostgresDump {
namespace: "gitea".into(),
pod: "pg-0".into(),
},
work.path(),
&Sink,
)
.await
.unwrap();
assert!(f.ends_with("dump.sql.gz"), "{f:?}");
let calls = r.0.lock().unwrap();
assert_eq!(calls[0].0, "/snap/bin/microk8s");
assert_eq!(calls[0].1[..3], ["kubectl", "get", "pvc"]);
assert_eq!(calls[2].0, "tar");
let exec = calls
.iter()
.find(|c| c.1.contains(&"exec".to_string()))
.unwrap();
assert!(exec.1.iter().any(|a| a.contains("pg_dumpall")));
assert!(calls.iter().any(|c| c.0 == "gzip"));
}
#[tokio::test]
async fn openssl_receives_passphrase_via_env() {
let r = Arc::new(Rec::default());
let work = tempfile::tempdir().unwrap();
let input = work.path().join("a.tar.gz");
std::fs::write(&input, "x").unwrap();
let out = OpensslEncryptor::new(r.clone())
.encrypt(&input, "pw")
.await
.unwrap();
assert!(out.to_string_lossy().ends_with("a.tar.gz.enc"));
let calls = r.0.lock().unwrap();
assert_eq!(calls[0].0, "openssl");
assert!(calls[0].1.contains(&"env:BACKUP_PASSPHRASE".to_string()));
assert_eq!(calls[0].2[0].1, "pw");
}
#[tokio::test]
async fn dir_storage_roundtrip() {
let root = tempfile::tempdir().unwrap();
let s = DirBackupStorage::new(root.path().to_path_buf());
let t = smb_target();
let local = root.path().join("local.bin");
std::fs::write(&local, "hello").unwrap();
s.upload(&t, &local, "remote.bin").await.unwrap();
assert_eq!(
s.list(&t).await.unwrap(),
vec![RemoteFile {
name: "remote.bin".into(),
size_bytes: 5
}]
);
s.delete(&t, "remote.bin").await.unwrap();
assert!(s.list(&t).await.unwrap().is_empty());
}
}

View File

@ -13,6 +13,20 @@ pub struct Output {
pub trait CommandRunner: Send + Sync { pub trait CommandRunner: Send + Sync {
async fn run(&self, program: &str, args: &[&str]) -> Result<Output, DomainError>; async fn run(&self, program: &str, args: &[&str]) -> Result<Output, DomainError>;
async fn read_file(&self, path: &str) -> Result<Option<String>, DomainError>; async fn read_file(&self, path: &str) -> Result<Option<String>, DomainError>;
/// Run with extra environment variables (used to pass secrets without exposing them in argv).
async fn run_env(
&self,
program: &str,
args: &[&str],
env: &[(&str, &str)],
) -> Result<Output, DomainError>;
/// Run a command and write its stdout to `file`; stderr is captured in the result.
async fn run_to_file(
&self,
program: &str,
args: &[&str],
file: &std::path::Path,
) -> Result<Output, DomainError>;
/// Run a command and forward each output line (stdout and stderr) to `out`. /// Run a command and forward each output line (stdout and stderr) to `out`.
async fn run_streaming( async fn run_streaming(
&self, &self,
@ -27,10 +41,24 @@ pub struct SystemCommandRunner;
#[async_trait] #[async_trait]
impl CommandRunner for SystemCommandRunner { impl CommandRunner for SystemCommandRunner {
async fn run(&self, program: &str, args: &[&str]) -> Result<Output, DomainError> { async fn run(&self, program: &str, args: &[&str]) -> Result<Output, DomainError> {
let out = tokio::process::Command::new(program) self.run_env(program, args, &[]).await
.args(args) }
async fn run_env(
&self,
program: &str,
args: &[&str],
env: &[(&str, &str)],
) -> Result<Output, DomainError> {
let mut cmd = tokio::process::Command::new(program);
cmd.args(args)
.env("DEBIAN_FRONTEND", "noninteractive") .env("DEBIAN_FRONTEND", "noninteractive")
.env("LC_ALL", "C") .env("LC_ALL", "C")
.stdin(std::process::Stdio::null());
for (k, v) in env {
cmd.env(k, v);
}
let out = cmd
.output() .output()
.await .await
.map_err(|e| DomainError::Unavailable(format!("{program}: {e}")))?; .map_err(|e| DomainError::Unavailable(format!("{program}: {e}")))?;
@ -41,6 +69,30 @@ impl CommandRunner for SystemCommandRunner {
}) })
} }
async fn run_to_file(
&self,
program: &str,
args: &[&str],
file: &std::path::Path,
) -> Result<Output, DomainError> {
let f = std::fs::File::create(file)
.map_err(|e| DomainError::Storage(format!("{}: {e}", file.display())))?;
let out = tokio::process::Command::new(program)
.args(args)
.env("LC_ALL", "C")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::from(f))
.stderr(std::process::Stdio::piped())
.output()
.await
.map_err(|e| DomainError::Unavailable(format!("{program}: {e}")))?;
Ok(Output {
stdout: String::new(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
success: out.status.success(),
})
}
async fn run_streaming( async fn run_streaming(
&self, &self,
program: &str, program: &str,

View File

@ -237,6 +237,22 @@ Conf openssl (3.0.16-1~deb12u1 Debian-Security:12/stable-security [amd64])\n";
) -> Result<bool, DomainError> { ) -> Result<bool, DomainError> {
unreachable!() unreachable!()
} }
async fn run_env(
&self,
p: &str,
a: &[&str],
_: &[(&str, &str)],
) -> Result<super::super::Output, DomainError> {
self.run(p, a).await
}
async fn run_to_file(
&self,
_: &str,
_: &[&str],
_: &std::path::Path,
) -> Result<super::super::Output, DomainError> {
unreachable!()
}
async fn read_file(&self, path: &str) -> Result<Option<String>, DomainError> { async fn read_file(&self, path: &str) -> Result<Option<String>, DomainError> {
Ok(match path { Ok(match path {
"/etc/os-release" => Some( "/etc/os-release" => Some(

View File

@ -79,6 +79,22 @@ mod tests {
..Default::default() ..Default::default()
}) })
} }
async fn run_env(
&self,
p: &str,
a: &[&str],
_: &[(&str, &str)],
) -> Result<crate::host::Output, DomainError> {
self.run(p, a).await
}
async fn run_to_file(
&self,
_: &str,
_: &[&str],
_: &std::path::Path,
) -> Result<crate::host::Output, DomainError> {
unreachable!()
}
async fn read_file(&self, _: &str) -> Result<Option<String>, DomainError> { async fn read_file(&self, _: &str) -> Result<Option<String>, DomainError> {
Ok(None) Ok(None)
} }

View File

@ -1,4 +1,5 @@
//! Infrastructure layer: SQLite repositories, Argon2 hashing, JWT issuing. //! Infrastructure layer: SQLite repositories, Argon2 hashing, JWT issuing.
pub mod backup;
pub mod cipher; pub mod cipher;
pub mod db; pub mod db;
pub mod host; pub mod host;
@ -9,6 +10,10 @@ pub mod sqlite;
pub mod token; pub mod token;
pub mod trivy; pub mod trivy;
pub use backup::{
CommandBackupStorage, DirBackupStorage, FakeBackupCollector, KubeBackupCollector,
OpensslEncryptor,
};
pub use cipher::AesGcmCipher; pub use cipher::AesGcmCipher;
pub use db::{connect, DbPool}; pub use db::{connect, DbPool};
pub use host::{ pub use host::{
@ -18,6 +23,9 @@ pub use k8s::{FakeClusterGateway, KubeGateway};
pub use mail::LettreMailer; pub use mail::LettreMailer;
pub use password::Argon2Hasher; pub use password::Argon2Hasher;
pub use sqlite::{SqliteAuditLog, SqliteJobRuns, SqliteRefreshTokens, SqliteSettings, SqliteUsers}; pub use sqlite::{SqliteAuditLog, SqliteJobRuns, SqliteRefreshTokens, SqliteSettings, SqliteUsers};
pub use sqlite::{SqliteFindings, SqliteInventory}; pub use sqlite::{
SqliteBackupRecords, SqliteBackupStrategies, SqliteBackupTargets, SqliteFindings,
SqliteInventory,
};
pub use token::JwtIssuer; pub use token::JwtIssuer;
pub use trivy::{FakeScanner, TrivyScanner}; pub use trivy::{FakeScanner, TrivyScanner};

View File

@ -753,3 +753,307 @@ mod finding_tests {
); );
} }
} }
use domain::backup::{BackupRecord, BackupSource, BackupStrategy, BackupTarget, StorageKind};
pub struct SqliteBackupTargets(pub DbPool);
fn target_from_row(r: &SqliteRow) -> BackupTarget {
BackupTarget {
id: r.get("id"),
name: r.get("name"),
kind: StorageKind::parse(r.get::<String, _>("kind").as_str()).unwrap_or(StorageKind::Smb),
host: r.get("host"),
port: r.get::<Option<i64>, _>("port").map(|p| p as u16),
share: r.get("share"),
path: r.get("path"),
username: r.get("username"),
password: r.get("password"),
tls: r.get("tls"),
}
}
const TARGET_COLS: &str = "id, name, kind, host, port, share, path, username, password, tls";
#[async_trait]
impl domain::ports::BackupTargetRepository for SqliteBackupTargets {
async fn list(&self) -> Result<Vec<BackupTarget>, DomainError> {
sqlx::query(&format!(
"SELECT {TARGET_COLS} FROM backup_targets ORDER BY name"
))
.fetch_all(&self.0)
.await
.map(|rows| rows.iter().map(target_from_row).collect())
.map_err(storage)
}
async fn get(&self, id: Uuid) -> Result<Option<BackupTarget>, DomainError> {
sqlx::query(&format!(
"SELECT {TARGET_COLS} FROM backup_targets WHERE id = ?"
))
.bind(id)
.fetch_optional(&self.0)
.await
.map(|r| r.as_ref().map(target_from_row))
.map_err(storage)
}
async fn insert(&self, t: &BackupTarget) -> Result<(), DomainError> {
sqlx::query(&format!(
"INSERT INTO backup_targets ({TARGET_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
))
.bind(t.id)
.bind(&t.name)
.bind(t.kind.as_str())
.bind(&t.host)
.bind(t.port.map(|p| p as i64))
.bind(&t.share)
.bind(&t.path)
.bind(&t.username)
.bind(&t.password)
.bind(t.tls)
.execute(&self.0)
.await
.map(|_| ())
.map_err(storage)
}
async fn update(&self, t: &BackupTarget) -> Result<(), DomainError> {
let res = sqlx::query("UPDATE backup_targets SET name = ?, kind = ?, host = ?, port = ?, share = ?, path = ?, username = ?, password = ?, tls = ? WHERE id = ?")
.bind(&t.name)
.bind(t.kind.as_str())
.bind(&t.host)
.bind(t.port.map(|p| p as i64))
.bind(&t.share)
.bind(&t.path)
.bind(&t.username)
.bind(&t.password)
.bind(t.tls)
.bind(t.id)
.execute(&self.0)
.await
.map_err(storage)?;
(res.rows_affected() > 0)
.then_some(())
.ok_or(DomainError::NotFound)
}
async fn delete(&self, id: Uuid) -> Result<(), DomainError> {
let res = sqlx::query("DELETE FROM backup_targets WHERE id = ?")
.bind(id)
.execute(&self.0)
.await
.map_err(storage)?;
(res.rows_affected() > 0)
.then_some(())
.ok_or(DomainError::NotFound)
}
}
pub struct SqliteBackupStrategies(pub DbPool);
fn strategy_from_row(r: &SqliteRow) -> Result<BackupStrategy, DomainError> {
Ok(BackupStrategy {
id: r.get("id"),
name: r.get("name"),
source: serde_json::from_str::<BackupSource>(r.get::<String, _>("source").as_str())
.map_err(|e| DomainError::Storage(e.to_string()))?,
schedule: r.get("schedule"),
target_id: r.get("target_id"),
retention: r.get::<i64, _>("retention") as u32,
passphrase: r.get("passphrase"),
enabled: r.get("enabled"),
})
}
const STRATEGY_COLS: &str = "id, name, source, schedule, target_id, retention, passphrase, enabled";
#[async_trait]
impl domain::ports::BackupStrategyRepository for SqliteBackupStrategies {
async fn list(&self) -> Result<Vec<BackupStrategy>, DomainError> {
let rows = sqlx::query(&format!(
"SELECT {STRATEGY_COLS} FROM backup_strategies ORDER BY name"
))
.fetch_all(&self.0)
.await
.map_err(storage)?;
rows.iter().map(strategy_from_row).collect()
}
async fn get(&self, id: Uuid) -> Result<Option<BackupStrategy>, DomainError> {
let row = sqlx::query(&format!(
"SELECT {STRATEGY_COLS} FROM backup_strategies WHERE id = ?"
))
.bind(id)
.fetch_optional(&self.0)
.await
.map_err(storage)?;
row.as_ref().map(strategy_from_row).transpose()
}
async fn insert(&self, s: &BackupStrategy) -> Result<(), DomainError> {
let source =
serde_json::to_string(&s.source).map_err(|e| DomainError::Storage(e.to_string()))?;
sqlx::query(&format!(
"INSERT INTO backup_strategies ({STRATEGY_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
))
.bind(s.id)
.bind(&s.name)
.bind(source)
.bind(&s.schedule)
.bind(s.target_id)
.bind(s.retention as i64)
.bind(&s.passphrase)
.bind(s.enabled)
.execute(&self.0)
.await
.map(|_| ())
.map_err(storage)
}
async fn update(&self, s: &BackupStrategy) -> Result<(), DomainError> {
let source =
serde_json::to_string(&s.source).map_err(|e| DomainError::Storage(e.to_string()))?;
let res = sqlx::query("UPDATE backup_strategies SET name = ?, source = ?, schedule = ?, target_id = ?, retention = ?, passphrase = ?, enabled = ? WHERE id = ?")
.bind(&s.name)
.bind(source)
.bind(&s.schedule)
.bind(s.target_id)
.bind(s.retention as i64)
.bind(&s.passphrase)
.bind(s.enabled)
.bind(s.id)
.execute(&self.0)
.await
.map_err(storage)?;
(res.rows_affected() > 0)
.then_some(())
.ok_or(DomainError::NotFound)
}
async fn delete(&self, id: Uuid) -> Result<(), DomainError> {
let res = sqlx::query("DELETE FROM backup_strategies WHERE id = ?")
.bind(id)
.execute(&self.0)
.await
.map_err(storage)?;
(res.rows_affected() > 0)
.then_some(())
.ok_or(DomainError::NotFound)
}
}
pub struct SqliteBackupRecords(pub DbPool);
#[async_trait]
impl domain::ports::BackupRecordRepository for SqliteBackupRecords {
async fn insert(&self, r: &BackupRecord) -> Result<(), DomainError> {
sqlx::query("INSERT INTO backup_records (id, strategy_id, filename, size_bytes, sha256, created_at) VALUES (?, ?, ?, ?, ?, ?)")
.bind(r.id)
.bind(r.strategy_id)
.bind(&r.filename)
.bind(r.size_bytes as i64)
.bind(&r.sha256)
.bind(r.created_at.to_rfc3339())
.execute(&self.0)
.await
.map(|_| ())
.map_err(storage)
}
async fn list_for(
&self,
strategy_id: Uuid,
limit: u32,
) -> Result<Vec<BackupRecord>, DomainError> {
sqlx::query("SELECT id, strategy_id, filename, size_bytes, sha256, created_at FROM backup_records WHERE strategy_id = ? ORDER BY created_at DESC LIMIT ?")
.bind(strategy_id)
.bind(limit)
.fetch_all(&self.0)
.await
.map(|rows| {
rows.iter()
.map(|r| BackupRecord {
id: r.get("id"),
strategy_id: r.get("strategy_id"),
filename: r.get("filename"),
size_bytes: r.get::<i64, _>("size_bytes") as u64,
sha256: r.get("sha256"),
created_at: parse_ts(r.get::<String, _>("created_at").as_str()),
})
.collect()
})
.map_err(storage)
}
async fn delete_by_filename(
&self,
strategy_id: Uuid,
filename: &str,
) -> Result<(), DomainError> {
sqlx::query("DELETE FROM backup_records WHERE strategy_id = ? AND filename = ?")
.bind(strategy_id)
.bind(filename)
.execute(&self.0)
.await
.map(|_| ())
.map_err(storage)
}
}
#[cfg(test)]
mod backup_tests {
use super::*;
use domain::ports::{BackupRecordRepository, BackupStrategyRepository, BackupTargetRepository};
#[tokio::test]
async fn targets_strategies_records_roundtrip() {
let pool = crate::connect("sqlite::memory:").await.unwrap();
let targets = SqliteBackupTargets(pool.clone());
let strategies = SqliteBackupStrategies(pool.clone());
let records = SqliteBackupRecords(pool);
let t = BackupTarget {
id: Uuid::new_v4(),
name: "NAS".into(),
kind: StorageKind::Ftp,
host: "h".into(),
port: Some(2121),
share: String::new(),
path: "p".into(),
username: "u".into(),
password: "enc".into(),
tls: true,
};
targets.insert(&t).await.unwrap();
assert_eq!(targets.get(t.id).await.unwrap().unwrap(), t);
let s = BackupStrategy {
id: Uuid::new_v4(),
name: "S".into(),
source: BackupSource::HostPath {
path: "/srv".into(),
},
schedule: "0 0 1 * * *".into(),
target_id: t.id,
retention: 3,
passphrase: Some("enc".into()),
enabled: true,
};
strategies.insert(&s).await.unwrap();
assert_eq!(strategies.list().await.unwrap(), vec![s.clone()]);
let mut s2 = s.clone();
s2.enabled = false;
strategies.update(&s2).await.unwrap();
assert!(!strategies.get(s.id).await.unwrap().unwrap().enabled);
let r = BackupRecord {
id: Uuid::new_v4(),
strategy_id: s.id,
filename: "s_1.tar.gz".into(),
size_bytes: 10,
sha256: "x".into(),
created_at: Utc::now(),
};
records.insert(&r).await.unwrap();
assert_eq!(records.list_for(s.id, 10).await.unwrap().len(), 1);
records
.delete_by_filename(s.id, "s_1.tar.gz")
.await
.unwrap();
assert!(records.list_for(s.id, 10).await.unwrap().is_empty());
strategies.delete(s.id).await.unwrap();
targets.delete(t.id).await.unwrap();
assert_eq!(
targets.delete(t.id).await.unwrap_err(),
DomainError::NotFound
);
}
}

View File

@ -317,6 +317,22 @@ mod tests {
}, },
}) })
} }
async fn run_env(
&self,
p: &str,
a: &[&str],
_: &[(&str, &str)],
) -> Result<crate::host::Output, DomainError> {
self.run(p, a).await
}
async fn run_to_file(
&self,
_: &str,
_: &[&str],
_: &std::path::Path,
) -> Result<crate::host::Output, DomainError> {
unreachable!()
}
async fn read_file(&self, _: &str) -> Result<Option<String>, DomainError> { async fn read_file(&self, _: &str) -> Result<Option<String>, DomainError> {
Ok(None) Ok(None)
} }

50
docs/restore.md Normal file
View File

@ -0,0 +1,50 @@
# Restoring backups
Backups are plain archives on the SMB share or FTP server, named
`<strategy-slug>_<YYYYmmdd-HHMMSS>.<ext>[.enc]`. The SHA-256 of every uploaded file is shown
in the backup history of the strategy.
## 1. Decrypt (only for `.enc` files)
```bash
openssl enc -d -aes-256-cbc -pbkdf2 -in FILE.sql.gz.enc -out FILE.sql.gz
```
Enter the passphrase of the strategy when prompted.
## 2. Restore per source type
### PostgreSQL dump (`.sql.gz`, produced by `pg_dumpall` inside the pod)
```bash
gunzip -c gitea-db_*.sql.gz | microk8s kubectl exec -i -n gitea gitea-postgresql-0 -- \
sh -c 'PGPASSWORD="$POSTGRES_PASSWORD" psql -U postgres'
```
Stop Gitea first (`microk8s kubectl scale deploy/gitea -n gitea --replicas=0`) and start it again afterwards.
### Persistent volume (`.tar.gz` of the hostpath directory)
Find the directory of the PVC on the host and unpack into it:
```bash
PV=$(microk8s kubectl get pvc -n gitea gitea-shared-storage -o jsonpath='{.spec.volumeName}')
DIR=$(microk8s kubectl get pv "$PV" -o jsonpath='{.spec.hostPath.path}')
microk8s kubectl scale deploy/gitea -n gitea --replicas=0
tar -xzf gitea-data_*.tar.gz -C "$DIR"
microk8s kubectl scale deploy/gitea -n gitea --replicas=1
```
### Kubernetes manifests (`.yaml.gz`)
```bash
gunzip -c gitea-manifests_*.yaml.gz | microk8s kubectl apply -f -
```
Secrets and PVC definitions are included; review before applying to a different cluster.
### Host directory (`.tar.gz`)
```bash
tar -xzf name_*.tar.gz -C /path/of/the/source
```

View File

@ -9,26 +9,26 @@ test('admin creates a target and a strategy, runs it and sees the backup', async
await page.getByRole('button', { name: 'New target' }).click() await page.getByRole('button', { name: 'New target' }).click()
const name = `NAS ${Date.now()}` const name = `NAS ${Date.now()}`
await page.getByLabel('Name').fill(name) await page.getByLabel('Name', { exact: true }).fill(name)
await page.getByLabel('Host').fill('nas.local') await page.getByLabel('Host', { exact: true }).fill('nas.local')
await page.getByLabel('Share').fill('backups') await page.getByLabel('Share').fill('backups')
await page.getByLabel('Directory').fill('softvisor') await page.getByLabel('Directory').fill('softvisor')
await page.getByLabel('Username').fill('backup') await page.getByLabel('Username', { exact: true }).fill('backup')
await page.getByLabel('Password').fill('smb-secret') await page.getByLabel('Password', { exact: true }).fill('smb-secret')
await page.getByRole('button', { name: 'Save target' }).click() await page.getByRole('button', { name: 'Save target' }).click()
const targetRow = page.getByRole('row', { name: new RegExp(name) }) const targetRow = page.getByRole('row', { name: new RegExp(name) })
await expect(targetRow).toBeVisible() await expect(targetRow).toBeVisible()
await targetRow.getByRole('button', { name: 'Test' }).click() await targetRow.getByRole('button', { name: 'Test' }).click()
await expect(page.getByRole('status')).toContainText('Connection ok') await expect(page.getByText(/Connection ok/)).toBeVisible()
await page.getByRole('button', { name: 'New strategy' }).click() await page.getByRole('button', { name: 'New strategy' }).click()
await page.getByLabel('Strategy name').fill('Gitea database') await page.getByLabel('Strategy name', { exact: true }).fill('Gitea database')
await page.getByLabel('Source type').selectOption('postgres_dump') await page.getByLabel('Source type', { exact: true }).selectOption('postgres_dump')
await page.getByLabel('Namespace').fill('gitea') await page.getByLabel('Namespace', { exact: true }).fill('gitea')
await page.getByLabel('Pod').fill('gitea-postgresql-0') await page.getByLabel('Pod', { exact: true }).fill('gitea-postgresql-0')
await page.getByLabel('Schedule').fill('0 0 2 * * *') await page.getByLabel('Schedule', { exact: true }).fill('0 0 2 * * *')
await page.getByLabel('Target').selectOption({ label: name }) await page.getByLabel('Target', { exact: true }).selectOption({ label: name })
await page.getByLabel('Keep last').fill('3') await page.getByLabel('Keep last', { exact: true }).fill('3')
await page.getByRole('button', { name: 'Save strategy' }).click() await page.getByRole('button', { name: 'Save strategy' }).click()
const row = page.getByRole('row', { name: /Gitea database/ }) const row = page.getByRole('row', { name: /Gitea database/ })
await expect(row).toBeVisible() await expect(row).toBeVisible()

View File

@ -60,4 +60,5 @@ export const api = {
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body), post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body), patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body), put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
delete: <T>(path: string) => request<T>('DELETE', path),
} }

View File

@ -0,0 +1,183 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { BackupSource, BackupStrategyPayload, BackupStrategyView } from '../api/types'
const props = defineProps<{
targets: { id: string; name: string }[]
current?: BackupStrategyView | null
busy?: boolean
}>()
const emit = defineEmits<{ submit: [payload: BackupStrategyPayload]; cancel: [] }>()
type SourceType = BackupSource['type']
const cur = props.current
const name = ref(cur?.name ?? '')
const sourceType = ref<SourceType>(cur?.source.type ?? 'volume_claim')
const namespace = ref(cur && 'namespace' in cur.source ? cur.source.namespace : 'gitea')
const pvc = ref(cur?.source.type === 'volume_claim' ? cur.source.pvc : '')
const pod = ref(cur?.source.type === 'postgres_dump' ? cur.source.pod : '')
const path = ref(cur?.source.type === 'host_path' ? cur.source.path : '')
const schedule = ref(cur?.schedule ?? '0 0 2 * * *')
const targetId = ref(cur?.target_id ?? props.targets[0]?.id ?? '')
const retention = ref<string>(cur?.retention.toString() ?? '7')
const passphrase = ref('')
const enabled = ref(cur?.enabled ?? true)
function source(): BackupSource {
switch (sourceType.value) {
case 'volume_claim':
return { type: 'volume_claim', namespace: namespace.value, pvc: pvc.value }
case 'postgres_dump':
return { type: 'postgres_dump', namespace: namespace.value, pod: pod.value }
case 'kubernetes_manifests':
return { type: 'kubernetes_manifests', namespace: namespace.value }
case 'host_path':
return { type: 'host_path', path: path.value }
}
}
function submit() {
// create: empty = no encryption; edit: empty keeps the stored passphrase
const pass = passphrase.value === '' ? (cur?.encrypted ? '' : null) : passphrase.value
emit('submit', {
name: name.value,
source: source(),
schedule: schedule.value,
target_id: targetId.value,
retention: Number(retention.value),
passphrase: pass,
enabled: enabled.value,
})
}
const input = 'mt-1 w-full rounded-md border border-gray-300 px-3 py-2'
</script>
<template>
<form class="grid gap-4" @submit.prevent="submit">
<div>
<label for="s-name" class="block text-sm font-medium">Strategy name</label>
<input id="s-name" v-model="name" name="name" required :class="input" />
</div>
<div>
<label for="s-type" class="block text-sm font-medium">Source type</label>
<select id="s-type" v-model="sourceType" name="source_type" :class="input">
<option value="volume_claim">Persistent volume (e.g. Gitea data)</option>
<option value="postgres_dump">PostgreSQL dump (pg_dumpall in pod)</option>
<option value="kubernetes_manifests">Kubernetes manifests of a namespace</option>
<option value="host_path">Directory on the host</option>
</select>
</div>
<div v-if="sourceType !== 'host_path'" class="grid grid-cols-2 gap-4">
<div>
<label for="s-ns" class="block text-sm font-medium">Namespace</label>
<input id="s-ns" v-model="namespace" name="namespace" required :class="input" />
</div>
<div v-if="sourceType === 'volume_claim'">
<label for="s-pvc" class="block text-sm font-medium">PVC</label>
<input
id="s-pvc"
v-model="pvc"
name="pvc"
required
placeholder="gitea-shared-storage"
:class="input"
/>
</div>
<div v-if="sourceType === 'postgres_dump'">
<label for="s-pod" class="block text-sm font-medium">Pod</label>
<input
id="s-pod"
v-model="pod"
name="pod"
required
placeholder="gitea-postgresql-0"
:class="input"
/>
</div>
</div>
<div v-else>
<label for="s-path" class="block text-sm font-medium">Host path</label>
<input
id="s-path"
v-model="path"
name="path"
required
placeholder="/opt/monitoring/data"
:class="input"
/>
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label for="s-cron" class="block text-sm font-medium">Schedule</label>
<input
id="s-cron"
v-model="schedule"
name="schedule"
required
class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 font-mono"
/>
<p class="mt-1 text-xs text-gray-500">sec min hour day month weekday</p>
</div>
<div>
<label for="s-target" class="block text-sm font-medium">Target</label>
<select id="s-target" v-model="targetId" name="target_id" required :class="input">
<option v-for="t in targets" :key="t.id" :value="t.id">{{ t.name }}</option>
</select>
</div>
<div>
<label for="s-keep" class="block text-sm font-medium">Keep last</label>
<input
id="s-keep"
v-model="retention"
name="retention"
type="number"
min="1"
max="365"
required
:class="input"
/>
</div>
</div>
<div>
<label for="s-pass" class="block text-sm font-medium">Encryption passphrase (optional)</label>
<input
id="s-pass"
v-model="passphrase"
name="passphrase"
type="password"
autocomplete="new-password"
minlength="12"
:class="input"
/>
<p class="mt-1 text-xs text-gray-500">
<template v-if="cur?.encrypted"
>Archives are encrypted, leave empty to keep the passphrase.</template
>
<template v-else
>AES-256 via openssl; restore with
<code>openssl enc -d -aes-256-cbc -pbkdf2</code>.</template
>
</p>
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="enabled" name="enabled" type="checkbox" />
Enabled (runs on schedule)
</label>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-md border border-gray-300 px-4 py-2 text-sm"
@click="emit('cancel')"
>
Cancel
</button>
<button
type="submit"
:disabled="busy"
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
Save strategy
</button>
</div>
</form>
</template>

View File

@ -0,0 +1,123 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { BackupTargetPayload, BackupTargetView, StorageKind } from '../api/types'
const props = defineProps<{ current?: BackupTargetView | null; busy?: boolean }>()
const emit = defineEmits<{ submit: [payload: BackupTargetPayload]; cancel: [] }>()
const name = ref(props.current?.name ?? '')
const kind = ref<StorageKind>(props.current?.kind ?? 'smb')
const host = ref(props.current?.host ?? '')
const port = ref<string>(props.current?.port?.toString() ?? '')
const share = ref(props.current?.share ?? '')
const path = ref(props.current?.path ?? '')
const username = ref(props.current?.username ?? '')
const password = ref('')
const tls = ref(props.current?.tls ?? false)
function submit() {
emit('submit', {
name: name.value,
kind: kind.value,
host: host.value,
port: port.value ? Number(port.value) : null,
share: kind.value === 'smb' ? share.value : '',
path: path.value,
username: username.value,
password: password.value,
tls: kind.value === 'ftp' ? tls.value : false,
})
}
const input = 'mt-1 w-full rounded-md border border-gray-300 px-3 py-2'
</script>
<template>
<form class="grid gap-4" @submit.prevent="submit">
<div>
<label for="t-name" class="block text-sm font-medium">Name</label>
<input id="t-name" v-model="name" name="name" required :class="input" />
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<label for="t-kind" class="block text-sm font-medium">Type</label>
<select id="t-kind" v-model="kind" name="kind" :class="input">
<option value="smb">SMB / CIFS</option>
<option value="ftp">FTP / FTPS</option>
</select>
</div>
<div class="col-span-2">
<label for="t-host" class="block text-sm font-medium">Host</label>
<input id="t-host" v-model="host" name="host" required :class="input" />
</div>
</div>
<div v-if="kind === 'smb'">
<label for="t-share" class="block text-sm font-medium">Share</label>
<input id="t-share" v-model="share" name="share" required :class="input" />
</div>
<div v-else class="grid grid-cols-2 gap-4">
<div>
<label for="t-port" class="block text-sm font-medium">Port</label>
<input
id="t-port"
v-model="port"
name="port"
type="number"
min="1"
max="65535"
placeholder="21"
:class="input"
/>
</div>
<label class="flex items-end gap-2 pb-2 text-sm">
<input v-model="tls" name="tls" type="checkbox" />
Use TLS (FTPS)
</label>
</div>
<div>
<label for="t-path" class="block text-sm font-medium">Directory</label>
<input
id="t-path"
v-model="path"
name="path"
placeholder="backups/softvisor"
:class="input"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="t-user" class="block text-sm font-medium">Username</label>
<input id="t-user" v-model="username" name="username" :class="input" />
</div>
<div>
<label for="t-pass" class="block text-sm font-medium">Password</label>
<input
id="t-pass"
v-model="password"
name="password"
type="password"
autocomplete="new-password"
:class="input"
/>
<p v-if="current?.password_set" class="mt-1 text-xs text-gray-500">
A password is stored, leave empty to keep it.
</p>
</div>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-md border border-gray-300 px-4 py-2 text-sm"
@click="emit('cancel')"
>
Cancel
</button>
<button
type="submit"
:disabled="busy"
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
Save target
</button>
</div>
</form>
</template>

View File

@ -0,0 +1,365 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { api, ApiError } from '../api/client'
import type {
BackupRecord,
BackupStrategyPayload,
BackupTargetPayload,
BackupTargetView,
JobRun,
StrategyStatus,
} from '../api/types'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
import TargetForm from '../components/TargetForm.vue'
import StrategyForm from '../components/StrategyForm.vue'
const auth = useAuthStore()
const toast = useToastStore()
const targets = ref<BackupTargetView[]>([])
const strategies = ref<StrategyStatus[]>([])
const dialog = ref<
| { kind: 'target'; current: BackupTargetView | null }
| { kind: 'strategy'; current: StrategyStatus | null }
| { kind: 'history'; strategy: StrategyStatus; records: BackupRecord[] }
| null
>(null)
const busy = ref(false)
const running = ref<Record<string, JobRun>>({})
let timer: ReturnType<typeof setInterval> | undefined
const fail = (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Request failed')
async function load() {
;[targets.value, strategies.value] = await Promise.all([
api.get<BackupTargetView[]>('/api/backups/targets'),
api.get<StrategyStatus[]>('/api/backups/strategies'),
])
}
onMounted(() => load().catch(fail))
onUnmounted(() => clearInterval(timer))
async function saveTarget(payload: BackupTargetPayload) {
if (!dialog.value || dialog.value.kind !== 'target') return
busy.value = true
try {
if (dialog.value.current)
await api.put(`/api/backups/targets/${dialog.value.current.id}`, payload)
else await api.post('/api/backups/targets', payload)
toast.success('Target saved')
dialog.value = null
await load()
} catch (e) {
fail(e)
} finally {
busy.value = false
}
}
async function testTarget(t: BackupTargetView) {
try {
await api.post(`/api/backups/targets/${t.id}/test`, {})
toast.success(`Connection ok: ${t.name}`)
} catch (e) {
fail(e)
}
}
async function deleteTarget(t: BackupTargetView) {
if (!confirm(`Delete target ${t.name}?`)) return
try {
await api.delete(`/api/backups/targets/${t.id}`)
await load()
} catch (e) {
fail(e)
}
}
async function saveStrategy(payload: BackupStrategyPayload) {
if (!dialog.value || dialog.value.kind !== 'strategy') return
busy.value = true
try {
if (dialog.value.current)
await api.put(`/api/backups/strategies/${dialog.value.current.id}`, payload)
else await api.post('/api/backups/strategies', payload)
toast.success('Strategy saved')
dialog.value = null
await load()
} catch (e) {
fail(e)
} finally {
busy.value = false
}
}
async function deleteStrategy(s: StrategyStatus) {
if (!confirm(`Delete strategy ${s.name}? Existing backups on the target are kept.`)) return
try {
await api.delete(`/api/backups/strategies/${s.id}`)
await load()
} catch (e) {
fail(e)
}
}
async function runNow(s: StrategyStatus) {
try {
const run = await api.post<JobRun>(`/api/backups/strategies/${s.id}/run`, {})
running.value[s.id] = run
toast.success(`Backup '${s.name}' started`)
clearInterval(timer)
timer = setInterval(async () => {
let active = false
for (const [sid, r] of Object.entries(running.value)) {
if (r.status !== 'running') continue
const fresh = await api.get<JobRun>(`/api/jobs/${r.id}`)
running.value[sid] = fresh
if (fresh.status === 'running') active = true
else {
if (fresh.status === 'success') toast.success('Backup finished')
else toast.error('Backup failed, see Jobs for the log')
await load()
}
}
if (!active) clearInterval(timer)
}, 2000)
} catch (e) {
fail(e)
}
}
async function history(s: StrategyStatus) {
try {
dialog.value = {
kind: 'history',
strategy: s,
records: await api.get<BackupRecord[]>(`/api/backups/strategies/${s.id}/records`),
}
} catch (e) {
fail(e)
}
}
const sourceLabel = (s: StrategyStatus) => {
const src = s.source
switch (src.type) {
case 'volume_claim':
return `PVC ${src.namespace}/${src.pvc}`
case 'postgres_dump':
return `pg_dumpall ${src.namespace}/${src.pod}`
case 'kubernetes_manifests':
return `manifests ${src.namespace}`
case 'host_path':
return `host ${src.path}`
}
}
const size = (b: number) =>
b > 1e9
? `${(b / 1e9).toFixed(1)} GB`
: b > 1e6
? `${(b / 1e6).toFixed(1)} MB`
: `${Math.ceil(b / 1e3)} kB`
const fmt = (d: string) => new Date(d).toLocaleString()
</script>
<template>
<h1 class="text-2xl font-semibold">Backups</h1>
<section class="mt-8">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium">Targets</h2>
<button
v-if="auth.isAdmin"
class="rounded-md border border-gray-300 px-4 py-2 text-sm"
@click="dialog = { kind: 'target', current: null }"
>
New target
</button>
</div>
<table class="mt-3 w-full text-left text-sm">
<thead class="border-b border-gray-200 text-gray-500">
<tr>
<th class="py-2">Name</th>
<th>Type</th>
<th>Location</th>
<th>User</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="t in targets" :key="t.id" class="border-b border-gray-100">
<td class="py-1.5 font-medium">{{ t.name }}</td>
<td class="uppercase">{{ t.kind }}{{ t.tls ? 'S' : '' }}</td>
<td class="font-mono text-xs">
{{
t.kind === 'smb'
? `//${t.host}/${t.share}/${t.path}`
: `ftp://${t.host}${t.port ? ':' + t.port : ''}/${t.path}`
}}
</td>
<td>{{ t.username || '–' }}</td>
<td class="space-x-3 whitespace-nowrap text-right">
<template v-if="auth.isAdmin">
<button class="text-blue-600 hover:underline" @click="testTarget(t)">Test</button>
<button
class="text-blue-600 hover:underline"
@click="dialog = { kind: 'target', current: t }"
>
Edit
</button>
<button class="text-red-600 hover:underline" @click="deleteTarget(t)">Delete</button>
</template>
</td>
</tr>
<tr v-if="targets.length === 0">
<td colspan="5" class="py-4 text-center text-gray-500">
No targets yet. Add an SMB share or FTP server first.
</td>
</tr>
</tbody>
</table>
</section>
<section class="mt-10">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium">Strategies</h2>
<button
v-if="auth.isAdmin"
:disabled="targets.length === 0"
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
@click="dialog = { kind: 'strategy', current: null }"
>
New strategy
</button>
</div>
<table class="mt-3 w-full text-left text-sm">
<thead class="border-b border-gray-200 text-gray-500">
<tr>
<th class="py-2">Name</th>
<th>Source</th>
<th>Schedule</th>
<th>Target</th>
<th>Keep</th>
<th>Last backup</th>
<th></th>
</tr>
</thead>
<tbody>
<tr
v-for="s in strategies"
:key="s.id"
class="border-b border-gray-100"
:class="s.enabled ? '' : 'text-gray-400'"
>
<td class="py-1.5 font-medium">
{{ s.name }}
<span v-if="s.encrypted" class="ml-1 rounded bg-gray-100 px-1 text-xs text-gray-600"
>encrypted</span
>
<span v-if="!s.enabled" class="ml-1 rounded bg-gray-100 px-1 text-xs">disabled</span>
</td>
<td class="font-mono text-xs">{{ sourceLabel(s) }}</td>
<td class="font-mono text-xs">{{ s.schedule }}</td>
<td>{{ s.target_name }}</td>
<td>{{ s.retention }}</td>
<td class="text-xs">
<template v-if="running[s.id]?.status === 'running'"
><span class="text-blue-600">running…</span></template
>
<template v-else-if="s.last_backup">
<div class="font-mono">{{ s.last_backup.filename }}</div>
<div class="text-gray-500">
{{ fmt(s.last_backup.created_at) }} · {{ size(s.last_backup.size_bytes) }}
</div>
</template>
<template v-else>never</template>
</td>
<td class="space-x-3 whitespace-nowrap text-right">
<button class="text-blue-600 hover:underline" @click="history(s)">History</button>
<template v-if="auth.isAdmin">
<button
class="text-blue-600 hover:underline"
:disabled="running[s.id]?.status === 'running'"
@click="runNow(s)"
>
Run now
</button>
<button
class="text-blue-600 hover:underline"
@click="dialog = { kind: 'strategy', current: s }"
>
Edit
</button>
<button class="text-red-600 hover:underline" @click="deleteStrategy(s)">
Delete
</button>
</template>
</td>
</tr>
<tr v-if="strategies.length === 0">
<td colspan="7" class="py-4 text-center text-gray-500">No strategies yet.</td>
</tr>
</tbody>
</table>
</section>
<div
v-if="dialog"
class="fixed inset-0 flex items-center justify-center overflow-y-auto bg-black/30"
>
<div class="w-full max-w-xl rounded-lg bg-white p-6 shadow-lg" role="dialog">
<template v-if="dialog.kind === 'target'">
<h2 class="mb-4 text-lg font-semibold">
{{ dialog.current ? 'Edit target' : 'New target' }}
</h2>
<TargetForm
:key="dialog.current?.id ?? 'new'"
:current="dialog.current"
:busy="busy"
@submit="saveTarget"
@cancel="dialog = null"
/>
</template>
<template v-else-if="dialog.kind === 'strategy'">
<h2 class="mb-4 text-lg font-semibold">
{{ dialog.current ? 'Edit strategy' : 'New strategy' }}
</h2>
<StrategyForm
:key="dialog.current?.id ?? 'new'"
:targets="targets"
:current="dialog.current"
:busy="busy"
@submit="saveStrategy"
@cancel="dialog = null"
/>
</template>
<template v-else>
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Backups of {{ dialog.strategy.name }}</h2>
<button class="text-blue-600 hover:underline" @click="dialog = null">Close</button>
</div>
<table data-testid="backup-records" class="mt-4 w-full text-left text-sm">
<thead class="border-b border-gray-200 text-gray-500">
<tr>
<th class="py-2">File</th>
<th>Created</th>
<th>Size</th>
<th>SHA-256</th>
</tr>
</thead>
<tbody>
<tr v-for="r in dialog.records" :key="r.id" class="border-b border-gray-100">
<td class="py-1 font-mono text-xs">{{ r.filename }}</td>
<td class="text-xs">{{ fmt(r.created_at) }}</td>
<td class="text-xs">{{ size(r.size_bytes) }}</td>
<td class="font-mono text-xs" :title="r.sha256">{{ r.sha256.slice(0, 12) }}…</td>
</tr>
<tr v-if="dialog.records.length === 0">
<td colspan="4" class="py-3 text-center text-gray-500">No backups yet.</td>
</tr>
</tbody>
</table>
</template>
</div>
</div>
</template>

View File

@ -1,8 +0,0 @@
<script setup lang="ts">
defineProps<{ title: string }>()
</script>
<template>
<h1 class="text-2xl font-semibold">{{ title }}</h1>
<p class="mt-2 text-gray-600">Not implemented yet.</p>
</template>

View File

@ -5,12 +5,12 @@ import AppShell from './components/AppShell.vue'
import LoginPage from './pages/LoginPage.vue' import LoginPage from './pages/LoginPage.vue'
import DashboardPage from './pages/DashboardPage.vue' import DashboardPage from './pages/DashboardPage.vue'
import UsersPage from './pages/UsersPage.vue' import UsersPage from './pages/UsersPage.vue'
import PlaceholderPage from './pages/PlaceholderPage.vue'
import SettingsPage from './pages/SettingsPage.vue' import SettingsPage from './pages/SettingsPage.vue'
import JobsPage from './pages/JobsPage.vue' import JobsPage from './pages/JobsPage.vue'
import UpdatesPage from './pages/UpdatesPage.vue' import UpdatesPage from './pages/UpdatesPage.vue'
import ClusterPage from './pages/ClusterPage.vue' import ClusterPage from './pages/ClusterPage.vue'
import VulnerabilitiesPage from './pages/VulnerabilitiesPage.vue' import VulnerabilitiesPage from './pages/VulnerabilitiesPage.vue'
import BackupsPage from './pages/BackupsPage.vue'
export const router = createRouter({ export const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
@ -24,7 +24,7 @@ export const router = createRouter({
{ path: 'updates', component: UpdatesPage }, { path: 'updates', component: UpdatesPage },
{ path: 'cluster', component: ClusterPage }, { path: 'cluster', component: ClusterPage },
{ path: 'vulnerabilities', component: VulnerabilitiesPage }, { path: 'vulnerabilities', component: VulnerabilitiesPage },
{ path: 'backups', component: PlaceholderPage, props: { title: 'Backups' } }, { path: 'backups', component: BackupsPage },
{ path: 'users', component: UsersPage, meta: { admin: true } }, { path: 'users', component: UsersPage, meta: { admin: true } },
{ path: 'jobs', component: JobsPage }, { path: 'jobs', component: JobsPage },
{ path: 'settings', component: SettingsPage }, { path: 'settings', component: SettingsPage },