import { eq, and, desc } from 'drizzle-orm'; import { db } from 'officerdb/db'; import { headscaleServers } from './schema'; import { encryptSecret, decryptSecret } from 'officerdb/crypto'; // Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT — // encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto. // See ../crypto.ts and ../schema/headscale.ts. // // Two return types on purpose: // HeadscaleServer — safe to serialize to the browser. Has NO api key field at all. // HeadscaleServerCredentials — url + decrypted key, for the sidecar's own upstream calls. Never returned // by a route handler. // The `serverCols` projection is what enforces that: `select()` without it would leak the ciphertext column // into every list response the moment someone forgot to strip it. export type HeadscaleServer = { id: number; name: string; url: string; version: string | null; sshHost: string | null; isActive: boolean; lastSeenAt: Date | null; createdAt: Date; }; export type HeadscaleServerCredentials = { id: number; name: string; url: string; apiKey: string }; const serverCols = { id: headscaleServers.id, name: headscaleServers.name, url: headscaleServers.url, version: headscaleServers.version, sshHost: headscaleServers.sshHost, isActive: headscaleServers.isActive, lastSeenAt: headscaleServers.lastSeenAt, createdAt: headscaleServers.createdAt, }; /** Every server the owner has registered, active first then newest. Never includes the API key. */ export async function listHeadscaleServers(userId: number): Promise { return db .select(serverCols) .from(headscaleServers) .where(eq(headscaleServers.userId, userId)) .orderBy(desc(headscaleServers.isActive), desc(headscaleServers.createdAt)); } /** The currently selected server with its key decrypted, or null when none is registered/active. */ export async function getActiveHeadscaleCredentials(userId: number): Promise { const [row] = await db .select() .from(headscaleServers) .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true))); if (!row) return null; return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) }; } /** One server's credentials by id — for probing a specific server rather than the active one. */ export async function getHeadscaleCredentials(userId: number, id: number): Promise { const [row] = await db .select() .from(headscaleServers) .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id))); if (!row) return null; return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) }; } type CreateHeadscaleServerParams = { userId: number; name: string; url: string; apiKey: string; version: string | null; /** Optional SSH target for the console. Null when the owner hasn't set one. */ sshHost: string | null; /** Make it the active server. True for the first registration, so the UI is never left with none selected. */ activate: boolean; }; /** Register a server. The key is encrypted before write; the returned row carries no key. */ export async function createHeadscaleServer(params: CreateHeadscaleServerParams): Promise { const { userId, name, url, apiKey, version, sshHost, activate } = params; return db.transaction(async (tx) => { if (activate) { await tx .update(headscaleServers) .set({ isActive: false, updatedAt: new Date() }) .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true))); } const [row] = await tx .insert(headscaleServers) .values({ userId, name, url, apiKey: encryptSecret('headscale', apiKey), version, sshHost, isActive: activate, lastSeenAt: version ? new Date() : null, }) .returning(serverCols); return row!; }); } // `sshHost: null` clears the console target; omitting the field leaves it alone. The two must stay // distinguishable, which is why this is `string | null` and not `string`. type UpdateHeadscaleServerParams = { name?: string; url?: string; apiKey?: string; sshHost?: string | null }; /** Edit a registration. Omitted fields are left alone; a supplied key is re-encrypted. */ export async function updateHeadscaleServer( userId: number, id: number, params: UpdateHeadscaleServerParams, ): Promise { const set: Record = { updatedAt: new Date() }; if (params.name !== undefined) set.name = params.name; if (params.url !== undefined) set.url = params.url; if (params.apiKey !== undefined) set.apiKey = encryptSecret('headscale', params.apiKey); if (params.sshHost !== undefined) set.sshHost = params.sshHost; const [row] = await db .update(headscaleServers) .set(set) .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id))) .returning(serverCols); return row ?? null; } /** Select a server. Clearing the others first keeps the one-active partial index satisfied. */ export async function setActiveHeadscaleServer(userId: number, id: number): Promise { return db.transaction(async (tx) => { await tx .update(headscaleServers) .set({ isActive: false, updatedAt: new Date() }) .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true))); const [row] = await tx .update(headscaleServers) .set({ isActive: true, updatedAt: new Date() }) .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id))) .returning(serverCols); return row ?? null; }); } /** * Drop a registration. If it was the active one, the newest survivor is promoted — otherwise deleting the * active server would leave the UI with servers registered but none selected, which reads as "not * configured" and is a confusing place to land. */ export async function deleteHeadscaleServer(userId: number, id: number): Promise { return db.transaction(async (tx) => { const [deleted] = await tx .delete(headscaleServers) .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id))) .returning({ id: headscaleServers.id, wasActive: headscaleServers.isActive }); if (!deleted) return false; if (deleted.wasActive) { const [next] = await tx .select({ id: headscaleServers.id }) .from(headscaleServers) .where(eq(headscaleServers.userId, userId)) .orderBy(desc(headscaleServers.createdAt)) .limit(1); if (next) { await tx .update(headscaleServers) .set({ isActive: true, updatedAt: new Date() }) .where(eq(headscaleServers.id, next.id)); } } return true; }); } /** Record a successful reachability probe: the version observed and when we last reached the server. */ export async function recordHeadscaleProbe(userId: number, id: number, version: string | null): Promise { await db .update(headscaleServers) .set({ version, lastSeenAt: new Date(), updatedAt: new Date() }) .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id))); }