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 @@
+ {#if settingsError}
+ {settingsError}
+ {/if}
{#if deleteError}
{deleteError}
{/if}
-
-
+