Skip to content

Actualbug2005/Proxmox

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

399 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Nexus — Modern Proxmox Management UI

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                │
└──────────────────────────────────────────────────────┘

Highlights

  • 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 ⌘K command palette for jump-to-any-resource.
  • Live telemetry — RRD charts for node / VM / CT, TanStack Query polling with stale-while-revalidate.
  • Embedded terminalxterm.js wired 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 jobId immediately (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-failure or continue-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|1 boolean passes through a nominally branded codec; the UI works in native boolean only. Raw 0/1 literals 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 Userid where a NodeName was expected) fail at tsc instead of at runtime.
  • Discriminated state machines — bulk items, chain step runs, and PVE tasks are discriminated unions; the compiler refuses illegal states like a pending row with a finishedAt, a success row with no UPID, or a running task with an exitstatus.

Quick install

On the Proxmox host, as root:

bash <(curl -fsSL https://raw.githubusercontent.com/Actualbug2005/Proxmox/main/install.sh)

The script:

  1. Installs Node.js 22 LTS if missing
  2. Clones this repo into /opt/nexus
  3. Writes .env.local with an auto-generated JWT secret
  4. Builds the Next.js app, prunes devDependencies
  5. Installs a systemd unit (nexus.service)
  6. 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.

Configuration

.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/2

Architecture

Proxy layer — src/app/api/proxmox/[...path]/route.ts

A 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.

Session store — src/lib/session-store.ts

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_URL is set. Native EX TTL, JSON serialization, connection pool auto-managed. Use this for HA pairs or multi-Nexus deployments behind a load balancer.

Wire codec — src/lib/proxmox-client.ts

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.

Script execution

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.

Auth

  • 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 an httpOnly cookie.
  • Double-submit CSRF: a non-httpOnly companion cookie (nexus_csrf) holds an HMAC-SHA256 of sessionId; the browser echoes it in the X-Nexus-CSRF header on mutations. Validated server-side with timingSafeEqual.
  • 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_session cookie is invalidated in the store before a new sessionId is issued — blocks session-fixation even if an attacker plants a cookie pre-auth.

Route middleware — src/lib/route-middleware.ts

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).

Development

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 dev

Opens at http://localhost:3000.

Tests

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.

Type check

npx tsc --noEmit

Clean 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.

Operations

systemctl status nexus        # health
systemctl restart nexus       # restart after .env.local edits
journalctl -u nexus -f        # live logs

To update: re-run the install script. It's idempotent — fast-forwards to origin/main, rebuilds, restarts the service.

Design system

"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: fixed keeps 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". No backdrop-filter in 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 place backdrop-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-nums on every numeric column.
  • Radius — 8px (rounded-lg) on every container, enforced !important on .studio-card to out-rank stray rounded-xl / rounded-2xl utilities.
  • Scrollbar — 8px etched-glass thumb (translucent white, 10% → 20% on hover); never a native OS scrollbar.

Layout

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

Contributing

Branch-per-feature, PR against main. Before opening a PR:

  • npx tsc --noEmit clean
  • npm test green
  • No inline 0 | 1 or === 1 for wire-bool conversions in components — the codec handles them
  • Follow the existing *_BOOL_KEYS pattern if you're adding a new wire-type field

License

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.

Credits

  • 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

About

Modern ProxmoxVE UI/UX with advance features.

Topics

Resources

License

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Contributors

Languages