Compare commits
24
Commits
v0.1.0-alpha
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ac1adcdbb | ||
|
|
079170b43a | ||
|
|
e79a8a996a | ||
|
|
2eb8198267 | ||
|
|
389652b2e5 | ||
|
|
cb6be7c634 | ||
|
|
09b043d764 | ||
|
|
144d08a581 | ||
|
|
7724c86572 | ||
|
|
d3b27bb90c | ||
|
|
5247c77132 | ||
|
|
a257b5571c | ||
|
|
0f28e879f0 | ||
|
|
4428a49876 | ||
|
|
7603fa5793 | ||
|
|
fb975cdc4e | ||
|
|
2735b9c8f4 | ||
|
|
5881f06cbc | ||
|
|
5cefe15f6a | ||
|
|
e58769a4bd | ||
|
|
4994d33fd5 | ||
|
|
f063af70d8 | ||
|
|
776d328ace | ||
|
|
01e64d5c61 |
@@ -0,0 +1,72 @@
|
||||
name: "Security Audit"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master", "main"]
|
||||
pull_request:
|
||||
branches: ["master", "main"]
|
||||
schedule:
|
||||
# Run every Monday at 08:00 UTC to catch newly disclosed CVEs
|
||||
- cron: "0 8 * * 1"
|
||||
|
||||
jobs:
|
||||
# ── Rust dependency audit (cargo-audit) ──────────────────────────────────────
|
||||
# Skipped automatically when no Cargo.toml is present at repo root.
|
||||
rust-audit:
|
||||
name: Rust Security Audit
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check for Cargo.toml
|
||||
id: has_cargo
|
||||
run: |
|
||||
if [ -f Cargo.toml ]; then
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No Cargo.toml found — skipping Rust audit"
|
||||
fi
|
||||
|
||||
- name: Install Rust stable
|
||||
if: steps.has_cargo.outputs.present == 'true'
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo registry
|
||||
if: steps.has_cargo.outputs.present == 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
key: ${{ runner.os }}-cargo-audit-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Install and run cargo-audit
|
||||
if: steps.has_cargo.outputs.present == 'true'
|
||||
run: |
|
||||
cargo install cargo-audit --locked
|
||||
cargo audit --deny warnings
|
||||
|
||||
# ── Shell script security lint (ShellCheck) ───────────────────────────────────
|
||||
# Fails only on errors (SC severity ≥ error). Warnings are reported but
|
||||
# do not block the build — keeping CI signal meaningful.
|
||||
shellcheck:
|
||||
name: ShellCheck
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run ShellCheck
|
||||
uses: ludeeus/[email protected]
|
||||
with:
|
||||
severity: error
|
||||
scandir: "."
|
||||
ignore_paths: ".git"
|
||||
+127
-30
@@ -9,16 +9,125 @@ Versions follow [Semantic Versioning](https://semver.org/).
|
||||
## [Unreleased]
|
||||
|
||||
### Planned
|
||||
- NixServer (WHM) — full Rust backend
|
||||
- NixClient (cPanel) — full Rust backend
|
||||
- DNS zone editor
|
||||
- Email account management
|
||||
- SSL certificate management via Let's Encrypt
|
||||
- SSL certificate management (Let's Encrypt via Certbot)
|
||||
- File manager
|
||||
- MySQL/MariaDB database manager
|
||||
- FTP account management (vsftpd)
|
||||
- WordPress one-click installer
|
||||
- Backup / restore system
|
||||
- Firewall rule editor (CSF/iptables)
|
||||
- Multi-server support (Agency plan)
|
||||
- NixClient pages: FTP, subdomains, redirects, PHP version selector, stats
|
||||
|
||||
---
|
||||
|
||||
## [0.4.0-alpha] — 2026-04-26
|
||||
|
||||
### Added
|
||||
- **MySQL / MariaDB database manager**
|
||||
- `GET /api/databases` — list all databases across all accounts (admin)
|
||||
- `GET /api/databases/:username` — list databases for one hosting account
|
||||
- `POST /api/databases` — create database + dedicated MariaDB user with GRANT ALL
|
||||
- `DELETE /api/databases/name/:db_name` — drop database + user, cascade nixpanel record
|
||||
- cPanel-style namespacing: `username_dbname` / `username_dbuser` (max 64/32 chars)
|
||||
- Identifier validation (alphanumeric + underscore only)
|
||||
- Live connection-details preview in the create form (database, username, host, port)
|
||||
- Safety check: refuses to drop databases without the account prefix separator
|
||||
- **NixServer — Databases page**: admin view across all accounts, create form with live preview, drop with confirmation modal
|
||||
- **NixClient — MySQL Databases page**: per-account view, create with password confirm + connection-info card, drop with confirmation
|
||||
|
||||
### Changed
|
||||
- NixServer/NixClient footer version: `v0.3.0-alpha` → `v0.4.0-alpha`
|
||||
|
||||
---
|
||||
|
||||
## [0.3.0-alpha] — 2026-04-25
|
||||
|
||||
### Added
|
||||
- **DNS Management** — full CRUD via pdnsutil
|
||||
- `GET /api/dns` — list all PowerDNS zones
|
||||
- `GET /api/dns/:domain` — list records (A, AAAA, MX, TXT, CNAME, NS, SRV, CAA)
|
||||
- `POST /api/dns/:domain` — add record (name, type, TTL, content)
|
||||
- `DELETE /api/dns/:domain` — delete an RRset (name + type)
|
||||
- SOA records hidden; zone auto-rectified after each change
|
||||
- **NixServer — Zone Manager page** (`domains`): lists all PowerDNS zones, click → DNS editor
|
||||
- **NixServer — DNS Records page** (`dns`): record table with inline add/delete, zone switcher dropdown, colour-coded type badges
|
||||
- **NixServer — Service Manager page** (`services`): dedicated page with refresh button and running/stopped summary pills
|
||||
- **NixServer — Account actions**: Suspend / Unsuspend / Terminate buttons with confirmation modal
|
||||
- `POST /api/accounts/:username/suspend` / `unsuspend`
|
||||
- `DELETE /api/accounts/:username` — deprovisions vhost, FPM, DNS; removes system user; cascades DB records
|
||||
- **Email account management**
|
||||
- `GET /api/email/:username` — list email accounts for a hosting account
|
||||
- `POST /api/email` — create mailbox (Maildir provisioned under `~/mail/`)
|
||||
- `DELETE /api/email/id/:id` — delete email account
|
||||
- **NixClient — Email Accounts page** — create, list, delete mailboxes with quota display
|
||||
- **NixClient — Domains page** — shows primary domain, document root, quota, bandwidth
|
||||
- **NixClient — Change Password page** — verifies current password, hashes and updates both `users` and `accounts` tables
|
||||
- **NixClient — AccountSidebar** now fetches real account data (domain, package, quota) from `GET /api/accounts/:username`
|
||||
- **Password change API** — `POST /api/me/password` (verifies old password via Argon2id, updates `users` + `accounts`)
|
||||
- **Single account lookup** — `GET /api/accounts/:username`
|
||||
|
||||
### Changed
|
||||
- NixServer footer / login version bump: `v0.2.0-alpha` → `v0.3.0-alpha`
|
||||
- NixClient footer / login version bump: `v0.2.0-alpha` → `v0.3.0-alpha`
|
||||
- List Accounts: domain cell links to site, suspend/terminate replace placeholder Edit button
|
||||
|
||||
---
|
||||
|
||||
## [0.2.0-alpha] — 2026-04-25
|
||||
|
||||
### Added
|
||||
- **Rust backend** — Cargo workspace with independently compiled + UPX-compressed section executables
|
||||
- `nixpanel-common` — shared `SectionRequest` / `SectionResponse` IPC types
|
||||
- `nixpanel-api` — main Axum HTTP server on port 4000
|
||||
- `POST /api/auth/login` — Argon2id password verification, HS256 JWT (8-hour expiry)
|
||||
- `GET /api/dashboard` — real account/domain/database/email counts + system stats
|
||||
- `GET /api/services` — live service status via systemctl
|
||||
- `POST /api/services/:name/:action` — start / stop / restart any managed service
|
||||
- `GET /api/accounts` — list hosting accounts from MariaDB
|
||||
- `POST /api/accounts` — create account (system user, home dir, DB record)
|
||||
- Auto-runs DB schema migrations on first start
|
||||
- Seeds admin user from `/opt/nixpanel/.admin_user` + `.admin_pass` on first run
|
||||
- `nixpanel-services` — section binary; reads JSON from stdin, calls systemctl, writes JSON to stdout
|
||||
- Actions: `list`, `start`, `stop`, `restart`, `reload`
|
||||
- Reports PID and uptime per service
|
||||
- `nixpanel-sysinfo` — section binary; reads `/proc/meminfo`, `/proc/stat`, `/proc/loadavg`, `df`
|
||||
- Actions: `stats` (CPU, RAM, disk, load, hostname, IP, OS, kernel), `versions` (Apache, PHP, MariaDB)
|
||||
- **Database schema** (`nixpanel-api/migrations/0001_initial.sql`)
|
||||
- Tables: `users`, `accounts`, `domains`, `dns_records`, `account_databases`,
|
||||
`email_accounts`, `ftp_accounts`, `ssl_certificates`, `backups`
|
||||
- **Frontend API clients** (`nixserver/src/api.ts`, `nixclient/src/api.ts`)
|
||||
- JWT stored in `localStorage`, included in all requests as `Authorization: Bearer`
|
||||
- Auto-clears token and reloads on 401
|
||||
|
||||
### Changed
|
||||
- **NixServer UI** — full redesign matching WHM visual style
|
||||
- Dark navy / orange-accent theme
|
||||
- Collapsible sidebar with grouped navigation sections
|
||||
- Dashboard: live stat bar, server info bar, service manager table, quick-action icon grid
|
||||
- List Accounts: real data table with search/filter
|
||||
- Create Account: full form wired to API (creates system user, DB record)
|
||||
- Login: real JWT auth; session persists across refresh via `localStorage`
|
||||
- **NixClient UI** — full redesign matching cPanel visual style
|
||||
- Blue-accent theme
|
||||
- Feature icon grid grouped by category with live search
|
||||
- Account sidebar with usage bars
|
||||
- Login: real JWT auth
|
||||
- **Installer** (`install.sh`)
|
||||
- Rust build step now copies all `nixpanel-*` section binaries to `/opt/nixpanel/bin/`
|
||||
- Installs UPX and compresses each binary after build
|
||||
- Admin credentials auto-generated with `openssl rand` (no TTY prompts)
|
||||
- Node.js 20 LTS installed as its own idempotent step (`nodejs.done`)
|
||||
- NixClient build uses absolute path to avoid wrong-directory check
|
||||
- npm output visible during build (removed `--silent`) for easier debugging
|
||||
- **Vite configs** — added `/api` proxy to `localhost:4000` for local development
|
||||
- **Minifier** — switched both UIs from `terser` (not installed) to `esbuild`
|
||||
|
||||
### Fixed
|
||||
- Login inputs changed from `type="email"` to `type="text"` with "Username" label
|
||||
- `sqlx::query!()` macros replaced with non-macro `query()` / `query_as()` / `query_scalar()`
|
||||
so the binary compiles without a live database connection
|
||||
- Removed `axum_extra` import that was never added to `Cargo.toml`
|
||||
- Git clone always removes stale `$SRC_DIR` before cloning (fixes "couldn't find remote ref master" on re-run)
|
||||
|
||||
---
|
||||
|
||||
@@ -28,33 +137,21 @@ Versions follow [Semantic Versioning](https://semver.org/).
|
||||
- **Installer** (`install.sh`) — fully automated Ubuntu 24.04 installer
|
||||
- Self-updating: always fetches the latest version from GitHub before running
|
||||
- Idempotent step tracking — re-runnable after failures without redoing completed steps
|
||||
- Installs Apache 2, PHP 8.2 + 8.3 (via ondrej/php PPA), MariaDB, PowerDNS, Exim 4, Dovecot, SpamAssassin, Fail2ban, Certbot
|
||||
- Installs Apache 2, PHP 8.2 + 8.3 (via ondrej/php PPA), MariaDB, PowerDNS,
|
||||
Exim 4, Dovecot, SpamAssassin, Fail2ban, Certbot
|
||||
- Configures Apache reverse proxy on ports 2083 (NixClient) and 2087 (NixServer)
|
||||
- Prompts for admin credentials, hostname, and license key during install
|
||||
- License activation against `https://license.nixpanel.io` with grace period fallback
|
||||
- **Licensing server** (`nixpanel-licensing`) — central SaaS licensing
|
||||
- Rust · Axum · SQLx · MariaDB
|
||||
- RSA-4096 signed JWT license tokens
|
||||
- Server IP binding with conflict detection
|
||||
- Generates admin credentials automatically; displays them in the completion banner
|
||||
- License activation against `https://license.nixpanel.io`
|
||||
- **NixServer** — WHM-style admin panel (React 18 + TypeScript + Tailwind + Vite, port 2087)
|
||||
- **NixClient** — cPanel-style user panel (React 18 + TypeScript + Tailwind + Vite, port 2083)
|
||||
- **Licensing** — license key validated against `https://license.nixpanel.io` during install
|
||||
- Plans: Free, Solo ($9.99/mo), Host ($29.99/mo), Agency ($79.99/mo)
|
||||
- Admin API with Argon2id authentication
|
||||
- Validation logging and grace period enforcement
|
||||
- Repo: [NixReaper/nixpanel-licensing](https://github.com/NixReaper/nixpanel-licensing)
|
||||
- **Website API** (`nixpanel-website-api`) — nixpanel.io customer portal backend
|
||||
- Rust · Axum · SQLx · MariaDB
|
||||
- Stripe subscriptions (checkout sessions, billing portal, webhooks)
|
||||
- Customer auth: register, login, email verification, password reset (Argon2id)
|
||||
- License management: view active license, IP change requests
|
||||
- SMTP transactional email via Lettre
|
||||
- Repo: [NixReaper/nixpanel-website](https://github.com/NixReaper/nixpanel-website)
|
||||
- **Website frontend** — nixpanel.io marketing & customer portal
|
||||
- React 18 · TypeScript · Tailwind CSS · Vite
|
||||
- Pages: Home, Pricing (monthly/annual toggle), Login, Register, Dashboard, Email Verify, Password Reset
|
||||
|
||||
### Infrastructure
|
||||
- `.gitattributes` enforcing LF line endings for `.sh`, `.rs`, `.toml`, `.sql`, `.md` files
|
||||
- **Infrastructure** — `.gitattributes` enforcing LF line endings
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/NixReaper/nixpanel/compare/v0.1.0-alpha...HEAD
|
||||
[Unreleased]: https://github.com/NixReaper/nixpanel/compare/v0.4.0-alpha...HEAD
|
||||
[0.4.0-alpha]: https://github.com/NixReaper/nixpanel/compare/v0.3.0-alpha...v0.4.0-alpha
|
||||
[0.3.0-alpha]: https://github.com/NixReaper/nixpanel/compare/v0.2.0-alpha...v0.3.0-alpha
|
||||
[0.2.0-alpha]: https://github.com/NixReaper/nixpanel/compare/v0.1.0-alpha...v0.2.0-alpha
|
||||
[0.1.0-alpha]: https://github.com/NixReaper/nixpanel/releases/tag/v0.1.0-alpha
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"nixpanel-common",
|
||||
"nixpanel-api",
|
||||
"nixpanel-accounts",
|
||||
"nixpanel-services",
|
||||
"nixpanel-sysinfo",
|
||||
]
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Only the latest release of NixPanel receives security fixes.
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| Latest stable | ✅ |
|
||||
| Older releases | ❌ |
|
||||
|
||||
We strongly recommend always running the latest version. The installer is self-updating and will always pull the current release.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
### Option 1 — GitHub Private Vulnerability Reporting (preferred)
|
||||
|
||||
Use GitHub's built-in private reporting:
|
||||
[Report a vulnerability](https://github.com/NixReaper/nixpanel/security/advisories/new)
|
||||
|
||||
This keeps the report confidential until a fix is released.
|
||||
|
||||
### Option 2 — Email
|
||||
|
||||
Send details to **[email protected]**
|
||||
|
||||
Include:
|
||||
- A description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Affected versions
|
||||
- Any proof-of-concept code (if available)
|
||||
|
||||
We will acknowledge your report within **48 hours** and aim to release a fix within **14 days** for critical issues.
|
||||
|
||||
## Scope
|
||||
|
||||
Issues we consider in-scope:
|
||||
|
||||
- Remote code execution
|
||||
- Privilege escalation
|
||||
- Authentication bypass
|
||||
- SQL injection
|
||||
- Sensitive data exposure (credentials, license keys, private keys)
|
||||
- Cross-site scripting (XSS) in the control panel UI
|
||||
- Server-side request forgery (SSRF)
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Vulnerabilities in software NixPanel installs but does not control (Apache, MariaDB, PHP, etc.) — report those to the respective upstream projects
|
||||
- Issues requiring physical access to the server
|
||||
- Social engineering attacks
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
After installing NixPanel:
|
||||
|
||||
1. **Run certbot immediately** — never leave the panel on plain HTTP in production
|
||||
```bash
|
||||
certbot --apache
|
||||
```
|
||||
2. **Restrict panel ports** — firewall ports 2083 and 2087 to trusted IPs only
|
||||
3. **Keep the system updated** — `apt upgrade` regularly
|
||||
4. **Use strong admin passwords** — the panel enforces Argon2id hashing
|
||||
5. **Monitor logs** — `journalctl -u nixpanel -f` and `/var/log/nixpanel/`
|
||||
|
||||
## Disclosure Policy
|
||||
|
||||
We follow **coordinated disclosure**. We ask that you:
|
||||
|
||||
- Give us reasonable time to fix the issue before public disclosure
|
||||
- Not access or modify other users' data during testing
|
||||
- Not perform denial-of-service testing
|
||||
|
||||
We will credit researchers in release notes unless you prefer to remain anonymous.
|
||||
+116
-53
@@ -96,6 +96,15 @@ if ! step_done "packages"; then
|
||||
# Prereqs for add-apt-repository
|
||||
apt-get install -y -qq ca-certificates gnupg software-properties-common
|
||||
|
||||
# Node.js 20 LTS via NodeSource (required for UI builds)
|
||||
if ! command -v node &>/dev/null; then
|
||||
info "Adding NodeSource Node.js 20 LTS repository…"
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - 2>/dev/null
|
||||
success "NodeSource repository added"
|
||||
else
|
||||
info "Node.js $(node --version) already present"
|
||||
fi
|
||||
|
||||
# ondrej/php PPA is required for PHP 8.2 on Ubuntu 24.04
|
||||
if ! grep -rq "ondrej/php" /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null; then
|
||||
info "Adding ondrej/php PPA (required for PHP 8.2 on Ubuntu 24.04)…"
|
||||
@@ -108,7 +117,7 @@ if ! step_done "packages"; then
|
||||
|
||||
apt-get install -y -qq \
|
||||
build-essential pkg-config libssl-dev libmariadb-dev curl wget git unzip \
|
||||
ca-certificates gnupg software-properties-common \
|
||||
ca-certificates gnupg software-properties-common nodejs \
|
||||
apache2 \
|
||||
php8.3 php8.3-fpm php8.3-cli php8.3-mysql php8.3-curl php8.3-mbstring \
|
||||
php8.3-xml php8.3-zip php8.3-gd php8.3-intl php8.3-bcmath \
|
||||
@@ -127,7 +136,20 @@ if ! step_done "packages"; then
|
||||
fi
|
||||
success "System packages installed"
|
||||
|
||||
# ── 4. Rust toolchain ─────────────────────────────────────────────────────────
|
||||
# ── 4. Node.js 20 LTS ─────────────────────────────────────────────────────────
|
||||
step "Installing Node.js"
|
||||
if ! step_done "nodejs"; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
if ! command -v node &>/dev/null || [[ "$(node --version | cut -d. -f1 | tr -d 'v')" -lt 18 ]]; then
|
||||
info "Installing Node.js 20 LTS via NodeSource…"
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - 2>/dev/null
|
||||
apt-get install -y -qq nodejs
|
||||
fi
|
||||
mark_done "nodejs"
|
||||
fi
|
||||
success "Node.js $(node --version) / npm $(npm --version) ready"
|
||||
|
||||
# ── 5. Rust toolchain ─────────────────────────────────────────────────────────
|
||||
step "Installing Rust toolchain"
|
||||
if ! step_done "rust"; then
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||
@@ -178,31 +200,91 @@ if ! step_done "source"; then
|
||||
[[ -z "$LATEST_TAG" ]] && LATEST_TAG="main"
|
||||
info "Fetching tag: $LATEST_TAG"
|
||||
|
||||
if [[ -d "$SRC_DIR/.git" ]]; then
|
||||
git -C "$SRC_DIR" fetch --tags --quiet
|
||||
git -C "$SRC_DIR" checkout "$LATEST_TAG" --quiet
|
||||
else
|
||||
git clone --depth 1 --branch "$LATEST_TAG" \
|
||||
"https://github.com/${GITHUB_REPO}.git" "$SRC_DIR" 2>/dev/null \
|
||||
|| git clone --depth 1 "https://github.com/${GITHUB_REPO}.git" "$SRC_DIR"
|
||||
fi
|
||||
# Always do a clean clone when re-running this step — avoids stale .git configs
|
||||
# (e.g. old clones that tracked 'master' before the branch was renamed to 'main')
|
||||
rm -rf "$SRC_DIR"
|
||||
git clone --depth 1 --branch "$LATEST_TAG" \
|
||||
"https://github.com/${GITHUB_REPO}.git" "$SRC_DIR"
|
||||
mark_done "source"
|
||||
fi
|
||||
success "Source ready"
|
||||
|
||||
# ── 8. Build panel binary ─────────────────────────────────────────────────────
|
||||
# ── 8. Build panel UIs (React apps) ───────────────────────────────────────────
|
||||
step "Building NixServer & NixClient UIs"
|
||||
if ! step_done "build_uis"; then
|
||||
cd "$SRC_DIR"
|
||||
|
||||
# Build NixServer (WHM admin panel)
|
||||
if [[ -f "nixserver/package.json" ]]; then
|
||||
info "Building NixServer…"
|
||||
cd "$SRC_DIR/nixserver"
|
||||
npm install --legacy-peer-deps
|
||||
npm run build
|
||||
mkdir -p "$INSTALL_DIR/nixserver/dist"
|
||||
cp -r dist/* "$INSTALL_DIR/nixserver/dist/"
|
||||
success "NixServer built"
|
||||
fi
|
||||
|
||||
# Build NixClient (cPanel user panel)
|
||||
if [[ -f "$SRC_DIR/nixclient/package.json" ]]; then
|
||||
info "Building NixClient…"
|
||||
cd "$SRC_DIR/nixclient"
|
||||
npm install --legacy-peer-deps
|
||||
npm run build
|
||||
mkdir -p "$INSTALL_DIR/nixclient/dist"
|
||||
cp -r dist/* "$INSTALL_DIR/nixclient/dist/"
|
||||
success "NixClient built"
|
||||
fi
|
||||
|
||||
mark_done "build_uis"
|
||||
fi
|
||||
|
||||
# ── 9. Build panel binary ─────────────────────────────────────────────────────
|
||||
step "Building nixpanel (Rust)"
|
||||
if ! step_done "build_panel"; then
|
||||
if [[ -f "$SRC_DIR/Cargo.toml" ]]; then
|
||||
cd "$SRC_DIR"
|
||||
$CARGO build --release --quiet
|
||||
# Copy any produced binaries
|
||||
[[ -f "target/release/nixpanel" ]] && \
|
||||
cp target/release/nixpanel "$BIN_DIR/nixpanel" && chmod 755 "$BIN_DIR/nixpanel"
|
||||
info "Compiling nixpanel workspace (this may take a few minutes on first build)…"
|
||||
$CARGO build --release
|
||||
|
||||
# Install UPX if available for binary compression
|
||||
if ! command -v upx &>/dev/null; then
|
||||
apt-get install -y -qq upx-ucl 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Main API binary — stop service first so the file isn't busy
|
||||
if [[ -f "target/release/nixpanel" ]]; then
|
||||
systemctl stop nixpanel 2>/dev/null || true
|
||||
cp target/release/nixpanel "$BIN_DIR/nixpanel"
|
||||
chmod 755 "$BIN_DIR/nixpanel"
|
||||
command -v upx &>/dev/null && upx --best --quiet "$BIN_DIR/nixpanel" 2>/dev/null || true
|
||||
success "nixpanel binary installed"
|
||||
fi
|
||||
|
||||
# Section binaries — each section is a separate compressed executable
|
||||
mkdir -p "$BIN_DIR"
|
||||
for section_bin in target/release/nixpanel-*; do
|
||||
[[ -f "$section_bin" ]] || continue
|
||||
# Skip debug/metadata files produced by UPX (.d files etc.)
|
||||
[[ "$section_bin" == *.* ]] && continue
|
||||
bin_name=$(basename "$section_bin")
|
||||
cp "$section_bin" "$BIN_DIR/$bin_name"
|
||||
chmod 755 "$BIN_DIR/$bin_name"
|
||||
command -v upx &>/dev/null && upx --best --quiet "$BIN_DIR/$bin_name" 2>/dev/null || true
|
||||
success "Section binary installed: $bin_name"
|
||||
done
|
||||
# Remove any UPX metadata files
|
||||
rm -f "$BIN_DIR"/*.d
|
||||
|
||||
mark_done "build_panel"
|
||||
success "Panel binary built"
|
||||
success "Panel binaries built and compressed"
|
||||
|
||||
# Always restart the service after a binary update
|
||||
if systemctl is-enabled nixpanel &>/dev/null; then
|
||||
systemctl restart nixpanel 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
warn "Panel Rust source 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."
|
||||
fi
|
||||
fi
|
||||
@@ -222,45 +304,23 @@ success "Secrets ready"
|
||||
# ── 10. Admin setup ───────────────────────────────────────────────────────────
|
||||
step "Panel configuration"
|
||||
if ! step_done "admin_account"; then
|
||||
echo ""
|
||||
echo -e " ${BOLD}Create your NixPanel admin account${RESET}"
|
||||
read -rp " Admin username [admin]: " ADMIN_USER
|
||||
ADMIN_USER="${ADMIN_USER:-admin}"
|
||||
read -rsp " Admin password: " ADMIN_PASS; echo ""
|
||||
read -rsp " Confirm password: " ADMIN_PASS2; echo ""
|
||||
[[ "$ADMIN_PASS" != "$ADMIN_PASS2" ]] && error "Passwords do not match"
|
||||
read -rp " Admin email: " ADMIN_EMAIL
|
||||
read -rp " Server hostname/domain [$(hostname -f)]: " SERVER_HOST
|
||||
SERVER_HOST="${SERVER_HOST:-$(hostname -f)}"
|
||||
read -rp " NixPanel license key: " LICENSE_KEY
|
||||
ADMIN_USER="admin"
|
||||
ADMIN_PASS=$(openssl rand -base64 16 | tr -d '/+=' | head -c 20)
|
||||
ADMIN_EMAIL="admin@$(hostname -f)"
|
||||
SERVER_HOST=$(hostname -f)
|
||||
|
||||
echo "$SERVER_HOST" > "$INSTALL_DIR/.server_host"
|
||||
echo "$LICENSE_KEY" > "$INSTALL_DIR/.license_key"
|
||||
chmod 600 "$INSTALL_DIR/.server_host" "$INSTALL_DIR/.license_key"
|
||||
|
||||
# Validate license key against central server
|
||||
info "Validating license key with ${LICENSING_URL}…"
|
||||
SERVER_IP=$(hostname -I | awk '{print $1}')
|
||||
HTTP_STATUS=$(curl -s -o /tmp/lic_resp.json -w "%{http_code}" \
|
||||
-X POST "${LICENSING_URL}/api/v1/activate" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"license_key\":\"${LICENSE_KEY}\",\"server_ip\":\"${SERVER_IP}\"}" \
|
||||
2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$HTTP_STATUS" == "200" ]]; then
|
||||
success "License activated"
|
||||
elif [[ "$HTTP_STATUS" == "000" ]]; then
|
||||
warn "Could not reach ${LICENSING_URL} — proceeding in grace period mode"
|
||||
else
|
||||
warn "License server returned HTTP ${HTTP_STATUS}:"
|
||||
cat /tmp/lic_resp.json 2>/dev/null || true
|
||||
echo ""
|
||||
warn "Continuing install — fix license key in ${INSTALL_DIR}/.license_key and restart nixpanel"
|
||||
fi
|
||||
echo "" > "$INSTALL_DIR/.license_key" # set via nixpanel.io after install
|
||||
echo "$ADMIN_USER" > "$INSTALL_DIR/.admin_user"
|
||||
echo "$ADMIN_PASS" > "$INSTALL_DIR/.admin_pass"
|
||||
chmod 600 "$INSTALL_DIR/.server_host" "$INSTALL_DIR/.license_key" \
|
||||
"$INSTALL_DIR/.admin_user" "$INSTALL_DIR/.admin_pass"
|
||||
|
||||
mark_done "admin_account"
|
||||
else
|
||||
SERVER_HOST=$(cat "$INSTALL_DIR/.server_host" 2>/dev/null || hostname -f)
|
||||
ADMIN_USER=$(cat "$INSTALL_DIR/.admin_user" 2>/dev/null || echo "admin")
|
||||
ADMIN_PASS=$(cat "$INSTALL_DIR/.admin_pass" 2>/dev/null || echo "(see /opt/nixpanel/.admin_pass)")
|
||||
fi
|
||||
success "Panel configuration done"
|
||||
|
||||
@@ -424,9 +484,12 @@ echo -e "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━
|
||||
echo -e "${BOLD}${GREEN} NixPanel Installation Complete!${RESET}"
|
||||
echo -e "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
|
||||
echo ""
|
||||
echo -e " ${BOLD}WHM (Admin panel):${RESET} http://${SERVER_IP}:2087"
|
||||
echo -e " ${BOLD}cPanel (User panel):${RESET} http://${SERVER_IP}:2083"
|
||||
echo -e " ${BOLD}Licensing server:${RESET} ${LICENSING_URL} (central)"
|
||||
echo -e " ${BOLD}NixServer (Admin):${RESET} http://${SERVER_IP}:2087"
|
||||
echo -e " ${BOLD}NixClient (Users):${RESET} http://${SERVER_IP}:2083"
|
||||
echo ""
|
||||
echo -e " ${BOLD}${YELLOW}Login credentials (save these!):${RESET}"
|
||||
echo -e " ${BOLD}Username:${RESET} ${ADMIN_USER}"
|
||||
echo -e " ${BOLD}Password:${RESET} ${ADMIN_PASS}"
|
||||
echo ""
|
||||
echo -e " ${BOLD}Logs:${RESET} ${LOG_DIR}/"
|
||||
echo -e " ${BOLD}Config:${RESET} ${INSTALL_DIR}/.env"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NixClient — Your Hosting Account</title>
|
||||
</head>
|
||||
<body class="bg-gray-950">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "nixpanel-nixclient",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,894 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import * as api from './api'
|
||||
import type { AccountInfo, EmailAccount, Database } from './api'
|
||||
|
||||
/* ── Types ───────────────────────────────────────────────────────────── */
|
||||
type Page =
|
||||
| 'home' | 'domains' | 'subdomains' | 'redirects'
|
||||
| 'email' | 'emailAccounts' | 'webmail' | 'spamFilter'
|
||||
| 'databases' | 'mysql' | 'phpmyadmin'
|
||||
| 'files' | 'fileManager' | 'ftp' | 'backups'
|
||||
| 'security' | 'ssl' | 'passwords' | 'ssh'
|
||||
| 'software' | 'php' | 'wordpress'
|
||||
| 'stats' | 'logs'
|
||||
|
||||
/* ── Feature groups ──────────────────────────────────────────────────── */
|
||||
type Feature = { id: Page; label: string; icon: string; desc: string }
|
||||
type FeatureGroup = { label: string; color: string; features: Feature[] }
|
||||
|
||||
const FEATURE_GROUPS: FeatureGroup[] = [
|
||||
{
|
||||
label: 'Domains', color: 'blue', features: [
|
||||
{ id: 'domains', label: 'Domains', icon: '🌐', desc: 'Addon & parked domains' },
|
||||
{ id: 'subdomains', label: 'Subdomains', icon: '🔗', desc: 'Create subdomains' },
|
||||
{ id: 'redirects', label: 'Redirects', icon: '↪️', desc: '301 / 302 redirects' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Email', color: 'emerald', features: [
|
||||
{ id: 'emailAccounts', label: 'Email Accounts', icon: '✉️', desc: 'Create & manage mailboxes' },
|
||||
{ id: 'webmail', label: 'Webmail', icon: '📬', desc: 'Open Roundcube webmail' },
|
||||
{ id: 'spamFilter', label: 'Spam Filters', icon: '🚫', desc: 'SpamAssassin rules' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Databases', color: 'purple', features: [
|
||||
{ id: 'mysql', label: 'MySQL Databases', icon: '🗄️', desc: 'Create & manage DBs' },
|
||||
{ id: 'phpmyadmin', label: 'phpMyAdmin', icon: '🖥️', desc: 'Database admin GUI' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Files', color: 'orange', features: [
|
||||
{ id: 'fileManager', label: 'File Manager', icon: '📁', desc: 'Browse & edit files' },
|
||||
{ id: 'ftp', label: 'FTP Accounts', icon: '📡', desc: 'Manage FTP users' },
|
||||
{ id: 'backups', label: 'Backups', icon: '💾', desc: 'Download & restore' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Security', color: 'red', features: [
|
||||
{ id: 'ssl', label: 'SSL / TLS', icon: '🔒', desc: "Let's Encrypt certs" },
|
||||
{ id: 'passwords', label: 'Password', icon: '🔑', desc: 'Change cPanel password' },
|
||||
{ id: 'ssh', label: 'SSH Keys', icon: '🗝️', desc: 'Manage SSH key pairs' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Software', color: 'cyan', features: [
|
||||
{ id: 'php', label: 'PHP Version', icon: '🐘', desc: 'Select PHP 8.x' },
|
||||
{ id: 'wordpress', label: 'WordPress', icon: '🌀', desc: 'Auto-install WP' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Metrics', color: 'yellow', features: [
|
||||
{ id: 'stats', label: 'Statistics', icon: '📊', desc: 'Bandwidth & visitors' },
|
||||
{ id: 'logs', label: 'Logs', icon: '📜', desc: 'Error & access logs' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const COLOR_MAP: Record<string, { tile: string; icon: string }> = {
|
||||
blue: { tile: 'hover:border-blue-500/40 hover:bg-blue-500/5', icon: 'bg-blue-500/15 text-blue-400' },
|
||||
emerald: { tile: 'hover:border-emerald-500/40 hover:bg-emerald-500/5', icon: 'bg-emerald-500/15 text-emerald-400' },
|
||||
purple: { tile: 'hover:border-purple-500/40 hover:bg-purple-500/5', icon: 'bg-purple-500/15 text-purple-400' },
|
||||
orange: { tile: 'hover:border-orange-500/40 hover:bg-orange-500/5', icon: 'bg-orange-500/15 text-orange-400' },
|
||||
red: { tile: 'hover:border-red-500/40 hover:bg-red-500/5', icon: 'bg-red-500/15 text-red-400' },
|
||||
cyan: { tile: 'hover:border-cyan-500/40 hover:bg-cyan-500/5', icon: 'bg-cyan-500/15 text-cyan-400' },
|
||||
yellow: { tile: 'hover:border-yellow-500/40 hover:bg-yellow-500/5', icon: 'bg-yellow-500/15 text-yellow-400' },
|
||||
}
|
||||
|
||||
/* ── Usage bar ───────────────────────────────────────────────────────── */
|
||||
function UsageBar({ label, used, total, unit, color }: { label: string; used: number; total: number; unit: string; color: string }) {
|
||||
const pct = total > 0 ? Math.min((used / total) * 100, 100) : 0
|
||||
const barColor = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : color
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-gray-400 font-medium">{label}</span>
|
||||
<span className="text-gray-500">{used === 0 ? '0' : used} / {total === 0 ? '∞' : total + ' ' + unit}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-[#2a3044] rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all ${barColor}`} style={{ width: `${total === 0 ? 5 : pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Account sidebar ─────────────────────────────────────────────────── */
|
||||
function AccountSidebar({ account, username, onNav }: {
|
||||
account: AccountInfo | null; username: string; onNav: (p: Page) => void
|
||||
}) {
|
||||
const domain = account?.domain ?? username
|
||||
return (
|
||||
<aside className="w-64 flex-shrink-0 space-y-4">
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-xl overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-blue-700 to-blue-600 px-4 py-4">
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center text-white text-lg font-black mb-2">
|
||||
{domain.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<p className="text-white font-bold text-sm leading-tight">{domain}</p>
|
||||
<p className="text-blue-200 text-xs mt-0.5">{account?.package_name ?? 'Hosting'} Account</p>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<UsageBar label="Disk Space" used={0} total={account?.disk_quota_mb ?? 0} unit="MB" color="bg-blue-500" />
|
||||
<UsageBar label="Bandwidth" used={0} total={account?.bandwidth_mb ?? 0} unit="MB" color="bg-emerald-500" />
|
||||
<UsageBar label="Email" used={0} total={0} unit="" color="bg-purple-500" />
|
||||
<UsageBar label="Databases" used={0} total={0} unit="" color="bg-orange-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-xl p-4 space-y-2">
|
||||
<p className="text-[10px] font-bold text-gray-600 uppercase tracking-widest mb-3">Account Info</p>
|
||||
{[
|
||||
{ label: 'Domain', value: account?.domain ?? '—' },
|
||||
{ label: 'Username', value: account?.username ?? username },
|
||||
{ label: 'Package', value: account?.package_name ?? '—' },
|
||||
{ label: 'Home Dir', value: `/home/${account?.username ?? username}` },
|
||||
{ label: 'Status', value: account?.status ?? '—' },
|
||||
].map(r => (
|
||||
<div key={r.label} className="flex justify-between items-center text-xs py-1 border-b border-[#252b3d] last:border-0">
|
||||
<span className="text-gray-600">{r.label}</span>
|
||||
<span className="text-gray-300 font-medium truncate max-w-[120px]">{r.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-xl overflow-hidden">
|
||||
<p className="px-4 py-2.5 text-[10px] font-bold text-gray-600 uppercase tracking-widest border-b border-[#2a3044]">Quick Links</p>
|
||||
{[
|
||||
{ label: '🌐 Visit Website', action: () => account?.domain && window.open(`http://${account.domain}`, '_blank') },
|
||||
{ label: '✉️ Email Accounts', action: () => onNav('emailAccounts') },
|
||||
{ label: '🔑 Change Password', action: () => onNav('passwords') },
|
||||
].map(l => (
|
||||
<button key={l.label} onClick={l.action}
|
||||
className="w-full flex items-center px-4 py-2.5 text-xs text-gray-400 hover:bg-[#252b3d] hover:text-white transition-colors border-b border-[#252b3d] last:border-0 text-left">
|
||||
{l.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Home / feature grid ─────────────────────────────────────────────── */
|
||||
function Home({ onNav }: { onNav: (p: Page) => void }) {
|
||||
const [search, setSearch] = useState('')
|
||||
const allFeatures = FEATURE_GROUPS.flatMap(g => g.features.map(f => ({ ...f, groupColor: g.color })))
|
||||
const filtered = search.trim()
|
||||
? allFeatures.filter(f => f.label.toLowerCase().includes(search.toLowerCase()) || f.desc.toLowerCase().includes(search.toLowerCase()))
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="relative">
|
||||
<span className="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-600 text-sm">🔍</span>
|
||||
<input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="Search features…"
|
||||
className="w-full bg-[#1a1f2e] border border-[#2a3044] focus:border-blue-500/60 text-white rounded-xl pl-9 pr-4 py-3 text-sm placeholder-gray-600 focus:outline-none focus:ring-1 focus:ring-blue-500/20 transition-all" />
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-600 hover:text-gray-400 text-xs">✕</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filtered ? (
|
||||
<div>
|
||||
<p className="text-xs text-gray-600 mb-3">{filtered.length} result{filtered.length !== 1 ? 's' : ''} for "{search}"</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{filtered.map(f => {
|
||||
const c = COLOR_MAP[f.groupColor]
|
||||
return (
|
||||
<button key={f.id} onClick={() => onNav(f.id)}
|
||||
className={`flex flex-col items-center text-center p-4 bg-[#1a1f2e] border border-[#2a3044] rounded-xl transition-all group ${c.tile}`}>
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-xl mb-2.5 ${c.icon}`}>{f.icon}</div>
|
||||
<span className="text-xs font-semibold text-gray-300 group-hover:text-white leading-tight">{f.label}</span>
|
||||
<span className="text-[10px] text-gray-600 group-hover:text-gray-400 mt-0.5 leading-tight">{f.desc}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{filtered.length === 0 && <div className="text-center py-12 text-gray-600 text-sm">No features found for "{search}"</div>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{FEATURE_GROUPS.map(group => {
|
||||
const c = COLOR_MAP[group.color]
|
||||
return (
|
||||
<div key={group.label}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h2 className="text-xs font-bold text-gray-500 uppercase tracking-widest">{group.label}</h2>
|
||||
<div className="flex-1 h-px bg-[#2a3044]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
{group.features.map(f => (
|
||||
<button key={f.id} onClick={() => onNav(f.id)}
|
||||
className={`flex flex-col items-center text-center p-4 bg-[#1a1f2e] border border-[#2a3044] rounded-xl transition-all group ${c.tile}`}>
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-xl mb-2.5 ${c.icon}`}>{f.icon}</div>
|
||||
<span className="text-xs font-semibold text-gray-300 group-hover:text-white leading-tight">{f.label}</span>
|
||||
<span className="text-[10px] text-gray-600 group-hover:text-gray-400 mt-1 leading-tight">{f.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Domains page ─────────────────────────────────────────────────────── */
|
||||
function Domains({ account }: { account: AccountInfo | null }) {
|
||||
if (!account) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-center">
|
||||
<div className="text-gray-600 text-sm">Loading domain info…</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-black text-white">Domains</h1>
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[#2a3044] bg-[#0f1520]">
|
||||
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-widest">Primary Domain</h3>
|
||||
</div>
|
||||
<div className="p-5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-lg font-bold text-white">{account.domain}</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Main domain · {account.package_name} package</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-emerald-500/20 border border-emerald-500/30 text-emerald-400 text-xs rounded-full font-semibold">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
Active
|
||||
</span>
|
||||
<a href={`http://${account.domain}`} target="_blank" rel="noopener"
|
||||
className="px-3 py-1.5 bg-[#2a3044] hover:bg-blue-500/20 hover:text-blue-400 text-gray-400 text-xs rounded-lg transition-colors">
|
||||
Visit Site ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 pb-5 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: 'Document Root', value: `/home/${account.username}/public_html` },
|
||||
{ label: 'Home Dir', value: `/home/${account.username}` },
|
||||
{ label: 'Disk Quota', value: account.disk_quota_mb === 0 ? 'Unlimited' : `${account.disk_quota_mb} MB` },
|
||||
{ label: 'Bandwidth', value: account.bandwidth_mb === 0 ? 'Unlimited' : `${account.bandwidth_mb} MB/mo` },
|
||||
].map(i => (
|
||||
<div key={i.label} className="bg-[#0f1520] border border-[#2a3044] rounded-lg p-3">
|
||||
<p className="text-[10px] font-bold text-gray-600 uppercase tracking-wider mb-1">{i.label}</p>
|
||||
<p className="text-xs text-gray-300 font-mono break-all">{i.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-bold text-white">Addon Domains</h3>
|
||||
<div className="px-3 py-1 bg-blue-500/10 border border-blue-500/20 text-blue-400 text-[11px] rounded font-semibold">Coming Soon</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">Addon domain management will be available in the next release.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Email Accounts page ─────────────────────────────────────────────── */
|
||||
function EmailAccounts({ account }: { account: AccountInfo | null }) {
|
||||
const [emails, setEmails] = useState<EmailAccount[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [form, setForm] = useState({ username: '', password: '', quota_mb: 1024 })
|
||||
|
||||
const username = account?.username ?? localStorage.getItem('nixclient_username') ?? ''
|
||||
const domain = account?.domain ?? ''
|
||||
|
||||
const flash = (m: string) => { setMsg(m); setTimeout(() => setMsg(''), 4000) }
|
||||
|
||||
const refresh = () => {
|
||||
if (!username) return
|
||||
setLoading(true)
|
||||
api.listMyEmailAccounts(username).then(setEmails).catch(console.error).finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { refresh() }, [username])
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.username || !form.password || !domain) {
|
||||
flash('Username, password, and domain are required')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.createEmailAccount({ username: form.username, domain, password: form.password, quota_mb: form.quota_mb })
|
||||
flash(`Created ${form.username}@${domain}`)
|
||||
setShowAdd(false)
|
||||
setForm({ username: '', password: '', quota_mb: 1024 })
|
||||
refresh()
|
||||
} catch (e: any) {
|
||||
flash(`Error: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number, address: string) => {
|
||||
if (!confirm(`Delete ${address}? This cannot be undone.`)) return
|
||||
try {
|
||||
await api.deleteEmailAccount(id)
|
||||
flash(`Deleted ${address}`)
|
||||
refresh()
|
||||
} catch (e: any) {
|
||||
flash(`Error: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-black text-white">Email Accounts</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{domain ? `Manage mailboxes for ${domain}` : 'Loading…'}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{msg && <span className="text-xs text-blue-400 bg-blue-500/10 border border-blue-500/20 px-3 py-1.5 rounded-lg">{msg}</span>}
|
||||
<button onClick={() => setShowAdd(!showAdd)}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded transition-colors">
|
||||
+ Create Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showAdd && (
|
||||
<form onSubmit={handleCreate} className="bg-[#1a1f2e] border border-blue-500/30 rounded-lg p-5 space-y-4">
|
||||
<h3 className="text-sm font-bold text-blue-400">New Email Account</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Username</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<input value={form.username} onChange={e => setForm(f => ({ ...f, username: e.target.value }))}
|
||||
placeholder="info"
|
||||
className="flex-1 bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-500/60" />
|
||||
<span className="text-gray-500 text-sm">@{domain || '…'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Password</label>
|
||||
<input type="password" value={form.password} onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder="••••••••"
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-500/60" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Quota (MB)</label>
|
||||
<input type="number" value={form.quota_mb} onChange={e => setForm(f => ({ ...f, quota_mb: Number(e.target.value) }))}
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-500/60" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button type="submit" className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-bold rounded transition-colors">Create Account</button>
|
||||
<button type="button" onClick={() => setShowAdd(false)} className="px-4 py-2 border border-[#2a3044] text-gray-400 hover:text-white text-sm rounded transition-colors">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Email list */}
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-gray-600 text-sm">
|
||||
<span className="inline-block w-4 h-4 border-2 border-gray-700 border-t-blue-500 rounded-full animate-spin mr-2" />Loading…
|
||||
</div>
|
||||
) : emails.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<div className="text-4xl mb-3">✉️</div>
|
||||
<p className="text-gray-500 text-sm">No email accounts yet.</p>
|
||||
<button onClick={() => setShowAdd(true)} className="mt-3 text-blue-400 hover:underline text-sm">Create your first mailbox →</button>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a3044] bg-[#0f1520]">
|
||||
{['Address', 'Quota', 'Created', 'Actions'].map(h => (
|
||||
<th key={h} className="text-left px-4 py-2.5 text-[10px] font-bold text-gray-500 uppercase tracking-wider">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{emails.map(em => (
|
||||
<tr key={em.id} className="border-b border-[#252b3d] hover:bg-[#252b3d]/50 transition-colors">
|
||||
<td className="px-4 py-3 text-gray-300 font-medium">{em.address}</td>
|
||||
<td className="px-4 py-3 text-gray-500 text-xs">{em.quota_mb} MB</td>
|
||||
<td className="px-4 py-3 text-gray-600 text-xs">{new Date(em.created_at).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button onClick={() => handleDelete(em.id, em.address)}
|
||||
className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:text-red-400 text-gray-400 transition-colors">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── MySQL Databases page ────────────────────────────────────────────── */
|
||||
function MySQLDatabases({ account }: { account: AccountInfo | null }) {
|
||||
const [dbs, setDbs] = useState<Database[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [confirm, setConfirm] = useState<string | null>(null)
|
||||
const [form, setForm] = useState({ db_suffix: '', db_user_suffix: '', db_password: '', db_password2: '' })
|
||||
|
||||
const username = account?.username ?? localStorage.getItem('nixclient_username') ?? ''
|
||||
const flash = (m: string) => { setMsg(m); setTimeout(() => setMsg(''), 5000) }
|
||||
|
||||
const refresh = () => {
|
||||
if (!username) return
|
||||
setLoading(true)
|
||||
api.listMyDatabases(username).then(setDbs).catch(console.error).finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { refresh() }, [username])
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (form.db_password !== form.db_password2) { flash('Passwords do not match'); return }
|
||||
if (form.db_password.length < 6) { flash('Password must be at least 6 characters'); return }
|
||||
try {
|
||||
const db = await api.createDatabase({
|
||||
username,
|
||||
db_suffix: form.db_suffix,
|
||||
db_user_suffix: form.db_user_suffix || undefined,
|
||||
db_password: form.db_password,
|
||||
})
|
||||
flash(`Created ${db.db_name}`)
|
||||
setShowAdd(false)
|
||||
setForm({ db_suffix: '', db_user_suffix: '', db_password: '', db_password2: '' })
|
||||
refresh()
|
||||
} catch (e: any) {
|
||||
flash(`Error: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (dbName: string) => {
|
||||
try {
|
||||
await api.deleteDatabase(dbName)
|
||||
flash(`Dropped ${dbName}`)
|
||||
refresh()
|
||||
} catch (e: any) {
|
||||
flash(`Error: ${e.message}`)
|
||||
}
|
||||
setConfirm(null)
|
||||
}
|
||||
|
||||
const previewDb = form.db_suffix ? `${username}_${form.db_suffix}` : '…'
|
||||
const previewUser = `${username}_${form.db_user_suffix || form.db_suffix || '…'}`
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Confirm drop modal */}
|
||||
{confirm && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-xl p-6 max-w-sm w-full shadow-2xl">
|
||||
<h3 className="text-base font-bold text-white mb-2">⚠️ Drop Database</h3>
|
||||
<p className="text-sm text-gray-400 mb-5">
|
||||
Permanently drop <span className="text-white font-semibold">{confirm}</span>? All data will be lost and cannot be recovered.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button onClick={() => setConfirm(null)} className="px-4 py-2 rounded-lg border border-[#2a3044] text-gray-400 hover:text-white text-sm transition-colors">Cancel</button>
|
||||
<button onClick={() => handleDelete(confirm)} className="px-4 py-2 rounded-lg bg-red-600 hover:bg-red-700 text-white text-sm font-semibold transition-colors">Drop Database</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-black text-white">MySQL Databases</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{account?.domain ?? username}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{msg && <span className="text-xs text-blue-400 bg-blue-500/10 border border-blue-500/20 px-3 py-1.5 rounded-lg">{msg}</span>}
|
||||
<button onClick={() => setShowAdd(!showAdd)}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold rounded transition-colors">
|
||||
+ Create Database
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showAdd && (
|
||||
<form onSubmit={handleCreate} className="bg-[#1a1f2e] border border-blue-500/30 rounded-lg p-5 space-y-4">
|
||||
<h3 className="text-sm font-bold text-blue-400">New MySQL Database</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Database Name</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-500 text-xs shrink-0 font-mono">{username}_</span>
|
||||
<input value={form.db_suffix} onChange={e => setForm(f => ({ ...f, db_suffix: e.target.value }))} required
|
||||
placeholder="myapp"
|
||||
className="flex-1 bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-500/60" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">DB Username <span className="text-gray-600 font-normal normal-case">(optional, defaults to DB name)</span></label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-500 text-xs shrink-0 font-mono">{username}_</span>
|
||||
<input value={form.db_user_suffix} onChange={e => setForm(f => ({ ...f, db_user_suffix: e.target.value }))}
|
||||
placeholder={form.db_suffix || 'myapp'}
|
||||
className="flex-1 bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-500/60" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Password</label>
|
||||
<input type="password" value={form.db_password} onChange={e => setForm(f => ({ ...f, db_password: e.target.value }))} required
|
||||
placeholder="Min 6 characters"
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-500/60" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-gray-500 uppercase mb-1">Confirm Password</label>
|
||||
<input type="password" value={form.db_password2} onChange={e => setForm(f => ({ ...f, db_password2: e.target.value }))} required
|
||||
placeholder="Repeat password"
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-blue-500/60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection info preview */}
|
||||
{form.db_suffix && (
|
||||
<div className="bg-[#0f1520] border border-[#2a3044] rounded-lg p-3 space-y-1">
|
||||
<p className="text-[10px] font-bold text-gray-600 uppercase mb-2">Connection Details (after creation)</p>
|
||||
{[
|
||||
{ label: 'Database', value: previewDb, color: 'text-emerald-400' },
|
||||
{ label: 'Username', value: previewUser, color: 'text-blue-400' },
|
||||
{ label: 'Host', value: 'localhost', color: 'text-gray-400' },
|
||||
{ label: 'Port', value: '3306', color: 'text-gray-400' },
|
||||
].map(r => (
|
||||
<div key={r.label} className="flex gap-3 text-xs font-mono">
|
||||
<span className="text-gray-600 w-20">{r.label}:</span>
|
||||
<span className={r.color}>{r.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button type="submit" className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-bold rounded transition-colors">Create Database</button>
|
||||
<button type="button" onClick={() => setShowAdd(false)} className="px-4 py-2 border border-[#2a3044] text-gray-400 hover:text-white text-sm rounded transition-colors">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Database list */}
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="py-12 text-center text-gray-600 text-sm">
|
||||
<span className="inline-block w-4 h-4 border-2 border-gray-700 border-t-blue-500 rounded-full animate-spin mr-2" />Loading…
|
||||
</div>
|
||||
) : dbs.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<div className="text-4xl mb-3">🗄️</div>
|
||||
<p className="text-gray-500 text-sm">No databases yet.</p>
|
||||
<button onClick={() => setShowAdd(true)} className="mt-3 text-blue-400 hover:underline text-sm">Create your first database →</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a3044] bg-[#0f1520]">
|
||||
{['Database', 'Username', 'Host', 'Created', 'Actions'].map(h => (
|
||||
<th key={h} className="text-left px-4 py-2.5 text-[10px] font-bold text-gray-500 uppercase tracking-wider">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dbs.map(db => (
|
||||
<tr key={db.id} className="border-b border-[#252b3d] hover:bg-[#252b3d]/50 transition-colors">
|
||||
<td className="px-4 py-2.5 text-emerald-400 font-medium font-mono text-xs">{db.db_name}</td>
|
||||
<td className="px-4 py-2.5 text-blue-400 font-mono text-xs">{db.db_user}</td>
|
||||
<td className="px-4 py-2.5 text-gray-600 text-xs">localhost:3306</td>
|
||||
<td className="px-4 py-2.5 text-gray-600 text-xs">{new Date(db.created_at).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<button onClick={() => setConfirm(db.db_name)}
|
||||
className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:text-red-400 text-gray-400 transition-colors">
|
||||
Drop
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="px-4 py-3 border-t border-[#2a3044] bg-[#0f1520]">
|
||||
<p className="text-[10px] text-gray-600">
|
||||
Connect via: <span className="font-mono text-gray-500">mysql -u <username> -p <database></span>
|
||||
· Host: <span className="font-mono text-gray-500">localhost</span>
|
||||
· Port: <span className="font-mono text-gray-500">3306</span>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Change Password page ────────────────────────────────────────────── */
|
||||
function ChangePassword() {
|
||||
const [form, setForm] = useState({ current: '', newPw: '', confirm: '' })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (form.newPw !== form.confirm) { setError('New passwords do not match.'); return }
|
||||
if (form.newPw.length < 8) { setError('Password must be at least 8 characters.'); return }
|
||||
setLoading(true); setError(''); setSuccess('')
|
||||
try {
|
||||
await api.changePassword(form.current, form.newPw)
|
||||
setSuccess('Password changed successfully.')
|
||||
setForm({ current: '', newPw: '', confirm: '' })
|
||||
} catch (e: any) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md space-y-5">
|
||||
<div>
|
||||
<h1 className="text-lg font-black text-white">Change Password</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Update your NixClient account password</p>
|
||||
</div>
|
||||
{error && <div className="px-4 py-3 bg-red-950/60 border border-red-800/50 rounded-lg text-red-400 text-sm">⚠️ {error}</div>}
|
||||
{success && <div className="px-4 py-3 bg-emerald-950/60 border border-emerald-800/50 rounded-lg text-emerald-400 text-sm">✓ {success}</div>}
|
||||
<form onSubmit={handleSubmit} className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg divide-y divide-[#2a3044]">
|
||||
<div className="p-5 space-y-4">
|
||||
{[
|
||||
{ label: 'Current Password', key: 'current' as const, placeholder: 'Enter current password' },
|
||||
{ label: 'New Password', key: 'newPw' as const, placeholder: 'At least 8 characters' },
|
||||
{ label: 'Confirm New Password', key: 'confirm' as const, placeholder: 'Repeat new password' },
|
||||
].map(f => (
|
||||
<div key={f.key}>
|
||||
<label className="block text-xs font-semibold text-gray-400 mb-1.5">{f.label}</label>
|
||||
<input type="password" value={form[f.key]} onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
|
||||
placeholder={f.placeholder} required
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-blue-500/60" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<button type="submit" disabled={loading}
|
||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-60 text-white text-sm font-bold rounded transition-colors flex items-center gap-2">
|
||||
{loading && <span className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />}
|
||||
{loading ? 'Updating…' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Placeholder ──────────────────────────────────────────────────────── */
|
||||
function Placeholder({ title, icon }: { title: string; icon: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-72 text-center">
|
||||
<div className="text-5xl mb-4">{icon}</div>
|
||||
<h2 className="text-lg font-bold text-white mb-2">{title}</h2>
|
||||
<p className="text-gray-600 text-sm max-w-sm">This module is under active development and will be available in an upcoming release.</p>
|
||||
<div className="mt-4 px-3 py-1.5 bg-blue-500/10 border border-blue-500/20 rounded text-xs text-blue-400 font-semibold">Coming Soon</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PAGE_META: Record<Page, { title: string; icon: string }> = {
|
||||
home: { title: 'My Control Panel', icon: '▦' },
|
||||
domains: { title: 'Domains', icon: '🌐' },
|
||||
subdomains: { title: 'Subdomains', icon: '🔗' },
|
||||
redirects: { title: 'Redirects', icon: '↪️' },
|
||||
email: { title: 'Email', icon: '✉️' },
|
||||
emailAccounts: { title: 'Email Accounts', icon: '✉️' },
|
||||
webmail: { title: 'Webmail', icon: '📬' },
|
||||
spamFilter: { title: 'Spam Filters', icon: '🚫' },
|
||||
databases: { title: 'Databases', icon: '🗄️' },
|
||||
mysql: { title: 'MySQL Databases', icon: '🗄️' },
|
||||
phpmyadmin: { title: 'phpMyAdmin', icon: '🖥️' },
|
||||
files: { title: 'Files', icon: '📁' },
|
||||
fileManager: { title: 'File Manager', icon: '📁' },
|
||||
ftp: { title: 'FTP Accounts', icon: '📡' },
|
||||
backups: { title: 'Backups', icon: '💾' },
|
||||
security: { title: 'Security', icon: '🔒' },
|
||||
ssl: { title: 'SSL / TLS', icon: '🔒' },
|
||||
passwords: { title: 'Change Password', icon: '🔑' },
|
||||
ssh: { title: 'SSH Keys', icon: '🗝️' },
|
||||
software: { title: 'Software', icon: '🐘' },
|
||||
php: { title: 'PHP Version', icon: '🐘' },
|
||||
wordpress: { title: 'WordPress', icon: '🌀' },
|
||||
stats: { title: 'Statistics', icon: '📊' },
|
||||
logs: { title: 'Logs', icon: '📜' },
|
||||
}
|
||||
|
||||
/* ── Client panel shell ───────────────────────────────────────────────── */
|
||||
function ClientPanel({ username, onLogout }: { username: string; onLogout: () => void }) {
|
||||
const [page, setPage] = useState<Page>('home')
|
||||
const [account, setAccount] = useState<AccountInfo | null>(null)
|
||||
const meta = PAGE_META[page]
|
||||
|
||||
// Fetch account info on mount
|
||||
useEffect(() => {
|
||||
api.getMyAccount(username).then(setAccount).catch(console.error)
|
||||
}, [username])
|
||||
|
||||
function renderPage() {
|
||||
switch (page) {
|
||||
case 'home': return <Home onNav={setPage} />
|
||||
case 'domains': return <Domains account={account} />
|
||||
case 'emailAccounts': return <EmailAccounts account={account} />
|
||||
case 'email': return <EmailAccounts account={account} />
|
||||
case 'mysql': return <MySQLDatabases account={account} />
|
||||
case 'databases': return <MySQLDatabases account={account} />
|
||||
case 'passwords': return <ChangePassword />
|
||||
default: return <Placeholder title={meta.title} icon={meta.icon} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0f1520] text-white flex flex-col">
|
||||
{/* Top nav */}
|
||||
<header className="bg-[#1a1f2e] border-b border-[#2a3044] sticky top-0 z-40 flex-shrink-0">
|
||||
<div className="h-14 max-w-screen-2xl mx-auto px-6 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<div className="w-7 h-7 rounded-lg bg-blue-600 flex items-center justify-center text-white text-sm font-black">N</div>
|
||||
<span className="text-base font-black text-white hidden sm:block">Nix<span className="text-blue-400">Client</span></span>
|
||||
</div>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{[
|
||||
{ id: 'home' as Page, label: 'Home' },
|
||||
{ id: 'emailAccounts' as Page, label: 'Email' },
|
||||
{ id: 'mysql' as Page, label: 'Databases' },
|
||||
{ id: 'ssl' as Page, label: 'Security' },
|
||||
{ id: 'stats' as Page, label: 'Stats' },
|
||||
].map(l => (
|
||||
<button key={l.id} onClick={() => setPage(l.id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
page === l.id ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white hover:bg-[#252b3d]'
|
||||
}`}>
|
||||
{l.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="hidden sm:flex items-center gap-2 px-3 py-1.5 bg-[#0f1520] border border-[#2a3044] rounded-lg">
|
||||
<span className="w-5 h-5 rounded-full bg-blue-600 flex items-center justify-center text-white text-[10px] font-bold">
|
||||
{(account?.domain ?? username).charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="text-xs text-gray-300 font-medium max-w-[120px] truncate">{account?.domain ?? username}</span>
|
||||
</div>
|
||||
<button onClick={onLogout}
|
||||
className="px-3 py-1.5 rounded-lg border border-[#2a3044] text-gray-400 hover:border-gray-600 hover:text-white transition-colors text-xs font-medium">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{page !== 'home' && (
|
||||
<div className="border-t border-[#252b3d] px-6 py-1.5 flex items-center gap-1.5 text-[11px] text-gray-600 max-w-screen-2xl mx-auto">
|
||||
<button onClick={() => setPage('home')} className="hover:text-blue-400 transition-colors">Home</button>
|
||||
<span>/</span>
|
||||
<span className="text-gray-400">{meta.title}</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="flex-1 max-w-screen-2xl mx-auto w-full px-6 py-6 flex gap-6">
|
||||
<AccountSidebar account={account} username={username} onNav={setPage} />
|
||||
<main className="flex-1 min-w-0">{renderPage()}</main>
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-[#2a3044] px-6 py-3 text-center text-[10px] text-gray-700">
|
||||
NixPanel Client v0.3.0-alpha · {account?.domain ?? username} · Powered by NixServer
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Login ────────────────────────────────────────────────────────────── */
|
||||
export default function App() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loggedInAs, setLoggedInAs] = useState<string | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (api.isLoggedIn()) {
|
||||
const stored = localStorage.getItem('nixclient_username')
|
||||
setLoggedInAs(stored ?? 'user')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!username || !password) { setError('Please enter your username and password.'); return }
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const result = await api.login(username, password)
|
||||
if (result.user.role === 'admin') {
|
||||
api.clearToken()
|
||||
setError('Admin accounts must use NixServer (port 2087).')
|
||||
return
|
||||
}
|
||||
localStorage.setItem('nixclient_username', result.user.username)
|
||||
setLoggedInAs(result.user.username)
|
||||
} catch (err: any) {
|
||||
setError(err.message === 'Unauthorized' ? 'Invalid username or password.' : err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
api.clearToken()
|
||||
localStorage.removeItem('nixclient_username')
|
||||
setLoggedInAs(null)
|
||||
setUsername(''); setPassword('')
|
||||
}
|
||||
|
||||
if (loggedInAs) return <ClientPanel username={loggedInAs} onLogout={handleLogout} />
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#0f1520] relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-blue-950/40 via-[#0f1520] to-[#0f1520]" />
|
||||
<div className="relative w-full max-w-sm px-4">
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center gap-3 mb-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-600 flex items-center justify-center text-white text-2xl font-black shadow-lg shadow-blue-900/50">N</div>
|
||||
<div className="text-left">
|
||||
<h1 className="text-2xl font-black text-white leading-none">Nix<span className="text-blue-400">Client</span></h1>
|
||||
<p className="text-xs text-gray-600 font-medium">Hosting Control Panel</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-2xl shadow-2xl overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-blue-700 to-blue-600 px-6 py-4">
|
||||
<h2 className="text-white font-bold text-base">Sign in to your account</h2>
|
||||
<p className="text-blue-200 text-xs mt-0.5">NixClient Hosting Control Panel</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="px-4 py-3 bg-red-950/60 border border-red-800/50 rounded-lg text-red-400 text-sm flex items-center gap-2">
|
||||
<span>⚠️</span> {error}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1.5">Username</label>
|
||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)} required autoFocus autoComplete="username" placeholder="username"
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-700 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-all" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1.5">Password</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="current-password" placeholder="••••••••"
|
||||
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-700 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-all" />
|
||||
</div>
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full py-2.5 rounded-lg bg-blue-600 hover:bg-blue-700 active:bg-blue-800 disabled:opacity-60 text-white font-bold text-sm transition-all shadow-lg shadow-blue-900/40 flex items-center justify-center gap-2">
|
||||
{loading ? (<><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />Signing in…</>) : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="border-t border-[#2a3044] px-6 py-3 bg-[#0f1520] text-[10px] text-gray-700 text-center">
|
||||
NixPanel Client v0.3.0-alpha · Manage your hosting account
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/* NixClient API client — all calls go to /api/ (proxied to Rust backend) */
|
||||
|
||||
const BASE = '/api'
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem('nixclient_token')
|
||||
}
|
||||
export function setToken(t: string) {
|
||||
localStorage.setItem('nixclient_token', t)
|
||||
}
|
||||
export function clearToken() {
|
||||
localStorage.removeItem('nixclient_token')
|
||||
}
|
||||
export function isLoggedIn(): boolean {
|
||||
return !!getToken()
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = getToken()
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
})
|
||||
if (res.status === 401) {
|
||||
clearToken()
|
||||
window.location.reload()
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/* ── Auth ────────────────────────────────────────────────────────────── */
|
||||
export interface LoginResult {
|
||||
token: string
|
||||
user: { id: number; username: string; role: string }
|
||||
}
|
||||
export async function login(username: string, password: string): Promise<LoginResult> {
|
||||
const result = await apiFetch<LoginResult>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
setToken(result.token)
|
||||
return result
|
||||
}
|
||||
|
||||
/* ── Account info ────────────────────────────────────────────────────── */
|
||||
export interface AccountInfo {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
email: string
|
||||
package_name: string
|
||||
disk_quota_mb: number
|
||||
bandwidth_mb: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
export async function getMyAccount(username: string): Promise<AccountInfo | null> {
|
||||
try {
|
||||
return await apiFetch<AccountInfo>(`/accounts/${username}`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Email accounts ──────────────────────────────────────────────────── */
|
||||
export interface EmailAccount {
|
||||
id: number
|
||||
account_id: number
|
||||
address: string
|
||||
quota_mb: number
|
||||
created_at: string
|
||||
}
|
||||
export async function listMyEmailAccounts(username: string): Promise<EmailAccount[]> {
|
||||
return apiFetch(`/email/${username}`)
|
||||
}
|
||||
export async function createEmailAccount(data: {
|
||||
username: string
|
||||
domain: string
|
||||
password: string
|
||||
quota_mb?: number
|
||||
}): Promise<EmailAccount> {
|
||||
return apiFetch('/email', { method: 'POST', body: JSON.stringify(data) })
|
||||
}
|
||||
export async function deleteEmailAccount(id: number): Promise<void> {
|
||||
await apiFetch(`/email/id/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/* ── Databases ───────────────────────────────────────────────────────── */
|
||||
export interface Database {
|
||||
id: number
|
||||
account_id: number
|
||||
db_name: string
|
||||
db_user: string
|
||||
created_at: string
|
||||
}
|
||||
export async function listMyDatabases(username: string): Promise<Database[]> {
|
||||
return apiFetch(`/databases/${username}`)
|
||||
}
|
||||
export async function createDatabase(data: {
|
||||
username: string
|
||||
db_suffix: string
|
||||
db_user_suffix?: string
|
||||
db_password: string
|
||||
}): Promise<Database> {
|
||||
return apiFetch('/databases', { method: 'POST', body: JSON.stringify(data) })
|
||||
}
|
||||
export async function deleteDatabase(dbName: string): Promise<void> {
|
||||
await apiFetch(`/databases/name/${encodeURIComponent(dbName)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/* ── Password change ─────────────────────────────────────────────────── */
|
||||
export async function changePassword(
|
||||
currentPassword: string,
|
||||
newPassword: string
|
||||
): Promise<void> {
|
||||
await apiFetch('/me/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: rgb(3, 7, 30);
|
||||
color: rgb(229, 231, 235);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
minify: 'esbuild',
|
||||
},
|
||||
server: {
|
||||
port: 3001,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:4000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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());
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "nixpanel"
|
||||
version = "0.4.0-alpha"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "nixpanel"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
nixpanel-common = { path = "../nixpanel-common" }
|
||||
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
|
||||
sqlx = { version = "0.7", features = ["mysql", "runtime-tokio-rustls", "chrono"] }
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
jsonwebtoken = "9"
|
||||
argon2 = { version = "0.5", features = ["std"] }
|
||||
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
dotenvy = "0.15"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
@@ -0,0 +1,127 @@
|
||||
-- Users (panel logins: admin + per-account users)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
username VARCHAR(64) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role ENUM('admin','user') NOT NULL DEFAULT 'user',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_username (username),
|
||||
UNIQUE KEY uq_email (email)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Hosting accounts
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
user_id BIGINT,
|
||||
username VARCHAR(64) NOT NULL,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
package_name VARCHAR(64) NOT NULL DEFAULT 'Default',
|
||||
disk_quota_mb INT NOT NULL DEFAULT 10240,
|
||||
bandwidth_mb INT NOT NULL DEFAULT 0,
|
||||
status ENUM('active','suspended','terminated') NOT NULL DEFAULT 'active',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_username (username),
|
||||
UNIQUE KEY uq_domain (domain),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Domains / DNS zones
|
||||
CREATE TABLE IF NOT EXISTS domains (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
account_id BIGINT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
type ENUM('main','addon','parked','subdomain') NOT NULL DEFAULT 'main',
|
||||
status ENUM('active','inactive') NOT NULL DEFAULT 'active',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_name (name),
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- DNS records
|
||||
CREATE TABLE IF NOT EXISTS dns_records (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
domain_id BIGINT NOT NULL,
|
||||
type VARCHAR(10) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
content VARCHAR(512) NOT NULL,
|
||||
ttl INT NOT NULL DEFAULT 3600,
|
||||
priority INT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- MySQL/MariaDB databases owned by accounts
|
||||
CREATE TABLE IF NOT EXISTS account_databases (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
account_id BIGINT NOT NULL,
|
||||
db_name VARCHAR(64) NOT NULL,
|
||||
db_user VARCHAR(64) NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_db_name (db_name),
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Email accounts
|
||||
CREATE TABLE IF NOT EXISTS email_accounts (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
account_id BIGINT NOT NULL,
|
||||
address VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
quota_mb INT NOT NULL DEFAULT 1024,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_address (address),
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- FTP accounts
|
||||
CREATE TABLE IF NOT EXISTS ftp_accounts (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
account_id BIGINT NOT NULL,
|
||||
username VARCHAR(64) NOT NULL,
|
||||
home_dir VARCHAR(512) NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_username (username),
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- SSL certificates
|
||||
CREATE TABLE IF NOT EXISTS ssl_certificates (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
account_id BIGINT,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
issuer VARCHAR(64) NOT NULL DEFAULT 'letsencrypt',
|
||||
cert_path VARCHAR(512),
|
||||
key_path VARCHAR(512),
|
||||
expires_at DATETIME,
|
||||
auto_renew TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_domain (domain),
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Backup jobs
|
||||
CREATE TABLE IF NOT EXISTS backups (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
account_id BIGINT,
|
||||
type ENUM('full','incremental') NOT NULL DEFAULT 'full',
|
||||
status ENUM('pending','running','completed','failed') NOT NULL DEFAULT 'pending',
|
||||
size_bytes BIGINT,
|
||||
path VARCHAR(512),
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at DATETIME,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
@@ -0,0 +1,70 @@
|
||||
use axum::{
|
||||
async_trait,
|
||||
extract::FromRequestParts,
|
||||
http::request::Parts,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::state::AppState;
|
||||
|
||||
/* ── JWT claims ──────────────────────────────────────────────────────── */
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Claims {
|
||||
pub sub: String, // username
|
||||
pub user_id: i64,
|
||||
pub role: String, // "admin" | "user"
|
||||
pub exp: i64,
|
||||
pub iat: i64,
|
||||
}
|
||||
|
||||
impl Claims {
|
||||
pub fn new(user_id: i64, username: &str, role: &str, secret: &str) -> anyhow::Result<String> {
|
||||
let now = Utc::now();
|
||||
let claims = Claims {
|
||||
sub: username.to_string(),
|
||||
user_id,
|
||||
role: role.to_string(),
|
||||
exp: (now + Duration::hours(8)).timestamp(),
|
||||
iat: now.timestamp(),
|
||||
};
|
||||
Ok(encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)?)
|
||||
}
|
||||
|
||||
pub fn verify(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
||||
Ok(decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_bytes()),
|
||||
&Validation::new(Algorithm::HS256),
|
||||
)?
|
||||
.claims)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Extractor — pulls Claims from Authorization: Bearer <token> ─────── */
|
||||
|
||||
#[async_trait]
|
||||
impl FromRequestParts<AppState> for Claims {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let auth = parts
|
||||
.headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
Claims::verify(auth, &state.config.jwt_secret).map_err(|_| AppError::Unauthorized)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use std::env;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub port: u16,
|
||||
pub jwt_secret: String,
|
||||
pub database_url: String,
|
||||
pub install_dir: String,
|
||||
pub bin_dir: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
Ok(Self {
|
||||
port: env::var("PORT").unwrap_or_else(|_| "4000".into()).parse()?,
|
||||
jwt_secret: env::var("JWT_SECRET").unwrap_or_else(|_| "change-me-in-production".into()),
|
||||
database_url: env::var("DATABASE_URL")?,
|
||||
install_dir: env::var("INSTALL_DIR").unwrap_or_else(|_| "/opt/nixpanel".into()),
|
||||
bin_dir: env::var("INSTALL_DIR")
|
||||
.map(|d| format!("{}/bin", d))
|
||||
.unwrap_or_else(|_| "/opt/nixpanel/bin".into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AppError {
|
||||
#[error("Unauthorized")]
|
||||
Unauthorized,
|
||||
|
||||
#[error("Not found")]
|
||||
NotFound,
|
||||
|
||||
#[error("Bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
|
||||
AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||
AppError::Internal(_) => {
|
||||
tracing::error!("{}", self);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".into())
|
||||
}
|
||||
};
|
||||
(status, Json(serde_json::json!({ "error": message }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for AppError {
|
||||
fn from(e: sqlx::Error) -> Self {
|
||||
AppError::Internal(anyhow::anyhow!(e))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<jsonwebtoken::errors::Error> for AppError {
|
||||
fn from(_e: jsonwebtoken::errors::Error) -> Self {
|
||||
AppError::Unauthorized
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
mod auth;
|
||||
mod config;
|
||||
mod error;
|
||||
mod routes;
|
||||
mod section;
|
||||
mod state;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::{
|
||||
routing::{delete, get, post},
|
||||
Router,
|
||||
};
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tracing::info;
|
||||
|
||||
use config::Config;
|
||||
use state::AppState;
|
||||
|
||||
/* ── DB schema bootstrap ────────────────────────────────────────────── */
|
||||
|
||||
async fn run_migrations(pool: &sqlx::MySqlPool) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS _schema_version (
|
||||
version INT NOT NULL,
|
||||
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (version)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||
).execute(pool).await?;
|
||||
|
||||
let current: i32 = sqlx::query_scalar(
|
||||
"SELECT COALESCE(MAX(version), 0) FROM _schema_version"
|
||||
).fetch_one(pool).await.unwrap_or(0);
|
||||
|
||||
if current < 1 {
|
||||
info!("Running migration 1: initial schema");
|
||||
for stmt in include_str!("../migrations/0001_initial.sql")
|
||||
.split(";\n")
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
sqlx::query(stmt).execute(pool).await?;
|
||||
}
|
||||
sqlx::query("INSERT INTO _schema_version (version) VALUES (1)")
|
||||
.execute(pool).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* ── Admin user seeding ─────────────────────────────────────────────── */
|
||||
|
||||
async fn seed_admin(pool: &sqlx::MySqlPool, install_dir: &str) -> anyhow::Result<()> {
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM users WHERE role = 'admin'"
|
||||
).fetch_one(pool).await.unwrap_or(0);
|
||||
|
||||
if count > 0 {
|
||||
return Ok(()); // already seeded
|
||||
}
|
||||
|
||||
let username = std::fs::read_to_string(format!("{}/.admin_user", install_dir))
|
||||
.unwrap_or_else(|_| "admin".into())
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let password = std::fs::read_to_string(format!("{}/.admin_pass", install_dir))
|
||||
.unwrap_or_else(|_| "changeme".into())
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let email = format!("{}@localhost", username);
|
||||
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hash = Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| anyhow::anyhow!("argon2: {}", e))?
|
||||
.to_string();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'admin')"
|
||||
)
|
||||
.bind(&username)
|
||||
.bind(&email)
|
||||
.bind(&hash)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
info!("Seeded admin user: {}", username);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* ── Router ─────────────────────────────────────────────────────────── */
|
||||
|
||||
fn build_router(state: AppState) -> Router {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
Router::new()
|
||||
// Auth (public)
|
||||
.route("/api/auth/login", post(routes::auth::login))
|
||||
// Dashboard
|
||||
.route("/api/dashboard", get(routes::dashboard::get_dashboard))
|
||||
// Services
|
||||
.route("/api/services", get(routes::services::list_services))
|
||||
.route("/api/services/:name/:action", post(routes::services::service_action))
|
||||
// Accounts — list / create
|
||||
.route("/api/accounts",
|
||||
get(routes::accounts::list_accounts)
|
||||
.post(routes::accounts::create_account))
|
||||
// Accounts — per-account actions
|
||||
.route("/api/accounts/:username",
|
||||
get(routes::accounts::get_account)
|
||||
.delete(routes::accounts::terminate_account))
|
||||
.route("/api/accounts/:username/suspend",
|
||||
post(routes::accounts::suspend_account))
|
||||
.route("/api/accounts/:username/unsuspend",
|
||||
post(routes::accounts::unsuspend_account))
|
||||
// Databases
|
||||
.route("/api/databases",
|
||||
get(routes::databases::list_all_databases)
|
||||
.post(routes::databases::create_database))
|
||||
.route("/api/databases/:username",
|
||||
get(routes::databases::list_account_databases))
|
||||
.route("/api/databases/name/:db_name",
|
||||
delete(routes::databases::delete_database))
|
||||
// DNS
|
||||
.route("/api/dns", get(routes::dns::list_zones))
|
||||
.route("/api/dns/:domain",
|
||||
get(routes::dns::list_zone_records)
|
||||
.post(routes::dns::add_record)
|
||||
.delete(routes::dns::delete_record))
|
||||
// Email accounts
|
||||
.route("/api/email", post(routes::email::create_email_account))
|
||||
.route("/api/email/:username", get(routes::email::list_email_accounts))
|
||||
.route("/api/email/id/:id", delete(routes::email::delete_email_account))
|
||||
// Profile / password
|
||||
.route("/api/me/password", post(routes::me::change_password))
|
||||
.with_state(state)
|
||||
.layer(cors)
|
||||
}
|
||||
|
||||
/* ── Entry point ────────────────────────────────────────────────────── */
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// Load .env if present (production uses EnvironmentFile= in systemd)
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
std::env::var("RUST_LOG")
|
||||
.unwrap_or_else(|_| "nixpanel=info,tower_http=warn".into())
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = Config::from_env()?;
|
||||
info!("NixPanel API starting on port {}", config.port);
|
||||
|
||||
// Connect to MariaDB
|
||||
let pool = sqlx::MySqlPool::connect(&config.database_url).await?;
|
||||
|
||||
// Migrations
|
||||
run_migrations(&pool).await?;
|
||||
|
||||
// Seed admin on first run
|
||||
seed_admin(&pool, &config.install_dir).await
|
||||
.unwrap_or_else(|e| tracing::warn!("Admin seed skipped: {}", e));
|
||||
|
||||
let state = AppState {
|
||||
db: pool,
|
||||
config: Arc::new(config.clone()),
|
||||
};
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], config.port));
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
info!("Listening on {}", addr);
|
||||
|
||||
axum::serve(listener, build_router(state)).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{auth::Claims, error::AppError, state::AppState};
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
pub struct Account {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub domain: String,
|
||||
pub email: String,
|
||||
pub package_name: String,
|
||||
pub disk_quota_mb: i32, // INT column → i32
|
||||
pub bandwidth_mb: i32, // INT column → i32
|
||||
pub status: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateAccountRequest {
|
||||
pub username: String,
|
||||
pub domain: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub package_name: Option<String>,
|
||||
pub disk_quota_mb: Option<i32>,
|
||||
pub bandwidth_mb: Option<i32>,
|
||||
}
|
||||
|
||||
pub async fn list_accounts(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<Account>>, AppError> {
|
||||
let accounts = sqlx::query_as::<_, Account>(
|
||||
"SELECT id, username, domain, email, package_name, disk_quota_mb, bandwidth_mb, status, created_at
|
||||
FROM accounts ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(Json(accounts))
|
||||
}
|
||||
|
||||
pub async fn create_account(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<CreateAccountRequest>,
|
||||
) -> Result<Json<Account>, AppError> {
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
|
||||
if body.username.is_empty() || body.domain.is_empty() {
|
||||
return Err(AppError::BadRequest("username and domain are required".into()));
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// Create system user (must exist before provisioner runs)
|
||||
let _ = std::process::Command::new("useradd")
|
||||
.args(["-m", "-s", "/bin/bash", "-d",
|
||||
&format!("/home/{}", body.username),
|
||||
&body.username])
|
||||
.output();
|
||||
|
||||
let package_name = body.package_name.unwrap_or_else(|| "Default".into());
|
||||
let disk_quota_mb: i32 = body.disk_quota_mb.unwrap_or(10240);
|
||||
let bandwidth_mb: i32 = body.bandwidth_mb.unwrap_or(0);
|
||||
|
||||
// Run the full account provisioner (vhost, PHP-FPM, DNS zone, home dirs)
|
||||
let provision_result = crate::section::call(
|
||||
&state.config.bin_dir,
|
||||
"nixpanel-accounts",
|
||||
"provision",
|
||||
serde_json::json!({
|
||||
"username": body.username,
|
||||
"domain": body.domain,
|
||||
"email": body.email,
|
||||
"php_version": "8.3",
|
||||
}),
|
||||
);
|
||||
|
||||
if let Err(ref e) = provision_result {
|
||||
tracing::warn!("Account provisioner error for {}: {}", body.username, e);
|
||||
} else if let Ok(ref resp) = provision_result {
|
||||
if let Some(errors) = resp.data.as_ref().and_then(|d| d.get("errors")) {
|
||||
tracing::warn!("Provisioner partial errors for {}: {}", body.username, errors);
|
||||
}
|
||||
tracing::info!("Provisioner steps for {}: {:?}",
|
||||
body.username,
|
||||
resp.data.as_ref().and_then(|d| d.get("steps"))
|
||||
);
|
||||
}
|
||||
|
||||
let insert_id: u64 = sqlx::query(
|
||||
"INSERT INTO accounts (username, domain, email, password_hash, package_name, disk_quota_mb, bandwidth_mb, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'active')",
|
||||
)
|
||||
.bind(&body.username)
|
||||
.bind(&body.domain)
|
||||
.bind(&body.email)
|
||||
.bind(&hash)
|
||||
.bind(&package_name)
|
||||
.bind(disk_quota_mb)
|
||||
.bind(bandwidth_mb)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.last_insert_id();
|
||||
|
||||
// Also create a NixClient panel user
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'user')
|
||||
ON DUPLICATE KEY UPDATE email = VALUES(email)",
|
||||
)
|
||||
.bind(&body.username)
|
||||
.bind(&body.email)
|
||||
.bind(&hash)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
let account = sqlx::query_as::<_, Account>(
|
||||
"SELECT id, username, domain, email, package_name, disk_quota_mb, bandwidth_mb, status, created_at
|
||||
FROM accounts WHERE id = ?",
|
||||
)
|
||||
.bind(insert_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(account))
|
||||
}
|
||||
|
||||
/* ── Suspend / Unsuspend ─────────────────────────────────────────────── */
|
||||
|
||||
pub async fn suspend_account(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let rows = sqlx::query(
|
||||
"UPDATE accounts SET status = 'suspended' WHERE username = ?",
|
||||
)
|
||||
.bind(&username)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if rows == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "ok": true, "status": "suspended" })))
|
||||
}
|
||||
|
||||
pub async fn unsuspend_account(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let rows = sqlx::query(
|
||||
"UPDATE accounts SET status = 'active' WHERE username = ?",
|
||||
)
|
||||
.bind(&username)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if rows == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "ok": true, "status": "active" })))
|
||||
}
|
||||
|
||||
/* ── Terminate (delete + deprovision) ────────────────────────────────── */
|
||||
|
||||
pub async fn terminate_account(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
// Fetch domain so we can deprovision DNS
|
||||
let domain: Option<String> = sqlx::query_scalar(
|
||||
"SELECT domain FROM accounts WHERE username = ?",
|
||||
)
|
||||
.bind(&username)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
if domain.is_none() {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
let domain = domain.unwrap();
|
||||
|
||||
// Run nixpanel-accounts deprovision (best effort)
|
||||
let result = crate::section::call(
|
||||
&state.config.bin_dir,
|
||||
"nixpanel-accounts",
|
||||
"deprovision",
|
||||
serde_json::json!({ "username": username, "domain": domain }),
|
||||
);
|
||||
if let Err(ref e) = result {
|
||||
tracing::warn!("Deprovision error for {}: {}", username, e);
|
||||
}
|
||||
|
||||
// Remove system user (keep home dir for safety — admin can wipe manually)
|
||||
let _ = std::process::Command::new("userdel")
|
||||
.arg(&username)
|
||||
.output();
|
||||
|
||||
// Remove DB records (cascades to email, ftp, db, ssl, etc.)
|
||||
sqlx::query("DELETE FROM accounts WHERE username = ?")
|
||||
.bind(&username)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
// Also remove panel login user
|
||||
sqlx::query("DELETE FROM users WHERE username = ? AND role = 'user'")
|
||||
.bind(&username)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"message": format!("Account {} terminated", username)
|
||||
})))
|
||||
}
|
||||
|
||||
/* ── Single account lookup ───────────────────────────────────────────── */
|
||||
|
||||
pub async fn get_account(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(username): Path<String>,
|
||||
) -> Result<Json<Account>, AppError> {
|
||||
let account = sqlx::query_as::<_, Account>(
|
||||
"SELECT id, username, domain, email, package_name, disk_quota_mb, bandwidth_mb, status, created_at
|
||||
FROM accounts WHERE username = ?",
|
||||
)
|
||||
.bind(&username)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
Ok(Json(account))
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use argon2::{Argon2, PasswordHash, PasswordVerifier};
|
||||
use axum::{extract::State, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{auth::Claims, error::AppError, state::AppState};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub user: UserInfo,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UserInfo {
|
||||
pub id: i64,
|
||||
pub username: 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(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResponse>, AppError> {
|
||||
let row: Option<UserRow> = sqlx::query_as::<_, UserRow>(
|
||||
"SELECT id, username, password_hash, role FROM users WHERE username = ? LIMIT 1",
|
||||
)
|
||||
.bind(&body.username)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let row = row.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let parsed = PasswordHash::new(&row.password_hash).map_err(|_| AppError::Unauthorized)?;
|
||||
Argon2::default()
|
||||
.verify_password(body.password.as_bytes(), &parsed)
|
||||
.map_err(|_| AppError::Unauthorized)?;
|
||||
|
||||
let token = Claims::new(row.id, &row.username, &row.role, &state.config.jwt_secret)
|
||||
.map_err(AppError::Internal)?;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
token,
|
||||
user: UserInfo {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
role: row.role,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{auth::Claims, error::AppError, section, state::AppState};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DashboardStats {
|
||||
pub accounts: i64,
|
||||
pub domains: i64,
|
||||
pub databases: i64,
|
||||
pub email_accounts: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DashboardResponse {
|
||||
pub stats: DashboardStats,
|
||||
pub sysinfo: serde_json::Value,
|
||||
pub versions: serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn get_dashboard(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<DashboardResponse>, AppError> {
|
||||
// Use non-macro query() — tables may not exist at compile time
|
||||
let accounts: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM accounts")
|
||||
.fetch_one(&state.db).await.unwrap_or(0);
|
||||
let domains: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM domains")
|
||||
.fetch_one(&state.db).await.unwrap_or(0);
|
||||
let databases: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM account_databases")
|
||||
.fetch_one(&state.db).await.unwrap_or(0);
|
||||
let email_accounts: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM email_accounts")
|
||||
.fetch_one(&state.db).await.unwrap_or(0);
|
||||
|
||||
let bin_dir = &state.config.bin_dir;
|
||||
|
||||
let sysinfo = section::call(bin_dir, "nixpanel-sysinfo", "stats", serde_json::Value::Null)
|
||||
.unwrap_or_else(|_| nixpanel_common::SectionResponse::err("sysinfo unavailable"));
|
||||
|
||||
let versions = section::call(bin_dir, "nixpanel-sysinfo", "versions", serde_json::Value::Null)
|
||||
.unwrap_or_else(|_| nixpanel_common::SectionResponse::err("versions unavailable"));
|
||||
|
||||
Ok(Json(DashboardResponse {
|
||||
stats: DashboardStats { accounts, domains, databases, email_accounts },
|
||||
sysinfo: sysinfo.data.unwrap_or(serde_json::Value::Null),
|
||||
versions: versions.data.unwrap_or(serde_json::Value::Null),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use crate::{auth::Claims, error::AppError, state::AppState};
|
||||
|
||||
/* ── Types ───────────────────────────────────────────────────────────── */
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
pub struct Database {
|
||||
pub id: i64,
|
||||
pub account_id: i64,
|
||||
pub db_name: String,
|
||||
pub db_user: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
pub struct DatabaseWithAccount {
|
||||
pub id: i64,
|
||||
pub account_id: i64,
|
||||
pub db_name: String,
|
||||
pub db_user: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub account_username: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateDatabaseRequest {
|
||||
/// Hosting account username (admin supplies this; NixClient uses JWT sub)
|
||||
pub username: String,
|
||||
/// Short suffix — final DB name will be `username_db_suffix`
|
||||
pub db_suffix: String,
|
||||
/// Short suffix — final DB user will be `username_user_suffix` (defaults to db_suffix)
|
||||
pub db_user_suffix: Option<String>,
|
||||
pub db_password: String,
|
||||
}
|
||||
|
||||
/* ── MySQL helpers ───────────────────────────────────────────────────── */
|
||||
|
||||
/// Escape a string value safe for use inside a single-quoted MySQL literal.
|
||||
fn escape_mysql(s: &str) -> String {
|
||||
s.replace('\\', "\\\\")
|
||||
.replace('\'', "\\'")
|
||||
.replace('\0', "\\0")
|
||||
.replace('\n', "\\n")
|
||||
.replace('\r', "\\r")
|
||||
}
|
||||
|
||||
/// Execute one or more SQL statements via `mysql -u root` (unix socket auth).
|
||||
fn mysql_exec(sql: &str) -> Result<String, String> {
|
||||
let mut child = Command::new("mysql")
|
||||
.args(["-u", "root"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("mysql spawn: {}", e))?;
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(sql.as_bytes()).ok();
|
||||
}
|
||||
|
||||
let out = child.wait_with_output().map_err(|e| format!("mysql wait: {}", e))?;
|
||||
if out.status.success() {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate an identifier: only alphanumeric + underscore, non-empty.
|
||||
fn valid_ident(s: &str) -> bool {
|
||||
!s.is_empty() && s.chars().all(|c| c.is_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
/* ── Handlers ────────────────────────────────────────────────────────── */
|
||||
|
||||
/// List all databases across all accounts (admin / NixServer).
|
||||
pub async fn list_all_databases(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<DatabaseWithAccount>>, AppError> {
|
||||
let rows = sqlx::query_as::<_, DatabaseWithAccount>(
|
||||
"SELECT ad.id, ad.account_id, ad.db_name, ad.db_user, ad.created_at,
|
||||
a.username AS account_username
|
||||
FROM account_databases ad
|
||||
JOIN accounts a ON a.id = ad.account_id
|
||||
ORDER BY ad.created_at DESC",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
/// List databases for a specific hosting account (NixClient).
|
||||
pub async fn list_account_databases(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(account_username): Path<String>,
|
||||
) -> Result<Json<Vec<Database>>, AppError> {
|
||||
let rows = sqlx::query_as::<_, Database>(
|
||||
"SELECT ad.id, ad.account_id, ad.db_name, ad.db_user, ad.created_at
|
||||
FROM account_databases ad
|
||||
JOIN accounts a ON a.id = ad.account_id
|
||||
WHERE a.username = ?
|
||||
ORDER BY ad.created_at DESC",
|
||||
)
|
||||
.bind(&account_username)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
/// Create a MariaDB database + user, then record in nixpanel DB.
|
||||
pub async fn create_database(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<CreateDatabaseRequest>,
|
||||
) -> Result<Json<Database>, AppError> {
|
||||
// Validate inputs
|
||||
if body.username.is_empty() || body.db_suffix.is_empty() || body.db_password.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"username, db_suffix, and db_password are required".into(),
|
||||
));
|
||||
}
|
||||
if !valid_ident(&body.db_suffix) {
|
||||
return Err(AppError::BadRequest(
|
||||
"db_suffix must be alphanumeric with underscores only".into(),
|
||||
));
|
||||
}
|
||||
let user_suffix = body.db_user_suffix.as_deref().unwrap_or(&body.db_suffix);
|
||||
if !valid_ident(user_suffix) {
|
||||
return Err(AppError::BadRequest(
|
||||
"db_user_suffix must be alphanumeric with underscores only".into(),
|
||||
));
|
||||
}
|
||||
if body.db_password.len() < 6 {
|
||||
return Err(AppError::BadRequest("Password must be at least 6 characters".into()));
|
||||
}
|
||||
|
||||
// Build cPanel-style namespaced names
|
||||
let db_name = format!("{}_{}", body.username, body.db_suffix);
|
||||
let db_user = format!("{}_{}", body.username, user_suffix);
|
||||
|
||||
// MySQL identifier length limits
|
||||
if db_name.len() > 64 {
|
||||
return Err(AppError::BadRequest("Database name too long (max 64 chars)".into()));
|
||||
}
|
||||
if db_user.len() > 32 {
|
||||
return Err(AppError::BadRequest("Database username too long (max 32 chars)".into()));
|
||||
}
|
||||
|
||||
// Get account_id
|
||||
let account_id: Option<i64> = sqlx::query_scalar(
|
||||
"SELECT id FROM accounts WHERE username = ?",
|
||||
)
|
||||
.bind(&body.username)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let account_id = account_id.ok_or_else(|| {
|
||||
AppError::BadRequest(format!("Account '{}' not found", body.username))
|
||||
})?;
|
||||
|
||||
// Check DB doesn't already exist in our tracking table
|
||||
let exists: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM account_databases WHERE db_name = ?",
|
||||
)
|
||||
.bind(&db_name)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
if exists > 0 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Database '{}' already exists",
|
||||
db_name
|
||||
)));
|
||||
}
|
||||
|
||||
// Create database + user in MariaDB
|
||||
let pw = escape_mysql(&body.db_password);
|
||||
let sql = format!(
|
||||
"CREATE DATABASE IF NOT EXISTS `{db_name}` \
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\n\
|
||||
CREATE USER IF NOT EXISTS '{db_user}'@'localhost' IDENTIFIED BY '{pw}';\n\
|
||||
GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{db_user}'@'localhost';\n\
|
||||
FLUSH PRIVILEGES;\n",
|
||||
db_name = db_name,
|
||||
db_user = db_user,
|
||||
pw = pw,
|
||||
);
|
||||
|
||||
mysql_exec(&sql).map_err(|e| AppError::BadRequest(format!("MariaDB: {}", e)))?;
|
||||
|
||||
// Record in nixpanel tracking table
|
||||
let insert_id: u64 = sqlx::query(
|
||||
"INSERT INTO account_databases (account_id, db_name, db_user) VALUES (?, ?, ?)",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(&db_name)
|
||||
.bind(&db_user)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.last_insert_id();
|
||||
|
||||
let db = sqlx::query_as::<_, Database>(
|
||||
"SELECT id, account_id, db_name, db_user, created_at
|
||||
FROM account_databases WHERE id = ?",
|
||||
)
|
||||
.bind(insert_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(db))
|
||||
}
|
||||
|
||||
/// Drop a MariaDB database and its dedicated user.
|
||||
pub async fn delete_database(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(db_name): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
// Fetch tracking record
|
||||
let row: Option<(i64, String)> = sqlx::query_as(
|
||||
"SELECT id, db_user FROM account_databases WHERE db_name = ?",
|
||||
)
|
||||
.bind(&db_name)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (db_id, db_user) = row.ok_or(AppError::NotFound)?;
|
||||
|
||||
// Safety: only allow dropping databases that follow the user_name pattern
|
||||
if !db_name.contains('_') {
|
||||
return Err(AppError::BadRequest(
|
||||
"Will not drop databases without account prefix".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Drop database and user from MariaDB
|
||||
let sql = format!(
|
||||
"DROP DATABASE IF EXISTS `{db_name}`;\n\
|
||||
DROP USER IF EXISTS '{db_user}'@'localhost';\n\
|
||||
FLUSH PRIVILEGES;\n",
|
||||
db_name = db_name,
|
||||
db_user = db_user,
|
||||
);
|
||||
|
||||
mysql_exec(&sql).map_err(|e| AppError::BadRequest(format!("MariaDB: {}", e)))?;
|
||||
|
||||
// Remove tracking record
|
||||
sqlx::query("DELETE FROM account_databases WHERE id = ?")
|
||||
.bind(db_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true, "deleted": db_name })))
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::{auth::Claims, error::AppError, state::AppState};
|
||||
|
||||
/* ── Types ───────────────────────────────────────────────────────────── */
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct DnsRecord {
|
||||
pub name: String,
|
||||
pub ttl: u32,
|
||||
pub rtype: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddRecordRequest {
|
||||
pub name: String,
|
||||
pub rtype: String,
|
||||
pub ttl: Option<u32>,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteRecordRequest {
|
||||
pub name: String,
|
||||
pub rtype: String,
|
||||
}
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/// Parse `pdnsutil list-zone <domain>` tab-separated output
|
||||
fn parse_zone(domain: &str, output: &str) -> Vec<DnsRecord> {
|
||||
let mut records = Vec::new();
|
||||
for line in output.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
// Columns: name\tttl\tIN\ttype\tcontent
|
||||
let cols: Vec<&str> = line.splitn(5, '\t').collect();
|
||||
if cols.len() < 5 {
|
||||
continue;
|
||||
}
|
||||
let raw_name = cols[0].trim().trim_end_matches('.');
|
||||
let ttl: u32 = cols[1].trim().parse().unwrap_or(3600);
|
||||
// cols[2] == "IN"
|
||||
let rtype = cols[3].trim().to_uppercase();
|
||||
let content = cols[4].trim().to_string();
|
||||
|
||||
if rtype == "SOA" {
|
||||
continue; // skip internal records
|
||||
}
|
||||
|
||||
// Normalise name: FQDN → relative label (@=apex)
|
||||
let name = if raw_name == domain || raw_name.is_empty() {
|
||||
"@".to_string()
|
||||
} else {
|
||||
let suffix = format!(".{}", domain);
|
||||
if raw_name.ends_with(&suffix) {
|
||||
raw_name[..raw_name.len() - suffix.len()].to_string()
|
||||
} else {
|
||||
raw_name.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
records.push(DnsRecord { name, ttl, rtype, content });
|
||||
}
|
||||
records
|
||||
}
|
||||
|
||||
fn pdnsutil(args: &[&str]) -> Result<String, String> {
|
||||
let out = Command::new("pdnsutil")
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run pdnsutil: {}", e))?;
|
||||
if out.status.success() {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Handlers ────────────────────────────────────────────────────────── */
|
||||
|
||||
/// List all DNS zones managed by PowerDNS
|
||||
pub async fn list_zones(
|
||||
_claims: Claims,
|
||||
State(_state): State<AppState>,
|
||||
) -> Result<Json<Vec<String>>, AppError> {
|
||||
let out = Command::new("pdnsutil")
|
||||
.args(["list-all-zones"])
|
||||
.output()
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("pdnsutil: {}", e)))?;
|
||||
|
||||
let zones: Vec<String> = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.map(|l| l.trim().trim_end_matches('.').to_string())
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect();
|
||||
|
||||
Ok(Json(zones))
|
||||
}
|
||||
|
||||
/// List all records in a DNS zone
|
||||
pub async fn list_zone_records(
|
||||
_claims: Claims,
|
||||
State(_state): State<AppState>,
|
||||
Path(domain): Path<String>,
|
||||
) -> Result<Json<Vec<DnsRecord>>, AppError> {
|
||||
let out = Command::new("pdnsutil")
|
||||
.args(["list-zone", &domain])
|
||||
.output()
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("pdnsutil: {}", e)))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
Ok(Json(parse_zone(&domain, &stdout)))
|
||||
}
|
||||
|
||||
/// Add a DNS record to a zone
|
||||
pub async fn add_record(
|
||||
_claims: Claims,
|
||||
State(_state): State<AppState>,
|
||||
Path(domain): Path<String>,
|
||||
Json(body): Json<AddRecordRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
if body.name.is_empty() || body.rtype.is_empty() || body.content.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"name, rtype, and content are required".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let ttl = body.ttl.unwrap_or(3600).to_string();
|
||||
pdnsutil(&["add-record", &domain, &body.name, &body.rtype, &ttl, &body.content])
|
||||
.map_err(|e| AppError::BadRequest(format!("DNS error: {}", e)))?;
|
||||
|
||||
let _ = pdnsutil(&["rectify-zone", &domain]);
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Delete an RRset (all records of a type at a name) from a zone
|
||||
pub async fn delete_record(
|
||||
_claims: Claims,
|
||||
State(_state): State<AppState>,
|
||||
Path(domain): Path<String>,
|
||||
Json(body): Json<DeleteRecordRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
if body.name.is_empty() || body.rtype.is_empty() {
|
||||
return Err(AppError::BadRequest("name and rtype are required".into()));
|
||||
}
|
||||
|
||||
pdnsutil(&["delete-rrset", &domain, &body.name, &body.rtype])
|
||||
.map_err(|e| AppError::BadRequest(format!("DNS error: {}", e)))?;
|
||||
|
||||
let _ = pdnsutil(&["rectify-zone", &domain]);
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use chrono::NaiveDateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{auth::Claims, error::AppError, state::AppState};
|
||||
|
||||
/* ── Types ───────────────────────────────────────────────────────────── */
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
pub struct EmailAccount {
|
||||
pub id: i64,
|
||||
pub account_id: i64,
|
||||
pub address: String,
|
||||
pub quota_mb: i32,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateEmailRequest {
|
||||
pub username: String, // local part (before @)
|
||||
pub domain: String, // domain
|
||||
pub password: String,
|
||||
pub quota_mb: Option<i32>,
|
||||
}
|
||||
|
||||
/* ── Handlers ────────────────────────────────────────────────────────── */
|
||||
|
||||
/// List all email accounts for a hosting account (by account username).
|
||||
/// Admin can pass any username; a regular user's JWT sub is used automatically.
|
||||
pub async fn list_email_accounts(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(account_username): Path<String>,
|
||||
) -> Result<Json<Vec<EmailAccount>>, AppError> {
|
||||
let emails = sqlx::query_as::<_, EmailAccount>(
|
||||
"SELECT ea.id, ea.account_id, ea.address, ea.quota_mb, ea.created_at
|
||||
FROM email_accounts ea
|
||||
JOIN accounts a ON a.id = ea.account_id
|
||||
WHERE a.username = ?
|
||||
ORDER BY ea.created_at DESC",
|
||||
)
|
||||
.bind(&account_username)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(emails))
|
||||
}
|
||||
|
||||
/// Create a new email account under a domain.
|
||||
pub async fn create_email_account(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<CreateEmailRequest>,
|
||||
) -> Result<Json<EmailAccount>, AppError> {
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
|
||||
if body.username.is_empty() || body.domain.is_empty() || body.password.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"username, domain, and password are required".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate local part (no @, spaces, etc.)
|
||||
if body.username.contains('@') || body.username.contains(' ') {
|
||||
return Err(AppError::BadRequest("Invalid email username".into()));
|
||||
}
|
||||
|
||||
let address = format!("{}@{}", body.username, body.domain);
|
||||
|
||||
// Look up account by domain
|
||||
let row: Option<(i64, String)> = sqlx::query_as(
|
||||
"SELECT id, username FROM accounts WHERE domain = ?",
|
||||
)
|
||||
.bind(&body.domain)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (account_id, acct_username) = row.ok_or_else(|| {
|
||||
AppError::BadRequest(format!("No account for domain {}", body.domain))
|
||||
})?;
|
||||
|
||||
// Hash password
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hash = Argon2::default()
|
||||
.hash_password(body.password.as_bytes(), &salt)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("argon2: {}", e)))?
|
||||
.to_string();
|
||||
|
||||
let quota_mb: i32 = body.quota_mb.unwrap_or(1024);
|
||||
|
||||
let insert_id: u64 = sqlx::query(
|
||||
"INSERT INTO email_accounts (account_id, address, password_hash, quota_mb)
|
||||
VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(&address)
|
||||
.bind(&hash)
|
||||
.bind(quota_mb)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.last_insert_id();
|
||||
|
||||
// Provision Maildir structure under the account's home directory
|
||||
let mail_root = format!("/home/{}/mail/{}", acct_username, body.username);
|
||||
for sub in ["new", "cur", "tmp"] {
|
||||
let _ = std::fs::create_dir_all(format!("{}/{}", mail_root, sub));
|
||||
}
|
||||
let _ = std::process::Command::new("chown")
|
||||
.args([
|
||||
"-R",
|
||||
&format!("{}:{}", acct_username, acct_username),
|
||||
&mail_root,
|
||||
])
|
||||
.output();
|
||||
|
||||
let email = sqlx::query_as::<_, EmailAccount>(
|
||||
"SELECT id, account_id, address, quota_mb, created_at
|
||||
FROM email_accounts WHERE id = ?",
|
||||
)
|
||||
.bind(insert_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(email))
|
||||
}
|
||||
|
||||
/// Delete an email account by ID.
|
||||
pub async fn delete_email_account(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
sqlx::query("DELETE FROM email_accounts WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{auth::Claims, error::AppError, state::AppState};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ChangePasswordRequest {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
/// Change the password of the currently logged-in user.
|
||||
pub async fn change_password(
|
||||
claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<ChangePasswordRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2, PasswordHash,
|
||||
};
|
||||
|
||||
if body.new_password.len() < 8 {
|
||||
return Err(AppError::BadRequest(
|
||||
"New password must be at least 8 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Fetch current hash from users table
|
||||
let hash: Option<String> = sqlx::query_scalar(
|
||||
"SELECT password_hash FROM users WHERE id = ?",
|
||||
)
|
||||
.bind(claims.user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let hash = hash.ok_or(AppError::NotFound)?;
|
||||
|
||||
// Verify current password
|
||||
let parsed = PasswordHash::new(&hash)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("hash parse: {}", e)))?;
|
||||
|
||||
Argon2::default()
|
||||
.verify_password(body.current_password.as_bytes(), &parsed)
|
||||
.map_err(|_| AppError::BadRequest("Current password is incorrect".into()))?;
|
||||
|
||||
// Hash new password
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let new_hash = Argon2::default()
|
||||
.hash_password(body.new_password.as_bytes(), &salt)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("argon2: {}", e)))?
|
||||
.to_string();
|
||||
|
||||
// Update users table
|
||||
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||
.bind(&new_hash)
|
||||
.bind(claims.user_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
// Also update accounts table if there's a matching account (for panel login)
|
||||
sqlx::query(
|
||||
"UPDATE accounts SET password_hash = ? WHERE username = ?",
|
||||
)
|
||||
.bind(&new_hash)
|
||||
.bind(&claims.sub)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.ok(); // not an error if no matching account
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod auth;
|
||||
pub mod accounts;
|
||||
pub mod dashboard;
|
||||
pub mod databases;
|
||||
pub mod dns;
|
||||
pub mod email;
|
||||
pub mod me;
|
||||
pub mod services;
|
||||
@@ -0,0 +1,48 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
|
||||
use crate::{auth::Claims, error::AppError, section, state::AppState};
|
||||
|
||||
pub async fn list_services(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let resp = section::call(
|
||||
&state.config.bin_dir,
|
||||
"nixpanel-services",
|
||||
"list",
|
||||
serde_json::Value::Null,
|
||||
)
|
||||
.map_err(|e| AppError::Internal(e))?;
|
||||
|
||||
Ok(Json(resp.data.unwrap_or(serde_json::json!([]))))
|
||||
}
|
||||
|
||||
pub async fn service_action(
|
||||
_claims: Claims,
|
||||
State(state): State<AppState>,
|
||||
Path((name, action)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let allowed = ["start", "stop", "restart", "reload"];
|
||||
if !allowed.contains(&action.as_str()) {
|
||||
return Err(AppError::BadRequest(format!("Unknown action: {}", action)));
|
||||
}
|
||||
|
||||
let resp = section::call(
|
||||
&state.config.bin_dir,
|
||||
"nixpanel-services",
|
||||
&action,
|
||||
serde_json::json!({ "service": name }),
|
||||
)
|
||||
.map_err(|e| AppError::Internal(e))?;
|
||||
|
||||
if resp.success {
|
||||
Ok(Json(serde_json::json!({ "success": true, "message": resp.data })))
|
||||
} else {
|
||||
Err(AppError::BadRequest(
|
||||
resp.error.unwrap_or_else(|| "Action failed".into()),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Helpers for calling section binaries via stdin/stdout JSON.
|
||||
|
||||
use nixpanel_common::{SectionRequest, SectionResponse};
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Call a section binary and return its parsed response.
|
||||
/// `bin_dir` is e.g. "/opt/nixpanel/bin".
|
||||
pub fn call(bin_dir: &str, binary: &str, action: &str, params: serde_json::Value)
|
||||
-> anyhow::Result<SectionResponse>
|
||||
{
|
||||
let bin_path = format!("{}/{}", bin_dir, binary);
|
||||
let req = SectionRequest { action: action.to_string(), params };
|
||||
let req_json = serde_json::to_string(&req)?;
|
||||
|
||||
let mut child = Command::new(&bin_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to start {}: {}", bin_path, e))?;
|
||||
|
||||
if let Some(stdin) = child.stdin.take() {
|
||||
let mut stdin = stdin;
|
||||
stdin.write_all(req_json.as_bytes())?;
|
||||
stdin.write_all(b"\n")?;
|
||||
}
|
||||
|
||||
let output = child.wait_with_output()?;
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
let resp: SectionResponse = serde_json::from_str(text.trim())
|
||||
.map_err(|e| anyhow::anyhow!("Bad response from {}: {} (raw: {})", binary, e, text.trim()))?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
use crate::config::Config;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: sqlx::MySqlPool,
|
||||
pub config: Arc<Config>,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "nixpanel-common"
|
||||
version = "0.2.0-alpha"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,47 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Every section binary reads one of these from stdin.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SectionRequest {
|
||||
pub action: String,
|
||||
#[serde(default)]
|
||||
pub params: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Every section binary writes one of these to stdout.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SectionResponse {
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl SectionResponse {
|
||||
pub fn ok<T: Serialize>(data: T) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
data: Some(serde_json::to_value(data).unwrap_or(serde_json::Value::Null)),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn err(msg: impl ToString) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(msg.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared service descriptor used by both sysinfo and services sections.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ServiceInfo {
|
||||
pub name: String,
|
||||
pub display: String,
|
||||
pub status: String, // "running" | "stopped" | "warning"
|
||||
pub pid: Option<u32>,
|
||||
pub uptime: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "nixpanel-services"
|
||||
version = "0.2.0-alpha"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "nixpanel-services"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
nixpanel-common = { path = "../nixpanel-common" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,156 @@
|
||||
//! nixpanel-services — service management section binary
|
||||
//!
|
||||
//! Protocol: read one SectionRequest JSON line from stdin,
|
||||
//! write one SectionResponse JSON line to stdout.
|
||||
//!
|
||||
//! Actions:
|
||||
//! list → [ServiceInfo]
|
||||
//! start {service} → {success, message}
|
||||
//! stop {service} → {success, message}
|
||||
//! restart {service} → {success, message}
|
||||
|
||||
use nixpanel_common::{SectionRequest, SectionResponse, ServiceInfo};
|
||||
use std::io::{self, BufRead};
|
||||
use std::process::Command;
|
||||
|
||||
/// Canonical list of managed services (systemd unit → display name).
|
||||
const SERVICES: &[(&str, &str)] = &[
|
||||
("apache2", "Apache HTTP Server"),
|
||||
("mariadb", "MariaDB"),
|
||||
("pdns", "PowerDNS"),
|
||||
("exim4", "Exim (SMTP)"),
|
||||
("dovecot", "Dovecot (IMAP)"),
|
||||
("spamd", "SpamAssassin"),
|
||||
("fail2ban", "Fail2ban"),
|
||||
("proftpd", "ProFTPD"),
|
||||
("nixpanel", "NixPanel API"),
|
||||
];
|
||||
|
||||
fn systemctl(args: &[&str]) -> std::io::Result<std::process::Output> {
|
||||
Command::new("systemctl").args(args).output()
|
||||
}
|
||||
|
||||
fn is_active(unit: &str) -> &'static str {
|
||||
match systemctl(&["is-active", "--quiet", unit]) {
|
||||
Ok(out) if out.status.success() => "running",
|
||||
_ => "stopped",
|
||||
}
|
||||
}
|
||||
|
||||
fn main_pid(unit: &str) -> Option<u32> {
|
||||
let out = systemctl(&["show", unit, "--property=MainPID"]).ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let pid_str = text.trim().strip_prefix("MainPID=")?;
|
||||
pid_str.parse::<u32>().ok().filter(|&p| p != 0)
|
||||
}
|
||||
|
||||
fn active_enter_timestamp(unit: &str) -> Option<u64> {
|
||||
let out = systemctl(&["show", unit, "--property=ActiveEnterTimestampMonotonic"]).ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let ts_str = text.trim().strip_prefix("ActiveEnterTimestampMonotonic=")?;
|
||||
ts_str.parse::<u64>().ok()
|
||||
}
|
||||
|
||||
fn monotonic_now_usec() -> u64 {
|
||||
// /proc/uptime gives seconds since boot; multiply by 1_000_000 for µs
|
||||
std::fs::read_to_string("/proc/uptime")
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
s.split_whitespace()
|
||||
.next()
|
||||
.and_then(|n| n.parse::<f64>().ok())
|
||||
})
|
||||
.map(|secs| (secs * 1_000_000.0) as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn format_uptime(elapsed_sec: u64) -> String {
|
||||
let days = elapsed_sec / 86400;
|
||||
let hours = (elapsed_sec % 86400) / 3600;
|
||||
let mins = (elapsed_sec % 3600) / 60;
|
||||
if days > 0 {
|
||||
format!("{}d {}h", days, hours)
|
||||
} else if hours > 0 {
|
||||
format!("{}h {}m", hours, mins)
|
||||
} else {
|
||||
format!("{}m", mins)
|
||||
}
|
||||
}
|
||||
|
||||
fn service_info(unit: &str, display: &str) -> ServiceInfo {
|
||||
let status = is_active(unit);
|
||||
let pid = if status == "running" { main_pid(unit) } else { None };
|
||||
let uptime = if status == "running" {
|
||||
active_enter_timestamp(unit).map(|start| {
|
||||
let now = monotonic_now_usec();
|
||||
let secs = if now > start { (now - start) / 1_000_000 } else { 0 };
|
||||
format_uptime(secs)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
ServiceInfo {
|
||||
name: unit.to_string(),
|
||||
display: display.to_string(),
|
||||
status: status.to_string(),
|
||||
pid,
|
||||
uptime,
|
||||
}
|
||||
}
|
||||
|
||||
fn list_services() -> SectionResponse {
|
||||
let infos: Vec<ServiceInfo> = SERVICES
|
||||
.iter()
|
||||
.map(|(unit, display)| service_info(unit, display))
|
||||
.collect();
|
||||
SectionResponse::ok(infos)
|
||||
}
|
||||
|
||||
fn control_service(unit: &str, action: &str) -> SectionResponse {
|
||||
// Validate service name
|
||||
if !SERVICES.iter().any(|(u, _)| *u == unit) {
|
||||
return SectionResponse::err(format!("Unknown service: {}", unit));
|
||||
}
|
||||
// Validate action
|
||||
let allowed = ["start", "stop", "restart", "reload"];
|
||||
if !allowed.contains(&action) {
|
||||
return SectionResponse::err(format!("Unknown action: {}", action));
|
||||
}
|
||||
|
||||
match systemctl(&[action, unit]) {
|
||||
Ok(out) if out.status.success() => SectionResponse::ok(serde_json::json!({
|
||||
"message": format!("Service {} {}ed", unit, action)
|
||||
})),
|
||||
Ok(out) => {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
SectionResponse::err(format!("systemctl {} {} failed: {}", action, unit, stderr.trim()))
|
||||
}
|
||||
Err(e) => SectionResponse::err(format!("Failed to run systemctl: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
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 request JSON: {}", e)),
|
||||
Ok(req) => match req.action.as_str() {
|
||||
"list" => list_services(),
|
||||
"start" | "stop" | "restart" | "reload" => {
|
||||
let unit = req.params["service"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
if unit.is_empty() {
|
||||
SectionResponse::err("Missing 'service' param")
|
||||
} else {
|
||||
control_service(unit, &req.action)
|
||||
}
|
||||
}
|
||||
other => SectionResponse::err(format!("Unknown action: {}", other)),
|
||||
},
|
||||
};
|
||||
|
||||
println!("{}", serde_json::to_string(&response).unwrap());
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "nixpanel-sysinfo"
|
||||
version = "0.2.0-alpha"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "nixpanel-sysinfo"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
nixpanel-common = { path = "../nixpanel-common" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,246 @@
|
||||
//! nixpanel-sysinfo — system information section binary
|
||||
//!
|
||||
//! Actions:
|
||||
//! stats → {hostname, ip, os, kernel, uptime_sec, cpu_pct, ram_used_mb,
|
||||
//! ram_total_mb, disk_used_gb, disk_total_gb, load_avg}
|
||||
//! versions → {apache, php, mariadb, nixpanel}
|
||||
|
||||
use nixpanel_common::{SectionRequest, SectionResponse};
|
||||
use std::fs;
|
||||
use std::io::{self, BufRead};
|
||||
use std::net::UdpSocket;
|
||||
use std::process::Command;
|
||||
|
||||
/* ── helpers ──────────────────────────────────────────────────────────── */
|
||||
|
||||
fn cmd(prog: &str, args: &[&str]) -> String {
|
||||
Command::new(prog)
|
||||
.args(args)
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn read_file(path: &str) -> String {
|
||||
fs::read_to_string(path).unwrap_or_default()
|
||||
}
|
||||
|
||||
/* ── hostname / IP ────────────────────────────────────────────────────── */
|
||||
|
||||
fn get_hostname() -> String {
|
||||
fs::read_to_string("/etc/hostname")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn get_ip() -> String {
|
||||
// Quick UDP trick — no actual packet sent
|
||||
UdpSocket::bind("0.0.0.0:0")
|
||||
.and_then(|s| {
|
||||
s.connect("8.8.8.8:80")?;
|
||||
s.local_addr()
|
||||
})
|
||||
.map(|a| a.ip().to_string())
|
||||
.unwrap_or_else(|_| "0.0.0.0".into())
|
||||
}
|
||||
|
||||
/* ── OS / kernel ──────────────────────────────────────────────────────── */
|
||||
|
||||
fn get_os() -> String {
|
||||
let content = read_file("/etc/os-release");
|
||||
for line in content.lines() {
|
||||
if let Some(val) = line.strip_prefix("PRETTY_NAME=") {
|
||||
return val.trim_matches('"').to_string();
|
||||
}
|
||||
}
|
||||
"Unknown".into()
|
||||
}
|
||||
|
||||
fn get_kernel() -> String {
|
||||
cmd("uname", &["-r"])
|
||||
}
|
||||
|
||||
/* ── uptime ───────────────────────────────────────────────────────────── */
|
||||
|
||||
fn get_uptime_sec() -> u64 {
|
||||
read_file("/proc/uptime")
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.map(|s| s as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/* ── CPU ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/// Reads /proc/stat twice (100ms apart) to compute a 1-sample CPU %.
|
||||
fn get_cpu_pct() -> f32 {
|
||||
fn read_stat() -> Option<(u64, u64)> {
|
||||
let content = fs::read_to_string("/proc/stat").ok()?;
|
||||
let line = content.lines().find(|l| l.starts_with("cpu "))?;
|
||||
let nums: Vec<u64> = line
|
||||
.split_whitespace()
|
||||
.skip(1)
|
||||
.filter_map(|n| n.parse().ok())
|
||||
.collect();
|
||||
if nums.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let idle = nums[3];
|
||||
let total: u64 = nums.iter().sum();
|
||||
Some((idle, total))
|
||||
}
|
||||
|
||||
let s1 = read_stat().unwrap_or((0, 1));
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
let s2 = read_stat().unwrap_or((0, 1));
|
||||
|
||||
let d_total = s2.1.saturating_sub(s1.1);
|
||||
let d_idle = s2.0.saturating_sub(s1.0);
|
||||
if d_total == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
((d_total - d_idle) as f32 / d_total as f32 * 100.0 * 10.0).round() / 10.0
|
||||
}
|
||||
|
||||
/* ── RAM ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
fn get_ram() -> (u64, u64) {
|
||||
// Returns (used_mb, total_mb)
|
||||
let content = read_file("/proc/meminfo");
|
||||
let mut total_kb: u64 = 0;
|
||||
let mut available_kb: u64 = 0;
|
||||
|
||||
for line in content.lines() {
|
||||
let mut parts = line.split_whitespace();
|
||||
match parts.next() {
|
||||
Some("MemTotal:") => { total_kb = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0); }
|
||||
Some("MemAvailable:") => { available_kb = parts.next().and_then(|n| n.parse().ok()).unwrap_or(0); }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let used_mb = (total_kb.saturating_sub(available_kb)) / 1024;
|
||||
let total_mb = total_kb / 1024;
|
||||
(used_mb, total_mb)
|
||||
}
|
||||
|
||||
/* ── Disk ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
fn get_disk() -> (u64, u64) {
|
||||
// Returns (used_gb, total_gb) for /
|
||||
let stat = nix_statvfs("/");
|
||||
stat.unwrap_or((0, 0))
|
||||
}
|
||||
|
||||
fn nix_statvfs(path: &str) -> Option<(u64, u64)> {
|
||||
// Use df as a fallback — parse "df -B1 /"
|
||||
let out = Command::new("df")
|
||||
.args(&["-B1", path])
|
||||
.output()
|
||||
.ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
let line = text.lines().nth(1)?; // skip header
|
||||
let mut parts = line.split_whitespace();
|
||||
let _fs = parts.next()?;
|
||||
let total = parts.next()?.parse::<u64>().ok()?;
|
||||
let used = parts.next()?.parse::<u64>().ok()?;
|
||||
Some((used / 1_073_741_824, total / 1_073_741_824))
|
||||
}
|
||||
|
||||
/* ── Load average ─────────────────────────────────────────────────────── */
|
||||
|
||||
fn get_load_avg() -> [f32; 3] {
|
||||
let content = read_file("/proc/loadavg");
|
||||
let mut parts = content.split_whitespace();
|
||||
let l1 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
|
||||
let l5 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
|
||||
let l15= parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
|
||||
[l1, l5, l15]
|
||||
}
|
||||
|
||||
/* ── Software versions ────────────────────────────────────────────────── */
|
||||
|
||||
fn apache_version() -> String {
|
||||
let out = cmd("apache2", &["-v"]);
|
||||
// "Server version: Apache/2.4.58 (Ubuntu)"
|
||||
out.lines()
|
||||
.find(|l| l.starts_with("Server version:"))
|
||||
.and_then(|l| l.split('/').nth(1))
|
||||
.and_then(|s| s.split_whitespace().next())
|
||||
.unwrap_or("—")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn php_version() -> String {
|
||||
cmd("php8.3", &["--version"])
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.unwrap_or("—")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn mariadb_version() -> String {
|
||||
let out = cmd("mariadb", &["--version"]);
|
||||
// "mariadb Ver 15.1 Distrib 10.11.x-MariaDB ..."
|
||||
out.split_whitespace()
|
||||
.enumerate()
|
||||
.find(|(_, w)| w.contains("MariaDB") || w.contains("Distrib"))
|
||||
.map(|(i, _)| {
|
||||
out.split_whitespace()
|
||||
.nth(i.saturating_sub(1))
|
||||
.unwrap_or("—")
|
||||
.trim_end_matches(',')
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|| "—".to_string())
|
||||
}
|
||||
|
||||
/* ── Actions ──────────────────────────────────────────────────────────── */
|
||||
|
||||
fn action_stats() -> SectionResponse {
|
||||
let (ram_used, ram_total) = get_ram();
|
||||
let (disk_used, disk_total) = get_disk();
|
||||
SectionResponse::ok(serde_json::json!({
|
||||
"hostname": get_hostname(),
|
||||
"ip": get_ip(),
|
||||
"os": get_os(),
|
||||
"kernel": get_kernel(),
|
||||
"uptime_sec": get_uptime_sec(),
|
||||
"cpu_pct": get_cpu_pct(),
|
||||
"ram_used_mb": ram_used,
|
||||
"ram_total_mb": ram_total,
|
||||
"disk_used_gb": disk_used,
|
||||
"disk_total_gb": disk_total,
|
||||
"load_avg": get_load_avg(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn action_versions() -> SectionResponse {
|
||||
SectionResponse::ok(serde_json::json!({
|
||||
"apache": apache_version(),
|
||||
"php": php_version(),
|
||||
"mariadb": mariadb_version(),
|
||||
"nixpanel": env!("CARGO_PKG_VERSION"),
|
||||
}))
|
||||
}
|
||||
|
||||
/* ── 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() {
|
||||
"stats" => action_stats(),
|
||||
"versions" => action_versions(),
|
||||
other => SectionResponse::err(format!("Unknown action: {}", other)),
|
||||
},
|
||||
};
|
||||
|
||||
println!("{}", serde_json::to_string(&response).unwrap());
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NixServer — NixPanel Admin</title>
|
||||
</head>
|
||||
<body class="bg-gray-950">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "nixpanel-nixserver",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,229 @@
|
||||
/* NixServer API client — all calls go to /api/ (proxied to Rust backend) */
|
||||
|
||||
const BASE = '/api'
|
||||
|
||||
/* ── Auth token ──────────────────────────────────────────────────────── */
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem('nixpanel_token')
|
||||
}
|
||||
export function setToken(t: string) {
|
||||
localStorage.setItem('nixpanel_token', t)
|
||||
}
|
||||
export function clearToken() {
|
||||
localStorage.removeItem('nixpanel_token')
|
||||
}
|
||||
export function isLoggedIn(): boolean {
|
||||
return !!getToken()
|
||||
}
|
||||
|
||||
/* ── Core fetch wrapper ──────────────────────────────────────────────── */
|
||||
async function apiFetch<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = getToken()
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
})
|
||||
if (res.status === 401) {
|
||||
clearToken()
|
||||
window.location.reload()
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/* ── Auth ────────────────────────────────────────────────────────────── */
|
||||
export interface LoginResult {
|
||||
token: string
|
||||
user: { id: number; username: string; role: string }
|
||||
}
|
||||
export async function login(username: string, password: string): Promise<LoginResult> {
|
||||
const result = await apiFetch<LoginResult>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
setToken(result.token)
|
||||
return result
|
||||
}
|
||||
|
||||
/* ── Dashboard ───────────────────────────────────────────────────────── */
|
||||
export interface DashboardStats {
|
||||
accounts: number
|
||||
domains: number
|
||||
databases: number
|
||||
email_accounts: number
|
||||
}
|
||||
export interface SysInfo {
|
||||
hostname: string
|
||||
ip: string
|
||||
os: string
|
||||
kernel: string
|
||||
uptime_sec: number
|
||||
cpu_pct: number
|
||||
ram_used_mb: number
|
||||
ram_total_mb: number
|
||||
disk_used_gb: number
|
||||
disk_total_gb: number
|
||||
load_avg: [number, number, number]
|
||||
}
|
||||
export interface Versions {
|
||||
apache: string
|
||||
php: string
|
||||
mariadb: string
|
||||
nixpanel: string
|
||||
}
|
||||
export interface DashboardData {
|
||||
stats: DashboardStats
|
||||
sysinfo: SysInfo | null
|
||||
versions: Versions | null
|
||||
}
|
||||
export async function getDashboard(): Promise<DashboardData> {
|
||||
return apiFetch('/dashboard')
|
||||
}
|
||||
|
||||
/* ── Services ────────────────────────────────────────────────────────── */
|
||||
export interface ServiceInfo {
|
||||
name: string
|
||||
display: string
|
||||
status: 'running' | 'stopped' | 'warning'
|
||||
pid: number | null
|
||||
uptime: string | null
|
||||
}
|
||||
export async function getServices(): Promise<ServiceInfo[]> {
|
||||
return apiFetch('/services')
|
||||
}
|
||||
export async function serviceAction(
|
||||
name: string,
|
||||
action: 'start' | 'stop' | 'restart'
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return apiFetch(`/services/${name}/${action}`, { method: 'POST' })
|
||||
}
|
||||
|
||||
/* ── Accounts ────────────────────────────────────────────────────────── */
|
||||
export interface Account {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
email: string
|
||||
package_name: string
|
||||
disk_quota_mb: number
|
||||
bandwidth_mb: number
|
||||
status: 'active' | 'suspended' | 'terminated'
|
||||
created_at: string
|
||||
}
|
||||
export async function getAccounts(): Promise<Account[]> {
|
||||
return apiFetch('/accounts')
|
||||
}
|
||||
export async function getAccount(username: string): Promise<Account> {
|
||||
return apiFetch(`/accounts/${username}`)
|
||||
}
|
||||
export async function createAccount(data: {
|
||||
username: string
|
||||
domain: string
|
||||
email: string
|
||||
password: string
|
||||
package_name?: string
|
||||
disk_quota_mb?: number
|
||||
bandwidth_mb?: number
|
||||
}): Promise<Account> {
|
||||
return apiFetch('/accounts', { method: 'POST', body: JSON.stringify(data) })
|
||||
}
|
||||
export async function suspendAccount(username: string): Promise<void> {
|
||||
await apiFetch(`/accounts/${username}/suspend`, { method: 'POST' })
|
||||
}
|
||||
export async function unsuspendAccount(username: string): Promise<void> {
|
||||
await apiFetch(`/accounts/${username}/unsuspend`, { method: 'POST' })
|
||||
}
|
||||
export async function terminateAccount(username: string): Promise<void> {
|
||||
await apiFetch(`/accounts/${username}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/* ── Databases ───────────────────────────────────────────────────────── */
|
||||
export interface Database {
|
||||
id: number
|
||||
account_id: number
|
||||
db_name: string
|
||||
db_user: string
|
||||
created_at: string
|
||||
account_username?: string // present in admin list view
|
||||
}
|
||||
export async function listAllDatabases(): Promise<Database[]> {
|
||||
return apiFetch('/databases')
|
||||
}
|
||||
export async function listAccountDatabases(username: string): Promise<Database[]> {
|
||||
return apiFetch(`/databases/${username}`)
|
||||
}
|
||||
export async function createDatabase(data: {
|
||||
username: string
|
||||
db_suffix: string
|
||||
db_user_suffix?: string
|
||||
db_password: string
|
||||
}): Promise<Database> {
|
||||
return apiFetch('/databases', { method: 'POST', body: JSON.stringify(data) })
|
||||
}
|
||||
export async function deleteDatabase(dbName: string): Promise<void> {
|
||||
await apiFetch(`/databases/name/${encodeURIComponent(dbName)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/* ── DNS ─────────────────────────────────────────────────────────────── */
|
||||
export interface DnsRecord {
|
||||
name: string
|
||||
ttl: number
|
||||
rtype: string
|
||||
content: string
|
||||
}
|
||||
export async function listZones(): Promise<string[]> {
|
||||
return apiFetch('/dns')
|
||||
}
|
||||
export async function listZoneRecords(domain: string): Promise<DnsRecord[]> {
|
||||
return apiFetch(`/dns/${domain}`)
|
||||
}
|
||||
export async function addDnsRecord(
|
||||
domain: string,
|
||||
record: { name: string; rtype: string; ttl?: number; content: string }
|
||||
): Promise<void> {
|
||||
await apiFetch(`/dns/${domain}`, { method: 'POST', body: JSON.stringify(record) })
|
||||
}
|
||||
export async function deleteDnsRecord(
|
||||
domain: string,
|
||||
name: string,
|
||||
rtype: string
|
||||
): Promise<void> {
|
||||
await apiFetch(`/dns/${domain}`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ name, rtype }),
|
||||
})
|
||||
}
|
||||
|
||||
/* ── Email accounts ──────────────────────────────────────────────────── */
|
||||
export interface EmailAccount {
|
||||
id: number
|
||||
account_id: number
|
||||
address: string
|
||||
quota_mb: number
|
||||
created_at: string
|
||||
}
|
||||
export async function listEmailAccounts(username: string): Promise<EmailAccount[]> {
|
||||
return apiFetch(`/email/${username}`)
|
||||
}
|
||||
export async function createEmailAccount(data: {
|
||||
username: string
|
||||
domain: string
|
||||
password: string
|
||||
quota_mb?: number
|
||||
}): Promise<EmailAccount> {
|
||||
return apiFetch('/email', { method: 'POST', body: JSON.stringify(data) })
|
||||
}
|
||||
export async function deleteEmailAccount(id: number): Promise<void> {
|
||||
await apiFetch(`/email/id/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: rgb(3, 7, 30);
|
||||
color: rgb(229, 231, 235);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
minify: 'esbuild',
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:4000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user