Compare commits

..
14 Commits
Author SHA1 Message Date
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
46 changed files with 3284 additions and 70 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"
+6 -17
View File
@@ -32,24 +32,13 @@ Versions follow [Semantic Versioning](https://semver.org/).
- Configures Apache reverse proxy on ports 2083 (NixClient) and 2087 (NixServer) - Configures Apache reverse proxy on ports 2083 (NixClient) and 2087 (NixServer)
- Prompts for admin credentials, hostname, and license key during install - Prompts for admin credentials, hostname, and license key during install
- License activation against `https://license.nixpanel.io` with grace period fallback - License activation against `https://license.nixpanel.io` with grace period fallback
- **Licensing server** (`nixpanel-licensing`) — central SaaS licensing - **Licensing** — license key validated against `https://license.nixpanel.io` during install
- Rust · Axum · SQLx · MariaDB
- RSA-4096 signed JWT license tokens
- Server IP binding with conflict detection
- Plans: Free, Solo ($9.99/mo), Host ($29.99/mo), Agency ($79.99/mo) - Plans: Free, Solo ($9.99/mo), Host ($29.99/mo), Agency ($79.99/mo)
- Admin API with Argon2id authentication - Server IP binding with grace period fallback
- Validation logging and grace period enforcement - **nixpanel.io** — customer portal at [nixpanel.io](https://nixpanel.io)
- Repo: [NixReaper/nixpanel-licensing](https://github.com/NixReaper/nixpanel-licensing) - Stripe subscriptions with monthly and annual billing
- **Website API** (`nixpanel-website-api`) — nixpanel.io customer portal backend - Customer auth: register, login, email verification, password reset
- Rust · Axum · SQLx · MariaDB - License management and IP change requests
- 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 ### Infrastructure
- `.gitattributes` enforcing LF line endings for `.sh`, `.rs`, `.toml`, `.sql`, `.md` files - `.gitattributes` enforcing LF line endings for `.sh`, `.rs`, `.toml`, `.sql`, `.md` files
+15
View File
@@ -0,0 +1,15 @@
[workspace]
resolver = "2"
members = [
"nixpanel-common",
"nixpanel-api",
"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.
+106 -53
View File
@@ -96,6 +96,15 @@ if ! step_done "packages"; then
# Prereqs for add-apt-repository # Prereqs for add-apt-repository
apt-get install -y -qq ca-certificates gnupg software-properties-common 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 # 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 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)…" 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 \ apt-get install -y -qq \
build-essential pkg-config libssl-dev libmariadb-dev curl wget git unzip \ 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 \ apache2 \
php8.3 php8.3-fpm php8.3-cli php8.3-mysql php8.3-curl php8.3-mbstring \ 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 \ 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 fi
success "System packages installed" 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" step "Installing Rust toolchain"
if ! step_done "rust"; then if ! step_done "rust"; then
curl -fsSL https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal curl -fsSL https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
@@ -178,31 +200,81 @@ if ! step_done "source"; then
[[ -z "$LATEST_TAG" ]] && LATEST_TAG="main" [[ -z "$LATEST_TAG" ]] && LATEST_TAG="main"
info "Fetching tag: $LATEST_TAG" info "Fetching tag: $LATEST_TAG"
if [[ -d "$SRC_DIR/.git" ]]; then # Always do a clean clone when re-running this step — avoids stale .git configs
git -C "$SRC_DIR" fetch --tags --quiet # (e.g. old clones that tracked 'master' before the branch was renamed to 'main')
git -C "$SRC_DIR" checkout "$LATEST_TAG" --quiet rm -rf "$SRC_DIR"
else git clone --depth 1 --branch "$LATEST_TAG" \
git clone --depth 1 --branch "$LATEST_TAG" \ "https://github.com/${GITHUB_REPO}.git" "$SRC_DIR"
"https://github.com/${GITHUB_REPO}.git" "$SRC_DIR" 2>/dev/null \
|| git clone --depth 1 "https://github.com/${GITHUB_REPO}.git" "$SRC_DIR"
fi
mark_done "source" mark_done "source"
fi fi
success "Source ready" 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)" step "Building nixpanel (Rust)"
if ! step_done "build_panel"; then if ! step_done "build_panel"; then
if [[ -f "$SRC_DIR/Cargo.toml" ]]; then if [[ -f "$SRC_DIR/Cargo.toml" ]]; then
cd "$SRC_DIR" cd "$SRC_DIR"
$CARGO build --release --quiet info "Compiling nixpanel workspace (this may take a few minutes on first build)…"
# Copy any produced binaries $CARGO build --release
[[ -f "target/release/nixpanel" ]] && \
cp target/release/nixpanel "$BIN_DIR/nixpanel" && chmod 755 "$BIN_DIR/nixpanel" # 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
if [[ -f "target/release/nixpanel" ]]; then
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
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
mark_done "build_panel" mark_done "build_panel"
success "Panel binary built" success "Panel binaries built and compressed"
else 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." warn "This is expected during early development. Re-run installer when source is ready."
fi fi
fi fi
@@ -222,45 +294,23 @@ success "Secrets ready"
# ── 10. Admin setup ─────────────────────────────────────────────────────────── # ── 10. Admin setup ───────────────────────────────────────────────────────────
step "Panel configuration" step "Panel configuration"
if ! step_done "admin_account"; then if ! step_done "admin_account"; then
echo "" ADMIN_USER="admin"
echo -e " ${BOLD}Create your NixPanel admin account${RESET}" ADMIN_PASS=$(openssl rand -base64 16 | tr -d '/+=' | head -c 20)
read -rp " Admin username [admin]: " ADMIN_USER ADMIN_EMAIL="admin@$(hostname -f)"
ADMIN_USER="${ADMIN_USER:-admin}" SERVER_HOST=$(hostname -f)
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
echo "$SERVER_HOST" > "$INSTALL_DIR/.server_host" echo "$SERVER_HOST" > "$INSTALL_DIR/.server_host"
echo "$LICENSE_KEY" > "$INSTALL_DIR/.license_key" echo "" > "$INSTALL_DIR/.license_key" # set via nixpanel.io after install
chmod 600 "$INSTALL_DIR/.server_host" "$INSTALL_DIR/.license_key" echo "$ADMIN_USER" > "$INSTALL_DIR/.admin_user"
echo "$ADMIN_PASS" > "$INSTALL_DIR/.admin_pass"
# Validate license key against central server chmod 600 "$INSTALL_DIR/.server_host" "$INSTALL_DIR/.license_key" \
info "Validating license key with ${LICENSING_URL}…" "$INSTALL_DIR/.admin_user" "$INSTALL_DIR/.admin_pass"
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
mark_done "admin_account" mark_done "admin_account"
else else
SERVER_HOST=$(cat "$INSTALL_DIR/.server_host" 2>/dev/null || hostname -f) 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 fi
success "Panel configuration done" success "Panel configuration done"
@@ -424,9 +474,12 @@ echo -e "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━
echo -e "${BOLD}${GREEN} NixPanel Installation Complete!${RESET}" echo -e "${BOLD}${GREEN} NixPanel Installation Complete!${RESET}"
echo -e "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" echo -e "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
echo "" echo ""
echo -e " ${BOLD}WHM (Admin panel):${RESET} http://${SERVER_IP}:2087" echo -e " ${BOLD}NixServer (Admin):${RESET} http://${SERVER_IP}:2087"
echo -e " ${BOLD}cPanel (User panel):${RESET} http://${SERVER_IP}:2083" echo -e " ${BOLD}NixClient (Users):${RESET} http://${SERVER_IP}:2083"
echo -e " ${BOLD}Licensing server:${RESET} ${LICENSING_URL} (central)" 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 ""
echo -e " ${BOLD}Logs:${RESET} ${LOG_DIR}/" echo -e " ${BOLD}Logs:${RESET} ${LOG_DIR}/"
echo -e " ${BOLD}Config:${RESET} ${INSTALL_DIR}/.env" 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: {},
},
}
+509
View File
@@ -0,0 +1,509 @@
import { useState } from 'react'
/* ── 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 (cPanel icon grid style) ────────────────────────────── */
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; badge: string }> = {
blue: { tile: 'hover:border-blue-500/40 hover:bg-blue-500/5', icon: 'bg-blue-500/15 text-blue-400', badge: 'bg-blue-500/20 text-blue-400 border-blue-500/30' },
emerald: { tile: 'hover:border-emerald-500/40 hover:bg-emerald-500/5', icon: 'bg-emerald-500/15 text-emerald-400', badge: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' },
purple: { tile: 'hover:border-purple-500/40 hover:bg-purple-500/5', icon: 'bg-purple-500/15 text-purple-400', badge: 'bg-purple-500/20 text-purple-400 border-purple-500/30' },
orange: { tile: 'hover:border-orange-500/40 hover:bg-orange-500/5', icon: 'bg-orange-500/15 text-orange-400', badge: 'bg-orange-500/20 text-orange-400 border-orange-500/30' },
red: { tile: 'hover:border-red-500/40 hover:bg-red-500/5', icon: 'bg-red-500/15 text-red-400', badge: 'bg-red-500/20 text-red-400 border-red-500/30' },
cyan: { tile: 'hover:border-cyan-500/40 hover:bg-cyan-500/5', icon: 'bg-cyan-500/15 text-cyan-400', badge: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30' },
yellow: { tile: 'hover:border-yellow-500/40 hover:bg-yellow-500/5', icon: 'bg-yellow-500/15 text-yellow-400', badge: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' },
}
/* ── 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 info sidebar ───────────────────────────────────────────────── */
function AccountSidebar({ domain }: { domain: string }) {
return (
<aside className="w-64 flex-shrink-0 space-y-4">
{/* Account card */}
<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">Hosting Account</p>
</div>
<div className="px-4 py-3 space-y-3">
<UsageBar label="Disk Space" used={0} total={0} unit="MB" color="bg-blue-500" />
<UsageBar label="Bandwidth" used={0} total={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>
{/* Quick stats */}
<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: 'PHP Version', value: '8.3' },
{ label: 'Server', value: 'Apache 2.4' },
{ label: 'IP Address', value: '—' },
{ label: 'Home Dir', value: '/home/user' },
].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">{r.value}</span>
</div>
))}
</div>
{/* Quick links */}
<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', href: '#' },
{ label: '📬 Open Webmail', href: '#' },
{ label: '🖥️ phpMyAdmin', href: '#' },
{ label: '📊 View Statistics', href: '#' },
].map(l => (
<a
key={l.label}
href={l.href}
className="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"
>
{l.label}
</a>
))}
</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, groupLabel: g.label }))
)
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">
{/* Search bar */}
<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>
{/* Search results */}
{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>
) : (
/* Normal grouped view */
<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>
)
}
/* ── 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>
)
}
/* ── Page meta ──────────────────────────────────────────────────────────── */
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: '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() {
const [page, setPage] = useState<Page>('home')
const [loggedIn, setLoggedIn] = useState(true)
if (!loggedIn) return null
const meta = PAGE_META[page]
const domain = 'yourdomain.com'
function renderPage() {
if (page === 'home') return <Home onNav={setPage} />
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">
{/* Logo */}
<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>
{/* Center nav */}
<nav className="hidden md:flex items-center gap-1">
{[
{ id: 'home' as Page, label: 'Home' },
{ id: 'email' 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>
{/* Right: account / logout */}
<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">
{domain.charAt(0).toUpperCase()}
</span>
<span className="text-xs text-gray-300 font-medium max-w-[120px] truncate">{domain}</span>
</div>
<button
onClick={() => setLoggedIn(false)}
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>
{/* Breadcrumb */}
{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>
{/* Body */}
<div className="flex-1 max-w-screen-2xl mx-auto w-full px-6 py-6 flex gap-6">
{/* Sidebar */}
<AccountSidebar domain={domain} />
{/* Main content */}
<main className="flex-1 min-w-0">
{renderPage()}
</main>
</div>
{/* Footer */}
<footer className="border-t border-[#2a3044] px-6 py-3 text-center text-[10px] text-gray-700">
NixPanel Client v0.1.0 · {domain} · Powered by NixServer
</footer>
</div>
)
}
/* ── Login ──────────────────────────────────────────────────────────────── */
export default function App() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [isLoggedIn, setIsLoggedIn] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleLogin = (e: React.FormEvent) => {
e.preventDefault()
if (!username || !password) {
setError('Please enter your username and password.')
return
}
setLoading(true)
setError('')
setTimeout(() => {
setLoading(false)
setIsLoggedIn(true)
}, 600)
}
if (isLoggedIn) return <ClientPanel />
return (
<div className="min-h-screen flex items-center justify-center bg-[#0f1520] relative overflow-hidden">
{/* Background radial */}
<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">
{/* Logo */}
<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>
{/* Card */}
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-2xl shadow-2xl overflow-hidden">
{/* Header band */}
<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.1.0 · Manage your hosting account
</div>
</div>
</div>
</div>
)
}
+70
View File
@@ -0,0 +1,70 @@
/* 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()
}
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
}
export interface AccountInfo {
username: string
domain: string
email: string
package_name: string
disk_quota_mb: number
bandwidth_mb: number
status: string
}
export async function getMyAccount(): Promise<AccountInfo | null> {
try {
// Returns the first account matching the logged-in user's username
const accounts = await apiFetch<AccountInfo[]>('/accounts')
return accounts[0] ?? null
} catch {
return null
}
}
+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,
},
},
},
})
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "nixpanel"
version = "0.1.0"
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 UNSIGNED 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 UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED,
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 UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED,
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 UNSIGNED NOT NULL AUTO_INCREMENT,
domain_id BIGINT UNSIGNED NOT NULL,
type VARCHAR(10) NOT NULL, -- A, AAAA, CNAME, MX, TXT, NS, SRV
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 UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED 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 UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED 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 UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED 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 UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED,
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 UNSIGNED NOT NULL AUTO_INCREMENT,
account_id BIGINT UNSIGNED,
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
+74
View File
@@ -0,0 +1,74 @@
use axum::{
async_trait,
extract::FromRequestParts,
http::{request::Parts, HeaderMap},
RequestPartsExt,
};
use axum_extra::TypedHeader;
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, // unix timestamp
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(),
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)?;
Ok(token)
}
pub fn verify(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
let data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::new(Algorithm::HS256),
)?;
Ok(data.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 headers: &HeaderMap = &parts.headers;
let auth = 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
}
}
+160
View File
@@ -0,0 +1,160 @@
mod auth;
mod config;
mod error;
mod routes;
mod section;
mod state;
use std::sync::Arc;
use std::net::SocketAddr;
use axum::{
routing::{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
.route("/api/accounts", get(routes::accounts::list_accounts)
.post(routes::accounts::create_account))
.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(())
}
+114
View File
@@ -0,0 +1,114 @@
use axum::{extract::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: i64,
pub bandwidth_mb: i64,
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<i64>,
pub bandwidth_mb: Option<i64>,
}
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,
};
// Validate
if body.username.is_empty() || body.domain.is_empty() {
return Err(AppError::BadRequest("username and domain are required".into()));
}
// Hash password
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(body.password.as_bytes(), &salt)
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?
.to_string();
// Create system user (best-effort — panel may not be running as root in dev)
let _ = std::process::Command::new("useradd")
.args(&[
"-m",
"-s", "/bin/bash",
"-d", &format!("/home/{}", body.username),
&body.username,
])
.output();
// Create Apache vhost directory
let vhost_dir = format!("/home/{}/public_html", body.username);
let _ = std::fs::create_dir_all(&vhost_dir);
let package_name = body.package_name.unwrap_or_else(|| "Default".into());
let disk_quota_mb = body.disk_quota_mb.unwrap_or(10240);
let bandwidth_mb = body.bandwidth_mb.unwrap_or(0);
let id = sqlx::query!(
"INSERT INTO accounts (username, domain, email, password_hash, package_name, disk_quota_mb, bandwidth_mb, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'active')",
body.username, body.domain, body.email, hash,
package_name, disk_quota_mb, bandwidth_mb
)
.execute(&state.db)
.await?
.last_insert_id() as i64;
// Also create a panel user so they can log into NixClient
let _ = sqlx::query!(
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'user')
ON DUPLICATE KEY UPDATE email = VALUES(email)",
body.username, body.email, hash
)
.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 = ?",
id
)
.fetch_one(&state.db)
.await?;
Ok(Json(account))
}
+58
View File
@@ -0,0 +1,58 @@
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,
}
pub async fn login(
State(state): State<AppState>,
Json(body): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, AppError> {
// Look up user
let row = sqlx::query!(
"SELECT id, username, password_hash, role FROM users WHERE username = ? LIMIT 1",
body.username
)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::Unauthorized)?;
// Verify password
let parsed = PasswordHash::new(&row.password_hash)
.map_err(|_| AppError::Unauthorized)?;
Argon2::default()
.verify_password(body.password.as_bytes(), &parsed)
.map_err(|_| AppError::Unauthorized)?;
// Issue JWT
let token = Claims::new(row.id, &row.username, &row.role, &state.config.jwt_secret)
.map_err(|e| AppError::Internal(e))?;
Ok(Json(LoginResponse {
token,
user: UserInfo {
id: row.id,
username: row.username,
role: row.role,
},
}))
}
+54
View File
@@ -0,0 +1,54 @@
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> {
// DB counts
let accounts = sqlx::query_scalar!("SELECT COUNT(*) FROM accounts")
.fetch_one(&state.db).await?.unwrap_or(0);
let domains = sqlx::query_scalar!("SELECT COUNT(*) FROM domains")
.fetch_one(&state.db).await?.unwrap_or(0);
let databases = sqlx::query_scalar!("SELECT COUNT(*) FROM account_databases")
.fetch_one(&state.db).await?.unwrap_or(0);
let email_accounts = sqlx::query_scalar!("SELECT COUNT(*) FROM email_accounts")
.fetch_one(&state.db).await?.unwrap_or(0);
// Section binaries
let bin_dir = &state.config.bin_dir;
let sysinfo_resp = section::call(bin_dir, "nixpanel-sysinfo", "stats", serde_json::Value::Null)
.unwrap_or_else(|_| nixpanel_common::SectionResponse::err("sysinfo unavailable"));
let versions_resp = section::call(bin_dir, "nixpanel-sysinfo", "versions", serde_json::Value::Null)
.unwrap_or_else(|_| nixpanel_common::SectionResponse::err("versions unavailable"));
Ok(Json(DashboardResponse {
stats: DashboardStats {
accounts,
domains,
databases,
email_accounts,
},
sysinfo: sysinfo_resp.data.unwrap_or(serde_json::Value::Null),
versions: versions_resp.data.unwrap_or(serde_json::Value::Null),
}))
}
+4
View File
@@ -0,0 +1,4 @@
pub mod auth;
pub mod accounts;
pub mod dashboard;
pub mod services;
+50
View File
@@ -0,0 +1,50 @@
use axum::{
extract::{Path, State},
Json,
};
use serde::{Deserialize, Serialize};
use crate::{auth::Claims, error::AppError, section, state::AppState};
pub async fn list_services(
claims: Claims,
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_actions = ["start", "stop", "restart", "reload"];
if !allowed_actions.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.1.0"
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.1.0"
edition = "2021"
[[bin]]
name = "nixpanel-services"
path = "src/main.rs"
[dependencies]
nixpanel-common = { path = "../nixpanel-common" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+157
View File
@@ -0,0 +1,157 @@
//! 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;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// 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.1.0"
edition = "2021"
[[bin]]
name = "nixpanel-sysinfo"
path = "src/main.rs"
[dependencies]
nixpanel-common = { path = "../nixpanel-common" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+247
View File
@@ -0,0 +1,247 @@
//! 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 /
use std::os::unix::fs::MetadataExt;
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: {},
},
}
+754
View File
@@ -0,0 +1,754 @@
import { useState, useEffect, useCallback } from 'react'
import * as api from './api'
import type { DashboardData, ServiceInfo, Account } from './api'
/* ── Types ──────────────────────────────────────────────────────────────── */
type Page =
| 'dashboard' | 'accounts' | 'createAccount' | 'listAccounts'
| 'domains' | 'dns' | 'databases' | 'email'
| 'ssl' | 'backups' | 'firewall'
| 'apache' | 'php' | 'services'
| 'settings' | 'logs' | 'packages'
/* ── Nav ─────────────────────────────────────────────────────────────────── */
type NavGroup = { label: string; items: { id: Page; label: string; icon: string }[] }
const NAV_GROUPS: NavGroup[] = [
{ label: 'Overview', items: [{ id: 'dashboard', label: 'Dashboard', icon: '▦' }] },
{ label: 'Account Management',items: [
{ id: 'listAccounts', label: 'List Accounts', icon: '📋' },
{ id: 'createAccount',label: 'Create Account', icon: '➕' },
{ id: 'packages', label: 'Feature Packages', icon: '📦' },
]},
{ label: 'Domains & DNS', items: [
{ id: 'domains', label: 'Zone Manager', icon: '🌐' },
{ id: 'dns', label: 'DNS Records', icon: '🔗' },
]},
{ label: 'Databases', items: [{ id: 'databases', label: 'MySQL / MariaDB', icon: '🗄️' }] },
{ label: 'Email', items: [{ id: 'email', label: 'Email Routing', icon: '✉️' }] },
{ label: 'Security', items: [
{ id: 'ssl', label: 'SSL / TLS', icon: '🔒' },
{ id: 'firewall', label: 'Firewall (CSF)', icon: '🛡️' },
]},
{ label: 'Server', items: [
{ id: 'apache', label: 'Apache Config', icon: '⚙️' },
{ id: 'php', label: 'PHP Manager', icon: '🐘' },
{ id: 'services', label: 'Service Manager', icon: '🔧' },
{ id: 'backups', label: 'Backup Manager', icon: '💾' },
{ id: 'logs', label: 'System Logs', icon: '📜' },
{ id: 'settings', label: 'Server Config', icon: '🔩' },
]},
]
/* ── Sidebar ────────────────────────────────────────────────────────────── */
function Sidebar({ current, onNav, onLogout, collapsed, onToggle, username }: {
current: Page; onNav: (p: Page) => void; onLogout: () => void
collapsed: boolean; onToggle: () => void; username: string
}) {
return (
<aside className={`flex-shrink-0 bg-[#1a1f2e] border-r border-[#2a3044] flex flex-col min-h-screen transition-all duration-200 ${collapsed ? 'w-14' : 'w-56'}`}>
<div className="h-14 flex items-center justify-between px-3 border-b border-[#2a3044]">
{!collapsed && (
<span className="text-base font-black tracking-tight text-white">
Nix<span className="text-orange-400">Server</span>
<span className="ml-1.5 text-[9px] font-bold bg-orange-500 text-white rounded px-1 py-0.5 uppercase tracking-wide align-middle">WHM</span>
</span>
)}
<button onClick={onToggle} className="text-gray-500 hover:text-white p-1 rounded transition-colors ml-auto" title={collapsed ? 'Expand' : 'Collapse'}>
{collapsed ? '▶' : '◀'}
</button>
</div>
<nav className="flex-1 overflow-y-auto py-2">
{NAV_GROUPS.map(group => (
<div key={group.label} className="mb-1">
{!collapsed && (
<p className="px-3 pt-3 pb-1 text-[9px] font-bold text-gray-600 uppercase tracking-widest">{group.label}</p>
)}
{group.items.map(item => (
<button
key={item.id}
onClick={() => onNav(item.id)}
title={collapsed ? item.label : undefined}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-all ${
current === item.id
? 'bg-orange-500/20 text-orange-400 border-r-2 border-orange-400'
: 'text-gray-400 hover:bg-[#252b3d] hover:text-white'
}`}
>
<span className="text-sm w-4 text-center flex-shrink-0">{item.icon}</span>
{!collapsed && <span className="truncate">{item.label}</span>}
</button>
))}
</div>
))}
</nav>
<div className="border-t border-[#2a3044] p-2">
{!collapsed && (
<div className="flex items-center gap-2 px-1 py-1.5 mb-1">
<div className="w-7 h-7 rounded-full bg-orange-500 flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{username.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-xs font-semibold text-white truncate">{username}</p>
<p className="text-[10px] text-gray-500">root</p>
</div>
</div>
)}
<button onClick={onLogout} title="Sign out"
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-gray-500 hover:bg-[#252b3d] hover:text-red-400 transition-colors ${collapsed ? 'justify-center' : ''}`}
>
<span>⏻</span>
{!collapsed && 'Sign out'}
</button>
</div>
</aside>
)
}
/* ── Stat bar ────────────────────────────────────────────────────────────── */
function StatBar({ data }: { data: DashboardData | null }) {
const s = data?.stats
const si = data?.sysinfo
const ramPct = si && si.ram_total_mb > 0
? Math.round((si.ram_used_mb / si.ram_total_mb) * 100)
: null
const diskPct = si && si.disk_total_gb > 0
? Math.round((si.disk_used_gb / si.disk_total_gb) * 100)
: null
function barColor(pct: number) {
return pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-emerald-500'
}
const stats = [
{ label: 'Hosting Accounts', value: s?.accounts ?? '—', unit: '', color: 'text-orange-400', bar: null },
{ label: 'Domains', value: s?.domains ?? '—', unit: '', color: 'text-blue-400', bar: null },
{ label: 'Databases', value: s?.databases ?? '—', unit: '', color: 'text-purple-400', bar: null },
{ label: 'Email Accounts', value: s?.email_accounts ?? '—', unit: '', color: 'text-cyan-400', bar: null },
{ label: 'Disk Used', value: si ? `${si.disk_used_gb}` : '—', unit: 'GB', color: diskPct && diskPct > 80 ? 'text-red-400' : 'text-emerald-400', bar: diskPct },
{ label: 'RAM Used', value: si ? `${Math.round(si.ram_used_mb / 1024 * 10) / 10}` : '—', unit: 'GB', color: ramPct && ramPct > 80 ? 'text-red-400' : 'text-yellow-400', bar: ramPct },
]
return (
<div className="grid grid-cols-3 lg:grid-cols-6 gap-0 border border-[#2a3044] rounded-lg overflow-hidden">
{stats.map((s, i) => (
<div key={s.label} className={`bg-[#1a1f2e] px-4 py-3 ${i < stats.length - 1 ? 'border-r border-[#2a3044]' : ''}`}>
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">{s.label}</p>
<p className={`text-xl font-black mt-0.5 ${s.color}`}>
{s.value}<span className="text-xs font-normal text-gray-500 ml-1">{s.unit}</span>
</p>
{s.bar !== null && (
<div className="mt-1.5 h-1 bg-[#2a3044] rounded-full overflow-hidden">
<div className={`h-full rounded-full ${barColor(s.bar)}`} style={{ width: `${s.bar}%` }} />
</div>
)}
</div>
))}
</div>
)
}
/* ── Service table ───────────────────────────────────────────────────────── */
function ServiceTable({ services, loading, onAction }: {
services: ServiceInfo[]
loading: boolean
onAction: (name: string, action: 'start' | 'stop' | 'restart') => void
}) {
const badge: Record<string, string> = {
running: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30',
stopped: 'bg-red-500/20 text-red-400 border border-red-500/30',
warning: 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30',
}
const running = services.filter(s => s.status === 'running').length
return (
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-[#2a3044] flex items-center justify-between">
<h3 className="text-sm font-bold text-white">Service Manager</h3>
{loading
? <span className="text-xs text-gray-600">Loading…</span>
: <span className="text-xs text-gray-500">{running} / {services.length} running</span>
}
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#2a3044]">
{['Service', 'Status', 'PID', 'Uptime', 'Actions'].map(h => (
<th key={h} className="text-left px-4 py-2 text-[10px] font-bold text-gray-500 uppercase tracking-wider hidden-last-two md:table-cell first:table-cell">{h}</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-600 text-sm">
<span className="inline-block w-4 h-4 border-2 border-gray-700 border-t-orange-500 rounded-full animate-spin mr-2" />
Loading services…
</td>
</tr>
) : services.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-600 text-sm">No service data available</td>
</tr>
) : services.map(svc => (
<tr key={svc.name} className="border-b border-[#252b3d] hover:bg-[#252b3d]/50 transition-colors">
<td className="px-4 py-2.5 text-gray-300 font-medium">{svc.display}</td>
<td className="px-4 py-2.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-semibold ${badge[svc.status] ?? badge.warning}`}>
<span className={`w-1.5 h-1.5 rounded-full ${svc.status === 'running' ? 'bg-emerald-400 animate-pulse' : 'bg-red-400'}`} />
{svc.status.charAt(0).toUpperCase() + svc.status.slice(1)}
</span>
</td>
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">{svc.pid ?? '—'}</td>
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">{svc.uptime ?? '—'}</td>
<td className="px-4 py-2.5">
<div className="flex gap-1.5">
<button onClick={() => onAction(svc.name, 'start')} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-emerald-500/20 hover:text-emerald-400 text-gray-400 transition-colors">Start</button>
<button onClick={() => onAction(svc.name, 'stop')} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-red-500/20 hover:text-red-400 text-gray-400 transition-colors">Stop</button>
<button onClick={() => onAction(svc.name, 'restart')} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-orange-500/20 hover:text-orange-400 text-gray-400 transition-colors">Restart</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
/* ── Quick action grid ───────────────────────────────────────────────────── */
function QuickActions({ onNav }: { onNav: (p: Page) => void }) {
const actions = [
{ label: 'Create Account', icon: '👤', page: 'createAccount' as Page, desc: 'Add hosting account' },
{ label: 'Zone Manager', icon: '🌐', page: 'domains' as Page, desc: 'Manage DNS zones' },
{ label: 'New Database', icon: '🗄️', page: 'databases' as Page, desc: 'Create MariaDB DB' },
{ label: 'Issue SSL', icon: '🔒', page: 'ssl' as Page, desc: "Let's Encrypt cert" },
{ label: 'Run Backup', icon: '💾', page: 'backups' as Page, desc: 'Full server backup' },
{ label: 'PHP Manager', icon: '🐘', page: 'php' as Page, desc: 'Switch PHP version' },
{ label: 'Firewall', icon: '🛡️', page: 'firewall' as Page, desc: 'Edit CSF rules' },
{ label: 'System Logs', icon: '📜', page: 'logs' as Page, desc: 'Apache, error logs' },
]
return (
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-[#2a3044]">
<h3 className="text-sm font-bold text-white">Quick Actions</h3>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-0">
{actions.map((a, i) => (
<button key={a.label} onClick={() => onNav(a.page)}
className={`flex flex-col items-center text-center p-4 hover:bg-orange-500/10 transition-all group ${i % 4 !== 3 ? 'border-r border-[#2a3044]' : ''} ${i < actions.length - 4 ? 'border-b border-[#2a3044]' : ''}`}
>
<span className="text-2xl mb-2 group-hover:scale-110 transition-transform">{a.icon}</span>
<span className="text-xs font-semibold text-gray-300 group-hover:text-white leading-tight">{a.label}</span>
<span className="text-[10px] text-gray-600 group-hover:text-gray-400 mt-0.5 leading-tight">{a.desc}</span>
</button>
))}
</div>
</div>
)
}
/* ── Server info bar ─────────────────────────────────────────────────────── */
function ServerInfoBar({ data }: { data: DashboardData | null }) {
const si = data?.sysinfo
const v = data?.versions
const info = [
{ label: 'Hostname', value: si?.hostname ?? '—' },
{ label: 'OS', value: si?.os ?? '—' },
{ label: 'Kernel', value: si?.kernel ?? '—' },
{ label: 'PHP', value: v?.php ?? '—' },
{ label: 'MariaDB', value: v?.mariadb ?? '—' },
{ label: 'Apache', value: v?.apache ?? '—' },
{ label: 'NixPanel', value: v?.nixpanel ?? '—' },
]
return (
<div className="bg-[#0f1520] border border-[#2a3044] rounded-lg px-4 py-3 flex flex-wrap gap-x-6 gap-y-2">
{info.map(i => (
<div key={i.label} className="flex items-center gap-2">
<span className="text-[10px] font-bold text-gray-600 uppercase tracking-wider">{i.label}:</span>
<span className="text-xs text-gray-300 font-medium">{i.value}</span>
</div>
))}
{si && (
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-gray-600 uppercase tracking-wider">Load:</span>
<span className="text-xs text-gray-300 font-medium">{si.load_avg[0].toFixed(2)}</span>
</div>
)}
</div>
)
}
/* ── Dashboard page ──────────────────────────────────────────────────────── */
function Dashboard({ onNav }: { onNav: (p: Page) => void }) {
const [dashData, setDashData] = useState<DashboardData | null>(null)
const [services, setServices] = useState<ServiceInfo[]>([])
const [svcLoading, setSvcLoading] = useState(true)
const [actionMsg, setActionMsg] = useState('')
useEffect(() => {
api.getDashboard().then(setDashData).catch(console.error)
}, [])
useEffect(() => {
api.getServices().then(s => { setServices(s); setSvcLoading(false) }).catch(() => setSvcLoading(false))
}, [])
const handleServiceAction = useCallback(async (name: string, action: 'start' | 'stop' | 'restart') => {
setActionMsg(`${action}ing ${name}…`)
try {
await api.serviceAction(name, action)
// Refresh services
const updated = await api.getServices()
setServices(updated)
setActionMsg(`${name} ${action}ed`)
} catch (e: any) {
setActionMsg(`Error: ${e.message}`)
}
setTimeout(() => setActionMsg(''), 3000)
}, [])
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-black text-white">Server Overview</h1>
<p className="text-xs text-gray-500 mt-0.5">
{dashData?.sysinfo
? `${dashData.sysinfo.hostname} · ${dashData.sysinfo.ip}`
: 'Welcome back, admin — NixServer WHM'}
</p>
</div>
<div className="flex items-center gap-3">
{actionMsg && (
<span className="text-xs text-orange-400 bg-orange-500/10 border border-orange-500/20 px-3 py-1.5 rounded-full">
{actionMsg}
</span>
)}
<span className="flex items-center gap-1.5 text-xs text-emerald-400 font-semibold bg-emerald-500/10 border border-emerald-500/20 px-3 py-1.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
All systems operational
</span>
</div>
</div>
<StatBar data={dashData} />
<ServerInfoBar data={dashData} />
<div className="grid grid-cols-1 xl:grid-cols-2 gap-5">
<ServiceTable services={services} loading={svcLoading} onAction={handleServiceAction} />
<QuickActions onNav={onNav} />
</div>
</div>
)
}
/* ── List accounts ───────────────────────────────────────────────────────── */
function ListAccounts({ onNav }: { onNav: (p: Page) => void }) {
const [accounts, setAccounts] = useState<Account[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
useEffect(() => {
api.getAccounts()
.then(setAccounts)
.catch(console.error)
.finally(() => setLoading(false))
}, [])
const filtered = accounts.filter(a =>
a.username.includes(search) || a.domain.includes(search)
)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-black text-white">Hosting Accounts</h1>
<button onClick={() => onNav('createAccount')} className="px-3 py-1.5 bg-orange-500 hover:bg-orange-600 text-white text-sm font-semibold rounded transition-colors">
+ Create Account
</button>
</div>
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg p-3 flex gap-3">
<input type="text" value={search} onChange={e => setSearch(e.target.value)}
placeholder="Search accounts, domains…"
className="flex-1 bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-orange-500/50"
/>
<select className="bg-[#0f1520] border border-[#2a3044] text-gray-300 rounded px-3 py-2 text-sm focus:outline-none">
<option>All Status</option><option>Active</option><option>Suspended</option>
</select>
</div>
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#2a3044] bg-[#0f1520]">
{['Username', 'Domain', 'Package', 'Disk Quota', 'Status', '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>
{loading ? (
<tr><td colSpan={7} className="px-4 py-12 text-center text-gray-600 text-sm">
<span className="inline-block w-4 h-4 border-2 border-gray-700 border-t-orange-500 rounded-full animate-spin mr-2" />Loading…
</td></tr>
) : filtered.length === 0 ? (
<tr><td colSpan={7} className="px-4 py-16 text-center">
<div className="text-3xl mb-3">👤</div>
<p className="text-gray-500 text-sm">
{search ? 'No accounts match your search.' : <>No accounts yet. <button onClick={() => onNav('createAccount')} className="text-orange-400 hover:underline">Create your first account →</button></>}
</p>
</td></tr>
) : filtered.map(acc => (
<tr key={acc.id} className="border-b border-[#252b3d] hover:bg-[#252b3d]/50 transition-colors">
<td className="px-4 py-2.5 text-gray-300 font-medium">{acc.username}</td>
<td className="px-4 py-2.5 text-gray-400">{acc.domain}</td>
<td className="px-4 py-2.5 text-gray-500 text-xs">{acc.package_name}</td>
<td className="px-4 py-2.5 text-gray-500 text-xs">{acc.disk_quota_mb === 0 ? '∞' : `${acc.disk_quota_mb} MB`}</td>
<td className="px-4 py-2.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-semibold ${
acc.status === 'active'
? 'bg-emerald-500/20 text-emerald-400'
: 'bg-red-500/20 text-red-400'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${acc.status === 'active' ? 'bg-emerald-400' : 'bg-red-400'}`} />
{acc.status.charAt(0).toUpperCase() + acc.status.slice(1)}
</span>
</td>
<td className="px-4 py-2.5 text-gray-600 text-xs">{new Date(acc.created_at).toLocaleDateString()}</td>
<td className="px-4 py-2.5">
<div className="flex gap-1.5">
<button className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:text-orange-400 text-gray-400 transition-colors">Edit</button>
<button className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:text-red-400 text-gray-400 transition-colors">Suspend</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
/* ── Create account form ─────────────────────────────────────────────────── */
function CreateAccount({ onNav }: { onNav: (p: Page) => void }) {
const [form, setForm] = useState({
username: '', domain: '', email: '', password: '',
package_name: 'Default', disk_quota_mb: 10240, bandwidth_mb: 0,
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const set = (k: string, v: string | number) => setForm(f => ({ ...f, [k]: v }))
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true); setError(''); setSuccess('')
try {
const acc = await api.createAccount({
...form,
disk_quota_mb: Number(form.disk_quota_mb),
bandwidth_mb: Number(form.bandwidth_mb),
})
setSuccess(`Account "${acc.username}" created for ${acc.domain}`)
setTimeout(() => onNav('listAccounts'), 1500)
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
}
const Field = ({ label, name, type = 'text', placeholder = '' }: { label: string; name: string; type?: string; placeholder?: string }) => (
<div>
<label className="block text-xs font-semibold text-gray-400 mb-1.5">{label}</label>
<input type={type} value={String((form as any)[name])} onChange={e => set(name, e.target.value)}
placeholder={placeholder}
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-orange-500/60"
/>
</div>
)
return (
<div className="max-w-2xl space-y-5">
<h1 className="text-lg font-black text-white">Create Hosting Account</h1>
{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">
<h2 className="text-xs font-bold text-orange-400 uppercase tracking-widest">Domain Information</h2>
<Field label="Domain Name" name="domain" placeholder="example.com" />
</div>
<div className="p-5 space-y-4">
<h2 className="text-xs font-bold text-orange-400 uppercase tracking-widest">Account Credentials</h2>
<div className="grid grid-cols-2 gap-4">
<Field label="Username" name="username" placeholder="username" />
<Field label="Password" name="password" type="password" placeholder="••••••••" />
<div className="col-span-2">
<Field label="Email Address" name="email" type="email" placeholder="[email protected]" />
</div>
</div>
</div>
<div className="p-5 space-y-4">
<h2 className="text-xs font-bold text-orange-400 uppercase tracking-widest">Resource Limits</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Package</label>
<select value={form.package_name} onChange={e => set('package_name', e.target.value)}
className="w-full bg-[#0f1520] border border-[#2a3044] text-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60">
<option>Default</option>
<option>Starter</option>
<option>Professional</option>
<option>Enterprise</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Disk Quota (MB)</label>
<input type="number" value={form.disk_quota_mb} onChange={e => set('disk_quota_mb', 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-orange-500/60"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Bandwidth (MB/mo, 0=∞)</label>
<input type="number" value={form.bandwidth_mb} onChange={e => set('bandwidth_mb', 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-orange-500/60"
/>
</div>
</div>
</div>
<div className="p-5 flex gap-3">
<button type="submit" disabled={loading}
className="px-5 py-2 bg-orange-500 hover:bg-orange-600 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 ? 'Creating…' : 'Create Account'}
</button>
<button type="button" onClick={() => onNav('listAccounts')}
className="px-5 py-2 bg-[#0f1520] border border-[#2a3044] hover:border-gray-500 text-gray-400 hover:text-white text-sm rounded transition-colors">
Cancel
</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-orange-500/10 border border-orange-500/20 rounded text-xs text-orange-400 font-semibold">Coming Soon</div>
</div>
)
}
const PAGE_META: Record<Page, { title: string; icon: string }> = {
dashboard: { title: 'Server Dashboard', icon: '▦' },
accounts: { title: 'Accounts', icon: '👤' },
listAccounts: { title: 'List Accounts', icon: '📋' },
createAccount: { title: 'Create Account', icon: '➕' },
packages: { title: 'Feature Packages', icon: '📦' },
domains: { title: 'Zone Manager', icon: '🌐' },
dns: { title: 'DNS Records', icon: '🔗' },
databases: { title: 'MySQL / MariaDB', icon: '🗄️' },
email: { title: 'Email Routing', icon: '✉️' },
ssl: { title: 'SSL / TLS Manager', icon: '🔒' },
backups: { title: 'Backup Manager', icon: '💾' },
firewall: { title: 'Firewall (CSF)', icon: '🛡️' },
apache: { title: 'Apache Config', icon: '⚙️' },
php: { title: 'PHP Manager', icon: '🐘' },
services: { title: 'Service Manager', icon: '🔧' },
settings: { title: 'Server Config', icon: '🔩' },
logs: { title: 'System Logs', icon: '📜' },
}
/* ── Admin panel shell ───────────────────────────────────────────────────── */
function AdminPanel({ username, onLogout }: { username: string; onLogout: () => void }) {
const [page, setPage] = useState<Page>('dashboard')
const [collapsed, setCollapsed] = useState(false)
const meta = PAGE_META[page]
function renderPage() {
switch (page) {
case 'dashboard': return <Dashboard onNav={setPage} />
case 'listAccounts': return <ListAccounts onNav={setPage} />
case 'createAccount': return <CreateAccount onNav={setPage} />
default: return <Placeholder title={meta.title} icon={meta.icon} />
}
}
return (
<div className="flex min-h-screen bg-[#0f1520] text-white">
<Sidebar current={page} onNav={setPage} onLogout={onLogout} collapsed={collapsed} onToggle={() => setCollapsed(c => !c)} username={username} />
<div className="flex-1 flex flex-col min-w-0">
<header className="h-14 border-b border-[#2a3044] bg-[#1a1f2e] flex items-center px-6 justify-between gap-4 flex-shrink-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-gray-600 text-sm">{meta.icon}</span>
<h2 className="font-semibold text-gray-200 text-sm truncate">{meta.title}</h2>
</div>
<div className="flex items-center gap-3">
<div className="hidden lg:flex items-center gap-2">
<span className="text-xs bg-[#0f1520] border border-[#2a3044] text-gray-500 px-2 py-1 rounded">Ubuntu 24.04</span>
</div>
<div className="flex items-center gap-2 pl-3 border-l border-[#2a3044]">
<div className="w-7 h-7 rounded-full bg-orange-500 flex items-center justify-center text-white text-xs font-bold">{username.charAt(0).toUpperCase()}</div>
<span className="text-sm font-semibold text-gray-300 hidden sm:block">{username}</span>
</div>
</div>
</header>
{page !== 'dashboard' && (
<div className="px-6 py-2 border-b border-[#2a3044] bg-[#0f1520] flex items-center gap-1.5 text-xs text-gray-600">
<button onClick={() => setPage('dashboard')} className="hover:text-orange-400 transition-colors">Dashboard</button>
<span>/</span>
<span className="text-gray-400">{meta.title}</span>
</div>
)}
<main className="flex-1 p-6 overflow-auto">{renderPage()}</main>
<footer className="border-t border-[#2a3044] px-6 py-2 flex items-center justify-between text-[10px] text-gray-700">
<span>NixPanel WHM v0.1.0 · Ubuntu 24.04 LTS</span>
<span>© 2025 NixPanel</span>
</footer>
</div>
</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)
// Restore session from localStorage
useEffect(() => {
if (api.isLoggedIn()) {
const stored = localStorage.getItem('nixpanel_username')
setLoggedInAs(stored ?? 'admin')
}
}, [])
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true); setError('')
try {
const result = await api.login(username, password)
localStorage.setItem('nixpanel_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('nixpanel_username')
setLoggedInAs(null)
setUsername(''); setPassword('')
}
if (loggedInAs) return <AdminPanel username={loggedInAs} onLogout={handleLogout} />
return (
<div className="min-h-screen flex bg-[#0f1520]">
{/* Left branding panel */}
<div className="hidden lg:flex flex-col justify-between w-80 bg-[#1a1f2e] border-r border-[#2a3044] p-10">
<div>
<div className="flex items-center gap-3 mb-10">
<div className="w-10 h-10 rounded-lg bg-orange-500 flex items-center justify-center text-white text-xl font-black shadow-lg shadow-orange-900/40">N</div>
<div>
<span className="text-xl font-black text-white">Nix<span className="text-orange-400">Server</span></span>
<div className="text-[10px] font-bold text-gray-600 uppercase tracking-wider">WHM Control Panel</div>
</div>
</div>
<div className="space-y-5">
{[
{ icon: '🌐', title: 'Full DNS Control', desc: 'PowerDNS-backed zone management' },
{ icon: '🔒', title: 'Auto SSL/TLS', desc: "Let's Encrypt for all domains" },
{ icon: '🛡️', title: 'Built-in Firewall', desc: 'CSF/Fail2ban integration' },
{ icon: '💾', title: 'Automated Backups', desc: 'Scheduled full & incremental' },
{ icon: '🐘', title: 'Multi-PHP Support', desc: 'PHP 8.0 – 8.3 per account' },
].map(f => (
<div key={f.title} className="flex items-start gap-3">
<span className="text-lg mt-0.5">{f.icon}</span>
<div>
<p className="text-sm font-semibold text-gray-200">{f.title}</p>
<p className="text-xs text-gray-600">{f.desc}</p>
</div>
</div>
))}
</div>
</div>
<p className="text-[10px] text-gray-700">NixPanel WHM v0.1.0 · Secured by Fail2ban</p>
</div>
{/* Login form */}
<div className="flex-1 flex items-center justify-center p-6">
<div className="w-full max-w-sm">
<div className="lg:hidden text-center mb-8">
<span className="text-3xl font-black text-white">Nix<span className="text-orange-400">Server</span></span>
<p className="text-xs text-gray-600 mt-1">WHM Control Panel</p>
</div>
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-xl shadow-2xl overflow-hidden">
<div className="bg-orange-500 px-6 py-4">
<h1 className="text-white font-bold text-base">Administrator Login</h1>
<p className="text-orange-200 text-xs mt-0.5">NixServer WHM · Root Access</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-400 uppercase tracking-wider mb-1.5">Username</label>
<input type="text" value={username} onChange={e => setUsername(e.target.value)} required autoFocus autoComplete="username" placeholder="admin"
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-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-all"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 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-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-all"
/>
</div>
<button type="submit" disabled={loading}
className="w-full py-2.5 rounded-lg bg-orange-500 hover:bg-orange-600 active:bg-orange-700 disabled:opacity-60 text-white font-bold text-sm transition-all shadow-lg shadow-orange-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" />Authenticating…</>
) : 'Sign in to WHM'}
</button>
</form>
<div className="border-t border-[#2a3044] px-6 py-3 bg-[#0f1520] flex items-center justify-between text-[10px] text-gray-700">
<span>Secured by Fail2ban</span>
<span>NixPanel v0.1</span>
</div>
</div>
</div>
</div>
</div>
)
}
+137
View File
@@ -0,0 +1,137 @@
/* 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 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) })
}
+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,
},
},
},
})