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.
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.
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.
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.
pct enter CT_ID
Now find the container’s address. The first address printed is your BRIDGE_IP.
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.
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:
- Users → Add: user name
shell, realmProxmox VE authentication server. - Roles → Create: name
ShellOnly, privilegesVM.ConsoleandVM.Audit. - API Tokens → Add: user
shell@pve, token IDbridge, and untick Privilege Separation so the token has the user’s permissions. Copy the secret now. Proxmox shows it only once. - Permissions → Add → User Permission: path
/vms, usershell@pve, roleShellOnly, 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.
Install Node.js and the bridge script
On the bridge container, as root, install what the bridge needs.
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.
nano /opt/urzone-term/urzone-term-bridge.mjs
/**
* 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.
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.
Configure the bridge
Generate a shared secret. WordPress and the bridge must use the same one, so copy it somewhere safe.
openssl rand -hex 32
Create the settings file, and fill it in using the box below.
nano /opt/urzone-term/bridge.env
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.
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.
curl -k -s -H "Authorization: PVEAPIToken=shell@pve!bridge=TOKEN_SECRET" https://PVE_IP:8006/api2/json/version
Test run
Start the bridge by hand to see that it works.
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:
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.
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.
useradd --system --no-create-home --shell /usr/sbin/nologin urzone-term
Create the service file and paste the contents below into it.
nano /etc/systemd/system/urzone-term.service
[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.
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:
journalctl -u urzone-term -f
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
- In Cloudflare Zero Trust, go to Networks → Tunnels → your tunnel → Public hostname and choose Add. Newer dashboards call it “Published application routes”.
- Subdomain
terms, domainexample.com, type HTTP, URLBRIDGE_IP:8080. - 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 specifictermsentry 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.comwith a Bypass policy for Everyone. - The machine running
cloudflaredmust be able to reachBRIDGE_IP:8080.
Option B: nginx
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.
curl -i https://terms.example.com
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.
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.
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.
Try it
- Reload your instances page with Ctrl+Shift+R.
- 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.
- Watch the bridge log while you connect. A good connection looks like the example below.
incoming connection, origin=https://example.com -> allowed
token ok for lxc VMID 101 on node pve - requesting termproxy
terminal session open for VMID 101
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:
- 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.
- A login prompt on that port inside the guest. Run the command below once inside the VM. Most cloud images already have it.
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.
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.
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.
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.envreadable 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=1once 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.

