add the officer-headscale sidecar and its server registry ui
officer-headscale owns the whole Headscale contract: the registered servers and their admin api keys, the >=0.29 version floor, and every multi-call composition the ui needs. the platform side is auth+forward only and holds no headscale credentials, so the existing /api/vpn/enroll route and its HEADSCALE_* env vars are untouched and unrelated. officer manages many servers rather than one. the owner registers each with a url and a key generated on that server and switches between them; exactly one is active, enforced by a partial unique index rather than by convention. keys are encrypted at rest and never leave the sidecar — the list projection cannot return one. registration validates before it saves: an unauthenticated GET /version to prove something headscale-shaped is there and meets the floor, then an authenticated call to prove the key works. an edit that moves either half re-validates. there is deliberately no transparent /api/v1/* passthrough. headscale serialises every uint64 as a json string and its rest shape moved repeatedly below 0.29; proxying raw would push all of that into the browser, which is the mistake the soulseek panels made with 37 raw upstream calls. the /headscale workspace is nav + view over the panel system. only the servers section is implemented — nodes, users and pre-auth keys say so plainly rather than rendering an empty table that reads as a failed fetch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -130,6 +130,17 @@ export type {
|
||||
BrowseTreeSearch,
|
||||
SoulseekBrowseSnapshot,
|
||||
} from './queries/soulseek';
|
||||
export {
|
||||
listHeadscaleServers,
|
||||
getActiveHeadscaleCredentials,
|
||||
getHeadscaleCredentials,
|
||||
createHeadscaleServer,
|
||||
updateHeadscaleServer,
|
||||
setActiveHeadscaleServer,
|
||||
deleteHeadscaleServer,
|
||||
recordHeadscaleProbe,
|
||||
} from './queries/headscale';
|
||||
export type { HeadscaleServer, HeadscaleServerCredentials } from './queries/headscale';
|
||||
export {
|
||||
getVaultTokens,
|
||||
setVaultTokens,
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { headscaleServers } from '../schema';
|
||||
import { encryptSecret, decryptSecret } from '../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;
|
||||
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,
|
||||
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<HeadscaleServer[]> {
|
||||
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<HeadscaleServerCredentials | null> {
|
||||
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(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<HeadscaleServerCredentials | null> {
|
||||
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(row.apiKey) };
|
||||
}
|
||||
|
||||
type CreateHeadscaleServerParams = {
|
||||
userId: number;
|
||||
name: string;
|
||||
url: string;
|
||||
apiKey: string;
|
||||
version: 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<HeadscaleServer> {
|
||||
const { userId, name, url, apiKey, version, 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(apiKey),
|
||||
version,
|
||||
isActive: activate,
|
||||
lastSeenAt: version ? new Date() : null,
|
||||
})
|
||||
.returning(serverCols);
|
||||
return row!;
|
||||
});
|
||||
}
|
||||
|
||||
type UpdateHeadscaleServerParams = { name?: string; url?: string; apiKey?: string };
|
||||
|
||||
/** 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<HeadscaleServer | null> {
|
||||
const set: Record<string, unknown> = { 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(params.apiKey);
|
||||
|
||||
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<HeadscaleServer | null> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
await db
|
||||
.update(headscaleServers)
|
||||
.set({ version, lastSeenAt: new Date(), updatedAt: new Date() })
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)));
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { pgTable, serial, integer, text, boolean, timestamp, unique, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { users } from './auth';
|
||||
|
||||
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
|
||||
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
|
||||
// toggles between them, so this is configuration the user creates at runtime rather than env vars.
|
||||
//
|
||||
// `api_key` is a Headscale *admin* credential — it can delete every node on a tailnet — so it is encrypted
|
||||
// at rest via ../crypto.ts, exactly like the vault token set. Encryption/decryption is confined to
|
||||
// queries/headscale.ts; nothing outside that file ever sees ciphertext, and list callers never see the key
|
||||
// at all. SECURITY_AUDIT.md L2 records plaintext credential storage as an open finding, so the plaintext
|
||||
// email/integrations tables are debt to avoid copying, not a precedent to follow.
|
||||
//
|
||||
// Every table here is `headscale_`-prefixed and this file holds nothing else: when sidecars own their own
|
||||
// schema it moves wholesale into src/servers/sidecar/headscale/ with no untangling. Only the
|
||||
// officer-headscale sidecar reads or writes these tables.
|
||||
|
||||
export const headscaleServers = pgTable(
|
||||
'headscale_servers',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
// Normalized without a trailing slash before write, so `${url}/api/v1/...` never doubles the separator.
|
||||
url: text('url').notNull(),
|
||||
apiKey: text('api_key').notNull(), // encrypted
|
||||
// Last version seen from the server's unauthenticated GET /version. Null until first probed; the
|
||||
// literal 'dev' when the server was built without VCS info, which is unknown rather than too-old.
|
||||
version: text('version'),
|
||||
isActive: boolean('is_active').notNull().default(false),
|
||||
// Last successful probe, so the UI can distinguish "never reached" from "was reachable, now isn't".
|
||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// One registration per URL — re-registering the same server should be an edit, not a duplicate.
|
||||
unique('uq_headscale_servers_user_url').on(t.userId, t.url),
|
||||
// At most one active server per owner, enforced by the DB rather than by convention: a partial unique
|
||||
// index over the active rows only. setActiveHeadscaleServer still clears the others in a transaction,
|
||||
// but a bug there fails loudly here instead of silently leaving two servers active.
|
||||
uniqueIndex('uq_headscale_servers_one_active')
|
||||
.on(t.userId)
|
||||
.where(sql`${t.isActive}`),
|
||||
],
|
||||
);
|
||||
@@ -8,4 +8,5 @@ export * from './pipeline-jobs';
|
||||
export * from './chat-events';
|
||||
export * from './music';
|
||||
export * from './soulseek';
|
||||
export * from './headscale';
|
||||
export * from './vault';
|
||||
|
||||
Reference in New Issue
Block a user