Initial source: RAPPORT Server-App v0.1.0

- Tauri-2-Admin-UI fuer den Rapport-Compose-Stack
- React-Frontend (JSX, kein TS) mit Material-Symbols-Icons
- Service-Cards mit Live-Stats (CPU/RAM), Logs, Restart/Stop
- Backup-/Restore-System mit pg_dumpall + Retention
- Container-Auto-Updates mit Pre-Backup
- App-Auto-Updater (Tauri signiert) gegen latest.json im Repo-Root
- HTTPS-WebUI (axum/rustls) mit Basic-Auth, CSRF, Rate-Limit, Security-Headers
- Setup-Wizard: lädt Docker+Colima+Lima direct von GitHub/docker.com nach ~/.rapport/bin/
- Tray-Modus + macOS-Notifications + Auto-Recovery
- Login-Item via tauri-plugin-autostart
This commit is contained in:
2026-05-24 17:03:50 +02:00
commit e2d2fd9fa2
69 changed files with 14405 additions and 0 deletions
+6777
View File
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
[package]
name = "rapport-server-app"
version = "0.1.0"
edition = "2021"
authors = ["Karim Gabriele Varano"]
license = "AGPL-3.0-or-later"
description = "RAPPORT Server-App — Tauri-Wrapper für gebundlete Backend-Services"
default-run = "rapport-server-app"
[lib]
name = "app_lib"
crate-type = ["lib", "cdylib", "staticlib"]
[[bin]]
name = "rapport-server-app"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-process = "2"
tauri-plugin-log = "2"
tauri-plugin-updater = "2"
tauri-plugin-autostart = "2"
tauri-plugin-notification = "2"
# Async runtime
tokio = { version = "1", features = ["full"] }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Process supervisor utilities
nix = { version = "0.29", features = ["signal", "process"], default-features = false }
# Filesystem & paths
directories = "5"
dirs = "5"
# HTTP client (for health checks)
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
# Logging
log = "0.4"
env_logger = "0.11"
# Time
chrono = { version = "0.4", features = ["serde"] }
# Crypto utilities (for JWT-secret / random password generation)
rand = "0.8"
base64 = "0.22"
# JWT-Signing fuer ANON_KEY / SERVICE_ROLE_KEY beim Compose-Stack-Bootstrap
jsonwebtoken = "9"
# HTTP server fuer den optionalen Admin-WebUI-Zugang (Mac Mini ohne Display)
axum = { version = "0.7", features = ["macros"] }
axum-server = { version = "0.7", features = ["tls-rustls"] }
rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["fs", "auth", "cors", "trace"] }
# Self-signed certs fuer den Admin-WebUI-TLS
rcgen = "0.13"
# Hostname-Lookup fuer Cert-SANs
hostname = "0.4"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities — IPC, Process restart, Log access",
"windows": ["main"],
"permissions": [
"core:default",
"core:webview:allow-print",
"process:allow-restart",
"process:allow-exit",
"updater:default",
"autostart:default",
"notification:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 B

+317
View File
@@ -0,0 +1,317 @@
//! Backup-Modul.
//!
//! Erstellt SQL-Dumps via `docker compose exec -T db pg_dumpall` und legt
//! sie unter `<data_dir>/backups/` ab. Ein Hintergrund-Scheduler triggert
//! abhaengig von `BACKUP_INTERVAL_HOURS` aus der Config. Retention pruned
//! alle ueber `BACKUP_RETENTION_COUNT` hinaus (aelteste zuerst).
use crate::{paths, services};
use chrono::{DateTime, Local};
use serde::Serialize;
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
#[derive(Debug, Clone, Serialize)]
pub struct BackupInfo {
pub filename: String,
pub created_iso: String,
pub bytes: u64,
}
/// Default-Intervall fuer den Scheduler-Tick (1 Stunde). Im Tick wird gepruefte
/// ob das letzte Backup aelter als `BACKUP_INTERVAL_HOURS` ist.
pub const SCHED_TICK: Duration = Duration::from_secs(60 * 60);
const DEFAULT_INTERVAL_HOURS: u64 = 24;
const DEFAULT_RETENTION: usize = 7;
pub fn list() -> Vec<BackupInfo> {
let dir = paths::backups_dir();
let Ok(read) = std::fs::read_dir(&dir) else { return vec![] };
let mut out: Vec<BackupInfo> = read
.filter_map(|e| e.ok())
.filter_map(|e| {
let meta = e.metadata().ok()?;
if !meta.is_file() {
return None;
}
let name = e.file_name().to_string_lossy().to_string();
if !name.starts_with("rapport-") || !name.ends_with(".sql") {
return None;
}
let created: DateTime<Local> = meta.modified().ok()?.into();
Some(BackupInfo {
filename: name,
created_iso: created.to_rfc3339(),
bytes: meta.len(),
})
})
.collect();
out.sort_by(|a, b| b.created_iso.cmp(&a.created_iso));
out
}
/// Erstellt ein neues Backup. Datei-Name `rapport-YYYYmmdd-HHMMSS.sql`.
pub async fn create() -> Result<BackupInfo, String> {
paths::ensure_dirs().map_err(|e| format!("ensure_dirs: {e}"))?;
let stamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
let filename = format!("rapport-{stamp}.sql");
let path = paths::backups_dir().join(&filename);
log::info!("Backup → {}", path.display());
// `docker compose exec -T db pg_dumpall ...` — `-T` schaltet das TTY ab,
// damit stdout sauber pipebar ist.
let output = Command::new("docker")
.current_dir(services::compose_dir())
.args([
"compose",
"exec",
"-T",
"db",
"pg_dumpall",
"-U",
"supabase_admin",
"--clean",
"--if-exists",
])
.output()
.await
.map_err(|e| format!("spawn pg_dumpall: {e}"))?;
if !output.status.success() {
return Err(format!(
"pg_dumpall exited {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
));
}
std::fs::write(&path, &output.stdout).map_err(|e| format!("write {}: {e}", path.display()))?;
let bytes = output.stdout.len() as u64;
log::info!("Backup geschrieben: {} ({} bytes)", path.display(), bytes);
crate::events::info(format!(
"Backup erstellt: {filename} ({} KB)",
bytes / 1024
))
.await;
Ok(BackupInfo {
filename,
created_iso: Local::now().to_rfc3339(),
bytes,
})
}
/// Loescht die aeltesten Backups bis nur noch `keep` da sind.
pub fn prune(keep: usize) -> Result<usize, String> {
let all = list();
if all.len() <= keep {
return Ok(0);
}
let removed = &all[keep..];
let mut count = 0;
for b in removed {
let path = paths::backups_dir().join(&b.filename);
match std::fs::remove_file(&path) {
Ok(_) => {
log::info!("Backup geprunet: {}", path.display());
count += 1;
}
Err(e) => log::warn!("remove {}: {e}", path.display()),
}
}
Ok(count)
}
/// Liest die Config-Werte mit Fallbacks.
fn config_values() -> (bool, u64, usize) {
let cfg = crate::config::load().unwrap_or_default();
let enabled = cfg
.get("BACKUP_ENABLED")
.map(|v| v != "false" && v != "0")
.unwrap_or(true);
let interval = cfg
.get("BACKUP_INTERVAL_HOURS")
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_INTERVAL_HOURS);
let retention = cfg
.get("BACKUP_RETENTION_COUNT")
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_RETENTION);
(enabled, interval, retention)
}
/// Hintergrund-Scheduler. Tickt jede Stunde, schaut ob das aelteste Backup
/// schon `BACKUP_INTERVAL_HOURS` her ist, und triggert dann einen neuen
/// Dump + Prune.
pub async fn scheduler_loop() {
let mut tick = tokio::time::interval(SCHED_TICK);
// Erstes tick() feuert sofort — das ueberspringen wir, damit nicht beim
// App-Start jedes Mal ein Backup laeuft.
tick.tick().await;
loop {
tick.tick().await;
let (enabled, interval, retention) = config_values();
if !enabled {
continue;
}
if !backup_due(interval) {
continue;
}
match create().await {
Ok(b) => log::info!("Auto-Backup: {}", b.filename),
Err(e) => log::warn!("Auto-Backup fehlgeschlagen: {e}"),
}
if let Err(e) = prune(retention) {
log::warn!("Prune fehlgeschlagen: {e}");
}
}
}
fn backup_due(interval_hours: u64) -> bool {
let all = list();
let Some(latest) = all.first() else { return true };
let Ok(latest_dt) = DateTime::parse_from_rfc3339(&latest.created_iso) else {
return true;
};
let now = Local::now();
let age = now.signed_duration_since(latest_dt.with_timezone(&Local));
age.num_hours() >= interval_hours as i64
}
pub fn last_backup_path(filename: &str) -> Option<PathBuf> {
let dir = paths::backups_dir();
let p = dir.join(filename);
if p.exists() && p.starts_with(&dir) {
Some(p)
} else {
None
}
}
#[derive(Debug, Clone, Serialize)]
pub struct RestoreResult {
pub restored_from: String,
pub safety_backup: String,
pub finished_at_iso: String,
pub psql_log: String,
}
/// Service-Liste die einen Restore stoert (haelt DB-Connections oder erwartet Schema).
/// `db` selbst bleibt natuerlich an.
const RESTORE_STOP_SERVICES: &[&str] = &["auth", "rest", "realtime", "storage", "kong", "app"];
/// Spielt einen Snapshot zurueck.
/// Workflow:
/// 1. Sicherheits-Backup vom aktuellen Zustand (kann nicht weh tun)
/// 2. Abhaengige Services stoppen (Connections raus)
/// 3. `psql -d postgres` mit dem Dump auf stdin pipen
/// 4. Alle Services wieder starten
pub async fn restore(filename: &str) -> Result<RestoreResult, String> {
// Pfad-Traversal-Schutz: Filename darf keine Separatoren enthalten.
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Err("ungueltiger Dateiname".into());
}
let dir = paths::backups_dir();
let path = dir.join(filename);
if !path.starts_with(&dir) || !path.exists() {
return Err(format!("Backup nicht gefunden: {filename}"));
}
let dump = std::fs::read(&path).map_err(|e| format!("read dump: {e}"))?;
// 1) Safety-Backup zuerst — wenn das schiefgeht, gar nicht erst restoren.
crate::events::info(format!("Restore von {filename} startet — erst Safety-Backup")).await;
let safety = create()
.await
.map_err(|e| format!("Safety-Backup vor Restore fehlgeschlagen: {e}"))?;
// 2) Abhaengige Services stoppen.
crate::events::info(format!(
"Restore: stoppe {} (db bleibt an)",
RESTORE_STOP_SERVICES.join(", ")
))
.await;
let mut stop_args = vec!["compose", "stop"];
stop_args.extend(RESTORE_STOP_SERVICES);
let stop_out = Command::new("docker")
.current_dir(services::compose_dir())
.args(&stop_args)
.output()
.await
.map_err(|e| format!("spawn compose stop: {e}"))?;
if !stop_out.status.success() {
return Err(format!(
"compose stop fehlgeschlagen: {}",
String::from_utf8_lossy(&stop_out.stderr).trim()
));
}
// 3) Dump via psql einspielen.
crate::events::info(format!("Restore: spiele Snapshot {filename} ein ({} KB)", dump.len() / 1024)).await;
let mut child = Command::new("docker")
.current_dir(services::compose_dir())
.args([
"compose", "exec", "-T", "db",
"psql", "-U", "supabase_admin", "-d", "postgres",
"-v", "ON_ERROR_STOP=0", // unbekannte Schema-Differenzen weiter durchziehen
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| format!("spawn psql: {e}"))?;
{
let mut stdin = child.stdin.take().ok_or_else(|| "no stdin".to_string())?;
stdin
.write_all(&dump)
.await
.map_err(|e| format!("write dump to psql: {e}"))?;
stdin
.shutdown()
.await
.map_err(|e| format!("psql stdin shutdown: {e}"))?;
}
let output = child
.wait_with_output()
.await
.map_err(|e| format!("wait psql: {e}"))?;
let psql_log = format!(
"{}\n--- stderr ---\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
// 4) Services wieder hoch — auch wenn psql Warnungen hatte (Datenbank ist
// eingespielt, die Services connecten sich neu).
crate::events::info("Restore: starte Services wieder").await;
let up_out = Command::new("docker")
.current_dir(services::compose_dir())
.args(["compose", "up", "-d"])
.output()
.await
.map_err(|e| format!("spawn compose up: {e}"))?;
if !up_out.status.success() {
return Err(format!(
"compose up nach Restore fehlgeschlagen: {}",
String::from_utf8_lossy(&up_out.stderr).trim()
));
}
crate::events::info(format!(
"Restore abgeschlossen aus {filename} (Safety: {})",
safety.filename
))
.await;
Ok(RestoreResult {
restored_from: filename.to_string(),
safety_backup: safety.filename,
finished_at_iso: Local::now().to_rfc3339(),
psql_log,
})
}
+163
View File
@@ -0,0 +1,163 @@
//! Tauri-Commands.
//!
//! Schmaler Layer zwischen WebView-Frontend und Supervisor. Jeder Command
//! ist `async`, sperrt den Supervisor-Mutex moeglichst kurz und gibt das
//! Ergebnis als JSON-serialisierbare Struktur zurueck.
use crate::backup::{self, BackupInfo, RestoreResult};
use crate::config;
use crate::container_update::{self, ApplyResult, CheckResult};
use crate::disk::{self, DiskUsage};
use crate::events::{self, Event};
use crate::firstaid::{self, DiagnoseResult, RecreateResult, ResetResult};
use crate::setup::{self, InstallResult, SetupStatus};
use crate::stats::{self, ContainerStats};
use crate::supervisor::ServiceStatus;
use crate::AppState;
use tauri::State;
#[tauri::command]
pub async fn list_services(state: State<'_, AppState>) -> Result<Vec<ServiceStatus>, String> {
// Mit Timeout + Cache — falls Supervisor-Mutex durch lange Operation belegt.
Ok(crate::supervisor::list_with_timeout(&state.supervisor, 300).await)
}
#[tauri::command]
pub async fn service_status(
state: State<'_, AppState>,
id: String,
) -> Result<Option<ServiceStatus>, String> {
let sv = state.supervisor.lock().await;
Ok(sv.status(&id))
}
#[tauri::command]
pub async fn service_logs(state: State<'_, AppState>, id: String) -> Result<Vec<String>, String> {
let sv = state.supervisor.lock().await;
Ok(sv.logs(&id).await)
}
#[tauri::command]
pub async fn start_service(state: State<'_, AppState>, id: String) -> Result<(), String> {
let mut sv = state.supervisor.lock().await;
sv.start(&id).await
}
#[tauri::command]
pub async fn stop_service(state: State<'_, AppState>, id: String) -> Result<(), String> {
let mut sv = state.supervisor.lock().await;
sv.stop(&id).await
}
#[tauri::command]
pub async fn restart_service(state: State<'_, AppState>, id: String) -> Result<(), String> {
let mut sv = state.supervisor.lock().await;
sv.restart(&id).await
}
#[tauri::command]
pub async fn restart_all(state: State<'_, AppState>) -> Result<(), String> {
crate::supervisor::Supervisor::restart_all_managed(state.supervisor.clone()).await
}
#[tauri::command]
pub async fn start_all(state: State<'_, AppState>) -> Result<(), String> {
crate::supervisor::Supervisor::start_all_managed(state.supervisor.clone()).await
}
#[tauri::command]
pub async fn stop_all(state: State<'_, AppState>) -> Result<(), String> {
crate::supervisor::Supervisor::stop_all_managed(state.supervisor.clone()).await
}
#[tauri::command]
pub async fn get_config() -> Result<config::EnvMap, String> {
let mut map = config::load()?;
config::ensure_defaults(&mut map);
Ok(map)
}
#[tauri::command]
pub async fn set_config_value(key: String, value: String) -> Result<(), String> {
let mut map = config::load()?;
map.insert(key, value);
config::save(&map)
}
#[tauri::command]
pub async fn backup_now() -> Result<BackupInfo, String> {
let info = backup::create().await?;
// Direkt nach manuellem Backup auch prunen (sonst macht's nur der Scheduler).
let retention = config::load()
.ok()
.and_then(|c| c.get("BACKUP_RETENTION_COUNT").cloned())
.and_then(|v| v.parse().ok())
.unwrap_or(7);
let _ = backup::prune(retention);
Ok(info)
}
#[tauri::command]
pub async fn list_backups() -> Result<Vec<BackupInfo>, String> {
Ok(backup::list())
}
#[tauri::command]
pub async fn restore_backup(filename: String) -> Result<RestoreResult, String> {
backup::restore(&filename).await
}
#[tauri::command]
pub async fn check_container_updates() -> Result<CheckResult, String> {
container_update::check().await
}
#[tauri::command]
pub async fn apply_container_updates() -> Result<ApplyResult, String> {
container_update::apply().await
}
#[tauri::command]
pub async fn list_events() -> Result<Vec<Event>, String> {
Ok(events::list().await)
}
#[tauri::command]
pub async fn list_stats() -> Result<Vec<ContainerStats>, String> {
Ok(stats::collect().await)
}
#[tauri::command]
pub async fn disk_usage() -> Result<DiskUsage, String> {
Ok(disk::collect().await)
}
#[tauri::command]
pub async fn firstaid_recreate() -> Result<RecreateResult, String> {
firstaid::recreate_containers().await
}
#[tauri::command]
pub async fn firstaid_reset_pgdata() -> Result<ResetResult, String> {
firstaid::reset_pgdata().await
}
#[tauri::command]
pub async fn firstaid_diagnose() -> Result<DiagnoseResult, String> {
firstaid::diagnose_bundle().await
}
#[tauri::command]
pub async fn setup_status() -> Result<SetupStatus, String> {
Ok(setup::status().await)
}
#[tauri::command]
pub async fn setup_install(state: State<'_, AppState>) -> Result<InstallResult, String> {
// Wir starten die Container hier NICHT automatisch — der User soll bewusst
// "Alle starten" klicken sobald das Dashboard erscheint. Beim spaeteren
// App-Reboot (Daemon schon hochgefahren) feuert die AUTO_START-Logik in
// lib.rs::setup() ganz normal.
let _ = state; // state nur fuer eventual zukuenftige Verwendung
setup::install_and_start().await
}
+121
View File
@@ -0,0 +1,121 @@
//! Konfigurations-Management.
//!
//! Liest und schreibt `config.env` im App-Data-Pfad. Generiert sichere
//! Defaults beim Erst-Start: `POSTGRES_PASSWORD`, `JWT_SECRET`, davon
//! abgeleitet `ANON_KEY` und `SERVICE_ROLE_KEY`.
use crate::paths;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use rand::Rng;
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
/// Geordnete Map (für stabile Datei-Reihenfolge).
pub type EnvMap = BTreeMap<String, String>;
pub fn load() -> Result<EnvMap, String> {
let path = paths::config_env_path();
if !path.exists() {
return Ok(EnvMap::new());
}
let content = fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
let mut map = EnvMap::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
map.insert(k.trim().to_string(), v.trim().to_string());
}
}
Ok(map)
}
pub fn save(map: &EnvMap) -> Result<(), String> {
paths::ensure_dirs().map_err(|e| format!("ensure_dirs: {e}"))?;
let path = paths::config_env_path();
let mut tmp = path.clone();
tmp.set_extension("env.tmp");
let mut file = fs::File::create(&tmp).map_err(|e| format!("create {}: {e}", tmp.display()))?;
writeln!(file, "# RAPPORT Server-App — Auto-generated config.env").unwrap();
writeln!(file, "# Aenderungen ueberleben App-Updates.").unwrap();
writeln!(file).unwrap();
for (k, v) in map {
writeln!(file, "{k}={v}").unwrap();
}
file.sync_all().map_err(|e| format!("sync: {e}"))?;
drop(file);
fs::rename(&tmp, &path).map_err(|e| format!("rename: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o600);
let _ = fs::set_permissions(&path, perms);
}
Ok(())
}
/// Erst-Start: fehlende Schluessel mit sicheren Defaults befuellen.
/// Aendert keine existierenden Werte.
pub fn ensure_defaults(map: &mut EnvMap) {
map.entry("POSTGRES_PASSWORD".into())
.or_insert_with(random_password);
map.entry("JWT_SECRET".into()).or_insert_with(random_jwt_secret);
map.entry("SITE_URL".into())
.or_insert_with(|| "http://localhost:8080".into());
map.entry("API_EXTERNAL_URL".into())
.or_insert_with(|| "http://localhost:8000".into());
map.entry("BIND_HOST".into())
.or_insert_with(|| "127.0.0.1".into());
map.entry("ADMIN_UI_PASSWORD".into())
.or_insert_with(random_password);
map.entry("ADMIN_UI_PORT".into())
.or_insert_with(|| "9090".into());
// Default 127.0.0.1: WebUI nur ueber Loopback. Fuer LAN-Zugriff manuell
// auf 0.0.0.0 stellen (Settings → Im LAN freigeben).
map.entry("ADMIN_UI_BIND".into())
.or_insert_with(|| "127.0.0.1".into());
// TLS ist Default: das WebUI laeuft ueber HTTPS mit selbst-signiertem Cert.
map.entry("ADMIN_UI_TLS".into())
.or_insert_with(|| "true".into());
map.entry("BACKUP_ENABLED".into())
.or_insert_with(|| "true".into());
map.entry("BACKUP_INTERVAL_HOURS".into())
.or_insert_with(|| "24".into());
map.entry("BACKUP_RETENTION_COUNT".into())
.or_insert_with(|| "7".into());
// Container-Auto-Update standardmaessig AUS — explizit aktivieren via
// Settings, weil ungeplante Migrations potenziell Daten betreffen.
map.entry("CONTAINER_AUTOUPDATE_ENABLED".into())
.or_insert_with(|| "false".into());
map.entry("CONTAINER_AUTOUPDATE_INTERVAL_HOURS".into())
.or_insert_with(|| "24".into());
map.entry("AUTO_START_CONTAINERS_ON_LAUNCH".into())
.or_insert_with(|| "true".into());
// Auto-Recovery: Container die in 'error' haengen werden automatisch
// neu gestartet (exponential backoff, dann aufgeben + Notification).
map.entry("AUTO_RECOVERY_ENABLED".into())
.or_insert_with(|| "true".into());
map.entry("AUTO_RECOVERY_BASE_DELAY_SECONDS".into())
.or_insert_with(|| "60".into());
map.entry("AUTO_RECOVERY_MAX_ATTEMPTS".into())
.or_insert_with(|| "5".into());
}
fn random_password() -> String {
let mut rng = rand::thread_rng();
let bytes: [u8; 24] = rng.gen();
URL_SAFE_NO_PAD.encode(bytes)
}
fn random_jwt_secret() -> String {
let mut rng = rand::thread_rng();
let mut bytes = [0u8; 48];
rng.fill(&mut bytes[..]);
URL_SAFE_NO_PAD.encode(bytes)
}
+255
View File
@@ -0,0 +1,255 @@
//! Container-Update-Modul.
//!
//! Workflow:
//! 1. `docker compose pull` zieht Image-Updates
//! 2. Image-IDs vorher/nachher vergleichen — was hat sich geaendert?
//! 3. Wenn was geaendert: erst Backup (pg_dumpall), dann
//! `docker compose up -d` — Compose erkennt selbst was neu erstellt werden
//! muss anhand veraenderter Image-IDs.
//!
//! Scheduler tickt `CONTAINER_AUTOUPDATE_INTERVAL_HOURS` (Default 24h).
//! `CONTAINER_AUTOUPDATE_ENABLED=false` schaltet den Auto-Teil ab — manuelles
//! `check_now` / `apply_now` geht trotzdem.
use crate::{backup, services};
use chrono::Local;
use serde::Serialize;
use std::collections::HashMap;
use std::time::Duration;
use tokio::process::Command;
pub const SCHED_TICK: Duration = Duration::from_secs(60 * 60);
const DEFAULT_INTERVAL_HOURS: u64 = 24;
#[derive(Debug, Clone, Serialize)]
pub struct UpdateAvailable {
pub service: String,
pub image: String,
pub old_id: String,
pub new_id: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct CheckResult {
pub checked_at_iso: String,
pub updates: Vec<UpdateAvailable>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ApplyResult {
pub applied_at_iso: String,
pub updated_services: Vec<String>,
pub backup_filename: Option<String>,
pub recreate_log: String,
}
#[derive(Debug, Clone, Serialize, Default)]
struct ImageRow {
service: String,
image: String,
id: String,
}
/// Liefert die Image-ID pro Compose-Service.
async fn snapshot_images() -> Result<HashMap<String, ImageRow>, String> {
let out = Command::new("docker")
.current_dir(services::compose_dir())
.args(["compose", "images", "--format", "json"])
.output()
.await
.map_err(|e| format!("spawn: {e}"))?;
if !out.status.success() {
return Err(format!(
"compose images: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
let stdout = String::from_utf8_lossy(&out.stdout);
let mut map = HashMap::new();
// Compose kann ein JSON-Array ODER JSON-Lines liefern — beide Faelle abfangen.
if let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(&stdout) {
for v in arr {
if let Some(row) = parse_row(&v) {
map.insert(row.service.clone(), row);
}
}
} else {
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(v) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(row) = parse_row(&v) {
map.insert(row.service.clone(), row);
}
}
}
}
Ok(map)
}
fn parse_row(v: &serde_json::Value) -> Option<ImageRow> {
let service = v.get("Service").and_then(|x| x.as_str())?.to_string();
let repo = v.get("Repository").and_then(|x| x.as_str()).unwrap_or("");
let tag = v.get("Tag").and_then(|x| x.as_str()).unwrap_or("");
let id = v.get("ID").or_else(|| v.get("ImageId"))
.and_then(|x| x.as_str()).unwrap_or("").to_string();
Some(ImageRow {
service,
image: format!("{repo}:{tag}"),
id,
})
}
/// Pullt alle Compose-Images. Bei `app` (lokal gebaut, nicht in der Registry)
/// kommt ein Fehler — ignorieren wir, das ist erwartet.
async fn compose_pull() -> Result<(), String> {
let out = Command::new("docker")
.current_dir(services::compose_dir())
.args(["compose", "pull", "--ignore-pull-failures"])
.output()
.await
.map_err(|e| format!("spawn: {e}"))?;
// Auch bei non-zero exit ignorieren wir — solange wir die Image-IDs danach
// einsammeln koennen, ist alles weitere ableitbar.
if !out.status.success() {
log::warn!(
"compose pull non-zero (oft `app`-Image — egal): {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
async fn compose_up() -> Result<String, String> {
let out = Command::new("docker")
.current_dir(services::compose_dir())
.args(["compose", "up", "-d"])
.output()
.await
.map_err(|e| format!("spawn: {e}"))?;
let log = format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
if !out.status.success() {
return Err(format!("compose up failed: {log}"));
}
Ok(log)
}
/// Prueft auf Image-Updates ohne sie anzuwenden. `docker compose pull` laeuft,
/// danach vergleichen wir die Image-IDs.
pub async fn check() -> Result<CheckResult, String> {
let before = snapshot_images().await?;
compose_pull().await?;
let after = snapshot_images().await?;
let mut updates = vec![];
for (svc, new_row) in &after {
let old_id = before.get(svc).map(|r| r.id.as_str()).unwrap_or("");
if !new_row.id.is_empty() && old_id != new_row.id {
updates.push(UpdateAvailable {
service: svc.clone(),
image: new_row.image.clone(),
old_id: old_id.to_string(),
new_id: new_row.id.clone(),
});
}
}
Ok(CheckResult {
checked_at_iso: Local::now().to_rfc3339(),
updates,
})
}
/// Pullt + (falls Updates da) Backup + Compose-Up. Liefert was wirklich
/// passiert ist — empty `updated_services` = nichts zu tun.
pub async fn apply() -> Result<ApplyResult, String> {
let check_res = check().await?;
if check_res.updates.is_empty() {
return Ok(ApplyResult {
applied_at_iso: Local::now().to_rfc3339(),
updated_services: vec![],
backup_filename: None,
recreate_log: "Keine Image-Updates — nichts zu tun.".into(),
});
}
// Pre-Backup. Wenn das schiefgeht, brechen wir ab — keine Updates ohne Safety-Net.
log::info!(
"Container-Update: Backup vor Recreate ({} Services updates)",
check_res.updates.len()
);
let backup = backup::create()
.await
.map_err(|e| format!("Pre-Backup fehlgeschlagen, Update abgebrochen: {e}"))?;
let log = compose_up().await?;
let services_list: Vec<String> =
check_res.updates.iter().map(|u| u.service.clone()).collect();
crate::events::info(format!(
"Container-Update angewendet auf {} (Backup: {})",
services_list.join(", "),
backup.filename
))
.await;
Ok(ApplyResult {
applied_at_iso: Local::now().to_rfc3339(),
updated_services: services_list,
backup_filename: Some(backup.filename),
recreate_log: log,
})
}
fn config_values() -> (bool, u64) {
let cfg = crate::config::load().unwrap_or_default();
let enabled = cfg
.get("CONTAINER_AUTOUPDATE_ENABLED")
.map(|v| v != "false" && v != "0")
.unwrap_or(false);
let interval = cfg
.get("CONTAINER_AUTOUPDATE_INTERVAL_HOURS")
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_INTERVAL_HOURS);
(enabled, interval)
}
pub async fn scheduler_loop() {
let mut tick = tokio::time::interval(SCHED_TICK);
tick.tick().await; // initial tick verwerfen (siehe backup::scheduler_loop)
let mut last_check: Option<chrono::DateTime<Local>> = None;
loop {
tick.tick().await;
let (enabled, interval) = config_values();
if !enabled {
continue;
}
let now = Local::now();
let due = match last_check {
None => true,
Some(t) => now.signed_duration_since(t).num_hours() >= interval as i64,
};
if !due {
continue;
}
last_check = Some(now);
match apply().await {
Ok(res) if res.updated_services.is_empty() => {
log::info!("Auto-Update: keine neuen Images");
}
Ok(res) => {
log::info!(
"Auto-Update angewendet auf: {} (Backup: {:?})",
res.updated_services.join(", "),
res.backup_filename
);
}
Err(e) => log::warn!("Auto-Update fehlgeschlagen: {e}"),
}
}
}
+158
View File
@@ -0,0 +1,158 @@
//! Disk-Usage-Sammler fuer's Backup-Panel und (spaeter) Warn-Banner.
//!
//! Vier Metriken:
//! - Postgres logische DB-Groesse (`pg_database_size('postgres')`)
//! - Backup-Verzeichnis-Groesse (Summe aller .sql-Files)
//! - Docker-Volumes-Total (alle Volumes des Daemons, via `docker system df`)
//! - Freier Disk-Space im Backup-Verzeichnis (via `df -k`)
use crate::{paths, services};
use serde::Serialize;
use tokio::process::Command;
#[derive(Debug, Clone, Serialize)]
pub struct DiskUsage {
/// Logische Groesse der `postgres`-DB (None wenn db nicht erreichbar).
pub postgres_db_bytes: Option<u64>,
/// Summe aller `rapport-*.sql`-Backups.
pub backups_total_bytes: u64,
pub backup_count: u32,
/// Freier Platz auf dem Disk wo die Backups liegen.
pub host_free_bytes: u64,
/// Gesamt-Platz auf demselben Disk.
pub host_total_bytes: u64,
/// Totaler Docker-Volumes-Verbrauch (alle Compose-Stacks, nicht nur unsere).
pub docker_volumes_bytes: Option<u64>,
}
pub async fn collect() -> DiskUsage {
let (host_free, host_total) = host_disk(&paths::backups_dir());
DiskUsage {
postgres_db_bytes: pg_db_size().await.ok(),
backups_total_bytes: backups_size(),
backup_count: backup_count(),
host_free_bytes: host_free,
host_total_bytes: host_total,
docker_volumes_bytes: docker_volumes_size().await.ok(),
}
}
async fn pg_db_size() -> Result<u64, String> {
// Postgres-Passwort fuer psql via -e PGPASSWORD durchreichen — sonst
// promptet psql interaktiv und wir kriegen einen Auth-Error.
let cfg = crate::config::load().unwrap_or_default();
let pw = cfg
.get("POSTGRES_PASSWORD")
.cloned()
.unwrap_or_default();
let out = Command::new("docker")
.current_dir(services::compose_dir())
.args([
"compose", "exec",
"-e", &format!("PGPASSWORD={pw}"),
"-T", "db",
"psql", "-U", "supabase_admin", "-d", "postgres",
"-tAc", "SELECT pg_database_size('postgres');",
])
.output()
.await
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
String::from_utf8_lossy(&out.stdout)
.trim()
.parse::<u64>()
.map_err(|e| e.to_string())
}
fn backups_size() -> u64 {
let dir = paths::backups_dir();
std::fs::read_dir(&dir)
.ok()
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter_map(|e| e.metadata().ok())
.filter(|m| m.is_file())
.map(|m| m.len())
.sum()
}
fn backup_count() -> u32 {
crate::backup::list().len() as u32
}
fn host_disk(path: &std::path::Path) -> (u64, u64) {
// `df -k <path>` → 2. Zeile: FS 1k-blocks Used Avail Capacity ... Mounted-on
let out = std::process::Command::new("df")
.arg("-k")
.arg(path)
.output();
let Ok(o) = out else { return (0, 0) };
if !o.status.success() {
return (0, 0);
}
let stdout = String::from_utf8_lossy(&o.stdout);
let mut lines = stdout.lines();
let _header = lines.next();
let Some(data_line) = lines.next() else { return (0, 0) };
let cols: Vec<&str> = data_line.split_whitespace().collect();
if cols.len() < 4 {
return (0, 0);
}
let total = cols[1].parse::<u64>().unwrap_or(0).saturating_mul(1024);
let avail = cols[3].parse::<u64>().unwrap_or(0).saturating_mul(1024);
(avail, total)
}
async fn docker_volumes_size() -> Result<u64, String> {
let out = Command::new("docker")
.args(["system", "df", "--format", "json"])
.output()
.await
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err("docker system df fehlgeschlagen".into());
}
let stdout = String::from_utf8_lossy(&out.stdout);
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
// Docker nennt's "Local Volumes" (Mac/Linux); aelter manchmal "Volumes".
let t = v.get("Type").and_then(|x| x.as_str()).unwrap_or("");
if t == "Local Volumes" || t == "Volumes" {
let size_str = v.get("Size").and_then(|x| x.as_str()).unwrap_or("");
return Ok(parse_human_size(size_str));
}
}
Err("Volumes-Row nicht gefunden".into())
}
/// "1.5GB" / "850MB" / "0B" → Bytes
fn parse_human_size(s: &str) -> u64 {
let s = s.trim();
let split = s
.find(|c: char| c.is_alphabetic())
.unwrap_or(s.len());
let (num, unit) = s.split_at(split);
let n: f64 = num.parse().unwrap_or(0.0);
let mul = match unit.trim().to_uppercase().as_str() {
"B" => 1.0,
"KB" | "K" => 1000.0,
"MB" | "M" => 1000.0 * 1000.0,
"GB" | "G" => 1000.0 * 1000.0 * 1000.0,
"TB" | "T" => 1000.0_f64.powi(4),
"KIB" => 1024.0,
"MIB" => 1024.0 * 1024.0,
"GIB" => 1024.0 * 1024.0 * 1024.0,
_ => 1.0,
};
(n * mul) as u64
}
+57
View File
@@ -0,0 +1,57 @@
//! Globaler App-Event-Log fuer das Status-Dashboard.
//!
//! Ringbuffer (max 100 Eintraege) mit Timestamp + Severity + Message.
//! Wird von verschiedenen Stellen geschrieben (Supervisor, Backup-Scheduler,
//! Container-Update, Pre-Pull, App-Startup) und vom Frontend gepollt.
use chrono::Local;
use serde::Serialize;
use std::collections::VecDeque;
use std::sync::OnceLock;
use tokio::sync::Mutex;
const CAPACITY: usize = 100;
#[derive(Debug, Clone, Serialize)]
pub struct Event {
pub ts_iso: String,
pub kind: &'static str,
pub message: String,
}
static EVENTS: OnceLock<Mutex<VecDeque<Event>>> = OnceLock::new();
fn store() -> &'static Mutex<VecDeque<Event>> {
EVENTS.get_or_init(|| Mutex::new(VecDeque::with_capacity(CAPACITY)))
}
pub async fn append(kind: &'static str, message: impl Into<String>) {
let msg = message.into();
log::info!("[event] {}", msg);
let mut q = store().lock().await;
if q.len() >= CAPACITY {
q.pop_front();
}
q.push_back(Event {
ts_iso: Local::now().to_rfc3339(),
kind,
message: msg,
});
}
pub async fn info(msg: impl Into<String>) {
append("info", msg).await
}
pub async fn warn(msg: impl Into<String>) {
append("warn", msg).await
}
pub async fn error(msg: impl Into<String>) {
append("error", msg).await
}
pub async fn list() -> Vec<Event> {
let q = store().lock().await;
q.iter().rev().cloned().collect()
}
+177
View File
@@ -0,0 +1,177 @@
//! Erste-Hilfe-Aktionen fuer den Notfall.
//!
//! Drei destruktive Aktionen mit unterschiedlichem Risiko:
//! - `recreate_containers` (mild): compose down + up --force-recreate.
//! Container neu, Volumes bleiben → Daten safe.
//! - `reset_pgdata` (NUKE): compose down -v + up. Volumes WEG → Postgres
//! wird von Grund auf neu initialisiert. Erfordert Pre-Backup.
//! - `diagnose_bundle`: kein Schaden — sammelt Logs, ps, version, config
//! (redacted) in eine Text-Datei unter backups/.
use crate::{backup::BackupInfo, paths, services};
use chrono::Local;
use serde::Serialize;
use tokio::process::Command;
#[derive(Debug, Clone, Serialize)]
pub struct RecreateResult {
pub log: String,
pub finished_at_iso: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ResetResult {
pub safety_backup: BackupInfo,
pub log: String,
pub finished_at_iso: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct DiagnoseResult {
pub filename: String,
pub bytes: u64,
}
async fn compose(args: &[&str]) -> Result<String, String> {
let out = Command::new("docker")
.current_dir(services::compose_dir())
.arg("compose")
.args(args)
.output()
.await
.map_err(|e| format!("spawn: {e}"))?;
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
if !out.status.success() {
return Err(format!("compose {args:?} failed: {combined}"));
}
Ok(combined)
}
/// Container neu erstellen — Volumes bleiben.
pub async fn recreate_containers() -> Result<RecreateResult, String> {
crate::events::warn("Erste Hilfe: Container neu erstellen ...").await;
let _ = compose(&["down"]).await;
let log = compose(&["up", "-d", "--force-recreate"]).await?;
crate::events::info("Erste Hilfe: Container neu erstellt").await;
Ok(RecreateResult {
log,
finished_at_iso: Local::now().to_rfc3339(),
})
}
/// VOLL DESTRUKTIV: Volumes platt + frisches Init.
pub async fn reset_pgdata() -> Result<ResetResult, String> {
crate::events::warn("Erste Hilfe: PGDATA-RESET — erst Safety-Backup").await;
let safety = crate::backup::create()
.await
.map_err(|e| format!("Safety-Backup vor PGDATA-Reset fehlgeschlagen — Reset abgebrochen: {e}"))?;
crate::events::warn(format!(
"Erste Hilfe: stoppe Stack inkl. Volumes (Safety: {})",
safety.filename
))
.await;
let _ = compose(&["down", "-v"]).await?;
let log = compose(&["up", "-d"]).await?;
crate::events::error(format!(
"Erste Hilfe: PGDATA platt gemacht und neu initialisiert (Safety: {})",
safety.filename
))
.await;
Ok(ResetResult {
safety_backup: safety,
log,
finished_at_iso: Local::now().to_rfc3339(),
})
}
/// Sammelt Diagnose-Informationen in eine Text-Datei unter backups/.
pub async fn diagnose_bundle() -> Result<DiagnoseResult, String> {
paths::ensure_dirs().map_err(|e| format!("ensure_dirs: {e}"))?;
let stamp = Local::now().format("%Y%m%d-%H%M%S").to_string();
let filename = format!("diagnose-{stamp}.txt");
let path = paths::backups_dir().join(&filename);
let mut out = String::new();
out.push_str(&format!("# RAPPORT Server-App Diagnose ({})\n\n", Local::now().to_rfc3339()));
out.push_str("## docker version\n```\n");
out.push_str(&run_capture(&["docker", "version"]).await);
out.push_str("\n```\n\n");
out.push_str("## docker compose ps -a\n```\n");
out.push_str(&run_capture(&["docker", "compose", "ps", "-a"]).await);
out.push_str("\n```\n\n");
out.push_str("## docker system df\n```\n");
out.push_str(&run_capture(&["docker", "system", "df"]).await);
out.push_str("\n```\n\n");
out.push_str("## config.env (redacted)\n```\n");
out.push_str(&redacted_config());
out.push_str("\n```\n\n");
out.push_str("## App-Events (letzte 100)\n```\n");
for e in crate::events::list().await {
out.push_str(&format!("[{}] {}: {}\n", e.ts_iso, e.kind, e.message));
}
out.push_str("\n```\n\n");
out.push_str("## Container-Logs (letzte 50 Zeilen pro Service)\n");
for svc in crate::services::default_services() {
out.push_str(&format!("\n### {}\n```\n", svc.id));
out.push_str(&run_capture(&[
"docker", "compose", "logs", "--no-color", "--tail", "50", &svc.id,
]).await);
out.push_str("\n```\n");
}
std::fs::write(&path, out.as_bytes()).map_err(|e| format!("write {}: {e}", path.display()))?;
let bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
crate::events::info(format!("Diagnose-Bundle: {filename} ({} KB)", bytes / 1024)).await;
Ok(DiagnoseResult { filename, bytes })
}
async fn run_capture(argv: &[&str]) -> String {
let mut cmd = Command::new(argv[0]);
cmd.current_dir(services::compose_dir());
for a in &argv[1..] {
cmd.arg(a);
}
match cmd.output().await {
Ok(o) => format!(
"{}{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
),
Err(e) => format!("(spawn fehlgeschlagen: {e})"),
}
}
const REDACT_PREFIXES: &[&str] = &[
"POSTGRES_PASSWORD",
"JWT_SECRET",
"ADMIN_UI_PASSWORD",
"ANON_KEY",
"SERVICE_ROLE_KEY",
"SMTP_PASS",
];
fn redacted_config() -> String {
let map = crate::config::load().unwrap_or_default();
let mut out = String::new();
for (k, v) in &map {
let redacted = REDACT_PREFIXES.iter().any(|p| k.contains(p));
if redacted {
out.push_str(&format!("{k}=<REDACTED {} chars>\n", v.len()));
} else {
out.push_str(&format!("{k}={v}\n"));
}
}
out
}
+57
View File
@@ -0,0 +1,57 @@
//! Health-Probes für Subprozesse.
//!
//! Jeder Service hat einen Probe-Typ. `HealthProbe::check()` ist async und
//! liefert `Ok(())` wenn der Service als gesund gilt, sonst eine Fehler-
//! beschreibung.
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::net::TcpStream;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum HealthProbe {
/// HTTP GET, erwartet 2xx-Status.
Http { url: String },
/// TCP-Connect, dann (optional) `SELECT 1` via psql.
/// Für Postgres: aktuell nur TCP-Connect — `psql`-Query käme dazu, wenn
/// libpq oder ein eingebetteter Client verfügbar ist.
TcpAndQuery { host: String, port: u16, db: String },
}
const TIMEOUT: Duration = Duration::from_secs(3);
impl HealthProbe {
pub async fn check(&self) -> Result<(), String> {
match self {
HealthProbe::Http { url } => check_http(url).await,
HealthProbe::TcpAndQuery { host, port, .. } => check_tcp(host, *port).await,
}
}
}
async fn check_http(url: &str) -> Result<(), String> {
let client = reqwest::Client::builder()
.timeout(TIMEOUT)
.build()
.map_err(|e| format!("reqwest build: {e}"))?;
let resp = client
.get(url)
.send()
.await
.map_err(|e| format!("http get {url}: {e}"))?;
if resp.status().is_success() || resp.status().is_redirection() {
Ok(())
} else {
Err(format!("status {}", resp.status()))
}
}
async fn check_tcp(host: &str, port: u16) -> Result<(), String> {
let addr = format!("{host}:{port}");
tokio::time::timeout(TIMEOUT, TcpStream::connect(&addr))
.await
.map_err(|_| format!("tcp connect {addr}: timeout"))?
.map_err(|e| format!("tcp connect {addr}: {e}"))?;
Ok(())
}
+462
View File
@@ -0,0 +1,462 @@
//! HTTP/HTTPS-Server fuer den Admin-WebUI-Zugang.
//!
//! Spiegelt die Tauri-Commands als REST-Endpoints + serviert das React-`dist/`.
//! Konfiguration aus `config.env`:
//! - `ADMIN_UI_BIND` (Default `127.0.0.1`; LAN-Freigabe → `0.0.0.0`)
//! - `ADMIN_UI_PORT` (Default `9090`)
//! - `ADMIN_UI_PASSWORD` (Auto-generiert, ~32 Bytes random)
//! - `ADMIN_UI_TLS` (Default `true` — self-signed Cert wird automatisch
//! in `<data_dir>/admin-ui-cert.pem` erzeugt)
//!
//! Auth: Basic-Auth mit User `admin`. Fehlversuche werden pro IP gezaehlt;
//! nach `MAX_FAILS` Fehlern in `FAIL_WINDOW` Sekunden ist die IP fuer
//! `LOCKOUT` Sekunden geblockt.
use crate::supervisor::Supervisor;
use axum::{
body::Body,
extract::{ConnectInfo, Path, State},
http::{header, Request, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use axum_server::tls_rustls::RustlsConfig;
use base64::Engine;
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use tower_http::services::ServeDir;
const MAX_FAILS: u32 = 5;
const FAIL_WINDOW: Duration = Duration::from_secs(60);
const LOCKOUT: Duration = Duration::from_secs(300);
#[derive(Default)]
struct AuthFailure {
count: u32,
first_seen: Option<Instant>,
locked_until: Option<Instant>,
}
type FailMap = Arc<Mutex<HashMap<IpAddr, AuthFailure>>>;
#[derive(Clone)]
pub struct HttpState {
pub supervisor: Arc<Mutex<Supervisor>>,
pub password: String,
fails: FailMap,
}
pub async fn serve(
bind: String,
port: u16,
password: String,
tls: bool,
supervisor: Arc<Mutex<Supervisor>>,
static_dir: std::path::PathBuf,
) -> Result<(), String> {
let state = HttpState {
supervisor,
password,
fails: Arc::new(Mutex::new(HashMap::new())),
};
let api = Router::new()
.route("/services", get(list_services))
.route("/services/start-all", post(start_all))
.route("/services/stop-all", post(stop_all))
.route("/services/:id/start", post(start_service))
.route("/services/:id/stop", post(stop_service))
.route("/services/:id/restart", post(restart_service_h))
.route("/services/restart-all", post(restart_all_h))
.route("/services/:id/logs", get(service_logs))
.route("/activity", get(current_activity))
.route("/backups", get(list_backups_h))
.route("/backups/now", post(backup_now_h))
.route("/backups/:filename/restore", post(restore_backup_h))
.route("/container-updates/check", post(check_updates_h))
.route("/container-updates/apply", post(apply_updates_h))
.route("/events", get(list_events_h))
.route("/stats", get(list_stats_h))
.route("/disk", get(disk_usage_h))
.route("/firstaid/recreate", post(firstaid_recreate_h))
.route("/firstaid/reset-pgdata", post(firstaid_reset_pgdata_h))
.route("/firstaid/diagnose", post(firstaid_diagnose_h))
.route("/setup/status", get(setup_status_h))
.route("/setup/install", post(setup_install_h));
let static_service = ServeDir::new(&static_dir).append_index_html_on_directories(true);
// Middleware-Reihenfolge (innen → aussen):
// csrf_check — state-changing Requests brauchen den Custom-Header
// basic_auth — IP-Lockout + Credential-Check
// security_headers — auf allen Antworten (auch 401 etc.)
let app = Router::new()
.nest("/api", api)
.fallback_service(static_service)
.layer(middleware::from_fn(csrf_check))
.layer(middleware::from_fn_with_state(state.clone(), basic_auth))
.layer(middleware::from_fn(security_headers))
.with_state(state);
let addr: SocketAddr = format!("{bind}:{port}")
.parse()
.map_err(|e| format!("bad bind addr {bind}:{port}: {e}"))?;
if tls {
// rustls 0.23 verlangt expliziten Crypto-Provider. Egal welcher zuerst —
// ignore_err falls schon installiert.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let (cert_path, key_path) = ensure_tls_cert()?;
let config = RustlsConfig::from_pem_file(cert_path, key_path)
.await
.map_err(|e| format!("rustls config: {e}"))?;
log::info!("Admin WebUI listening on https://{addr} (user: admin)");
axum_server::bind_rustls(addr, config)
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
.await
.map_err(|e| format!("axum_server: {e}"))?;
} else {
log::info!("Admin WebUI listening on http://{addr} (user: admin) — TLS off!");
let listener = tokio::net::TcpListener::bind(addr)
.await
.map_err(|e| format!("listen {addr}: {e}"))?;
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.map_err(|e| format!("axum serve: {e}"))?;
}
Ok(())
}
// ---------------------------------------------------------------------------
// TLS cert provisioning
// ---------------------------------------------------------------------------
fn ensure_tls_cert() -> Result<(std::path::PathBuf, std::path::PathBuf), String> {
let cert = crate::paths::admin_ui_cert_path();
let key = crate::paths::admin_ui_key_path();
if cert.exists() && key.exists() {
return Ok((cert, key));
}
log::info!("Generating self-signed TLS cert for Admin WebUI ...");
crate::paths::ensure_dirs().map_err(|e| format!("ensure_dirs: {e}"))?;
let hostname = hostname::get()
.ok()
.and_then(|n| n.into_string().ok())
.unwrap_or_else(|| "localhost".into());
let san = vec![
"localhost".to_string(),
"127.0.0.1".to_string(),
hostname.clone(),
format!("{hostname}.local"),
];
let cert_kp = rcgen::generate_simple_self_signed(san)
.map_err(|e| format!("rcgen: {e}"))?;
std::fs::write(&cert, cert_kp.cert.pem()).map_err(|e| format!("write cert: {e}"))?;
std::fs::write(&key, cert_kp.key_pair.serialize_pem())
.map_err(|e| format!("write key: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o600));
}
Ok((cert, key))
}
// ---------------------------------------------------------------------------
// Basic-Auth middleware + per-IP rate limit
// ---------------------------------------------------------------------------
async fn basic_auth(
State(state): State<HttpState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
req: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
let ip = peer.ip();
// Lockout-Check.
{
let mut fails = state.fails.lock().await;
if let Some(rec) = fails.get_mut(&ip) {
if let Some(until) = rec.locked_until {
if Instant::now() < until {
return Ok(too_many("Too many failed attempts. Locked out."));
}
// Lockout abgelaufen — Reset.
*rec = AuthFailure::default();
}
}
}
let ok = check_auth(req.headers().get(header::AUTHORIZATION), &state.password);
if ok {
let mut fails = state.fails.lock().await;
fails.remove(&ip);
return Ok(next.run(req).await);
}
// Failure registrieren.
let mut fails = state.fails.lock().await;
let rec = fails.entry(ip).or_default();
let now = Instant::now();
match rec.first_seen {
Some(first) if now.duration_since(first) < FAIL_WINDOW => {
rec.count += 1;
}
_ => {
rec.count = 1;
rec.first_seen = Some(now);
}
}
if rec.count >= MAX_FAILS {
rec.locked_until = Some(now + LOCKOUT);
log::warn!(
"Auth lockout: {} after {} fails — blocked for {}s",
ip,
rec.count,
LOCKOUT.as_secs()
);
}
Ok(unauthorized())
}
fn check_auth(header: Option<&axum::http::HeaderValue>, password: &str) -> bool {
let s = match header.and_then(|h| h.to_str().ok()) {
Some(s) => s,
None => return false,
};
let Some(b64) = s.strip_prefix("Basic ") else { return false };
let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(b64) else { return false };
let Ok(s) = std::str::from_utf8(&decoded) else { return false };
let Some((user, pass)) = s.split_once(':') else { return false };
user == "admin" && pass == password
}
fn unauthorized() -> Response {
let mut resp = Response::new(Body::from("Authentication required"));
*resp.status_mut() = StatusCode::UNAUTHORIZED;
resp.headers_mut().insert(
header::WWW_AUTHENTICATE,
"Basic realm=\"RAPPORT Server Admin\"".parse().unwrap(),
);
resp
}
fn too_many(msg: &'static str) -> Response {
let mut resp = Response::new(Body::from(msg));
*resp.status_mut() = StatusCode::TOO_MANY_REQUESTS;
resp
}
// ---------------------------------------------------------------------------
// CSRF — Custom-Header-Pflicht fuer state-changing Methoden
// ---------------------------------------------------------------------------
//
// Browser senden Authorization-Header automatisch wenn sie Credentials gecacht
// haben. Damit eine boesartige Seite nicht via auto-submittendem `<form>` einen
// `start-all` triggern kann, verlangen wir auf POST/PUT/DELETE/PATCH den
// Custom-Header `X-Rapport-Csrf: 1`. Cross-origin `<form>` kann diesen nicht
// setzen; cross-origin `fetch()` triggert dafuer CORS-Preflight, das wir nicht
// allowen — also auch geblockt.
async fn csrf_check(req: Request<Body>, next: Next) -> Result<Response, StatusCode> {
let method = req.method();
let is_writing = matches!(
method.as_str(),
"POST" | "PUT" | "DELETE" | "PATCH"
);
if is_writing && req.headers().get("x-rapport-csrf").is_none() {
return Err(StatusCode::FORBIDDEN);
}
Ok(next.run(req).await)
}
// ---------------------------------------------------------------------------
// Security-Header — Defense-in-Depth fuer den WebUI-Browser-Tab
// ---------------------------------------------------------------------------
async fn security_headers(req: Request<Body>, next: Next) -> Response {
let mut resp = next.run(req).await;
let h = resp.headers_mut();
// Clickjacking
h.insert("x-frame-options", "DENY".parse().unwrap());
// MIME-Sniffing
h.insert("x-content-type-options", "nosniff".parse().unwrap());
// Kein Referer rauslassen (privates LAN)
h.insert("referrer-policy", "no-referrer".parse().unwrap());
// Strict-Transport-Security — Browser merken sich, dass nur HTTPS gilt.
// 1 Woche reicht; spaeter koennen wir hochdrehen wenn das setup stabil ist.
h.insert(
"strict-transport-security",
"max-age=604800".parse().unwrap(),
);
// CSP: kein externes JS, kein eval, keine inline-Scripts.
// Inline-Styles sind erlaubt weil React StyleSheets manchmal so generiert.
h.insert(
"content-security-policy",
"default-src 'self'; \
script-src 'self'; \
style-src 'self' 'unsafe-inline'; \
img-src 'self' data:; \
connect-src 'self'; \
font-src 'self'; \
object-src 'none'; \
base-uri 'self'; \
form-action 'self'; \
frame-ancestors 'none'"
.parse()
.unwrap(),
);
resp
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
async fn list_services(State(s): State<HttpState>) -> impl IntoResponse {
Json(crate::supervisor::list_with_timeout(&s.supervisor, 300).await)
}
async fn start_all(State(s): State<HttpState>) -> impl IntoResponse {
map_result(crate::supervisor::Supervisor::start_all_managed(s.supervisor.clone()).await)
}
async fn stop_all(State(s): State<HttpState>) -> impl IntoResponse {
map_result(crate::supervisor::Supervisor::stop_all_managed(s.supervisor.clone()).await)
}
async fn start_service(
State(s): State<HttpState>,
Path(id): Path<String>,
) -> impl IntoResponse {
let mut sv = s.supervisor.lock().await;
map_result(sv.start(&id).await)
}
async fn stop_service(
State(s): State<HttpState>,
Path(id): Path<String>,
) -> impl IntoResponse {
let mut sv = s.supervisor.lock().await;
map_result(sv.stop(&id).await)
}
async fn restart_service_h(
State(s): State<HttpState>,
Path(id): Path<String>,
) -> impl IntoResponse {
let mut sv = s.supervisor.lock().await;
map_result(sv.restart(&id).await)
}
async fn restart_all_h(State(s): State<HttpState>) -> impl IntoResponse {
map_result(crate::supervisor::Supervisor::restart_all_managed(s.supervisor.clone()).await)
}
async fn service_logs(
State(s): State<HttpState>,
Path(id): Path<String>,
) -> impl IntoResponse {
let sv = s.supervisor.lock().await;
Json(sv.logs(&id).await)
}
async fn current_activity(State(s): State<HttpState>) -> impl IntoResponse {
let sv = s.supervisor.lock().await;
Json(sv.current_activity().await)
}
async fn list_backups_h() -> impl IntoResponse {
Json(crate::backup::list())
}
async fn backup_now_h() -> Response {
match crate::backup::create().await {
Ok(info) => Json(info).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn restore_backup_h(Path(filename): Path<String>) -> Response {
match crate::backup::restore(&filename).await {
Ok(res) => Json(res).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn check_updates_h() -> Response {
match crate::container_update::check().await {
Ok(res) => Json(res).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn apply_updates_h() -> Response {
match crate::container_update::apply().await {
Ok(res) => Json(res).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn list_events_h() -> impl IntoResponse {
Json(crate::events::list().await)
}
async fn list_stats_h() -> impl IntoResponse {
Json(crate::stats::collect().await)
}
async fn disk_usage_h() -> impl IntoResponse {
Json(crate::disk::collect().await)
}
async fn firstaid_recreate_h() -> Response {
match crate::firstaid::recreate_containers().await {
Ok(r) => Json(r).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn firstaid_reset_pgdata_h() -> Response {
match crate::firstaid::reset_pgdata().await {
Ok(r) => Json(r).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn firstaid_diagnose_h() -> Response {
match crate::firstaid::diagnose_bundle().await {
Ok(r) => Json(r).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
async fn setup_status_h() -> impl IntoResponse {
Json(crate::setup::status().await)
}
async fn setup_install_h(State(s): State<HttpState>) -> Response {
let _ = s;
match crate::setup::install_and_start().await {
Ok(r) => Json(r).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
fn map_result(r: Result<(), String>) -> Response {
match r {
Ok(()) => (StatusCode::OK, "ok").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
+476
View File
@@ -0,0 +1,476 @@
//! RAPPORT Server-App — Tauri-Entry-Point.
//!
//! Hier wird der Process-Supervisor initialisiert, in den App-State gehängt,
//! und alle `#[tauri::command]`-Handler registriert.
mod backup;
mod commands;
mod config;
mod container_update;
mod disk;
mod events;
mod firstaid;
mod health;
mod http_server;
mod paths;
mod services;
mod setup;
mod stats;
mod supervisor;
use std::sync::Arc;
use supervisor::Supervisor;
use tauri::Manager;
use tokio::sync::Mutex;
/// Geteilter Supervisor-State über `tauri::State`.
pub struct AppState {
pub supervisor: Arc<Mutex<Supervisor>>,
}
/// Aggregat-Status fuer die Tray-Icon-Farbe.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TrayStatus {
/// keine Services laufen (alle stopped) oder Liste leer
Off,
/// alle laufen, niemand error/starting
Ok,
/// gemischt: einige laufen, andere stopped/starting (oder noch nicht alle ready)
Warn,
/// mind. ein service in error
Error,
}
fn aggregate_status(statuses: &[supervisor::ServiceStatus]) -> TrayStatus {
use supervisor::ServiceState as S;
if statuses.is_empty() {
return TrayStatus::Off;
}
let mut has_error = false;
let mut has_running = false;
let mut has_transitioning = false;
let mut has_stopped = false;
for s in statuses {
match s.state {
S::Error => has_error = true,
S::Running => has_running = true,
S::Starting | S::Stopping => has_transitioning = true,
S::Stopped => has_stopped = true,
}
}
if has_error {
TrayStatus::Error
} else if has_running && !has_transitioning && !has_stopped {
TrayStatus::Ok
} else if !has_running && !has_transitioning && has_stopped {
TrayStatus::Off
} else {
TrayStatus::Warn
}
}
/// 44x44 Farb-PNG-Bytes (Pillow-generiert, in `icons/` checked-in).
const TRAY_OFF_PNG: &[u8] = include_bytes!("../icons/tray-off@2x.png");
const TRAY_OK_PNG: &[u8] = include_bytes!("../icons/tray-ok@2x.png");
const TRAY_WARN_PNG: &[u8] = include_bytes!("../icons/tray-warn@2x.png");
const TRAY_ERROR_PNG: &[u8] = include_bytes!("../icons/tray-error@2x.png");
fn set_tray_icon(app: &tauri::AppHandle, status: TrayStatus) {
let bytes = match status {
TrayStatus::Off => TRAY_OFF_PNG,
TrayStatus::Ok => TRAY_OK_PNG,
TrayStatus::Warn => TRAY_WARN_PNG,
TrayStatus::Error => TRAY_ERROR_PNG,
};
let tooltip = match status {
TrayStatus::Off => "RAPPORT Server — gestoppt",
TrayStatus::Ok => "RAPPORT Server — alle Services laufen",
TrayStatus::Warn => "RAPPORT Server — teilweise gestartet / im Uebergang",
TrayStatus::Error => "RAPPORT Server — Fehler in mindestens einem Service",
};
let Some(tray) = app.tray_by_id("main-tray") else { return };
if let Ok(image) = tauri::image::Image::from_bytes(bytes) {
let _ = tray.set_icon(Some(image));
let _ = tray.set_icon_as_template(false);
}
let _ = tray.set_tooltip(Some(tooltip));
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.init();
log::info!("RAPPORT Server-App starting ...");
// PATH IMMER erweitern (auch wenn ~/.rapport/bin/ noch nicht existiert) —
// sonst klappt der Auto-Start nach dem Setup-Wizard nicht weil unser
// Prozess den frisch installierten docker/colima nicht findet.
let rapport_bin = setup::bin_dir();
let cur = std::env::var("PATH").unwrap_or_default();
let new_path = format!("{}:{cur}", rapport_bin.display());
std::env::set_var("PATH", new_path);
log::info!("PATH erweitert um {}", rapport_bin.display());
let supervisor = Arc::new(Mutex::new(Supervisor::new()));
tauri::Builder::default()
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_updater::Builder::new().build())
// Autostart: macOS legt einen LaunchAgent unter
// ~/Library/LaunchAgents/com.rapport.server-app.plist an.
// Beim Login-Launch passen wir `--hidden` mit, damit das Fenster
// direkt im Tray landet (kein Popup im Login-Flow).
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
Some(vec!["--hidden"]),
))
.manage(AppState {
supervisor: supervisor.clone(),
})
.setup(move |app| {
// App-Data-Verzeichnisse (PGDATA-Volume-Mount, logs, backups) anlegen.
if let Err(e) = paths::ensure_dirs() {
log::error!("ensure_dirs: {e}");
}
// `--hidden` aus dem Login-Launch: Fenster sofort verstecken,
// App lebt nur im Tray weiter.
let launched_hidden = std::env::args().any(|a| a == "--hidden");
if launched_hidden {
if let Some(w) = app.get_webview_window("main") {
let _ = w.hide();
}
}
// Compose-Verzeichnis aufloesen (kommt aus config.env oder
// Auto-Detect-Reihe). Ohne das gibt's nichts zu supervisen.
let compose_override = config::load()
.ok()
.and_then(|c| c.get("COMPOSE_DIR").cloned());
match services::init_compose_dir(compose_override) {
Ok(p) => log::info!("Compose-Dir: {}", p.display()),
Err(e) => log::error!("Compose-Dir nicht gefunden: {e}"),
}
// config.env vorhanden? Sonst Defaults generieren und persistieren.
// Sonst landen Template-Platzhalter ({POSTGRES_PASSWORD}) literal
// in den Container-Envs.
match config::load() {
Ok(mut map) => {
let before = map.len();
config::ensure_defaults(&mut map);
if map.len() != before {
if let Err(e) = config::save(&map) {
log::error!("config save: {e}");
} else {
log::info!("Initialized config.env with defaults");
}
}
}
Err(e) => log::error!("config load: {e}"),
}
// Auto-register alle Services beim Start (noch ohne sie zu starten).
let supervisor_clone = supervisor.clone();
let auto_start_containers = config::load()
.ok()
.and_then(|c| c.get("AUTO_START_CONTAINERS_ON_LAUNCH").cloned())
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
tauri::async_runtime::spawn(async move {
let mut sv = supervisor_clone.lock().await;
let n = services::default_services().len();
for service_def in services::default_services() {
sv.register(service_def);
}
let how = if launched_hidden { " (hidden launch)" } else { "" };
events::info(format!(
"RAPPORT Server-App gestartet{how}{n} Services registriert"
)).await;
drop(sv);
if auto_start_containers {
// Nur autostart wenn Daemon erreichbar ist — sonst landen
// alle Services in Error, Auto-Recovery spinnt nutzlos und
// der Setup-Wizard kommt nie zum Zug.
let daemon_ok = tokio::process::Command::new("docker")
.arg("info")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await
.map(|s| s.success())
.unwrap_or(false);
if daemon_ok {
events::info("Auto-Start: Container werden hochgefahren ...").await;
if let Err(e) = supervisor::Supervisor::start_all_managed(supervisor_clone.clone()).await {
events::warn(format!("Auto-Start fehlgeschlagen: {e}")).await;
}
} else {
events::info("Auto-Start uebersprungen — Docker-Daemon nicht erreichbar (Setup noetig?)").await;
}
}
});
// Pre-pull aller Compose-Images im Hintergrund. Erste Klicks
// sollen nicht hinter einem 150-MB-Pull haengen bleiben.
tauri::async_runtime::spawn(async move {
events::info("Pre-pull Docker-Images gestartet").await;
let out = tokio::process::Command::new("docker")
.current_dir(services::compose_dir())
.args(["compose", "pull"])
.output()
.await;
match out {
Ok(o) if o.status.success() => events::info("Pre-pull fertig").await,
Ok(o) => {
events::warn(format!(
"Pre-pull mit Fehlern: {}",
String::from_utf8_lossy(&o.stderr).trim().chars().take(200).collect::<String>()
)).await;
}
Err(e) => events::warn(format!("Pre-pull konnte nicht starten: {e}")).await,
}
});
// Backup-Scheduler — pg_dumpall alle BACKUP_INTERVAL_HOURS Stunden.
tauri::async_runtime::spawn(async move {
backup::scheduler_loop().await;
});
// Container-Update-Scheduler — alle CONTAINER_AUTOUPDATE_INTERVAL_HOURS:
// docker compose pull + (falls Updates) Backup + Compose-Up.
tauri::async_runtime::spawn(async move {
container_update::scheduler_loop().await;
});
// Health-Tick-Loop: alle 2s State aus compose ps, daraus:
// - Tray-Icon-Farbe (gruen/gelb/rot)
// - macOS-Notification fuer NEU in Error gerutschte Services
let supervisor_for_health = supervisor.clone();
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
use tauri_plugin_notification::NotificationExt;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(2));
let mut last_state: Option<TrayStatus> = None;
loop {
interval.tick().await;
let mut sv = supervisor_for_health.lock().await;
let newly_errored = sv.tick_health().await;
let agg = aggregate_status(&sv.list());
// Display-Names holen bevor wir den Lock abgeben.
let error_titles: Vec<(String, String)> = newly_errored
.iter()
.map(|id| (id.clone(), sv.display_name(id).unwrap_or_else(|| id.clone())))
.collect();
drop(sv);
if Some(agg) != last_state {
set_tray_icon(&app_handle, agg);
last_state = Some(agg);
}
for (id, name) in error_titles {
events::error(format!("Service {name} ist in den Fehler-State gewechselt")).await;
let _ = app_handle
.notification()
.builder()
.title("RAPPORT Server — Fehler")
.body(format!("{name} ({id}) ist auf 'unhealthy'/'restarting'."))
.show();
}
}
});
// Auto-Recovery-Loop: alle 15s pruefen ob Services in Error sind die
// jetzt einen Restart-Versuch bekommen sollen (mit exponential backoff).
let supervisor_for_recovery = supervisor.clone();
let app_handle_for_recovery = app.handle().clone();
tauri::async_runtime::spawn(async move {
use tauri_plugin_notification::NotificationExt;
let mut interval = tokio::time::interval(std::time::Duration::from_secs(15));
interval.tick().await; // initial tick verwerfen
loop {
interval.tick().await;
let cfg = config::load().unwrap_or_default();
let enabled = cfg
.get("AUTO_RECOVERY_ENABLED")
.map(|v| v != "false" && v != "0")
.unwrap_or(true);
if !enabled {
continue;
}
let base_delay = std::time::Duration::from_secs(
cfg.get("AUTO_RECOVERY_BASE_DELAY_SECONDS")
.and_then(|v| v.parse().ok())
.unwrap_or(60),
);
let max_attempts: u32 = cfg
.get("AUTO_RECOVERY_MAX_ATTEMPTS")
.and_then(|v| v.parse().ok())
.unwrap_or(5);
// Wenn der Daemon gerade aus ist (Wizard noch nicht durch),
// gar nicht erst versuchen — sonst pumpen wir die Versuchs-
// zaehler hoch und geben spaeter auf wenn's eigentlich
// ginge.
let daemon_ok = tokio::process::Command::new("docker")
.arg("info")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await
.map(|s| s.success())
.unwrap_or(false);
if !daemon_ok {
continue;
}
let mut sv = supervisor_for_recovery.lock().await;
let report = sv.recovery_candidates(max_attempts, base_delay);
drop(sv);
for id in &report.to_restart {
events::warn(format!("Auto-Recovery: Restart-Versuch fuer {id}")).await;
let mut sv = supervisor_for_recovery.lock().await;
let display = sv.display_name(id).unwrap_or_else(|| id.clone());
if let Err(e) = sv.restart(id).await {
events::warn(format!(
"Auto-Recovery {display}: {e}"
)).await;
}
}
for id in &report.maxed_out {
events::error(format!("Auto-Recovery aufgegeben fuer {id} — manueller Eingriff noetig")).await;
let sv = supervisor_for_recovery.lock().await;
let display = sv.display_name(id).unwrap_or_else(|| id.clone());
drop(sv);
let _ = app_handle_for_recovery
.notification()
.builder()
.title("RAPPORT Server — Auto-Recovery aufgegeben")
.body(format!(
"{display} ({id}) crasht wiederholt. Bitte manuell pruefen."
))
.show();
}
}
});
// HTTP-Admin-WebUI (LAN-Zugriff fuer headless Mac Mini).
let supervisor_for_http = supervisor.clone();
let static_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("dist");
tauri::async_runtime::spawn(async move {
let cfg = config::load().unwrap_or_default();
let bind = cfg
.get("ADMIN_UI_BIND")
.cloned()
.unwrap_or_else(|| "127.0.0.1".into());
let port: u16 = cfg
.get("ADMIN_UI_PORT")
.and_then(|p| p.parse().ok())
.unwrap_or(9090);
let password = cfg
.get("ADMIN_UI_PASSWORD")
.cloned()
.unwrap_or_default();
let tls = cfg
.get("ADMIN_UI_TLS")
.map(|v| v != "false" && v != "0")
.unwrap_or(true);
if password.is_empty() {
log::warn!("ADMIN_UI_PASSWORD leer — WebUI nicht gestartet");
return;
}
if let Err(e) = http_server::serve(
bind, port, password, tls, supervisor_for_http, static_dir,
)
.await
{
log::error!("http_server: {e}");
}
});
// Tray-Icon mit Show/Quit-Menue. Fenster schliessen reduziert in
// den Tray (Container laufen weiter). Quit aus dem Tray beendet
// die App tatsaechlich.
#[cfg(desktop)]
{
use tauri::menu::{Menu, MenuItem};
use tauri::tray::{MouseButton, TrayIconBuilder, TrayIconEvent};
let show_item = MenuItem::with_id(app, "show", "Show Dashboard", true, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit RAPPORT Server", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_item, &quit_item])?;
let _ = TrayIconBuilder::with_id("main-tray")
.tooltip("RAPPORT Server")
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => {
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
let _ = w.set_focus();
}
}
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click { button: MouseButton::Left, .. } = event {
if let Some(window) = tray.app_handle().get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.build(app)?;
}
Ok(())
})
.on_window_event(|window, event| {
// Fenster schliessen → in den Tray verstecken statt App killen.
// So laufen die Docker-Container weiter, der HTTP-WebUI-Server
// bleibt erreichbar, und der Health-Tick-Loop tickt weiter.
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
})
.invoke_handler(tauri::generate_handler![
commands::list_services,
commands::start_service,
commands::stop_service,
commands::restart_service,
commands::start_all,
commands::stop_all,
commands::restart_all,
commands::service_logs,
commands::service_status,
commands::backup_now,
commands::list_backups,
commands::restore_backup,
commands::check_container_updates,
commands::apply_container_updates,
commands::list_events,
commands::list_stats,
commands::disk_usage,
commands::firstaid_recreate,
commands::firstaid_reset_pgdata,
commands::firstaid_diagnose,
commands::setup_status,
commands::setup_install,
commands::get_config,
commands::set_config_value,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run()
}
+63
View File
@@ -0,0 +1,63 @@
//! Plattform-spezifische Pfade fuer den App-Data-Bereich.
//!
//! Die App selber liest/schreibt nur in `data_dir()` — Postgres-Volume,
//! Storage-Files, Backups, `config.env`. Container greifen ueber `-v`-Mounts
//! auf diese Pfade zu.
use directories::ProjectDirs;
use std::path::PathBuf;
const QUALIFIER: &str = "com";
const ORG: &str = "rapport";
const APP: &str = "server-app";
/// `~/Library/Application Support/com.rapport.server-app/` (macOS),
/// `~/.local/share/rapport-server-app/` (Linux),
/// `%APPDATA%/rapport/server-app/` (Windows).
pub fn data_dir() -> PathBuf {
ProjectDirs::from(QUALIFIER, ORG, APP)
.map(|d| d.data_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from("./data"))
}
pub fn postgres_data_dir() -> PathBuf {
data_dir().join("postgres")
}
pub fn storage_data_dir() -> PathBuf {
data_dir().join("storage")
}
pub fn logs_dir() -> PathBuf {
data_dir().join("logs")
}
pub fn backups_dir() -> PathBuf {
data_dir().join("backups")
}
pub fn config_env_path() -> PathBuf {
data_dir().join("config.env")
}
pub fn admin_ui_cert_path() -> PathBuf {
data_dir().join("admin-ui-cert.pem")
}
pub fn admin_ui_key_path() -> PathBuf {
data_dir().join("admin-ui-key.pem")
}
/// Erzeugt alle noetigen Verzeichnisse falls sie noch nicht existieren.
pub fn ensure_dirs() -> std::io::Result<()> {
for d in [
data_dir(),
postgres_data_dir(),
storage_data_dir(),
logs_dir(),
backups_dir(),
] {
std::fs::create_dir_all(&d)?;
}
Ok(())
}
+164
View File
@@ -0,0 +1,164 @@
//! Service-Inventar.
//!
//! Die App ist eine duenne UI ueber dem Compose-Stack im
//! `RAPPORT/SERVER-CONTAINER`-Repo. `id` ist hier 1:1 der Compose-Service-Name —
//! der Supervisor bildet daraus `docker compose <id>`-Aufrufe. Image-, Env- und
//! Mount-Konfiguration kommt komplett aus `SERVER-CONTAINER/docker-compose.yml`
//! plus dortiger `.env`.
use crate::health::HealthProbe;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// Wird beim App-Start aus Konfig / Auto-Detect gesetzt — danach read-only.
static COMPOSE_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Compose-Projektverzeichnis. Liefert den vorher gesetzten Pfad, oder
/// panickt wenn `init_compose_dir()` noch nicht gelaufen ist.
pub fn compose_dir() -> &'static Path {
COMPOSE_DIR
.get()
.map(|p| p.as_path())
.expect("compose_dir not initialized — call init_compose_dir() first")
}
/// Initialisiert den Compose-Pfad. Reihenfolge:
/// 1. expliziter `override` (z.B. aus `config.env` `COMPOSE_DIR`)
/// 2. Env-Variable `RAPPORT_COMPOSE_DIR`
/// 3. `~/RAPPORT/SERVER-CONTAINER/`
/// 4. `<exe-parent>/../SERVER-CONTAINER/`
/// 5. `<crate-dir>/../../SERVER-CONTAINER/` (Dev-Layout)
///
/// Jeder Kandidat muss eine `docker-compose.yml` enthalten.
pub fn init_compose_dir(override_path: Option<String>) -> Result<&'static Path, String> {
if let Some(p) = COMPOSE_DIR.get() {
return Ok(p.as_path());
}
let candidates = collect_candidates(override_path);
for c in &candidates {
if c.join("docker-compose.yml").exists() {
let _ = COMPOSE_DIR.set(c.clone());
return Ok(COMPOSE_DIR.get().unwrap().as_path());
}
}
Err(format!(
"no docker-compose.yml found — tried: {}",
candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
))
}
fn collect_candidates(override_path: Option<String>) -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(p) = override_path {
if !p.is_empty() {
out.push(PathBuf::from(p));
}
}
if let Ok(p) = std::env::var("RAPPORT_COMPOSE_DIR") {
out.push(PathBuf::from(p));
}
if let Some(home) = std::env::var_os("HOME") {
// Eigener Dev-Klon hat Vorrang (Source-Edits sollen sichtbar bleiben).
out.push(PathBuf::from(&home).join("RAPPORT/SERVER-CONTAINER"));
// Setup-Wizard-Default: vom Gitea-Tarball nach ~/.rapport/compose/ extrahiert.
out.push(PathBuf::from(&home).join(".rapport/compose"));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(parent) = exe.parent() {
out.push(parent.join("../SERVER-CONTAINER"));
}
}
out.push(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("SERVER-CONTAINER"),
);
out
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDef {
/// Compose-Service-Name. Wird so als Argument an `docker compose` gegeben.
pub id: String,
/// Anzeige-Name fuer die UI.
pub display_name: String,
/// Host-Port fuer die UI-Anzeige + Health-Probe.
pub port: u16,
/// Andere Services die laut Compose vorher laufen sollten — nur
/// informativ fuer Sortierung in der UI. Echte Dep-Order erzwingt Compose.
pub depends_on: Vec<String>,
/// Health-Probe (HTTP/TCP) fuer "echt up" jenseits von `docker ps`.
pub health: HealthProbe,
}
pub fn default_services() -> Vec<ServiceDef> {
vec![
ServiceDef {
id: "db".into(),
display_name: "Postgres".into(),
port: 15432, // Host-Port aus SERVER-CONTAINER/.env (DB_PORT)
depends_on: vec![],
health: HealthProbe::TcpAndQuery {
host: "127.0.0.1".into(),
port: 5432,
db: "postgres".into(),
},
},
ServiceDef {
id: "auth".into(),
display_name: "GoTrue (Auth)".into(),
port: 9999,
depends_on: vec!["db".into()],
health: HealthProbe::Http {
url: "http://127.0.0.1:9999/health".into(),
},
},
ServiceDef {
id: "rest".into(),
display_name: "PostgREST".into(),
port: 3000,
depends_on: vec!["db".into()],
health: HealthProbe::Http {
url: "http://127.0.0.1:3000/".into(),
},
},
ServiceDef {
id: "realtime".into(),
display_name: "Realtime".into(),
port: 4000,
depends_on: vec!["db".into()],
health: HealthProbe::Http {
url: "http://127.0.0.1:4000/api/health".into(),
},
},
ServiceDef {
id: "storage".into(),
display_name: "Storage".into(),
port: 5000,
depends_on: vec!["db".into(), "rest".into()],
health: HealthProbe::Http {
url: "http://127.0.0.1:5000/status".into(),
},
},
ServiceDef {
id: "kong".into(),
display_name: "Kong (API-Gateway)".into(),
port: 18000, // KONG_HTTP_PORT
depends_on: vec!["auth".into(), "rest".into(), "realtime".into(), "storage".into()],
health: HealthProbe::Http {
url: "http://127.0.0.1:8000/".into(),
},
},
ServiceDef {
id: "app".into(),
display_name: "Frontend (rapport-app)".into(),
port: 18080, // APP_PORT
depends_on: vec!["kong".into()],
health: HealthProbe::Http {
url: "http://127.0.0.1:8080/".into(),
},
},
]
}
+537
View File
@@ -0,0 +1,537 @@
//! Setup-Wizard: Detection + Direct-Download-Installation von Docker-CLI,
//! Colima und Lima. Kein Brew, keine externen Package-Manager.
//!
//! Layout:
//! ~/.rapport/
//! ├── bin/
//! │ ├── docker
//! │ ├── docker-compose
//! │ ├── colima
//! │ └── limactl
//! ├── lima-share/ (von Lima-Tarball, share/lima/)
//! └── home/ (HOME-Override fuer Colima/Lima Konfig)
//!
//! `lib.rs::run()` prependet `~/.rapport/bin` an PATH und setzt `LIMA_HOME`
//! etc. damit die Tools die mitgelieferten Files finden.
use serde::Serialize;
use std::path::PathBuf;
use std::process::Stdio;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::Command;
// ---- Pinned Versionen ----------------------------------------------------
const DOCKER_VERSION: &str = "29.5.2";
const COLIMA_VERSION: &str = "v0.10.1";
const LIMA_VERSION: &str = "v2.1.1";
// Quelle fuer den Compose-Stack — wird beim Setup-Wizard automatisch nach
// ~/.rapport/compose/ heruntergeladen wenn lokal noch nichts gefunden wird.
const COMPOSE_TARBALL_URL: &str =
"https://git.kgva.ch/karim/RAPPORT-SERVER/archive/main.tar.gz";
#[derive(Debug, Clone, Serialize)]
pub struct SetupStatus {
pub docker_cli: bool,
pub colima_installed: bool,
pub limactl_installed: bool,
pub daemon_running: bool,
pub ready: bool,
pub recommended_action: RecommendedAction,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RecommendedAction {
/// Alles ok — UI rendert das Dashboard.
NoneReady,
/// Tools alle installiert, nur Daemon ist aus → `colima start`.
StartColima,
/// Tools fehlen → Direct-Download.
InstallAll,
}
pub fn install_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".rapport")
}
pub fn bin_dir() -> PathBuf {
install_dir().join("bin")
}
pub fn compose_dir() -> PathBuf {
install_dir().join("compose")
}
/// Lima erwartet die Templates relativ zur Binary unter `<bin>/../share/lima/`.
/// Wir halten uns an dieses Layout in `~/.rapport/share/`.
fn lima_share_dir() -> PathBuf {
install_dir().join("share")
}
pub async fn status() -> SetupStatus {
let docker_cli = bin_exists("docker") || which("docker").await;
let colima_installed = bin_exists("colima") || which("colima").await;
let limactl_installed = bin_exists("limactl") || which("limactl").await;
let daemon_running = if docker_cli { docker_info_ok().await } else { false };
let ready = daemon_running;
let action = if ready {
RecommendedAction::NoneReady
} else if docker_cli && colima_installed && limactl_installed {
RecommendedAction::StartColima
} else {
RecommendedAction::InstallAll
};
SetupStatus {
docker_cli,
colima_installed,
limactl_installed,
daemon_running,
ready,
recommended_action: action,
}
}
#[derive(Debug, Clone, Serialize)]
pub struct InstallResult {
pub finished_at_iso: String,
pub log: String,
}
pub async fn install_and_start() -> Result<InstallResult, String> {
let s = status().await;
crate::events::info(format!("Setup: Aktion = {:?}", s.recommended_action)).await;
let mut log = String::new();
if matches!(s.recommended_action, RecommendedAction::InstallAll) {
download_all(&mut log).await?;
}
// Compose-Stack-Bootstrap: nur wenn noch nichts gefunden wird (z.B.
// Erst-Install). Wer eine eigene SERVER-CONTAINER-Repo hat (lokaler
// Klon unter ~/RAPPORT/SERVER-CONTAINER/) bleibt unberuehrt.
if let Err(_) = crate::services::init_compose_dir(None) {
bootstrap_compose_stack(&mut log).await?;
}
crate::events::info("Setup: colima start ...").await;
log.push_str("\n=== colima start ===\n");
let colima = pick_path("colima");
let limactl = pick_path("limactl");
let extra_path = format!(
"{}:{}",
bin_dir().display(),
std::env::var("PATH").unwrap_or_default()
);
let out = Command::new(&colima)
.args(["start", "--cpu", "2", "--memory", "4", "--disk", "30"])
.env("PATH", &extra_path)
.env("LIMA_HOME", install_dir().join("lima-home"))
.env("HOME", std::env::var("HOME").unwrap_or_default())
.output()
.await
.map_err(|e| format!("spawn colima ({}): {e}", colima.display()))?;
log.push_str(&format!(
"stdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
));
if !out.status.success() {
return Err(format!("colima start failed:\n{log}\n\nlimactl: {}", limactl.display()));
}
let after = status().await;
if !after.daemon_running {
return Err(format!(
"Daemon nach Setup nicht erreichbar. Log:\n{log}"
));
}
crate::events::info("Setup fertig: Daemon laeuft").await;
Ok(InstallResult {
finished_at_iso: chrono::Local::now().to_rfc3339(),
log,
})
}
// ---------------------------------------------------------------------------
// Direct-Download-Sektion
// ---------------------------------------------------------------------------
async fn download_all(log: &mut String) -> Result<(), String> {
let arch = detect_arch()?;
std::fs::create_dir_all(bin_dir()).map_err(|e| format!("mkdir bin: {e}"))?;
std::fs::create_dir_all(lima_share_dir()).map_err(|e| format!("mkdir lima-share: {e}"))?;
// Lima zuerst (Colima braucht es).
if !file_exists(&bin_dir().join("limactl")) {
install_lima(&arch, log).await?;
}
if !file_exists(&bin_dir().join("colima")) {
install_colima(&arch, log).await?;
}
if !file_exists(&bin_dir().join("docker")) {
install_docker(&arch, log).await?;
}
Ok(())
}
struct Arch {
/// `arm64` oder `amd64`
docker_label: &'static str,
/// `Darwin-arm64` oder `Darwin-x86_64`
colima_label: &'static str,
/// `Darwin-arm64` oder `Darwin-x86_64`
lima_label: &'static str,
/// `aarch64` oder `x86_64` (subdir auf download.docker.com)
docker_subdir: &'static str,
}
fn detect_arch() -> Result<Arch, String> {
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
Ok(Arch {
docker_label: "arm64",
colima_label: "Darwin-arm64",
lima_label: "Darwin-arm64",
docker_subdir: "aarch64",
})
} else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
Ok(Arch {
docker_label: "amd64",
colima_label: "Darwin-x86_64",
lima_label: "Darwin-x86_64",
docker_subdir: "x86_64",
})
} else {
Err("Setup-Wizard unterstuetzt aktuell nur macOS (Mac Intel + Apple Silicon)".into())
}
}
async fn install_lima(arch: &Arch, log: &mut String) -> Result<(), String> {
let url = format!(
"https://github.com/lima-vm/lima/releases/download/{LIMA_VERSION}/lima-{ver}-{lbl}.tar.gz",
ver = LIMA_VERSION.trim_start_matches('v'),
lbl = arch.lima_label
);
crate::events::info(format!("Setup: lade Lima {LIMA_VERSION} ...")).await;
log.push_str(&format!("=== lima ===\n{url}\n"));
let tmp = tempdir_path("lima.tar.gz");
download_to(&url, &tmp).await?;
let extract_to = install_dir();
extract_tar_gz(&tmp, &extract_to).await?;
// Tarball legt `bin/` und `share/` ab — moven nach unserer Struktur.
let _ = std::fs::rename(extract_to.join("bin/limactl"), bin_dir().join("limactl"));
let _ = std::fs::rename(extract_to.join("share"), lima_share_dir());
let _ = std::fs::remove_dir(extract_to.join("bin"));
let _ = std::fs::remove_file(&tmp);
chmod_x(&bin_dir().join("limactl"))?;
unquarantine(&bin_dir().join("limactl"));
Ok(())
}
async fn install_colima(arch: &Arch, log: &mut String) -> Result<(), String> {
let url = format!(
"https://github.com/abiosoft/colima/releases/download/{COLIMA_VERSION}/colima-{lbl}",
lbl = arch.colima_label
);
crate::events::info(format!("Setup: lade Colima {COLIMA_VERSION} ...")).await;
log.push_str(&format!("\n=== colima ===\n{url}\n"));
let dest = bin_dir().join("colima");
download_to(&url, &dest).await?;
chmod_x(&dest)?;
unquarantine(&dest);
Ok(())
}
async fn install_docker(arch: &Arch, log: &mut String) -> Result<(), String> {
let url = format!(
"https://download.docker.com/mac/static/stable/{sub}/docker-{ver}.tgz",
sub = arch.docker_subdir,
ver = DOCKER_VERSION
);
crate::events::info(format!("Setup: lade Docker-CLI {DOCKER_VERSION} ...")).await;
log.push_str(&format!("\n=== docker ===\n{url}\n"));
let tmp = tempdir_path("docker.tgz");
download_to(&url, &tmp).await?;
let extract_to = tempdir_path("docker-extract");
std::fs::create_dir_all(&extract_to).map_err(|e| format!("mkdir extract: {e}"))?;
extract_tar_gz(&tmp, &extract_to).await?;
// Tarball-Layout: docker/{docker,docker-compose,docker-buildx,...}
let src = extract_to.join("docker");
for bin in &["docker", "docker-compose", "docker-buildx"] {
let from = src.join(bin);
let to = bin_dir().join(bin);
if from.exists() {
let _ = std::fs::rename(&from, &to);
chmod_x(&to)?;
unquarantine(&to);
}
}
let _ = std::fs::remove_dir_all(&extract_to);
let _ = std::fs::remove_file(&tmp);
Ok(())
}
// ---------------------------------------------------------------------------
// Compose-Stack-Bootstrap
// ---------------------------------------------------------------------------
async fn bootstrap_compose_stack(log: &mut String) -> Result<(), String> {
crate::events::info("Setup: lade Compose-Stack von Gitea ...").await;
log.push_str("\n=== compose-stack download ===\n");
let dest = compose_dir();
std::fs::create_dir_all(&dest).map_err(|e| format!("mkdir compose dir: {e}"))?;
let tmp = tempdir_path("compose.tar.gz");
download_to(COMPOSE_TARBALL_URL, &tmp).await?;
// Gitea-Archiv legt einen Top-Level-Ordner an — strip-components=1
let status = Command::new("tar")
.args(["-xzf"])
.arg(&tmp)
.args(["-C"])
.arg(&dest)
.arg("--strip-components=1")
.status()
.await
.map_err(|e| format!("spawn tar: {e}"))?;
if !status.success() {
return Err(format!("tar -xzf {} failed", tmp.display()));
}
let _ = std::fs::remove_file(&tmp);
// .env aus .env.example mit unseren Secrets generieren — sonst startet
// compose nicht.
let env_path = dest.join(".env");
if !env_path.exists() {
let example_path = dest.join(".env.example");
let template = if example_path.exists() {
std::fs::read_to_string(&example_path)
.map_err(|e| format!("read .env.example: {e}"))?
} else {
String::new()
};
let env_content = render_compose_env(&template)?;
std::fs::write(&env_path, env_content).map_err(|e| format!("write .env: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&env_path, std::fs::Permissions::from_mode(0o600));
}
crate::events::info(format!(".env generiert: {}", env_path.display())).await;
}
crate::events::info(format!("Compose-Stack bereit unter {}", dest.display())).await;
Ok(())
}
/// Generiert eine `.env` aus dem Template — bekannte Keys mit Werten aus
/// unserer `config.env` oder mit sinnvollen Defaults, unbekannte werden 1:1
/// uebernommen falls schon ein Wert dort steht, sonst leer gelassen.
fn render_compose_env(template: &str) -> Result<String, String> {
let app_cfg = crate::config::load().unwrap_or_default();
let pg_pw = app_cfg
.get("POSTGRES_PASSWORD")
.cloned()
.ok_or_else(|| "POSTGRES_PASSWORD fehlt in config.env".to_string())?;
let jwt = app_cfg
.get("JWT_SECRET")
.cloned()
.ok_or_else(|| "JWT_SECRET fehlt in config.env".to_string())?;
let (anon, service) = generate_supabase_keys(&jwt)?;
let defaults: std::collections::HashMap<&str, String> = [
("POSTGRES_PASSWORD", pg_pw),
("JWT_SECRET", jwt),
("ANON_KEY", anon),
("SERVICE_ROLE_KEY", service),
("SITE_URL", "http://localhost:18080".into()),
("API_EXTERNAL_URL", "http://localhost:18000".into()),
("APP_PORT", "18080".into()),
("KONG_HTTP_PORT", "18000".into()),
("KONG_HTTPS_PORT", "18443".into()),
("DB_PORT", "15432".into()),
("RAPPORT_APP_TAG", "main".into()),
("SMTP_HOST", String::new()),
("SMTP_PORT", "587".into()),
("SMTP_USER", String::new()),
("SMTP_PASS", String::new()),
("SMTP_SENDER_NAME", "RAPPORT".into()),
("SMTP_ADMIN_EMAIL", String::new()),
]
.into_iter()
.collect();
let mut out = String::from("# Auto-generiert vom RAPPORT Server-App Setup-Wizard.\n");
out.push_str("# Werte koennen hier oder im Settings-Tab der App geaendert werden.\n\n");
for line in template.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
out.push_str(line);
out.push('\n');
continue;
}
if let Some(eq_pos) = trimmed.find('=') {
let key = trimmed[..eq_pos].trim();
if let Some(val) = defaults.get(key) {
out.push_str(&format!("{key}={val}\n"));
} else {
// Unbekannter Key — 1:1 uebernehmen
out.push_str(line);
out.push('\n');
}
} else {
out.push_str(line);
out.push('\n');
}
}
Ok(out)
}
/// Generiert die zwei Standard-Supabase-JWTs ('anon' + 'service_role') die
/// signiert mit `jwt_secret` sind. Beide haben sehr lange Laufzeit (10 Jahre).
fn generate_supabase_keys(jwt_secret: &str) -> Result<(String, String), String> {
use jsonwebtoken::{encode, EncodingKey, Header};
#[derive(serde::Serialize)]
struct Claims {
role: &'static str,
iss: &'static str,
iat: u64,
exp: u64,
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| format!("time: {e}"))?
.as_secs();
let exp = now + 60 * 60 * 24 * 365 * 10; // 10 Jahre
let key = EncodingKey::from_secret(jwt_secret.as_bytes());
let make = |role: &'static str| -> Result<String, String> {
encode(
&Header::default(),
&Claims { role, iss: "supabase", iat: now, exp },
&key,
)
.map_err(|e| format!("encode JWT: {e}"))
};
Ok((make("anon")?, make("service_role")?))
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn bin_exists(name: &str) -> bool {
file_exists(&bin_dir().join(name))
}
fn file_exists(p: &std::path::Path) -> bool {
p.is_file()
}
fn pick_path(name: &str) -> PathBuf {
let local = bin_dir().join(name);
if local.is_file() {
local
} else {
PathBuf::from(name) // PATH-Lookup
}
}
async fn which(cmd: &str) -> bool {
match Command::new("which").arg(cmd).output().await {
Ok(o) => o.status.success() && !o.stdout.is_empty(),
Err(_) => false,
}
}
async fn docker_info_ok() -> bool {
let docker = pick_path("docker");
let extra_path = format!(
"{}:{}",
bin_dir().display(),
std::env::var("PATH").unwrap_or_default()
);
match Command::new(&docker)
.arg("info")
.env("PATH", &extra_path)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
{
Ok(s) => s.success(),
Err(_) => false,
}
}
fn tempdir_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("rapport-setup-{name}"))
}
async fn download_to(url: &str, dest: &std::path::Path) -> Result<(), String> {
let resp = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.build()
.map_err(|e| format!("reqwest build: {e}"))?
.get(url)
.send()
.await
.map_err(|e| format!("http get {url}: {e}"))?;
if !resp.status().is_success() {
return Err(format!("HTTP {} fuer {url}", resp.status()));
}
let mut out = tokio::fs::File::create(dest)
.await
.map_err(|e| format!("create {}: {e}", dest.display()))?;
let bytes = resp
.bytes()
.await
.map_err(|e| format!("read body: {e}"))?;
out.write_all(&bytes)
.await
.map_err(|e| format!("write {}: {e}", dest.display()))?;
out.flush().await.map_err(|e| format!("flush: {e}"))?;
Ok(())
}
async fn extract_tar_gz(tar_path: &std::path::Path, out_dir: &std::path::Path) -> Result<(), String> {
let status = Command::new("tar")
.args(["-xzf"])
.arg(tar_path)
.arg("-C")
.arg(out_dir)
.status()
.await
.map_err(|e| format!("spawn tar: {e}"))?;
if !status.success() {
return Err(format!("tar -xzf {} failed", tar_path.display()));
}
let _ = AsyncReadExt::read(&mut tokio::io::empty(), &mut [0u8; 0]).await; // silence unused
Ok(())
}
fn chmod_x(p: &std::path::Path) -> Result<(), String> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(p, perms).map_err(|e| format!("chmod {}: {e}", p.display()))?;
}
let _ = p;
Ok(())
}
fn unquarantine(p: &std::path::Path) {
// Macht aus dem GitHub-Download eine "vertrauenswuerdige" Datei, sonst
// blockt macOS Gatekeeper das Ausfuehren beim ersten Mal.
let _ = std::process::Command::new("xattr")
.args(["-d", "com.apple.quarantine"])
.arg(p)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
+85
View File
@@ -0,0 +1,85 @@
//! Container-Live-Stats via `docker stats --no-stream`.
//!
//! Wird auf Demand abgerufen (separater Tauri-Command + HTTP-Endpoint),
//! NICHT in den 2s-Health-Tick eingehaengt — `docker stats` braucht selbst
//! ~500ms-1s und wuerde den UI-Poll ausbremsen.
use serde::Serialize;
use tokio::process::Command;
#[derive(Debug, Clone, Serialize)]
pub struct ContainerStats {
/// Compose-Service-ID (z.B. `db`, `auth`) — abgeleitet vom Container-Namen.
pub service_id: String,
pub cpu_percent: f32,
pub mem_bytes: u64,
pub mem_percent: f32,
}
pub async fn collect() -> Vec<ContainerStats> {
let out = Command::new("docker")
.args(["stats", "--no-stream", "--format", "{{json .}}"])
.output()
.await;
let stdout = match out {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
_ => return vec![],
};
let mut stats = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let name = v.get("Name").and_then(|x| x.as_str()).unwrap_or("");
let Some(service_id) = name.strip_prefix("rapport-").map(str::to_string) else {
continue;
};
// Sonderfall: das Frontend-Image heisst `rapport-server-app` in compose
// (container_name), aber Compose-Service ist `app`. Manuelles Mapping.
let service_id = if service_id == "server-app" {
"app".into()
} else {
service_id
};
let cpu = parse_percent(v.get("CPUPerc").and_then(|x| x.as_str()).unwrap_or(""));
let mem_pct = parse_percent(v.get("MemPerc").and_then(|x| x.as_str()).unwrap_or(""));
let mem_bytes = parse_mem_usage(v.get("MemUsage").and_then(|x| x.as_str()).unwrap_or(""));
stats.push(ContainerStats {
service_id,
cpu_percent: cpu,
mem_bytes,
mem_percent: mem_pct,
});
}
stats
}
fn parse_percent(s: &str) -> f32 {
s.trim_end_matches('%').trim().parse().unwrap_or(0.0)
}
/// "55.32MiB / 7.756GiB" → 55.32 MiB in Bytes
fn parse_mem_usage(s: &str) -> u64 {
let part = s.split('/').next().unwrap_or("").trim();
parse_size(part)
}
fn parse_size(s: &str) -> u64 {
let split_idx = s.find(|c: char| c.is_alphabetic()).unwrap_or(s.len());
let (num_str, unit) = s.split_at(split_idx);
let n: f64 = num_str.parse().unwrap_or(0.0);
let mul = match unit.trim().to_lowercase().as_str() {
"b" => 1.0,
"kib" | "kb" | "k" => 1024.0,
"mib" | "mb" | "m" => 1024.0 * 1024.0,
"gib" | "gb" | "g" => 1024.0 * 1024.0 * 1024.0,
"tib" | "tb" | "t" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
_ => 1.0,
};
(n * mul) as u64
}
+603
View File
@@ -0,0 +1,603 @@
//! Process-Supervisor — duenner Wrapper um `docker compose`.
//!
//! Statt selbst Container-Argumente zu bauen, delegieren wir Start/Stop/Logs
//! an die Compose-Datei in `services::compose_dir()`. Das hat den Vorteil dass
//! Image-/Env-/Volume-Konfiguration nur einmal existiert (im SERVER-CONTAINER-
//! Repo) und nicht hier und dort gepflegt werden muss.
use crate::services::{self, ServiceDef};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::process::Stdio;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
const LOG_RING_CAPACITY: usize = 1000;
/// Globaler Status-Cache fuer schnelles `list_services` ohne den Supervisor-Mutex
/// blockieren zu muessen. Wird von list_with_timeout() gefuellt; bei lang
/// laufenden compose-Calls (z.B. Erst-Start mit Image-Pull) liefert der Cache
/// die letzte bekannte Liste so dass die UI nicht hangs zeigt.
static STATUS_CACHE: std::sync::OnceLock<Arc<Mutex<Vec<ServiceStatus>>>> =
std::sync::OnceLock::new();
fn status_cache() -> &'static Arc<Mutex<Vec<ServiceStatus>>> {
STATUS_CACHE.get_or_init(|| Arc::new(Mutex::new(Vec::new())))
}
/// Versucht den Supervisor-Mutex in `timeout_ms` zu kriegen — wenn nicht,
/// gibt den Cache zurueck. Aktualisiert Cache bei jedem Erfolg.
pub async fn list_with_timeout(
supervisor: &Arc<Mutex<Supervisor>>,
timeout_ms: u64,
) -> Vec<ServiceStatus> {
let timeout = std::time::Duration::from_millis(timeout_ms);
match tokio::time::timeout(timeout, supervisor.lock()).await {
Ok(sv) => {
let list = sv.list();
*status_cache().lock().await = list.clone();
list
}
Err(_) => status_cache().lock().await.clone(),
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ServiceState {
Stopped,
Starting,
Running,
Stopping,
Error,
}
#[derive(Debug, Clone, Serialize)]
pub struct ServiceStatus {
pub id: String,
pub display_name: String,
pub state: ServiceState,
pub pid: Option<u32>,
pub port: u16,
pub last_error: Option<String>,
}
struct ServiceEntry {
def: ServiceDef,
state: ServiceState,
log_pump: Option<Child>,
pid: Option<u32>,
last_error: Option<String>,
logs: Arc<Mutex<VecDeque<String>>>,
/// Wann zum erstmals in Error gerutscht — gesetzt bei Transition INTO Error,
/// gecleart wenn raus.
errored_since: Option<Instant>,
recovery_attempts: u32,
/// Wann der naechste Auto-Restart-Versuch faellig ist.
next_recovery_at: Option<Instant>,
}
#[derive(Debug, Default, Clone, Serialize)]
pub struct RecoveryReport {
pub to_restart: Vec<String>,
pub maxed_out: Vec<String>,
}
pub struct Supervisor {
services: HashMap<String, ServiceEntry>,
pub activity: Arc<Mutex<String>>,
}
impl Supervisor {
pub fn new() -> Self {
Self {
services: HashMap::new(),
activity: Arc::new(Mutex::new(String::new())),
}
}
pub fn activity_handle(&self) -> Arc<Mutex<String>> {
self.activity.clone()
}
pub async fn current_activity(&self) -> String {
self.activity.lock().await.clone()
}
pub fn register(&mut self, def: ServiceDef) {
let id = def.id.clone();
self.services.insert(
id,
ServiceEntry {
def,
state: ServiceState::Stopped,
log_pump: None,
pid: None,
last_error: None,
logs: Arc::new(Mutex::new(VecDeque::with_capacity(LOG_RING_CAPACITY))),
errored_since: None,
recovery_attempts: 0,
next_recovery_at: None,
},
);
}
pub fn list(&self) -> Vec<ServiceStatus> {
let mut out: Vec<_> = self
.services
.values()
.map(|e| ServiceStatus {
id: e.def.id.clone(),
display_name: e.def.display_name.clone(),
state: e.state,
pid: e.pid,
port: e.def.port,
last_error: e.last_error.clone(),
})
.collect();
out.sort_by(|a, b| a.port.cmp(&b.port));
out
}
pub fn status(&self, id: &str) -> Option<ServiceStatus> {
let e = self.services.get(id)?;
Some(ServiceStatus {
id: e.def.id.clone(),
display_name: e.def.display_name.clone(),
state: e.state,
pid: e.pid,
port: e.def.port,
last_error: e.last_error.clone(),
})
}
pub async fn logs(&self, id: &str) -> Vec<String> {
let Some(e) = self.services.get(id) else { return vec![] };
e.logs.lock().await.iter().cloned().collect()
}
pub async fn start(&mut self, id: &str) -> Result<(), String> {
log::info!("start({id})");
let entry = self
.services
.get_mut(id)
.ok_or_else(|| format!("unknown service: {id}"))?;
if matches!(entry.state, ServiceState::Running | ServiceState::Starting) {
return Ok(());
}
entry.state = ServiceState::Starting;
entry.last_error = None;
let logs_ref = entry.logs.clone();
let result = compose_up(&entry.def.id, logs_ref).await;
let entry = self.services.get_mut(id).unwrap();
match result {
Ok(log_pump) => {
entry.pid = log_pump.id();
entry.log_pump = Some(log_pump);
Ok(())
}
Err(e) => {
entry.state = ServiceState::Error;
entry.last_error = Some(e.clone());
Err(e)
}
}
}
/// Drop-in replacement fuer `start_all` als freie Funktion — haelt den
/// Supervisor-Mutex NUR fuer kurze State-Updates, NICHT waehrend `docker
/// compose up -d`. So bleibt die UI responsive auch beim Erst-Pull.
pub async fn start_all_managed(arc: Arc<Mutex<Self>>) -> Result<(), String> {
log::info!("start_all_managed (docker compose up -d, lock-frei waehrend compose)");
crate::events::info("Container werden hochgefahren (kann beim Erst-Start bis zu 1 Min dauern) ...").await;
// 1) Quick lock: mark all Starting + prime Cache
{
let mut sv = arc.lock().await;
sv.mark_all_starting();
*status_cache().lock().await = sv.list();
}
// 2) Slow compose call — KEIN Lock gehalten
let compose_result = compose(&["up", "-d"]).await;
// 3) Quick lock: finalize
let mut sv = arc.lock().await;
match compose_result {
Err(e) => {
sv.mark_all_error(&e);
*status_cache().lock().await = sv.list();
crate::events::error(format!("Start fehlgeschlagen: {e}")).await;
Err(e)
}
Ok(_) => {
sv.ensure_log_pumps().await;
*status_cache().lock().await = sv.list();
drop(sv);
crate::events::info("Alle Services gestartet").await;
Ok(())
}
}
}
pub async fn stop_all_managed(arc: Arc<Mutex<Self>>) -> Result<(), String> {
log::info!("stop_all_managed (docker compose down, lock-frei waehrend compose)");
crate::events::info("Alle Services stoppen ...").await;
{
let mut sv = arc.lock().await;
sv.mark_all_stopping().await;
*status_cache().lock().await = sv.list();
}
let res = compose(&["down"]).await;
let mut sv = arc.lock().await;
sv.mark_all_stopped();
*status_cache().lock().await = sv.list();
res.map(|_| ())
}
pub async fn restart_all_managed(arc: Arc<Mutex<Self>>) -> Result<(), String> {
log::info!("restart_all_managed (docker compose restart, lock-frei waehrend compose)");
crate::events::info("Alle Services neu starten ...").await;
{
let mut sv = arc.lock().await;
for entry in sv.services.values_mut() {
entry.state = ServiceState::Starting;
entry.last_error = None;
if let Some(mut lp) = entry.log_pump.take() {
let _ = lp.kill().await;
}
}
*status_cache().lock().await = sv.list();
}
let res = compose(&["restart"]).await;
let mut sv = arc.lock().await;
match res {
Err(e) => {
sv.mark_all_error(&e);
*status_cache().lock().await = sv.list();
crate::events::error(format!("Restart-All fehlgeschlagen: {e}")).await;
Err(e)
}
Ok(_) => {
sv.ensure_log_pumps().await;
*status_cache().lock().await = sv.list();
drop(sv);
crate::events::info("Alle Services neu gestartet").await;
Ok(())
}
}
}
pub async fn restart(&mut self, id: &str) -> Result<(), String> {
log::info!("restart({id})");
crate::events::info(format!("Service {id} neu starten ...")).await;
let entry = self
.services
.get_mut(id)
.ok_or_else(|| format!("unknown service: {id}"))?;
entry.state = ServiceState::Starting;
entry.last_error = None;
if let Some(mut lp) = entry.log_pump.take() {
let _ = lp.kill().await;
}
let logs = entry.logs.clone();
let id_clone = entry.def.id.clone();
let res = compose(&["restart", &id_clone]).await;
let entry = self.services.get_mut(id).unwrap();
if let Err(ref e) = res {
entry.state = ServiceState::Error;
entry.last_error = Some(e.clone());
return Err(e.clone());
}
// Log-Pump neu starten — der alte Stream ist tot.
match spawn_log_pump(&id_clone, logs).await {
Ok(lp) => {
entry.pid = lp.id();
entry.log_pump = Some(lp);
}
Err(e) => log::warn!("log pump restart {id_clone}: {e}"),
}
crate::events::info(format!("Service {id} neu gestartet")).await;
Ok(())
}
pub async fn stop(&mut self, id: &str) -> Result<(), String> {
log::info!("stop({id})");
let entry = self
.services
.get_mut(id)
.ok_or_else(|| format!("unknown service: {id}"))?;
if matches!(entry.state, ServiceState::Stopped) {
return Ok(());
}
entry.state = ServiceState::Stopping;
if let Some(mut lp) = entry.log_pump.take() {
let _ = lp.kill().await;
}
let id_clone = entry.def.id.clone();
let _ = compose(&["stop", &id_clone]).await;
entry.pid = None;
entry.state = ServiceState::Stopped;
Ok(())
}
/// Quick: alle Services auf `Starting` setzen + Cache update.
/// Wird vor dem langen compose-Call aufgerufen damit die UI nicht hangs sieht.
fn mark_all_starting(&mut self) {
for entry in self.services.values_mut() {
entry.state = ServiceState::Starting;
entry.last_error = None;
}
}
/// Quick: alle auf `Stopping` setzen + Log-Pumps killen.
async fn mark_all_stopping(&mut self) {
for entry in self.services.values_mut() {
if let Some(mut lp) = entry.log_pump.take() {
let _ = lp.kill().await;
}
entry.state = ServiceState::Stopping;
}
}
/// Quick: alle als Stopped markieren (Endzustand nach compose down).
fn mark_all_stopped(&mut self) {
for entry in self.services.values_mut() {
entry.state = ServiceState::Stopped;
entry.pid = None;
}
}
/// Quick: alle als Error markieren (Endzustand wenn compose-Call schiefging).
fn mark_all_error(&mut self, msg: &str) {
for entry in self.services.values_mut() {
entry.state = ServiceState::Error;
entry.last_error = Some(msg.to_string());
}
}
/// Quick: Log-Pumps fuer alle Services nachstarten falls noch keiner laeuft.
async fn ensure_log_pumps(&mut self) {
let ids: Vec<String> = self.services.keys().cloned().collect();
for id in ids {
let Some(entry) = self.services.get_mut(&id) else { continue };
if entry.log_pump.is_none() {
if let Ok(lp) = spawn_log_pump(&entry.def.id, entry.logs.clone()).await {
entry.pid = lp.id();
entry.log_pump = Some(lp);
}
}
}
}
/// Fragt `docker compose ps` nach dem aktuellen Stand und uebernimmt
/// die State-Machine daraus. Liefert die Liste der Services die in
/// diesem Tick NEU in den Error-State gewechselt sind — der Caller
/// dispatched daraus z.B. eine macOS-Notification.
pub async fn tick_health(&mut self) -> Vec<String> {
let mut newly_errored = Vec::new();
let statuses = match query_compose_status().await {
Ok(m) => m,
Err(e) => {
log::warn!("compose ps failed: {e}");
return newly_errored;
}
};
let ids: Vec<String> = self.services.keys().cloned().collect();
for id in ids {
let Some(e) = self.services.get_mut(&id) else { continue };
let cs = statuses.get(&id);
let new_state = match cs {
// Container nicht in compose ps:
// - wenn wir gerade auf Starting sind (z.B. compose up haengt
// noch im Pull) → bleibt Starting (sonst flackert die UI)
// - sonst → Stopped
None => {
if e.state == ServiceState::Starting {
ServiceState::Starting
} else {
ServiceState::Stopped
}
}
Some(s) if s.health == Some("unhealthy".to_string()) => ServiceState::Error,
Some(s) if s.state == "restarting" || s.state == "dead" => ServiceState::Error,
Some(s) if s.state == "exited" || s.state == "stopped" || s.state == "removing" => {
ServiceState::Stopped
}
Some(s) if s.state == "created" || s.state == "paused" => ServiceState::Starting,
Some(s) if s.state == "running" => match s.health.as_deref() {
Some("starting") => ServiceState::Starting,
_ => ServiceState::Running,
},
Some(_) => ServiceState::Starting,
};
if e.state != new_state {
let was_error = matches!(e.state, ServiceState::Error);
if new_state == ServiceState::Error {
if let Some(s) = cs {
e.last_error = Some(format!("{}: {}", s.state, s.health.clone().unwrap_or_default()));
}
if !was_error {
// Transition INTO Error: Recovery-Counter starten.
e.errored_since = Some(Instant::now());
e.recovery_attempts = 0;
// Erste Recovery erst nach base-delay (lib.rs setzt das spaeter neu).
e.next_recovery_at = Some(Instant::now() + Duration::from_secs(60));
newly_errored.push(id.clone());
}
} else {
// Out of Error: Recovery-State resetten.
e.errored_since = None;
e.recovery_attempts = 0;
e.next_recovery_at = None;
if matches!(new_state, ServiceState::Running | ServiceState::Stopped) {
e.last_error = None;
}
}
e.state = new_state;
}
}
newly_errored
}
/// Display-Name fuer eine Service-ID (fuer Notifications, UI etc.).
pub fn display_name(&self, id: &str) -> Option<String> {
self.services.get(id).map(|e| e.def.display_name.clone())
}
/// Bewertet welche Services jetzt einen Recovery-Restart bekommen sollen.
/// Inkrementiert dabei den Versuchszaehler atomisch und plant den naechsten
/// Versuch via exponential backoff. Wenn `max_attempts` erreicht ist,
/// landet die Service-ID in `maxed_out` und es wird nicht weiter versucht.
pub fn recovery_candidates(
&mut self,
max_attempts: u32,
base_delay: Duration,
) -> RecoveryReport {
let now = Instant::now();
let mut report = RecoveryReport::default();
for entry in self.services.values_mut() {
if !matches!(entry.state, ServiceState::Error) {
continue;
}
if entry.recovery_attempts >= max_attempts {
continue; // bereits aufgegeben — keine Doppel-Notification
}
let due = entry.next_recovery_at.map(|t| now >= t).unwrap_or(true);
if !due {
continue;
}
entry.recovery_attempts += 1;
if entry.recovery_attempts >= max_attempts {
entry.next_recovery_at = None;
report.maxed_out.push(entry.def.id.clone());
} else {
// exponential backoff: 1×, 2×, 4×, 8×, ... base_delay (capped 8x)
let exp = (entry.recovery_attempts.saturating_sub(1)).min(8);
let backoff = base_delay.saturating_mul(1u32 << exp);
entry.next_recovery_at = Some(now + backoff);
}
report.to_restart.push(entry.def.id.clone());
}
report
}
}
// ---------------------------------------------------------------------------
// Compose helpers
// ---------------------------------------------------------------------------
#[derive(Debug, serde::Deserialize)]
struct ComposeStatusRaw {
#[serde(rename = "Service")]
service: String,
#[serde(rename = "State")]
state: String,
#[serde(rename = "Health", default)]
health: String,
}
struct ComposeStatus {
state: String,
health: Option<String>,
}
async fn query_compose_status() -> Result<HashMap<String, ComposeStatus>, String> {
let output = Command::new("docker")
.current_dir(services::compose_dir())
.args(["compose", "ps", "-a", "--format", "json"])
.output()
.await
.map_err(|e| format!("spawn: {e}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
let mut out = HashMap::new();
// Compose gibt JSON-Lines aus (ein Objekt pro Zeile), nicht ein Array.
for line in String::from_utf8_lossy(&output.stdout).lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<ComposeStatusRaw>(line) {
Ok(raw) => {
out.insert(
raw.service,
ComposeStatus {
state: raw.state,
health: if raw.health.is_empty() {
None
} else {
Some(raw.health)
},
},
);
}
Err(e) => log::warn!("compose ps json parse: {e} | line={line}"),
}
}
Ok(out)
}
async fn compose(args: &[&str]) -> Result<std::process::Output, String> {
let mut cmd = Command::new("docker");
cmd.current_dir(services::compose_dir()).arg("compose");
for a in args {
cmd.arg(a);
}
let output = cmd
.output()
.await
.map_err(|e| format!("spawn docker compose: {e}"))?;
if !output.status.success() {
return Err(format!(
"docker compose {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(output)
}
async fn compose_up(service: &str, logs: Arc<Mutex<VecDeque<String>>>) -> Result<Child, String> {
compose(&["up", "-d", service]).await?;
spawn_log_pump(service, logs).await
}
async fn spawn_log_pump(
service: &str,
logs: Arc<Mutex<VecDeque<String>>>,
) -> Result<Child, String> {
let mut cmd = Command::new("docker");
cmd.current_dir(services::compose_dir())
.args(["compose", "logs", "-f", "--no-log-prefix", service])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = cmd
.spawn()
.map_err(|e| format!("spawn docker compose logs: {e}"))?;
if let Some(stdout) = child.stdout.take() {
tokio::spawn(pump_lines(stdout, logs.clone()));
}
if let Some(stderr) = child.stderr.take() {
tokio::spawn(pump_lines(stderr, logs));
}
Ok(child)
}
async fn pump_lines<R: tokio::io::AsyncRead + Unpin + Send + 'static>(
reader: R,
logs: Arc<Mutex<VecDeque<String>>>,
) {
let mut lines = BufReader::new(reader).lines();
while let Ok(Some(line)) = lines.next_line().await {
let mut buf = logs.lock().await;
if buf.len() >= LOG_RING_CAPACITY {
buf.pop_front();
}
buf.push_back(line);
}
}
+60
View File
@@ -0,0 +1,60 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0",
"productName": "RAPPORT Server",
"version": "0.1.0",
"identifier": "com.rapport.server-app",
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devUrl": "http://localhost:3001",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "RAPPORT Server",
"width": 1100,
"height": 760,
"minWidth": 760,
"minHeight": 500,
"decorations": true,
"resizable": true,
"fullscreen": false
}
],
"trayIcon": {
"iconPath": "icons/icon.png",
"iconAsTemplate": true,
"menuOnLeftClick": false,
"tooltip": "RAPPORT Server"
},
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"targets": "all",
"category": "DeveloperTool",
"shortDescription": "Doppelklick-Self-Hosting für Rapport",
"longDescription": "Admin-UI für den Rapport-Server-Stack. Verwaltet Postgres, GoTrue, PostgREST, Realtime, Storage, Kong und nginx als Docker-Container. Setzt einen lokalen Docker-Daemon voraus (Colima / OrbStack / Docker Desktop).",
"copyright": "© 2026 Karim Gabriele Varano — AGPL-3.0-or-later",
"resources": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"updater": {
"endpoints": [
"https://git.kgva.ch/karim/RAPPORT-SERVER-APP/raw/branch/main/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQzM0U0NjNEQTE4MzFGOUEKUldTYUg0T2hQVVkrUS95Z3JXdmJQVWxkejhQNHFHWkswVndmMVpPV01TZ3NvWVo5UkZlQ1kwOUoK"
}
}
}