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] ## [Unreleased]
### Planned ### Planned
- NixServer (WHM) — full Rust backend - SSL certificate management (Let's Encrypt via Certbot)
- NixClient (cPanel) — full Rust backend
- DNS zone editor
- Email account management
- SSL certificate management via Let's Encrypt
- File manager - File manager
- MySQL/MariaDB database manager - FTP account management (vsftpd)
- WordPress one-click installer - WordPress one-click installer
- Backup / restore system - Backup / restore system
- Firewall rule editor (CSF/iptables)
- Multi-server support (Agency plan) - 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 - **Installer** (`install.sh`) — fully automated Ubuntu 24.04 installer
- Self-updating: always fetches the latest version from GitHub before running - Self-updating: always fetches the latest version from GitHub before running
- Idempotent step tracking — re-runnable after failures without redoing completed steps - 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) - Configures Apache reverse proxy on ports 2083 (NixClient) and 2087 (NixServer)
- Prompts for admin credentials, hostname, and license key during install - Generates admin credentials automatically; displays them in the completion banner
- License activation against `https://license.nixpanel.io` with grace period fallback - 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 - **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) - Plans: Free, Solo ($9.99/mo), Host ($29.99/mo), Agency ($79.99/mo)
- Server IP binding with grace period fallback - **Infrastructure** — `.gitattributes` enforcing LF line endings
- **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
--- ---
[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 [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 = [ members = [
"nixpanel-common", "nixpanel-common",
"nixpanel-api", "nixpanel-api",
"nixpanel-accounts",
"nixpanel-services", "nixpanel-services",
"nixpanel-sysinfo", "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 apt-get install -y -qq upx-ucl 2>/dev/null || true
fi fi
# Main API binary # Main API binary — stop service first so the file isn't busy
if [[ -f "target/release/nixpanel" ]]; then if [[ -f "target/release/nixpanel" ]]; then
systemctl stop nixpanel 2>/dev/null || true
cp target/release/nixpanel "$BIN_DIR/nixpanel" cp target/release/nixpanel "$BIN_DIR/nixpanel"
chmod 755 "$BIN_DIR/nixpanel" chmod 755 "$BIN_DIR/nixpanel"
command -v upx &>/dev/null && upx --best --quiet "$BIN_DIR/nixpanel" 2>/dev/null || true 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" mkdir -p "$BIN_DIR"
for section_bin in target/release/nixpanel-*; do for section_bin in target/release/nixpanel-*; do
[[ -f "$section_bin" ]] || continue [[ -f "$section_bin" ]] || continue
# Skip debug/metadata files produced by UPX (.d files etc.)
[[ "$section_bin" == *.* ]] && continue
bin_name=$(basename "$section_bin") bin_name=$(basename "$section_bin")
cp "$section_bin" "$BIN_DIR/$bin_name" cp "$section_bin" "$BIN_DIR/$bin_name"
chmod 755 "$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 command -v upx &>/dev/null && upx --best --quiet "$BIN_DIR/$bin_name" 2>/dev/null || true
success "Section binary installed: $bin_name" success "Section binary installed: $bin_name"
done done
# Remove any UPX metadata files
rm -f "$BIN_DIR"/*.d
mark_done "build_panel" mark_done "build_panel"
success "Panel binaries built and compressed" 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 else
warn "Panel Rust backend not yet present in repo — skipping build" 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." warn "This is expected during early development. Re-run installer when source is ready."
+590 -205
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() return res.json()
} }
/* ── Auth ────────────────────────────────────────────────────────────── */
export interface LoginResult { export interface LoginResult {
token: string token: string
user: { id: number; username: string; role: string } user: { id: number; username: string; role: string }
@@ -50,7 +51,9 @@ export async function login(username: string, password: string): Promise<LoginRe
return result return result
} }
/* ── Account info ────────────────────────────────────────────────────── */
export interface AccountInfo { export interface AccountInfo {
id: number
username: string username: string
domain: string domain: string
email: string email: string
@@ -58,13 +61,72 @@ export interface AccountInfo {
disk_quota_mb: number disk_quota_mb: number
bandwidth_mb: number bandwidth_mb: number
status: string status: string
created_at: string
} }
export async function getMyAccount(): Promise<AccountInfo | null> { export async function getMyAccount(username: string): Promise<AccountInfo | null> {
try { try {
// Returns the first account matching the logged-in user's username return await apiFetch<AccountInfo>(`/accounts/${username}`)
const accounts = await apiFetch<AccountInfo[]>('/accounts')
return accounts[0] ?? null
} catch { } catch {
return null 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] [package]
name = "nixpanel" name = "nixpanel"
version = "0.1.0" version = "0.4.0-alpha"
edition = "2021" edition = "2021"
[[bin]] [[bin]]
+18 -18
View File
@@ -1,6 +1,6 @@
-- Users (panel logins: admin + per-account users) -- Users (panel logins: admin + per-account users)
CREATE TABLE IF NOT EXISTS 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, username VARCHAR(64) NOT NULL,
email VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
@@ -14,8 +14,8 @@ CREATE TABLE IF NOT EXISTS users (
-- Hosting accounts -- Hosting accounts
CREATE TABLE IF NOT EXISTS accounts ( CREATE TABLE IF NOT EXISTS accounts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED, user_id BIGINT,
username VARCHAR(64) NOT NULL, username VARCHAR(64) NOT NULL,
domain VARCHAR(255) NOT NULL, domain VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL,
@@ -34,8 +34,8 @@ CREATE TABLE IF NOT EXISTS accounts (
-- Domains / DNS zones -- Domains / DNS zones
CREATE TABLE IF NOT EXISTS domains ( CREATE TABLE IF NOT EXISTS domains (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED, account_id BIGINT,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
type ENUM('main','addon','parked','subdomain') NOT NULL DEFAULT 'main', type ENUM('main','addon','parked','subdomain') NOT NULL DEFAULT 'main',
status ENUM('active','inactive') NOT NULL DEFAULT 'active', status ENUM('active','inactive') NOT NULL DEFAULT 'active',
@@ -47,9 +47,9 @@ CREATE TABLE IF NOT EXISTS domains (
-- DNS records -- DNS records
CREATE TABLE IF NOT EXISTS dns_records ( CREATE TABLE IF NOT EXISTS dns_records (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
domain_id BIGINT UNSIGNED NOT NULL, domain_id BIGINT NOT NULL,
type VARCHAR(10) NOT NULL, -- A, AAAA, CNAME, MX, TXT, NS, SRV type VARCHAR(10) NOT NULL,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
content VARCHAR(512) NOT NULL, content VARCHAR(512) NOT NULL,
ttl INT NOT NULL DEFAULT 3600, ttl INT NOT NULL DEFAULT 3600,
@@ -61,8 +61,8 @@ CREATE TABLE IF NOT EXISTS dns_records (
-- MySQL/MariaDB databases owned by accounts -- MySQL/MariaDB databases owned by accounts
CREATE TABLE IF NOT EXISTS account_databases ( CREATE TABLE IF NOT EXISTS account_databases (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED NOT NULL, account_id BIGINT NOT NULL,
db_name VARCHAR(64) NOT NULL, db_name VARCHAR(64) NOT NULL,
db_user VARCHAR(64) NOT NULL, db_user VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -73,8 +73,8 @@ CREATE TABLE IF NOT EXISTS account_databases (
-- Email accounts -- Email accounts
CREATE TABLE IF NOT EXISTS email_accounts ( CREATE TABLE IF NOT EXISTS email_accounts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED NOT NULL, account_id BIGINT NOT NULL,
address VARCHAR(255) NOT NULL, address VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
quota_mb INT NOT NULL DEFAULT 1024, quota_mb INT NOT NULL DEFAULT 1024,
@@ -86,8 +86,8 @@ CREATE TABLE IF NOT EXISTS email_accounts (
-- FTP accounts -- FTP accounts
CREATE TABLE IF NOT EXISTS ftp_accounts ( CREATE TABLE IF NOT EXISTS ftp_accounts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED NOT NULL, account_id BIGINT NOT NULL,
username VARCHAR(64) NOT NULL, username VARCHAR(64) NOT NULL,
home_dir VARCHAR(512) NOT NULL, home_dir VARCHAR(512) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -98,8 +98,8 @@ CREATE TABLE IF NOT EXISTS ftp_accounts (
-- SSL certificates -- SSL certificates
CREATE TABLE IF NOT EXISTS ssl_certificates ( CREATE TABLE IF NOT EXISTS ssl_certificates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED, account_id BIGINT,
domain VARCHAR(255) NOT NULL, domain VARCHAR(255) NOT NULL,
issuer VARCHAR(64) NOT NULL DEFAULT 'letsencrypt', issuer VARCHAR(64) NOT NULL DEFAULT 'letsencrypt',
cert_path VARCHAR(512), cert_path VARCHAR(512),
@@ -114,8 +114,8 @@ CREATE TABLE IF NOT EXISTS ssl_certificates (
-- Backup jobs -- Backup jobs
CREATE TABLE IF NOT EXISTS backups ( CREATE TABLE IF NOT EXISTS backups (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED, account_id BIGINT,
type ENUM('full','incremental') NOT NULL DEFAULT 'full', type ENUM('full','incremental') NOT NULL DEFAULT 'full',
status ENUM('pending','running','completed','failed') NOT NULL DEFAULT 'pending', status ENUM('pending','running','completed','failed') NOT NULL DEFAULT 'pending',
size_bytes BIGINT, size_bytes BIGINT,
+11 -15
View File
@@ -1,10 +1,8 @@
use axum::{ use axum::{
async_trait, async_trait,
extract::FromRequestParts, extract::FromRequestParts,
http::{request::Parts, HeaderMap}, http::request::Parts,
RequestPartsExt,
}; };
use axum_extra::TypedHeader;
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -19,7 +17,7 @@ pub struct Claims {
pub sub: String, // username pub sub: String, // username
pub user_id: i64, pub user_id: i64,
pub role: String, // "admin" | "user" pub role: String, // "admin" | "user"
pub exp: i64, // unix timestamp pub exp: i64,
pub iat: i64, pub iat: i64,
} }
@@ -33,25 +31,24 @@ impl Claims {
exp: (now + Duration::hours(8)).timestamp(), exp: (now + Duration::hours(8)).timestamp(),
iat: now.timestamp(), iat: now.timestamp(),
}; };
let token = encode( Ok(encode(
&Header::default(), &Header::default(),
&claims, &claims,
&EncodingKey::from_secret(secret.as_bytes()), &EncodingKey::from_secret(secret.as_bytes()),
)?; )?)
Ok(token)
} }
pub fn verify(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> { pub fn verify(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
let data = decode::<Claims>( Ok(decode::<Claims>(
token, token,
&DecodingKey::from_secret(secret.as_bytes()), &DecodingKey::from_secret(secret.as_bytes()),
&Validation::new(Algorithm::HS256), &Validation::new(Algorithm::HS256),
)?; )?
Ok(data.claims) .claims)
} }
} }
/* ── Extractor: pulls Claims from Authorization: Bearer <token> ──────── */ /* ── Extractor — pulls Claims from Authorization: Bearer <token> ─────── */
#[async_trait] #[async_trait]
impl FromRequestParts<AppState> for Claims { impl FromRequestParts<AppState> for Claims {
@@ -61,14 +58,13 @@ impl FromRequestParts<AppState> for Claims {
parts: &mut Parts, parts: &mut Parts,
state: &AppState, state: &AppState,
) -> Result<Self, Self::Rejection> { ) -> Result<Self, Self::Rejection> {
let headers: &HeaderMap = &parts.headers; let auth = parts
let auth = headers .headers
.get("authorization") .get("authorization")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer ")) .and_then(|s| s.strip_prefix("Bearer "))
.ok_or(AppError::Unauthorized)?; .ok_or(AppError::Unauthorized)?;
Claims::verify(auth, &state.config.jwt_secret) Claims::verify(auth, &state.config.jwt_secret).map_err(|_| AppError::Unauthorized)
.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 { impl From<jsonwebtoken::errors::Error> for AppError {
fn from(e: jsonwebtoken::errors::Error) -> Self { fn from(_e: jsonwebtoken::errors::Error) -> Self {
AppError::Unauthorized AppError::Unauthorized
} }
} }
+32 -3
View File
@@ -9,7 +9,7 @@ use std::sync::Arc;
use std::net::SocketAddr; use std::net::SocketAddr;
use axum::{ use axum::{
routing::{get, post}, routing::{delete, get, post},
Router, Router,
}; };
use tower_http::cors::{Any, CorsLayer}; use tower_http::cors::{Any, CorsLayer};
@@ -112,9 +112,38 @@ fn build_router(state: AppState) -> Router {
// Services // Services
.route("/api/services", get(routes::services::list_services)) .route("/api/services", get(routes::services::list_services))
.route("/api/services/:name/:action", post(routes::services::service_action)) .route("/api/services/:name/:action", post(routes::services::service_action))
// Accounts // Accounts — list / create
.route("/api/accounts", get(routes::accounts::list_accounts) .route("/api/accounts",
get(routes::accounts::list_accounts)
.post(routes::accounts::create_account)) .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) .with_state(state)
.layer(cors) .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 chrono::NaiveDateTime;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -11,8 +14,8 @@ pub struct Account {
pub domain: String, pub domain: String,
pub email: String, pub email: String,
pub package_name: String, pub package_name: String,
pub disk_quota_mb: i64, pub disk_quota_mb: i32, // INT column → i32
pub bandwidth_mb: i64, pub bandwidth_mb: i32, // INT column → i32
pub status: String, pub status: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
} }
@@ -24,18 +27,17 @@ pub struct CreateAccountRequest {
pub email: String, pub email: String,
pub password: String, pub password: String,
pub package_name: Option<String>, pub package_name: Option<String>,
pub disk_quota_mb: Option<i64>, pub disk_quota_mb: Option<i32>,
pub bandwidth_mb: Option<i64>, pub bandwidth_mb: Option<i32>,
} }
pub async fn list_accounts( pub async fn list_accounts(
claims: Claims, _claims: Claims,
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Vec<Account>>, AppError> { ) -> Result<Json<Vec<Account>>, AppError> {
let accounts = sqlx::query_as!( let accounts = sqlx::query_as::<_, Account>(
Account,
"SELECT id, username, domain, email, package_name, disk_quota_mb, bandwidth_mb, status, created_at "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) .fetch_all(&state.db)
.await?; .await?;
@@ -43,7 +45,7 @@ pub async fn list_accounts(
} }
pub async fn create_account( pub async fn create_account(
claims: Claims, _claims: Claims,
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<CreateAccountRequest>, Json(body): Json<CreateAccountRequest>,
) -> Result<Json<Account>, AppError> { ) -> Result<Json<Account>, AppError> {
@@ -52,63 +54,199 @@ pub async fn create_account(
Argon2, Argon2,
}; };
// Validate
if body.username.is_empty() || body.domain.is_empty() { if body.username.is_empty() || body.domain.is_empty() {
return Err(AppError::BadRequest("username and domain are required".into())); return Err(AppError::BadRequest("username and domain are required".into()));
} }
// Hash password
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default() let hash = Argon2::default()
.hash_password(body.password.as_bytes(), &salt) .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(); .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") let _ = std::process::Command::new("useradd")
.args(&[ .args(["-m", "-s", "/bin/bash", "-d",
"-m", &format!("/home/{}", body.username),
"-s", "/bin/bash", &body.username])
"-d", &format!("/home/{}", body.username),
&body.username,
])
.output(); .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 package_name = body.package_name.unwrap_or_else(|| "Default".into());
let disk_quota_mb = body.disk_quota_mb.unwrap_or(10240); let disk_quota_mb: i32 = body.disk_quota_mb.unwrap_or(10240);
let bandwidth_mb = body.bandwidth_mb.unwrap_or(0); 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) "INSERT INTO accounts (username, domain, email, password_hash, package_name, disk_quota_mb, bandwidth_mb, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'active')", 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) .execute(&state.db)
.await? .await?
.last_insert_id() as i64; .last_insert_id();
// Also create a panel user so they can log into NixClient // Also create a NixClient panel user
let _ = sqlx::query!( let _ = sqlx::query(
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'user') "INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'user')
ON DUPLICATE KEY UPDATE email = VALUES(email)", ON DUPLICATE KEY UPDATE email = VALUES(email)",
body.username, body.email, hash
) )
.bind(&body.username)
.bind(&body.email)
.bind(&hash)
.execute(&state.db) .execute(&state.db)
.await; .await;
let account = sqlx::query_as!( let account = sqlx::query_as::<_, Account>(
Account,
"SELECT id, username, domain, email, package_name, disk_quota_mb, bandwidth_mb, status, created_at "SELECT id, username, domain, email, package_name, disk_quota_mb, bandwidth_mb, status, created_at
FROM accounts WHERE id = ?", FROM accounts WHERE id = ?",
id
) )
.bind(insert_id)
.fetch_one(&state.db) .fetch_one(&state.db)
.await?; .await?;
Ok(Json(account)) 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, 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( pub async fn login(
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<LoginRequest>, Json(body): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, AppError> { ) -> Result<Json<LoginResponse>, AppError> {
// Look up user let row: Option<UserRow> = sqlx::query_as::<_, UserRow>(
let row = sqlx::query!(
"SELECT id, username, password_hash, role FROM users WHERE username = ? LIMIT 1", "SELECT id, username, password_hash, role FROM users WHERE username = ? LIMIT 1",
body.username
) )
.bind(&body.username)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await? .await?;
.ok_or(AppError::Unauthorized)?;
// Verify password let row = row.ok_or(AppError::Unauthorized)?;
let parsed = PasswordHash::new(&row.password_hash)
.map_err(|_| AppError::Unauthorized)?; let parsed = PasswordHash::new(&row.password_hash).map_err(|_| AppError::Unauthorized)?;
Argon2::default() Argon2::default()
.verify_password(body.password.as_bytes(), &parsed) .verify_password(body.password.as_bytes(), &parsed)
.map_err(|_| AppError::Unauthorized)?; .map_err(|_| AppError::Unauthorized)?;
// Issue JWT
let token = Claims::new(row.id, &row.username, &row.role, &state.config.jwt_secret) 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 { Ok(Json(LoginResponse {
token, token,
+15 -21
View File
@@ -19,36 +19,30 @@ pub struct DashboardResponse {
} }
pub async fn get_dashboard( pub async fn get_dashboard(
claims: Claims, _claims: Claims,
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<DashboardResponse>, AppError> { ) -> Result<Json<DashboardResponse>, AppError> {
// DB counts // Use non-macro query() — tables may not exist at compile time
let accounts = sqlx::query_scalar!("SELECT COUNT(*) FROM accounts") let accounts: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM accounts")
.fetch_one(&state.db).await?.unwrap_or(0); .fetch_one(&state.db).await.unwrap_or(0);
let domains = sqlx::query_scalar!("SELECT COUNT(*) FROM domains") let domains: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM domains")
.fetch_one(&state.db).await?.unwrap_or(0); .fetch_one(&state.db).await.unwrap_or(0);
let databases = sqlx::query_scalar!("SELECT COUNT(*) FROM account_databases") let databases: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM account_databases")
.fetch_one(&state.db).await?.unwrap_or(0); .fetch_one(&state.db).await.unwrap_or(0);
let email_accounts = sqlx::query_scalar!("SELECT COUNT(*) FROM email_accounts") let email_accounts: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM email_accounts")
.fetch_one(&state.db).await?.unwrap_or(0); .fetch_one(&state.db).await.unwrap_or(0);
// Section binaries
let bin_dir = &state.config.bin_dir; 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")); .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")); .unwrap_or_else(|_| nixpanel_common::SectionResponse::err("versions unavailable"));
Ok(Json(DashboardResponse { Ok(Json(DashboardResponse {
stats: DashboardStats { stats: DashboardStats { accounts, domains, databases, email_accounts },
accounts, sysinfo: sysinfo.data.unwrap_or(serde_json::Value::Null),
domains, versions: versions.data.unwrap_or(serde_json::Value::Null),
databases,
email_accounts,
},
sysinfo: sysinfo_resp.data.unwrap_or(serde_json::Value::Null),
versions: versions_resp.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 auth;
pub mod accounts; pub mod accounts;
pub mod dashboard; pub mod dashboard;
pub mod databases;
pub mod dns;
pub mod email;
pub mod me;
pub mod services; pub mod services;
+8 -10
View File
@@ -2,12 +2,11 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
Json, Json,
}; };
use serde::{Deserialize, Serialize};
use crate::{auth::Claims, error::AppError, section, state::AppState}; use crate::{auth::Claims, error::AppError, section, state::AppState};
pub async fn list_services( pub async fn list_services(
claims: Claims, _claims: Claims,
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, AppError> { ) -> Result<Json<serde_json::Value>, AppError> {
let resp = section::call( let resp = section::call(
@@ -22,12 +21,12 @@ pub async fn list_services(
} }
pub async fn service_action( pub async fn service_action(
claims: Claims, _claims: Claims,
State(state): State<AppState>, State(state): State<AppState>,
Path((name, action)): Path<(String, String)>, Path((name, action)): Path<(String, String)>,
) -> Result<Json<serde_json::Value>, AppError> { ) -> Result<Json<serde_json::Value>, AppError> {
let allowed_actions = ["start", "stop", "restart", "reload"]; let allowed = ["start", "stop", "restart", "reload"];
if !allowed_actions.contains(&action.as_str()) { if !allowed.contains(&action.as_str()) {
return Err(AppError::BadRequest(format!("Unknown action: {}", action))); return Err(AppError::BadRequest(format!("Unknown action: {}", action)));
} }
@@ -40,11 +39,10 @@ pub async fn service_action(
.map_err(|e| AppError::Internal(e))?; .map_err(|e| AppError::Internal(e))?;
if resp.success { if resp.success {
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({ "success": true, "message": resp.data })))
"success": true,
"message": resp.data
})))
} else { } 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] [package]
name = "nixpanel-common" name = "nixpanel-common"
version = "0.1.0" version = "0.2.0-alpha"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "nixpanel-services" name = "nixpanel-services"
version = "0.1.0" version = "0.2.0-alpha"
edition = "2021" edition = "2021"
[[bin]] [[bin]]
-1
View File
@@ -12,7 +12,6 @@
use nixpanel_common::{SectionRequest, SectionResponse, ServiceInfo}; use nixpanel_common::{SectionRequest, SectionResponse, ServiceInfo};
use std::io::{self, BufRead}; use std::io::{self, BufRead};
use std::process::Command; use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// Canonical list of managed services (systemd unit → display name). /// Canonical list of managed services (systemd unit → display name).
const SERVICES: &[(&str, &str)] = &[ const SERVICES: &[(&str, &str)] = &[
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "nixpanel-sysinfo" name = "nixpanel-sysinfo"
version = "0.1.0" version = "0.2.0-alpha"
edition = "2021" edition = "2021"
[[bin]] [[bin]]
-1
View File
@@ -129,7 +129,6 @@ fn get_ram() -> (u64, u64) {
fn get_disk() -> (u64, u64) { fn get_disk() -> (u64, u64) {
// Returns (used_gb, total_gb) for / // Returns (used_gb, total_gb) for /
use std::os::unix::fs::MetadataExt;
let stat = nix_statvfs("/"); let stat = nix_statvfs("/");
stat.unwrap_or((0, 0)) stat.unwrap_or((0, 0))
} }
+666 -133
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[]> { export async function getAccounts(): Promise<Account[]> {
return apiFetch('/accounts') return apiFetch('/accounts')
} }
export async function getAccount(username: string): Promise<Account> {
return apiFetch(`/accounts/${username}`)
}
export async function createAccount(data: { export async function createAccount(data: {
username: string username: string
domain: string domain: string
@@ -135,3 +138,92 @@ export async function createAccount(data: {
}): Promise<Account> { }): Promise<Account> {
return apiFetch('/accounts', { method: 'POST', body: JSON.stringify(data) }) 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' })
}