157 lines
5.1 KiB
Rust
157 lines
5.1 KiB
Rust
//! 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;
|
||
|
|
|
||
|
|
/// 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());
|
||
|
|
}
|