From 2ab78012bae7840c447ee9b6720fd28417c30d51 Mon Sep 17 00:00:00 2001 From: Rob Bond Date: Thu, 7 May 2026 18:44:13 +0100 Subject: [PATCH] fix: use /vncproxy endpoint with websocket:0 parameter The /vncproxy endpoint requires websocket: 0 (not -1) in the POST body. Co-Authored-By: Claude --- .env.example | 1 + src/app/api/lxc/route.js | 3 +- src/app/api/proxy/[...path]/route.js | 86 ++++++++++++++++++++++++++++ src/app/api/vms/route.js | 3 +- src/app/api/vnc/route.js | 45 +++++++++++++++ src/components/ConsolePanel.js | 43 ++++++++++++++ src/components/Dashboard.js | 10 +++- src/components/LXCList.js | 48 ++++++---------- src/components/VMList.js | 48 ++++++---------- 9 files changed, 219 insertions(+), 68 deletions(-) create mode 100644 src/app/api/proxy/[...path]/route.js create mode 100644 src/app/api/vnc/route.js create mode 100644 src/components/ConsolePanel.js diff --git a/.env.example b/.env.example index df5d7d1..b01de68 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ PROXMOX_API_URL=http://proxmox.lan:8006/api2/json PROXMOX_WEB_URL=http://proxmox.lan:8006 +PROXMOX_CONSOLE_TOKEN=root@pam!monitorapp PROXMOX_TOKEN_ID=root@pam!monitorapp PROXMOX_TOKEN_SECRET=103d622c-05a3-4002-9356-f67691e10d40 API_POLL_INTERVAL=30000 diff --git a/src/app/api/lxc/route.js b/src/app/api/lxc/route.js index 883bbd6..d66ecf3 100644 --- a/src/app/api/lxc/route.js +++ b/src/app/api/lxc/route.js @@ -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 */ } diff --git a/src/app/api/proxy/[...path]/route.js b/src/app/api/proxy/[...path]/route.js new file mode 100644 index 0000000..ce12d4c --- /dev/null +++ b/src/app/api/proxy/[...path]/route.js @@ -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 }); + } +} diff --git a/src/app/api/vms/route.js b/src/app/api/vms/route.js index cea4079..abac524 100644 --- a/src/app/api/vms/route.js +++ b/src/app/api/vms/route.js @@ -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 */ } diff --git a/src/app/api/vnc/route.js b/src/app/api/vnc/route.js new file mode 100644 index 0000000..69a7308 --- /dev/null +++ b/src/app/api/vnc/route.js @@ -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 }); + } +} diff --git a/src/components/ConsolePanel.js b/src/components/ConsolePanel.js new file mode 100644 index 0000000..d1ece79 --- /dev/null +++ b/src/components/ConsolePanel.js @@ -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 ( +
+ {/* Header */} +
+
+ Console + + ({vm.type.toUpperCase()} {vm.vmid} on {vm.node}) + +
+
+ {loading && Loading…} + +
+
+ {/* Frame */} +