From f81c1051ef68627bcf975030617f0d286b8ffb04 Mon Sep 17 00:00:00 2001 From: Cypress Reed Date: Thu, 27 Aug 2026 13:38:27 -0600 Subject: [PATCH] add option for users to change hostname and VM name from dashboard --- apps/dashboard/src/lib/remote/vms.remote.ts | 22 +++++++++ .../src/lib/server/backends/proxmox/index.ts | 29 ++++++++++- .../src/lib/server/backends/types.ts | 1 + .../servers/[id]/settings/+page.svelte | 48 ++++++++++++++++--- 4 files changed, 92 insertions(+), 8 deletions(-) diff --git a/apps/dashboard/src/lib/remote/vms.remote.ts b/apps/dashboard/src/lib/remote/vms.remote.ts index d29733b..b7b9ca3 100644 --- a/apps/dashboard/src/lib/remote/vms.remote.ts +++ b/apps/dashboard/src/lib/remote/vms.remote.ts @@ -15,6 +15,7 @@ import { requireProjectAccess } from '$lib/server/auth-context'; import { isProjectBillingExempt, requireProjectBillingActive } from '$lib/server/billing/autumn'; import { queueVmDeletion } from '$lib/server/vm-deletion'; import { provisionVm } from '$lib/server/vm-provisioning'; +import { isValidPtrHostname } from '$lib/ptr'; import { instrument, timingLog } from '$lib/server/observability'; import { accessibilityFixtureEnabled, @@ -578,6 +579,27 @@ export const createVm = command(createParams, async (params) => { }); }); +const updateHostnameParams = type({ vmId: 'string', hostname: 'string' }); +export const updateVmHostname = command(updateHostnameParams, async (params) => { + const event = getRequestEvent(); + if (!event?.locals.user) error(401, 'Authentication required'); + + const db = initDrizzle(); + const row = await db.query.vms.findFirst({ where: eq(vms.id, params.vmId) }); + if (!row) error(404, `VM "${params.vmId}" not found`); + if (!row.active) error(400, 'Cannot rename an inactive VM'); + if (!row.ownerProjectId) error(400, 'VM is not attached to a project'); + await requireProjectAccess(db, event.locals.user.id, row.ownerProjectId, 'read_write'); + + const hostname = params.hostname.trim(); + if (!isValidPtrHostname(hostname)) error(400, 'Hostname must be a valid hostname'); + + const backend = getBackend(row.backend); + await backend.updateVmHostname(row.id, hostname, row.proxmoxId ?? undefined); + await db.update(vms).set({ name: hostname }).where(eq(vms.id, row.id)); + return { id: row.id, name: hostname }; +}); + const deleteParams = type({ vmId: 'string' }); export const deleteVm = command(deleteParams, async (params) => { const event = getRequestEvent(); diff --git a/apps/dashboard/src/lib/server/backends/proxmox/index.ts b/apps/dashboard/src/lib/server/backends/proxmox/index.ts index f1f2e13..4081b48 100644 --- a/apps/dashboard/src/lib/server/backends/proxmox/index.ts +++ b/apps/dashboard/src/lib/server/backends/proxmox/index.ts @@ -76,6 +76,7 @@ type ProxmoxBackendOptions = { }; type CloudInitVendorConfigParams = { + hostname?: string; enableSshPasswordAuth?: boolean; }; @@ -95,6 +96,7 @@ function firstIpv6AddressInPrefix(prefix: string) { function cloudInitVendorConfig(params: CloudInitVendorConfigParams) { const yamlContents = `#cloud-config\n${stringifyYaml({ + ...(params.hostname ? { hostname: params.hostname, manage_etc_hosts: true } : {}), write_files: [ { path: '/etc/sysctl.d/99-ipv6-forwarding.conf', @@ -540,7 +542,10 @@ export class ProxmoxBackend implements VmBackend { ), this.uploadSnippet( cloudInitVendorConfigFilename, - cloudInitVendorConfig({ enableSshPasswordAuth: Boolean(params.password) }) + cloudInitVendorConfig({ + hostname: params.name, + enableSshPasswordAuth: Boolean(params.password) + }) ) ]); @@ -644,6 +649,28 @@ export class ProxmoxBackend implements VmBackend { }; } + async updateVmHostname(id: string, hostname: string, proxmoxId?: number): Promise { + clearProxmoxReadCaches(); + const { node, vmid } = await this.resolve(id, proxmoxId); + const storage = await this.snippetStorage(node); + const filename = `stack-${vmid}-hostname.yaml`; + await this.uploadSnippet( + filename, + `#cloud-config\\n${stringifyYaml({ hostname, manage_etc_hosts: true })}` + ); + + const currentConfig = await this.client.getQemuConfig(node, vmid); + const customConfigs = (currentConfig.cicustom ?? '') + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry && !entry.startsWith('user=')); + customConfigs.push(`user=${storage}:snippets/${filename}`); + const upid = await this.client.updateQemuConfigAsync(node, vmid, { + cicustom: customConfigs.join(',') + }); + await this.client.waitForTask(node, upid); + } + async deleteVm(id: string, proxmoxId?: number): Promise { clearProxmoxReadCaches(); let resolved: ResolvedVm; diff --git a/apps/dashboard/src/lib/server/backends/types.ts b/apps/dashboard/src/lib/server/backends/types.ts index caeba15..a09aaa8 100644 --- a/apps/dashboard/src/lib/server/backends/types.ts +++ b/apps/dashboard/src/lib/server/backends/types.ts @@ -116,6 +116,7 @@ export interface VmBackend { options?: Pick ): Promise; createVm(params: VmCreateParams): Promise; + updateVmHostname(id: string, hostname: string, proxmoxId?: number): Promise; deleteVm(id: string, proxmoxId?: number): Promise; startVm(id: string, proxmoxId?: number): Promise; stopVm(id: string, proxmoxId?: number): Promise; diff --git a/apps/dashboard/src/routes/(app)/projects/[projectid]/servers/[id]/settings/+page.svelte b/apps/dashboard/src/routes/(app)/projects/[projectid]/servers/[id]/settings/+page.svelte index 3b347b7..ed2c21a 100644 --- a/apps/dashboard/src/routes/(app)/projects/[projectid]/servers/[id]/settings/+page.svelte +++ b/apps/dashboard/src/routes/(app)/projects/[projectid]/servers/[id]/settings/+page.svelte @@ -7,16 +7,36 @@ import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; - import ComingSoon from '$lib/components/coming-soon.svelte'; import { confirmDestructive } from '$lib/confirm.svelte'; - import { deleteVm } from '$lib/remote/vms.remote'; + import { deleteVm, updateVmHostname } from '$lib/remote/vms.remote'; + import { getErrorMessage } from '$lib/utils'; let { data }: PageProps = $props(); let selectedServer = $derived(getServerWithFallback(data.serverId, data.server)); - let nameValue = $derived(selectedServer.name); + let nameValue = $state(''); + let saving = $state(false); + $effect(() => { + if (!nameValue) nameValue = selectedServer.name; + }); let deleting = $state(false); + let settingsError = $state(''); let deleteError = $state(''); + async function handleSave() { + const hostname = nameValue.trim(); + if (!hostname || saving || hostname === selectedServer.name) return; + saving = true; + settingsError = ''; + try { + await updateVmHostname({ vmId: selectedServer.id, hostname }); + await invalidate('project:vms'); + } catch (error) { + settingsError = getErrorMessage(error, 'Failed to update server hostname.'); + } finally { + saving = false; + } + } + async function handleDelete() { if (deleting) return; const ok = await confirmDestructive({ @@ -49,11 +69,14 @@

- Server hostname +

+ This updates the guest hostname and the server name shown in the dashboard. +

+ {#if settingsError} +

{settingsError}

+ {/if} {#if deleteError}

{deleteError}

{/if}
- - +