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>
50 lines
2.9 KiB
TypeScript
50 lines
2.9 KiB
TypeScript
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}`),
|
|
],
|
|
);
|