Files
nixpanel/nixpanel-api/src/main.rs
T
NixReaper 079170b43a feat: v0.3.0-alpha — DNS management, email accounts, suspend/terminate, password change
Backend:
- routes/dns.rs: GET/POST/DELETE /api/dns/:domain (pdnsutil CRUD, SOA hidden, auto-rectify)
- routes/email.rs: GET/POST/DELETE email accounts with Maildir provisioning
- routes/me.rs: POST /api/me/password (verify current, hash new, update users+accounts)
- routes/accounts.rs: GET /api/accounts/:username, suspend, unsuspend, terminate (deprovision+userdel)
- main.rs: register all new routes; import axum::routing::delete
- Cargo.toml: version bump 0.2.0-alpha -> 0.3.0-alpha

NixServer:
- Zone Manager page: lists all PowerDNS zones, click -> DNS editor
- DNS Records page: add/delete records, colour-coded type badges, zone switcher
- Service Manager page: dedicated page with refresh + running/stopped pills
- List Accounts: Suspend/Unsuspend/Terminate buttons with confirmation modal
- Domain cell links to live site

NixClient:
- Domains page: primary domain info, document root, quota, bandwidth
- Email Accounts page: create/list/delete mailboxes with quota display
- Change Password page: Argon2id verify + update server-side
- AccountSidebar: fetches real account data (domain, package, quota)
- api.ts: listMyEmailAccounts, createEmailAccount, deleteEmailAccount, changePassword, getMyAccount(username)

CHANGELOG.md: v0.3.0-alpha entry
2026-04-26 05:23:55 -05:00

182 lines
6.1 KiB
Rust

mod auth;
mod config;
mod error;
mod routes;
mod section;
mod state;
use std::sync::Arc;
use std::net::SocketAddr;
use axum::{
routing::{delete, 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 — list / create
.route("/api/accounts",
get(routes::accounts::list_accounts)
.post(routes::accounts::create_account))
// Accounts — per-account actions
.route("/api/accounts/:username",
get(routes::accounts::get_account)
.delete(routes::accounts::terminate_account))
.route("/api/accounts/:username/suspend",
post(routes::accounts::suspend_account))
.route("/api/accounts/:username/unsuspend",
post(routes::accounts::unsuspend_account))
// DNS
.route("/api/dns", get(routes::dns::list_zones))
.route("/api/dns/:domain",
get(routes::dns::list_zone_records)
.post(routes::dns::add_record)
.delete(routes::dns::delete_record))
// Email accounts
.route("/api/email", post(routes::email::create_email_account))
.route("/api/email/:username", get(routes::email::list_email_accounts))
.route("/api/email/id/:id", delete(routes::email::delete_email_account))
// Profile / password
.route("/api/me/password", post(routes::me::change_password))
.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(())
}