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:
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user