Compare commits
10
Commits
5247c77132
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ac1adcdbb | ||
|
|
079170b43a | ||
|
|
e79a8a996a | ||
|
|
2eb8198267 | ||
|
|
389652b2e5 | ||
|
|
cb6be7c634 | ||
|
|
09b043d764 | ||
|
|
144d08a581 | ||
|
|
7724c86572 | ||
|
|
d3b27bb90c |
+126
-18
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ resolver = "2"
|
||||
members = [
|
||||
"nixpanel-common",
|
||||
"nixpanel-api",
|
||||
"nixpanel-accounts",
|
||||
"nixpanel-services",
|
||||
"nixpanel-sysinfo",
|
||||
]
|
||||
|
||||
+11
-1
@@ -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."
|
||||
|
||||
+590
-205
File diff suppressed because it is too large
Load Diff
+66
-4
@@ -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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "nixpanel"
|
||||
version = "0.1.0"
|
||||
version = "0.4.0-alpha"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "nixpanel-common"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0-alpha"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nixpanel-services"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0-alpha"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "nixpanel-sysinfo"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0-alpha"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
+666
-133
File diff suppressed because it is too large
Load Diff
@@ -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' })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user