8 Commits
Author SHA1 Message Date
aria.hacker a53e9006a5 Fix SPICE CA cert not applied: .vv format needs inline ca=, not tls-ca-file=<path> 2026-07-06 22:10:41 +02:00
aria.hacker aa5383d826 Fix SPICE proxy tunnel: proxy URL was written into host= instead of a real proxy= line
Proxmox's spiceproxy API returns proxy as a ready-made "http://<addr>:3128"
URL and host as the real (often cluster-internal) SPICE target. buildVVFile
picked params.proxy first and wrote that whole URL into the .vv file's
host= field, and never emitted a proxy= line at all — so virt-viewer tried
to dial "http://<ip>:3128" as a literal hostname instead of tunneling
through the proxy, and every connection failed.

Bump to 1.0.7.
2026-07-06 21:46:07 +02:00
aria.hacker a299eaf925 Always render VM list; surface SPICE connect target for diagnosis
- Auto-connect (single-VM shortcut and autostart match) now renders the
  VM list first instead of skipping straight to the connect attempt.
  With cluster dedup collapsing 3 duplicate entries down to 1, the VM
  vanished from the screen entirely whenever the connection failed.
- proxmox:connect now returns the resolved host:port it handed to
  remote-viewer, shown in the success notice, so connection failures
  (e.g. Proxmox handing out a cluster-internal node address the client
  cannot route to) can be diagnosed without racing delete-this-file.
2026-07-06 20:45:50 +02:00
aria.hacker 0968468978 Dedup VMs across real Proxmox cluster nodes (was listing each VM once per entered host) 2026-07-06 20:02:57 +02:00
aria.hacker 7fbac7c18a Add per-VM autostart checkbox (single-select, persisted)
Only one VM can be marked for autostart at a time — checking a new
one unchecks the previous. Matched by host+vmid since the same vmid
can exist on multiple standalone Proxmox hosts in a cluster setup.
2026-07-06 13:43:38 +02:00
aria.hacker 5c4e3781fd Bump version to 1.0.3 2026-07-06 12:19:52 +02:00
aria.hacker f4828f73b9 Fix broken VirtViewer download links (virt-manager.org dead, use gitlab.com release) 2026-07-06 11:01:30 +02:00
aria.hacker ebb405ab9b Support multiple standalone Proxmox hosts with shared credentials
Comma-separated host list logs into every host, merges VM lists tagged
by origin host, and routes SPICE connections to the right one. Also
adds a username format hint (user@realm) on the login form.
2026-07-06 10:29:44 +02:00
8 changed files with 226 additions and 37 deletions
+3 -3
View File
@@ -18,7 +18,7 @@ Jetzt automatisch herunterladen und installieren (ca. 30 MB)?" \
DetailPrint "Lade VirtViewer herunter..." DetailPrint "Lade VirtViewer herunter..."
nsExec::ExecToLog 'powershell.exe -NonInteractive -Command \ nsExec::ExecToLog 'powershell.exe -NonInteractive -Command \
"Invoke-WebRequest \ "Invoke-WebRequest \
-Uri \"https://virt-manager.org/download/virt-viewer-x64.msi\" \ -Uri \"https://gitlab.com/virt-viewer/virt-viewer/-/releases/v11.0/downloads/virt-viewer-x64-11.0-1.0.msi\" \
-OutFile \"$TEMP\virt-viewer-setup.msi\" \ -OutFile \"$TEMP\virt-viewer-setup.msi\" \
-UseBasicParsing"' -UseBasicParsing"'
Pop $0 Pop $0
@@ -26,7 +26,7 @@ Jetzt automatisch herunterladen und installieren (ca. 30 MB)?" \
MessageBox MB_OK|MB_ICONEXCLAMATION \ MessageBox MB_OK|MB_ICONEXCLAMATION \
"Download fehlgeschlagen (Fehlercode: $0).$\n$\n\ "Download fehlgeschlagen (Fehlercode: $0).$\n$\n\
Bitte VirtViewer nach der Installation manuell installieren:$\n\ Bitte VirtViewer nach der Installation manuell installieren:$\n\
https://virt-manager.org/download/" https://gitlab.com/virt-viewer/virt-viewer/-/releases/v11.0"
Goto vv_skip Goto vv_skip
${EndIf} ${EndIf}
@@ -40,7 +40,7 @@ https://virt-manager.org/download/"
MessageBox MB_OK|MB_ICONEXCLAMATION \ MessageBox MB_OK|MB_ICONEXCLAMATION \
"VirtViewer-Installation schlug fehl (Code: $0).$\n$\n\ "VirtViewer-Installation schlug fehl (Code: $0).$\n$\n\
Bitte nach Abschluss manuell installieren:$\n\ Bitte nach Abschluss manuell installieren:$\n\
https://virt-manager.org/download/" https://gitlab.com/virt-viewer/virt-viewer/-/releases/v11.0"
${EndIf} ${EndIf}
Goto vv_skip Goto vv_skip
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxmox-spice-client", "name": "proxmox-spice-client",
"version": "1.0.1", "version": "1.0.8",
"description": "VDI SPICE Client f\u00fcr Proxmox", "description": "VDI SPICE Client f\u00fcr Proxmox",
"main": "src/main.js", "main": "src/main.js",
"scripts": { "scripts": {
+121 -22
View File
@@ -8,7 +8,10 @@ const ProxmoxClient = require('./proxmox');
const store = new Store({ name: 'proxmox-spice-client' }); const store = new Store({ name: 'proxmox-spice-client' });
let mainWindow; let mainWindow;
let client; // ProxmoxClient instance (persists across login → VM views) // One ProxmoxClient per logged-in host — several standalone Proxmox hosts can
// share the same root@pam credentials without being a real PVE cluster, so we
// log into every host the user entered and merge their VM lists.
let clients = [];
// ---------- Window management ---------- // ---------- Window management ----------
@@ -120,36 +123,108 @@ ipcMain.handle('credentials:clear', () => {
// ---------- IPC: Proxmox ---------- // ---------- IPC: Proxmox ----------
ipcMain.handle('proxmox:login', async (_e, { host, username, password }) => { ipcMain.handle('proxmox:login', async (_e, { host, username, password }) => {
try { const hosts = String(host || '')
client = new ProxmoxClient(host); .split(',')
await client.login(username, password); .map((h) => h.trim())
createWindow('vms.html', 680, 520, true); .filter(Boolean);
return { success: true };
} catch (err) { if (hosts.length === 0) {
return { success: false, error: err.message }; return { success: false, error: 'Bitte mindestens einen Host angeben.' };
} }
const attempts = await Promise.all(
hosts.map(async (h) => {
const c = new ProxmoxClient(h);
try {
await c.login(username, password);
return { host: h, client: c, success: true };
} catch (err) {
return { host: h, success: false, error: err.message };
}
})
);
const succeeded = attempts.filter((a) => a.success);
const failed = attempts.filter((a) => !a.success);
if (succeeded.length === 0) {
// Single host → show the real error. Multiple hosts, all failing → the
// per-host errors are usually identical (wrong creds), so the first is enough.
return { success: false, error: attempts[0].error };
}
// Several hostnames can point at nodes of the SAME real Proxmox cluster
// (e.g. Stefan's setup) — in that case /cluster/resources returns the
// identical cluster-wide VM list from every one of them, so keeping all
// logged-in clients would just show each VM 3x. Detect real cluster
// membership and keep only one representative client per cluster.
const seenGroups = new Set();
const dedupedClients = [];
await Promise.all(
succeeded.map(async (entry) => {
try {
entry.clusterId = await entry.client.getClusterId();
} catch {
entry.clusterId = null;
}
})
);
for (const entry of succeeded) {
const group = entry.clusterId || `standalone:${entry.host}`;
if (seenGroups.has(group)) continue;
seenGroups.add(group);
dedupedClients.push(entry);
}
clients = dedupedClients;
createWindow('vms.html', 680, 520, true);
if (failed.length > 0) {
setImmediate(() => {
dialog.showMessageBox(mainWindow, {
type: 'warning',
title: 'Nicht alle Hosts erreichbar',
message: `${failed.length} von ${hosts.length} Host(s) konnten nicht angemeldet werden.`,
detail: failed.map((f) => `${f.host}: ${f.error}`).join('\n'),
buttons: ['OK'],
});
});
}
return { success: true };
}); });
ipcMain.handle('proxmox:getVMs', async () => { ipcMain.handle('proxmox:getVMs', async () => {
try { try {
const vms = await client.getSpiceVMs(); const perHost = await Promise.all(
return { success: true, vms }; clients.map(async ({ host, client }) => {
try {
const vms = await client.getSpiceVMs();
return vms.map((vm) => ({ ...vm, host }));
} catch {
return [];
}
})
);
return { success: true, vms: perHost.flat() };
} catch (err) { } catch (err) {
return { success: false, error: err.message }; return { success: false, error: err.message };
} }
}); });
ipcMain.handle('proxmox:logout', () => { ipcMain.handle('proxmox:logout', () => {
client = null; clients = [];
createWindow('login.html', 460, 500, false); createWindow('login.html', 460, 500, false);
}); });
ipcMain.handle('proxmox:connect', async (_e, { node, vmid }) => { ipcMain.handle('proxmox:connect', async (_e, { host, node, vmid }) => {
try { try {
const params = await client.getSpiceTicket(node, vmid); const entry = clients.find((c) => c.host === host) || clients[0];
const vvPath = buildVVFile(params, vmid); if (!entry) throw new Error('Nicht angemeldet.');
const params = await entry.client.getSpiceTicket(node, vmid);
const { vvPath, target } = buildVVFile(params, vmid, entry.client);
launchRemoteViewer(vvPath); launchRemoteViewer(vvPath);
return { success: true }; return { success: true, target };
} catch (err) { } catch (err) {
return { success: false, error: err.message }; return { success: false, error: err.message };
} }
@@ -177,22 +252,41 @@ ipcMain.handle('settings:browseViewerPath', async () => {
return chosen; return chosen;
}); });
// Only one VM can be marked for autostart at a time — identified by
// host+vmid since the same vmid can exist on several hosts.
ipcMain.handle('settings:getAutoConnect', () => store.get('autoConnect', null));
ipcMain.handle('settings:setAutoConnect', (_e, target) => {
if (target) store.set('autoConnect', target);
else store.delete('autoConnect');
});
// ---------- SPICE helpers ---------- // ---------- SPICE helpers ----------
function buildVVFile(params, vmid) { function buildVVFile(params, vmid, client) {
const lines = ['[virt-viewer]', `type=${params.type || 'spice'}`]; const lines = ['[virt-viewer]', `type=${params.type || 'spice'}`];
const host = params.proxy || params.host || client.host.split(':')[0]; // params.host = the real SPICE target (often a cluster-internal node
// address) and params.proxy = a ready-made "http://<reachable-host>:3128"
// URL that virt-viewer tunnels through via HTTP CONNECT. They are two
// different .vv fields — writing params.proxy into host= (as before)
// handed virt-viewer a full URL as a hostname and never opened the
// tunnel, so it tried (and failed) to dial the internal address directly.
const host = params.host || client.host.split(':')[0];
lines.push(`host=${host}`); lines.push(`host=${host}`);
if (params['tls-port']) lines.push(`tls-port=${params['tls-port']}`); if (params['tls-port']) lines.push(`tls-port=${params['tls-port']}`);
if (params.port) lines.push(`port=${params.port}`); if (params.port) lines.push(`port=${params.port}`);
if (params.password) lines.push(`password=${params.password}`); if (params.password) lines.push(`password=${params.password}`);
if (params.proxy) lines.push(`proxy=${params.proxy}`);
// virt-viewer's .vv format only understands the CA inline via `ca=`, with
// newlines escaped as literal "\n" -- there is no `tls-ca-file=<path>` key,
// so pointing at a temp .pem file was silently ignored and left the
// self-signed cluster CA unverified, failing the TLS handshake.
if (params.ca) { if (params.ca) {
const caPath = path.join(os.tmpdir(), 'proxmox-spice-ca.pem'); const caInline = String(params.ca).replace(/\r\n|\r|\n/g, '\\n');
fs.writeFileSync(caPath, params.ca); lines.push(`ca=${caInline}`);
lines.push(`tls-ca-file=${caPath}`);
} }
if (params['host-subject']) lines.push(`host-subject=${params['host-subject']}`); if (params['host-subject']) lines.push(`host-subject=${params['host-subject']}`);
@@ -206,7 +300,12 @@ function buildVVFile(params, vmid) {
const vvPath = path.join(os.tmpdir(), `spice-${vmid}-${Date.now()}.vv`); const vvPath = path.join(os.tmpdir(), `spice-${vmid}-${Date.now()}.vv`);
fs.writeFileSync(vvPath, lines.join('\n') + '\n', { mode: 0o600 }); fs.writeFileSync(vvPath, lines.join('\n') + '\n', { mode: 0o600 });
return vvPath;
// Surfaced to the renderer so a failed connection is diagnosable without
// having to catch the .vv file before remote-viewer deletes it.
const port = params['tls-port'] || params.port || '?';
const target = params.proxy ? `${host}:${port} via ${params.proxy}` : `${host}:${port}`;
return { vvPath, target };
} }
// VirtViewer's Windows installer names its folder after the bundled version // VirtViewer's Windows installer names its folder after the bundled version
@@ -251,7 +350,7 @@ function launchRemoteViewer(vvPath) {
if (i >= candidates.length) { if (i >= candidates.length) {
const hint = const hint =
process.platform === 'win32' process.platform === 'win32'
? 'Windows: https://virt-manager.org/download/\n\n' + ? 'Windows: https://gitlab.com/virt-viewer/virt-viewer/-/releases/v11.0\n\n' +
'Falls VirtViewer an einem nicht-standardmäßigen Ort installiert ist, ' + 'Falls VirtViewer an einem nicht-standardmäßigen Ort installiert ist, ' +
'trage den Pfad unter „Einstellungen → Viewer-Pfad" ein.' 'trage den Pfad unter „Einstellungen → Viewer-Pfad" ein.'
: 'Linux: sudo apt install virt-viewer'; : 'Linux: sudo apt install virt-viewer';
+2
View File
@@ -17,5 +17,7 @@ contextBridge.exposeInMainWorld('api', {
getViewerPath: () => ipcRenderer.invoke('settings:getViewerPath'), getViewerPath: () => ipcRenderer.invoke('settings:getViewerPath'),
browseViewerPath: () => ipcRenderer.invoke('settings:browseViewerPath'), browseViewerPath: () => ipcRenderer.invoke('settings:browseViewerPath'),
clearViewerPath: () => ipcRenderer.invoke('settings:clearViewerPath'), clearViewerPath: () => ipcRenderer.invoke('settings:clearViewerPath'),
getAutoConnect: () => ipcRenderer.invoke('settings:getAutoConnect'),
setAutoConnect: (target) => ipcRenderer.invoke('settings:setAutoConnect', target),
}, },
}); });
+10
View File
@@ -121,6 +121,16 @@ class ProxmoxClient {
return vga.startsWith('qxl') || vga === 'virtio-vga-gl'; return vga.startsWith('qxl') || vga === 'virtio-vga-gl';
} }
// Real Proxmox clusters: every member node returns the identical
// cluster-wide resource list, so logging into several nodes of the same
// cluster would just duplicate every VM. Returns the cluster's name, or
// null if this host isn't part of a cluster (standalone).
async getClusterId() {
const status = await this.request('GET', '/cluster/status');
const cluster = (status || []).find((s) => s.type === 'cluster');
return cluster ? cluster.name || cluster.id || null : null;
}
async getSpiceTicket(node, vmid) { async getSpiceTicket(node, vmid) {
// proxy = address clients should connect to for SPICE traffic // proxy = address clients should connect to for SPICE traffic
const proxy = this.host.split(':')[0]; const proxy = this.host.split(':')[0];
+4 -2
View File
@@ -17,12 +17,14 @@
<form id="loginForm" autocomplete="on"> <form id="loginForm" autocomplete="on">
<div class="field"> <div class="field">
<label for="host">Proxmox Host</label> <label for="host">Proxmox Host(s)</label>
<input type="text" id="host" placeholder="pve.example.com" autocomplete="url" spellcheck="false"> <input type="text" id="host" placeholder="pve.example.com" autocomplete="url" spellcheck="false">
<div class="field-hint">Mehrere eigenständige Hosts mit denselben Zugangsdaten? Mit Komma trennen, z. B. pve1.example.com, pve2.example.com</div>
</div> </div>
<div class="field"> <div class="field">
<label for="username">Benutzer</label> <label for="username">Benutzer</label>
<input type="text" id="username" placeholder="user@pam" autocomplete="username" spellcheck="false"> <input type="text" id="username" placeholder="root@pam" autocomplete="username" spellcheck="false">
<div class="field-hint">Format: Benutzer@Realm — z. B. root@pam</div>
</div> </div>
<div class="field"> <div class="field">
<label for="password">Passwort</label> <label for="password">Passwort</label>
+21
View File
@@ -76,6 +76,13 @@ body.login-page {
margin-bottom: 0.4rem; margin-bottom: 0.4rem;
} }
.field-hint {
font-size: 0.72rem;
color: var(--muted);
margin-top: 0.35rem;
line-height: 1.4;
}
input[type="text"], input[type="text"],
input[type="password"] { input[type="password"] {
width: 100%; width: 100%;
@@ -243,6 +250,20 @@ body.vms-page {
.vm-info { flex: 1; min-width: 0; } .vm-info { flex: 1; min-width: 0; }
.vm-autostart {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
color: var(--muted);
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
user-select: none;
}
.vm-autostart input { cursor: pointer; }
.vm-name { .vm-name {
font-size: 0.97rem; font-size: 0.97rem;
font-weight: 600; font-weight: 600;
+64 -9
View File
@@ -76,6 +76,15 @@ async function loadVMs() {
return; return;
} }
const autoConnect = await api.settings.getAutoConnect();
const autoMatch = autoConnect && vms.find(
(vm) => vm.host === autoConnect.host && String(vm.vmid) === String(autoConnect.vmid)
);
// Render the list first — even when we're about to auto-connect, Stefan
// needs to see the VM (and reach Settings/retry) if that connection fails.
renderList(vms, autoConnect);
// Auto-connect when there is exactly one SPICE VM // Auto-connect when there is exactly one SPICE VM
if (vms.length === 1) { if (vms.length === 1) {
showNotice(`Nur eine VM verfügbar — verbinde mit „${vms[0].name}" …`); showNotice(`Nur eine VM verfügbar — verbinde mit „${vms[0].name}" …`);
@@ -83,42 +92,87 @@ async function loadVMs() {
return; return;
} }
renderList(vms); // Auto-connect the VM the user marked for autostart (only one at a time)
if (autoMatch) {
showNotice(`Autostart-VM „${autoMatch.name}" markiert — verbinde …`);
await connectVM(autoMatch);
return;
}
} }
loadVMs(); loadVMs();
// ── Render ──────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────
function renderList(vms) { function renderList(vms, autoConnect) {
// Only show the host when there's more than one — single-host setups don't need the noise.
const multiHost = new Set(vms.map((vm) => vm.host)).size > 1;
listEl.innerHTML = vms listEl.innerHTML = vms
.map( .map((vm) => {
(vm) => ` const isAutostart = !!(
autoConnect &&
autoConnect.host === vm.host &&
String(autoConnect.vmid) === String(vm.vmid)
);
return `
<div class="vm-card" data-vmid="${vm.vmid}"> <div class="vm-card" data-vmid="${vm.vmid}">
<div class="vm-info"> <div class="vm-info">
<div class="vm-name">${esc(vm.name)}</div> <div class="vm-name">${esc(vm.name)}</div>
<div class="vm-meta"> <div class="vm-meta">
<span class="status-dot"></span> <span class="status-dot"></span>
Läuft &nbsp;·&nbsp; Node: ${esc(vm.node)} &nbsp;·&nbsp; ID: ${vm.vmid} Läuft &nbsp;·&nbsp; ${multiHost ? `Host: ${esc(vm.host)} &nbsp;·&nbsp; ` : ''}Node: ${esc(vm.node)} &nbsp;·&nbsp; ID: ${vm.vmid}
</div> </div>
</div> </div>
<label class="vm-autostart" title="Beim Start automatisch mit dieser VM verbinden">
<input
type="checkbox"
class="autostart-checkbox"
data-vmid="${vm.vmid}"
data-host="${esc(vm.host)}"
data-name="${esc(vm.name)}"
${isAutostart ? 'checked' : ''}
>
Autostart
</label>
<button <button
class="btn-connect" class="btn-connect"
data-vmid="${vm.vmid}" data-vmid="${vm.vmid}"
data-host="${esc(vm.host)}"
data-node="${esc(vm.node)}" data-node="${esc(vm.node)}"
data-name="${esc(vm.name)}" data-name="${esc(vm.name)}"
>Verbinden</button> >Verbinden</button>
</div> </div>
` `;
) })
.join(''); .join('');
listEl.querySelectorAll('.btn-connect').forEach((btn) => { listEl.querySelectorAll('.btn-connect').forEach((btn) => {
btn.addEventListener('click', () => btn.addEventListener('click', () =>
connectVM({ vmid: btn.dataset.vmid, node: btn.dataset.node, name: btn.dataset.name }) connectVM({
vmid: btn.dataset.vmid,
host: btn.dataset.host,
node: btn.dataset.node,
name: btn.dataset.name,
})
); );
}); });
listEl.querySelectorAll('.autostart-checkbox').forEach((cb) => {
cb.addEventListener('change', async () => {
if (cb.checked) {
// Only one VM can be the autostart VM — uncheck any other.
listEl.querySelectorAll('.autostart-checkbox').forEach((other) => {
if (other !== cb) other.checked = false;
});
await api.settings.setAutoConnect({ host: cb.dataset.host, vmid: cb.dataset.vmid });
showNotice(`${cb.dataset.name}" wird beim nächsten Start automatisch verbunden.`);
} else {
await api.settings.setAutoConnect(null);
}
});
});
listEl.classList.remove('hidden'); listEl.classList.remove('hidden');
} }
@@ -135,7 +189,8 @@ async function connectVM(vm) {
if (!result.success) { if (!result.success) {
showError(result.error || 'Verbindung fehlgeschlagen.'); showError(result.error || 'Verbindung fehlgeschlagen.');
} else { } else {
showNotice(`${vm.name}" — SPICE-Sitzung gestartet.`); const targetInfo = result.target ? ` (Ziel: ${result.target})` : '';
showNotice(`${vm.name}" — SPICE-Sitzung gestartet${targetInfo}.`);
} }
} }