feat: real account provisioner, fix NixClient auth bypass

- nixpanel-accounts: creates Apache vhost, PHP-FPM pool, PowerDNS
  zone (A/MX/SPF records), home dir structure, default index.html
- nixpanel-api: create_account now calls provisioner after DB insert
- NixClient: replace fake setTimeout login with real JWT API call;
  admin accounts blocked with redirect message; logout clears token
- CHANGELOG updated
This commit is contained in:
NixReaper
2026-04-25 21:32:32 -05:00
parent cb6be7c634
commit 389652b2e5
6 changed files with 420 additions and 18 deletions
+19
View File
@@ -8,6 +8,25 @@ Versions follow [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- `nixpanel-accounts` section binary — full account provisioner
- Creates Apache vhost with PHP-FPM proxy
- Creates PHP-FPM pool (per-user socket, open_basedir isolation)
- Creates PowerDNS zone with A, www, mail, ftp, MX, SPF records
- Builds home directory structure: `public_html/`, `logs/`, `tmp/`, `mail/`, `backups/`
- Writes default `index.html` for new domains
- Sets correct ownership and permissions
- `deprovision` action removes vhost, FPM pool, DNS zone
- NixClient real JWT authentication — login now calls `/api/auth/login`
- Session persists across refresh via `localStorage`
- Admin accounts redirected to NixServer (port 2087) instead of being allowed in
### Fixed
- NixClient login accepted any username/password (was using fake `setTimeout` bypass)
- NixClient Sign out button now clears JWT and session correctly
- `CreateAccount` `FormField` component moved to module level — fixes focus jumping to next field after one keystroke (React remount bug from component defined inside render function)
- `BIGINT UNSIGNED` columns changed to `BIGINT` in DB schema — fixes `i64` type mismatch error on login
### Planned
- DNS zone editor (CRUD records via PowerDNS API)
- Email account management (Dovecot mailbox provisioning)
+1
View File
@@ -3,6 +3,7 @@ resolver = "2"
members = [
"nixpanel-common",
"nixpanel-api",
"nixpanel-accounts",
"nixpanel-services",
"nixpanel-sysinfo",
]
+38 -14
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import * as api from './api'
/* ── Types ──────────────────────────────────────────────────────────────── */
type Page =
@@ -304,14 +305,11 @@ const PAGE_META: Record<Page, { title: string; icon: string }> = {
}
/* ── Client panel shell ─────────────────────────────────────────────────── */
function ClientPanel() {
function ClientPanel({ onLogout }: { onLogout: () => void }) {
const [page, setPage] = useState<Page>('home')
const [loggedIn, setLoggedIn] = useState(true)
if (!loggedIn) return null
const meta = PAGE_META[page]
const domain = 'yourdomain.com'
const username = localStorage.getItem('nixclient_username') ?? 'user'
const domain = username // domain matches account username for now
function renderPage() {
if (page === 'home') return <Home onNav={setPage} />
@@ -363,7 +361,7 @@ function ClientPanel() {
<span className="text-xs text-gray-300 font-medium max-w-[120px] truncate">{domain}</span>
</div>
<button
onClick={() => setLoggedIn(false)}
onClick={onLogout}
className="px-3 py-1.5 rounded-lg border border-[#2a3044] text-gray-400 hover:border-gray-600 hover:text-white transition-colors text-xs font-medium"
>
Sign out
@@ -404,11 +402,19 @@ function ClientPanel() {
export default function App() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [isLoggedIn, setIsLoggedIn] = useState(false)
const [loggedInAs, setLoggedInAs] = useState<string | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleLogin = (e: React.FormEvent) => {
// Restore session on load
useEffect(() => {
if (api.isLoggedIn()) {
const stored = localStorage.getItem('nixclient_username')
setLoggedInAs(stored ?? 'user')
}
}, [])
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
if (!username || !password) {
setError('Please enter your username and password.')
@@ -416,13 +422,31 @@ export default function App() {
}
setLoading(true)
setError('')
setTimeout(() => {
try {
const result = await api.login(username, password)
// Only allow non-admin users into NixClient
if (result.user.role === 'admin') {
api.clearToken()
setError('Admin accounts must use NixServer (port 2087).')
return
}
localStorage.setItem('nixclient_username', result.user.username)
setLoggedInAs(result.user.username)
} catch (err: any) {
setError(err.message === 'Unauthorized' ? 'Invalid username or password.' : err.message)
} finally {
setLoading(false)
setIsLoggedIn(true)
}, 600)
}
}
if (isLoggedIn) return <ClientPanel />
const handleLogout = () => {
api.clearToken()
localStorage.removeItem('nixclient_username')
setLoggedInAs(null)
setUsername(''); setPassword('')
}
if (loggedInAs) return <ClientPanel onLogout={handleLogout} />
return (
<div className="min-h-screen flex items-center justify-center bg-[#0f1520] relative overflow-hidden">
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "nixpanel-accounts"
version = "0.2.0-alpha"
edition = "2021"
[[bin]]
name = "nixpanel-accounts"
path = "src/main.rs"
[dependencies]
nixpanel-common = { path = "../nixpanel-common" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+322
View File
@@ -0,0 +1,322 @@
//! nixpanel-accounts — account provisioning section binary
//!
//! Actions:
//! provision {username, domain, email, server_ip, php_version?}
//! → creates vhost, PHP-FPM pool, DNS zone, home directory structure
//!
//! deprovision {username, domain}
//! → removes vhost, PHP-FPM pool, DNS zone, optionally home dir
use nixpanel_common::{SectionRequest, SectionResponse};
use std::fs;
use std::io::{self, BufRead};
use std::process::Command;
/* ── helpers ──────────────────────────────────────────────────────────── */
fn run(prog: &str, args: &[&str]) -> Result<String, String> {
let out = Command::new(prog)
.args(args)
.output()
.map_err(|e| format!("Failed to run {}: {}", prog, e))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
fn run_ok(prog: &str, args: &[&str]) {
let _ = Command::new(prog).args(args).output();
}
fn write_file(path: &str, content: &str) -> Result<(), String> {
fs::write(path, content).map_err(|e| format!("Write {}: {}", path, e))
}
fn server_ip() -> String {
// Read from /opt/nixpanel sysinfo, or fall back to hostname -I
Command::new("hostname")
.arg("-I")
.output()
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.split_whitespace()
.next()
.unwrap_or("127.0.0.1")
.to_string()
})
.unwrap_or_else(|_| "127.0.0.1".to_string())
}
/* ── Apache vhost ─────────────────────────────────────────────────────── */
fn create_vhost(username: &str, domain: &str, php_version: &str) -> Result<(), String> {
let public_html = format!("/home/{}/public_html", username);
let log_dir = format!("/home/{}/logs", username);
let sock = format!("/run/php/php{}-fpm-{}.sock", php_version, username);
let conf = format!(
r#"# NixPanel — {domain} ({username})
<VirtualHost *:80>
ServerName {domain}
ServerAlias www.{domain}
DocumentRoot {public_html}
<Directory {public_html}>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
<FilesMatch \.php$>
SetHandler "proxy:unix:{sock}|fcgi://localhost"
</FilesMatch>
ErrorLog {log_dir}/error.log
CustomLog {log_dir}/access.log combined
</VirtualHost>
"#,
domain = domain,
username = username,
public_html = public_html,
log_dir = log_dir,
sock = sock,
);
let conf_path = format!("/etc/apache2/sites-available/{}.conf", username);
write_file(&conf_path, &conf)?;
run("a2ensite", &[&format!("{}.conf", username)])?;
Ok(())
}
fn remove_vhost(username: &str) {
run_ok("a2dissite", &[&format!("{}.conf", username)]);
let _ = fs::remove_file(format!("/etc/apache2/sites-available/{}.conf", username));
}
/* ── PHP-FPM pool ─────────────────────────────────────────────────────── */
fn create_fpm_pool(username: &str, php_version: &str) -> Result<(), String> {
let sock = format!("/run/php/php{}-fpm-{}.sock", php_version, username);
let pool = format!(
r#"[{username}]
user = {username}
group = {username}
listen = {sock}
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = ondemand
pm.max_children = 10
pm.process_idle_timeout = 10s
pm.max_requests = 500
php_admin_value[open_basedir] = /home/{username}/:/tmp/
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
env[PATH] = /usr/local/bin:/usr/bin:/bin
"#,
username = username,
sock = sock,
);
let pool_path = format!(
"/etc/php/{}/fpm/pool.d/{}.conf",
php_version, username
);
write_file(&pool_path, &pool)?;
run("systemctl", &["reload", &format!("php{}-fpm", php_version)])?;
Ok(())
}
fn remove_fpm_pool(username: &str, php_version: &str) {
let path = format!("/etc/php/{}/fpm/pool.d/{}.conf", php_version, username);
let _ = fs::remove_file(&path);
run_ok("systemctl", &["reload", &format!("php{}-fpm", php_version)]);
}
/* ── PowerDNS zone ────────────────────────────────────────────────────── */
fn create_dns_zone(domain: &str, ip: &str) -> Result<(), String> {
// Create zone
run("pdnsutil", &["create-zone", domain])?;
// A records
run("pdnsutil", &["add-record", domain, "@", "A", "3600", ip])?;
run("pdnsutil", &["add-record", domain, "www", "A", "3600", ip])?;
run("pdnsutil", &["add-record", domain, "mail","A", "3600", ip])?;
run("pdnsutil", &["add-record", domain, "ftp", "A", "3600", ip])?;
// MX record
run("pdnsutil", &["add-record", domain, "@", "MX", "3600",
&format!("10 mail.{}.", domain)])?;
// SPF TXT
run("pdnsutil", &["add-record", domain, "@", "TXT", "3600",
&format!("\"v=spf1 a mx ip4:{} ~all\"", ip)])?;
// Increase serial and notify
run_ok("pdnsutil", &["rectify-zone", domain]);
Ok(())
}
fn remove_dns_zone(domain: &str) {
run_ok("pdnsutil", &["delete-zone", domain]);
}
/* ── Home directory structure ─────────────────────────────────────────── */
fn create_home(username: &str, domain: &str) -> Result<(), String> {
let dirs = [
format!("/home/{}/public_html", username),
format!("/home/{}/logs", username),
format!("/home/{}/tmp", username),
format!("/home/{}/mail", username),
format!("/home/{}/backups", username),
];
for dir in &dirs {
fs::create_dir_all(dir)
.map_err(|e| format!("mkdir {}: {}", dir, e))?;
}
// Default index.html
let index = format!(
r#"<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Welcome to {domain}</title>
<style>body{{font-family:sans-serif;text-align:center;padding:80px;color:#333}}
h1{{color:#e85d04}}p{{color:#666}}</style></head>
<body>
<h1>🚀 {domain}</h1>
<p>Your hosting account is active. Upload your files to <code>public_html</code> to get started.</p>
<p><small>Powered by NixPanel</small></p>
</body></html>
"#,
domain = domain
);
write_file(&format!("/home/{}/public_html/index.html", username), &index)?;
// Set ownership to system user
run("chown", &["-R", &format!("{}:{}", username, username),
&format!("/home/{}", username)])?;
run("chmod", &["750", &format!("/home/{}", username)])?;
run("chmod", &["755", &format!("/home/{}/public_html", username)])?;
Ok(())
}
/* ── Actions ──────────────────────────────────────────────────────────── */
fn action_provision(params: &serde_json::Value) -> SectionResponse {
let username = params["username"].as_str().unwrap_or("");
let domain = params["domain"].as_str().unwrap_or("");
let php_version = params["php_version"].as_str().unwrap_or("8.3");
let ip = params["server_ip"].as_str()
.map(|s| s.to_string())
.unwrap_or_else(server_ip);
if username.is_empty() || domain.is_empty() {
return SectionResponse::err("username and domain are required");
}
// Sanitise: only allow alphanumeric + hyphen/dot in domain,
// alphanumeric + underscore in username
if !username.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
return SectionResponse::err("Invalid username");
}
let mut steps: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new();
// 1. Home directory
match create_home(username, domain) {
Ok(()) => steps.push("home directory created".into()),
Err(e) => errors.push(format!("home dir: {}", e)),
}
// 2. PHP-FPM pool
match create_fpm_pool(username, php_version) {
Ok(()) => steps.push(format!("PHP {} FPM pool created", php_version)),
Err(e) => errors.push(format!("fpm pool: {}", e)),
}
// 3. Apache vhost
match create_vhost(username, domain, php_version) {
Ok(()) => steps.push("Apache vhost created".into()),
Err(e) => errors.push(format!("vhost: {}", e)),
}
// 4. Reload Apache
match run("systemctl", &["reload", "apache2"]) {
Ok(_) => steps.push("Apache reloaded".into()),
Err(e) => errors.push(format!("apache reload: {}", e)),
}
// 5. PowerDNS zone
match create_dns_zone(domain, &ip) {
Ok(()) => steps.push(format!("DNS zone created (IP: {})", ip)),
Err(e) => errors.push(format!("DNS: {}", e)),
}
if errors.is_empty() {
SectionResponse::ok(serde_json::json!({
"steps": steps,
"domain": domain,
"ip": ip,
}))
} else {
// Partial success — return both what worked and what didn't
SectionResponse::ok(serde_json::json!({
"steps": steps,
"errors": errors,
"partial": true,
}))
}
}
fn action_deprovision(params: &serde_json::Value) -> SectionResponse {
let username = params["username"].as_str().unwrap_or("");
let domain = params["domain"].as_str().unwrap_or("");
let php_version = params["php_version"].as_str().unwrap_or("8.3");
if username.is_empty() {
return SectionResponse::err("username is required");
}
remove_vhost(username);
remove_fpm_pool(username, php_version);
if !domain.is_empty() {
remove_dns_zone(domain);
}
run_ok("systemctl", &["reload", "apache2"]);
SectionResponse::ok(serde_json::json!({
"message": format!("Account {} deprovisioned", username)
}))
}
/* ── Entry point ──────────────────────────────────────────────────────── */
fn main() {
let stdin = io::stdin();
let mut line = String::new();
stdin.lock().read_line(&mut line).unwrap_or(0);
let response = match serde_json::from_str::<SectionRequest>(line.trim()) {
Err(e) => SectionResponse::err(format!("Invalid JSON: {}", e)),
Ok(req) => match req.action.as_str() {
"provision" => action_provision(&req.params),
"deprovision" => action_deprovision(&req.params),
other => SectionResponse::err(format!("Unknown action: {}", other)),
},
};
println!("{}", serde_json::to_string(&response).unwrap());
}
+26 -3
View File
@@ -61,19 +61,42 @@ pub async fn create_account(
.map_err(|e| AppError::Internal(anyhow::anyhow!("argon2: {}", e)))?
.to_string();
// Create system user (best-effort)
// Create system user (must exist before provisioner runs)
let _ = std::process::Command::new("useradd")
.args(["-m", "-s", "/bin/bash", "-d",
&format!("/home/{}", body.username),
&body.username])
.output();
let _ = std::fs::create_dir_all(format!("/home/{}/public_html", body.username));
let package_name = body.package_name.unwrap_or_else(|| "Default".into());
let disk_quota_mb: i32 = body.disk_quota_mb.unwrap_or(10240);
let bandwidth_mb: i32 = body.bandwidth_mb.unwrap_or(0);
// Run the full account provisioner (vhost, PHP-FPM, DNS zone, home dirs)
let provision_result = crate::section::call(
&state.config.bin_dir,
"nixpanel-accounts",
"provision",
serde_json::json!({
"username": body.username,
"domain": body.domain,
"email": body.email,
"php_version": "8.3",
}),
);
if let Err(ref e) = provision_result {
tracing::warn!("Account provisioner error for {}: {}", body.username, e);
} else if let Ok(ref resp) = provision_result {
if let Some(errors) = resp.data.as_ref().and_then(|d| d.get("errors")) {
tracing::warn!("Provisioner partial errors for {}: {}", body.username, errors);
}
tracing::info!("Provisioner steps for {}: {:?}",
body.username,
resp.data.as_ref().and_then(|d| d.get("steps"))
);
}
let insert_id: u64 = sqlx::query(
"INSERT INTO accounts (username, domain, email, password_hash, package_name, disk_quota_mb, bandwidth_mb, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'active')",