feat: add Rust backend — workspace with section executables

Cargo workspace with four crates:
- nixpanel-common: shared SectionRequest/SectionResponse types
- nixpanel-api: main Axum HTTP server (JWT auth, DB, routing)
- nixpanel-services: section binary for systemctl start/stop/restart
- nixpanel-sysinfo: section binary for CPU/RAM/disk/OS/version reads

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

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

Frontend changes:
- nixserver/src/api.ts + App.tsx wired to real API (JWT stored in
  localStorage, real data in dashboard/services/accounts)
- nixclient/src/api.ts added
- Both vite configs get /api proxy to localhost:4000 for dev
- install.sh updated to copy + compress all section binaries
This commit is contained in:
NixReaper
2026-04-25 16:41:57 -05:00
parent a257b5571c
commit 5247c77132
26 changed files with 1899 additions and 331 deletions
+70
View File
@@ -0,0 +1,70 @@
/* NixClient API client — all calls go to /api/ (proxied to Rust backend) */
const BASE = '/api'
export function getToken(): string | null {
return localStorage.getItem('nixclient_token')
}
export function setToken(t: string) {
localStorage.setItem('nixclient_token', t)
}
export function clearToken() {
localStorage.removeItem('nixclient_token')
}
export function isLoggedIn(): boolean {
return !!getToken()
}
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken()
const res = await fetch(`${BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers ?? {}),
},
})
if (res.status === 401) {
clearToken()
window.location.reload()
throw new Error('Unauthorized')
}
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error ?? `HTTP ${res.status}`)
}
return res.json()
}
export interface LoginResult {
token: string
user: { id: number; username: string; role: string }
}
export async function login(username: string, password: string): Promise<LoginResult> {
const result = await apiFetch<LoginResult>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
})
setToken(result.token)
return result
}
export interface AccountInfo {
username: string
domain: string
email: string
package_name: string
disk_quota_mb: number
bandwidth_mb: number
status: string
}
export async function getMyAccount(): Promise<AccountInfo | null> {
try {
// Returns the first account matching the logged-in user's username
const accounts = await apiFetch<AccountInfo[]>('/accounts')
return accounts[0] ?? null
} catch {
return null
}
}