Compare commits

..
10 Commits
Author SHA1 Message Date
NixReaper 2ac1adcdbb feat: v0.4.0-alpha — MySQL/MariaDB database manager
Security Audit / Rust Security Audit (push) Canceled after 0s
Security Audit / ShellCheck (push) Canceled after 0s
Backend:
- routes/databases.rs: full CRUD for MariaDB databases + users
  - mysql -u root via stdin pipe (unix socket auth, API runs as root)
  - cPanel-style naming: username_dbname / username_dbuser
  - Identifier validation (alphanumeric + underscore only)
  - Password escaping for mysql string literals
  - Safety guard: refuses to drop DBs without account prefix
  - GET /api/databases (admin all), GET /api/databases/:username (per-account)
  - POST /api/databases (create DB + user + GRANT ALL + nixpanel record)
  - DELETE /api/databases/name/:db_name (drop DB + user + nixpanel record)
- routes/mod.rs: add databases module
- main.rs: register database routes

NixServer:
- DatabaseManager page: table across all accounts, create form with
  account selector + live name preview, drop with confirmation modal

NixClient:
- MySQLDatabases page: per-account view, create form with password
  confirm + connection-details card, drop with confirmation modal
- api.ts: listMyDatabases, createDatabase, deleteDatabase

CHANGELOG.md: v0.4.0-alpha entry
Cargo.toml: version bump 0.3.0-alpha -> 0.4.0-alpha
2026-04-26 06:01:23 -05:00
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
NixReaper e79a8a996a fix: always restart nixpanel service after binary rebuild in install.sh 2026-04-26 04:54:08 -05:00
NixReaper 2eb8198267 fix: stop nixpanel service before overwriting binary to avoid text file busy error on reinstall 2026-04-25 21:38:56 -05:00
NixReaper 389652b2e5 feat: real account provisioner, fix NixClient auth bypass
- nixpanel-accounts: creates Apache vhost, PHP-FPM pool, PowerDNS
  zone (A/MX/SPF records), home dir structure, default index.html
- nixpanel-api: create_account now calls provisioner after DB insert
- NixClient: replace fake setTimeout login with real JWT API call;
  admin accounts blocked with redirect message; logout clears token
- CHANGELOG updated
2026-04-25 21:32:32 -05:00
NixReaper cb6be7c634 fix: move FormField outside CreateAccount to prevent input focus loss on keystroke 2026-04-25 21:21:45 -05:00
NixReaper 09b043d764 fix: change BIGINT UNSIGNED to BIGINT, INT columns map to i32 not i64 2026-04-25 20:59:41 -05:00
NixReaper 144d08a581 fix: skip UPX .d metadata files when copying section binaries 2026-04-25 17:15:34 -05:00
NixReaper 7724c86572 chore: bump version to 0.2.0-alpha, update CHANGELOG 2026-04-25 16:58:27 -05:00
NixReaper d3b27bb90c fix: replace sqlx macros with non-macro queries, drop axum_extra dep, fix unused imports 2026-04-25 16:48:14 -05:00
28 changed files with 2951 additions and 616 deletions
+126 -18
View File
@@ -9,16 +9,125 @@ Versions follow [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Planned
- NixServer (WHM) — full Rust backend
- NixClient (cPanel) — full Rust backend
- DNS zone editor
- Email account management
- SSL certificate management via Let's Encrypt
- SSL certificate management (Let's Encrypt via Certbot)
- File manager
- MySQL/MariaDB database manager
- FTP account management (vsftpd)
- WordPress one-click installer
- Backup / restore system
- Firewall rule editor (CSF/iptables)
- Multi-server support (Agency plan)
- NixClient pages: FTP, subdomains, redirects, PHP version selector, stats
---
## [0.4.0-alpha] — 2026-04-26
### Added
- **MySQL / MariaDB database manager**
- `GET /api/databases` — list all databases across all accounts (admin)
- `GET /api/databases/:username` — list databases for one hosting account
- `POST /api/databases` — create database + dedicated MariaDB user with GRANT ALL
- `DELETE /api/databases/name/:db_name` — drop database + user, cascade nixpanel record
- cPanel-style namespacing: `username_dbname` / `username_dbuser` (max 64/32 chars)
- Identifier validation (alphanumeric + underscore only)
- Live connection-details preview in the create form (database, username, host, port)
- Safety check: refuses to drop databases without the account prefix separator
- **NixServer — Databases page**: admin view across all accounts, create form with live preview, drop with confirmation modal
- **NixClient — MySQL Databases page**: per-account view, create with password confirm + connection-info card, drop with confirmation
### Changed
- NixServer/NixClient footer version: `v0.3.0-alpha` → `v0.4.0-alpha`
---
## [0.3.0-alpha] — 2026-04-25
### Added
- **DNS Management** — full CRUD via pdnsutil
- `GET /api/dns` — list all PowerDNS zones
- `GET /api/dns/:domain` — list records (A, AAAA, MX, TXT, CNAME, NS, SRV, CAA)
- `POST /api/dns/:domain` — add record (name, type, TTL, content)
- `DELETE /api/dns/:domain` — delete an RRset (name + type)
- SOA records hidden; zone auto-rectified after each change
- **NixServer — Zone Manager page** (`domains`): lists all PowerDNS zones, click → DNS editor
- **NixServer — DNS Records page** (`dns`): record table with inline add/delete, zone switcher dropdown, colour-coded type badges
- **NixServer — Service Manager page** (`services`): dedicated page with refresh button and running/stopped summary pills
- **NixServer — Account actions**: Suspend / Unsuspend / Terminate buttons with confirmation modal
- `POST /api/accounts/:username/suspend` / `unsuspend`
- `DELETE /api/accounts/:username` — deprovisions vhost, FPM, DNS; removes system user; cascades DB records
- **Email account management**
- `GET /api/email/:username` — list email accounts for a hosting account
- `POST /api/email` — create mailbox (Maildir provisioned under `~/mail/`)
- `DELETE /api/email/id/:id` — delete email account
- **NixClient — Email Accounts page** — create, list, delete mailboxes with quota display
- **NixClient — Domains page** — shows primary domain, document root, quota, bandwidth
- **NixClient — Change Password page** — verifies current password, hashes and updates both `users` and `accounts` tables
- **NixClient — AccountSidebar** now fetches real account data (domain, package, quota) from `GET /api/accounts/:username`
- **Password change API** — `POST /api/me/password` (verifies old password via Argon2id, updates `users` + `accounts`)
- **Single account lookup** — `GET /api/accounts/:username`
### Changed
- NixServer footer / login version bump: `v0.2.0-alpha` → `v0.3.0-alpha`
- NixClient footer / login version bump: `v0.2.0-alpha` → `v0.3.0-alpha`
- List Accounts: domain cell links to site, suspend/terminate replace placeholder Edit button
---
## [0.2.0-alpha] — 2026-04-25
### Added
- **Rust backend** — Cargo workspace with independently compiled + UPX-compressed section executables
- `nixpanel-common` — shared `SectionRequest` / `SectionResponse` IPC types
- `nixpanel-api` — main Axum HTTP server on port 4000
- `POST /api/auth/login` — Argon2id password verification, HS256 JWT (8-hour expiry)
- `GET /api/dashboard` — real account/domain/database/email counts + system stats
- `GET /api/services` — live service status via systemctl
- `POST /api/services/:name/:action` — start / stop / restart any managed service
- `GET /api/accounts` — list hosting accounts from MariaDB
- `POST /api/accounts` — create account (system user, home dir, DB record)
- Auto-runs DB schema migrations on first start
- Seeds admin user from `/opt/nixpanel/.admin_user` + `.admin_pass` on first run
- `nixpanel-services` — section binary; reads JSON from stdin, calls systemctl, writes JSON to stdout
- Actions: `list`, `start`, `stop`, `restart`, `reload`
- Reports PID and uptime per service
- `nixpanel-sysinfo` — section binary; reads `/proc/meminfo`, `/proc/stat`, `/proc/loadavg`, `df`
- Actions: `stats` (CPU, RAM, disk, load, hostname, IP, OS, kernel), `versions` (Apache, PHP, MariaDB)
- **Database schema** (`nixpanel-api/migrations/0001_initial.sql`)
- Tables: `users`, `accounts`, `domains`, `dns_records`, `account_databases`,
`email_accounts`, `ftp_accounts`, `ssl_certificates`, `backups`
- **Frontend API clients** (`nixserver/src/api.ts`, `nixclient/src/api.ts`)
- JWT stored in `localStorage`, included in all requests as `Authorization: Bearer`
- Auto-clears token and reloads on 401
### Changed
- **NixServer UI** — full redesign matching WHM visual style
- Dark navy / orange-accent theme
- Collapsible sidebar with grouped navigation sections
- Dashboard: live stat bar, server info bar, service manager table, quick-action icon grid
- List Accounts: real data table with search/filter
- Create Account: full form wired to API (creates system user, DB record)
- Login: real JWT auth; session persists across refresh via `localStorage`
- **NixClient UI** — full redesign matching cPanel visual style
- Blue-accent theme
- Feature icon grid grouped by category with live search
- Account sidebar with usage bars
- Login: real JWT auth
- **Installer** (`install.sh`)
- Rust build step now copies all `nixpanel-*` section binaries to `/opt/nixpanel/bin/`
- Installs UPX and compresses each binary after build
- Admin credentials auto-generated with `openssl rand` (no TTY prompts)
- Node.js 20 LTS installed as its own idempotent step (`nodejs.done`)
- NixClient build uses absolute path to avoid wrong-directory check
- npm output visible during build (removed `--silent`) for easier debugging
- **Vite configs** — added `/api` proxy to `localhost:4000` for local development
- **Minifier** — switched both UIs from `terser` (not installed) to `esbuild`
### Fixed
- Login inputs changed from `type="email"` to `type="text"` with "Username" label
- `sqlx::query!()` macros replaced with non-macro `query()` / `query_as()` / `query_scalar()`
so the binary compiles without a live database connection
- Removed `axum_extra` import that was never added to `Cargo.toml`
- Git clone always removes stale `$SRC_DIR` before cloning (fixes "couldn't find remote ref master" on re-run)
---
@@ -28,22 +137,21 @@ Versions follow [Semantic Versioning](https://semver.org/).
- **Installer** (`install.sh`) — fully automated Ubuntu 24.04 installer
- Self-updating: always fetches the latest version from GitHub before running
- Idempotent step tracking — re-runnable after failures without redoing completed steps
- Installs Apache 2, PHP 8.2 + 8.3 (via ondrej/php PPA), MariaDB, PowerDNS, Exim 4, Dovecot, SpamAssassin, Fail2ban, Certbot
- Installs Apache 2, PHP 8.2 + 8.3 (via ondrej/php PPA), MariaDB, PowerDNS,
Exim 4, Dovecot, SpamAssassin, Fail2ban, Certbot
- Configures Apache reverse proxy on ports 2083 (NixClient) and 2087 (NixServer)
- Prompts for admin credentials, hostname, and license key during install
- License activation against `https://license.nixpanel.io` with grace period fallback
- Generates admin credentials automatically; displays them in the completion banner
- License activation against `https://license.nixpanel.io`
- **NixServer** — WHM-style admin panel (React 18 + TypeScript + Tailwind + Vite, port 2087)
- **NixClient** — cPanel-style user panel (React 18 + TypeScript + Tailwind + Vite, port 2083)
- **Licensing** — license key validated against `https://license.nixpanel.io` during install
- Plans: Free, Solo ($9.99/mo), Host ($29.99/mo), Agency ($79.99/mo)
- Server IP binding with grace period fallback
- **nixpanel.io** — customer portal at [nixpanel.io](https://nixpanel.io)
- Stripe subscriptions with monthly and annual billing
- Customer auth: register, login, email verification, password reset
- License management and IP change requests
### Infrastructure
- `.gitattributes` enforcing LF line endings for `.sh`, `.rs`, `.toml`, `.sql`, `.md` files
- **Infrastructure** — `.gitattributes` enforcing LF line endings
---
[Unreleased]: https://github.com/NixReaper/nixpanel/compare/v0.1.0-alpha...HEAD
[Unreleased]: https://github.com/NixReaper/nixpanel/compare/v0.4.0-alpha...HEAD
[0.4.0-alpha]: https://github.com/NixReaper/nixpanel/compare/v0.3.0-alpha...v0.4.0-alpha
[0.3.0-alpha]: https://github.com/NixReaper/nixpanel/compare/v0.2.0-alpha...v0.3.0-alpha
[0.2.0-alpha]: https://github.com/NixReaper/nixpanel/compare/v0.1.0-alpha...v0.2.0-alpha
[0.1.0-alpha]: https://github.com/NixReaper/nixpanel/releases/tag/v0.1.0-alpha
+1
View File
@@ -3,6 +3,7 @@ resolver = "2"
members = [
"nixpanel-common",
"nixpanel-api",
"nixpanel-accounts",
"nixpanel-services",
"nixpanel-sysinfo",
]
+11 -1
View File
@@ -252,8 +252,9 @@ if ! step_done "build_panel"; then
apt-get install -y -qq upx-ucl 2>/dev/null || true
fi
# Main API binary
# Main API binary — stop service first so the file isn't busy
if [[ -f "target/release/nixpanel" ]]; then
systemctl stop nixpanel 2>/dev/null || true
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
@@ -264,15 +265,24 @@ if ! step_done "build_panel"; then
mkdir -p "$BIN_DIR"
for section_bin in target/release/nixpanel-*; do
[[ -f "$section_bin" ]] || continue
# Skip debug/metadata files produced by UPX (.d files etc.)
[[ "$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
# Remove any UPX metadata files
rm -f "$BIN_DIR"/*.d
mark_done "build_panel"
success "Panel binaries built and compressed"
# Always restart the service after a binary update
if systemctl is-enabled nixpanel &>/dev/null; then
systemctl restart nixpanel 2>/dev/null || true
fi
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."
+589 -204
View File
File diff suppressed because it is too large Load Diff
+66 -4
View File
@@ -37,6 +37,7 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
return res.json()
}
/* ── Auth ────────────────────────────────────────────────────────────── */
export interface LoginResult {
token: string
user: { id: number; username: string; role: string }
@@ -50,7 +51,9 @@ export async function login(username: string, password: string): Promise<LoginRe
return result
}
/* ── Account info ────────────────────────────────────────────────────── */
export interface AccountInfo {
id: number
username: string
domain: string
email: string
@@ -58,13 +61,72 @@ export interface AccountInfo {
disk_quota_mb: number
bandwidth_mb: number
status: string
created_at: string
}
export async function getMyAccount(): Promise<AccountInfo | null> {
export async function getMyAccount(username: string): 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
return await apiFetch<AccountInfo>(`/accounts/${username}`)
} catch {
return null
}
}
/* ── Email accounts ──────────────────────────────────────────────────── */
export interface EmailAccount {
id: number
account_id: number
address: string
quota_mb: number
created_at: string
}
export async function listMyEmailAccounts(username: string): Promise<EmailAccount[]> {
return apiFetch(`/email/${username}`)
}
export async function createEmailAccount(data: {
username: string
domain: string
password: string
quota_mb?: number
}): Promise<EmailAccount> {
return apiFetch('/email', { method: 'POST', body: JSON.stringify(data) })
}
export async function deleteEmailAccount(id: number): Promise<void> {
await apiFetch(`/email/id/${id}`, { method: 'DELETE' })
}
/* ── Databases ───────────────────────────────────────────────────────── */
export interface Database {
id: number
account_id: number
db_name: string
db_user: string
created_at: string
}
export async function listMyDatabases(username: string): Promise<Database[]> {
return apiFetch(`/databases/${username}`)
}
export async function createDatabase(data: {
username: string
db_suffix: string
db_user_suffix?: string
db_password: string
}): Promise<Database> {
return apiFetch('/databases', { method: 'POST', body: JSON.stringify(data) })
}
export async function deleteDatabase(dbName: string): Promise<void> {
await apiFetch(`/databases/name/${encodeURIComponent(dbName)}`, { method: 'DELETE' })
}
/* ── Password change ─────────────────────────────────────────────────── */
export async function changePassword(
currentPassword: string,
newPassword: string
): Promise<void> {
await apiFetch('/me/password', {
method: 'POST',
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword,
}),
})
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "nixpanel-accounts"
version = "0.2.0-alpha"
edition = "2021"
[[bin]]
name = "nixpanel-accounts"
path = "src/main.rs"
[dependencies]
nixpanel-common = { path = "../nixpanel-common" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+322
View File
@@ -0,0 +1,322 @@
//! nixpanel-accounts — account provisioning section binary
//!
//! Actions:
//! provision {username, domain, email, server_ip, php_version?}
//! → creates vhost, PHP-FPM pool, DNS zone, home directory structure
//!
//! deprovision {username, domain}
//! → removes vhost, PHP-FPM pool, DNS zone, optionally home dir
use nixpanel_common::{SectionRequest, SectionResponse};
use std::fs;
use std::io::{self, BufRead};
use std::process::Command;
/* ── helpers ──────────────────────────────────────────────────────────── */
fn run(prog: &str, args: &[&str]) -> Result<String, String> {
let out = Command::new(prog)
.args(args)
.output()
.map_err(|e| format!("Failed to run {}: {}", prog, e))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
fn run_ok(prog: &str, args: &[&str]) {
let _ = Command::new(prog).args(args).output();
}
fn write_file(path: &str, content: &str) -> Result<(), String> {
fs::write(path, content).map_err(|e| format!("Write {}: {}", path, e))
}
fn server_ip() -> String {
// Read from /opt/nixpanel sysinfo, or fall back to hostname -I
Command::new("hostname")
.arg("-I")
.output()
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.split_whitespace()
.next()
.unwrap_or("127.0.0.1")
.to_string()
})
.unwrap_or_else(|_| "127.0.0.1".to_string())
}
/* ── Apache vhost ─────────────────────────────────────────────────────── */
fn create_vhost(username: &str, domain: &str, php_version: &str) -> Result<(), String> {
let public_html = format!("/home/{}/public_html", username);
let log_dir = format!("/home/{}/logs", username);
let sock = format!("/run/php/php{}-fpm-{}.sock", php_version, username);
let conf = format!(
r#"# NixPanel — {domain} ({username})
<VirtualHost *:80>
ServerName {domain}
ServerAlias www.{domain}
DocumentRoot {public_html}
<Directory {public_html}>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
<FilesMatch \.php$>
SetHandler "proxy:unix:{sock}|fcgi://localhost"
</FilesMatch>
ErrorLog {log_dir}/error.log
CustomLog {log_dir}/access.log combined
</VirtualHost>
"#,
domain = domain,
username = username,
public_html = public_html,
log_dir = log_dir,
sock = sock,
);
let conf_path = format!("/etc/apache2/sites-available/{}.conf", username);
write_file(&conf_path, &conf)?;
run("a2ensite", &[&format!("{}.conf", username)])?;
Ok(())
}
fn remove_vhost(username: &str) {
run_ok("a2dissite", &[&format!("{}.conf", username)]);
let _ = fs::remove_file(format!("/etc/apache2/sites-available/{}.conf", username));
}
/* ── PHP-FPM pool ─────────────────────────────────────────────────────── */
fn create_fpm_pool(username: &str, php_version: &str) -> Result<(), String> {
let sock = format!("/run/php/php{}-fpm-{}.sock", php_version, username);
let pool = format!(
r#"[{username}]
user = {username}
group = {username}
listen = {sock}
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
pm.max_children = 10
pm.process_idle_timeout = 10s
pm.max_requests = 500
php_admin_value[open_basedir] = /home/{username}/:/tmp/
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
env[PATH] = /usr/local/bin:/usr/bin:/bin
"#,
username = username,
sock = sock,
);
let pool_path = format!(
"/etc/php/{}/fpm/pool.d/{}.conf",
php_version, username
);
write_file(&pool_path, &pool)?;
run("systemctl", &["reload", &format!("php{}-fpm", php_version)])?;
Ok(())
}
fn remove_fpm_pool(username: &str, php_version: &str) {
let path = format!("/etc/php/{}/fpm/pool.d/{}.conf", php_version, username);
let _ = fs::remove_file(&path);
run_ok("systemctl", &["reload", &format!("php{}-fpm", php_version)]);
}
/* ── PowerDNS zone ────────────────────────────────────────────────────── */
fn create_dns_zone(domain: &str, ip: &str) -> Result<(), String> {
// Create zone
run("pdnsutil", &["create-zone", domain])?;
// A records
run("pdnsutil", &["add-record", domain, "@", "A", "3600", ip])?;
run("pdnsutil", &["add-record", domain, "www", "A", "3600", ip])?;
run("pdnsutil", &["add-record", domain, "mail","A", "3600", ip])?;
run("pdnsutil", &["add-record", domain, "ftp", "A", "3600", ip])?;
// MX record
run("pdnsutil", &["add-record", domain, "@", "MX", "3600",
&format!("10 mail.{}.", domain)])?;
// SPF TXT
run("pdnsutil", &["add-record", domain, "@", "TXT", "3600",
&format!("\"v=spf1 a mx ip4:{} ~all\"", ip)])?;
// Increase serial and notify
run_ok("pdnsutil", &["rectify-zone", domain]);
Ok(())
}
fn remove_dns_zone(domain: &str) {
run_ok("pdnsutil", &["delete-zone", domain]);
}
/* ── Home directory structure ─────────────────────────────────────────── */
fn create_home(username: &str, domain: &str) -> Result<(), String> {
let dirs = [
format!("/home/{}/public_html", username),
format!("/home/{}/logs", username),
format!("/home/{}/tmp", username),
format!("/home/{}/mail", username),
format!("/home/{}/backups", username),
];
for dir in &dirs {
fs::create_dir_all(dir)
.map_err(|e| format!("mkdir {}: {}", dir, e))?;
}
// Default index.html
let index = format!(
r#"<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Welcome to {domain}</title>
<style>body{{font-family:sans-serif;text-align:center;padding:80px;color:#333}}
h1{{color:#e85d04}}p{{color:#666}}</style></head>
<body>
<h1>🚀 {domain}</h1>
<p>Your hosting account is active. Upload your files to <code>public_html</code> to get started.</p>
<p><small>Powered by NixPanel</small></p>
</body></html>
"#,
domain = domain
);
write_file(&format!("/home/{}/public_html/index.html", username), &index)?;
// Set ownership to system user
run("chown", &["-R", &format!("{}:{}", username, username),
&format!("/home/{}", username)])?;
run("chmod", &["750", &format!("/home/{}", username)])?;
run("chmod", &["755", &format!("/home/{}/public_html", username)])?;
Ok(())
}
/* ── Actions ──────────────────────────────────────────────────────────── */
fn action_provision(params: &serde_json::Value) -> SectionResponse {
let username = params["username"].as_str().unwrap_or("");
let domain = params["domain"].as_str().unwrap_or("");
let php_version = params["php_version"].as_str().unwrap_or("8.3");
let ip = params["server_ip"].as_str()
.map(|s| s.to_string())
.unwrap_or_else(server_ip);
if username.is_empty() || domain.is_empty() {
return SectionResponse::err("username and domain are required");
}
// Sanitise: only allow alphanumeric + hyphen/dot in domain,
// alphanumeric + underscore in username
if !username.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
return SectionResponse::err("Invalid username");
}
let mut steps: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new();
// 1. Home directory
match create_home(username, domain) {
Ok(()) => steps.push("home directory created".into()),
Err(e) => errors.push(format!("home dir: {}", e)),
}
// 2. PHP-FPM pool
match create_fpm_pool(username, php_version) {
Ok(()) => steps.push(format!("PHP {} FPM pool created", php_version)),
Err(e) => errors.push(format!("fpm pool: {}", e)),
}
// 3. Apache vhost
match create_vhost(username, domain, php_version) {
Ok(()) => steps.push("Apache vhost created".into()),
Err(e) => errors.push(format!("vhost: {}", e)),
}
// 4. Reload Apache
match run("systemctl", &["reload", "apache2"]) {
Ok(_) => steps.push("Apache reloaded".into()),
Err(e) => errors.push(format!("apache reload: {}", e)),
}
// 5. PowerDNS zone
match create_dns_zone(domain, &ip) {
Ok(()) => steps.push(format!("DNS zone created (IP: {})", ip)),
Err(e) => errors.push(format!("DNS: {}", e)),
}
if errors.is_empty() {
SectionResponse::ok(serde_json::json!({
"steps": steps,
"domain": domain,
"ip": ip,
}))
} else {
// Partial success — return both what worked and what didn't
SectionResponse::ok(serde_json::json!({
"steps": steps,
"errors": errors,
"partial": true,
}))
}
}
fn action_deprovision(params: &serde_json::Value) -> SectionResponse {
let username = params["username"].as_str().unwrap_or("");
let domain = params["domain"].as_str().unwrap_or("");
let php_version = params["php_version"].as_str().unwrap_or("8.3");
if username.is_empty() {
return SectionResponse::err("username is required");
}
remove_vhost(username);
remove_fpm_pool(username, php_version);
if !domain.is_empty() {
remove_dns_zone(domain);
}
run_ok("systemctl", &["reload", "apache2"]);
SectionResponse::ok(serde_json::json!({
"message": format!("Account {} deprovisioned", username)
}))
}
/* ── 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() {
"provision" => action_provision(&req.params),
"deprovision" => action_deprovision(&req.params),
other => SectionResponse::err(format!("Unknown action: {}", other)),
},
};
println!("{}", serde_json::to_string(&response).unwrap());
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nixpanel"
version = "0.1.0"
version = "0.4.0-alpha"
edition = "2021"
[[bin]]
+18 -18
View File
@@ -1,6 +1,6 @@
-- Users (panel logins: admin + per-account users)
CREATE TABLE IF NOT EXISTS users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
id BIGINT NOT NULL AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
@@ -14,8 +14,8 @@ CREATE TABLE IF NOT EXISTS users (
-- Hosting accounts
CREATE TABLE IF NOT EXISTS accounts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED,
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT,
username VARCHAR(64) NOT NULL,
domain VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
@@ -34,8 +34,8 @@ CREATE TABLE IF NOT EXISTS accounts (
-- Domains / DNS zones
CREATE TABLE IF NOT EXISTS domains (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED,
id BIGINT NOT NULL AUTO_INCREMENT,
account_id BIGINT,
name VARCHAR(255) NOT NULL,
type ENUM('main','addon','parked','subdomain') NOT NULL DEFAULT 'main',
status ENUM('active','inactive') NOT NULL DEFAULT 'active',
@@ -47,9 +47,9 @@ CREATE TABLE IF NOT EXISTS domains (
-- 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
id BIGINT NOT NULL AUTO_INCREMENT,
domain_id BIGINT NOT NULL,
type VARCHAR(10) NOT NULL,
name VARCHAR(255) NOT NULL,
content VARCHAR(512) NOT NULL,
ttl INT NOT NULL DEFAULT 3600,
@@ -61,8 +61,8 @@ CREATE TABLE IF NOT EXISTS dns_records (
-- 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,
id BIGINT NOT NULL AUTO_INCREMENT,
account_id BIGINT NOT NULL,
db_name VARCHAR(64) NOT NULL,
db_user VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -73,8 +73,8 @@ CREATE TABLE IF NOT EXISTS account_databases (
-- Email accounts
CREATE TABLE IF NOT EXISTS email_accounts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED NOT NULL,
id BIGINT NOT NULL AUTO_INCREMENT,
account_id BIGINT NOT NULL,
address VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
quota_mb INT NOT NULL DEFAULT 1024,
@@ -86,8 +86,8 @@ CREATE TABLE IF NOT EXISTS email_accounts (
-- FTP accounts
CREATE TABLE IF NOT EXISTS ftp_accounts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED NOT NULL,
id BIGINT NOT NULL AUTO_INCREMENT,
account_id BIGINT NOT NULL,
username VARCHAR(64) NOT NULL,
home_dir VARCHAR(512) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -98,8 +98,8 @@ CREATE TABLE IF NOT EXISTS ftp_accounts (
-- SSL certificates
CREATE TABLE IF NOT EXISTS ssl_certificates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED,
id BIGINT NOT NULL AUTO_INCREMENT,
account_id BIGINT,
domain VARCHAR(255) NOT NULL,
issuer VARCHAR(64) NOT NULL DEFAULT 'letsencrypt',
cert_path VARCHAR(512),
@@ -114,8 +114,8 @@ CREATE TABLE IF NOT EXISTS ssl_certificates (
-- Backup jobs
CREATE TABLE IF NOT EXISTS backups (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED,
id BIGINT NOT NULL AUTO_INCREMENT,
account_id BIGINT,
type ENUM('full','incremental') NOT NULL DEFAULT 'full',
status ENUM('pending','running','completed','failed') NOT NULL DEFAULT 'pending',
size_bytes BIGINT,
+11 -15
View File
@@ -1,10 +1,8 @@
use axum::{
async_trait,
extract::FromRequestParts,
http::{request::Parts, HeaderMap},
RequestPartsExt,
http::request::Parts,
};
use axum_extra::TypedHeader;
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
@@ -19,7 +17,7 @@ pub struct Claims {
pub sub: String, // username
pub user_id: i64,
pub role: String, // "admin" | "user"
pub exp: i64, // unix timestamp
pub exp: i64,
pub iat: i64,
}
@@ -33,25 +31,24 @@ impl Claims {
exp: (now + Duration::hours(8)).timestamp(),
iat: now.timestamp(),
};
let token = encode(
Ok(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>(
Ok(decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::new(Algorithm::HS256),
)?;
Ok(data.claims)
)?
.claims)
}
}
/* ── Extractor: pulls Claims from Authorization: Bearer <token> ──────── */
/* ── Extractor — pulls Claims from Authorization: Bearer <token> ─────── */
#[async_trait]
impl FromRequestParts<AppState> for Claims {
@@ -61,14 +58,13 @@ impl FromRequestParts<AppState> for Claims {
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let headers: &HeaderMap = &parts.headers;
let auth = headers
let auth = parts
.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)
Claims::verify(auth, &state.config.jwt_secret).map_err(|_| AppError::Unauthorized)
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ impl From<sqlx::Error> for AppError {
}
impl From<jsonwebtoken::errors::Error> for AppError {
fn from(e: jsonwebtoken::errors::Error) -> Self {
fn from(_e: jsonwebtoken::errors::Error) -> Self {
AppError::Unauthorized
}
}
+32 -3
View File
@@ -9,7 +9,7 @@ use std::sync::Arc;
use std::net::SocketAddr;
use axum::{
routing::{get, post},
routing::{delete, get, post},
Router,
};
use tower_http::cors::{Any, CorsLayer};
@@ -112,9 +112,38 @@ fn build_router(state: AppState) -> Router {
// 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)
// 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))
// Databases
.route("/api/databases",
get(routes::databases::list_all_databases)
.post(routes::databases::create_database))
.route("/api/databases/:username",
get(routes::databases::list_account_databases))
.route("/api/databases/name/:db_name",
delete(routes::databases::delete_database))
// 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)
}
+174 -36
View File
@@ -1,4 +1,7 @@
use axum::{extract::State, Json};
use axum::{
extract::{Path, State},
Json,
};
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
@@ -11,8 +14,8 @@ pub struct Account {
pub domain: String,
pub email: String,
pub package_name: String,
pub disk_quota_mb: i64,
pub bandwidth_mb: i64,
pub disk_quota_mb: i32, // INT column → i32
pub bandwidth_mb: i32, // INT column → i32
pub status: String,
pub created_at: NaiveDateTime,
}
@@ -24,18 +27,17 @@ pub struct CreateAccountRequest {
pub email: String,
pub password: String,
pub package_name: Option<String>,
pub disk_quota_mb: Option<i64>,
pub bandwidth_mb: Option<i64>,
pub disk_quota_mb: Option<i32>,
pub bandwidth_mb: Option<i32>,
}
pub async fn list_accounts(
claims: Claims,
_claims: Claims,
State(state): State<AppState>,
) -> Result<Json<Vec<Account>>, AppError> {
let accounts = sqlx::query_as!(
Account,
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"
FROM accounts ORDER BY created_at DESC",
)
.fetch_all(&state.db)
.await?;
@@ -43,7 +45,7 @@ pub async fn list_accounts(
}
pub async fn create_account(
claims: Claims,
_claims: Claims,
State(state): State<AppState>,
Json(body): Json<CreateAccountRequest>,
) -> Result<Json<Account>, AppError> {
@@ -52,63 +54,199 @@ pub async fn create_account(
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)))?
.map_err(|e| AppError::Internal(anyhow::anyhow!("argon2: {}", e)))?
.to_string();
// Create system user (best-effort — panel may not be running as root in dev)
// Create system user (must exist before provisioner runs)
let _ = std::process::Command::new("useradd")
.args(&[
"-m",
"-s", "/bin/bash",
"-d", &format!("/home/{}", body.username),
&body.username,
])
.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 disk_quota_mb: i32 = body.disk_quota_mb.unwrap_or(10240);
let bandwidth_mb: i32 = body.bandwidth_mb.unwrap_or(0);
let id = sqlx::query!(
// Run the full account provisioner (vhost, PHP-FPM, DNS zone, home dirs)
let provision_result = crate::section::call(
&state.config.bin_dir,
"nixpanel-accounts",
"provision",
serde_json::json!({
"username": body.username,
"domain": body.domain,
"email": body.email,
"php_version": "8.3",
}),
);
if let Err(ref e) = provision_result {
tracing::warn!("Account provisioner error for {}: {}", body.username, e);
} else if let Ok(ref resp) = provision_result {
if let Some(errors) = resp.data.as_ref().and_then(|d| d.get("errors")) {
tracing::warn!("Provisioner partial errors for {}: {}", body.username, errors);
}
tracing::info!("Provisioner steps for {}: {:?}",
body.username,
resp.data.as_ref().and_then(|d| d.get("steps"))
);
}
let insert_id: u64 = 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
)
.bind(&body.username)
.bind(&body.domain)
.bind(&body.email)
.bind(&hash)
.bind(&package_name)
.bind(disk_quota_mb)
.bind(bandwidth_mb)
.execute(&state.db)
.await?
.last_insert_id() as i64;
.last_insert_id();
// Also create a panel user so they can log into NixClient
let _ = sqlx::query!(
// Also create a NixClient panel user
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
)
.bind(&body.username)
.bind(&body.email)
.bind(&hash)
.execute(&state.db)
.await;
let account = sqlx::query_as!(
Account,
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
)
.bind(insert_id)
.fetch_one(&state.db)
.await?;
Ok(Json(account))
}
/* ── Suspend / Unsuspend ─────────────────────────────────────────────── */
pub async fn suspend_account(
_claims: Claims,
State(state): State<AppState>,
Path(username): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let rows = sqlx::query(
"UPDATE accounts SET status = 'suspended' WHERE username = ?",
)
.bind(&username)
.execute(&state.db)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound);
}
Ok(Json(serde_json::json!({ "ok": true, "status": "suspended" })))
}
pub async fn unsuspend_account(
_claims: Claims,
State(state): State<AppState>,
Path(username): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let rows = sqlx::query(
"UPDATE accounts SET status = 'active' WHERE username = ?",
)
.bind(&username)
.execute(&state.db)
.await?
.rows_affected();
if rows == 0 {
return Err(AppError::NotFound);
}
Ok(Json(serde_json::json!({ "ok": true, "status": "active" })))
}
/* ── Terminate (delete + deprovision) ────────────────────────────────── */
pub async fn terminate_account(
_claims: Claims,
State(state): State<AppState>,
Path(username): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
// Fetch domain so we can deprovision DNS
let domain: Option<String> = sqlx::query_scalar(
"SELECT domain FROM accounts WHERE username = ?",
)
.bind(&username)
.fetch_optional(&state.db)
.await?;
if domain.is_none() {
return Err(AppError::NotFound);
}
let domain = domain.unwrap();
// Run nixpanel-accounts deprovision (best effort)
let result = crate::section::call(
&state.config.bin_dir,
"nixpanel-accounts",
"deprovision",
serde_json::json!({ "username": username, "domain": domain }),
);
if let Err(ref e) = result {
tracing::warn!("Deprovision error for {}: {}", username, e);
}
// Remove system user (keep home dir for safety — admin can wipe manually)
let _ = std::process::Command::new("userdel")
.arg(&username)
.output();
// Remove DB records (cascades to email, ftp, db, ssl, etc.)
sqlx::query("DELETE FROM accounts WHERE username = ?")
.bind(&username)
.execute(&state.db)
.await?;
// Also remove panel login user
sqlx::query("DELETE FROM users WHERE username = ? AND role = 'user'")
.bind(&username)
.execute(&state.db)
.await
.ok();
Ok(Json(serde_json::json!({
"ok": true,
"message": format!("Account {} terminated", username)
})))
}
/* ── Single account lookup ───────────────────────────────────────────── */
pub async fn get_account(
_claims: Claims,
State(state): State<AppState>,
Path(username): Path<String>,
) -> Result<Json<Account>, AppError> {
let account = sqlx::query_as::<_, Account>(
"SELECT id, username, domain, email, package_name, disk_quota_mb, bandwidth_mb, status, created_at
FROM accounts WHERE username = ?",
)
.bind(&username)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
Ok(Json(account))
}
+16 -10
View File
@@ -23,29 +23,35 @@ pub struct UserInfo {
pub role: String,
}
// Internal row type — non-macro query returns untyped rows, use FromRow
#[derive(sqlx::FromRow)]
struct UserRow {
id: i64,
username: String,
password_hash: String,
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!(
let row: Option<UserRow> = sqlx::query_as::<_, UserRow>(
"SELECT id, username, password_hash, role FROM users WHERE username = ? LIMIT 1",
body.username
)
.bind(&body.username)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::Unauthorized)?;
.await?;
// Verify password
let parsed = PasswordHash::new(&row.password_hash)
.map_err(|_| AppError::Unauthorized)?;
let row = row.ok_or(AppError::Unauthorized)?;
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))?;
.map_err(AppError::Internal)?;
Ok(Json(LoginResponse {
token,
+15 -21
View File
@@ -19,36 +19,30 @@ pub struct DashboardResponse {
}
pub async fn get_dashboard(
claims: Claims,
_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);
// Use non-macro query() — tables may not exist at compile time
let accounts: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM accounts")
.fetch_one(&state.db).await.unwrap_or(0);
let domains: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM domains")
.fetch_one(&state.db).await.unwrap_or(0);
let databases: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM account_databases")
.fetch_one(&state.db).await.unwrap_or(0);
let email_accounts: i64 = 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)
let sysinfo = 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)
let versions = 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),
stats: DashboardStats { accounts, domains, databases, email_accounts },
sysinfo: sysinfo.data.unwrap_or(serde_json::Value::Null),
versions: versions.data.unwrap_or(serde_json::Value::Null),
}))
}
+266
View File
@@ -0,0 +1,266 @@
use axum::{
extract::{Path, State},
Json,
};
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::process::{Command, Stdio};
use crate::{auth::Claims, error::AppError, state::AppState};
/* ── Types ───────────────────────────────────────────────────────────── */
#[derive(Serialize, sqlx::FromRow)]
pub struct Database {
pub id: i64,
pub account_id: i64,
pub db_name: String,
pub db_user: String,
pub created_at: NaiveDateTime,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct DatabaseWithAccount {
pub id: i64,
pub account_id: i64,
pub db_name: String,
pub db_user: String,
pub created_at: NaiveDateTime,
pub account_username: String,
}
#[derive(Deserialize)]
pub struct CreateDatabaseRequest {
/// Hosting account username (admin supplies this; NixClient uses JWT sub)
pub username: String,
/// Short suffix — final DB name will be `username_db_suffix`
pub db_suffix: String,
/// Short suffix — final DB user will be `username_user_suffix` (defaults to db_suffix)
pub db_user_suffix: Option<String>,
pub db_password: String,
}
/* ── MySQL helpers ───────────────────────────────────────────────────── */
/// Escape a string value safe for use inside a single-quoted MySQL literal.
fn escape_mysql(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('\'', "\\'")
.replace('\0', "\\0")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
/// Execute one or more SQL statements via `mysql -u root` (unix socket auth).
fn mysql_exec(sql: &str) -> Result<String, String> {
let mut child = Command::new("mysql")
.args(["-u", "root"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("mysql spawn: {}", e))?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(sql.as_bytes()).ok();
}
let out = child.wait_with_output().map_err(|e| format!("mysql wait: {}", e))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
/// Validate an identifier: only alphanumeric + underscore, non-empty.
fn valid_ident(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_alphanumeric() || c == '_')
}
/* ── Handlers ────────────────────────────────────────────────────────── */
/// List all databases across all accounts (admin / NixServer).
pub async fn list_all_databases(
_claims: Claims,
State(state): State<AppState>,
) -> Result<Json<Vec<DatabaseWithAccount>>, AppError> {
let rows = sqlx::query_as::<_, DatabaseWithAccount>(
"SELECT ad.id, ad.account_id, ad.db_name, ad.db_user, ad.created_at,
a.username AS account_username
FROM account_databases ad
JOIN accounts a ON a.id = ad.account_id
ORDER BY ad.created_at DESC",
)
.fetch_all(&state.db)
.await?;
Ok(Json(rows))
}
/// List databases for a specific hosting account (NixClient).
pub async fn list_account_databases(
_claims: Claims,
State(state): State<AppState>,
Path(account_username): Path<String>,
) -> Result<Json<Vec<Database>>, AppError> {
let rows = sqlx::query_as::<_, Database>(
"SELECT ad.id, ad.account_id, ad.db_name, ad.db_user, ad.created_at
FROM account_databases ad
JOIN accounts a ON a.id = ad.account_id
WHERE a.username = ?
ORDER BY ad.created_at DESC",
)
.bind(&account_username)
.fetch_all(&state.db)
.await?;
Ok(Json(rows))
}
/// Create a MariaDB database + user, then record in nixpanel DB.
pub async fn create_database(
_claims: Claims,
State(state): State<AppState>,
Json(body): Json<CreateDatabaseRequest>,
) -> Result<Json<Database>, AppError> {
// Validate inputs
if body.username.is_empty() || body.db_suffix.is_empty() || body.db_password.is_empty() {
return Err(AppError::BadRequest(
"username, db_suffix, and db_password are required".into(),
));
}
if !valid_ident(&body.db_suffix) {
return Err(AppError::BadRequest(
"db_suffix must be alphanumeric with underscores only".into(),
));
}
let user_suffix = body.db_user_suffix.as_deref().unwrap_or(&body.db_suffix);
if !valid_ident(user_suffix) {
return Err(AppError::BadRequest(
"db_user_suffix must be alphanumeric with underscores only".into(),
));
}
if body.db_password.len() < 6 {
return Err(AppError::BadRequest("Password must be at least 6 characters".into()));
}
// Build cPanel-style namespaced names
let db_name = format!("{}_{}", body.username, body.db_suffix);
let db_user = format!("{}_{}", body.username, user_suffix);
// MySQL identifier length limits
if db_name.len() > 64 {
return Err(AppError::BadRequest("Database name too long (max 64 chars)".into()));
}
if db_user.len() > 32 {
return Err(AppError::BadRequest("Database username too long (max 32 chars)".into()));
}
// Get account_id
let account_id: Option<i64> = sqlx::query_scalar(
"SELECT id FROM accounts WHERE username = ?",
)
.bind(&body.username)
.fetch_optional(&state.db)
.await?;
let account_id = account_id.ok_or_else(|| {
AppError::BadRequest(format!("Account '{}' not found", body.username))
})?;
// Check DB doesn't already exist in our tracking table
let exists: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM account_databases WHERE db_name = ?",
)
.bind(&db_name)
.fetch_one(&state.db)
.await?;
if exists > 0 {
return Err(AppError::BadRequest(format!(
"Database '{}' already exists",
db_name
)));
}
// Create database + user in MariaDB
let pw = escape_mysql(&body.db_password);
let sql = format!(
"CREATE DATABASE IF NOT EXISTS `{db_name}` \
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\n\
CREATE USER IF NOT EXISTS '{db_user}'@'localhost' IDENTIFIED BY '{pw}';\n\
GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{db_user}'@'localhost';\n\
FLUSH PRIVILEGES;\n",
db_name = db_name,
db_user = db_user,
pw = pw,
);
mysql_exec(&sql).map_err(|e| AppError::BadRequest(format!("MariaDB: {}", e)))?;
// Record in nixpanel tracking table
let insert_id: u64 = sqlx::query(
"INSERT INTO account_databases (account_id, db_name, db_user) VALUES (?, ?, ?)",
)
.bind(account_id)
.bind(&db_name)
.bind(&db_user)
.execute(&state.db)
.await?
.last_insert_id();
let db = sqlx::query_as::<_, Database>(
"SELECT id, account_id, db_name, db_user, created_at
FROM account_databases WHERE id = ?",
)
.bind(insert_id)
.fetch_one(&state.db)
.await?;
Ok(Json(db))
}
/// Drop a MariaDB database and its dedicated user.
pub async fn delete_database(
_claims: Claims,
State(state): State<AppState>,
Path(db_name): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
// Fetch tracking record
let row: Option<(i64, String)> = sqlx::query_as(
"SELECT id, db_user FROM account_databases WHERE db_name = ?",
)
.bind(&db_name)
.fetch_optional(&state.db)
.await?;
let (db_id, db_user) = row.ok_or(AppError::NotFound)?;
// Safety: only allow dropping databases that follow the user_name pattern
if !db_name.contains('_') {
return Err(AppError::BadRequest(
"Will not drop databases without account prefix".into(),
));
}
// Drop database and user from MariaDB
let sql = format!(
"DROP DATABASE IF EXISTS `{db_name}`;\n\
DROP USER IF EXISTS '{db_user}'@'localhost';\n\
FLUSH PRIVILEGES;\n",
db_name = db_name,
db_user = db_user,
);
mysql_exec(&sql).map_err(|e| AppError::BadRequest(format!("MariaDB: {}", e)))?;
// Remove tracking record
sqlx::query("DELETE FROM account_databases WHERE id = ?")
.bind(db_id)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true, "deleted": db_name })))
}
+163
View File
@@ -0,0 +1,163 @@
use axum::{
extract::{Path, State},
Json,
};
use serde::{Deserialize, Serialize};
use std::process::Command;
use crate::{auth::Claims, error::AppError, state::AppState};
/* ── Types ───────────────────────────────────────────────────────────── */
#[derive(Serialize, Clone)]
pub struct DnsRecord {
pub name: String,
pub ttl: u32,
pub rtype: String,
pub content: String,
}
#[derive(Deserialize)]
pub struct AddRecordRequest {
pub name: String,
pub rtype: String,
pub ttl: Option<u32>,
pub content: String,
}
#[derive(Deserialize)]
pub struct DeleteRecordRequest {
pub name: String,
pub rtype: String,
}
/* ── Helpers ─────────────────────────────────────────────────────────── */
/// Parse `pdnsutil list-zone <domain>` tab-separated output
fn parse_zone(domain: &str, output: &str) -> Vec<DnsRecord> {
let mut records = Vec::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
continue;
}
// Columns: name\tttl\tIN\ttype\tcontent
let cols: Vec<&str> = line.splitn(5, '\t').collect();
if cols.len() < 5 {
continue;
}
let raw_name = cols[0].trim().trim_end_matches('.');
let ttl: u32 = cols[1].trim().parse().unwrap_or(3600);
// cols[2] == "IN"
let rtype = cols[3].trim().to_uppercase();
let content = cols[4].trim().to_string();
if rtype == "SOA" {
continue; // skip internal records
}
// Normalise name: FQDN → relative label (@=apex)
let name = if raw_name == domain || raw_name.is_empty() {
"@".to_string()
} else {
let suffix = format!(".{}", domain);
if raw_name.ends_with(&suffix) {
raw_name[..raw_name.len() - suffix.len()].to_string()
} else {
raw_name.to_string()
}
};
records.push(DnsRecord { name, ttl, rtype, content });
}
records
}
fn pdnsutil(args: &[&str]) -> Result<String, String> {
let out = Command::new("pdnsutil")
.args(args)
.output()
.map_err(|e| format!("Failed to run pdnsutil: {}", e))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
/* ── Handlers ────────────────────────────────────────────────────────── */
/// List all DNS zones managed by PowerDNS
pub async fn list_zones(
_claims: Claims,
State(_state): State<AppState>,
) -> Result<Json<Vec<String>>, AppError> {
let out = Command::new("pdnsutil")
.args(["list-all-zones"])
.output()
.map_err(|e| AppError::Internal(anyhow::anyhow!("pdnsutil: {}", e)))?;
let zones: Vec<String> = String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.trim().trim_end_matches('.').to_string())
.filter(|l| !l.is_empty())
.collect();
Ok(Json(zones))
}
/// List all records in a DNS zone
pub async fn list_zone_records(
_claims: Claims,
State(_state): State<AppState>,
Path(domain): Path<String>,
) -> Result<Json<Vec<DnsRecord>>, AppError> {
let out = Command::new("pdnsutil")
.args(["list-zone", &domain])
.output()
.map_err(|e| AppError::Internal(anyhow::anyhow!("pdnsutil: {}", e)))?;
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
Ok(Json(parse_zone(&domain, &stdout)))
}
/// Add a DNS record to a zone
pub async fn add_record(
_claims: Claims,
State(_state): State<AppState>,
Path(domain): Path<String>,
Json(body): Json<AddRecordRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
if body.name.is_empty() || body.rtype.is_empty() || body.content.is_empty() {
return Err(AppError::BadRequest(
"name, rtype, and content are required".into(),
));
}
let ttl = body.ttl.unwrap_or(3600).to_string();
pdnsutil(&["add-record", &domain, &body.name, &body.rtype, &ttl, &body.content])
.map_err(|e| AppError::BadRequest(format!("DNS error: {}", e)))?;
let _ = pdnsutil(&["rectify-zone", &domain]);
Ok(Json(serde_json::json!({ "ok": true })))
}
/// Delete an RRset (all records of a type at a name) from a zone
pub async fn delete_record(
_claims: Claims,
State(_state): State<AppState>,
Path(domain): Path<String>,
Json(body): Json<DeleteRecordRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
if body.name.is_empty() || body.rtype.is_empty() {
return Err(AppError::BadRequest("name and rtype are required".into()));
}
pdnsutil(&["delete-rrset", &domain, &body.name, &body.rtype])
.map_err(|e| AppError::BadRequest(format!("DNS error: {}", e)))?;
let _ = pdnsutil(&["rectify-zone", &domain]);
Ok(Json(serde_json::json!({ "ok": true })))
}
+145
View File
@@ -0,0 +1,145 @@
use axum::{
extract::{Path, State},
Json,
};
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use crate::{auth::Claims, error::AppError, state::AppState};
/* ── Types ───────────────────────────────────────────────────────────── */
#[derive(Serialize, sqlx::FromRow)]
pub struct EmailAccount {
pub id: i64,
pub account_id: i64,
pub address: String,
pub quota_mb: i32,
pub created_at: NaiveDateTime,
}
#[derive(Deserialize)]
pub struct CreateEmailRequest {
pub username: String, // local part (before @)
pub domain: String, // domain
pub password: String,
pub quota_mb: Option<i32>,
}
/* ── Handlers ────────────────────────────────────────────────────────── */
/// List all email accounts for a hosting account (by account username).
/// Admin can pass any username; a regular user's JWT sub is used automatically.
pub async fn list_email_accounts(
_claims: Claims,
State(state): State<AppState>,
Path(account_username): Path<String>,
) -> Result<Json<Vec<EmailAccount>>, AppError> {
let emails = sqlx::query_as::<_, EmailAccount>(
"SELECT ea.id, ea.account_id, ea.address, ea.quota_mb, ea.created_at
FROM email_accounts ea
JOIN accounts a ON a.id = ea.account_id
WHERE a.username = ?
ORDER BY ea.created_at DESC",
)
.bind(&account_username)
.fetch_all(&state.db)
.await?;
Ok(Json(emails))
}
/// Create a new email account under a domain.
pub async fn create_email_account(
_claims: Claims,
State(state): State<AppState>,
Json(body): Json<CreateEmailRequest>,
) -> Result<Json<EmailAccount>, AppError> {
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2,
};
if body.username.is_empty() || body.domain.is_empty() || body.password.is_empty() {
return Err(AppError::BadRequest(
"username, domain, and password are required".into(),
));
}
// Validate local part (no @, spaces, etc.)
if body.username.contains('@') || body.username.contains(' ') {
return Err(AppError::BadRequest("Invalid email username".into()));
}
let address = format!("{}@{}", body.username, body.domain);
// Look up account by domain
let row: Option<(i64, String)> = sqlx::query_as(
"SELECT id, username FROM accounts WHERE domain = ?",
)
.bind(&body.domain)
.fetch_optional(&state.db)
.await?;
let (account_id, acct_username) = row.ok_or_else(|| {
AppError::BadRequest(format!("No account for domain {}", body.domain))
})?;
// 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!("argon2: {}", e)))?
.to_string();
let quota_mb: i32 = body.quota_mb.unwrap_or(1024);
let insert_id: u64 = sqlx::query(
"INSERT INTO email_accounts (account_id, address, password_hash, quota_mb)
VALUES (?, ?, ?, ?)",
)
.bind(account_id)
.bind(&address)
.bind(&hash)
.bind(quota_mb)
.execute(&state.db)
.await?
.last_insert_id();
// Provision Maildir structure under the account's home directory
let mail_root = format!("/home/{}/mail/{}", acct_username, body.username);
for sub in ["new", "cur", "tmp"] {
let _ = std::fs::create_dir_all(format!("{}/{}", mail_root, sub));
}
let _ = std::process::Command::new("chown")
.args([
"-R",
&format!("{}:{}", acct_username, acct_username),
&mail_root,
])
.output();
let email = sqlx::query_as::<_, EmailAccount>(
"SELECT id, account_id, address, quota_mb, created_at
FROM email_accounts WHERE id = ?",
)
.bind(insert_id)
.fetch_one(&state.db)
.await?;
Ok(Json(email))
}
/// Delete an email account by ID.
pub async fn delete_email_account(
_claims: Claims,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<serde_json::Value>, AppError> {
sqlx::query("DELETE FROM email_accounts WHERE id = ?")
.bind(id)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+72
View File
@@ -0,0 +1,72 @@
use axum::{extract::State, Json};
use serde::Deserialize;
use crate::{auth::Claims, error::AppError, state::AppState};
#[derive(Deserialize)]
pub struct ChangePasswordRequest {
pub current_password: String,
pub new_password: String,
}
/// Change the password of the currently logged-in user.
pub async fn change_password(
claims: Claims,
State(state): State<AppState>,
Json(body): Json<ChangePasswordRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, PasswordVerifier, SaltString},
Argon2, PasswordHash,
};
if body.new_password.len() < 8 {
return Err(AppError::BadRequest(
"New password must be at least 8 characters".into(),
));
}
// Fetch current hash from users table
let hash: Option<String> = sqlx::query_scalar(
"SELECT password_hash FROM users WHERE id = ?",
)
.bind(claims.user_id)
.fetch_optional(&state.db)
.await?;
let hash = hash.ok_or(AppError::NotFound)?;
// Verify current password
let parsed = PasswordHash::new(&hash)
.map_err(|e| AppError::Internal(anyhow::anyhow!("hash parse: {}", e)))?;
Argon2::default()
.verify_password(body.current_password.as_bytes(), &parsed)
.map_err(|_| AppError::BadRequest("Current password is incorrect".into()))?;
// Hash new password
let salt = SaltString::generate(&mut OsRng);
let new_hash = Argon2::default()
.hash_password(body.new_password.as_bytes(), &salt)
.map_err(|e| AppError::Internal(anyhow::anyhow!("argon2: {}", e)))?
.to_string();
// Update users table
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
.bind(&new_hash)
.bind(claims.user_id)
.execute(&state.db)
.await?;
// Also update accounts table if there's a matching account (for panel login)
sqlx::query(
"UPDATE accounts SET password_hash = ? WHERE username = ?",
)
.bind(&new_hash)
.bind(&claims.sub)
.execute(&state.db)
.await
.ok(); // not an error if no matching account
Ok(Json(serde_json::json!({ "ok": true })))
}
+4
View File
@@ -1,4 +1,8 @@
pub mod auth;
pub mod accounts;
pub mod dashboard;
pub mod databases;
pub mod dns;
pub mod email;
pub mod me;
pub mod services;
+8 -10
View File
@@ -2,12 +2,11 @@ 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,
_claims: Claims,
State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, AppError> {
let resp = section::call(
@@ -22,12 +21,12 @@ pub async fn list_services(
}
pub async fn service_action(
claims: Claims,
_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()) {
let allowed = ["start", "stop", "restart", "reload"];
if !allowed.contains(&action.as_str()) {
return Err(AppError::BadRequest(format!("Unknown action: {}", action)));
}
@@ -40,11 +39,10 @@ pub async fn service_action(
.map_err(|e| AppError::Internal(e))?;
if resp.success {
Ok(Json(serde_json::json!({
"success": true,
"message": resp.data
})))
Ok(Json(serde_json::json!({ "success": true, "message": resp.data })))
} else {
Err(AppError::BadRequest(resp.error.unwrap_or_else(|| "Action failed".into())))
Err(AppError::BadRequest(
resp.error.unwrap_or_else(|| "Action failed".into()),
))
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nixpanel-common"
version = "0.1.0"
version = "0.2.0-alpha"
edition = "2021"
[dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nixpanel-services"
version = "0.1.0"
version = "0.2.0-alpha"
edition = "2021"
[[bin]]
-1
View File
@@ -12,7 +12,6 @@
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)] = &[
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nixpanel-sysinfo"
version = "0.1.0"
version = "0.2.0-alpha"
edition = "2021"
[[bin]]
-1
View File
@@ -129,7 +129,6 @@ fn get_ram() -> (u64, u64) {
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))
}
+665 -132
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -124,6 +124,9 @@ export interface Account {
export async function getAccounts(): Promise<Account[]> {
return apiFetch('/accounts')
}
export async function getAccount(username: string): Promise<Account> {
return apiFetch(`/accounts/${username}`)
}
export async function createAccount(data: {
username: string
domain: string
@@ -135,3 +138,92 @@ export async function createAccount(data: {
}): Promise<Account> {
return apiFetch('/accounts', { method: 'POST', body: JSON.stringify(data) })
}
export async function suspendAccount(username: string): Promise<void> {
await apiFetch(`/accounts/${username}/suspend`, { method: 'POST' })
}
export async function unsuspendAccount(username: string): Promise<void> {
await apiFetch(`/accounts/${username}/unsuspend`, { method: 'POST' })
}
export async function terminateAccount(username: string): Promise<void> {
await apiFetch(`/accounts/${username}`, { method: 'DELETE' })
}
/* ── Databases ───────────────────────────────────────────────────────── */
export interface Database {
id: number
account_id: number
db_name: string
db_user: string
created_at: string
account_username?: string // present in admin list view
}
export async function listAllDatabases(): Promise<Database[]> {
return apiFetch('/databases')
}
export async function listAccountDatabases(username: string): Promise<Database[]> {
return apiFetch(`/databases/${username}`)
}
export async function createDatabase(data: {
username: string
db_suffix: string
db_user_suffix?: string
db_password: string
}): Promise<Database> {
return apiFetch('/databases', { method: 'POST', body: JSON.stringify(data) })
}
export async function deleteDatabase(dbName: string): Promise<void> {
await apiFetch(`/databases/name/${encodeURIComponent(dbName)}`, { method: 'DELETE' })
}
/* ── DNS ─────────────────────────────────────────────────────────────── */
export interface DnsRecord {
name: string
ttl: number
rtype: string
content: string
}
export async function listZones(): Promise<string[]> {
return apiFetch('/dns')
}
export async function listZoneRecords(domain: string): Promise<DnsRecord[]> {
return apiFetch(`/dns/${domain}`)
}
export async function addDnsRecord(
domain: string,
record: { name: string; rtype: string; ttl?: number; content: string }
): Promise<void> {
await apiFetch(`/dns/${domain}`, { method: 'POST', body: JSON.stringify(record) })
}
export async function deleteDnsRecord(
domain: string,
name: string,
rtype: string
): Promise<void> {
await apiFetch(`/dns/${domain}`, {
method: 'DELETE',
body: JSON.stringify({ name, rtype }),
})
}
/* ── Email accounts ──────────────────────────────────────────────────── */
export interface EmailAccount {
id: number
account_id: number
address: string
quota_mb: number
created_at: string
}
export async function listEmailAccounts(username: string): Promise<EmailAccount[]> {
return apiFetch(`/email/${username}`)
}
export async function createEmailAccount(data: {
username: string
domain: string
password: string
quota_mb?: number
}): Promise<EmailAccount> {
return apiFetch('/email', { method: 'POST', body: JSON.stringify(data) })
}
export async function deleteEmailAccount(id: number): Promise<void> {
await apiFetch(`/email/id/${id}`, { method: 'DELETE' })
}