Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ dev/vyos/.cache

# local wireguard config
dev/fyra-wg.conf

# local PVE cluster CA, extracted by dev/pve/init-cluster.sh
dev/pve-root-ca.pem
9 changes: 7 additions & 2 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 vite dev",
"dev": "NODE_EXTRA_CA_CERTS=../../dev/pve-root-ca.pem nodemon --watch src --ext ts,js,svelte,json,css -d 1 --exec \"pnpm run build && wrangler dev -c wrangler.local.jsonc --local-protocol http --env-file .env --port 5173\"",
"dev:vite": "NODE_TLS_REJECT_UNAUTHORIZED=0 vite dev",
"build": "vite build",
"preview": "vite preview",
"test:accessibility": "playwright test",
"test:accessibility:ci": "playwright install --with-deps chrome && playwright test",
"cf:dev": "pnpm run build && wrangler dev -c wrangler.local.jsonc --local-protocol http",
"cf:dev": "pnpm run build && NODE_EXTRA_CA_CERTS=../../dev/pve-root-ca.pem wrangler dev -c wrangler.local.jsonc --local-protocol http --env-file .env",
"cf:dev:remote": "pnpm run build && wrangler dev --remote",
"cf:deploy": "wrangler deploy --env=\"\"",
"cf:deploy:preview": "pnpm run build && wrangler deploy --env preview",
Expand Down Expand Up @@ -49,6 +50,7 @@
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.2",
"mode-watcher": "^1.1.0",
"nodemon": "^3.1.14",
"pg": "^8.22.0",
"postcss": "^8.5.19",
"prettier": "^3.9.5",
Expand All @@ -75,6 +77,9 @@
"@better-svelte-email/components": "^2.1.1",
"@better-svelte-email/server": "^2.1.1",
"@plausible-analytics/tracker": "^0.4.5",
"@xterm/addon-attach": "^0.12.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"arktype": "^2.2.3",
"autumn-js": "1.2.33",
"ip-address": "^10.2.0",
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/src/lib/server/backends/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type {
BackendImageImportTarget,
BackendImageImportParams,
VmBackend,
VmConsoleSession,
VmInfo,
VmMetricsHistorySample,
VmMetricsTimeframe,
Expand Down
119 changes: 112 additions & 7 deletions apps/dashboard/src/lib/server/backends/proxmox/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import ky, { HTTPError, type KyInstance } from 'ky';
import type { Fetcher } from '@cloudflare/workers-types';
import { createVpcFetch, insecureDirectFetch } from '$lib/server/vpc';
import type { Fetcher, WebSocket } from '@cloudflare/workers-types';
import { createVpcFetch, insecureDirectFetch, type VpcFetch } from '$lib/server/vpc';
import type {
PveResponse,
PveNode,
Expand All @@ -14,7 +14,8 @@ import type {
PveNextId,
PveCreateQemuParams,
PveClusterResource,
PveAgentNetworkInterface
PveAgentNetworkInterface,
PveTermProxyTicket
} from './types';

export interface ProxmoxClientConfig {
Expand All @@ -27,22 +28,31 @@ export interface ProxmoxClientConfig {

export class ProxmoxClient {
private api: KyInstance;
private rawFetch: VpcFetch;
private authHeader: string;
private apiBaseUrl: string;

constructor(config: ProxmoxClientConfig) {
const { baseUrl, tokenId, tokenSecret, verifySsl = true, vpc } = config;

const usingInsecureDirectFetch = !vpc && !verifySsl;
const directFetch = usingInsecureDirectFetch ? insecureDirectFetch : globalThis.fetch;
const directFetch = usingInsecureDirectFetch
? insecureDirectFetch
: globalThis.fetch.bind(globalThis);

this.authHeader = `PVEAPIToken=${tokenId}=${tokenSecret}`;
this.apiBaseUrl = `${baseUrl.replace(/\/+$/, '')}/api2/json`;
this.rawFetch = createVpcFetch(vpc ? [vpc] : [], directFetch);

this.api = ky.create({
prefix: `${baseUrl.replace(/\/+$/, '')}/api2/json`,
prefix: this.apiBaseUrl,
headers: {
Authorization: `PVEAPIToken=${tokenId}=${tokenSecret}`,
Authorization: this.authHeader,
Accept: 'application/json',
...(usingInsecureDirectFetch ? { 'Accept-Encoding': 'identity' } : {})
},
timeout: 30_000,
fetch: createVpcFetch(vpc ? [vpc] : [], directFetch)
fetch: this.rawFetch
});
}

Expand Down Expand Up @@ -409,6 +419,101 @@ export class ProxmoxClient {
return res.data;
}

// Terminal (xterm.js) console

async createTermProxyTicket(node: string, vmid: number): Promise<PveTermProxyTicket> {
const res = await this.api
.post(`nodes/${encodeURIComponent(node)}/qemu/${vmid}/termproxy`)
.json<PveResponse<PveTermProxyTicket>>();
return res.data;
}

async openTermProxyWebSocket(
node: string,
vmid: number,
ticket: PveTermProxyTicket
): Promise<WebSocket> {
const url =
`${this.apiBaseUrl}/nodes/${encodeURIComponent(node)}/qemu/${vmid}/vncwebsocket` +
`?port=${encodeURIComponent(ticket.port)}&vncticket=${encodeURIComponent(ticket.ticket)}`;

const response = await this.rawFetch(url, {
headers: {
Authorization: this.authHeader,
Upgrade: 'websocket'
}
});

const ws = (response as unknown as { webSocket: WebSocket | null }).webSocket;
if (!ws) {
throw new Error(
`Proxmox termproxy websocket upgrade failed for node "${node}" vmid ${vmid}`
);
}

ws.accept();
ws.binaryType = 'arraybuffer';
return ws;
}

private decodeTermProxyMessage(data: unknown): string {
if (typeof data === 'string') return data;
if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
if (ArrayBuffer.isView(data)) return new TextDecoder().decode(data as Uint8Array);
return String(data);
}

private authenticateTermProxy(socket: WebSocket, ticket: PveTermProxyTicket): Promise<string> {
return new Promise((resolve, reject) => {
const cleanup = () => {
socket.removeEventListener('message', onMessage);
socket.removeEventListener('close', onClose);
};
const onMessage = (event: { data: unknown }) => {
cleanup();
const text = this.decodeTermProxyMessage(event.data);
if (!text.startsWith('OK')) {
reject(new Error(`Proxmox termproxy authentication failed: ${text.slice(0, 200)}`));
return;
}
resolve(text.slice(2));
};
const onClose = () => {
cleanup();
reject(new Error('Proxmox termproxy connection closed before authentication'));
};

socket.addEventListener('message', onMessage);
socket.addEventListener('close', onClose);
socket.send(`${ticket.user}:${ticket.ticket}\n`);
});
}

private sendTermProxyInput(socket: WebSocket, data: string): void {
const byteLength = new TextEncoder().encode(data).length;
socket.send(`0:${byteLength}:${data}`);
}

async openTermProxy(
node: string,
vmid: number
): Promise<{
ticket: PveTermProxyTicket;
socket: WebSocket;
sendInput: (data: string) => void;
initialData: string;
}> {
const ticket = await this.createTermProxyTicket(node, vmid);
const socket = await this.openTermProxyWebSocket(node, vmid, ticket);
const initialData = await this.authenticateTermProxy(socket, ticket);
return {
ticket,
socket,
sendInput: (data: string) => this.sendTermProxyInput(socket, data),
initialData
};
}

// Guest agent

async getNetworkInterfaces(node: string, vmid: number): Promise<PveAgentNetworkInterface[]> {
Expand Down
16 changes: 15 additions & 1 deletion apps/dashboard/src/lib/server/backends/proxmox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
BackendImageImportTarget,
BackendImageImportParams,
VmBackend,
VmConsoleSession,
VmInfo,
VmCreateParams,
VmCreateResult,
Expand Down Expand Up @@ -301,7 +302,9 @@ export class ProxmoxBackend implements VmBackend {
}

const directFetch =
this.options.snippetsEndpointVerifySsl === false ? insecureDirectFetch : globalThis.fetch;
this.options.snippetsEndpointVerifySsl === false
? insecureDirectFetch
: globalThis.fetch.bind(globalThis);
const snippetFetch = createVpcFetch(
this.options.snippetsVpc ? [this.options.snippetsVpc] : [],
directFetch
Expand Down Expand Up @@ -738,4 +741,15 @@ export class ProxmoxBackend implements VmBackend {
const upid = await this.client.rebootVm(node, vmid);
await this.client.waitForTask(node, upid);
}

async openConsole(
id: string,
proxmoxId?: number,
options: Pick<VmLookupOptions, 'proxmoxNode'> = {}
): Promise<VmConsoleSession> {
const { node, vmid } =
this.resolveFromHint(proxmoxId, options.proxmoxNode) ?? (await this.resolve(id, proxmoxId));
const { socket, sendInput, initialData } = await this.client.openTermProxy(node, vmid);
return { socket, sendInput, initialData };
}
}
7 changes: 7 additions & 0 deletions apps/dashboard/src/lib/server/backends/proxmox/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,13 @@ export interface PveStorageContent {
ctime?: number;
}

export interface PveTermProxyTicket {
user: string;
ticket: string;
port: string;
upid: string;
}

export interface PveAgentNetworkInterface {
name: string;
'hardware-address'?: string;
Expand Down
13 changes: 13 additions & 0 deletions apps/dashboard/src/lib/server/backends/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { WebSocket } from '@cloudflare/workers-types';

export type VmStatus = 'running' | 'stopped' | 'paused' | 'unknown';

export interface VmInfo {
Expand Down Expand Up @@ -99,6 +101,12 @@ export interface VmLookupOptions {
includeNetworkInterfaces?: boolean;
}

export interface VmConsoleSession {
socket: WebSocket;
sendInput: (data: string) => void;
initialData: string;
}

export interface VmBackend {
readonly name: string;
ping(): Promise<void>;
Expand Down Expand Up @@ -128,4 +136,9 @@ export interface VmBackend {
node: string,
upid: string
): Promise<{ status: 'running' | 'stopped'; exitstatus?: string }>;
openConsole?(
id: string,
proxmoxId?: number,
options?: Pick<VmLookupOptions, 'proxmoxNode'>
): Promise<VmConsoleSession>;
}
2 changes: 1 addition & 1 deletion apps/dashboard/src/lib/server/vyos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export class VyosClient {

this.apiKey = config.apiKey;

const directFetch = config.verifySsl ? globalThis.fetch : insecureDirectFetch;
const directFetch = config.verifySsl ? globalThis.fetch.bind(globalThis) : insecureDirectFetch;

this.api = ky.create({
prefix: `${config.apiUrl}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
import Terminal from '~icons/nucleo/terminal';
import Trash2 from '~icons/nucleo/trash';
import { getServerWithFallback, serversState } from '$lib/state/servers.svelte';

import VmConsole from './lib/vm-console.svelte';
let { data }: PageProps = $props();
let selectedServer = $derived(getServerWithFallback(data.serverId, data.server));

let copied = $state('');
let liveLoaded = $derived(selectedServer.liveLoaded || serversState.firstStatusRefreshComplete);

Expand Down Expand Up @@ -158,12 +159,6 @@
];
});

const terminalLines = [
{ type: 'prompt' as const, text: 'ls /var/log' },
{ type: 'output' as const, text: 'www.log gamer.log uwaa.log fyra.log chicago.log' },
{ type: 'cursor' as const, text: '' }
];

const currentLogs = [
{
id: 'log-1',
Expand Down Expand Up @@ -304,11 +299,11 @@
<Terminal class="h-3 w-3 text-muted-foreground" />
<span class="text-xs font-semibold text-muted-foreground">Console</span>
</div>
<div
class="min-h-[180px] flex-1 bg-background p-4 font-mono text-sm leading-relaxed text-muted-foreground"
>
<div class="min-h-[180px] flex-1">
{#if !data.featureFlags.vpsConsole}
<div class="flex h-full min-h-[148px] flex-col items-center justify-center gap-3">
<div
class="flex h-full min-h-[148px] flex-col items-center justify-center gap-3 bg-background p-4 font-mono text-sm leading-relaxed text-muted-foreground"
>
<Terminal class="h-8 w-8 text-muted-foreground/60" />
<div class="text-center">
<p class="text-sm font-medium text-muted-foreground">Coming soon</p>
Expand Down Expand Up @@ -339,27 +334,33 @@
{/if}
</div>
{:else if selectedServer.status === 'running'}
{#each terminalLines as line (line.type + line.text)}
{#if line.type === 'prompt'}
<div>
<span class="text-muted-foreground">user@{selectedServer.name}~:</span>
{line.text}
</div>
{:else if line.type === 'output'}
<div class="text-muted-foreground">{line.text}</div>
{:else}
<div>
<span class="text-muted-foreground">user@{selectedServer.name}~:</span>
<span class="inline-block h-4 w-1.5 animate-pulse bg-muted-foreground"></span>
</div>
{/if}
{/each}
<div class="flex h-full min-h-[180px] flex-col">
{#key selectedServer.id}
<VmConsole
vmId={selectedServer.id}
serverName={selectedServer.name}
interactive={true}
/>
{/key}
</div>
{:else if selectedServer.status === 'restarting'}
<div class="text-amber-500">Restarting server...</div>
<div
class="flex h-full items-center justify-center bg-background p-4 font-mono text-sm text-amber-500"
>
Restarting server...
</div>
{:else if selectedServer.status === 'unknown'}
<div class="text-muted-foreground">Server status unavailable. Reconnecting...</div>
<div
class="flex h-full items-center justify-center bg-background p-4 font-mono text-sm text-muted-foreground"
>
Server status unavailable. Reconnecting...
</div>
{:else}
<div class="text-muted-foreground">Server is offline. Start the server to connect.</div>
<div
class="flex h-full items-center justify-center bg-background p-4 font-mono text-sm text-muted-foreground"
>
Server is offline. Start the server to connect.
</div>
{/if}
</div>
</div>
Expand Down
Loading
Loading