urZONE / Deployment Guide

Add a Web Shell to urZONE PVE

The web shell puts a Shell button on every container and virtual machine in your urZONE PVE page. One click opens a terminal right inside the browser. A small helper program, called the bridge, makes it work. This guide builds the bridge one step at a time. Follow the steps in order, and use the Copy buttons to send only the commands you need to your terminal.

01 Prepare 02 Build the bridge 03 Publish 04 Connect

Before you begin

Proxmox and the plugin

Proxmox VE and the urZONE PVE plugin must already show your instances in WordPress. Use plugin version 2.4 or newer for VM shells. Containers work on older versions. You also need root access to the Proxmox host and edit access to your WordPress wp-config.php.

A home for the bridge

A small Debian 12 or Ubuntu 24.04 LXC container with 512 MB of RAM and 4 GB of disk, on the same network as Proxmox. It needs Node.js 18 or newer. Older Ubuntu releases ship an old Node.js, so install Node.js 18 or newer from NodeSource there.

A public HTTPS name

A web address for the bridge, for example terms.example.com, served through a Cloudflare Tunnel or an nginx reverse proxy. You will set this up in Step 07.

A Proxmox API token

A token that is allowed to open consoles. You will create a safe, restricted one in Step 02.

Replace the placeholders: The guide uses PVE_IP (your Proxmox address), BRIDGE_IP (the bridge container’s address), terms.example.com and https://example.com. Swap each one for your own value. Words like example.com belong to nobody and can never connect.

How it works

Proxmox’s own shell uses a connection that a browser cannot log in to with an API token, and WordPress cannot keep that kind of connection open. So one very small program sits in the middle: the bridge.

How the pieces connect
Browser (xterm.js)
   |  wss://terms.example.com/?t=<signed token>
   v
Cloudflare Tunnel or nginx (HTTPS)
   v
Bridge (Node.js, installed in this guide)  -- holds the Proxmox API token
   v
Proxmox VE (termproxy -> your container or VM)

How it stays safe

  • WordPress first checks that the logged-in user owns the instance. Only then does it issue a signed token that is valid for 30 seconds and works only once.
  • The bridge checks the signature, the expiry and the website address (the origin) before it contacts Proxmox.
  • The browser never sees any Proxmox login details.
Step 01

Create the bridge container

In the Proxmox web interface, click Create CT and fill in these values:

  • Hostname terms, and set a root password.
  • Template Debian 12. If it is not listed, download it under local storage → CT Templates.
  • Disk 4 GB, 1 core, 512 MB of memory.
  • Put it on your normal network bridge (for example vmbr0) with DHCP or a static address.

Finish and start it. Open its Console, or run the command below on the Proxmox host. Replace CT_ID with the container’s number.

Proxmox host · Enter the container
pct enter CT_ID

Now find the container’s address. The first address printed is your BRIDGE_IP.

Bridge container · Show address
hostname -I

Tip: use a static address or a DHCP reservation. Your tunnel or proxy will point at this address, so it must not change.

Step 02

Create a Proxmox token for the bridge

The bridge only asks Proxmox to open a console. You can reuse the token the plugin uses, but a separate, restricted token is safer. In Proxmox, go to Datacenter → Permissions and do these four things in order:

  1. Users → Add: user name shell, realm Proxmox VE authentication server.
  2. Roles → Create: name ShellOnly, privileges VM.Console and VM.Audit.
  3. API Tokens → Add: user shell@pve, token ID bridge, and untick Privilege Separation so the token has the user’s permissions. Copy the secret now. Proxmox shows it only once.
  4. Permissions → Add → User Permission: path /vms, user shell@pve, role ShellOnly, with Propagate ticked.

Prefer your plugin’s token? You can skip this step and use its values in Step 04. A root token works, but it gives the bridge far more power than it needs.

Step 03

Install Node.js and the bridge script

On the bridge container, as root, install what the bridge needs.

Bridge container · Install Node.js
apt update && apt install -y nodejs npm nano curl openssl
node -v        # must print v18 or higher
mkdir -p /opt/urzone-term && cd /opt/urzone-term
npm init -y
npm i ws@8

Create the script file, paste the whole script below into it, then save with Ctrl+O, Enter, and exit with Ctrl+X.

Bridge container · Open the script file
nano /opt/urzone-term/urzone-term-bridge.mjs
urzone-term-bridge.mjs · Paste this whole script
/**
 * urZONE PVE web shell bridge
 *
 * Browser (xterm.js) <-wss-> tunnel or nginx <-ws-> this bridge <-wss-> Proxmox termproxy
 *
 * Settings come from environment variables (see the install guide):
 *   PVE_HOST, PVE_USER, PVE_TOKEN_ID, PVE_SECRET   Proxmox address and API token (required)
 *   TERM_SECRET     same value as URZONE_TERMINAL_SECRET in wp-config.php (required)
 *   ALLOWED_ORIGIN  comma-separated website addresses allowed to connect (recommended)
 *   LISTEN_HOST     default 127.0.0.1 (use 0.0.0.0 when a proxy on another machine connects)
 *   LISTEN_PORT     default 8080
 *   PVE_PORT        default 8006
 *   PVE_VERIFY_TLS  set to 1 once Proxmox has a trusted certificate
 */

import https from 'node:https';
import crypto from 'node:crypto';
import { WebSocketServer, WebSocket } from 'ws';

const {
  PVE_HOST, PVE_USER, PVE_TOKEN_ID, PVE_SECRET, TERM_SECRET,
  ALLOWED_ORIGIN = '',
  PVE_PORT = '8006',
  PVE_VERIFY_TLS = '0',
  LISTEN_PORT = '8080',
} = process.env;

for (const [k, v] of Object.entries({ PVE_HOST, PVE_USER, PVE_TOKEN_ID, PVE_SECRET, TERM_SECRET })) {
  if (!v) { console.error(`Missing required env var: ${k}`); process.exit(1); }
}

const AUTH = `PVEAPIToken=${PVE_USER}!${PVE_TOKEN_ID}=${PVE_SECRET}`;
const agent = new https.Agent({ rejectUnauthorized: PVE_VERIFY_TLS === '1' });
const usedTokens = new Set();

function pve(method, path, body = '') {
  return new Promise((resolve, reject) => {
    const req = https.request(
      {
        host: PVE_HOST, port: Number(PVE_PORT), path: '/api2/json' + path, method, agent,
        headers: {
          Authorization: AUTH,
          'Content-Type': 'application/x-www-form-urlencoded',
          'Content-Length': Buffer.byteLength(body),
        },
      },
      (res) => {
        let body = '';
        res.on('data', (d) => (body += d));
        res.on('end', () => {
          if (res.statusCode >= 300) return reject(new Error(`PVE ${res.statusCode}: ${body}`));
          try { resolve(JSON.parse(body).data); } catch (e) { reject(e); }
        });
      }
    );
    req.on('error', reject);
    req.end(body);
  });
}

/** Verifies the HMAC-signed, single-use token issued by WordPress. */
function verifyToken(raw) {
  const [payload, sig] = String(raw || '').split('.');
  if (!payload || !sig) throw new Error('malformed token');

  const expected = crypto.createHmac('sha256', TERM_SECRET).update(payload).digest('hex');
  const a = Buffer.from(sig);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new Error('bad signature');

  const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));

  if (!claims.exp || claims.exp < Date.now() / 1000) throw new Error('token expired');
  if (!claims.jti || usedTokens.has(claims.jti)) throw new Error('token replayed');
  if (!/^[A-Za-z0-9._-]+$/.test(String(claims.n))) throw new Error('bad node');
  if (!Number.isInteger(claims.v) || claims.v < 100) throw new Error('bad vmid');

  usedTokens.add(claims.jti);
  setTimeout(() => usedTokens.delete(claims.jti), 120000);

  return claims;
}

/**
 * ALLOWED_ORIGIN accepts a comma-separated list. Exact origins and one-level
 * wildcards both work, e.g.:
 *   ALLOWED_ORIGIN="https://example.com,https://*.example.com,https://customer.com"
 */
const originRules = ALLOWED_ORIGIN.split(',').map((s) => s.trim()).filter(Boolean);

function originAllowed(origin) {
  if (!originRules.length) return true;
  if (!origin) return false;

  let url;
  try { url = new URL(origin); } catch { return false; }

  return originRules.some((rule) => {
    if (rule.startsWith('https://*.')) {
      const suffix = rule.slice('https://*'.length); // for example .example.com
      return url.protocol === 'https:' && url.hostname.endsWith(suffix);
    }
    return rule === origin;
  });
}

const wss = new WebSocketServer({
  port: Number(LISTEN_PORT),
  host: process.env.LISTEN_HOST || '127.0.0.1',
  maxPayload: 64 * 1024,
  verifyClient: (info) => {
    const ok = originAllowed(info.origin);
    console.log(`[${new Date().toISOString()}] incoming connection, origin=${info.origin || '(none)'} -> ${ok ? 'allowed' : 'REJECTED (add it to ALLOWED_ORIGIN)'}`);
    return ok;
  },
});

wss.on('connection', async (client, req) => {
  let up = null;
  let ping = null;
  let closed = false;

  const started = Date.now();

  // Says WHO ended the session, logs it, and tells the browser (shown in the shell window).
  const end = (why = 'closed') => {
    if (closed) return;
    closed = true;
    console.log(`session ended after ${Math.round((Date.now() - started) / 1000)}s: ${why}`);
    clearInterval(ping);
    try { up && up.close(); } catch {}
    try { client.close(4000, String(why).slice(0, 100)); } catch {}
  };

  client.on('close', (code) => end(`browser closed the connection (code ${code})`));
  client.on('error', (e) => end(`browser connection error: ${e.message}`));

  try {
    const claims = verifyToken(new URL(req.url, 'http://bridge').searchParams.get('t'));
    const type = claims.t === 'qemu' ? 'qemu' : 'lxc';
    const base = `/nodes/${claims.n}/${type}/${claims.v}`;

    let termBody = '';
    if (type === 'qemu') {
      if (!/^serial[0-3]$/.test(String(claims.s || ''))) throw new Error('VM token has no serial port');
      termBody = 'serial=' + claims.s;
    }

    console.log('token ok for', type, 'VMID', claims.v, 'on node', claims.n, '- requesting termproxy');
    const tp = await pve('POST', base + '/termproxy', termBody);

    up = new WebSocket(
      `wss://${PVE_HOST}:${PVE_PORT}/api2/json${base}/vncwebsocket?port=${tp.port}&vncticket=${encodeURIComponent(tp.ticket)}`,
      { agent, headers: { Authorization: AUTH } }
    );

    let ready = false;
    let pendingSize = null;

    const sendSize = (cols, rows) => {
      if (/^\d{1,4}$/.test(cols) && /^\d{1,4}$/.test(rows)) up.send(`1:${cols}:${rows}:`);
    };

    up.on('open', () => up.send(`${tp.user}:${tp.ticket}\n`));

    up.on('message', (data) => {
      let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);

      if (!ready) {
        if (!buf.subarray(0, 2).equals(Buffer.from('OK'))) {
          console.error('Proxmox did not answer OK, got:', buf.toString().slice(0, 200));
          return end('Proxmox login failed');
        }
        console.log('terminal session open for VMID', claims.v);
        ready = true;
        buf = buf.subarray(2);
        ping = setInterval(() => { if (up.readyState === 1) up.send('2'); }, 30000);
        if (pendingSize) sendSize(pendingSize[0], pendingSize[1]);
      }

      if (buf.length && client.readyState === 1) client.send(buf, { binary: true });
    });

    up.on('close', (code, reason) => { console.log('proxmox side closed:', code, reason.toString()); end(`Proxmox ended the session (code ${code})`); });
    up.on('error', (e) => { console.error('proxmox websocket error:', e.message); end('Proxmox connection error: ' + e.message); });

    client.on('message', (raw) => {
      const s = raw.toString();
      const kind = s[0];
      const rest = s.slice(1);

      if (kind === 'r') {
        const [cols, rows] = rest.split(',');
        if (!ready) pendingSize = [cols, rows];
        else sendSize(cols, rows);
      } else if (kind === 'd' && ready && up.readyState === 1) {
        up.send(`0:${Buffer.byteLength(rest)}:${rest}`);
      }
    });
  } catch (err) {
    console.error('terminal connection rejected:', err.message);
    end(String(err.message).startsWith('PVE ') ? 'Proxmox refused to open the shell' : err.message);
  }
});

console.log(`urZONE terminal bridge listening on ${process.env.LISTEN_HOST || '127.0.0.1'}:${LISTEN_PORT}`);

Check that it was pasted correctly. You should see script OK.

Bridge container · Check the script
node --check /opt/urzone-term/urzone-term-bridge.mjs && echo "script OK"

Pasting tip: Paste into a real terminal, such as SSH or pct enter from the Proxmox host. The Proxmox browser console can scramble long pastes. If a paste looks broken, clear the file in nano with repeated Ctrl+K and paste again. You can also copy the file over with scp.

Step 04

Configure the bridge

Generate a shared secret. WordPress and the bridge must use the same one, so copy it somewhere safe.

Bridge container · Make a secret
openssl rand -hex 32

Create the settings file, and fill it in using the box below.

Bridge container · Open the settings file
nano /opt/urzone-term/bridge.env
bridge.env · Fill in your values
LISTEN_HOST=0.0.0.0
PVE_HOST=PVE_IP
PVE_USER=shell@pve
PVE_TOKEN_ID=bridge
PVE_SECRET=the-token-secret-from-step-2
TERM_SECRET=the-secret-you-just-generated
ALLOWED_ORIGIN=https://example.com

Rules: one NAME=value per line, no quotes, and no spaces around =. Then lock the file down so only root can read it.

Bridge container · Lock the file
chmod 600 /opt/urzone-term/bridge.env

What each setting does

LISTEN_HOST

0.0.0.0 lets a tunnel or proxy on another machine reach the bridge. Use 127.0.0.1 if the proxy runs on this same container.

PVE_HOST

Your Proxmox IP address or host name. Port 8006 is assumed. Change it with PVE_PORT.

PVE_USER, PVE_TOKEN_ID, PVE_SECRET

The token from Step 02.

TERM_SECRET

The shared signing secret. WordPress must use exactly the same one.

ALLOWED_ORIGIN

The addresses of the websites allowed to open shells, separated by commas, for example https://example.com,https://*.example.com. The *. form matches subdomains. Always set it.

PVE_VERIFY_TLS

Leave it out while Proxmox uses its default self-signed certificate. Set it to 1 once Proxmox has a trusted certificate.

LISTEN_PORT

Defaults to 8080.

Check that the token works

Use your own values. You should see JSON that contains the Proxmox version. An error or a 401 means the user, token ID or secret is wrong.

Bridge container · Test the token
curl -k -s -H "Authorization: PVEAPIToken=shell@pve!bridge=TOKEN_SECRET" https://PVE_IP:8006/api2/json/version
Step 05

Test run

Start the bridge by hand to see that it works.

Bridge container · Start it
cd /opt/urzone-term
set -a; . ./bridge.env; set +a
node urzone-term-bridge.mjs

It should print listening on 0.0.0.0:8080. In a second terminal, run this:

Second terminal · Ask the bridge
curl -i http://localhost:8080

The answer 426 Upgrade Required means the bridge is alive. It only speaks websocket, so that answer is correct. Press Ctrl+C in the first terminal to stop it.

Step 06

Run it as a service

A service keeps the bridge running and starts it again after a reboot. First create a dedicated user for it.

Bridge container · Create a user
useradd --system --no-create-home --shell /usr/sbin/nologin urzone-term

Create the service file and paste the contents below into it.

Bridge container · Open the service file
nano /etc/systemd/system/urzone-term.service
urzone-term.service · Paste this
[Unit]
Description=urZONE PVE web shell bridge
After=network-online.target
Wants=network-online.target

[Service]
User=urzone-term
WorkingDirectory=/opt/urzone-term
EnvironmentFile=/opt/urzone-term/bridge.env
ExecStart=/usr/bin/node /opt/urzone-term/urzone-term-bridge.mjs
Restart=always
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target

If which node prints a different path, change the ExecStart line to match. Then start the service.

Bridge container · Start the service
systemctl daemon-reload
systemctl enable --now urzone-term
systemctl status urzone-term --no-pager
ss -ltn | grep 8080

You can watch the bridge’s log at any time with this command:

Bridge container · Watch the log
journalctl -u urzone-term -f
Step 07

Publish it over HTTPS

Browsers on an HTTPS page only allow secure websocket (wss://) connections, so the bridge has to be reachable through HTTPS. Pick one option.

Option A: Cloudflare Tunnel

  1. In Cloudflare Zero Trust, go to Networks → Tunnels → your tunnel → Public hostname and choose Add. Newer dashboards call it “Published application routes”.
  2. Subdomain terms, domain example.com, type HTTP, URL BRIDGE_IP:8080.
  3. Save. Websockets work automatically.

Important: The name must match exactly what you put in WordPress later. terms is not term.

  • If you have a wildcard entry (*.example.com) in the tunnel, a wildcard DNS record or a wildcard proxy host, the specific terms entry must come first, or requests land on your website instead.
  • Do not protect this name with a Cloudflare Access policy. A browser cannot complete the Access login for a websocket, and the signed token already protects the bridge. If a wildcard Access application covers it, add an application for exactly terms.example.com with a Bypass policy for Everyone.
  • The machine running cloudflared must be able to reach BRIDGE_IP:8080.

Option B: nginx

nginx · Site configuration
server {
    listen 443 ssl http2;
    server_name terms.example.com;
    # ssl_certificate and ssl_certificate_key as for any other site

    location / {
        proxy_pass http://BRIDGE_IP:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 3600s;
    }
}

With Nginx Proxy Manager instead: add a Proxy Host for terms.example.com, scheme http, forward to BRIDGE_IP port 8080, turn Websockets Support on, and add an SSL certificate. An exact host name wins over a wildcard host.

Check it

From any computer, run the command below. You should get 426 Upgrade Required. A redirect to your website’s signup page means a wildcard is catching the name. A 502 means the bridge is not running or cannot be reached.

Any computer · Check the public address
curl -i https://terms.example.com
Step 08

Connect WordPress

Add these two lines to wp-config.php, above the line that says That’s all, stop editing! On a multisite network, add them once for the whole network.

wp-config.php · Add these lines
define( 'URZONE_TERMINAL_URL', 'wss://terms.example.com' );
define( 'URZONE_TERMINAL_SECRET', 'the-same-secret-as-TERM_SECRET' );
  • The address starts with wss://, has no port, and has no path (unless your proxy uses one).
  • The secret must be exactly equal to TERM_SECRET.
  • If your site’s address is not yet in ALLOWED_ORIGIN, add it, then restart the bridge with the command below.
Bridge container · Restart the bridge
systemctl restart urzone-term

If you use the urZONE PVE Settings plugin, you can type the same two values into the Web shell tab instead of editing the file.

Step 09

Try it

  1. Reload your instances page with Ctrl+Shift+R.
  2. Make sure a container is running and click Shell. A terminal window opens. You can drag it by its title bar, maximize it, resize it or close it.
  3. Watch the bridge log while you connect. A good connection looks like the example below.
Bridge log · A good connection
incoming connection, origin=https://example.com -> allowed
token ok for lxc VMID 101 on node pve - requesting termproxy
terminal session open for VMID 101
Virtual machines

Shells on virtual machines

A VM has no shell of its own. The browser attaches to its serial console instead, so two things are needed:

  1. A serial port on the VM. VMs created by urZONE PVE 2.4 or newer get one. For an existing VM, the first Shell click adds it and tells you to click Restart. Then click Shell again.
  2. A login prompt on that port inside the guest. Run the command below once inside the VM. Most cloud images already have it.
Inside the VM · Enable the login prompt
systemctl enable --now [email protected]

Windows VMs have no text console. Use the Proxmox graphical console for those.

Troubleshooting

Start with the bridge log. The log and the shell window both say who ended a session and why.

Bridge container · Show the latest log
journalctl -u urzone-term -n 30 --no-pager

“Disconnected” and nothing new in the log

The connection never reached the bridge. Test curl -i https://terms.example.com, which should answer 426. Check the tunnel or proxy, and check that URZONE_TERMINAL_URL matches the host name exactly.

origin=... -> REJECTED

Add that exact address to ALLOWED_ORIGIN, then restart the service.

bad signature

The secret in wp-config.php and TERM_SECRET are different. Look for stray spaces or quotes.

token expired

The clocks of the WordPress server and the bridge differ by more than 30 seconds. Compare date +%s on both, and turn on time sync with timedatectl set-ntp true.

PVE 401 or PVE 403

Proxmox refused the token. Repeat the curl test from Step 04. If it works, re-check the permission path (/vms) and that Propagate is ticked.

It opens, then drops after a few seconds

Read the session ended after Ns: line to see which side closed. For Proxmox’s side, run pvesh get /nodes/pve/tasks --vmid ID --limit 5 on the Proxmox host, then pvesh get /nodes/pve/tasks/UPID/log.

A VM shows a blank window

There is no login prompt on its serial port, or the VM has not been restarted since the serial port was added.

502 from Cloudflare

The bridge is not running, or the tunnel host cannot reach BRIDGE_IP:8080. Check systemctl status urzone-term and ss -ltn | grep 8080.

A redirect to cloudflareaccess.com

An Access policy covers the host name. See Step 07, Option A.

Pasting breaks in the Proxmox console

Use SSH or pct enter instead, and use nano rather than cat > file << EOF.

After changing the script or bridge.env

Run node --check on the script first, then systemctl restart urzone-term.

Updating and removing

Update

Replace /opt/urzone-term/urzone-term-bridge.mjs with the new version, run node --check on it, then restart the service.

Bridge container · Restart
systemctl restart urzone-term

Remove

Stop and remove the service and the files, then take out the tunnel route or proxy host, delete the two URZONE_TERMINAL_* lines from wp-config.php, and delete the shell@pve user and token in Proxmox.

Bridge container · Remove the bridge
systemctl disable --now urzone-term
rm /etc/systemd/system/urzone-term.service
rm -r /opt/urzone-term

Security notes

Read this first: Whoever opens a shell gets a root shell inside that container. WordPress only issues a token for instances the logged-in user owns. Keep that rule intact, and keep the shared secret private.

  • Keep bridge.env readable by root only (chmod 600). Anyone who can read it can use your Proxmox token.
  • Use the restricted token from Step 02 instead of root@pam.
  • Always set ALLOWED_ORIGIN. Do not expose port 8080 to the internet directly. Only the tunnel or proxy should reach it, so firewall it.
  • Set PVE_VERIFY_TLS=1 once Proxmox has a trusted certificate.
  • Keep Node.js and the container updated.
  • Giving shells to untrusted users, for example on a free public demo, lets them run code on your hardware. Put those guests on their own network bridge, and use firewall rules to limit their outgoing traffic.

You’re done

Your containers and VMs now have a working Shell button. If something does not connect, start with the Troubleshooting section and the bridge log.

Proxmox and Proxmox VE are trademarks of Proxmox Server Solutions GmbH. urZONE PVE is an independent third-party integration and is not affiliated with or endorsed by Proxmox Server Solutions GmbH, the WordPress Foundation, Cloudflare, or Automattic.