import "server-only"; import { execFile } from "node:child_process"; import { readFile, writeFile } from "node:fs/promises"; import { memoizeAsync } from "@/lib/server/cache/memoize-async"; import { SYSTEM_TIMEZONE_OPTIONS, type SystemPreferences } from "@/lib/shared/contracts/system"; const ALLOWED_TIMEZONE_SET = new Set(SYSTEM_TIMEZONE_OPTIONS); const PREFERENCES_CACHE_TTL_MS = 30_000; function normalizeHostname(input: string) { let hostname = input.trim().toLowerCase().replace(/\s+/g, ""); if (hostname.endsWith(".local")) { hostname = hostname.slice(0, -6); } if (!hostname) { hostname = "homeio"; } if (!/^[a-z0-9][a-z0-9.-]{0,62}$/.test(hostname)) { throw new Error( "Invalid hostname. Use lowercase letters, numbers, hyphens, and dots.", ); } return hostname; } async function readCommandOutput(command: string, args: string[]) { const { stdout } = await execFileAsync(command, args); return stdout.trim(); } function execFileAsync(command: string, args: string[]) { return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { execFile(command, args, (error, stdout = "", stderr = "") => { if (error) { const execError = error as Error & { stdout?: string; stderr?: string }; execError.stdout = stdout; execError.stderr = stderr; reject(execError); return; } resolve({ stdout, stderr }); }); }); } function resolveTimezoneFilePath() { return process.env.HOMEIO_TIMEZONE_FILE_PATH ?? "/etc/timezone"; } function resolveHostsFilePath() { return process.env.HOMEIO_HOSTS_FILE_PATH ?? "/etc/hosts"; } function isCommandUnavailable(error: unknown) { if (!(error instanceof Error)) return false; const message = error.message.toLowerCase(); return message.includes("enoent") || message.includes("no such file"); } function isOperationNotPermitted(error: unknown) { if (!(error instanceof Error)) return false; const message = error.message.toLowerCase(); return message.includes("operation not permitted") || message.includes("eperm"); } function isIgnorableAvahiError(error: unknown) { if (!(error instanceof Error)) return false; const stdout = (error as Error & { stdout?: string }).stdout ?? ""; const stderr = (error as Error & { stderr?: string }).stderr ?? ""; const haystack = `${error.message}\n${stdout}\n${stderr}`.toLowerCase(); return ( haystack.includes("avahi-daemon.service could not be found") || haystack.includes("unit avahi-daemon.service not found") || haystack.includes("not found") || haystack.includes("enoent") ); } async function readHostname() { try { const hostname = await readCommandOutput("hostnamectl", ["--static"]); if (hostname.length > 0) return hostname; } catch { // fallback below } return readCommandOutput("hostname", []); } async function readTimezone() { try { const timezone = await readCommandOutput("timedatectl", ["show", "--property=Timezone", "--value"]); if (timezone.length > 0) return timezone; } catch { // fallback below } try { const timezone = await readFile(resolveTimezoneFilePath(), "utf8"); if (timezone.trim().length > 0) return timezone.trim(); } catch { // fallback below } return "UTC"; } async function syncHostsFile(hostname: string) { let currentHosts = ""; const hostsFilePath = resolveHostsFilePath(); try { currentHosts = await readFile(hostsFilePath, "utf8"); } catch { currentHosts = ""; } const hostsEntry = hostname.includes(".") ? `127.0.1.1 ${hostname}` : `127.0.1.1 ${hostname} ${hostname}.local`; let nextHosts: string; if (/^127\.0\.1\.1/m.test(currentHosts)) { nextHosts = currentHosts.replace(/^127\.0\.1\.1.*$/m, hostsEntry); } else { nextHosts = `${currentHosts.trimEnd()}\n${hostsEntry}\n`; } try { await writeFile(hostsFilePath, nextHosts, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EACCES") throw error; // /etc/hosts is root-owned in containers — skip silently } } async function restartAvahiDaemon() { try { await execFileAsync("systemctl", ["restart", "avahi-daemon"]); } catch (error) { if (isIgnorableAvahiError(error)) { return; } throw error; } } async function readSystemPreferences(): Promise { const [hostname, timezone] = await Promise.all([readHostname(), readTimezone()]); return { hostname, timezone }; } // Settings reads these on every open, and each read spawns hostnamectl and // timedatectl. They change through updateSystemPreferences, which drops the // cache, or at the shell, which the TTL catches up with. const cachedPreferences = memoizeAsync(readSystemPreferences, PREFERENCES_CACHE_TTL_MS); export async function getSystemPreferences(): Promise { return { ...(await cachedPreferences()) }; } export function resetPreferencesCacheForTests() { cachedPreferences.invalidate(); } export async function updateSystemPreferences(input: SystemPreferences) { try { await applySystemPreferences(input); } finally { cachedPreferences.invalidate(); } return getSystemPreferences(); } async function applySystemPreferences(input: SystemPreferences) { const hostname = normalizeHostname(input.hostname); // Compare with the system as it is now, not as it was cached. const current = await readSystemPreferences(); const timezone = input.timezone.trim(); if (!ALLOWED_TIMEZONE_SET.has(timezone) && timezone !== current.timezone) { throw new Error("Invalid timezone. Choose one of the supported timezone options."); } if (hostname !== current.hostname) { try { await execFileAsync("hostnamectl", ["set-hostname", hostname]); } catch (error) { if (!isCommandUnavailable(error)) throw error; try { await execFileAsync("hostname", [hostname]); } catch (fallbackError) { if (!isOperationNotPermitted(fallbackError)) throw fallbackError; // container without SYS_ADMIN — system hostname unchanged, hosts file still updated below } } await syncHostsFile(hostname); await restartAvahiDaemon(); } if (timezone !== current.timezone) { try { await execFileAsync("timedatectl", ["set-timezone", timezone]); } catch (error) { if (!isCommandUnavailable(error)) throw error; try { await writeFile(resolveTimezoneFilePath(), `${timezone}\n`, "utf8"); } catch (writeError) { if ((writeError as NodeJS.ErrnoException).code !== "EACCES") throw writeError; // /etc/timezone is root-owned in containers — skip silently } } } }