Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5247c77132 | ||
|
|
a257b5571c | ||
|
|
0f28e879f0 | ||
|
|
4428a49876 | ||
|
|
7603fa5793 | ||
|
|
fb975cdc4e | ||
|
|
2735b9c8f4 | ||
|
|
5881f06cbc | ||
|
|
5cefe15f6a | ||
|
|
e58769a4bd | ||
|
|
4994d33fd5 | ||
|
|
f063af70d8 | ||
|
|
776d328ace | ||
|
|
01e64d5c61 |
@@ -0,0 +1,72 @@
|
|||||||
|
name: "Security Audit"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["master", "main"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["master", "main"]
|
||||||
|
schedule:
|
||||||
|
# Run every Monday at 08:00 UTC to catch newly disclosed CVEs
|
||||||
|
- cron: "0 8 * * 1"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── Rust dependency audit (cargo-audit) ──────────────────────────────────────
|
||||||
|
# Skipped automatically when no Cargo.toml is present at repo root.
|
||||||
|
rust-audit:
|
||||||
|
name: Rust Security Audit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Check for Cargo.toml
|
||||||
|
id: has_cargo
|
||||||
|
run: |
|
||||||
|
if [ -f Cargo.toml ]; then
|
||||||
|
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No Cargo.toml found — skipping Rust audit"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
if: steps.has_cargo.outputs.present == 'true'
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Cache cargo registry
|
||||||
|
if: steps.has_cargo.outputs.present == 'true'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
key: ${{ runner.os }}-cargo-audit-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Install and run cargo-audit
|
||||||
|
if: steps.has_cargo.outputs.present == 'true'
|
||||||
|
run: |
|
||||||
|
cargo install cargo-audit --locked
|
||||||
|
cargo audit --deny warnings
|
||||||
|
|
||||||
|
# ── Shell script security lint (ShellCheck) ───────────────────────────────────
|
||||||
|
# Fails only on errors (SC severity ≥ error). Warnings are reported but
|
||||||
|
# do not block the build — keeping CI signal meaningful.
|
||||||
|
shellcheck:
|
||||||
|
name: ShellCheck
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Run ShellCheck
|
||||||
|
uses: ludeeus/[email protected]
|
||||||
|
with:
|
||||||
|
severity: error
|
||||||
|
scandir: "."
|
||||||
|
ignore_paths: ".git"
|
||||||
+6
-17
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>NixClient — Your Hosting Account</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-950">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "nixpanel-nixclient",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"postcss": "^8.4.0",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: rgb(3, 7, 30);
|
||||||
|
color: rgb(229, 231, 235);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: false,
|
||||||
|
minify: 'esbuild',
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3001,
|
||||||
|
strictPort: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:4000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,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"
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
use std::env;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Config {
|
||||||
|
pub port: u16,
|
||||||
|
pub jwt_secret: String,
|
||||||
|
pub database_url: String,
|
||||||
|
pub install_dir: String,
|
||||||
|
pub bin_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn from_env() -> anyhow::Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
port: env::var("PORT").unwrap_or_else(|_| "4000".into()).parse()?,
|
||||||
|
jwt_secret: env::var("JWT_SECRET").unwrap_or_else(|_| "change-me-in-production".into()),
|
||||||
|
database_url: env::var("DATABASE_URL")?,
|
||||||
|
install_dir: env::var("INSTALL_DIR").unwrap_or_else(|_| "/opt/nixpanel".into()),
|
||||||
|
bin_dir: env::var("INSTALL_DIR")
|
||||||
|
.map(|d| format!("{}/bin", d))
|
||||||
|
.unwrap_or_else(|_| "/opt/nixpanel/bin".into()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("Unauthorized")]
|
||||||
|
Unauthorized,
|
||||||
|
|
||||||
|
#[error("Not found")]
|
||||||
|
NotFound,
|
||||||
|
|
||||||
|
#[error("Bad request: {0}")]
|
||||||
|
BadRequest(String),
|
||||||
|
|
||||||
|
#[error("Internal error: {0}")]
|
||||||
|
Internal(#[from] anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, message) = match &self {
|
||||||
|
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
|
||||||
|
AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
|
||||||
|
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||||
|
AppError::Internal(_) => {
|
||||||
|
tracing::error!("{}", self);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".into())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(serde_json::json!({ "error": message }))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for AppError {
|
||||||
|
fn from(e: sqlx::Error) -> Self {
|
||||||
|
AppError::Internal(anyhow::anyhow!(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<jsonwebtoken::errors::Error> for AppError {
|
||||||
|
fn from(e: jsonwebtoken::errors::Error) -> Self {
|
||||||
|
AppError::Unauthorized
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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(())
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod accounts;
|
||||||
|
pub mod dashboard;
|
||||||
|
pub mod services;
|
||||||
@@ -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())))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
//! Helpers for calling section binaries via stdin/stdout JSON.
|
||||||
|
|
||||||
|
use nixpanel_common::{SectionRequest, SectionResponse};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
/// Call a section binary and return its parsed response.
|
||||||
|
/// `bin_dir` is e.g. "/opt/nixpanel/bin".
|
||||||
|
pub fn call(bin_dir: &str, binary: &str, action: &str, params: serde_json::Value)
|
||||||
|
-> anyhow::Result<SectionResponse>
|
||||||
|
{
|
||||||
|
let bin_path = format!("{}/{}", bin_dir, binary);
|
||||||
|
let req = SectionRequest { action: action.to_string(), params };
|
||||||
|
let req_json = serde_json::to_string(&req)?;
|
||||||
|
|
||||||
|
let mut child = Command::new(&bin_path)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| anyhow::anyhow!("Failed to start {}: {}", bin_path, e))?;
|
||||||
|
|
||||||
|
if let Some(stdin) = child.stdin.take() {
|
||||||
|
let mut stdin = stdin;
|
||||||
|
stdin.write_all(req_json.as_bytes())?;
|
||||||
|
stdin.write_all(b"\n")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = child.wait_with_output()?;
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let resp: SectionResponse = serde_json::from_str(text.trim())
|
||||||
|
.map_err(|e| anyhow::anyhow!("Bad response from {}: {} (raw: {})", binary, e, text.trim()))?;
|
||||||
|
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use crate::config::Config;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: sqlx::MySqlPool,
|
||||||
|
pub config: Arc<Config>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "nixpanel-common"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Every section binary reads one of these from stdin.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SectionRequest {
|
||||||
|
pub action: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub params: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every section binary writes one of these to stdout.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SectionResponse {
|
||||||
|
pub success: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub data: Option<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SectionResponse {
|
||||||
|
pub fn ok<T: Serialize>(data: T) -> Self {
|
||||||
|
Self {
|
||||||
|
success: true,
|
||||||
|
data: Some(serde_json::to_value(data).unwrap_or(serde_json::Value::Null)),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn err(msg: impl ToString) -> Self {
|
||||||
|
Self {
|
||||||
|
success: false,
|
||||||
|
data: None,
|
||||||
|
error: Some(msg.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared service descriptor used by both sysinfo and services sections.
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ServiceInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub display: String,
|
||||||
|
pub status: String, // "running" | "stopped" | "warning"
|
||||||
|
pub pid: Option<u32>,
|
||||||
|
pub uptime: Option<String>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "nixpanel-services"
|
||||||
|
version = "0.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"
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>NixServer — NixPanel Admin</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-950">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "nixpanel-nixserver",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"postcss": "^8.4.0",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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) })
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: rgb(3, 7, 30);
|
||||||
|
color: rgb(229, 231, 235);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: false,
|
||||||
|
minify: 'esbuild',
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
strictPort: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:4000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user