import { json } from '@sveltejs/kit'; import { join } from 'path'; import { existsSync, rmSync, renameSync } from 'fs'; import type { RequestHandler } from './$types'; import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack, getBackupConfigs } from '$lib/server/db'; import { clearDockerClientCache } from '$lib/server/docker'; import { deleteGitStackFiles, getGitReposDir } from '$lib/server/git'; import { getStacksDir } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; import { auditEnvironment } from '$lib/server/audit'; import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager'; import { resetHostDetection, detectHostDataDir } from '$lib/server/host-path'; import { redactEnvironment } from '$lib/server/environment-redact'; import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors'; import { cleanPem } from '$lib/utils/pem'; import { validateEnvName } from '$lib/utils/env-name'; import { unregisterSchedule } from '$lib/server/scheduler'; import { closeEdgeConnection } from '$lib/server/hawser'; import { computeAuditDiff } from '$lib/utils/diff'; import { deleteEnvironmentIcon } from '$lib/server/env-icons'; /** * @openapi * summary: Get a single environment by id, including its parsed labels and public IP * path: id:integer! Environment id (from GET /api/environments) * resp-200: {id:integer!, name:string!, connectionType:string!, labels:array, publicIp:string, hasTlsKey:boolean, hasHawserToken:boolean} * resp-200-desc: The tlsKey (private TLS client key) and hawserToken secrets are NEVER returned; hasTlsKey / hasHawserToken indicate whether one is stored. * resp-403: Permission denied (RBAC 'environments:view' missing) * resp-404: Environment not found * resp-500: Unexpected error while loading the environment */ export const GET: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('environments', 'view')) { return json({ error: 'Permission denied' }, { status: 403 }); } const id = parseInt(params.id); const envAccessDenied = await auth.requireEnvAccess(id); if (envAccessDenied) return envAccessDenied; try { const env = await getEnvironment(id); if (!env) { return json({ error: 'Environment not found' }, { status: 404 }); } // Get public IP for this environment const publicIps = await getEnvironmentPublicIps(); const publicIp = publicIps[id.toString()] || null; // Parse labels from JSON string to array return json({ ...redactEnvironment(env), labels: parseLabels(env.labels as string | null), publicIp }); } catch (error) { console.error('Failed to get environment:', error); return json({ error: 'Failed to get environment' }, { status: 500 }); } }; /** * @openapi * summary: Update an environment; renaming also renames its on-disk stacks/git-repos directories * path: id:integer! Environment id (from GET /api/environments) * body: {name:string, host:string, port:integer, protocol:string, tlsCa:string, tlsCert:string, tlsKey:string, tlsSkipVerify:boolean, icon:string, socketPath:string, collectActivity:boolean, collectMetrics:boolean, highlightChanges:boolean, labels:string, connectionType:string, hawserToken:string, publicIp:string} * body-desc: tlsKey/hawserToken are write-only - a blank value keeps the stored secret; a non-blank value replaces it. * body-example: {"name":"hhdocker03","collectMetrics":true} * resp-200: {id:integer!, name:string!, connectionType:string!, labels:array, publicIp:string, hasTlsKey:boolean, hasHawserToken:boolean} * resp-200-desc: The tlsKey/hawserToken secrets are NEVER returned; hasTlsKey / hasHawserToken indicate whether one is stored. * resp-400: Invalid new name (rename validation) * resp-403: Permission denied (RBAC 'environments:edit' missing) * resp-404: Environment not found * resp-409: Rename target directory already exists, or the on-disk rename failed (e.g. EXDEV across filesystems) * resp-500: Unexpected error while updating the environment */ export const PUT: RequestHandler = async (event) => { const { params, request, cookies } = event; const auth = await authorize(cookies); if (auth.authEnabled && !await auth.can('environments', 'edit')) { return json({ error: 'Permission denied' }, { status: 403 }); } const id = parseInt(params.id); const envAccessDenied = await auth.requireEnvAccess(id); if (envAccessDenied) return envAccessDenied; try { // Get old values before update for diff const oldEnv = await getEnvironment(id); if (!oldEnv) { return json({ error: 'Environment not found' }, { status: 404 }); } const data = await request.json(); // #1179: validate name if it's being changed. Existing invalid names are // not auto-corrected — only writes go through this check. const isRename = data.name !== undefined && data.name !== oldEnv.name; if (isRename) { const nameCheck = validateEnvName(data.name); if (!nameCheck.ok) { return json({ error: nameCheck.reason }, { status: 400 }); } } // Rename on-disk directories BEFORE the DB write. If the fs rename // fails (cross-mount EXDEV, perm error, target exists), we surface a // 409 and leave the DB untouched — better than the previous behavior // of silently orphaning stacks under the old name. // // Applies to ALL connection types. For socket/direct envs the staging // dir IS the deployed dir, so containers need a redeploy after rename // (see client warning). For Hawser envs the agent owns the deployed // dir on the remote host and isn't affected, but Dockhand still keeps // a local staging copy under stacks/// (for the // in-app editor) and ALL git stacks clone to git-repos// // regardless of where they ultimately deploy — so the rename matters // locally for every env type. if (isRename) { const stacksDir = getStacksDir(); const gitReposDir = getGitReposDir(); const oldStacks = join(stacksDir, oldEnv.name); const newStacks = join(stacksDir, data.name); const oldRepos = join(gitReposDir, oldEnv.name); const newRepos = join(gitReposDir, data.name); // Refuse to overwrite a target dir that already holds someone // else's data. if (existsSync(oldStacks) && existsSync(newStacks)) { return json({ error: `Cannot rename: ${newStacks} already exists. Pick a different name or move that directory out of the way.` }, { status: 409 }); } if (existsSync(oldRepos) && existsSync(newRepos)) { return json({ error: `Cannot rename: ${newRepos} already exists. Pick a different name or move that directory out of the way.` }, { status: 409 }); } try { if (existsSync(oldStacks)) renameSync(oldStacks, newStacks); if (existsSync(oldRepos)) renameSync(oldRepos, newRepos); } catch (err: any) { // Best-effort rollback if the second rename failed after the first // succeeded. Avoids leaving the filesystem in a split state. try { if (existsSync(newStacks) && !existsSync(oldStacks)) renameSync(newStacks, oldStacks); } catch {} try { if (existsSync(newRepos) && !existsSync(oldRepos)) renameSync(newRepos, oldRepos); } catch {} const code = err?.code === 'EXDEV' ? 'EXDEV: stacks dir is on a different filesystem from the rename target. Move it back to the same filesystem to rename this environment.' : (err?.message || 'Rename failed'); return json({ error: code }, { status: 409 }); } } // Clear cached Docker client before updating clearDockerClientCache(id); // Handle labels - only update if provided in the request const labels = data.labels !== undefined ? serializeLabels(Array.isArray(data.labels) ? data.labels.slice(0, MAX_LABELS) : []) : undefined; // The GET/list responses never return the tlsKey / hawserToken secrets, so the // edit form can't round-trip them. A blank value therefore means "keep the stored // secret" (pass undefined so updateEnvironment leaves the column untouched); a // non-blank value replaces it. Same "leave blank to keep" pattern as registries / // git credentials. (Removing a secret entirely is done by deleting the env.) const cleanedTlsKey = cleanPem(data.tlsKey); const trimmedToken = typeof data.hawserToken === 'string' ? data.hawserToken.trim() : data.hawserToken; const env = await updateEnvironment(id, { name: data.name, host: data.host, port: data.port, protocol: data.protocol, tlsCa: cleanPem(data.tlsCa), tlsCert: cleanPem(data.tlsCert), tlsKey: cleanedTlsKey || undefined, tlsSkipVerify: data.tlsSkipVerify, icon: data.icon, socketPath: data.socketPath, collectActivity: data.collectActivity, collectMetrics: data.collectMetrics, highlightChanges: data.highlightChanges, labels: labels, connectionType: data.connectionType, hawserToken: trimmedToken || undefined }); if (!env) { return json({ error: 'Environment not found' }, { status: 404 }); } // Re-run host detection: editing an env (e.g. pointing it at a socket proxy) // lets a socketless deployment reach Docker without a restart (#1203). resetHostDetection(); void detectHostDataDir(); // Notify event collectors if collectActivity or collectMetrics setting changed if (data.collectActivity !== undefined || data.collectMetrics !== undefined) { refreshSubprocessEnvironments(); } // Handle public IP - update if provided in request if (data.publicIp !== undefined) { await setEnvironmentPublicIp(id, data.publicIp || null); } // Get current public IP for response const publicIps = await getEnvironmentPublicIps(); const publicIp = publicIps[id.toString()] || null; // Compute diff for audit (exclude sensitive TLS fields) const diff = computeAuditDiff(oldEnv, env, { excludeFields: ['tlsCa', 'tlsCert', 'tlsKey', 'hawserToken', 'labels'] }); // Audit log await auditEnvironment(event, 'update', env.id, env.name, diff); // Parse labels from JSON string to array return json({ ...redactEnvironment(env), labels: parseLabels(env.labels as string | null), publicIp }); } catch (error) { console.error('Failed to update environment:', error); return json({ error: 'Failed to update environment' }, { status: 500 }); } }; /** * @openapi * summary: Delete an environment and all its associated git stacks, schedules, icons, and on-disk directories * path: id:integer! Environment id (from GET /api/environments) * resp-200: {success:boolean!} * resp-400: Invalid environment id, or the environment could not be deleted * resp-403: Permission denied (RBAC 'environments:delete' missing) * resp-404: Environment not found * resp-500: Environment name is empty/whitespace (refuses to delete to avoid an unsafe directory cleanup), or an unexpected error */ export const DELETE: RequestHandler = async (event) => { const { params, cookies } = event; const auth = await authorize(cookies); // Deleting an environment cascades a filesystem cleanup of its stack dirs, git // files, and schedules - an instance-level action, not an env-scoped one. Require // admin under RBAC so an env-scoped role can't remove environments it doesn't own. if (auth.authEnabled && auth.isEnterprise && !auth.isAdmin) { return json({ error: 'Permission denied' }, { status: 403 }); } if (auth.authEnabled && !await auth.can('environments', 'delete')) { return json({ error: 'Permission denied' }, { status: 403 }); } try { const id = parseInt(params.id); if (isNaN(id) || id <= 0) { return json({ error: 'Invalid environment ID' }, { status: 400 }); } // Get environment name before deletion for audit log const env = await getEnvironment(id); if (!env) { return json({ error: 'Environment not found' }, { status: 404 }); } // Safety: never delete directories if env name is empty/whitespace if (!env.name?.trim()) { return json({ error: 'Cannot delete environment with empty name' }, { status: 500 }); } // Close Edge connection if this is a Hawser Edge environment // This rejects any pending requests and closes the WebSocket closeEdgeConnection(id); // Clear cached Docker client before deleting clearDockerClientCache(id); // Clean up git stacks for this environment const gitStacks = await getGitStacksForEnvironmentOnly(id); for (const stack of gitStacks) { // Unregister schedule if auto-update was enabled if (stack.autoUpdate) { unregisterSchedule(stack.id, 'git_stack_sync'); } // Delete git stack files from filesystem await deleteGitStackFiles(stack.id, stack.stackName, stack.environmentId); // Delete git stack from database await deleteGitStack(stack.id); } const success = await deleteEnvironment(id); if (!success) { return json({ error: 'Cannot delete this environment' }, { status: 400 }); } // Clean up custom icon file if exists deleteEnvironmentIcon(id); // Clean up public IP entry for this environment await deleteEnvironmentPublicIp(id); // Clean up update check settings and unregister schedule await deleteEnvUpdateCheckSettings(id); unregisterSchedule(id, 'env_update_check'); // Clean up image prune settings and unregister schedule await deleteImagePruneSettings(id); unregisterSchedule(id, 'image_prune'); // Unregister backup config schedules for this environment (audit #10). // The config rows themselves cascade-delete with the environment, but the // in-memory croner jobs would otherwise stay registered as orphans. try { const envBackupConfigs = await getBackupConfigs({ environmentId: id }); for (const config of envBackupConfigs) { unregisterSchedule(config.id, 'backup'); } } catch (err) { console.error(`Failed to unregister backup schedules for environment "${env.name}":`, err); } // Clean up stack directory for this environment // Safety: only delete subdirectory named after the env, never the parent try { const stacksDir = getStacksDir(); const envStackDir = join(stacksDir, env.name); if (envStackDir !== stacksDir && envStackDir.startsWith(stacksDir) && existsSync(envStackDir)) { rmSync(envStackDir, { recursive: true, force: true }); } } catch (err) { console.error(`Failed to clean up stack directory for environment "${env.name}":`, err); } // Clean up git-repos directory for this environment try { const gitReposDir = getGitReposDir(); const envGitDir = join(gitReposDir, env.name); if (envGitDir !== gitReposDir && envGitDir.startsWith(gitReposDir) && existsSync(envGitDir)) { rmSync(envGitDir, { recursive: true, force: true }); } } catch (err) { console.error(`Failed to clean up git-repos directory for environment "${env.name}":`, err); } // Notify event collectors to stop collecting from deleted environment refreshSubprocessEnvironments(); // Audit log await auditEnvironment(event, 'delete', id, env.name); return json({ success: true }); } catch (error) { console.error('Failed to delete environment:', error); return json({ error: 'Failed to delete environment' }, { status: 500 }); } };