Compare commits
10
Commits
5247c77132
...
2ac1adcdbb
| 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]
|
## [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
|
||||||
|
|||||||
@@ -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
@@ -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."
|
||||||
|
|||||||
+589
-204
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()
|
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,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]
|
[package]
|
||||||
name = "nixpanel"
|
name = "nixpanel"
|
||||||
version = "0.1.0"
|
version = "0.4.0-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
@@ -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
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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;
|
||||||
|
|||||||
@@ -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,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,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]]
|
||||||
|
|||||||
@@ -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,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]]
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
+665
-132
File diff suppressed because it is too large
Load Diff
@@ -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' })
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user