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
+247
View File
@@ -0,0 +1,247 @@
//! nixpanel-sysinfo — system information section binary
//!
//! Actions:
//! stats → {hostname, ip, os, kernel, uptime_sec, cpu_pct, ram_used_mb,
//! ram_total_mb, disk_used_gb, disk_total_gb, load_avg}
//! versions → {apache, php, mariadb, nixpanel}
use nixpanel_common::{SectionRequest, SectionResponse};
use std::fs;
use std::io::{self, BufRead};
use std::net::UdpSocket;
use std::process::Command;
/* ── helpers ──────────────────────────────────────────────────────────── */
fn cmd(prog: &str, args: &[&str]) -> String {
Command::new(prog)
.args(args)
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default()
}
fn read_file(path: &str) -> String {
fs::read_to_string(path).unwrap_or_default()
}
/* ── hostname / IP ────────────────────────────────────────────────────── */
fn get_hostname() -> String {
fs::read_to_string("/etc/hostname")
.unwrap_or_default()
.trim()
.to_string()
}
fn get_ip() -> String {
// Quick UDP trick — no actual packet sent
UdpSocket::bind("0.0.0.0:0")
.and_then(|s| {
s.connect("8.8.8.8:80")?;
s.local_addr()
})
.map(|a| a.ip().to_string())
.unwrap_or_else(|_| "0.0.0.0".into())
}
/* ── OS / kernel ──────────────────────────────────────────────────────── */
fn get_os() -> String {
let content = read_file("/etc/os-release");
for line in content.lines() {
if let Some(val) = line.strip_prefix("PRETTY_NAME=") {
return val.trim_matches('"').to_string();
}
}
"Unknown".into()
}
fn get_kernel() -> String {
cmd("uname", &["-r"])
}
/* ── uptime ───────────────────────────────────────────────────────────── */
fn get_uptime_sec() -> u64 {
read_file("/proc/uptime")
.split_whitespace()
.next()
.and_then(|s| s.parse::<f64>().ok())
.map(|s| s as u64)
.unwrap_or(0)
}
/* ── CPU ──────────────────────────────────────────────────────────────── */
/// Reads /proc/stat twice (100ms apart) to compute a 1-sample CPU %.
fn get_cpu_pct() -> f32 {
fn read_stat() -> Option<(u64, u64)> {
let content = fs::read_to_string("/proc/stat").ok()?;
let line = content.lines().find(|l| l.starts_with("cpu "))?;
let nums: Vec<u64> = line
.split_whitespace()
.skip(1)
.filter_map(|n| n.parse().ok())
.collect();
if nums.len() < 4 {
return None;
}
let idle = nums[3];
let total: u64 = nums.iter().sum();
Some((idle, total))
}
let s1 = read_stat().unwrap_or((0, 1));
std::thread::sleep(std::time::Duration::from_millis(100));
let s2 = read_stat().unwrap_or((0, 1));
let d_total = s2.1.saturating_sub(s1.1);
let d_idle = s2.0.saturating_sub(s1.0);
if d_total == 0 {
return 0.0;
}
((d_total - d_idle) as f32 / d_total as f32 * 100.0 * 10.0).round() / 10.0
}
/* ── RAM ──────────────────────────────────────────────────────────────── */
fn get_ram() -> (u64, u64) {
// Returns (used_mb, total_mb)
let content = read_file("/proc/meminfo");
let mut total_kb: u64 = 0;
let mut available_kb: u64 = 0;
for line in content.lines() {
let mut parts = line.split_whitespace();
match parts.next() {
Some("MemTotal:") => { total_kb = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0); }
Some("MemAvailable:") => { available_kb = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0); }
_ => {}
}
}
let used_mb = (total_kb.saturating_sub(available_kb)) / 1024;
let total_mb = total_kb / 1024;
(used_mb, total_mb)
}
/* ── Disk ─────────────────────────────────────────────────────────────── */
fn get_disk() -> (u64, u64) {
// Returns (used_gb, total_gb) for /
use std::os::unix::fs::MetadataExt;
let stat = nix_statvfs("/");
stat.unwrap_or((0, 0))
}
fn nix_statvfs(path: &str) -> Option<(u64, u64)> {
// Use df as a fallback — parse "df -B1 /"
let out = Command::new("df")
.args(&["-B1", path])
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let line = text.lines().nth(1)?; // skip header
let mut parts = line.split_whitespace();
let _fs = parts.next()?;
let total = parts.next()?.parse::<u64>().ok()?;
let used = parts.next()?.parse::<u64>().ok()?;
Some((used / 1_073_741_824, total / 1_073_741_824))
}
/* ── Load average ─────────────────────────────────────────────────────── */
fn get_load_avg() -> [f32; 3] {
let content = read_file("/proc/loadavg");
let mut parts = content.split_whitespace();
let l1 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
let l5 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
let l15= parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
[l1, l5, l15]
}
/* ── Software versions ────────────────────────────────────────────────── */
fn apache_version() -> String {
let out = cmd("apache2", &["-v"]);
// "Server version: Apache/2.4.58 (Ubuntu)"
out.lines()
.find(|l| l.starts_with("Server version:"))
.and_then(|l| l.split('/').nth(1))
.and_then(|s| s.split_whitespace().next())
.unwrap_or("—")
.to_string()
}
fn php_version() -> String {
cmd("php8.3", &["--version"])
.lines()
.next()
.and_then(|l| l.split_whitespace().nth(1))
.unwrap_or("—")
.to_string()
}
fn mariadb_version() -> String {
let out = cmd("mariadb", &["--version"]);
// "mariadb Ver 15.1 Distrib 10.11.x-MariaDB ..."
out.split_whitespace()
.enumerate()
.find(|(_, w)| w.contains("MariaDB") || w.contains("Distrib"))
.map(|(i, _)| {
out.split_whitespace()
.nth(i.saturating_sub(1))
.unwrap_or("—")
.trim_end_matches(',')
.to_string()
})
.unwrap_or_else(|| "—".to_string())
}
/* ── Actions ──────────────────────────────────────────────────────────── */
fn action_stats() -> SectionResponse {
let (ram_used, ram_total) = get_ram();
let (disk_used, disk_total) = get_disk();
SectionResponse::ok(serde_json::json!({
"hostname": get_hostname(),
"ip": get_ip(),
"os": get_os(),
"kernel": get_kernel(),
"uptime_sec": get_uptime_sec(),
"cpu_pct": get_cpu_pct(),
"ram_used_mb": ram_used,
"ram_total_mb": ram_total,
"disk_used_gb": disk_used,
"disk_total_gb": disk_total,
"load_avg": get_load_avg(),
}))
}
fn action_versions() -> SectionResponse {
SectionResponse::ok(serde_json::json!({
"apache": apache_version(),
"php": php_version(),
"mariadb": mariadb_version(),
"nixpanel": env!("CARGO_PKG_VERSION"),
}))
}
/* ── Entry point ──────────────────────────────────────────────────────── */
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 JSON: {}", e)),
Ok(req) => match req.action.as_str() {
"stats" => action_stats(),
"versions" => action_versions(),
other => SectionResponse::err(format!("Unknown action: {}", other)),
},
};
println!("{}", serde_json::to_string(&response).unwrap());
}