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
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "nixpanel"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "nixpanel"
path = "src/main.rs"
[dependencies]
nixpanel-common = { path = "../nixpanel-common" }
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.5", features = ["cors"] }
sqlx = { version = "0.7", features = ["mysql", "runtime-tokio-rustls", "chrono"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
jsonwebtoken = "9"
argon2 = { version = "0.5", features = ["std"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"
chrono = { version = "0.4", features = ["serde"] }
thiserror = "1"
anyhow = "1"
+127
View File
@@ -0,0 +1,127 @@
-- Users (panel logins: admin + per-account users)
CREATE TABLE IF NOT EXISTS users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role ENUM('admin','user') NOT NULL DEFAULT 'user',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_username (username),
UNIQUE KEY uq_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Hosting accounts
CREATE TABLE IF NOT EXISTS accounts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED,
username VARCHAR(64) NOT NULL,
domain VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
package_name VARCHAR(64) NOT NULL DEFAULT 'Default',
disk_quota_mb INT NOT NULL DEFAULT 10240,
bandwidth_mb INT NOT NULL DEFAULT 0,
status ENUM('active','suspended','terminated') NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_username (username),
UNIQUE KEY uq_domain (domain),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Domains / DNS zones
CREATE TABLE IF NOT EXISTS domains (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED,
name VARCHAR(255) NOT NULL,
type ENUM('main','addon','parked','subdomain') NOT NULL DEFAULT 'main',
status ENUM('active','inactive') NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_name (name),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- DNS records
CREATE TABLE IF NOT EXISTS dns_records (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
domain_id BIGINT UNSIGNED NOT NULL,
type VARCHAR(10) NOT NULL, -- A, AAAA, CNAME, MX, TXT, NS, SRV
name VARCHAR(255) NOT NULL,
content VARCHAR(512) NOT NULL,
ttl INT NOT NULL DEFAULT 3600,
priority INT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- MySQL/MariaDB databases owned by accounts
CREATE TABLE IF NOT EXISTS account_databases (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED NOT NULL,
db_name VARCHAR(64) NOT NULL,
db_user VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_db_name (db_name),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Email accounts
CREATE TABLE IF NOT EXISTS email_accounts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED NOT NULL,
address VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
quota_mb INT NOT NULL DEFAULT 1024,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_address (address),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- FTP accounts
CREATE TABLE IF NOT EXISTS ftp_accounts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED NOT NULL,
username VARCHAR(64) NOT NULL,
home_dir VARCHAR(512) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_username (username),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- SSL certificates
CREATE TABLE IF NOT EXISTS ssl_certificates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED,
domain VARCHAR(255) NOT NULL,
issuer VARCHAR(64) NOT NULL DEFAULT 'letsencrypt',
cert_path VARCHAR(512),
key_path VARCHAR(512),
expires_at DATETIME,
auto_renew TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_domain (domain),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Backup jobs
CREATE TABLE IF NOT EXISTS backups (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED,
type ENUM('full','incremental') NOT NULL DEFAULT 'full',
status ENUM('pending','running','completed','failed') NOT NULL DEFAULT 'pending',
size_bytes BIGINT,
path VARCHAR(512),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME,
PRIMARY KEY (id),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
+74
View File
@@ -0,0 +1,74 @@
use axum::{
async_trait,
extract::FromRequestParts,
http::{request::Parts, HeaderMap},
RequestPartsExt,
};
use axum_extra::TypedHeader;
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use crate::error::AppError;
use crate::state::AppState;
/* ── JWT claims ──────────────────────────────────────────────────────── */
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: String, // username
pub user_id: i64,
pub role: String, // "admin" | "user"
pub exp: i64, // unix timestamp
pub iat: i64,
}
impl Claims {
pub fn new(user_id: i64, username: &str, role: &str, secret: &str) -> anyhow::Result<String> {
let now = Utc::now();
let claims = Claims {
sub: username.to_string(),
user_id,
role: role.to_string(),
exp: (now + Duration::hours(8)).timestamp(),
iat: now.timestamp(),
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)?;
Ok(token)
}
pub fn verify(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
let data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::new(Algorithm::HS256),
)?;
Ok(data.claims)
}
}
/* ── Extractor: pulls Claims from Authorization: Bearer <token> ──────── */
#[async_trait]
impl FromRequestParts<AppState> for Claims {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let headers: &HeaderMap = &parts.headers;
let auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.ok_or(AppError::Unauthorized)?;
Claims::verify(auth, &state.config.jwt_secret)
.map_err(|_| AppError::Unauthorized)
}
}
+24
View File
@@ -0,0 +1,24 @@
use std::env;
#[derive(Clone, Debug)]
pub struct Config {
pub port: u16,
pub jwt_secret: String,
pub database_url: String,
pub install_dir: String,
pub bin_dir: String,
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
Ok(Self {
port: env::var("PORT").unwrap_or_else(|_| "4000".into()).parse()?,
jwt_secret: env::var("JWT_SECRET").unwrap_or_else(|_| "change-me-in-production".into()),
database_url: env::var("DATABASE_URL")?,
install_dir: env::var("INSTALL_DIR").unwrap_or_else(|_| "/opt/nixpanel".into()),
bin_dir: env::var("INSTALL_DIR")
.map(|d| format!("{}/bin", d))
.unwrap_or_else(|_| "/opt/nixpanel/bin".into()),
})
}
}
+48
View File
@@ -0,0 +1,48 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Unauthorized")]
Unauthorized,
#[error("Not found")]
NotFound,
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Internal error: {0}")]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::Internal(_) => {
tracing::error!("{}", self);
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".into())
}
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
AppError::Internal(anyhow::anyhow!(e))
}
}
impl From<jsonwebtoken::errors::Error> for AppError {
fn from(e: jsonwebtoken::errors::Error) -> Self {
AppError::Unauthorized
}
}
+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(())
}
+114
View File
@@ -0,0 +1,114 @@
use axum::{extract::State, Json};
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use crate::{auth::Claims, error::AppError, state::AppState};
#[derive(Serialize, sqlx::FromRow)]
pub struct Account {
pub id: i64,
pub username: String,
pub domain: String,
pub email: String,
pub package_name: String,
pub disk_quota_mb: i64,
pub bandwidth_mb: i64,
pub status: String,
pub created_at: NaiveDateTime,
}
#[derive(Deserialize)]
pub struct CreateAccountRequest {
pub username: String,
pub domain: String,
pub email: String,
pub password: String,
pub package_name: Option<String>,
pub disk_quota_mb: Option<i64>,
pub bandwidth_mb: Option<i64>,
}
pub async fn list_accounts(
claims: Claims,
State(state): State<AppState>,
) -> Result<Json<Vec<Account>>, AppError> {
let accounts = sqlx::query_as!(
Account,
"SELECT id, username, domain, email, package_name, disk_quota_mb, bandwidth_mb, status, created_at
FROM accounts ORDER BY created_at DESC"
)
.fetch_all(&state.db)
.await?;
Ok(Json(accounts))
}
pub async fn create_account(
claims: Claims,
State(state): State<AppState>,
Json(body): Json<CreateAccountRequest>,
) -> Result<Json<Account>, AppError> {
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2,
};
// Validate
if body.username.is_empty() || body.domain.is_empty() {
return Err(AppError::BadRequest("username and domain are required".into()));
}
// Hash password
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(body.password.as_bytes(), &salt)
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?
.to_string();
// Create system user (best-effort — panel may not be running as root in dev)
let _ = std::process::Command::new("useradd")
.args(&[
"-m",
"-s", "/bin/bash",
"-d", &format!("/home/{}", body.username),
&body.username,
])
.output();
// Create Apache vhost directory
let vhost_dir = format!("/home/{}/public_html", body.username);
let _ = std::fs::create_dir_all(&vhost_dir);
let package_name = body.package_name.unwrap_or_else(|| "Default".into());
let disk_quota_mb = body.disk_quota_mb.unwrap_or(10240);
let bandwidth_mb = body.bandwidth_mb.unwrap_or(0);
let id = sqlx::query!(
"INSERT INTO accounts (username, domain, email, password_hash, package_name, disk_quota_mb, bandwidth_mb, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'active')",
body.username, body.domain, body.email, hash,
package_name, disk_quota_mb, bandwidth_mb
)
.execute(&state.db)
.await?
.last_insert_id() as i64;
// Also create a panel user so they can log into NixClient
let _ = sqlx::query!(
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'user')
ON DUPLICATE KEY UPDATE email = VALUES(email)",
body.username, body.email, hash
)
.execute(&state.db)
.await;
let account = sqlx::query_as!(
Account,
"SELECT id, username, domain, email, package_name, disk_quota_mb, bandwidth_mb, status, created_at
FROM accounts WHERE id = ?",
id
)
.fetch_one(&state.db)
.await?;
Ok(Json(account))
}
+58
View File
@@ -0,0 +1,58 @@
use argon2::{Argon2, PasswordHash, PasswordVerifier};
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use crate::{auth::Claims, error::AppError, state::AppState};
#[derive(Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Serialize)]
pub struct LoginResponse {
pub token: String,
pub user: UserInfo,
}
#[derive(Serialize)]
pub struct UserInfo {
pub id: i64,
pub username: String,
pub role: String,
}
pub async fn login(
State(state): State<AppState>,
Json(body): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, AppError> {
// Look up user
let row = sqlx::query!(
"SELECT id, username, password_hash, role FROM users WHERE username = ? LIMIT 1",
body.username
)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::Unauthorized)?;
// Verify password
let parsed = PasswordHash::new(&row.password_hash)
.map_err(|_| AppError::Unauthorized)?;
Argon2::default()
.verify_password(body.password.as_bytes(), &parsed)
.map_err(|_| AppError::Unauthorized)?;
// Issue JWT
let token = Claims::new(row.id, &row.username, &row.role, &state.config.jwt_secret)
.map_err(|e| AppError::Internal(e))?;
Ok(Json(LoginResponse {
token,
user: UserInfo {
id: row.id,
username: row.username,
role: row.role,
},
}))
}
+54
View File
@@ -0,0 +1,54 @@
use axum::{extract::State, Json};
use serde::Serialize;
use crate::{auth::Claims, error::AppError, section, state::AppState};
#[derive(Serialize)]
pub struct DashboardStats {
pub accounts: i64,
pub domains: i64,
pub databases: i64,
pub email_accounts: i64,
}
#[derive(Serialize)]
pub struct DashboardResponse {
pub stats: DashboardStats,
pub sysinfo: serde_json::Value,
pub versions: serde_json::Value,
}
pub async fn get_dashboard(
claims: Claims,
State(state): State<AppState>,
) -> Result<Json<DashboardResponse>, AppError> {
// DB counts
let accounts = sqlx::query_scalar!("SELECT COUNT(*) FROM accounts")
.fetch_one(&state.db).await?.unwrap_or(0);
let domains = sqlx::query_scalar!("SELECT COUNT(*) FROM domains")
.fetch_one(&state.db).await?.unwrap_or(0);
let databases = sqlx::query_scalar!("SELECT COUNT(*) FROM account_databases")
.fetch_one(&state.db).await?.unwrap_or(0);
let email_accounts = sqlx::query_scalar!("SELECT COUNT(*) FROM email_accounts")
.fetch_one(&state.db).await?.unwrap_or(0);
// Section binaries
let bin_dir = &state.config.bin_dir;
let sysinfo_resp = section::call(bin_dir, "nixpanel-sysinfo", "stats", serde_json::Value::Null)
.unwrap_or_else(|_| nixpanel_common::SectionResponse::err("sysinfo unavailable"));
let versions_resp = section::call(bin_dir, "nixpanel-sysinfo", "versions", serde_json::Value::Null)
.unwrap_or_else(|_| nixpanel_common::SectionResponse::err("versions unavailable"));
Ok(Json(DashboardResponse {
stats: DashboardStats {
accounts,
domains,
databases,
email_accounts,
},
sysinfo: sysinfo_resp.data.unwrap_or(serde_json::Value::Null),
versions: versions_resp.data.unwrap_or(serde_json::Value::Null),
}))
}
+4
View File
@@ -0,0 +1,4 @@
pub mod auth;
pub mod accounts;
pub mod dashboard;
pub mod services;
+50
View File
@@ -0,0 +1,50 @@
use axum::{
extract::{Path, State},
Json,
};
use serde::{Deserialize, Serialize};
use crate::{auth::Claims, error::AppError, section, state::AppState};
pub async fn list_services(
claims: Claims,
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, AppError> {
let resp = section::call(
&state.config.bin_dir,
"nixpanel-services",
"list",
serde_json::Value::Null,
)
.map_err(|e| AppError::Internal(e))?;
Ok(Json(resp.data.unwrap_or(serde_json::json!([]))))
}
pub async fn service_action(
claims: Claims,
State(state): State<AppState>,
Path((name, action)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, AppError> {
let allowed_actions = ["start", "stop", "restart", "reload"];
if !allowed_actions.contains(&action.as_str()) {
return Err(AppError::BadRequest(format!("Unknown action: {}", action)));
}
let resp = section::call(
&state.config.bin_dir,
"nixpanel-services",
&action,
serde_json::json!({ "service": name }),
)
.map_err(|e| AppError::Internal(e))?;
if resp.success {
Ok(Json(serde_json::json!({
"success": true,
"message": resp.data
})))
} else {
Err(AppError::BadRequest(resp.error.unwrap_or_else(|| "Action failed".into())))
}
}
+35
View File
@@ -0,0 +1,35 @@
//! Helpers for calling section binaries via stdin/stdout JSON.
use nixpanel_common::{SectionRequest, SectionResponse};
use std::io::Write;
use std::process::{Command, Stdio};
/// Call a section binary and return its parsed response.
/// `bin_dir` is e.g. "/opt/nixpanel/bin".
pub fn call(bin_dir: &str, binary: &str, action: &str, params: serde_json::Value)
-> anyhow::Result<SectionResponse>
{
let bin_path = format!("{}/{}", bin_dir, binary);
let req = SectionRequest { action: action.to_string(), params };
let req_json = serde_json::to_string(&req)?;
let mut child = Command::new(&bin_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to start {}: {}", bin_path, e))?;
if let Some(stdin) = child.stdin.take() {
let mut stdin = stdin;
stdin.write_all(req_json.as_bytes())?;
stdin.write_all(b"\n")?;
}
let output = child.wait_with_output()?;
let text = String::from_utf8_lossy(&output.stdout);
let resp: SectionResponse = serde_json::from_str(text.trim())
.map_err(|e| anyhow::anyhow!("Bad response from {}: {} (raw: {})", binary, e, text.trim()))?;
Ok(resp)
}
+8
View File
@@ -0,0 +1,8 @@
use std::sync::Arc;
use crate::config::Config;
#[derive(Clone)]
pub struct AppState {
pub db: sqlx::MySqlPool,
pub config: Arc<Config>,
}