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:
@@ -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)
|
||||
|
||||
@@ -3,6 +3,7 @@ resolver = "2"
|
||||
members = [
|
||||
"nixpanel-common",
|
||||
"nixpanel-api",
|
||||
"nixpanel-accounts",
|
||||
"nixpanel-services",
|
||||
"nixpanel-sysinfo",
|
||||
]
|
||||
|
||||
+39
-15
@@ -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 [error, setError] = useState('')
|
||||
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">
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "nixpanel-accounts"
|
||||
version = "0.2.0-alpha"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "nixpanel-accounts"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
nixpanel-common = { path = "../nixpanel-common" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,322 @@
|
||||
//! nixpanel-accounts — account provisioning section binary
|
||||
//!
|
||||
//! Actions:
|
||||
//! provision {username, domain, email, server_ip, php_version?}
|
||||
//! → creates vhost, PHP-FPM pool, DNS zone, home directory structure
|
||||
//!
|
||||
//! deprovision {username, domain}
|
||||
//! → removes vhost, PHP-FPM pool, DNS zone, optionally home dir
|
||||
|
||||
use nixpanel_common::{SectionRequest, SectionResponse};
|
||||
use std::fs;
|
||||
use std::io::{self, BufRead};
|
||||
use std::process::Command;
|
||||
|
||||
/* ── helpers ──────────────────────────────────────────────────────────── */
|
||||
|
||||
fn run(prog: &str, args: &[&str]) -> Result<String, String> {
|
||||
let out = Command::new(prog)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run {}: {}", prog, e))?;
|
||||
if out.status.success() {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn run_ok(prog: &str, args: &[&str]) {
|
||||
let _ = Command::new(prog).args(args).output();
|
||||
}
|
||||
|
||||
fn write_file(path: &str, content: &str) -> Result<(), String> {
|
||||
fs::write(path, content).map_err(|e| format!("Write {}: {}", path, e))
|
||||
}
|
||||
|
||||
fn server_ip() -> String {
|
||||
// Read from /opt/nixpanel sysinfo, or fall back to hostname -I
|
||||
Command::new("hostname")
|
||||
.arg("-I")
|
||||
.output()
|
||||
.map(|o| {
|
||||
String::from_utf8_lossy(&o.stdout)
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("127.0.0.1")
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_else(|_| "127.0.0.1".to_string())
|
||||
}
|
||||
|
||||
/* ── Apache vhost ─────────────────────────────────────────────────────── */
|
||||
|
||||
fn create_vhost(username: &str, domain: &str, php_version: &str) -> Result<(), String> {
|
||||
let public_html = format!("/home/{}/public_html", username);
|
||||
let log_dir = format!("/home/{}/logs", username);
|
||||
let sock = format!("/run/php/php{}-fpm-{}.sock", php_version, username);
|
||||
|
||||
let conf = format!(
|
||||
r#"# NixPanel — {domain} ({username})
|
||||
<VirtualHost *:80>
|
||||
ServerName {domain}
|
||||
ServerAlias www.{domain}
|
||||
DocumentRoot {public_html}
|
||||
|
||||
<Directory {public_html}>
|
||||
Options -Indexes +FollowSymLinks
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
<FilesMatch \.php$>
|
||||
SetHandler "proxy:unix:{sock}|fcgi://localhost"
|
||||
</FilesMatch>
|
||||
|
||||
ErrorLog {log_dir}/error.log
|
||||
CustomLog {log_dir}/access.log combined
|
||||
</VirtualHost>
|
||||
"#,
|
||||
domain = domain,
|
||||
username = username,
|
||||
public_html = public_html,
|
||||
log_dir = log_dir,
|
||||
sock = sock,
|
||||
);
|
||||
|
||||
let conf_path = format!("/etc/apache2/sites-available/{}.conf", username);
|
||||
write_file(&conf_path, &conf)?;
|
||||
|
||||
run("a2ensite", &[&format!("{}.conf", username)])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_vhost(username: &str) {
|
||||
run_ok("a2dissite", &[&format!("{}.conf", username)]);
|
||||
let _ = fs::remove_file(format!("/etc/apache2/sites-available/{}.conf", username));
|
||||
}
|
||||
|
||||
/* ── PHP-FPM pool ─────────────────────────────────────────────────────── */
|
||||
|
||||
fn create_fpm_pool(username: &str, php_version: &str) -> Result<(), String> {
|
||||
let sock = format!("/run/php/php{}-fpm-{}.sock", php_version, username);
|
||||
let pool = format!(
|
||||
r#"[{username}]
|
||||
user = {username}
|
||||
group = {username}
|
||||
|
||||
listen = {sock}
|
||||
listen.owner = www-data
|
||||
listen.group = www-data
|
||||
listen.mode = 0660
|
||||
|
||||
pm = ondemand
|
||||
pm.max_children = 10
|
||||
pm.process_idle_timeout = 10s
|
||||
pm.max_requests = 500
|
||||
|
||||
php_admin_value[open_basedir] = /home/{username}/:/tmp/
|
||||
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
|
||||
|
||||
env[PATH] = /usr/local/bin:/usr/bin:/bin
|
||||
"#,
|
||||
username = username,
|
||||
sock = sock,
|
||||
);
|
||||
|
||||
let pool_path = format!(
|
||||
"/etc/php/{}/fpm/pool.d/{}.conf",
|
||||
php_version, username
|
||||
);
|
||||
write_file(&pool_path, &pool)?;
|
||||
|
||||
run("systemctl", &["reload", &format!("php{}-fpm", php_version)])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_fpm_pool(username: &str, php_version: &str) {
|
||||
let path = format!("/etc/php/{}/fpm/pool.d/{}.conf", php_version, username);
|
||||
let _ = fs::remove_file(&path);
|
||||
run_ok("systemctl", &["reload", &format!("php{}-fpm", php_version)]);
|
||||
}
|
||||
|
||||
/* ── PowerDNS zone ────────────────────────────────────────────────────── */
|
||||
|
||||
fn create_dns_zone(domain: &str, ip: &str) -> Result<(), String> {
|
||||
// Create zone
|
||||
run("pdnsutil", &["create-zone", domain])?;
|
||||
|
||||
// A records
|
||||
run("pdnsutil", &["add-record", domain, "@", "A", "3600", ip])?;
|
||||
run("pdnsutil", &["add-record", domain, "www", "A", "3600", ip])?;
|
||||
run("pdnsutil", &["add-record", domain, "mail","A", "3600", ip])?;
|
||||
run("pdnsutil", &["add-record", domain, "ftp", "A", "3600", ip])?;
|
||||
|
||||
// MX record
|
||||
run("pdnsutil", &["add-record", domain, "@", "MX", "3600",
|
||||
&format!("10 mail.{}.", domain)])?;
|
||||
|
||||
// SPF TXT
|
||||
run("pdnsutil", &["add-record", domain, "@", "TXT", "3600",
|
||||
&format!("\"v=spf1 a mx ip4:{} ~all\"", ip)])?;
|
||||
|
||||
// Increase serial and notify
|
||||
run_ok("pdnsutil", &["rectify-zone", domain]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_dns_zone(domain: &str) {
|
||||
run_ok("pdnsutil", &["delete-zone", domain]);
|
||||
}
|
||||
|
||||
/* ── Home directory structure ─────────────────────────────────────────── */
|
||||
|
||||
fn create_home(username: &str, domain: &str) -> Result<(), String> {
|
||||
let dirs = [
|
||||
format!("/home/{}/public_html", username),
|
||||
format!("/home/{}/logs", username),
|
||||
format!("/home/{}/tmp", username),
|
||||
format!("/home/{}/mail", username),
|
||||
format!("/home/{}/backups", username),
|
||||
];
|
||||
|
||||
for dir in &dirs {
|
||||
fs::create_dir_all(dir)
|
||||
.map_err(|e| format!("mkdir {}: {}", dir, e))?;
|
||||
}
|
||||
|
||||
// Default index.html
|
||||
let index = format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"><title>Welcome to {domain}</title>
|
||||
<style>body{{font-family:sans-serif;text-align:center;padding:80px;color:#333}}
|
||||
h1{{color:#e85d04}}p{{color:#666}}</style></head>
|
||||
<body>
|
||||
<h1>🚀 {domain}</h1>
|
||||
<p>Your hosting account is active. Upload your files to <code>public_html</code> to get started.</p>
|
||||
<p><small>Powered by NixPanel</small></p>
|
||||
</body></html>
|
||||
"#,
|
||||
domain = domain
|
||||
);
|
||||
write_file(&format!("/home/{}/public_html/index.html", username), &index)?;
|
||||
|
||||
// Set ownership to system user
|
||||
run("chown", &["-R", &format!("{}:{}", username, username),
|
||||
&format!("/home/{}", username)])?;
|
||||
run("chmod", &["750", &format!("/home/{}", username)])?;
|
||||
run("chmod", &["755", &format!("/home/{}/public_html", username)])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* ── Actions ──────────────────────────────────────────────────────────── */
|
||||
|
||||
fn action_provision(params: &serde_json::Value) -> SectionResponse {
|
||||
let username = params["username"].as_str().unwrap_or("");
|
||||
let domain = params["domain"].as_str().unwrap_or("");
|
||||
let php_version = params["php_version"].as_str().unwrap_or("8.3");
|
||||
let ip = params["server_ip"].as_str()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(server_ip);
|
||||
|
||||
if username.is_empty() || domain.is_empty() {
|
||||
return SectionResponse::err("username and domain are required");
|
||||
}
|
||||
|
||||
// Sanitise: only allow alphanumeric + hyphen/dot in domain,
|
||||
// alphanumeric + underscore in username
|
||||
if !username.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
|
||||
return SectionResponse::err("Invalid username");
|
||||
}
|
||||
|
||||
let mut steps: Vec<String> = Vec::new();
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
|
||||
// 1. Home directory
|
||||
match create_home(username, domain) {
|
||||
Ok(()) => steps.push("home directory created".into()),
|
||||
Err(e) => errors.push(format!("home dir: {}", e)),
|
||||
}
|
||||
|
||||
// 2. PHP-FPM pool
|
||||
match create_fpm_pool(username, php_version) {
|
||||
Ok(()) => steps.push(format!("PHP {} FPM pool created", php_version)),
|
||||
Err(e) => errors.push(format!("fpm pool: {}", e)),
|
||||
}
|
||||
|
||||
// 3. Apache vhost
|
||||
match create_vhost(username, domain, php_version) {
|
||||
Ok(()) => steps.push("Apache vhost created".into()),
|
||||
Err(e) => errors.push(format!("vhost: {}", e)),
|
||||
}
|
||||
|
||||
// 4. Reload Apache
|
||||
match run("systemctl", &["reload", "apache2"]) {
|
||||
Ok(_) => steps.push("Apache reloaded".into()),
|
||||
Err(e) => errors.push(format!("apache reload: {}", e)),
|
||||
}
|
||||
|
||||
// 5. PowerDNS zone
|
||||
match create_dns_zone(domain, &ip) {
|
||||
Ok(()) => steps.push(format!("DNS zone created (IP: {})", ip)),
|
||||
Err(e) => errors.push(format!("DNS: {}", e)),
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
SectionResponse::ok(serde_json::json!({
|
||||
"steps": steps,
|
||||
"domain": domain,
|
||||
"ip": ip,
|
||||
}))
|
||||
} else {
|
||||
// Partial success — return both what worked and what didn't
|
||||
SectionResponse::ok(serde_json::json!({
|
||||
"steps": steps,
|
||||
"errors": errors,
|
||||
"partial": true,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn action_deprovision(params: &serde_json::Value) -> SectionResponse {
|
||||
let username = params["username"].as_str().unwrap_or("");
|
||||
let domain = params["domain"].as_str().unwrap_or("");
|
||||
let php_version = params["php_version"].as_str().unwrap_or("8.3");
|
||||
|
||||
if username.is_empty() {
|
||||
return SectionResponse::err("username is required");
|
||||
}
|
||||
|
||||
remove_vhost(username);
|
||||
remove_fpm_pool(username, php_version);
|
||||
if !domain.is_empty() {
|
||||
remove_dns_zone(domain);
|
||||
}
|
||||
run_ok("systemctl", &["reload", "apache2"]);
|
||||
|
||||
SectionResponse::ok(serde_json::json!({
|
||||
"message": format!("Account {} deprovisioned", username)
|
||||
}))
|
||||
}
|
||||
|
||||
/* ── Entry point ──────────────────────────────────────────────────────── */
|
||||
|
||||
fn main() {
|
||||
let stdin = io::stdin();
|
||||
let mut line = String::new();
|
||||
stdin.lock().read_line(&mut line).unwrap_or(0);
|
||||
|
||||
let response = match serde_json::from_str::<SectionRequest>(line.trim()) {
|
||||
Err(e) => SectionResponse::err(format!("Invalid JSON: {}", e)),
|
||||
Ok(req) => match req.action.as_str() {
|
||||
"provision" => action_provision(&req.params),
|
||||
"deprovision" => action_deprovision(&req.params),
|
||||
other => SectionResponse::err(format!("Unknown action: {}", other)),
|
||||
},
|
||||
};
|
||||
|
||||
println!("{}", serde_json::to_string(&response).unwrap());
|
||||
}
|
||||
@@ -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')",
|
||||
|
||||
Reference in New Issue
Block a user