fix: use /vncproxy endpoint with websocket:0 parameter

The /vncproxy endpoint requires websocket: 0 (not -1) in the POST body.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-07 18:44:13 +01:00
parent 607844f184
commit 2ab78012ba
9 changed files with 219 additions and 68 deletions

View File

@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { getNodes, getLXC } from '@/lib/proxmox';
const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || process.env.PROXMOX_API_URL?.replace('/api2/json', '');
const PROXMOX_CONSOLE_TOKEN = process.env.PROXMOX_CONSOLE_TOKEN || process.env.PROXMOX_TOKEN_ID;
/** @param {import('next').Request} _req */
export async function GET(_req) {
@@ -16,7 +17,7 @@ export async function GET(_req) {
...lxcs.map((lxc) => ({
...lxc,
node: node.name,
consoleUrl: `${PROXMOX_WEB_URL}/?console=lxc&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${lxc.vmid}`,
consoleUrl: `${PROXMOX_WEB_URL}/?console=lxc&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${lxc.vmid}&token=${encodeURIComponent(PROXMOX_CONSOLE_TOKEN)}`,
})),
);
} catch { /* skip node */ }

View File

@@ -0,0 +1,86 @@
import { NextResponse } from 'next/server';
import axios from 'axios';
const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || 'http://proxmox:8006';
const PROXMOX_API_URL = process.env.PROXMOX_API_URL;
/** @param {import('next').Request} req */
export async function GET(req) {
const url = new URL(req.url);
const pathSegments = url.pathname.split('/api/proxy/')[1];
if (!pathSegments) {
return NextResponse.json({ error: 'Invalid proxy path' }, { status: 400 });
}
const proxmoxPath = `/${pathSegments}`;
const targetUrl = `${PROXMOX_WEB_URL.replace(/\/+$/, '')}${proxmoxPath}`;
const headers = {
Authorization: `PVEAPIToken=${process.env.PROXMOX_TOKEN_ID}=${process.env.PROXMOX_TOKEN_SECRET}`,
};
try {
const res = await axios({ url: targetUrl, method: 'GET', headers, responseType: 'text' });
let body = res.data;
// Rewrite relative URLs back through our proxy
body = body.replace(
/href="([^"]*)"/g,
(_, href) => {
if (href.startsWith('http') || href.startsWith('javascript:')) return `href="${href}"`;
return `href="/api/proxy/${href}"`;
},
);
body = body.replace(
/src="([^"]*)"/g,
(_, src) => {
if (src.startsWith('http') || src.startsWith('data:') || src.startsWith('blob:')) return `src="${src}"`;
return `src="/api/proxy/${src}"`;
},
);
return new NextResponse(body, {
status: res.status,
headers: {
'Content-Type': res.headers['content-type'] || 'text/html',
'X-Frame-Options': 'SAMEORIGIN',
},
});
} catch (err) {
const msg = err.response?.data?.message || err.response?.data || err.message;
return NextResponse.json({ error: msg }, { status: 502 });
}
}
/** @param {import('next').Request} req */
export async function POST(req) {
const url = new URL(req.url);
const pathSegments = url.pathname.split('/api/proxy/')[1];
if (!pathSegments) {
return NextResponse.json({ error: 'Invalid proxy path' }, { status: 400 });
}
const proxmoxPath = `/${pathSegments}`;
const targetUrl = `${PROXMOX_WEB_URL.replace(/\/+$/, '')}${proxmoxPath}`;
const headers = {
Authorization: `PVEAPIToken=${process.env.PROXMOX_TOKEN_ID}=${process.env.PROXMOX_TOKEN_SECRET}`,
'Content-Type': 'application/x-www-form-urlencoded',
};
const body = await req.text();
try {
const res = await axios({
url: targetUrl,
method: 'POST',
headers,
data: body,
});
return NextResponse.json(res.data);
} catch (err) {
const msg = err.response?.data?.message || err.response?.data || err.message;
return NextResponse.json({ error: msg }, { status: 502 });
}
}

View File

@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { getNodes, getVMs } from '@/lib/proxmox';
const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || process.env.PROXMOX_API_URL?.replace('/api2/json', '');
const PROXMOX_CONSOLE_TOKEN = process.env.PROXMOX_CONSOLE_TOKEN || process.env.PROXMOX_TOKEN_ID;
/** @param {import('next').Request} _req */
export async function GET(_req) {
@@ -16,7 +17,7 @@ export async function GET(_req) {
...vms.map((vm) => ({
...vm,
node: node.name,
consoleUrl: `${PROXMOX_WEB_URL}/?console=qemu&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${vm.vmid}`,
consoleUrl: `${PROXMOX_WEB_URL}/?console=qemu&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${vm.vmid}&token=${encodeURIComponent(PROXMOX_CONSOLE_TOKEN)}`,
})),
);
} catch { /* skip node */ }

45
src/app/api/vnc/route.js Normal file
View File

@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import axios from 'axios';
const PROXMOX_API_URL = process.env.PROXMOX_API_URL;
const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || PROXMOX_API_URL?.replace('/api2/json', '');
/** @param {import('next').Request} req */
export async function GET(req) {
const url = new URL(req.url);
const node = url.searchParams.get('node');
const vmid = url.searchParams.get('vmid');
const type = url.searchParams.get('type');
if (!node || !vmid || !type) {
return NextResponse.json({ error: 'Missing node, vmid, or type' }, { status: 400 });
}
try {
const ticketRes = await axios({
url: `${PROXMOX_API_URL}/nodes/${node}/${type}/${vmid}/vncproxy`,
method: 'POST',
headers: {
Authorization: `PVEAPIToken=${process.env.PROXMOX_TOKEN_ID}=${process.env.PROXMOX_TOKEN_SECRET}`,
'Content-Type': 'application/json',
},
data: JSON.stringify({ websocket: 0 }),
});
const ticket = ticketRes.data?.ticket;
if (!ticket) {
return NextResponse.json(
{ error: 'No VNC ticket returned from Proxmox API' },
{ status: 502 },
);
}
const consoleUrl = `${PROXMOX_WEB_URL}/?vncticket=${encodeURIComponent(ticket)}&novnc=1&node=${encodeURIComponent(node)}&vmid=${vmid}`;
return NextResponse.json({ consoleUrl });
} catch (err) {
const msg = err.response?.data?.message || err.response?.data || err.message;
return NextResponse.json({ error: msg }, { status: 502 });
}
}

View File

@@ -0,0 +1,43 @@
'use client';
import { useState } from 'react';
/**
* @param {{ vmid: number; name: string; node: string; type: string; consoleUrl: string }} vm
* @param {() => void} onClose
* @returns {import('react').ReactNode}
*/
export default function ConsolePanel({ vm, onClose }) {
const [loading, setLoading] = useState(true);
return (
<div className="fixed bottom-0 left-0 right-0 z-50 flex h-[60vh] flex-col rounded-t-xl border border-border-card bg-bg-card shadow-2xl sm:right-4">
{/* Header */}
<div className="flex items-center justify-between border-b border-border-card px-4 py-2">
<div className="flex items-center gap-2 text-sm font-medium text-text-primary">
<span>Console</span>
<span className="text-[10px] text-text-secondary">
({vm.type.toUpperCase()} {vm.vmid} on {vm.node})
</span>
</div>
<div className="flex items-center gap-2">
{loading && <span className="text-xs text-text-secondary">Loading</span>}
<button
onClick={onClose}
className="rounded-md border border-border-card bg-bg-card px-2 py-1 text-xs text-text-secondary hover:text-text-primary"
>
Close
</button>
</div>
</div>
{/* Frame */}
<iframe
src={vm.consoleUrl}
title={`${vm.name} console`}
className="h-full w-full border-0"
onLoad={() => setLoading(false)}
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
/>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import VMList from './VMList';
import LXCList from './LXCList';
import axios from 'axios';
import { SWRConfig } from 'swr';
import ConsolePanel from './ConsolePanel';
const POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000);
@@ -31,6 +32,7 @@ async function fetchData(url) {
/** @returns {import('react').ReactNode} */
export default function Dashboard() {
const [activeTab, setActiveTab] = useState('nodes');
const [activeConsole, setActiveConsole] = useState(null);
const [powerActionStatus, setPowerActionStatus] = useState(null);
/**
@@ -71,6 +73,7 @@ export default function Dashboard() {
{ id: 'nodes', label: 'Nodes' },
{ id: 'vms', label: 'VMs' },
{ id: 'lxc', label: 'LXCs' },
...(activeConsole ? [{ id: 'console', label: `Console: ${activeConsole.name}` }] : []),
];
return (
@@ -147,8 +150,11 @@ export default function Dashboard() {
{/* Tab content */}
<div>
{activeTab === 'nodes' && <NodeList nodes={nodes} />}
{activeTab === 'vms' && <VMList vms={vms} onPowerAction={handlePowerAction} />}
{activeTab === 'lxc' && <LXCList lxc={lxc} onPowerAction={handlePowerAction} />}
{activeTab === 'vms' && <VMList vms={vms} onPowerAction={handlePowerAction} onOpenConsole={(vm) => { setActiveConsole(vm); setActiveTab('console'); }} />}
{activeTab === 'lxc' && <LXCList lxc={lxc} onPowerAction={handlePowerAction} onOpenConsole={(lxc) => { setActiveConsole(lxc); setActiveTab('console'); }} />}
{activeTab === 'console' && activeConsole && (
<ConsolePanel vm={activeConsole} onClose={() => setActiveConsole(null)} />
)}
</div>
{/* Footer */}

View File

@@ -5,9 +5,9 @@ import StatusBadge from './StatusBadge';
* @returns {import('react').ReactNode}
*/
/**
* @param {{ lxc: Array<{ vmid: number; name: string; type: string; status: string; cpu: number; memory: { total: number; used: number }; disk: { total: number; used: number }; node: string }>, onPowerAction: (node: string, vmid: number, type: string, action: string) => void }} props
* @param {{ lxc: Array<{ vmid: number; name: string; type: string; status: string; cpu: number; memory: { total: number; used: number }; disk: { total: number; used: number }; node: string; consoleUrl?: string }>, onPowerAction: (node: string, vmid: number, type: string, action: string) => void }} props
*/
export default function LXCList({ lxc, onPowerAction }) {
export default function LXCList({ lxc, onPowerAction, onOpenConsole }) {
if (!lxc?.length) {
return <p className="text-text-secondary text-sm">No LXC containers found.</p>;
}
@@ -36,26 +36,18 @@ export default function LXCList({ lxc, onPowerAction }) {
{running.map((item) => (
<tr key={item.vmid} className="border-b border-border-card last:border-0">
<td className="px-4 py-2 text-text-secondary">{item.vmid}</td>
<td className="px-4 py-2">
{item.consoleUrl ? (
<a
href={item.consoleUrl}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-text-primary hover:text-accent"
>
{item.name}
<span className="ml-1 text-[10px] text-text-secondary">[]</span>
</a>
) : (
<span className="font-medium text-text-primary">{item.name}</span>
)}
</td>
<td className="px-4 py-2 font-medium text-text-primary">{item.name}</td>
<td className="px-4 py-2 text-text-secondary">{item.node}</td>
<td className="px-4 py-2 text-text-secondary">{(item.cpu * 100).toFixed(1)}%</td>
<td className="px-4 py-2 text-text-secondary">{formatBytes(item.memory?.used || 0)} / {formatBytes(item.memory?.total || 0)}</td>
<td className="px-4 py-2"><StatusBadge status={item.status} /></td>
<td className="px-4 py-2">
<button
onClick={() => onOpenConsole?.(item)}
className="mr-2 rounded-md border border-border-card bg-bg-card px-2 py-1 text-xs text-text-primary hover:border-accent hover:text-accent"
>
Console
</button>
<button
onClick={() => onPowerAction(item.node, item.vmid, 'lxc', 'restart')}
className="mr-2 rounded-md border border-border-card bg-bg-card px-2 py-1 text-xs text-text-primary hover:border-accent hover:text-accent"
@@ -96,25 +88,17 @@ export default function LXCList({ lxc, onPowerAction }) {
{stopped.map((item) => (
<tr key={item.vmid} className="border-b border-border-card last:border-0">
<td className="px-4 py-2 text-text-secondary">{item.vmid}</td>
<td className="px-4 py-2">
{item.consoleUrl ? (
<a
href={item.consoleUrl}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-text-primary hover:text-accent"
>
{item.name}
<span className="ml-1 text-[10px] text-text-secondary">[]</span>
</a>
) : (
<span className="font-medium text-text-primary">{item.name}</span>
)}
</td>
<td className="px-4 py-2 font-medium text-text-primary">{item.name}</td>
<td className="px-4 py-2 text-text-secondary">{item.node}</td>
<td className="px-4 py-2 text-text-secondary">{formatBytes(item.disk?.used || 0)}</td>
<td className="px-4 py-2"><StatusBadge status={item.status} /></td>
<td className="px-4 py-2">
<button
onClick={() => onOpenConsole?.(item)}
className="mr-2 rounded-md border border-border-card bg-bg-card px-2 py-1 text-xs text-text-primary hover:border-accent hover:text-accent"
>
Console
</button>
<button
onClick={() => onPowerAction(item.node, item.vmid, 'lxc', 'start')}
className="rounded-md border border-status-green/30 bg-status-green/10 px-2 py-1 text-xs text-status-green hover:bg-status-green/20"

View File

@@ -5,9 +5,9 @@ import StatusBadge from './StatusBadge';
* @returns {import('react').ReactNode}
*/
/**
* @param {{ vms: Array<{ vmid: number; name: string; type: string; status: string; cpu: number; memory: { total: number; used: number }; disk: { total: number; used: number }; node: string }>, onPowerAction: (node: string, vmid: number, type: string, action: string) => void }} props
* @param {{ vms: Array<{ vmid: number; name: string; type: string; status: string; cpu: number; memory: { total: number; used: number }; disk: { total: number; used: number }; node: string; consoleUrl?: string }>, onPowerAction: (node: string, vmid: number, type: string, action: string) => void }} props
*/
export default function VMList({ vms, onPowerAction }) {
export default function VMList({ vms, onPowerAction, onOpenConsole }) {
if (!vms?.length) {
return <p className="text-text-secondary text-sm">No VMs found.</p>;
}
@@ -37,26 +37,18 @@ export default function VMList({ vms, onPowerAction }) {
{running.map((vm) => (
<tr key={vm.vmid} className="border-b border-border-card last:border-0">
<td className="px-4 py-2 text-text-secondary">{vm.vmid}</td>
<td className="px-4 py-2">
{vm.consoleUrl ? (
<a
href={vm.consoleUrl}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-text-primary hover:text-accent"
>
{vm.name}
<span className="ml-1 text-[10px] text-text-secondary">[]</span>
</a>
) : (
<span className="font-medium text-text-primary">{vm.name}</span>
)}
</td>
<td className="px-4 py-2 font-medium text-text-primary">{vm.name}</td>
<td className="px-4 py-2 text-text-secondary">{vm.node}</td>
<td className="px-4 py-2 text-text-secondary">{(vm.cpu * 100).toFixed(1)}%</td>
<td className="px-4 py-2 text-text-secondary">{formatBytes(vm.memory?.used || 0)} / {formatBytes(vm.memory?.total || 0)}</td>
<td className="px-4 py-2"><StatusBadge status={vm.status} /></td>
<td className="px-4 py-2">
<button
onClick={() => onOpenConsole?.(vm)}
className="mr-2 rounded-md border border-border-card bg-bg-card px-2 py-1 text-xs text-text-primary hover:border-accent hover:text-accent"
>
Console
</button>
<button
onClick={() => onPowerAction(vm.node, vm.vmid, 'qemu', 'restart')}
className="mr-2 rounded-md border border-border-card bg-bg-card px-2 py-1 text-xs text-text-primary hover:border-accent hover:text-accent"
@@ -97,25 +89,17 @@ export default function VMList({ vms, onPowerAction }) {
{stopped.map((vm) => (
<tr key={vm.vmid} className="border-b border-border-card last:border-0">
<td className="px-4 py-2 text-text-secondary">{vm.vmid}</td>
<td className="px-4 py-2">
{vm.consoleUrl ? (
<a
href={vm.consoleUrl}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-text-primary hover:text-accent"
>
{vm.name}
<span className="ml-1 text-[10px] text-text-secondary">[]</span>
</a>
) : (
<span className="font-medium text-text-primary">{vm.name}</span>
)}
</td>
<td className="px-4 py-2 font-medium text-text-primary">{vm.name}</td>
<td className="px-4 py-2 text-text-secondary">{vm.node}</td>
<td className="px-4 py-2 text-text-secondary">{formatBytes(vm.disk?.used || 0)}</td>
<td className="px-4 py-2"><StatusBadge status={vm.status} /></td>
<td className="px-4 py-2">
<button
onClick={() => onOpenConsole?.(vm)}
className="mr-2 rounded-md border border-border-card bg-bg-card px-2 py-1 text-xs text-text-primary hover:border-accent hover:text-accent"
>
Console
</button>
<button
onClick={() => onPowerAction(vm.node, vm.vmid, 'qemu', 'start')}
className="rounded-md border border-status-green/30 bg-status-green/10 px-2 py-1 text-xs text-status-green hover:bg-status-green/20"