diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b11a2fd --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/install.sh b/install.sh index ff4e38f..c2698a1 100644 --- a/install.sh +++ b/install.sh @@ -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." diff --git a/nixclient/src/api.ts b/nixclient/src/api.ts new file mode 100644 index 0000000..beac6a2 --- /dev/null +++ b/nixclient/src/api.ts @@ -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(path: string, options: RequestInit = {}): Promise { + 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 { + const result = await apiFetch('/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 { + try { + // Returns the first account matching the logged-in user's username + const accounts = await apiFetch('/accounts') + return accounts[0] ?? null + } catch { + return null + } +} diff --git a/nixclient/vite.config.ts b/nixclient/vite.config.ts index 0a42598..324d52c 100644 --- a/nixclient/vite.config.ts +++ b/nixclient/vite.config.ts @@ -11,5 +11,11 @@ export default defineConfig({ server: { port: 3001, strictPort: true, + proxy: { + '/api': { + target: 'http://localhost:4000', + changeOrigin: true, + }, + }, }, }) diff --git a/nixpanel-api/Cargo.toml b/nixpanel-api/Cargo.toml new file mode 100644 index 0000000..e09434c --- /dev/null +++ b/nixpanel-api/Cargo.toml @@ -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" diff --git a/nixpanel-api/migrations/0001_initial.sql b/nixpanel-api/migrations/0001_initial.sql new file mode 100644 index 0000000..6990697 --- /dev/null +++ b/nixpanel-api/migrations/0001_initial.sql @@ -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 diff --git a/nixpanel-api/src/auth.rs b/nixpanel-api/src/auth.rs new file mode 100644 index 0000000..00bf972 --- /dev/null +++ b/nixpanel-api/src/auth.rs @@ -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 { + 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 { + let data = decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &Validation::new(Algorithm::HS256), + )?; + Ok(data.claims) + } +} + +/* ── Extractor: pulls Claims from Authorization: Bearer ──────── */ + +#[async_trait] +impl FromRequestParts for Claims { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + 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) + } +} diff --git a/nixpanel-api/src/config.rs b/nixpanel-api/src/config.rs new file mode 100644 index 0000000..6a1f27d --- /dev/null +++ b/nixpanel-api/src/config.rs @@ -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 { + 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()), + }) + } +} diff --git a/nixpanel-api/src/error.rs b/nixpanel-api/src/error.rs new file mode 100644 index 0000000..b723ca7 --- /dev/null +++ b/nixpanel-api/src/error.rs @@ -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 for AppError { + fn from(e: sqlx::Error) -> Self { + AppError::Internal(anyhow::anyhow!(e)) + } +} + +impl From for AppError { + fn from(e: jsonwebtoken::errors::Error) -> Self { + AppError::Unauthorized + } +} diff --git a/nixpanel-api/src/main.rs b/nixpanel-api/src/main.rs new file mode 100644 index 0000000..07a8717 --- /dev/null +++ b/nixpanel-api/src/main.rs @@ -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(()) +} diff --git a/nixpanel-api/src/routes/accounts.rs b/nixpanel-api/src/routes/accounts.rs new file mode 100644 index 0000000..4ee94fe --- /dev/null +++ b/nixpanel-api/src/routes/accounts.rs @@ -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, + pub disk_quota_mb: Option, + pub bandwidth_mb: Option, +} + +pub async fn list_accounts( + claims: Claims, + State(state): State, +) -> Result>, 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, + Json(body): Json, +) -> Result, 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)) +} diff --git a/nixpanel-api/src/routes/auth.rs b/nixpanel-api/src/routes/auth.rs new file mode 100644 index 0000000..4921634 --- /dev/null +++ b/nixpanel-api/src/routes/auth.rs @@ -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, + Json(body): Json, +) -> Result, 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, + }, + })) +} diff --git a/nixpanel-api/src/routes/dashboard.rs b/nixpanel-api/src/routes/dashboard.rs new file mode 100644 index 0000000..a744211 --- /dev/null +++ b/nixpanel-api/src/routes/dashboard.rs @@ -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, +) -> Result, 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), + })) +} diff --git a/nixpanel-api/src/routes/mod.rs b/nixpanel-api/src/routes/mod.rs new file mode 100644 index 0000000..072e13d --- /dev/null +++ b/nixpanel-api/src/routes/mod.rs @@ -0,0 +1,4 @@ +pub mod auth; +pub mod accounts; +pub mod dashboard; +pub mod services; diff --git a/nixpanel-api/src/routes/services.rs b/nixpanel-api/src/routes/services.rs new file mode 100644 index 0000000..a5d8e6e --- /dev/null +++ b/nixpanel-api/src/routes/services.rs @@ -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, +) -> Result, 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, + Path((name, action)): Path<(String, String)>, +) -> Result, 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()))) + } +} diff --git a/nixpanel-api/src/section.rs b/nixpanel-api/src/section.rs new file mode 100644 index 0000000..7f29cdc --- /dev/null +++ b/nixpanel-api/src/section.rs @@ -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 +{ + 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) +} diff --git a/nixpanel-api/src/state.rs b/nixpanel-api/src/state.rs new file mode 100644 index 0000000..35103b6 --- /dev/null +++ b/nixpanel-api/src/state.rs @@ -0,0 +1,8 @@ +use std::sync::Arc; +use crate::config::Config; + +#[derive(Clone)] +pub struct AppState { + pub db: sqlx::MySqlPool, + pub config: Arc, +} diff --git a/nixpanel-common/Cargo.toml b/nixpanel-common/Cargo.toml new file mode 100644 index 0000000..b295aad --- /dev/null +++ b/nixpanel-common/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "nixpanel-common" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/nixpanel-common/src/lib.rs b/nixpanel-common/src/lib.rs new file mode 100644 index 0000000..a5a725a --- /dev/null +++ b/nixpanel-common/src/lib.rs @@ -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(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl SectionResponse { + pub fn ok(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, + pub uptime: Option, +} diff --git a/nixpanel-services/Cargo.toml b/nixpanel-services/Cargo.toml new file mode 100644 index 0000000..369ade0 --- /dev/null +++ b/nixpanel-services/Cargo.toml @@ -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" diff --git a/nixpanel-services/src/main.rs b/nixpanel-services/src/main.rs new file mode 100644 index 0000000..38f8bba --- /dev/null +++ b/nixpanel-services/src/main.rs @@ -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 { + 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 { + 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::().ok().filter(|&p| p != 0) +} + +fn active_enter_timestamp(unit: &str) -> Option { + 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::().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::().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 = 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::(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()); +} diff --git a/nixpanel-sysinfo/Cargo.toml b/nixpanel-sysinfo/Cargo.toml new file mode 100644 index 0000000..039d665 --- /dev/null +++ b/nixpanel-sysinfo/Cargo.toml @@ -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" diff --git a/nixpanel-sysinfo/src/main.rs b/nixpanel-sysinfo/src/main.rs new file mode 100644 index 0000000..59503da --- /dev/null +++ b/nixpanel-sysinfo/src/main.rs @@ -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::().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 = 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::().ok()?; + let used = parts.next()?.parse::().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::(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()); +} diff --git a/nixserver/src/App.tsx b/nixserver/src/App.tsx index 092d7f9..f095751 100644 --- a/nixserver/src/App.tsx +++ b/nixserver/src/App.tsx @@ -1,4 +1,6 @@ -import { useState } from 'react' +import { useState, useEffect, useCallback } from 'react' +import * as api from './api' +import type { DashboardData, ServiceInfo, Account } from './api' /* ── Types ──────────────────────────────────────────────────────────────── */ type Page = @@ -8,77 +10,43 @@ type Page = | 'apache' | 'php' | 'services' | 'settings' | 'logs' | 'packages' -/* ── Sidebar nav definition ─────────────────────────────────────────────── */ +/* ── Nav ─────────────────────────────────────────────────────────────────── */ type NavGroup = { label: string; items: { id: Page; label: string; icon: string }[] } const NAV_GROUPS: NavGroup[] = [ - { - label: 'Overview', - items: [ - { id: 'dashboard', label: 'Dashboard', icon: '▦' }, - ], - }, - { - label: 'Account Management', - items: [ - { id: 'listAccounts', label: 'List Accounts', icon: '📋' }, - { id: 'createAccount',label: 'Create Account', icon: '➕' }, - { id: 'packages', label: 'Feature Packages', icon: '📦' }, - ], - }, - { - label: 'Domains & DNS', - items: [ - { id: 'domains', label: 'Zone Manager', icon: '🌐' }, - { id: 'dns', label: 'DNS Records', icon: '🔗' }, - ], - }, - { - label: 'Databases', - items: [ - { id: 'databases', label: 'MySQL / MariaDB', icon: '🗄️' }, - ], - }, - { - label: 'Email', - items: [ - { id: 'email', label: 'Email Routing', icon: '✉️' }, - ], - }, - { - label: 'Security', - items: [ - { id: 'ssl', label: 'SSL / TLS', icon: '🔒' }, - { id: 'firewall', label: 'Firewall (CSF)', icon: '🛡️' }, - ], - }, - { - label: 'Server', - items: [ - { id: 'apache', label: 'Apache Config', icon: '⚙️' }, - { id: 'php', label: 'PHP Manager', icon: '🐘' }, - { id: 'services', label: 'Service Manager', icon: '🔧' }, - { id: 'backups', label: 'Backup Manager', icon: '💾' }, - { id: 'logs', label: 'System Logs', icon: '📜' }, - { id: 'settings', label: 'Server Config', icon: '🔩' }, - ], - }, + { label: 'Overview', items: [{ id: 'dashboard', label: 'Dashboard', icon: '▦' }] }, + { label: 'Account Management',items: [ + { id: 'listAccounts', label: 'List Accounts', icon: '📋' }, + { id: 'createAccount',label: 'Create Account', icon: '➕' }, + { id: 'packages', label: 'Feature Packages', icon: '📦' }, + ]}, + { label: 'Domains & DNS', items: [ + { id: 'domains', label: 'Zone Manager', icon: '🌐' }, + { id: 'dns', label: 'DNS Records', icon: '🔗' }, + ]}, + { label: 'Databases', items: [{ id: 'databases', label: 'MySQL / MariaDB', icon: '🗄️' }] }, + { label: 'Email', items: [{ id: 'email', label: 'Email Routing', icon: '✉️' }] }, + { label: 'Security', items: [ + { id: 'ssl', label: 'SSL / TLS', icon: '🔒' }, + { id: 'firewall', label: 'Firewall (CSF)', icon: '🛡️' }, + ]}, + { label: 'Server', items: [ + { id: 'apache', label: 'Apache Config', icon: '⚙️' }, + { id: 'php', label: 'PHP Manager', icon: '🐘' }, + { id: 'services', label: 'Service Manager', icon: '🔧' }, + { id: 'backups', label: 'Backup Manager', icon: '💾' }, + { id: 'logs', label: 'System Logs', icon: '📜' }, + { id: 'settings', label: 'Server Config', icon: '🔩' }, + ]}, ] /* ── Sidebar ────────────────────────────────────────────────────────────── */ -function Sidebar({ - current, onNav, onLogout, collapsed, onToggle, -}: { +function Sidebar({ current, onNav, onLogout, collapsed, onToggle, username }: { current: Page; onNav: (p: Page) => void; onLogout: () => void - collapsed: boolean; onToggle: () => void + collapsed: boolean; onToggle: () => void; username: string }) { return ( -