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:
+15
@@ -0,0 +1,15 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"nixpanel-common",
|
||||
"nixpanel-api",
|
||||
"nixpanel-services",
|
||||
"nixpanel-sysinfo",
|
||||
]
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
+28
-4
@@ -244,11 +244,35 @@ step "Building nixpanel (Rust)"
|
||||
if ! step_done "build_panel"; then
|
||||
if [[ -f "$SRC_DIR/Cargo.toml" ]]; then
|
||||
cd "$SRC_DIR"
|
||||
$CARGO build --release --quiet
|
||||
[[ -f "target/release/nixpanel" ]] && \
|
||||
cp target/release/nixpanel "$BIN_DIR/nixpanel" && chmod 755 "$BIN_DIR/nixpanel"
|
||||
info "Compiling nixpanel workspace (this may take a few minutes on first build)…"
|
||||
$CARGO build --release
|
||||
|
||||
# Install UPX if available for binary compression
|
||||
if ! command -v upx &>/dev/null; then
|
||||
apt-get install -y -qq upx-ucl 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Main API binary
|
||||
if [[ -f "target/release/nixpanel" ]]; then
|
||||
cp target/release/nixpanel "$BIN_DIR/nixpanel"
|
||||
chmod 755 "$BIN_DIR/nixpanel"
|
||||
command -v upx &>/dev/null && upx --best --quiet "$BIN_DIR/nixpanel" 2>/dev/null || true
|
||||
success "nixpanel binary installed"
|
||||
fi
|
||||
|
||||
# Section binaries — each section is a separate compressed executable
|
||||
mkdir -p "$BIN_DIR"
|
||||
for section_bin in target/release/nixpanel-*; do
|
||||
[[ -f "$section_bin" ]] || continue
|
||||
bin_name=$(basename "$section_bin")
|
||||
cp "$section_bin" "$BIN_DIR/$bin_name"
|
||||
chmod 755 "$BIN_DIR/$bin_name"
|
||||
command -v upx &>/dev/null && upx --best --quiet "$BIN_DIR/$bin_name" 2>/dev/null || true
|
||||
success "Section binary installed: $bin_name"
|
||||
done
|
||||
|
||||
mark_done "build_panel"
|
||||
success "Panel binary built"
|
||||
success "Panel binaries built and compressed"
|
||||
else
|
||||
warn "Panel Rust backend not yet present in repo — skipping build"
|
||||
warn "This is expected during early development. Re-run installer when source is ready."
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/* NixClient API client — all calls go to /api/ (proxied to Rust backend) */
|
||||
|
||||
const BASE = '/api'
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem('nixclient_token')
|
||||
}
|
||||
export function setToken(t: string) {
|
||||
localStorage.setItem('nixclient_token', t)
|
||||
}
|
||||
export function clearToken() {
|
||||
localStorage.removeItem('nixclient_token')
|
||||
}
|
||||
export function isLoggedIn(): boolean {
|
||||
return !!getToken()
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = getToken()
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
})
|
||||
if (res.status === 401) {
|
||||
clearToken()
|
||||
window.location.reload()
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
token: string
|
||||
user: { id: number; username: string; role: string }
|
||||
}
|
||||
export async function login(username: string, password: string): Promise<LoginResult> {
|
||||
const result = await apiFetch<LoginResult>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
setToken(result.token)
|
||||
return result
|
||||
}
|
||||
|
||||
export interface AccountInfo {
|
||||
username: string
|
||||
domain: string
|
||||
email: string
|
||||
package_name: string
|
||||
disk_quota_mb: number
|
||||
bandwidth_mb: number
|
||||
status: string
|
||||
}
|
||||
export async function getMyAccount(): Promise<AccountInfo | null> {
|
||||
try {
|
||||
// Returns the first account matching the logged-in user's username
|
||||
const accounts = await apiFetch<AccountInfo[]>('/accounts')
|
||||
return accounts[0] ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,11 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 3001,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:4000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod auth;
|
||||
pub mod accounts;
|
||||
pub mod dashboard;
|
||||
pub mod services;
|
||||
@@ -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())))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "nixpanel-common"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,47 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Every section binary reads one of these from stdin.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SectionRequest {
|
||||
pub action: String,
|
||||
#[serde(default)]
|
||||
pub params: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Every section binary writes one of these to stdout.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SectionResponse {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl SectionResponse {
|
||||
pub fn ok<T: Serialize>(data: T) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
data: Some(serde_json::to_value(data).unwrap_or(serde_json::Value::Null)),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn err(msg: impl ToString) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(msg.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared service descriptor used by both sysinfo and services sections.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ServiceInfo {
|
||||
pub name: String,
|
||||
pub display: String,
|
||||
pub status: String, // "running" | "stopped" | "warning"
|
||||
pub pid: Option<u32>,
|
||||
pub uptime: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "nixpanel-services"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "nixpanel-services"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
nixpanel-common = { path = "../nixpanel-common" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,157 @@
|
||||
//! nixpanel-services — service management section binary
|
||||
//!
|
||||
//! Protocol: read one SectionRequest JSON line from stdin,
|
||||
//! write one SectionResponse JSON line to stdout.
|
||||
//!
|
||||
//! Actions:
|
||||
//! list → [ServiceInfo]
|
||||
//! start {service} → {success, message}
|
||||
//! stop {service} → {success, message}
|
||||
//! restart {service} → {success, message}
|
||||
|
||||
use nixpanel_common::{SectionRequest, SectionResponse, ServiceInfo};
|
||||
use std::io::{self, BufRead};
|
||||
use std::process::Command;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Canonical list of managed services (systemd unit → display name).
|
||||
const SERVICES: &[(&str, &str)] = &[
|
||||
("apache2", "Apache HTTP Server"),
|
||||
("mariadb", "MariaDB"),
|
||||
("pdns", "PowerDNS"),
|
||||
("exim4", "Exim (SMTP)"),
|
||||
("dovecot", "Dovecot (IMAP)"),
|
||||
("spamd", "SpamAssassin"),
|
||||
("fail2ban", "Fail2ban"),
|
||||
("proftpd", "ProFTPD"),
|
||||
("nixpanel", "NixPanel API"),
|
||||
];
|
||||
|
||||
fn systemctl(args: &[&str]) -> std::io::Result<std::process::Output> {
|
||||
Command::new("systemctl").args(args).output()
|
||||
}
|
||||
|
||||
fn is_active(unit: &str) -> &'static str {
|
||||
match systemctl(&["is-active", "--quiet", unit]) {
|
||||
Ok(out) if out.status.success() => "running",
|
||||
_ => "stopped",
|
||||
}
|
||||
}
|
||||
|
||||
fn main_pid(unit: &str) -> Option<u32> {
|
||||
let out = systemctl(&["show", unit, "--property=MainPID"]).ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let pid_str = text.trim().strip_prefix("MainPID=")?;
|
||||
pid_str.parse::<u32>().ok().filter(|&p| p != 0)
|
||||
}
|
||||
|
||||
fn active_enter_timestamp(unit: &str) -> Option<u64> {
|
||||
let out = systemctl(&["show", unit, "--property=ActiveEnterTimestampMonotonic"]).ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let ts_str = text.trim().strip_prefix("ActiveEnterTimestampMonotonic=")?;
|
||||
ts_str.parse::<u64>().ok()
|
||||
}
|
||||
|
||||
fn monotonic_now_usec() -> u64 {
|
||||
// /proc/uptime gives seconds since boot; multiply by 1_000_000 for µs
|
||||
std::fs::read_to_string("/proc/uptime")
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
s.split_whitespace()
|
||||
.next()
|
||||
.and_then(|n| n.parse::<f64>().ok())
|
||||
})
|
||||
.map(|secs| (secs * 1_000_000.0) as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn format_uptime(elapsed_sec: u64) -> String {
|
||||
let days = elapsed_sec / 86400;
|
||||
let hours = (elapsed_sec % 86400) / 3600;
|
||||
let mins = (elapsed_sec % 3600) / 60;
|
||||
if days > 0 {
|
||||
format!("{}d {}h", days, hours)
|
||||
} else if hours > 0 {
|
||||
format!("{}h {}m", hours, mins)
|
||||
} else {
|
||||
format!("{}m", mins)
|
||||
}
|
||||
}
|
||||
|
||||
fn service_info(unit: &str, display: &str) -> ServiceInfo {
|
||||
let status = is_active(unit);
|
||||
let pid = if status == "running" { main_pid(unit) } else { None };
|
||||
let uptime = if status == "running" {
|
||||
active_enter_timestamp(unit).map(|start| {
|
||||
let now = monotonic_now_usec();
|
||||
let secs = if now > start { (now - start) / 1_000_000 } else { 0 };
|
||||
format_uptime(secs)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
ServiceInfo {
|
||||
name: unit.to_string(),
|
||||
display: display.to_string(),
|
||||
status: status.to_string(),
|
||||
pid,
|
||||
uptime,
|
||||
}
|
||||
}
|
||||
|
||||
fn list_services() -> SectionResponse {
|
||||
let infos: Vec<ServiceInfo> = SERVICES
|
||||
.iter()
|
||||
.map(|(unit, display)| service_info(unit, display))
|
||||
.collect();
|
||||
SectionResponse::ok(infos)
|
||||
}
|
||||
|
||||
fn control_service(unit: &str, action: &str) -> SectionResponse {
|
||||
// Validate service name
|
||||
if !SERVICES.iter().any(|(u, _)| *u == unit) {
|
||||
return SectionResponse::err(format!("Unknown service: {}", unit));
|
||||
}
|
||||
// Validate action
|
||||
let allowed = ["start", "stop", "restart", "reload"];
|
||||
if !allowed.contains(&action) {
|
||||
return SectionResponse::err(format!("Unknown action: {}", action));
|
||||
}
|
||||
|
||||
match systemctl(&[action, unit]) {
|
||||
Ok(out) if out.status.success() => SectionResponse::ok(serde_json::json!({
|
||||
"message": format!("Service {} {}ed", unit, action)
|
||||
})),
|
||||
Ok(out) => {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
SectionResponse::err(format!("systemctl {} {} failed: {}", action, unit, stderr.trim()))
|
||||
}
|
||||
Err(e) => SectionResponse::err(format!("Failed to run systemctl: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let stdin = io::stdin();
|
||||
let mut line = String::new();
|
||||
stdin.lock().read_line(&mut line).unwrap_or(0);
|
||||
|
||||
let response = match serde_json::from_str::<SectionRequest>(line.trim()) {
|
||||
Err(e) => SectionResponse::err(format!("Invalid request JSON: {}", e)),
|
||||
Ok(req) => match req.action.as_str() {
|
||||
"list" => list_services(),
|
||||
"start" | "stop" | "restart" | "reload" => {
|
||||
let unit = req.params["service"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
if unit.is_empty() {
|
||||
SectionResponse::err("Missing 'service' param")
|
||||
} else {
|
||||
control_service(unit, &req.action)
|
||||
}
|
||||
}
|
||||
other => SectionResponse::err(format!("Unknown action: {}", other)),
|
||||
},
|
||||
};
|
||||
|
||||
println!("{}", serde_json::to_string(&response).unwrap());
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "nixpanel-sysinfo"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "nixpanel-sysinfo"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
nixpanel-common = { path = "../nixpanel-common" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,247 @@
|
||||
//! nixpanel-sysinfo — system information section binary
|
||||
//!
|
||||
//! Actions:
|
||||
//! stats → {hostname, ip, os, kernel, uptime_sec, cpu_pct, ram_used_mb,
|
||||
//! ram_total_mb, disk_used_gb, disk_total_gb, load_avg}
|
||||
//! versions → {apache, php, mariadb, nixpanel}
|
||||
|
||||
use nixpanel_common::{SectionRequest, SectionResponse};
|
||||
use std::fs;
|
||||
use std::io::{self, BufRead};
|
||||
use std::net::UdpSocket;
|
||||
use std::process::Command;
|
||||
|
||||
/* ── helpers ──────────────────────────────────────────────────────────── */
|
||||
|
||||
fn cmd(prog: &str, args: &[&str]) -> String {
|
||||
Command::new(prog)
|
||||
.args(args)
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn read_file(path: &str) -> String {
|
||||
fs::read_to_string(path).unwrap_or_default()
|
||||
}
|
||||
|
||||
/* ── hostname / IP ────────────────────────────────────────────────────── */
|
||||
|
||||
fn get_hostname() -> String {
|
||||
fs::read_to_string("/etc/hostname")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn get_ip() -> String {
|
||||
// Quick UDP trick — no actual packet sent
|
||||
UdpSocket::bind("0.0.0.0:0")
|
||||
.and_then(|s| {
|
||||
s.connect("8.8.8.8:80")?;
|
||||
s.local_addr()
|
||||
})
|
||||
.map(|a| a.ip().to_string())
|
||||
.unwrap_or_else(|_| "0.0.0.0".into())
|
||||
}
|
||||
|
||||
/* ── OS / kernel ──────────────────────────────────────────────────────── */
|
||||
|
||||
fn get_os() -> String {
|
||||
let content = read_file("/etc/os-release");
|
||||
for line in content.lines() {
|
||||
if let Some(val) = line.strip_prefix("PRETTY_NAME=") {
|
||||
return val.trim_matches('"').to_string();
|
||||
}
|
||||
}
|
||||
"Unknown".into()
|
||||
}
|
||||
|
||||
fn get_kernel() -> String {
|
||||
cmd("uname", &["-r"])
|
||||
}
|
||||
|
||||
/* ── uptime ───────────────────────────────────────────────────────────── */
|
||||
|
||||
fn get_uptime_sec() -> u64 {
|
||||
read_file("/proc/uptime")
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.map(|s| s as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/* ── CPU ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/// Reads /proc/stat twice (100ms apart) to compute a 1-sample CPU %.
|
||||
fn get_cpu_pct() -> f32 {
|
||||
fn read_stat() -> Option<(u64, u64)> {
|
||||
let content = fs::read_to_string("/proc/stat").ok()?;
|
||||
let line = content.lines().find(|l| l.starts_with("cpu "))?;
|
||||
let nums: Vec<u64> = line
|
||||
.split_whitespace()
|
||||
.skip(1)
|
||||
.filter_map(|n| n.parse().ok())
|
||||
.collect();
|
||||
if nums.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let idle = nums[3];
|
||||
let total: u64 = nums.iter().sum();
|
||||
Some((idle, total))
|
||||
}
|
||||
|
||||
let s1 = read_stat().unwrap_or((0, 1));
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
let s2 = read_stat().unwrap_or((0, 1));
|
||||
|
||||
let d_total = s2.1.saturating_sub(s1.1);
|
||||
let d_idle = s2.0.saturating_sub(s1.0);
|
||||
if d_total == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
((d_total - d_idle) as f32 / d_total as f32 * 100.0 * 10.0).round() / 10.0
|
||||
}
|
||||
|
||||
/* ── RAM ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
fn get_ram() -> (u64, u64) {
|
||||
// Returns (used_mb, total_mb)
|
||||
let content = read_file("/proc/meminfo");
|
||||
let mut total_kb: u64 = 0;
|
||||
let mut available_kb: u64 = 0;
|
||||
|
||||
for line in content.lines() {
|
||||
let mut parts = line.split_whitespace();
|
||||
match parts.next() {
|
||||
Some("MemTotal:") => { total_kb = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0); }
|
||||
Some("MemAvailable:") => { available_kb = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0); }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let used_mb = (total_kb.saturating_sub(available_kb)) / 1024;
|
||||
let total_mb = total_kb / 1024;
|
||||
(used_mb, total_mb)
|
||||
}
|
||||
|
||||
/* ── Disk ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
fn get_disk() -> (u64, u64) {
|
||||
// Returns (used_gb, total_gb) for /
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let stat = nix_statvfs("/");
|
||||
stat.unwrap_or((0, 0))
|
||||
}
|
||||
|
||||
fn nix_statvfs(path: &str) -> Option<(u64, u64)> {
|
||||
// Use df as a fallback — parse "df -B1 /"
|
||||
let out = Command::new("df")
|
||||
.args(&["-B1", path])
|
||||
.output()
|
||||
.ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let line = text.lines().nth(1)?; // skip header
|
||||
let mut parts = line.split_whitespace();
|
||||
let _fs = parts.next()?;
|
||||
let total = parts.next()?.parse::<u64>().ok()?;
|
||||
let used = parts.next()?.parse::<u64>().ok()?;
|
||||
Some((used / 1_073_741_824, total / 1_073_741_824))
|
||||
}
|
||||
|
||||
/* ── Load average ─────────────────────────────────────────────────────── */
|
||||
|
||||
fn get_load_avg() -> [f32; 3] {
|
||||
let content = read_file("/proc/loadavg");
|
||||
let mut parts = content.split_whitespace();
|
||||
let l1 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
|
||||
let l5 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
|
||||
let l15= parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
|
||||
[l1, l5, l15]
|
||||
}
|
||||
|
||||
/* ── Software versions ────────────────────────────────────────────────── */
|
||||
|
||||
fn apache_version() -> String {
|
||||
let out = cmd("apache2", &["-v"]);
|
||||
// "Server version: Apache/2.4.58 (Ubuntu)"
|
||||
out.lines()
|
||||
.find(|l| l.starts_with("Server version:"))
|
||||
.and_then(|l| l.split('/').nth(1))
|
||||
.and_then(|s| s.split_whitespace().next())
|
||||
.unwrap_or("—")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn php_version() -> String {
|
||||
cmd("php8.3", &["--version"])
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.unwrap_or("—")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn mariadb_version() -> String {
|
||||
let out = cmd("mariadb", &["--version"]);
|
||||
// "mariadb Ver 15.1 Distrib 10.11.x-MariaDB ..."
|
||||
out.split_whitespace()
|
||||
.enumerate()
|
||||
.find(|(_, w)| w.contains("MariaDB") || w.contains("Distrib"))
|
||||
.map(|(i, _)| {
|
||||
out.split_whitespace()
|
||||
.nth(i.saturating_sub(1))
|
||||
.unwrap_or("—")
|
||||
.trim_end_matches(',')
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|| "—".to_string())
|
||||
}
|
||||
|
||||
/* ── Actions ──────────────────────────────────────────────────────────── */
|
||||
|
||||
fn action_stats() -> SectionResponse {
|
||||
let (ram_used, ram_total) = get_ram();
|
||||
let (disk_used, disk_total) = get_disk();
|
||||
SectionResponse::ok(serde_json::json!({
|
||||
"hostname": get_hostname(),
|
||||
"ip": get_ip(),
|
||||
"os": get_os(),
|
||||
"kernel": get_kernel(),
|
||||
"uptime_sec": get_uptime_sec(),
|
||||
"cpu_pct": get_cpu_pct(),
|
||||
"ram_used_mb": ram_used,
|
||||
"ram_total_mb": ram_total,
|
||||
"disk_used_gb": disk_used,
|
||||
"disk_total_gb": disk_total,
|
||||
"load_avg": get_load_avg(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn action_versions() -> SectionResponse {
|
||||
SectionResponse::ok(serde_json::json!({
|
||||
"apache": apache_version(),
|
||||
"php": php_version(),
|
||||
"mariadb": mariadb_version(),
|
||||
"nixpanel": env!("CARGO_PKG_VERSION"),
|
||||
}))
|
||||
}
|
||||
|
||||
/* ── Entry point ──────────────────────────────────────────────────────── */
|
||||
|
||||
fn main() {
|
||||
let stdin = io::stdin();
|
||||
let mut line = String::new();
|
||||
stdin.lock().read_line(&mut line).unwrap_or(0);
|
||||
|
||||
let response = match serde_json::from_str::<SectionRequest>(line.trim()) {
|
||||
Err(e) => SectionResponse::err(format!("Invalid JSON: {}", e)),
|
||||
Ok(req) => match req.action.as_str() {
|
||||
"stats" => action_stats(),
|
||||
"versions" => action_versions(),
|
||||
other => SectionResponse::err(format!("Unknown action: {}", other)),
|
||||
},
|
||||
};
|
||||
|
||||
println!("{}", serde_json::to_string(&response).unwrap());
|
||||
}
|
||||
+366
-327
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
/* NixServer API client — all calls go to /api/ (proxied to Rust backend) */
|
||||
|
||||
const BASE = '/api'
|
||||
|
||||
/* ── Auth token ──────────────────────────────────────────────────────── */
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem('nixpanel_token')
|
||||
}
|
||||
export function setToken(t: string) {
|
||||
localStorage.setItem('nixpanel_token', t)
|
||||
}
|
||||
export function clearToken() {
|
||||
localStorage.removeItem('nixpanel_token')
|
||||
}
|
||||
export function isLoggedIn(): boolean {
|
||||
return !!getToken()
|
||||
}
|
||||
|
||||
/* ── Core fetch wrapper ──────────────────────────────────────────────── */
|
||||
async function apiFetch<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = getToken()
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
})
|
||||
if (res.status === 401) {
|
||||
clearToken()
|
||||
window.location.reload()
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/* ── Auth ────────────────────────────────────────────────────────────── */
|
||||
export interface LoginResult {
|
||||
token: string
|
||||
user: { id: number; username: string; role: string }
|
||||
}
|
||||
export async function login(username: string, password: string): Promise<LoginResult> {
|
||||
const result = await apiFetch<LoginResult>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
setToken(result.token)
|
||||
return result
|
||||
}
|
||||
|
||||
/* ── Dashboard ───────────────────────────────────────────────────────── */
|
||||
export interface DashboardStats {
|
||||
accounts: number
|
||||
domains: number
|
||||
databases: number
|
||||
email_accounts: number
|
||||
}
|
||||
export interface SysInfo {
|
||||
hostname: string
|
||||
ip: string
|
||||
os: string
|
||||
kernel: string
|
||||
uptime_sec: number
|
||||
cpu_pct: number
|
||||
ram_used_mb: number
|
||||
ram_total_mb: number
|
||||
disk_used_gb: number
|
||||
disk_total_gb: number
|
||||
load_avg: [number, number, number]
|
||||
}
|
||||
export interface Versions {
|
||||
apache: string
|
||||
php: string
|
||||
mariadb: string
|
||||
nixpanel: string
|
||||
}
|
||||
export interface DashboardData {
|
||||
stats: DashboardStats
|
||||
sysinfo: SysInfo | null
|
||||
versions: Versions | null
|
||||
}
|
||||
export async function getDashboard(): Promise<DashboardData> {
|
||||
return apiFetch('/dashboard')
|
||||
}
|
||||
|
||||
/* ── Services ────────────────────────────────────────────────────────── */
|
||||
export interface ServiceInfo {
|
||||
name: string
|
||||
display: string
|
||||
status: 'running' | 'stopped' | 'warning'
|
||||
pid: number | null
|
||||
uptime: string | null
|
||||
}
|
||||
export async function getServices(): Promise<ServiceInfo[]> {
|
||||
return apiFetch('/services')
|
||||
}
|
||||
export async function serviceAction(
|
||||
name: string,
|
||||
action: 'start' | 'stop' | 'restart'
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return apiFetch(`/services/${name}/${action}`, { method: 'POST' })
|
||||
}
|
||||
|
||||
/* ── Accounts ────────────────────────────────────────────────────────── */
|
||||
export interface Account {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
email: string
|
||||
package_name: string
|
||||
disk_quota_mb: number
|
||||
bandwidth_mb: number
|
||||
status: 'active' | 'suspended' | 'terminated'
|
||||
created_at: string
|
||||
}
|
||||
export async function getAccounts(): Promise<Account[]> {
|
||||
return apiFetch('/accounts')
|
||||
}
|
||||
export async function createAccount(data: {
|
||||
username: string
|
||||
domain: string
|
||||
email: string
|
||||
password: string
|
||||
package_name?: string
|
||||
disk_quota_mb?: number
|
||||
bandwidth_mb?: number
|
||||
}): Promise<Account> {
|
||||
return apiFetch('/accounts', { method: 'POST', body: JSON.stringify(data) })
|
||||
}
|
||||
@@ -11,5 +11,11 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:4000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user