A fast, high-contrast web UI for Proxmox VE that runs as a lightweight overlay on the Proxmox host. Built to give you everything the legacy ExtJS UI does, plus a few things it doesn't, with keyboard-driven UX and a modern component aesthetic.
┌──────────────────────────────────────────────────────┐
│ nexus Proxmox Management Overlay │
│ │
│ ◆ Dashboard ◆ Nodes ◆ VMs/CTs ◆ Storage │
│ ◆ Firewall ◆ HA ◆ Access ◆ Backups │
│ ◆ Network ◆ Console ◆ Scripts │
└──────────────────────────────────────────────────────┘
- Full parity with the stock PVE UI — VM/CT lifecycle (create, clone, migrate, snapshot, backup, restore), storage management, firewall rules + options, HA groups + resources, users/groups/realms/ACLs, cluster status, S.M.A.R.T. disks, network interfaces, certificates, APT updates, journal, remote console.
- Keyboard-first — global
⌘Kcommand palette for jump-to-any-resource. - Live telemetry — RRD charts for node / VM / CT, TanStack Query polling with stale-while-revalidate.
- Embedded terminal —
xterm.jswired to the PVE VNC websocket proxy; no separate app. - Community Scripts marketplace — two-pane catalogue backed by the upstream PocketBase public API (migrated from the old per-slug JSON files), per-script detail with install-method tabs, logo, credentials, severity-coloured notes, and best-effort env overrides (hostname, CT ID, CPU/RAM/disk, storage, password). Execution is fire-and-forget: the server spawns the script detached, returns a
jobIdimmediately (so Cloudflare Tunnel's 100 s cap never matters), and a floating bottom-right status bar plus a live-log drawer track progress with an Abort button. CSRF-protected + structured upstream error handling on every endpoint. - Bulk operations — pick a selection of VMs/CTs in any dashboard table, fire a start/stop/shutdown/reboot/snapshot batch at up to 3 concurrent, and watch the per-item progress (with UPID links back to PVE's task log) stream in a floating progress panel until every item hits a terminal state.
- Script chains — compose ordered sequences of Community Scripts, pick
halt-on-failureorcontinue-on-failure, run ad-hoc or on a 5-field cron. Persisted, scheduler-fired, and auto-disabled after 5 consecutive failed fires (flip back on in the UI once you've fixed the root cause). - Cluster-aware — pulls from
/cluster/resources; single pane for multi-node deployments. - Type-safe wire layer — every Proxmox
0|1boolean passes through a nominally branded codec; the UI works in nativebooleanonly. Raw0/1literals are compile-errors outside the codec. - Branded identifiers — VM ids, node names, PVE userids, session tickets, CSRF tokens, and batch ids all carry phantom-tag brands so field-swap bugs (e.g. passing a
Useridwhere aNodeNamewas expected) fail attscinstead of at runtime. - Discriminated state machines — bulk items, chain step runs, and PVE tasks are discriminated unions; the compiler refuses illegal states like a
pendingrow with afinishedAt, asuccessrow with no UPID, or arunningtask with anexitstatus.
On the Proxmox host, as root:
bash <(curl -fsSL https://raw.githubusercontent.com/Actualbug2005/Proxmox/main/install.sh)The script:
- Installs Node.js 22 LTS if missing
- Clones this repo into
/opt/nexus - Writes
.env.localwith an auto-generated JWT secret - Builds the Next.js app, prunes devDependencies
- Installs a
systemdunit (nexus.service) - Opens the chosen port in the PVE host firewall (default 3000)
After it finishes: http://<your-pve-ip>:3000 — log in with any PVE credentials.
.env.local (generated by the installer):
| Variable | Required | Purpose |
|---|---|---|
PROXMOX_HOST |
yes | Host PVE is reachable at (default: localhost since Nexus runs on the host) |
JWT_SECRET |
yes | Opaque session-cookie signer; auto-generated 36-byte base64 |
PORT |
no | Listen port (default 3000) |
REDIS_URL |
no | Enable ioredis-backed session store for multi-instance / HA deployments; without it, sessions live in-memory and reset on restart |
NEXUS_SECURE_COOKIES |
no | Set to false to serve session cookies without the Secure flag when operating over plain HTTP on a trusted LAN. Defaults to true in production, false in dev. |
NEXUS_DATA_DIR |
no | On-disk home for persisted state (scheduled-jobs.json, scheduled-chains.json). Defaults to $TMPDIR/nexus-data — override to a durable path in production. |
NEXUS_VERSION_FILE |
no | Path the version/health endpoints read the running SemVer from. Defaults to /opt/nexus/current/VERSION (baked in by the release tarball). |
NEXUS_REPO |
no | owner/repo for the GitHub release-check probe. Defaults to Actualbug2005/Proxmox. |
TLS verification for PVE's self-signed cert is handled inside the pveFetch helper (scoped undici.Agent), so NODE_TLS_REJECT_UNAUTHORIZED=0 is no longer set process-wide — outbound calls to anything other than PVE still validate certs normally.
Examples for Redis:
REDIS_URL=redis://127.0.0.1:6379
REDIS_URL=redis://:password@redis.internal:6379/2A single dynamic Next.js route catches every /api/proxmox/* request and forwards it to https://<PROXMOX_HOST>:8006/api2/json/*. It injects the stored PVEAuthCookie server-side (the browser only ever sees an opaque session id), attaches the CSRFPreventionToken header on mutating verbs, and handles TLS rejection for self-signed certs.
Two backends; chosen at startup based on env:
- In-memory
Map(default) — with a 5-minute interval GC pass for expired entries. Fine for single-instance installs. - Redis via ioredis — when
REDIS_URLis set. NativeEXTTL, JSON serialization, connection pool auto-managed. Use this for HA pairs or multi-Nexus deployments behind a load balancer.
Proxmox's HTTP API uses integer 0 | 1 on the wire for boolean flags. The codec handles translation at the HTTP boundary so every consumer (JSX, React state, mutation handlers) works with native boolean only:
// Public type exposes boolean to the UI
export type PveBool = (0 | 1) & { readonly [__brand]: 'PveBool' };
export type FirewallOptionsPublic = UnwireBool<FirewallOptions, 'enable' | /* ... */>;
// The codec is the only place that casts `as PveBool`
const encodeFirewallOptions = (opts: Partial<FirewallOptionsPublic>) =>
encodeBoolFields(opts, FIREWALL_OPTIONS_BOOL_KEYS);The brand makes raw 0/1 literal assignment a compile error anywhere outside the codec. See the refactor(core): enforce branded PveBool lockdown commit for the enforcement mechanism.
Community scripts can take 2–10 minutes to finish, far longer than any reasonable edge-proxy read timeout (Cloudflare Tunnel: 100 s). So /api/scripts/run doesn't await the child — it spawns an ssh root@<node> bash -s (or local bash when target == nexus host) detached, tees stdout+stderr to both an on-disk log (/tmp/nexus-script-logs/<jobId>.log) and an in-memory 64 KB ring, and returns {jobId} in <1 s. Status + tail live in src/lib/script-jobs.ts; the UI polls /api/scripts/jobs/[jobId] every 2 s until the child closes, then GC's the job record after 24 h. DELETE /api/scripts/jobs/[jobId] sends SIGTERM for user-initiated aborts. Caller-supplied env overrides are filtered through a whitelist + value regex before ever hitting the bash preamble.
- User logs in with PVE credentials (PAM/PVE realms). The server-side handler calls
POST /access/ticket, stashes the ticket + CSRF token in the session store, and returns only the opaque session id to the browser as anhttpOnlycookie. - Double-submit CSRF: a non-httpOnly companion cookie (
nexus_csrf) holds an HMAC-SHA256 ofsessionId; the browser echoes it in theX-Nexus-CSRFheader on mutations. Validated server-side withtimingSafeEqual. - Ticket refresh & back-off: every authenticated call checks ticket age and proactively re-auths against PVE at 90 min (well before the ~2 h expiry). A failed renewal stamps a 30 s back-off so a transiently-broken PVE doesn't get hammered on every request.
- Session rotation on login: any pre-existing
nexus_sessioncookie is invalidated in the store before a new sessionId is issued — blocks session-fixation even if an attacker plants a cookie pre-auth.
Every authenticated API route composes through withAuth / withCsrf HOFs so the auth preamble can't be forgotten:
export const POST = withCsrf(async (req, { session, sessionId }) => {
// session is a fully-validated PVEAuthSession — branded fields and all
});The handler signature requires ctx.session, so omitting the wrapper is a compile error. withAuth is the read-only variant for GETs (no CSRF check — GETs are safe by definition). Three routes intentionally stay hand-rolled — auth/login (creates the session), auth/logout (works even without a session), and proxmox/[...path] (multi-verb handler with custom refresh + response helper).
Requirements: Node.js 22+ (uses --experimental-strip-types).
git clone https://github.com/Actualbug2005/Proxmox.git
cd Proxmox/nexus
npm install
# Point at a reachable PVE host (edit .env.local)
cp .env.example .env.local # or write one by hand; see Configuration
npm run devOpens at http://localhost:3000.
Built on Node's native node:test runner via tsx loader — no Jest/Vitest install needed.
npm test~220 tests across ~50 suites covering: the wire codec, CSRF double-submit, PVE ticket renewal + back-off, permission probe fail-closed paths, exec-audit GCM envelope round-trips, remote-shell node-name injection guards, rate-limit token buckets + slot semaphores, brand parsers (accept/reject matrix), bulk-op discriminated transitions, chain-store JSON migration sanitiser, chain runner halt-on-failure vs continue semantics, cluster-pressure recent-failure collection, scheduler tick dedup + auto-disable, and the community-scripts PocketBase fetcher.
npx tsc --noEmitClean output means every wire-boundary value has flowed through the codec — there are no raw 0/1 literals leaking into UI state anywhere in the tree.
systemctl status nexus # health
systemctl restart nexus # restart after .env.local edits
journalctl -u nexus -f # live logsTo update: re-run the install script. It's idempotent — fast-forwards to origin/main, rebuilds, restarts the service.
"Studio Dark" — monochrome, high-contrast, in the Vercel/Linear lineage. Two distinct materials give the app a clear chrome-vs-content hierarchy without any GPU-animated compositing:
- Canvas — zinc-950 (
#09090b) anchored by a single static indigo-700 radial glow at 15% alpha from the top-center.background-attachment: fixedkeeps it stationary while long tables scroll; no aurora mesh, no noise overlay. - Workspace cards (
.studio-card) — translucent zinc-900 at 40% alpha over the canvas, 1px border at 5% white with an 8% top-edge "light catch". Nobackdrop-filterin the content layer — safe to stack dozens per page. Nested cards auto-drop their fill to transparent to avoid compound-alpha darkening. - Chrome / sidebar (
.liquid-glass) — the single placebackdrop-filter: blur(40px) saturate(200%)lives, over a translucent zinc layer. Gives the sidebar an OS-shell feel floating above the workspace. - Accessibility —
@media (prefers-reduced-transparency: reduce)collapses both materials to solid zinc-900 with opaque borders. The indigo glow stays (it's colour, not filter). - Typography — Geist Sans/Mono. Fixed scale: 11px all-caps section labels only, 12px meta, 13px tabular data, 14px body, 16/18/24px headings.
font-variant-numeric: tabular-numson every numeric column. - Radius — 8px (
rounded-lg) on every container, enforced!importanton.studio-cardto out-rank strayrounded-xl/rounded-2xlutilities. - Scrollbar — 8px etched-glass thumb (translucent white, 10% → 20% on hover); never a native OS scrollbar.
nexus/
├── src/
│ ├── app/
│ │ ├── (app)/ # Route group — shared auth shell + sidebar
│ │ │ ├── layout.tsx # Master shell: session gate, aurora mesh,
│ │ │ │ # noise overlay, Sidebar, CommandPalette
│ │ │ ├── dashboard/ # Main UI — VMs, CTs, storage, HA, access, etc.
│ │ │ ├── console/ # xterm.js embed (locked viewport)
│ │ │ └── scripts/ # Community Scripts marketplace
│ │ ├── api/
│ │ │ ├── proxmox/[...path]/ # Dynamic proxy → PVE
│ │ │ ├── scripts/ # GET index (PocketBase), GET [slug] manifest
│ │ │ ├── scripts/run/ # POST — fire-and-forget, returns {jobId}
│ │ │ └── scripts/jobs/ # GET list, GET/[jobId] detail+log, DELETE abort
│ │ ├── login/
│ │ └── globals.css # Design tokens + aurora mesh + Liquid Glass
│ ├── components/ # Domain UI (firewall-options-tab, backup-job-editor, …)
│ ├── lib/
│ │ ├── proxmox-client.ts # The wire codec + typed API surface
│ │ ├── community-scripts.ts # PocketBase fetcher + ScriptManifest/Category types
│ │ ├── script-jobs.ts # In-memory job registry + /tmp log files + env whitelist
│ │ ├── auth.ts # PVE ticket acquisition + session
│ │ ├── csrf.ts # HMAC double-submit
│ │ ├── session-store.ts # Memory / Redis backends
│ │ └── nas/providers/ # NAS provider interface + native (Samba/NFS) impl
│ ├── hooks/ # TanStack Query wrappers (useClusterResources, …)
│ └── types/proxmox.ts # Wire types + Public (UnwireBool) variants
├── server.ts # Production entry (node --experimental-strip-types)
└── package.json
Branch-per-feature, PR against main. Before opening a PR:
npx tsc --noEmitcleannpm testgreen- No inline
0 | 1or=== 1for wire-bool conversions in components — the codec handles them - Follow the existing
*_BOOL_KEYSpattern if you're adding a new wire-type field
Released under the MIT License. You can use, copy, modify, distribute, or sublicense the code freely as long as the copyright notice travels with it.
Community scripts fetched at runtime from community-scripts/ProxmoxVE and its PocketBase API are under their own MIT license — no code from that project is bundled here; Nexus only reads the published metadata and forwards execution requests to the node you pick.
- Proxmox VE — the underlying virtualization platform
- community-scripts.org — the tteck scripts library powering the Scripts tab
- Next.js, TanStack Query, Tailwind, xterm.js, Recharts, Lucide — the stack