More routes
This commit is contained in:
parent
a580b7bccf
commit
a9a10a6d52
12 changed files with 2400 additions and 211 deletions
86
src/auth.rs
Normal file
86
src/auth.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
use axum::response::Redirect;
|
||||
use axum::{
|
||||
Router, extract::Path, http::HeaderMap, http::StatusCode, response::IntoResponse,
|
||||
response::Json, routing::get, routing::post,
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use pam::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use sysinfo::{Components, Disks, Networks, System};
|
||||
use tokio::process::Command;
|
||||
use zbus::Connection;
|
||||
|
||||
const JWT_SECRET: &str = "change-me-to-a-long-random-string";
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Claims {
|
||||
sub: String,
|
||||
exp: usize,
|
||||
}
|
||||
|
||||
pub fn create_token(username: &str) -> String {
|
||||
let claims = Claims {
|
||||
sub: username.to_owned(),
|
||||
exp: (chrono::Utc::now() + chrono::Duration::hours(8)).timestamp() as usize,
|
||||
};
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(JWT_SECRET.as_bytes()),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn verify_token(headers: &HeaderMap) -> bool {
|
||||
let Some(val) = headers.get("Authorization") else {
|
||||
return false;
|
||||
};
|
||||
let token = val.to_str().unwrap_or("").replace("Bearer ", "");
|
||||
decode::<Claims>(
|
||||
&token,
|
||||
&DecodingKey::from_secret(JWT_SECRET.as_bytes()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
pub fn decode_basic_auth(headers: &HeaderMap) -> Option<(String, String)> {
|
||||
let val = headers.get("Authorization")?.to_str().ok()?;
|
||||
let encoded = val.strip_prefix("Basic ")?;
|
||||
let decoded = general_purpose::STANDARD.decode(encoded).ok()?;
|
||||
let s = String::from_utf8(decoded).ok()?;
|
||||
let (user, pass) = s.split_once(':')?;
|
||||
Some((user.to_string(), pass.to_string()))
|
||||
}
|
||||
|
||||
pub fn verify_system_credentials(username: &str, password: &str) -> bool {
|
||||
let mut client = match Client::with_password("login") {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
client
|
||||
.conversation_mut()
|
||||
.set_credentials(username, password);
|
||||
client.authenticate().is_ok()
|
||||
}
|
||||
|
||||
// POST /auth/login
|
||||
pub async fn post_login(headers: HeaderMap) -> impl IntoResponse {
|
||||
let (username, password) = match decode_basic_auth(&headers) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Missing or invalid Authorization header",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if !verify_system_credentials(&username, &password) {
|
||||
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
|
||||
}
|
||||
let token = create_token(&username);
|
||||
(StatusCode::OK, Json(serde_json::json!({ "token": token }))).into_response()
|
||||
}
|
||||
8
src/config.rs
Normal file
8
src/config.rs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
pub const ALLOWED_SERVICES: &[&str] = &[
|
||||
"syncthing",
|
||||
"caddy",
|
||||
"sshd",
|
||||
"cloudflare-dyndns.timer",
|
||||
"cloudflare-dyndns",
|
||||
"docker",
|
||||
];
|
||||
162
src/main.rs
162
src/main.rs
|
|
@ -1,141 +1,41 @@
|
|||
use axum::{Router, body::Body, response::Json, routing::get};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use sysinfo::{Components, Disks, Networks, System};
|
||||
use std::path::Path;
|
||||
use axum::response::Redirect;
|
||||
use axum::{
|
||||
Router, extract::Path, http::HeaderMap, http::StatusCode, response::IntoResponse,
|
||||
response::Json, routing::get, routing::post,
|
||||
};
|
||||
use tokio::process::Command;
|
||||
|
||||
mod auth;
|
||||
mod config;
|
||||
mod models;
|
||||
|
||||
use models::SystemStats;
|
||||
|
||||
use self::models::MemoryStats;
|
||||
mod routes;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let app = Router::new()
|
||||
.route("/", get(root))
|
||||
.route("/stats", get(get_stats));
|
||||
.route("/", get(|| async { Redirect::permanent("/stats") }))
|
||||
.route("/stats", get(routes::stats::get_stats))
|
||||
.route("/auth/login", post(auth::post_login))
|
||||
.route(
|
||||
"/services/{service}/restart",
|
||||
post(routes::services::restart_service),
|
||||
)
|
||||
.route(
|
||||
"/services/{service}/start",
|
||||
post(routes::services::start_service),
|
||||
)
|
||||
.route(
|
||||
"/services/{service}/stop",
|
||||
post(routes::services::stop_service),
|
||||
)
|
||||
.route(
|
||||
"/services/{service}/logs",
|
||||
get(routes::services::service_logs),
|
||||
)
|
||||
.route("/system/reboot", post(routes::system::system_reboot));
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await.unwrap();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:3001")
|
||||
.await
|
||||
.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
// which calls one of these handlers
|
||||
async fn root() {}
|
||||
async fn get_stats() -> Json<models::SystemStats> {
|
||||
let mut sys = System::new_all();
|
||||
sys.refresh_all();
|
||||
|
||||
// Memory (MB)
|
||||
let memory = models::MemoryStats {
|
||||
total: sys.total_memory() / 1_000_000,
|
||||
used: sys.used_memory() / 1_000_000,
|
||||
available: sys.available_memory() / 1_000_000,
|
||||
percent: (sys.used_memory() as f64 / sys.total_memory() as f64 * 100.0) as u64,
|
||||
};
|
||||
|
||||
// CPU
|
||||
let cpu = models::CpuStats {
|
||||
percent: sys.global_cpu_usage(),
|
||||
model: sys.cpus()[0].brand().to_string(),
|
||||
cores: sys.cpus().len(),
|
||||
};
|
||||
|
||||
// Disk (Fixed)
|
||||
let disks = Disks::new_with_refreshed_list();
|
||||
|
||||
// Find only the root partition
|
||||
let root_disk = disks.iter().find(|d| d.mount_point() == std::path::Path::new("/"));
|
||||
|
||||
let disk = if let Some(d) = root_disk {
|
||||
let total_bytes = d.total_space();
|
||||
let available_bytes = d.available_space();
|
||||
let used_bytes = total_bytes - available_bytes;
|
||||
let mb_factor = 1024 * 1024;
|
||||
|
||||
models::DiskStats {
|
||||
total: total_bytes / mb_factor,
|
||||
used: used_bytes / mb_factor,
|
||||
available: available_bytes / mb_factor,
|
||||
percent: (used_bytes as f64 / total_bytes as f64 * 100.0) as u64,
|
||||
}
|
||||
} else {
|
||||
// Fallback if "/" isn't found (prevents crash)
|
||||
models::DiskStats { total: 0, used: 0, available: 0, percent: 0 }
|
||||
};
|
||||
|
||||
// Uptime
|
||||
let seconds = System::uptime();
|
||||
let uptime = models::UptimeStats {
|
||||
seconds,
|
||||
days: seconds / 86400,
|
||||
hours: (seconds % 86400) / 3600,
|
||||
minutes: (seconds % 3600) / 60,
|
||||
};
|
||||
|
||||
// Network (bytes, same as original)
|
||||
let networks = Networks::new_with_refreshed_list();
|
||||
let network: HashMap<String, models::NetworkStats> = networks
|
||||
.iter()
|
||||
.map(|(name, data): (&String, &sysinfo::NetworkData)| {
|
||||
(
|
||||
name.clone(),
|
||||
models::NetworkStats {
|
||||
rx: data.total_received(),
|
||||
tx: data.total_transmitted(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Load average
|
||||
let load = System::load_average();
|
||||
let load_avg = models::LoadAvgStats {
|
||||
one: load.one,
|
||||
five: load.five,
|
||||
fifteen: load.fifteen,
|
||||
};
|
||||
|
||||
// Temperature
|
||||
let components = Components::new_with_refreshed_list();
|
||||
let temperature: f32 = components
|
||||
.iter()
|
||||
.next()
|
||||
.and_then(|c: &sysinfo::Component| c.temperature())
|
||||
.unwrap_or(0.0f32);
|
||||
|
||||
// Services — check a list of known services via systemctl
|
||||
let service_names = vec![
|
||||
"syncthing",
|
||||
"caddy",
|
||||
"sshd",
|
||||
"cloudflare-dyndns.timer",
|
||||
"cloudflare-dyndns",
|
||||
"docker",
|
||||
];
|
||||
let mut services: HashMap<String, String> = HashMap::new();
|
||||
for name in service_names {
|
||||
let output = Command::new("systemctl")
|
||||
.args(["is-active", name])
|
||||
.output()
|
||||
.await;
|
||||
let status = match output {
|
||||
Ok(out) => String::from_utf8_lossy(&out.stdout).trim().to_string(),
|
||||
Err(_) => "unknown".to_string(),
|
||||
};
|
||||
services.insert(name.to_string(), status);
|
||||
}
|
||||
|
||||
Json(models::SystemStats {
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
memory,
|
||||
cpu,
|
||||
disk,
|
||||
uptime,
|
||||
network,
|
||||
load_avg,
|
||||
temperature,
|
||||
services,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,41 @@
|
|||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ActionResponse {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
impl ActionResponse {
|
||||
pub fn ok(message: String) -> (StatusCode, Json<Self>) {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(Self {
|
||||
success: true,
|
||||
message,
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
pub fn err(status: StatusCode, message: &str) -> (StatusCode, Json<Self>) {
|
||||
(
|
||||
status,
|
||||
Json(Self {
|
||||
success: false,
|
||||
message: message.to_string(),
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SystemStats {
|
||||
pub timestamp: String,
|
||||
|
|
|
|||
3
src/routes/mod.rs
Normal file
3
src/routes/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod services;
|
||||
pub mod stats;
|
||||
pub mod system;
|
||||
116
src/routes/services.rs
Normal file
116
src/routes/services.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
use axum::response::Redirect;
|
||||
use axum::{
|
||||
Router, extract::Path, http::HeaderMap, http::StatusCode, response::IntoResponse,
|
||||
response::Json, routing::get, routing::post,
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use pam::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use sysinfo::{Components, Disks, Networks, System};
|
||||
use tokio::process::Command;
|
||||
use zbus::Connection;
|
||||
|
||||
use crate::auth;
|
||||
use crate::config;
|
||||
use crate::models::ActionResponse;
|
||||
|
||||
async fn systemd_action(action: &str, service: &str) -> (StatusCode, Json<ActionResponse>) {
|
||||
let unit = if service.contains('.') {
|
||||
service.to_string() // already has extension e.g. cloudflare-dyndns.timer
|
||||
} else {
|
||||
format!("{}.service", service)
|
||||
};
|
||||
|
||||
let conn = match Connection::system().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return ActionResponse::err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
};
|
||||
|
||||
let method = match action {
|
||||
"restart" => "RestartUnit",
|
||||
"start" => "StartUnit",
|
||||
"stop" => "StopUnit",
|
||||
_ => return ActionResponse::err(StatusCode::BAD_REQUEST, "Invalid action"),
|
||||
};
|
||||
|
||||
let result = conn
|
||||
.call_method(
|
||||
Some("org.freedesktop.systemd1"),
|
||||
"/org/freedesktop/systemd1",
|
||||
Some("org.freedesktop.systemd1.Manager"),
|
||||
method,
|
||||
&(unit.as_str(), "replace"),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => ActionResponse::ok(format!("{} {}ed successfully", service, action)),
|
||||
Err(e) => ActionResponse::err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /services/:service/restart
|
||||
pub async fn restart_service(headers: HeaderMap, Path(service): Path<String>) -> impl IntoResponse {
|
||||
if !auth::verify_token(&headers) {
|
||||
return ActionResponse::err(StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
}
|
||||
if !config::ALLOWED_SERVICES.contains(&service.as_str()) {
|
||||
return ActionResponse::err(StatusCode::BAD_REQUEST, "Service not allowed").into_response();
|
||||
}
|
||||
systemd_action("restart", &service).await.into_response()
|
||||
}
|
||||
|
||||
// POST /services/:service/start
|
||||
pub async fn start_service(headers: HeaderMap, Path(service): Path<String>) -> impl IntoResponse {
|
||||
if !auth::verify_token(&headers) {
|
||||
return ActionResponse::err(StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
}
|
||||
if !config::ALLOWED_SERVICES.contains(&service.as_str()) {
|
||||
return ActionResponse::err(StatusCode::BAD_REQUEST, "Service not allowed").into_response();
|
||||
}
|
||||
systemd_action("start", &service).await.into_response()
|
||||
}
|
||||
|
||||
// POST /services/:service/stop
|
||||
pub async fn stop_service(headers: HeaderMap, Path(service): Path<String>) -> impl IntoResponse {
|
||||
if !auth::verify_token(&headers) {
|
||||
return ActionResponse::err(StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
}
|
||||
if !config::ALLOWED_SERVICES.contains(&service.as_str()) {
|
||||
return ActionResponse::err(StatusCode::BAD_REQUEST, "Service not allowed").into_response();
|
||||
}
|
||||
systemd_action("stop", &service).await.into_response()
|
||||
}
|
||||
|
||||
// GET /services/:service/logs
|
||||
pub async fn service_logs(headers: HeaderMap, Path(service): Path<String>) -> impl IntoResponse {
|
||||
if !auth::verify_token(&headers) {
|
||||
return ActionResponse::err(StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
}
|
||||
if !config::ALLOWED_SERVICES.contains(&service.as_str()) {
|
||||
return ActionResponse::err(StatusCode::BAD_REQUEST, "Service not allowed").into_response();
|
||||
}
|
||||
|
||||
let out = Command::new("/run/current-system/sw/bin/journalctl")
|
||||
.args(["-u", &service, "-n", "100", "--no-pager"])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
match out {
|
||||
Ok(o) => (
|
||||
StatusCode::OK,
|
||||
Json(ActionResponse {
|
||||
success: true,
|
||||
message: String::new(),
|
||||
stdout: String::from_utf8_lossy(&o.stdout).to_string(),
|
||||
stderr: String::from_utf8_lossy(&o.stderr).to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
ActionResponse::err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
118
src/routes/stats.rs
Normal file
118
src/routes/stats.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
use axum::response::Redirect;
|
||||
use axum::{
|
||||
Router, extract::Path, http::HeaderMap, http::StatusCode, response::IntoResponse,
|
||||
response::Json, routing::get, routing::post,
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use pam::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use sysinfo::{Components, Disks, Networks, System};
|
||||
use tokio::process::Command;
|
||||
use zbus::Connection;
|
||||
|
||||
use crate::models;
|
||||
|
||||
pub async fn get_stats() -> Json<models::SystemStats> {
|
||||
let mut sys = System::new_all();
|
||||
sys.refresh_all();
|
||||
|
||||
let memory = models::MemoryStats {
|
||||
total: sys.total_memory() / 1_000_000,
|
||||
used: sys.used_memory() / 1_000_000,
|
||||
available: sys.available_memory() / 1_000_000,
|
||||
percent: (sys.used_memory() as f64 / sys.total_memory() as f64 * 100.0) as u64,
|
||||
};
|
||||
|
||||
let cpu = models::CpuStats {
|
||||
percent: sys.global_cpu_usage(),
|
||||
model: sys.cpus()[0].brand().to_string(),
|
||||
cores: sys.cpus().len(),
|
||||
};
|
||||
|
||||
let disks = Disks::new_with_refreshed_list();
|
||||
let root_disk = disks
|
||||
.iter()
|
||||
.find(|d| d.mount_point() == std::path::Path::new("/"));
|
||||
let disk = if let Some(d) = root_disk {
|
||||
let total = d.total_space();
|
||||
let available = d.available_space();
|
||||
let used = total - available;
|
||||
let mb = 1024 * 1024;
|
||||
models::DiskStats {
|
||||
total: total / mb,
|
||||
used: used / mb,
|
||||
available: available / mb,
|
||||
percent: (used as f64 / total as f64 * 100.0) as u64,
|
||||
}
|
||||
} else {
|
||||
models::DiskStats {
|
||||
total: 0,
|
||||
used: 0,
|
||||
available: 0,
|
||||
percent: 0,
|
||||
}
|
||||
};
|
||||
|
||||
let seconds = System::uptime();
|
||||
let uptime = models::UptimeStats {
|
||||
seconds,
|
||||
days: seconds / 86400,
|
||||
hours: (seconds % 86400) / 3600,
|
||||
minutes: (seconds % 3600) / 60,
|
||||
};
|
||||
|
||||
let networks = Networks::new_with_refreshed_list();
|
||||
let network: HashMap<String, models::NetworkStats> = networks
|
||||
.iter()
|
||||
.map(|(name, data): (&String, &sysinfo::NetworkData)| {
|
||||
(
|
||||
name.clone(),
|
||||
models::NetworkStats {
|
||||
rx: data.total_received(),
|
||||
tx: data.total_transmitted(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let load = System::load_average();
|
||||
let load_avg = models::LoadAvgStats {
|
||||
one: load.one,
|
||||
five: load.five,
|
||||
fifteen: load.fifteen,
|
||||
};
|
||||
|
||||
let components = Components::new_with_refreshed_list();
|
||||
let temperature: f32 = components
|
||||
.iter()
|
||||
.next()
|
||||
.and_then(|c: &sysinfo::Component| c.temperature())
|
||||
.unwrap_or(0.0f32);
|
||||
|
||||
let mut services: HashMap<String, String> = HashMap::new();
|
||||
for name in crate::config::ALLOWED_SERVICES {
|
||||
let output = Command::new("/run/current-system/sw/bin/systemctl")
|
||||
.args(["is-active", name])
|
||||
.output()
|
||||
.await;
|
||||
let status = match output {
|
||||
Ok(out) => String::from_utf8_lossy(&out.stdout).trim().to_string(),
|
||||
Err(_) => "unknown".to_string(),
|
||||
};
|
||||
services.insert(name.to_string(), status);
|
||||
}
|
||||
|
||||
Json(models::SystemStats {
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
memory,
|
||||
cpu,
|
||||
disk,
|
||||
uptime,
|
||||
network,
|
||||
load_avg,
|
||||
temperature,
|
||||
services,
|
||||
})
|
||||
}
|
||||
40
src/routes/system.rs
Normal file
40
src/routes/system.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
use axum::{
|
||||
Router, extract::Path, http::HeaderMap, http::StatusCode, response::IntoResponse,
|
||||
response::Json, routing::get, routing::post,
|
||||
};
|
||||
use zbus::Connection;
|
||||
|
||||
use crate::auth;
|
||||
use crate::models;
|
||||
|
||||
// POST /system/reboot
|
||||
pub async fn system_reboot(headers: HeaderMap) -> impl IntoResponse {
|
||||
if !auth::verify_token(&headers) {
|
||||
return models::ActionResponse::err(StatusCode::UNAUTHORIZED, "Unauthorized")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let conn = match Connection::system().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return models::ActionResponse::err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let result = conn
|
||||
.call_method(
|
||||
Some("org.freedesktop.login1"),
|
||||
"/org/freedesktop/login1",
|
||||
Some("org.freedesktop.login1.Manager"),
|
||||
"Reboot",
|
||||
&(false,), // false = don't ask for confirmation
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => models::ActionResponse::ok("Rebooting...".to_string()).into_response(),
|
||||
Err(e) => models::ActionResponse::err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue