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
+160
View File
@@ -0,0 +1,160 @@
mod auth;
mod config;
mod error;
mod routes;
mod section;
mod state;
use std::sync::Arc;
use std::net::SocketAddr;
use axum::{
routing::{get, post},
Router,
};
use tower_http::cors::{Any, CorsLayer};
use tracing::info;
use config::Config;
use state::AppState;
/* ── DB schema bootstrap ────────────────────────────────────────────── */
async fn run_migrations(pool: &sqlx::MySqlPool) -> anyhow::Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS _schema_version (
version INT NOT NULL,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
).execute(pool).await?;
let current: i32 = sqlx::query_scalar(
"SELECT COALESCE(MAX(version), 0) FROM _schema_version"
).fetch_one(pool).await.unwrap_or(0);
if current < 1 {
info!("Running migration 1: initial schema");
for stmt in include_str!("../migrations/0001_initial.sql")
.split(";\n")
.map(str::trim)
.filter(|s| !s.is_empty())
{
sqlx::query(stmt).execute(pool).await?;
}
sqlx::query("INSERT INTO _schema_version (version) VALUES (1)")
.execute(pool).await?;
}
Ok(())
}
/* ── Admin user seeding ─────────────────────────────────────────────── */
async fn seed_admin(pool: &sqlx::MySqlPool, install_dir: &str) -> anyhow::Result<()> {
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2,
};
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM users WHERE role = 'admin'"
).fetch_one(pool).await.unwrap_or(0);
if count > 0 {
return Ok(()); // already seeded
}
let username = std::fs::read_to_string(format!("{}/.admin_user", install_dir))
.unwrap_or_else(|_| "admin".into())
.trim()
.to_string();
let password = std::fs::read_to_string(format!("{}/.admin_pass", install_dir))
.unwrap_or_else(|_| "changeme".into())
.trim()
.to_string();
let email = format!("{}@localhost", username);
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("argon2: {}", e))?
.to_string();
sqlx::query(
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'admin')"
)
.bind(&username)
.bind(&email)
.bind(&hash)
.execute(pool)
.await?;
info!("Seeded admin user: {}", username);
Ok(())
}
/* ── Router ─────────────────────────────────────────────────────────── */
fn build_router(state: AppState) -> Router {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
Router::new()
// Auth (public)
.route("/api/auth/login", post(routes::auth::login))
// Dashboard
.route("/api/dashboard", get(routes::dashboard::get_dashboard))
// Services
.route("/api/services", get(routes::services::list_services))
.route("/api/services/:name/:action", post(routes::services::service_action))
// Accounts
.route("/api/accounts", get(routes::accounts::list_accounts)
.post(routes::accounts::create_account))
.with_state(state)
.layer(cors)
}
/* ── Entry point ────────────────────────────────────────────────────── */
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Load .env if present (production uses EnvironmentFile= in systemd)
let _ = dotenvy::dotenv();
tracing_subscriber::fmt()
.with_env_filter(
std::env::var("RUST_LOG")
.unwrap_or_else(|_| "nixpanel=info,tower_http=warn".into())
)
.init();
let config = Config::from_env()?;
info!("NixPanel API starting on port {}", config.port);
// Connect to MariaDB
let pool = sqlx::MySqlPool::connect(&config.database_url).await?;
// Migrations
run_migrations(&pool).await?;
// Seed admin on first run
seed_admin(&pool, &config.install_dir).await
.unwrap_or_else(|e| tracing::warn!("Admin seed skipped: {}", e));
let state = AppState {
db: pool,
config: Arc::new(config.clone()),
};
let addr = SocketAddr::from(([127, 0, 0, 1], config.port));
let listener = tokio::net::TcpListener::bind(addr).await?;
info!("Listening on {}", addr);
axum::serve(listener, build_router(state)).await?;
Ok(())
}