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

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

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

CHANGELOG.md: v0.4.0-alpha entry
Cargo.toml: version bump 0.3.0-alpha -> 0.4.0-alpha
2026-04-26 06:01:23 -05:00
NixReaper 079170b43a feat: v0.3.0-alpha — DNS management, email accounts, suspend/terminate, password change
Backend:
- routes/dns.rs: GET/POST/DELETE /api/dns/:domain (pdnsutil CRUD, SOA hidden, auto-rectify)
- routes/email.rs: GET/POST/DELETE email accounts with Maildir provisioning
- routes/me.rs: POST /api/me/password (verify current, hash new, update users+accounts)
- routes/accounts.rs: GET /api/accounts/:username, suspend, unsuspend, terminate (deprovision+userdel)
- main.rs: register all new routes; import axum::routing::delete
- Cargo.toml: version bump 0.2.0-alpha -> 0.3.0-alpha

NixServer:
- Zone Manager page: lists all PowerDNS zones, click -> DNS editor
- DNS Records page: add/delete records, colour-coded type badges, zone switcher
- Service Manager page: dedicated page with refresh + running/stopped pills
- List Accounts: Suspend/Unsuspend/Terminate buttons with confirmation modal
- Domain cell links to live site

NixClient:
- Domains page: primary domain info, document root, quota, bandwidth
- Email Accounts page: create/list/delete mailboxes with quota display
- Change Password page: Argon2id verify + update server-side
- AccountSidebar: fetches real account data (domain, package, quota)
- api.ts: listMyEmailAccounts, createEmailAccount, deleteEmailAccount, changePassword, getMyAccount(username)

CHANGELOG.md: v0.3.0-alpha entry
2026-04-26 05:23:55 -05:00
NixReaper e79a8a996a fix: always restart nixpanel service after binary rebuild in install.sh 2026-04-26 04:54:08 -05:00
NixReaper 2eb8198267 fix: stop nixpanel service before overwriting binary to avoid text file busy error on reinstall 2026-04-25 21:38:56 -05:00
NixReaper 389652b2e5 feat: real account provisioner, fix NixClient auth bypass
- nixpanel-accounts: creates Apache vhost, PHP-FPM pool, PowerDNS
  zone (A/MX/SPF records), home dir structure, default index.html
- nixpanel-api: create_account now calls provisioner after DB insert
- NixClient: replace fake setTimeout login with real JWT API call;
  admin accounts blocked with redirect message; logout clears token
- CHANGELOG updated
2026-04-25 21:32:32 -05:00
NixReaper cb6be7c634 fix: move FormField outside CreateAccount to prevent input focus loss on keystroke 2026-04-25 21:21:45 -05:00
NixReaper 09b043d764 fix: change BIGINT UNSIGNED to BIGINT, INT columns map to i32 not i64 2026-04-25 20:59:41 -05:00
NixReaper 144d08a581 fix: skip UPX .d metadata files when copying section binaries 2026-04-25 17:15:34 -05:00
NixReaper 7724c86572 chore: bump version to 0.2.0-alpha, update CHANGELOG 2026-04-25 16:58:27 -05:00
NixReaper d3b27bb90c fix: replace sqlx macros with non-macro queries, drop axum_extra dep, fix unused imports 2026-04-25 16:48:14 -05:00
NixReaper 5247c77132 feat: add Rust backend — workspace with section executables
Cargo workspace with four crates:
- nixpanel-common: shared SectionRequest/SectionResponse types
- nixpanel-api: main Axum HTTP server (JWT auth, DB, routing)
- nixpanel-services: section binary for systemctl start/stop/restart
- nixpanel-sysinfo: section binary for CPU/RAM/disk/OS/version reads

nixpanel-api features:
- POST /api/auth/login — Argon2id verification, HS256 JWT
- GET  /api/dashboard  — real account counts + sysinfo + versions
- GET  /api/services   — live systemctl status via section binary
- POST /api/services/:name/:action — start/stop/restart
- GET  /api/accounts   — list from MariaDB
- POST /api/accounts   — create account + system user

Section binaries are UPX-compressed after build.
DB schema auto-migrated on first start; admin user seeded from
/opt/nixpanel/.admin_user + .admin_pass files.

Frontend changes:
- nixserver/src/api.ts + App.tsx wired to real API (JWT stored in
  localStorage, real data in dashboard/services/accounts)
- nixclient/src/api.ts added
- Both vite configs get /api proxy to localhost:4000 for dev
- install.sh updated to copy + compress all section binaries
2026-04-25 16:41:57 -05:00
NixReaper a257b5571c feat: redesign NixServer (WHM) and NixClient (cPanel) UIs
NixServer now has a dark orange-accented WHM-style layout with
collapsible sidebar grouped by function, stat bar, service manager
table with actions, quick-action icon grid, server info bar, and
a branded split-panel login page.

NixClient now has a cPanel-style feature icon grid with category
sections, live search, account sidebar with usage bars, and a
blue-themed login page.
2026-04-25 15:51:47 -05:00
NixReaper 0f28e879f0 fix: change login field from email to username type
Browser validates type=email and rejects plain usernames like 'admin'.
Changed to type=text with Username label to match the generated credentials.
2026-04-25 15:27:28 -05:00
NixReaper 4428a49876 fix: auto-generate admin creds, fix NixClient build path
- Replace interactive read prompts with auto-generated password
  (openssl rand) so curl|bash installs work without a TTY
- Display username/password in the completion banner
- Fix NixClient package.json check to use absolute path $SRC_DIR
  (was relative, broke after 'cd nixserver' changed cwd)
2026-04-25 10:17:42 -05:00
NixReaper 7603fa5793 fix: add Node.js 20 LTS as its own install step, show npm build errors
- Node.js now has its own step (nodejs.done) separate from the packages
  step, so it always runs even when packages.done already exists
- Checks for Node 18+ and skips reinstall if already present
- Removed --silent and 2>/dev/null from npm commands so build errors
  are visible instead of silently killing the installer
2026-04-25 09:56:59 -05:00
NixReaper fb975cdc4e fix: install Node.js 20 LTS before UI build steps
npm was never installed, causing the NixServer/NixClient builds to fail
immediately after 'Building NixServer...' with no visible error.

Added NodeSource setup_20.x before the ondrej/php PPA, then included
nodejs in the apt-get install list. NodeSource gives us npm 10+ and
Node 20 LTS rather than Ubuntu's stale node package.
2026-04-25 09:55:31 -05:00
NixReaper 2735b9c8f4 fix: switch vite minifier from terser to esbuild
terser is not in package.json so vite fails at the minification step
after transforming all modules — the script exits non-zero, killing
the installer. esbuild is bundled with vite and requires no extra dep.
2026-04-25 09:45:13 -05:00
NixReaper 5881f06cbc fix: always clean-clone source dir, drop stale master refspec workaround
The old .git/config in /opt/nixpanel/src had a fetch refspec pointing to
refs/heads/master (from before the branch rename). Re-running the installer
with that directory present caused 'git fetch' to fail with 'couldn't find
remote ref refs/heads/master'.

Fix: rm -rf the source dir before cloning whenever the source step hasn't
been marked done, so we always get a clean checkout with the correct branch.
2026-04-25 09:37:40 -05:00
NixReaper 5cefe15f6a fix: make git clone fallback explicitly use main branch
The releases API returns 404 when no releases exist, causing the installer
to fall back to 'main'. However, the fallback clone was not explicitly
specifying a branch, which could cause issues if the repository's default
branch configuration is in an inconsistent state. Now the fallback clone
also explicitly uses --branch main to ensure consistent behavior.
2026-04-25 09:26:56 -05:00
NixReaper e58769a4bd feat: add NixServer and NixClient React UIs, wire into installer
- nixserver/: React 18 + TypeScript + Vite + Tailwind
  - Login page + placeholder admin dashboard with card grid
  - Blue accent color (#3b82f6)
  - Full Tailwind + PostCSS + TypeScript setup
- nixclient/: identical structure, user-facing panel
  - Green accent color (#10b981)
  - Same build tooling as NixServer
- install.sh: new 'Building NixServer & NixClient UIs' step
  - npm install + npm run build for each
  - Outputs to /opt/nixpanel/nixserver/dist and /opt/nixpanel/nixclient/dist
  - Both panels immediately accessible via Apache on 2087 and 2083
  - Skips gracefully if source not present (early dev)
2026-04-25 09:22:12 -05:00
NixReaper 4994d33fd5 ci: fix security workflow — drop CodeQL, fix ShellCheck severity
- Remove CodeQL job: this repo has no JS/TS or Rust source code,
  so CodeQL exits with error 32 ('no source code seen during build')
  on every run. CodeQL belongs on nixpanel-website/nixpanel-licensing.
- cargo-audit: skip Rust toolchain install entirely when no Cargo.toml
  present (saves ~3 min of wasted CI time); use step output condition
- ShellCheck: severity error->only fails on real errors, not style
  warnings; pin to ludeeus/[email protected] (not @master)
- Rename workflow to 'Security Audit' (CodeQL is no longer included)
2026-04-25 08:54:20 -05:00
NixReaper f063af70d8 ci: add CodeQL, cargo-audit, and ShellCheck security scanning
- CodeQL: scans JS/TS with security-extended + security-and-quality queries
- cargo-audit: scans Rust dependencies for known CVEs (--deny warnings)
- ShellCheck: lints install.sh and all shell scripts for bugs/vulnerabilities
- Runs on push to master, PRs, and weekly on Mondays at 08:00 UTC
2026-04-25 05:17:52 -05:00
NixReaper 776d328ace security: add SECURITY.md policy and enable GitHub security features
- SECURITY.md: reporting instructions (private advisory + email),
  scope, disclosure policy, post-install hardening tips
- Enabled via API: Dependabot alerts, private vulnerability reporting
2026-04-25 05:17:11 -05:00
NixReaper 01e64d5c61 chore: remove private repo references from public changelog 2026-04-25 05:13:53 -05:00
52 changed files with 5632 additions and 83 deletions
+72
View File
@@ -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
View File
@@ -9,16 +9,125 @@ Versions follow [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Planned
- NixServer (WHM) — full Rust backend
- NixClient (cPanel) — full Rust backend
- DNS zone editor
- Email account management
- SSL certificate management via Let's Encrypt
- SSL certificate management (Let's Encrypt via Certbot)
- File manager
- MySQL/MariaDB database manager
- FTP account management (vsftpd)
- WordPress one-click installer
- Backup / restore system
- Firewall rule editor (CSF/iptables)
- Multi-server support (Agency plan)
- NixClient pages: FTP, subdomains, redirects, PHP version selector, stats
---
## [0.4.0-alpha] — 2026-04-26
### Added
- **MySQL / MariaDB database manager**
- `GET /api/databases` — list all databases across all accounts (admin)
- `GET /api/databases/:username` — list databases for one hosting account
- `POST /api/databases` — create database + dedicated MariaDB user with GRANT ALL
- `DELETE /api/databases/name/:db_name` — drop database + user, cascade nixpanel record
- cPanel-style namespacing: `username_dbname` / `username_dbuser` (max 64/32 chars)
- Identifier validation (alphanumeric + underscore only)
- Live connection-details preview in the create form (database, username, host, port)
- Safety check: refuses to drop databases without the account prefix separator
- **NixServer — Databases page**: admin view across all accounts, create form with live preview, drop with confirmation modal
- **NixClient — MySQL Databases page**: per-account view, create with password confirm + connection-info card, drop with confirmation
### Changed
- NixServer/NixClient footer version: `v0.3.0-alpha` → `v0.4.0-alpha`
---
## [0.3.0-alpha] — 2026-04-25
### Added
- **DNS Management** — full CRUD via pdnsutil
- `GET /api/dns` — list all PowerDNS zones
- `GET /api/dns/:domain` — list records (A, AAAA, MX, TXT, CNAME, NS, SRV, CAA)
- `POST /api/dns/:domain` — add record (name, type, TTL, content)
- `DELETE /api/dns/:domain` — delete an RRset (name + type)
- SOA records hidden; zone auto-rectified after each change
- **NixServer — Zone Manager page** (`domains`): lists all PowerDNS zones, click → DNS editor
- **NixServer — DNS Records page** (`dns`): record table with inline add/delete, zone switcher dropdown, colour-coded type badges
- **NixServer — Service Manager page** (`services`): dedicated page with refresh button and running/stopped summary pills
- **NixServer — Account actions**: Suspend / Unsuspend / Terminate buttons with confirmation modal
- `POST /api/accounts/:username/suspend` / `unsuspend`
- `DELETE /api/accounts/:username` — deprovisions vhost, FPM, DNS; removes system user; cascades DB records
- **Email account management**
- `GET /api/email/:username` — list email accounts for a hosting account
- `POST /api/email` — create mailbox (Maildir provisioned under `~/mail/`)
- `DELETE /api/email/id/:id` — delete email account
- **NixClient — Email Accounts page** — create, list, delete mailboxes with quota display
- **NixClient — Domains page** — shows primary domain, document root, quota, bandwidth
- **NixClient — Change Password page** — verifies current password, hashes and updates both `users` and `accounts` tables
- **NixClient — AccountSidebar** now fetches real account data (domain, package, quota) from `GET /api/accounts/:username`
- **Password change API** — `POST /api/me/password` (verifies old password via Argon2id, updates `users` + `accounts`)
- **Single account lookup** — `GET /api/accounts/:username`
### Changed
- NixServer footer / login version bump: `v0.2.0-alpha` → `v0.3.0-alpha`
- NixClient footer / login version bump: `v0.2.0-alpha` → `v0.3.0-alpha`
- List Accounts: domain cell links to site, suspend/terminate replace placeholder Edit button
---
## [0.2.0-alpha] — 2026-04-25
### Added
- **Rust backend** — Cargo workspace with independently compiled + UPX-compressed section executables
- `nixpanel-common` — shared `SectionRequest` / `SectionResponse` IPC types
- `nixpanel-api` — main Axum HTTP server on port 4000
- `POST /api/auth/login` — Argon2id password verification, HS256 JWT (8-hour expiry)
- `GET /api/dashboard` — real account/domain/database/email counts + system stats
- `GET /api/services` — live service status via systemctl
- `POST /api/services/:name/:action` — start / stop / restart any managed service
- `GET /api/accounts` — list hosting accounts from MariaDB
- `POST /api/accounts` — create account (system user, home dir, DB record)
- Auto-runs DB schema migrations on first start
- Seeds admin user from `/opt/nixpanel/.admin_user` + `.admin_pass` on first run
- `nixpanel-services` — section binary; reads JSON from stdin, calls systemctl, writes JSON to stdout
- Actions: `list`, `start`, `stop`, `restart`, `reload`
- Reports PID and uptime per service
- `nixpanel-sysinfo` — section binary; reads `/proc/meminfo`, `/proc/stat`, `/proc/loadavg`, `df`
- Actions: `stats` (CPU, RAM, disk, load, hostname, IP, OS, kernel), `versions` (Apache, PHP, MariaDB)
- **Database schema** (`nixpanel-api/migrations/0001_initial.sql`)
- Tables: `users`, `accounts`, `domains`, `dns_records`, `account_databases`,
`email_accounts`, `ftp_accounts`, `ssl_certificates`, `backups`
- **Frontend API clients** (`nixserver/src/api.ts`, `nixclient/src/api.ts`)
- JWT stored in `localStorage`, included in all requests as `Authorization: Bearer`
- Auto-clears token and reloads on 401
### Changed
- **NixServer UI** — full redesign matching WHM visual style
- Dark navy / orange-accent theme
- Collapsible sidebar with grouped navigation sections
- Dashboard: live stat bar, server info bar, service manager table, quick-action icon grid
- List Accounts: real data table with search/filter
- Create Account: full form wired to API (creates system user, DB record)
- Login: real JWT auth; session persists across refresh via `localStorage`
- **NixClient UI** — full redesign matching cPanel visual style
- Blue-accent theme
- Feature icon grid grouped by category with live search
- Account sidebar with usage bars
- Login: real JWT auth
- **Installer** (`install.sh`)
- Rust build step now copies all `nixpanel-*` section binaries to `/opt/nixpanel/bin/`
- Installs UPX and compresses each binary after build
- Admin credentials auto-generated with `openssl rand` (no TTY prompts)
- Node.js 20 LTS installed as its own idempotent step (`nodejs.done`)
- NixClient build uses absolute path to avoid wrong-directory check
- npm output visible during build (removed `--silent`) for easier debugging
- **Vite configs** — added `/api` proxy to `localhost:4000` for local development
- **Minifier** — switched both UIs from `terser` (not installed) to `esbuild`
### Fixed
- Login inputs changed from `type="email"` to `type="text"` with "Username" label
- `sqlx::query!()` macros replaced with non-macro `query()` / `query_as()` / `query_scalar()`
so the binary compiles without a live database connection
- Removed `axum_extra` import that was never added to `Cargo.toml`
- Git clone always removes stale `$SRC_DIR` before cloning (fixes "couldn't find remote ref master" on re-run)
---
@@ -28,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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+12
View File
@@ -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>
+24
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+894
View File
@@ -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 &lt;username&gt; -p &lt;database&gt;</span>
&nbsp;·&nbsp;Host: <span className="font-mono text-gray-500">localhost</span>
&nbsp;·&nbsp;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>
)
}
+132
View File
@@ -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,
}),
})
}
+22
View File
@@ -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);
}
+10
View File
@@ -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>,
)
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
+18
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -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,
},
},
},
})
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "nixpanel-accounts"
version = "0.2.0-alpha"
edition = "2021"
[[bin]]
name = "nixpanel-accounts"
path = "src/main.rs"
[dependencies]
nixpanel-common = { path = "../nixpanel-common" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+322
View File
@@ -0,0 +1,322 @@
//! nixpanel-accounts — account provisioning section binary
//!
//! Actions:
//! provision {username, domain, email, server_ip, php_version?}
//! → creates vhost, PHP-FPM pool, DNS zone, home directory structure
//!
//! deprovision {username, domain}
//! → removes vhost, PHP-FPM pool, DNS zone, optionally home dir
use nixpanel_common::{SectionRequest, SectionResponse};
use std::fs;
use std::io::{self, BufRead};
use std::process::Command;
/* ── helpers ──────────────────────────────────────────────────────────── */
fn run(prog: &str, args: &[&str]) -> Result<String, String> {
let out = Command::new(prog)
.args(args)
.output()
.map_err(|e| format!("Failed to run {}: {}", prog, e))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
fn run_ok(prog: &str, args: &[&str]) {
let _ = Command::new(prog).args(args).output();
}
fn write_file(path: &str, content: &str) -> Result<(), String> {
fs::write(path, content).map_err(|e| format!("Write {}: {}", path, e))
}
fn server_ip() -> String {
// Read from /opt/nixpanel sysinfo, or fall back to hostname -I
Command::new("hostname")
.arg("-I")
.output()
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.split_whitespace()
.next()
.unwrap_or("127.0.0.1")
.to_string()
})
.unwrap_or_else(|_| "127.0.0.1".to_string())
}
/* ── Apache vhost ─────────────────────────────────────────────────────── */
fn create_vhost(username: &str, domain: &str, php_version: &str) -> Result<(), String> {
let public_html = format!("/home/{}/public_html", username);
let log_dir = format!("/home/{}/logs", username);
let sock = format!("/run/php/php{}-fpm-{}.sock", php_version, username);
let conf = format!(
r#"# NixPanel — {domain} ({username})
<VirtualHost *:80>
ServerName {domain}
ServerAlias www.{domain}
DocumentRoot {public_html}
<Directory {public_html}>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
<FilesMatch \.php$>
SetHandler "proxy:unix:{sock}|fcgi://localhost"
</FilesMatch>
ErrorLog {log_dir}/error.log
CustomLog {log_dir}/access.log combined
</VirtualHost>
"#,
domain = domain,
username = username,
public_html = public_html,
log_dir = log_dir,
sock = sock,
);
let conf_path = format!("/etc/apache2/sites-available/{}.conf", username);
write_file(&conf_path, &conf)?;
run("a2ensite", &[&format!("{}.conf", username)])?;
Ok(())
}
fn remove_vhost(username: &str) {
run_ok("a2dissite", &[&format!("{}.conf", username)]);
let _ = fs::remove_file(format!("/etc/apache2/sites-available/{}.conf", username));
}
/* ── PHP-FPM pool ─────────────────────────────────────────────────────── */
fn create_fpm_pool(username: &str, php_version: &str) -> Result<(), String> {
let sock = format!("/run/php/php{}-fpm-{}.sock", php_version, username);
let pool = format!(
r#"[{username}]
user = {username}
group = {username}
listen = {sock}
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
pm.max_children = 10
pm.process_idle_timeout = 10s
pm.max_requests = 500
php_admin_value[open_basedir] = /home/{username}/:/tmp/
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
env[PATH] = /usr/local/bin:/usr/bin:/bin
"#,
username = username,
sock = sock,
);
let pool_path = format!(
"/etc/php/{}/fpm/pool.d/{}.conf",
php_version, username
);
write_file(&pool_path, &pool)?;
run("systemctl", &["reload", &format!("php{}-fpm", php_version)])?;
Ok(())
}
fn remove_fpm_pool(username: &str, php_version: &str) {
let path = format!("/etc/php/{}/fpm/pool.d/{}.conf", php_version, username);
let _ = fs::remove_file(&path);
run_ok("systemctl", &["reload", &format!("php{}-fpm", php_version)]);
}
/* ── PowerDNS zone ────────────────────────────────────────────────────── */
fn create_dns_zone(domain: &str, ip: &str) -> Result<(), String> {
// Create zone
run("pdnsutil", &["create-zone", domain])?;
// A records
run("pdnsutil", &["add-record", domain, "@", "A", "3600", ip])?;
run("pdnsutil", &["add-record", domain, "www", "A", "3600", ip])?;
run("pdnsutil", &["add-record", domain, "mail","A", "3600", ip])?;
run("pdnsutil", &["add-record", domain, "ftp", "A", "3600", ip])?;
// MX record
run("pdnsutil", &["add-record", domain, "@", "MX", "3600",
&format!("10 mail.{}.", domain)])?;
// SPF TXT
run("pdnsutil", &["add-record", domain, "@", "TXT", "3600",
&format!("\"v=spf1 a mx ip4:{} ~all\"", ip)])?;
// Increase serial and notify
run_ok("pdnsutil", &["rectify-zone", domain]);
Ok(())
}
fn remove_dns_zone(domain: &str) {
run_ok("pdnsutil", &["delete-zone", domain]);
}
/* ── Home directory structure ─────────────────────────────────────────── */
fn create_home(username: &str, domain: &str) -> Result<(), String> {
let dirs = [
format!("/home/{}/public_html", username),
format!("/home/{}/logs", username),
format!("/home/{}/tmp", username),
format!("/home/{}/mail", username),
format!("/home/{}/backups", username),
];
for dir in &dirs {
fs::create_dir_all(dir)
.map_err(|e| format!("mkdir {}: {}", dir, e))?;
}
// Default index.html
let index = format!(
r#"<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Welcome to {domain}</title>
<style>body{{font-family:sans-serif;text-align:center;padding:80px;color:#333}}
h1{{color:#e85d04}}p{{color:#666}}</style></head>
<body>
<h1>🚀 {domain}</h1>
<p>Your hosting account is active. Upload your files to <code>public_html</code> to get started.</p>
<p><small>Powered by NixPanel</small></p>
</body></html>
"#,
domain = domain
);
write_file(&format!("/home/{}/public_html/index.html", username), &index)?;
// Set ownership to system user
run("chown", &["-R", &format!("{}:{}", username, username),
&format!("/home/{}", username)])?;
run("chmod", &["750", &format!("/home/{}", username)])?;
run("chmod", &["755", &format!("/home/{}/public_html", username)])?;
Ok(())
}
/* ── Actions ──────────────────────────────────────────────────────────── */
fn action_provision(params: &serde_json::Value) -> SectionResponse {
let username = params["username"].as_str().unwrap_or("");
let domain = params["domain"].as_str().unwrap_or("");
let php_version = params["php_version"].as_str().unwrap_or("8.3");
let ip = params["server_ip"].as_str()
.map(|s| s.to_string())
.unwrap_or_else(server_ip);
if username.is_empty() || domain.is_empty() {
return SectionResponse::err("username and domain are required");
}
// Sanitise: only allow alphanumeric + hyphen/dot in domain,
// alphanumeric + underscore in username
if !username.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
return SectionResponse::err("Invalid username");
}
let mut steps: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new();
// 1. Home directory
match create_home(username, domain) {
Ok(()) => steps.push("home directory created".into()),
Err(e) => errors.push(format!("home dir: {}", e)),
}
// 2. PHP-FPM pool
match create_fpm_pool(username, php_version) {
Ok(()) => steps.push(format!("PHP {} FPM pool created", php_version)),
Err(e) => errors.push(format!("fpm pool: {}", e)),
}
// 3. Apache vhost
match create_vhost(username, domain, php_version) {
Ok(()) => steps.push("Apache vhost created".into()),
Err(e) => errors.push(format!("vhost: {}", e)),
}
// 4. Reload Apache
match run("systemctl", &["reload", "apache2"]) {
Ok(_) => steps.push("Apache reloaded".into()),
Err(e) => errors.push(format!("apache reload: {}", e)),
}
// 5. PowerDNS zone
match create_dns_zone(domain, &ip) {
Ok(()) => steps.push(format!("DNS zone created (IP: {})", ip)),
Err(e) => errors.push(format!("DNS: {}", e)),
}
if errors.is_empty() {
SectionResponse::ok(serde_json::json!({
"steps": steps,
"domain": domain,
"ip": ip,
}))
} else {
// Partial success — return both what worked and what didn't
SectionResponse::ok(serde_json::json!({
"steps": steps,
"errors": errors,
"partial": true,
}))
}
}
fn action_deprovision(params: &serde_json::Value) -> SectionResponse {
let username = params["username"].as_str().unwrap_or("");
let domain = params["domain"].as_str().unwrap_or("");
let php_version = params["php_version"].as_str().unwrap_or("8.3");
if username.is_empty() {
return SectionResponse::err("username is required");
}
remove_vhost(username);
remove_fpm_pool(username, php_version);
if !domain.is_empty() {
remove_dns_zone(domain);
}
run_ok("systemctl", &["reload", "apache2"]);
SectionResponse::ok(serde_json::json!({
"message": format!("Account {} deprovisioned", username)
}))
}
/* ── Entry point ──────────────────────────────────────────────────────── */
fn main() {
let stdin = io::stdin();
let mut line = String::new();
stdin.lock().read_line(&mut line).unwrap_or(0);
let response = match serde_json::from_str::<SectionRequest>(line.trim()) {
Err(e) => SectionResponse::err(format!("Invalid JSON: {}", e)),
Ok(req) => match req.action.as_str() {
"provision" => action_provision(&req.params),
"deprovision" => action_deprovision(&req.params),
other => SectionResponse::err(format!("Unknown action: {}", other)),
},
};
println!("{}", serde_json::to_string(&response).unwrap());
}
+30
View File
@@ -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"
+127
View File
@@ -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
+70
View File
@@ -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)
}
}
+24
View File
@@ -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()),
})
}
}
+48
View File
@@ -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
}
}
+189
View File
@@ -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(())
}
+252
View File
@@ -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))
}
+64
View File
@@ -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,
},
}))
}
+48
View File
@@ -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),
}))
}
+266
View File
@@ -0,0 +1,266 @@
use axum::{
extract::{Path, State},
Json,
};
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::process::{Command, Stdio};
use crate::{auth::Claims, error::AppError, state::AppState};
/* ── Types ───────────────────────────────────────────────────────────── */
#[derive(Serialize, sqlx::FromRow)]
pub struct Database {
pub id: i64,
pub account_id: i64,
pub db_name: String,
pub db_user: String,
pub created_at: NaiveDateTime,
}
#[derive(Serialize, sqlx::FromRow)]
pub struct DatabaseWithAccount {
pub id: i64,
pub account_id: i64,
pub db_name: String,
pub db_user: String,
pub created_at: NaiveDateTime,
pub account_username: String,
}
#[derive(Deserialize)]
pub struct CreateDatabaseRequest {
/// Hosting account username (admin supplies this; NixClient uses JWT sub)
pub username: String,
/// Short suffix — final DB name will be `username_db_suffix`
pub db_suffix: String,
/// Short suffix — final DB user will be `username_user_suffix` (defaults to db_suffix)
pub db_user_suffix: Option<String>,
pub db_password: String,
}
/* ── MySQL helpers ───────────────────────────────────────────────────── */
/// Escape a string value safe for use inside a single-quoted MySQL literal.
fn escape_mysql(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('\'', "\\'")
.replace('\0', "\\0")
.replace('\n', "\\n")
.replace('\r', "\\r")
}
/// Execute one or more SQL statements via `mysql -u root` (unix socket auth).
fn mysql_exec(sql: &str) -> Result<String, String> {
let mut child = Command::new("mysql")
.args(["-u", "root"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("mysql spawn: {}", e))?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(sql.as_bytes()).ok();
}
let out = child.wait_with_output().map_err(|e| format!("mysql wait: {}", e))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
/// Validate an identifier: only alphanumeric + underscore, non-empty.
fn valid_ident(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_alphanumeric() || c == '_')
}
/* ── Handlers ────────────────────────────────────────────────────────── */
/// List all databases across all accounts (admin / NixServer).
pub async fn list_all_databases(
_claims: Claims,
State(state): State<AppState>,
) -> Result<Json<Vec<DatabaseWithAccount>>, AppError> {
let rows = sqlx::query_as::<_, DatabaseWithAccount>(
"SELECT ad.id, ad.account_id, ad.db_name, ad.db_user, ad.created_at,
a.username AS account_username
FROM account_databases ad
JOIN accounts a ON a.id = ad.account_id
ORDER BY ad.created_at DESC",
)
.fetch_all(&state.db)
.await?;
Ok(Json(rows))
}
/// List databases for a specific hosting account (NixClient).
pub async fn list_account_databases(
_claims: Claims,
State(state): State<AppState>,
Path(account_username): Path<String>,
) -> Result<Json<Vec<Database>>, AppError> {
let rows = sqlx::query_as::<_, Database>(
"SELECT ad.id, ad.account_id, ad.db_name, ad.db_user, ad.created_at
FROM account_databases ad
JOIN accounts a ON a.id = ad.account_id
WHERE a.username = ?
ORDER BY ad.created_at DESC",
)
.bind(&account_username)
.fetch_all(&state.db)
.await?;
Ok(Json(rows))
}
/// Create a MariaDB database + user, then record in nixpanel DB.
pub async fn create_database(
_claims: Claims,
State(state): State<AppState>,
Json(body): Json<CreateDatabaseRequest>,
) -> Result<Json<Database>, AppError> {
// Validate inputs
if body.username.is_empty() || body.db_suffix.is_empty() || body.db_password.is_empty() {
return Err(AppError::BadRequest(
"username, db_suffix, and db_password are required".into(),
));
}
if !valid_ident(&body.db_suffix) {
return Err(AppError::BadRequest(
"db_suffix must be alphanumeric with underscores only".into(),
));
}
let user_suffix = body.db_user_suffix.as_deref().unwrap_or(&body.db_suffix);
if !valid_ident(user_suffix) {
return Err(AppError::BadRequest(
"db_user_suffix must be alphanumeric with underscores only".into(),
));
}
if body.db_password.len() < 6 {
return Err(AppError::BadRequest("Password must be at least 6 characters".into()));
}
// Build cPanel-style namespaced names
let db_name = format!("{}_{}", body.username, body.db_suffix);
let db_user = format!("{}_{}", body.username, user_suffix);
// MySQL identifier length limits
if db_name.len() > 64 {
return Err(AppError::BadRequest("Database name too long (max 64 chars)".into()));
}
if db_user.len() > 32 {
return Err(AppError::BadRequest("Database username too long (max 32 chars)".into()));
}
// Get account_id
let account_id: Option<i64> = sqlx::query_scalar(
"SELECT id FROM accounts WHERE username = ?",
)
.bind(&body.username)
.fetch_optional(&state.db)
.await?;
let account_id = account_id.ok_or_else(|| {
AppError::BadRequest(format!("Account '{}' not found", body.username))
})?;
// Check DB doesn't already exist in our tracking table
let exists: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM account_databases WHERE db_name = ?",
)
.bind(&db_name)
.fetch_one(&state.db)
.await?;
if exists > 0 {
return Err(AppError::BadRequest(format!(
"Database '{}' already exists",
db_name
)));
}
// Create database + user in MariaDB
let pw = escape_mysql(&body.db_password);
let sql = format!(
"CREATE DATABASE IF NOT EXISTS `{db_name}` \
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;\n\
CREATE USER IF NOT EXISTS '{db_user}'@'localhost' IDENTIFIED BY '{pw}';\n\
GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{db_user}'@'localhost';\n\
FLUSH PRIVILEGES;\n",
db_name = db_name,
db_user = db_user,
pw = pw,
);
mysql_exec(&sql).map_err(|e| AppError::BadRequest(format!("MariaDB: {}", e)))?;
// Record in nixpanel tracking table
let insert_id: u64 = sqlx::query(
"INSERT INTO account_databases (account_id, db_name, db_user) VALUES (?, ?, ?)",
)
.bind(account_id)
.bind(&db_name)
.bind(&db_user)
.execute(&state.db)
.await?
.last_insert_id();
let db = sqlx::query_as::<_, Database>(
"SELECT id, account_id, db_name, db_user, created_at
FROM account_databases WHERE id = ?",
)
.bind(insert_id)
.fetch_one(&state.db)
.await?;
Ok(Json(db))
}
/// Drop a MariaDB database and its dedicated user.
pub async fn delete_database(
_claims: Claims,
State(state): State<AppState>,
Path(db_name): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
// Fetch tracking record
let row: Option<(i64, String)> = sqlx::query_as(
"SELECT id, db_user FROM account_databases WHERE db_name = ?",
)
.bind(&db_name)
.fetch_optional(&state.db)
.await?;
let (db_id, db_user) = row.ok_or(AppError::NotFound)?;
// Safety: only allow dropping databases that follow the user_name pattern
if !db_name.contains('_') {
return Err(AppError::BadRequest(
"Will not drop databases without account prefix".into(),
));
}
// Drop database and user from MariaDB
let sql = format!(
"DROP DATABASE IF EXISTS `{db_name}`;\n\
DROP USER IF EXISTS '{db_user}'@'localhost';\n\
FLUSH PRIVILEGES;\n",
db_name = db_name,
db_user = db_user,
);
mysql_exec(&sql).map_err(|e| AppError::BadRequest(format!("MariaDB: {}", e)))?;
// Remove tracking record
sqlx::query("DELETE FROM account_databases WHERE id = ?")
.bind(db_id)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true, "deleted": db_name })))
}
+163
View File
@@ -0,0 +1,163 @@
use axum::{
extract::{Path, State},
Json,
};
use serde::{Deserialize, Serialize};
use std::process::Command;
use crate::{auth::Claims, error::AppError, state::AppState};
/* ── Types ───────────────────────────────────────────────────────────── */
#[derive(Serialize, Clone)]
pub struct DnsRecord {
pub name: String,
pub ttl: u32,
pub rtype: String,
pub content: String,
}
#[derive(Deserialize)]
pub struct AddRecordRequest {
pub name: String,
pub rtype: String,
pub ttl: Option<u32>,
pub content: String,
}
#[derive(Deserialize)]
pub struct DeleteRecordRequest {
pub name: String,
pub rtype: String,
}
/* ── Helpers ─────────────────────────────────────────────────────────── */
/// Parse `pdnsutil list-zone <domain>` tab-separated output
fn parse_zone(domain: &str, output: &str) -> Vec<DnsRecord> {
let mut records = Vec::new();
for line in output.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
continue;
}
// Columns: name\tttl\tIN\ttype\tcontent
let cols: Vec<&str> = line.splitn(5, '\t').collect();
if cols.len() < 5 {
continue;
}
let raw_name = cols[0].trim().trim_end_matches('.');
let ttl: u32 = cols[1].trim().parse().unwrap_or(3600);
// cols[2] == "IN"
let rtype = cols[3].trim().to_uppercase();
let content = cols[4].trim().to_string();
if rtype == "SOA" {
continue; // skip internal records
}
// Normalise name: FQDN → relative label (@=apex)
let name = if raw_name == domain || raw_name.is_empty() {
"@".to_string()
} else {
let suffix = format!(".{}", domain);
if raw_name.ends_with(&suffix) {
raw_name[..raw_name.len() - suffix.len()].to_string()
} else {
raw_name.to_string()
}
};
records.push(DnsRecord { name, ttl, rtype, content });
}
records
}
fn pdnsutil(args: &[&str]) -> Result<String, String> {
let out = Command::new("pdnsutil")
.args(args)
.output()
.map_err(|e| format!("Failed to run pdnsutil: {}", e))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).to_string())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
/* ── Handlers ────────────────────────────────────────────────────────── */
/// List all DNS zones managed by PowerDNS
pub async fn list_zones(
_claims: Claims,
State(_state): State<AppState>,
) -> Result<Json<Vec<String>>, AppError> {
let out = Command::new("pdnsutil")
.args(["list-all-zones"])
.output()
.map_err(|e| AppError::Internal(anyhow::anyhow!("pdnsutil: {}", e)))?;
let zones: Vec<String> = String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.trim().trim_end_matches('.').to_string())
.filter(|l| !l.is_empty())
.collect();
Ok(Json(zones))
}
/// List all records in a DNS zone
pub async fn list_zone_records(
_claims: Claims,
State(_state): State<AppState>,
Path(domain): Path<String>,
) -> Result<Json<Vec<DnsRecord>>, AppError> {
let out = Command::new("pdnsutil")
.args(["list-zone", &domain])
.output()
.map_err(|e| AppError::Internal(anyhow::anyhow!("pdnsutil: {}", e)))?;
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
Ok(Json(parse_zone(&domain, &stdout)))
}
/// Add a DNS record to a zone
pub async fn add_record(
_claims: Claims,
State(_state): State<AppState>,
Path(domain): Path<String>,
Json(body): Json<AddRecordRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
if body.name.is_empty() || body.rtype.is_empty() || body.content.is_empty() {
return Err(AppError::BadRequest(
"name, rtype, and content are required".into(),
));
}
let ttl = body.ttl.unwrap_or(3600).to_string();
pdnsutil(&["add-record", &domain, &body.name, &body.rtype, &ttl, &body.content])
.map_err(|e| AppError::BadRequest(format!("DNS error: {}", e)))?;
let _ = pdnsutil(&["rectify-zone", &domain]);
Ok(Json(serde_json::json!({ "ok": true })))
}
/// Delete an RRset (all records of a type at a name) from a zone
pub async fn delete_record(
_claims: Claims,
State(_state): State<AppState>,
Path(domain): Path<String>,
Json(body): Json<DeleteRecordRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
if body.name.is_empty() || body.rtype.is_empty() {
return Err(AppError::BadRequest("name and rtype are required".into()));
}
pdnsutil(&["delete-rrset", &domain, &body.name, &body.rtype])
.map_err(|e| AppError::BadRequest(format!("DNS error: {}", e)))?;
let _ = pdnsutil(&["rectify-zone", &domain]);
Ok(Json(serde_json::json!({ "ok": true })))
}
+145
View File
@@ -0,0 +1,145 @@
use axum::{
extract::{Path, State},
Json,
};
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use crate::{auth::Claims, error::AppError, state::AppState};
/* ── Types ───────────────────────────────────────────────────────────── */
#[derive(Serialize, sqlx::FromRow)]
pub struct EmailAccount {
pub id: i64,
pub account_id: i64,
pub address: String,
pub quota_mb: i32,
pub created_at: NaiveDateTime,
}
#[derive(Deserialize)]
pub struct CreateEmailRequest {
pub username: String, // local part (before @)
pub domain: String, // domain
pub password: String,
pub quota_mb: Option<i32>,
}
/* ── Handlers ────────────────────────────────────────────────────────── */
/// List all email accounts for a hosting account (by account username).
/// Admin can pass any username; a regular user's JWT sub is used automatically.
pub async fn list_email_accounts(
_claims: Claims,
State(state): State<AppState>,
Path(account_username): Path<String>,
) -> Result<Json<Vec<EmailAccount>>, AppError> {
let emails = sqlx::query_as::<_, EmailAccount>(
"SELECT ea.id, ea.account_id, ea.address, ea.quota_mb, ea.created_at
FROM email_accounts ea
JOIN accounts a ON a.id = ea.account_id
WHERE a.username = ?
ORDER BY ea.created_at DESC",
)
.bind(&account_username)
.fetch_all(&state.db)
.await?;
Ok(Json(emails))
}
/// Create a new email account under a domain.
pub async fn create_email_account(
_claims: Claims,
State(state): State<AppState>,
Json(body): Json<CreateEmailRequest>,
) -> Result<Json<EmailAccount>, AppError> {
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2,
};
if body.username.is_empty() || body.domain.is_empty() || body.password.is_empty() {
return Err(AppError::BadRequest(
"username, domain, and password are required".into(),
));
}
// Validate local part (no @, spaces, etc.)
if body.username.contains('@') || body.username.contains(' ') {
return Err(AppError::BadRequest("Invalid email username".into()));
}
let address = format!("{}@{}", body.username, body.domain);
// Look up account by domain
let row: Option<(i64, String)> = sqlx::query_as(
"SELECT id, username FROM accounts WHERE domain = ?",
)
.bind(&body.domain)
.fetch_optional(&state.db)
.await?;
let (account_id, acct_username) = row.ok_or_else(|| {
AppError::BadRequest(format!("No account for domain {}", body.domain))
})?;
// Hash password
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(body.password.as_bytes(), &salt)
.map_err(|e| AppError::Internal(anyhow::anyhow!("argon2: {}", e)))?
.to_string();
let quota_mb: i32 = body.quota_mb.unwrap_or(1024);
let insert_id: u64 = sqlx::query(
"INSERT INTO email_accounts (account_id, address, password_hash, quota_mb)
VALUES (?, ?, ?, ?)",
)
.bind(account_id)
.bind(&address)
.bind(&hash)
.bind(quota_mb)
.execute(&state.db)
.await?
.last_insert_id();
// Provision Maildir structure under the account's home directory
let mail_root = format!("/home/{}/mail/{}", acct_username, body.username);
for sub in ["new", "cur", "tmp"] {
let _ = std::fs::create_dir_all(format!("{}/{}", mail_root, sub));
}
let _ = std::process::Command::new("chown")
.args([
"-R",
&format!("{}:{}", acct_username, acct_username),
&mail_root,
])
.output();
let email = sqlx::query_as::<_, EmailAccount>(
"SELECT id, account_id, address, quota_mb, created_at
FROM email_accounts WHERE id = ?",
)
.bind(insert_id)
.fetch_one(&state.db)
.await?;
Ok(Json(email))
}
/// Delete an email account by ID.
pub async fn delete_email_account(
_claims: Claims,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<serde_json::Value>, AppError> {
sqlx::query("DELETE FROM email_accounts WHERE id = ?")
.bind(id)
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
+72
View File
@@ -0,0 +1,72 @@
use axum::{extract::State, Json};
use serde::Deserialize;
use crate::{auth::Claims, error::AppError, state::AppState};
#[derive(Deserialize)]
pub struct ChangePasswordRequest {
pub current_password: String,
pub new_password: String,
}
/// Change the password of the currently logged-in user.
pub async fn change_password(
claims: Claims,
State(state): State<AppState>,
Json(body): Json<ChangePasswordRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, PasswordVerifier, SaltString},
Argon2, PasswordHash,
};
if body.new_password.len() < 8 {
return Err(AppError::BadRequest(
"New password must be at least 8 characters".into(),
));
}
// Fetch current hash from users table
let hash: Option<String> = sqlx::query_scalar(
"SELECT password_hash FROM users WHERE id = ?",
)
.bind(claims.user_id)
.fetch_optional(&state.db)
.await?;
let hash = hash.ok_or(AppError::NotFound)?;
// Verify current password
let parsed = PasswordHash::new(&hash)
.map_err(|e| AppError::Internal(anyhow::anyhow!("hash parse: {}", e)))?;
Argon2::default()
.verify_password(body.current_password.as_bytes(), &parsed)
.map_err(|_| AppError::BadRequest("Current password is incorrect".into()))?;
// Hash new password
let salt = SaltString::generate(&mut OsRng);
let new_hash = Argon2::default()
.hash_password(body.new_password.as_bytes(), &salt)
.map_err(|e| AppError::Internal(anyhow::anyhow!("argon2: {}", e)))?
.to_string();
// Update users table
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
.bind(&new_hash)
.bind(claims.user_id)
.execute(&state.db)
.await?;
// Also update accounts table if there's a matching account (for panel login)
sqlx::query(
"UPDATE accounts SET password_hash = ? WHERE username = ?",
)
.bind(&new_hash)
.bind(&claims.sub)
.execute(&state.db)
.await
.ok(); // not an error if no matching account
Ok(Json(serde_json::json!({ "ok": true })))
}
+8
View File
@@ -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;
+48
View File
@@ -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()),
))
}
}
+35
View File
@@ -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)
}
+8
View File
@@ -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>,
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "nixpanel-common"
version = "0.2.0-alpha"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+47
View File
@@ -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>,
}
+13
View File
@@ -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"
+156
View File
@@ -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());
}
+13
View File
@@ -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"
+246
View File
@@ -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());
}
+12
View File
@@ -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>
+24
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
File diff suppressed because it is too large Load Diff
+229
View File
@@ -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' })
}
+22
View File
@@ -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);
}
+10
View File
@@ -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>,
)
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
+18
View File
@@ -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" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -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,
},
},
},
})