feat: add Rust backend — workspace with section executables

Cargo workspace with four crates:
- nixpanel-common: shared SectionRequest/SectionResponse types
- nixpanel-api: main Axum HTTP server (JWT auth, DB, routing)
- nixpanel-services: section binary for systemctl start/stop/restart
- nixpanel-sysinfo: section binary for CPU/RAM/disk/OS/version reads

nixpanel-api features:
- POST /api/auth/login — Argon2id verification, HS256 JWT
- GET  /api/dashboard  — real account counts + sysinfo + versions
- GET  /api/services   — live systemctl status via section binary
- POST /api/services/:name/:action — start/stop/restart
- GET  /api/accounts   — list from MariaDB
- POST /api/accounts   — create account + system user

Section binaries are UPX-compressed after build.
DB schema auto-migrated on first start; admin user seeded from
/opt/nixpanel/.admin_user + .admin_pass files.

Frontend changes:
- nixserver/src/api.ts + App.tsx wired to real API (JWT stored in
  localStorage, real data in dashboard/services/accounts)
- nixclient/src/api.ts added
- Both vite configs get /api proxy to localhost:4000 for dev
- install.sh updated to copy + compress all section binaries
This commit is contained in:
NixReaper
2026-04-25 16:41:57 -05:00
parent a257b5571c
commit 5247c77132
26 changed files with 1899 additions and 331 deletions
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "nixpanel-services"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "nixpanel-services"
path = "src/main.rs"
[dependencies]
nixpanel-common = { path = "../nixpanel-common" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+157
View File
@@ -0,0 +1,157 @@
//! nixpanel-services — service management section binary
//!
//! Protocol: read one SectionRequest JSON line from stdin,
//! write one SectionResponse JSON line to stdout.
//!
//! Actions:
//! list → [ServiceInfo]
//! start {service} → {success, message}
//! stop {service} → {success, message}
//! restart {service} → {success, message}
use nixpanel_common::{SectionRequest, SectionResponse, ServiceInfo};
use std::io::{self, BufRead};
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// Canonical list of managed services (systemd unit → display name).
const SERVICES: &[(&str, &str)] = &[
("apache2", "Apache HTTP Server"),
("mariadb", "MariaDB"),
("pdns", "PowerDNS"),
("exim4", "Exim (SMTP)"),
("dovecot", "Dovecot (IMAP)"),
("spamd", "SpamAssassin"),
("fail2ban", "Fail2ban"),
("proftpd", "ProFTPD"),
("nixpanel", "NixPanel API"),
];
fn systemctl(args: &[&str]) -> std::io::Result<std::process::Output> {
Command::new("systemctl").args(args).output()
}
fn is_active(unit: &str) -> &'static str {
match systemctl(&["is-active", "--quiet", unit]) {
Ok(out) if out.status.success() => "running",
_ => "stopped",
}
}
fn main_pid(unit: &str) -> Option<u32> {
let out = systemctl(&["show", unit, "--property=MainPID"]).ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let pid_str = text.trim().strip_prefix("MainPID=")?;
pid_str.parse::<u32>().ok().filter(|&p| p != 0)
}
fn active_enter_timestamp(unit: &str) -> Option<u64> {
let out = systemctl(&["show", unit, "--property=ActiveEnterTimestampMonotonic"]).ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let ts_str = text.trim().strip_prefix("ActiveEnterTimestampMonotonic=")?;
ts_str.parse::<u64>().ok()
}
fn monotonic_now_usec() -> u64 {
// /proc/uptime gives seconds since boot; multiply by 1_000_000 for µs
std::fs::read_to_string("/proc/uptime")
.ok()
.and_then(|s| {
s.split_whitespace()
.next()
.and_then(|n| n.parse::<f64>().ok())
})
.map(|secs| (secs * 1_000_000.0) as u64)
.unwrap_or(0)
}
fn format_uptime(elapsed_sec: u64) -> String {
let days = elapsed_sec / 86400;
let hours = (elapsed_sec % 86400) / 3600;
let mins = (elapsed_sec % 3600) / 60;
if days > 0 {
format!("{}d {}h", days, hours)
} else if hours > 0 {
format!("{}h {}m", hours, mins)
} else {
format!("{}m", mins)
}
}
fn service_info(unit: &str, display: &str) -> ServiceInfo {
let status = is_active(unit);
let pid = if status == "running" { main_pid(unit) } else { None };
let uptime = if status == "running" {
active_enter_timestamp(unit).map(|start| {
let now = monotonic_now_usec();
let secs = if now > start { (now - start) / 1_000_000 } else { 0 };
format_uptime(secs)
})
} else {
None
};
ServiceInfo {
name: unit.to_string(),
display: display.to_string(),
status: status.to_string(),
pid,
uptime,
}
}
fn list_services() -> SectionResponse {
let infos: Vec<ServiceInfo> = SERVICES
.iter()
.map(|(unit, display)| service_info(unit, display))
.collect();
SectionResponse::ok(infos)
}
fn control_service(unit: &str, action: &str) -> SectionResponse {
// Validate service name
if !SERVICES.iter().any(|(u, _)| *u == unit) {
return SectionResponse::err(format!("Unknown service: {}", unit));
}
// Validate action
let allowed = ["start", "stop", "restart", "reload"];
if !allowed.contains(&action) {
return SectionResponse::err(format!("Unknown action: {}", action));
}
match systemctl(&[action, unit]) {
Ok(out) if out.status.success() => SectionResponse::ok(serde_json::json!({
"message": format!("Service {} {}ed", unit, action)
})),
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
SectionResponse::err(format!("systemctl {} {} failed: {}", action, unit, stderr.trim()))
}
Err(e) => SectionResponse::err(format!("Failed to run systemctl: {}", e)),
}
}
fn main() {
let stdin = io::stdin();
let mut line = String::new();
stdin.lock().read_line(&mut line).unwrap_or(0);
let response = match serde_json::from_str::<SectionRequest>(line.trim()) {
Err(e) => SectionResponse::err(format!("Invalid request JSON: {}", e)),
Ok(req) => match req.action.as_str() {
"list" => list_services(),
"start" | "stop" | "restart" | "reload" => {
let unit = req.params["service"]
.as_str()
.unwrap_or("");
if unit.is_empty() {
SectionResponse::err("Missing 'service' param")
} else {
control_service(unit, &req.action)
}
}
other => SectionResponse::err(format!("Unknown action: {}", other)),
},
};
println!("{}", serde_json::to_string(&response).unwrap());
}