More routes

This commit is contained in:
Jack Mechem 2026-03-27 17:47:48 -07:00
parent a580b7bccf
commit a9a10a6d52
12 changed files with 2400 additions and 211 deletions

1884
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -5,8 +5,18 @@ edition = "2024"
[dependencies] [dependencies]
axum = "0.8.8" axum = "0.8.8"
base64 = "0.22.1"
bcrypt = "0.19.0"
chrono = "0.4.44" chrono = "0.4.44"
jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] }
pam = { version = "0.8.0", features = ["client"] }
rustls = { version = "0.23.37", features = ["ring"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
sysinfo = "0.38.4" sysinfo = "0.38.4"
tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "process"] } tokio = { version = "1.50.0", features = [
"macros",
"rt-multi-thread",
"process",
] }
zbus = "5.14.0"

8
README.md Normal file
View file

@ -0,0 +1,8 @@
# Server Stats API
# Installation & Usage
## Dependencies
- rust, cargo

139
flake.nix
View file

@ -1,73 +1,72 @@
{ {
description = "sysapi - system stats & command execution REST API in Rust"; description = "sysapi - system stats & command execution REST API in Rust";
inputs = {
inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; rust-overlay = {
rust-overlay = { url = "github:oxalica/rust-overlay";
url = "github:oxalica/rust-overlay"; inputs.nixpkgs.follows = "nixpkgs";
inputs.nixpkgs.follows = "nixpkgs"; };
flake-utils.url = "github:numtide/flake-utils";
}; };
flake-utils.url = "github:numtide/flake-utils"; outputs =
}; {
self,
outputs = nixpkgs,
{ rust-overlay,
self, flake-utils,
nixpkgs, ...
rust-overlay, }:
flake-utils, flake-utils.lib.eachDefaultSystem (
... system:
}: let
flake-utils.lib.eachDefaultSystem ( overlays = [ (import rust-overlay) ];
system: pkgs = import nixpkgs { inherit system overlays; };
let rustToolchain = pkgs.rust-bin.stable.latest.default.override {
overlays = [ (import rust-overlay) ]; extensions = [
pkgs = import nixpkgs { inherit system overlays; }; "rust-src"
"rust-analyzer"
# Pin to stable. Swap to rust-overlay's nightly if you need it: "clippy"
# pkgs.rust-bin.nightly.latest.default "rustfmt"
rustToolchain = pkgs.rust-bin.stable.latest.default.override { ];
extensions = [ };
"rust-src" nativeBuildInputs = with pkgs; [
"rust-analyzer" rustToolchain
"clippy" pkg-config
"rustfmt" ];
]; buildInputs = with pkgs; [
}; openssl
linux-pam
# Native build inputs required to compile Rust crates with C deps libclang
nativeBuildInputs = with pkgs; [ glibc.dev
rustToolchain ];
pkg-config in
]; {
packages.default = pkgs.rustPlatform.buildRustPackage {
# Runtime / link-time dependencies pname = "sysapi";
buildInputs = with pkgs; [ version = "0.1.0";
openssl # required by jsonwebtoken / reqwest src = ./.;
]; cargoHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
inherit nativeBuildInputs buildInputs;
in OPENSSL_NO_VENDOR = 1;
{ PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
devShells.default = pkgs.mkShell { LIBCLANG_PATH = "${pkgs.libclang.lib}/lib";
inherit nativeBuildInputs buildInputs; BINDGEN_EXTRA_CLANG_ARGS = "-I${pkgs.linux-pam}/include -I${pkgs.glibc.dev}/include";
};
# Let pkg-config find openssl devShells.default = pkgs.mkShell {
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig"; inherit nativeBuildInputs buildInputs;
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
# Tells the openssl-sys crate where to look (fallback if pkg-config fails) OPENSSL_DIR = "${pkgs.openssl.dev}";
OPENSSL_DIR = "${pkgs.openssl.dev}"; OPENSSL_LIB_DIR = "${pkgs.openssl.out}/lib";
OPENSSL_LIB_DIR = "${pkgs.openssl.out}/lib"; OPENSSL_INCLUDE_DIR = "${pkgs.openssl.dev}/include";
OPENSSL_INCLUDE_DIR = "${pkgs.openssl.dev}/include"; LIBCLANG_PATH = "${pkgs.libclang.lib}/lib";
BINDGEN_EXTRA_CLANG_ARGS = "-I${pkgs.linux-pam}/include -I${pkgs.glibc.dev}/include";
# Points rust-analyzer at the stdlib source RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library";
RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library"; shellHook = ''
echo "🦀 sysapi dev shell ready"
shellHook = '' echo " rustc $(rustc --version)"
echo "🦀 sysapi dev shell ready" echo " cargo $(cargo --version)"
echo " rustc $(rustc --version)" '';
echo " cargo $(cargo --version)" };
''; }
}; );
}
);
} }

86
src/auth.rs Normal file
View 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
View file

@ -0,0 +1,8 @@
pub const ALLOWED_SERVICES: &[&str] = &[
"syncthing",
"caddy",
"sshd",
"cloudflare-dyndns.timer",
"cloudflare-dyndns",
"docker",
];

View file

@ -1,141 +1,41 @@
use axum::{Router, body::Body, response::Json, routing::get}; use axum::response::Redirect;
use serde_json::{Value, json}; use axum::{
use std::collections::HashMap; Router, extract::Path, http::HeaderMap, http::StatusCode, response::IntoResponse,
use sysinfo::{Components, Disks, Networks, System}; response::Json, routing::get, routing::post,
use std::path::Path; };
use tokio::process::Command; use tokio::process::Command;
mod auth;
mod config;
mod models; mod models;
mod routes;
use models::SystemStats;
use self::models::MemoryStats;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let app = Router::new() let app = Router::new()
.route("/", get(root)) .route("/", get(|| async { Redirect::permanent("/stats") }))
.route("/stats", get(get_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(); 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,
})
}

View file

@ -1,6 +1,41 @@
use axum::http::StatusCode;
use axum::response::Json;
use serde::Serialize; use serde::Serialize;
use std::collections::HashMap; 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)] #[derive(Serialize)]
pub struct SystemStats { pub struct SystemStats {
pub timestamp: String, pub timestamp: String,

3
src/routes/mod.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod services;
pub mod stats;
pub mod system;

116
src/routes/services.rs Normal file
View 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
View 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
View 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(),
}
}