offscale is a plugin

headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.

  api/router.ts   the thin auth-gated proxy, now at /api/offscale
  sidecar/        18 files, the whole headscale contract and its admin keys
  db/             schema + queries, offscale_servers
  web/            26 files as panels and a layout — no screen, per the rule

removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.

the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.

AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.

install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.

verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.

757 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 00:15:38 +00:00
co-authored by Claude Opus 5
parent 0e24aa3d52
commit e13128846b
111 changed files with 351 additions and 302 deletions
-2
View File
@@ -63,8 +63,6 @@ export function App() {
<Route path="/music" element={<Dashboard.MusicScreen />} />
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
<Route path="/soulseek/:section" element={<Dashboard.SoulseekScreen />} />
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
<Route path="/photos" element={<Dashboard.PhotosScreen />} />
<Route path="/photos/:section" element={<Dashboard.PhotosScreen />} />
<Route path="/app-store" element={<Dashboard.AppStoreScreen />} />
@@ -1,61 +0,0 @@
import { useEffect, useMemo } from 'react';
import { Navigate, useParams } from 'react-router';
import type { LayoutNode } from 'officerdev';
import { WorkspaceView, DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /headscale uses the Workspace/Panel system (like /soulseek and /music): the server picker
// (headscale-servers) above the section nav (headscale-nav) on the left, and the section view
// (headscale-view) on the right. All three talk to the officer-headscale sidecar through the /api/headscale
// auth proxy, which holds no Headscale credentials of its own — the registered servers and their keys live
// in the sidecar.
//
// The open section is :section in the URL, so every panel reads it with useParams instead of passing it
// between themselves over a channel. This screen backs both /headscale and /headscale/:section and is the
// single place that decides what an absent or bogus section means.
function hasAppType(node: LayoutNode, appType: string): boolean {
if (node.type === 'panel') return node.appType === appType;
return node.children.some((c) => hasAppType(c.node, appType));
}
export const HeadscaleScreen = () => {
const { section } = useParams();
const rawWorkspace = useDashboardState<LayoutNode>('screens/headscale', defaultLayout);
// A layout saved before the server picker existed has no panel for it, and nothing else would ever add
// one — so it is rebuilt from the default. That costs a one-time reset of any manual sizing, which is
// cheaper than a screen permanently missing a panel. Pinning the app types is `appTypes` below; this is
// the part the framework can't do, because it is about a panel that is *missing* rather than wrong.
const workspace = useMemo(() => {
if (hasAppType(rawWorkspace.value, 'headscale-servers')) return rawWorkspace;
return { ...rawWorkspace, value: defaultLayout };
}, [rawWorkspace]);
useEffect(() => {
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
rawWorkspace.setValue(workspace.value);
}
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
// Bare /headscale, or a section that doesn't exist, resolves to a canonical URL rather than rendering a
// default while the address bar says something else — the nav highlight is derived from the URL, so a URL
// that names nothing would leave nothing highlighted.
if (!isHeadscaleSection(section)) {
return <Navigate to={headscaleSectionPath(DEFAULT_HEADSCALE_SECTION)} replace />;
}
return (
<div className="h-full w-full pt-2">
<WorkspaceView
workspace={workspace}
locked
appTypes={{
allowed: ['headscale-servers', 'headscale-nav', 'headscale-view'],
fallback: 'headscale-view',
}}
/>
</div>
);
};
@@ -1,22 +0,0 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'headscale-root',
direction: 'horizontal',
children: [
{
node: {
type: 'group',
id: 'headscale-sidebar',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'headscale-servers', appType: 'headscale-servers' }, size: 30 },
{ node: { type: 'panel', id: 'headscale-nav', appType: 'headscale-nav' }, size: 70 },
],
},
size: 22,
},
{ node: { type: 'panel', id: 'headscale-view', appType: 'headscale-view' }, size: 78 },
],
};
@@ -1 +0,0 @@
export * from './HeadscaleScreen';
@@ -178,7 +178,6 @@ export const CORE_DOCK_ITEMS: DockItem[] = [
// Core because the tailnet is the perimeter — origin checking was removed on the grounds that the
// tailnet stands in its place, so administering it cannot be an optional extra. It is `kind: 'admin'`,
// and DashboardLayout filters every tile through canVisit(), so a member never sees this one.
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
// Core by necessity: the store is how every other feature arrives, so it can never be one of the
// things that disappears when uninstalled.
{ label: 'App store', to: '/app-store', icon: Store, color: '#64748b' },
@@ -15,7 +15,6 @@ export * from './Calendar';
export * from './Contacts';
export * from './Music';
export * from './Soulseek';
export * from './Headscale';
export * from './Photos';
export * from './Jellyfin';
export * from './Transmission';
@@ -27,7 +27,6 @@ const RULES: TitleRule[] = [
{ match: (p) => p.startsWith('/photos'), title: 'Photos' },
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
{ match: (p) => p.startsWith('/headscale'), title: 'Headscale' },
{ match: (p) => p.startsWith('/transmission'), title: 'Transmission' },
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
{ match: (p) => p.startsWith('/invoices'), title: 'Invoices' },
@@ -1,10 +1,4 @@
export {
getAllRoleGrants,
getRoleGrants,
setRoleGrant,
revokeRoleGrant,
replaceRoleGrants,
} from './queries';
export { getAllRoleGrants, getRoleGrants, setRoleGrant, revokeRoleGrant, replaceRoleGrants } from './queries';
export type { RoleGrant } from './queries';
@@ -1,6 +1 @@
export {
appendChatEvent,
getChatEventsSince,
getLastChatEventSeq,
pruneChatEventsOlderThan,
} from './queries';
export { appendChatEvent, getChatEventsSince, getLastChatEventSeq, pruneChatEventsOlderThan } from './queries';
+4 -1
View File
@@ -43,7 +43,10 @@ export async function waitForDatabase(timeoutMs = 60_000): Promise<boolean> {
return true;
} catch (err) {
if (Date.now() - started >= timeoutMs) {
console.error(`[db] Postgres did not answer within ${Math.round(timeoutMs / 1000)}s:`, err instanceof Error ? err.message : err);
console.error(
`[db] Postgres did not answer within ${Math.round(timeoutMs / 1000)}s:`,
err instanceof Error ? err.message : err,
);
return false;
}
if (!announced) {
@@ -1,12 +0,0 @@
export {
listHeadscaleServers,
getActiveHeadscaleCredentials,
getHeadscaleCredentials,
createHeadscaleServer,
updateHeadscaleServer,
setActiveHeadscaleServer,
deleteHeadscaleServer,
recordHeadscaleProbe,
} from './queries';
export type { HeadscaleServer, HeadscaleServerCredentials } from './queries';
@@ -1,186 +0,0 @@
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;
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<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('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<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('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<HeadscaleServer> {
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<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('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<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)));
}
@@ -1,55 +0,0 @@
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from '../auth/schema';
// 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'),
// Where to SSH for a shell on the box running this Headscale — the last-resort escape hatch for when the
// API cannot answer (headscale is down, the tailnet is down, the logs are the only evidence). Deliberately
// NOT derived from `url`: the whole point is to reach the machine when the control plane's own hostname
// stops resolving, so this is usually a raw IP on a different path. No port, user or key material — the
// connection uses whatever ~/.ssh already knows, so there is no credential here to protect.
sshHost: text('ssh_host'),
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.
uniqueIndex('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}`),
],
);
-2
View File
@@ -34,14 +34,12 @@ export * from './auth';
export * from './capabilities';
export * from './chat-events';
export * from './dashboards';
export * from './headscale';
export * from './integrations';
export * from './pipeline-jobs';
export * from './server';
export * from './service-connections';
export * from './user-data';
// ── Plugins — exported only so tsgo stays clean; nothing mounts them ──────────────────────────────
export * from './dav';
@@ -60,7 +60,13 @@ export async function getActiveInvoiceshelfCredentials(userId: number): Promise<
.from(invoiceshelfAccounts)
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true)));
if (!row) return null;
return { id: row.id, label: row.label, url: row.url, token: decryptSecret('invoiceshelf', row.token), companyId: row.companyId };
return {
id: row.id,
label: row.label,
url: row.url,
token: decryptSecret('invoiceshelf', row.token),
companyId: row.companyId,
};
}
/** One account's credentials by id — for probing a specific account rather than the active one. */
@@ -70,7 +76,13 @@ export async function getInvoiceshelfCredentials(userId: number, id: number): Pr
.from(invoiceshelfAccounts)
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id)));
if (!row) return null;
return { id: row.id, label: row.label, url: row.url, token: decryptSecret('invoiceshelf', row.token), companyId: row.companyId };
return {
id: row.id,
label: row.label,
url: row.url,
token: decryptSecret('invoiceshelf', row.token),
companyId: row.companyId,
};
}
type CreateInvoiceshelfAccountParams = {
+1 -8
View File
@@ -14,11 +14,4 @@ export {
setPlaylistItems,
} from './queries';
export type {
FavoriteKind,
GroupedFavorites,
NowPlaying,
NowPlayingInput,
PlaylistSummary,
Playlist,
} from './queries';
export type { FavoriteKind, GroupedFavorites, NowPlaying, NowPlayingInput, PlaylistSummary, Playlist } from './queries';
+1 -7
View File
@@ -1,9 +1,3 @@
export {
upsertPushDevice,
getPushDevices,
deletePushDevice,
recordPushFailure,
markPushDeviceSeen,
} from './queries';
export { upsertPushDevice, getPushDevices, deletePushDevice, recordPushFailure, markPushDeviceSeen } from './queries';
export type { PushDeviceSelect, PushDeviceInsert } from '../types';
@@ -69,8 +69,5 @@ export async function recordPushFailure(token: string): Promise<void> {
/** A send worked: clear the failure count and mark the device alive. */
export async function markPushDeviceSeen(token: string): Promise<void> {
await db
.update(pushDevices)
.set({ failureCount: 0, lastSeenAt: new Date() })
.where(eq(pushDevices.token, token));
await db.update(pushDevices).set({ failureCount: 0, lastSeenAt: new Date() }).where(eq(pushDevices.token, token));
}
@@ -158,7 +158,10 @@ export async function deletePhotosAccount(userId: number, id: number): Promise<b
.orderBy(desc(photosConfig.createdAt))
.limit(1);
if (next) {
await tx.update(photosConfig).set({ isActive: true, updatedAt: new Date() }).where(eq(photosConfig.id, next.id));
await tx
.update(photosConfig)
.set({ isActive: true, updatedAt: new Date() })
.where(eq(photosConfig.id, next.id));
}
}
return true;
-2
View File
@@ -32,8 +32,6 @@ export * from './chat-events/schema'; // chat_session_events
export * from './agent-panels/schema'; // agent_panels
// Core because the tailnet is the perimeter — a security model resting on it cannot treat administering
// it as an optional extra. The secret store bootstraps a `headscale` key on this basis.
export * from './headscale/schema'; // headscale_servers
// The app store itself, and the credentials it stores for what it installs. `app-store/effects.ts`
// reads service_connections, so this is core however few plugins are installed.
-1
View File
@@ -61,7 +61,6 @@ export type DashboardInsert = typeof Schema.dashboards.$inferInsert;
export type ScreenSelect = typeof Schema.screens.$inferSelect;
export type ScreenInsert = typeof Schema.screens.$inferInsert;
// ── Email ──
export type EmailAccountSelect = typeof EmailSchema.emailAccounts.$inferSelect;
@@ -1,8 +1 @@
export {
getUserSettings,
setUserSettings,
getUserState,
patchUserState,
getDockPaths,
setDockPaths,
} from './queries';
export { getUserSettings, setUserSettings, getUserState, patchUserState, getDockPaths, setDockPaths } from './queries';
+11 -2
View File
@@ -55,7 +55,13 @@ async function listTaskFiles(): Promise<TaskFile[]> {
const p = join(tasksDir, f);
const st = await stat(p).catch(() => null);
if (!st) continue;
out.push({ taskId: f.slice(0, -'.output'.length), path: p, cwdLabel: cwd.name, sizeBytes: st.size, mtimeMs: st.mtimeMs });
out.push({
taskId: f.slice(0, -'.output'.length),
path: p,
cwdLabel: cwd.name,
sizeBytes: st.size,
mtimeMs: st.mtimeMs,
});
}
}
}
@@ -109,7 +115,10 @@ activityRouter.post('/announce', async (ctx) => {
if (!safe) return ctx.text('path is not under an allowed root', 403);
await mkdir(dirname(ANNOUNCED_PATH), { recursive: true });
const list = await readAnnounced();
const next = [{ name: body.name, path: safe, ts: Date.now() }, ...list.filter((a) => a.name !== body.name)].slice(0, 100);
const next = [{ name: body.name, path: safe, ts: Date.now() }, ...list.filter((a) => a.name !== body.name)].slice(
0,
100,
);
await writeFile(ANNOUNCED_PATH, JSON.stringify(next));
return ctx.json({ ok: true, name: body.name, path: safe });
});
+1 -3
View File
@@ -11,9 +11,7 @@ const RELAY_TOKEN_CONTEXT = 'officer-browser-relay-v1';
// tokens from the platform's signing key is the coupling the per-purpose split exists to remove.
export function deriveRelayToken(userId: number, port: number, salt: string): string {
return createHmac('sha256', getKey('jwt'))
.update(`${RELAY_TOKEN_CONTEXT}:${port}:${userId}:${salt}`)
.digest('hex');
return createHmac('sha256', getKey('jwt')).update(`${RELAY_TOKEN_CONTEXT}:${port}:${userId}:${salt}`).digest('hex');
}
const tokenToUser = new Map<string, number>();
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* @deprecated Legacy chat types - Use types from ./chat/types.ts instead
*
*
* This file is kept for backward compatibility with existing code.
* New code should import from ./chat/types.ts
*/
+2 -2
View File
@@ -26,11 +26,11 @@ function formatTimestamp(): string {
function formatContext(context?: LogContext): string {
if (!context || Object.keys(context).length === 0) return '';
const lines = Object.entries(context)
.map(([key, value]) => ` ${key}=${value}`)
.join('\n');
return '\n' + lines;
}
-18
View File
@@ -1,18 +0,0 @@
import { createSidecarProxy } from '../../sidecar/create-proxy';
// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge:
// this file must never grow app logic.
//
// The sidecar exposes only Officer-owned routes under `/_officer/` — Headscale's REST shape differs
// across releases, and version handling belongs in the sidecar. It holds the admin API key; the platform
// does not know Headscale's URL.
const proxy = createSidecarProxy({
name: 'headscale',
prefix: '/api/headscale',
});
export const headscaleRouter = proxy.router;
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
export const getHeadscaleServerUrl = proxy.getHttpUrl;
+7 -4
View File
@@ -38,7 +38,9 @@ function buildTransportUrl(config: SmtpConfig): string {
if (config.provider === 'mailhog') {
return `smtp://${config.host ?? 'localhost'}:${config.port ?? 1025}`;
}
const auth = config.username ? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password ?? '')}@` : '';
const auth = config.username
? `${encodeURIComponent(config.username)}:${encodeURIComponent(config.password ?? '')}@`
: '';
const protocol = config.secure ? 'smtps' : 'smtp';
return `${protocol}://${auth}${config.host}:${config.port ?? 587}`;
}
@@ -73,7 +75,7 @@ smtpRouter.post('/test-connection', async (ctx) => {
if (result.type === 'resend') {
const res = await fetch('https://api.resend.com/domains', {
headers: { 'Authorization': `Bearer ${result.apiKey}` },
headers: { Authorization: `Bearer ${result.apiKey}` },
});
if (!res.ok) {
const err = await res.json();
@@ -103,14 +105,15 @@ smtpRouter.post('/test', async (ctx) => {
}
const from = `${body.fromName} <${body.fromEmail}>`;
const testHtml = '<h2>Officer Test Email</h2><p>If you received this, your email configuration is working correctly.</p>';
const testHtml =
'<h2>Officer Test Email</h2><p>If you received this, your email configuration is working correctly.</p>';
try {
if (body.provider === 'resend') {
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${body.apiKey}`,
Authorization: `Bearer ${body.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ from, to: body.to, subject: 'officer.dev Test Email', html: testHtml }),
@@ -57,7 +57,16 @@ async function readDisks() {
const { stdout } = await exec('df', [
'-B1',
'--output=target,fstype,size,used,pcent',
'-x', 'tmpfs', '-x', 'devtmpfs', '-x', 'squashfs', '-x', 'overlay', '-x', 'efivarfs',
'-x',
'tmpfs',
'-x',
'devtmpfs',
'-x',
'squashfs',
'-x',
'overlay',
'-x',
'efivarfs',
]);
return stdout
.trim()
@@ -131,7 +140,9 @@ async function readTemps() {
}
const CPU_DRIVERS = ['k10temp', 'zenpower', 'coretemp', 'k8temp', 'cpu_thermal'];
const cpu =
sensors.find((s) => CPU_DRIVERS.includes(s.name.toLowerCase()) && /tctl|tdie|package|composite|core 0/i.test(s.label)) ??
sensors.find(
(s) => CPU_DRIVERS.includes(s.name.toLowerCase()) && /tctl|tdie|package|composite|core 0/i.test(s.label),
) ??
sensors.find((s) => CPU_DRIVERS.includes(s.name.toLowerCase())) ??
null;
return { cpuC: cpu?.celsius ?? null, cpuLabel: cpu ? `${cpu.name} · ${cpu.label}` : null, sensors };
@@ -151,15 +162,18 @@ async function readGpu() {
const busyRaw = await readFile(`${dev}/gpu_busy_percent`, 'utf8').catch(() => null);
if (busyRaw == null) continue;
const busyPct = Number.parseInt(busyRaw.trim(), 10);
const vramUsed = Number.parseInt((await readFile(`${dev}/mem_info_vram_used`, 'utf8').catch(() => '0')).trim(), 10) || 0;
const vramTotal = Number.parseInt((await readFile(`${dev}/mem_info_vram_total`, 'utf8').catch(() => '0')).trim(), 10) || 0;
const vramUsed =
Number.parseInt((await readFile(`${dev}/mem_info_vram_used`, 'utf8').catch(() => '0')).trim(), 10) || 0;
const vramTotal =
Number.parseInt((await readFile(`${dev}/mem_info_vram_total`, 'utf8').catch(() => '0')).trim(), 10) || 0;
return { busyPct: Number.isFinite(busyPct) ? busyPct : 0, vramUsedBytes: vramUsed, vramTotalBytes: vramTotal };
}
return null;
}
// Network throughput — bytes/sec computed from the delta since the previous /stats call (~2s window).
let lastNet: { total: { rx: number; tx: number }; per: Record<string, { rx: number; tx: number }>; ts: number } | null = null;
let lastNet: { total: { rx: number; tx: number }; per: Record<string, { rx: number; tx: number }>; ts: number } | null =
null;
async function readNet() {
const now = Date.now();
let data: string;
@@ -185,11 +199,20 @@ async function readNet() {
}
const prev = lastNet;
lastNet = { total: { rx: totRx, tx: totTx }, per, ts: now };
if (!prev || now <= prev.ts) return { rxBytesPerSec: 0, txBytesPerSec: 0, interfaces: [] as { name: string; rxBytesPerSec: number; txBytesPerSec: number }[] };
if (!prev || now <= prev.ts)
return {
rxBytesPerSec: 0,
txBytesPerSec: 0,
interfaces: [] as { name: string; rxBytesPerSec: number; txBytesPerSec: number }[],
};
const dt = (now - prev.ts) / 1000;
const rate = (cur: number, old: number) => Math.max(0, Math.round((cur - old) / dt));
const interfaces = Object.entries(per)
.map(([name, v]) => ({ name, rxBytesPerSec: rate(v.rx, prev.per[name]?.rx ?? v.rx), txBytesPerSec: rate(v.tx, prev.per[name]?.tx ?? v.tx) }))
.map(([name, v]) => ({
name,
rxBytesPerSec: rate(v.rx, prev.per[name]?.rx ?? v.rx),
txBytesPerSec: rate(v.tx, prev.per[name]?.tx ?? v.tx),
}))
.filter((i) => i.rxBytesPerSec > 0 || i.txBytesPerSec > 0)
.sort((a, b) => b.rxBytesPerSec + b.txBytesPerSec - (a.rxBytesPerSec + a.txBytesPerSec));
return { rxBytesPerSec: rate(totRx, prev.total.rx), txBytesPerSec: rate(totTx, prev.total.tx), interfaces };
@@ -215,7 +238,9 @@ async function readPower() {
for (const d of await readdir('/sys/class/hwmon')) {
const base = `/sys/class/hwmon/${d}`;
if ((await readFile(`${base}/name`, 'utf8').catch(() => '')).trim() !== 'amdgpu') continue;
const p = (await readFile(`${base}/power1_average`, 'utf8').catch(() => null)) ?? (await readFile(`${base}/power1_input`, 'utf8').catch(() => null));
const p =
(await readFile(`${base}/power1_average`, 'utf8').catch(() => null)) ??
(await readFile(`${base}/power1_input`, 'utf8').catch(() => null));
if (p != null) {
const uw = Number.parseInt(p.trim(), 10);
if (Number.isFinite(uw)) gpuWatts = Math.round((uw / 1e6) * 10) / 10;
@@ -233,8 +258,7 @@ systemMonitorRouter.get('/stats', async (ctx) => {
await new Promise((r) => setTimeout(r, 120));
const second = await readCpuSample().catch(() => null);
const cpuUsage =
first && second ? pct(second.busy - first.busy, second.total - first.total) : 0;
const cpuUsage = first && second ? pct(second.busy - first.busy, second.total - first.total) : 0;
const perCore =
first && second
? second.perCore.map((c, i) => {
@@ -311,7 +335,9 @@ systemMonitorRouter.get('/pm2', async (ctx) => {
// GET /docker — running docker containers (the "dockers" scope).
systemMonitorRouter.get('/docker', async (ctx) => {
try {
const { stdout } = await exec('docker', ['ps', '--no-trunc', '--format', '{{json .}}'], { maxBuffer: 8 * 1024 * 1024 });
const { stdout } = await exec('docker', ['ps', '--no-trunc', '--format', '{{json .}}'], {
maxBuffer: 8 * 1024 * 1024,
});
return ctx.json({
containers: stdout
.trim()
+10 -2
View File
@@ -43,11 +43,19 @@ export function descendantPids(root: number): number[] {
export function killTree(root: number) {
const pids = [root, ...descendantPids(root)];
for (const pid of pids) {
try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
try {
process.kill(pid, 'SIGTERM');
} catch {
/* already gone */
}
}
setTimeout(() => {
for (const pid of pids) {
try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ }
try {
process.kill(pid, 'SIGKILL');
} catch {
/* gone */
}
}
}, 2000);
}
+1 -6
View File
@@ -36,12 +36,7 @@ import { validatePassword } from '../auth/validate-password';
* The specials are a subset of the class `validatePassword` accepts, chosen to survive being copied,
* pasted, quoted in a shell and read aloud: no quotes, no backslash, no backtick.
*/
const CLASSES = [
'abcdefghijkmnopqrstuvwxyz',
'ABCDEFGHJKLMNPQRSTUVWXYZ',
'23456789',
'!@#$%^&*()-_=+',
] as const;
const CLASSES = ['abcdefghijkmnopqrstuvwxyz', 'ABCDEFGHJKLMNPQRSTUVWXYZ', '23456789', '!@#$%^&*()-_=+'] as const;
const PASSWORD_LENGTH = 20;
+7 -7
View File
@@ -26,13 +26,13 @@ bash setup.sh
Every script must:
| Rule | Why |
|---|---|
| **Be idempotent.** Running twice must be safe and must not create a second anything. | Install is resumable; a retry after a half-failure re-runs steps that already succeeded. |
| **Never prompt when `OFFICER_NONINTERACTIVE=1`.** Fail with a clear message instead. | A prompt behind a web form is a hang with no output, which is the worst failure to diagnose. |
| **Write only inside `OFFICER_SERVICE_DIR`.** | The app store owns that directory and nothing else. The user's own estate is never touched. |
| **Emit progress on stdout.** | The installer streams it to a terminal panel in the UI, so the user watches it happen rather than staring at a spinner. |
| **Print `OFFICER_RESULT_<KEY>=value` for anything the platform must store.** | How a generated secret or a resolved port gets back to `service_connections` without the installer parsing free text. |
| Rule | Why |
| ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| **Be idempotent.** Running twice must be safe and must not create a second anything. | Install is resumable; a retry after a half-failure re-runs steps that already succeeded. |
| **Never prompt when `OFFICER_NONINTERACTIVE=1`.** Fail with a clear message instead. | A prompt behind a web form is a hang with no output, which is the worst failure to diagnose. |
| **Write only inside `OFFICER_SERVICE_DIR`.** | The app store owns that directory and nothing else. The user's own estate is never touched. |
| **Emit progress on stdout.** | The installer streams it to a terminal panel in the UI, so the user watches it happen rather than staring at a spinner. |
| **Print `OFFICER_RESULT_<KEY>=value` for anything the platform must store.** | How a generated secret or a resolved port gets back to `service_connections` without the installer parsing free text. |
Exit non-zero on failure, with the reason on stderr. The installer records it in `last_error` and the
row stays `failed` rather than pretending to be installed.
+3 -1
View File
@@ -187,7 +187,9 @@ describe('kinds', () => {
test('admin capabilities are never grantable', () => {
const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key));
for (const key of ['user-admin', 'server-admin', 'wallet', 'headscale']) {
// `headscale` was here until 2026-08-15, when it left with offscale. A plugin's permissions are
// registered at install from its manifest, so they are not in this compile-time list by design.
for (const key of ['user-admin', 'server-admin', 'wallet']) {
expect(CAPABILITY_BY_KEY.get(key)?.kind).toBe('admin');
expect(grantable.has(key)).toBe(false);
}
+3 -8
View File
@@ -408,14 +408,9 @@ const CORE_REGISTRY: Capability[] = [
// on this router and stays owner-only — see ownerGate in users-router.ts, which is the second lock.
selfService: ['PUT /'],
},
{
key: 'headscale',
label: 'Headscale',
description: 'The tailnet: machines, routes and ACLs',
kind: 'admin',
api: ['/headscale'],
routes: ['/headscale'],
},
// `headscale` lived here until 2026-08-15, when it left with the rest of offscale. A plugin declares
// its own permissions in its manifest and they are registered at install — see plugins/mount.ts. The
// platform no longer knows this capability exists, which is the entire point.
{
key: 'wallet',
label: 'Wallet',
-2
View File
@@ -25,7 +25,6 @@ import { pluginsRouter } from './api/plugins/router';
// import { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router';
import { agentHandoffRouter } from './api/agent-handoff/router';
// import { slskdRouter } from './api/slskd/router';
import { headscaleRouter } from './api/headscale/router';
// import { transmissionRouter } from './api/transmission/router';
// import { invoiceshelfRouter } from './api/invoiceshelf/router';
// import { jellyfinRouter } from './api/jellyfin/router';
@@ -144,7 +143,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
// ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI — plugin, switched off 2026-08-13
// ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) — plugin, switched off
// ['/notify', notifyRouter], // plugin — switched off 2026-08-13
['/headscale', headscaleRouter],
// ['/transmission', transmissionRouter], // plugin — switched off 2026-08-13
// ['/invoiceshelf', invoiceshelfRouter], // plugin — switched off 2026-08-13
// ['/jellyfin', jellyfinRouter], // plugin — switched off 2026-08-13
+1 -3
View File
@@ -432,9 +432,7 @@ export async function dropPostgresRole(osUser: string): Promise<DropRoleResult>
if (!url) return { ok: false, error: 'POSTGRES_URL is not set' };
try {
const present = await db.execute<{ rolname: string }>(
sql`select rolname from pg_roles where rolname = ${osUser}`,
);
const present = await db.execute<{ rolname: string }>(sql`select rolname from pg_roles where rolname = ${osUser}`);
// Already gone is success, so a retry after a partial teardown finishes rather than refuses.
if (present.length === 0) return { ok: true, removed: false, reassigned: [] };
+34 -14
View File
@@ -93,6 +93,27 @@ export async function installPlugin(appName: string, onStep?: OnStep): Promise<P
const steps: string[] = [];
try {
if (plugin.schema) await step(steps, onStep, 'schema: skipped — not wired yet (see install.ts)');
await recordPluginInstall(appName, plugin.manifest.version);
await step(steps, onStep, `recorded at ${plugin.manifest.version}`);
// MOUNT BEFORE STARTING THE SIDECAR, and the order is not cosmetic.
//
// `createSidecarProxy` learns its sidecar's port from a one-shot event (`<name>:server`), and it
// subscribes when the plugin's router module is first imported — which happens here, at mount. Start
// the process first and it announces its port to nobody: the sidecar is online, the routes are
// mounted, and every request answers `503 sidecar not available` until something makes it reconnect.
//
// Found installing offscale, whose sidecar binds its own HTTP server. `example` never caught it
// because it has no listener to announce.
const { mounted } = await refreshPluginMounts();
await step(
steps,
onStep,
mounted.length ? `mounted: ${mounted.join(', ')}` : 'mounted: nothing (no api/router.ts)',
);
if (hasSidecar(plugin)) {
addPluginToEcosystem(plugin);
await step(steps, onStep, `ecosystem: ${pluginProcessName(appName)} added`);
@@ -106,18 +127,6 @@ export async function installPlugin(appName: string, onStep?: OnStep): Promise<P
await step(steps, onStep, 'sidecar: started');
}
if (plugin.schema) await step(steps, onStep, 'schema: skipped — not wired yet (see install.ts)');
await recordPluginInstall(appName, plugin.manifest.version);
await step(steps, onStep, `recorded at ${plugin.manifest.version}`);
const { mounted } = await refreshPluginMounts();
await step(
steps,
onStep,
mounted.length ? `mounted: ${mounted.join(', ')}` : 'mounted: nothing (no api/router.ts)',
);
return { ok: true, appName, steps };
} catch (err) {
return { ok: false, appName, steps, error: err instanceof Error ? err.message : String(err) };
@@ -166,14 +175,25 @@ export async function setPluginRunning(
await step(steps, onStep, enabled ? 'enabled' : 'disabled');
const plugin = await findPlugin(appName);
// Enabling mounts BEFORE starting, for the same reason install does: the proxy has to be listening
// before the sidecar announces its port. Disabling is the mirror — stop answering, then stop the
// process — so neither direction leaves a mounted route in front of a sidecar that cannot be reached.
if (enabled) {
const { mounted } = await refreshPluginMounts();
await step(steps, onStep, `mounts: ${mounted.join(', ') || 'no plugin routes'}`);
}
if (plugin && hasSidecar(plugin)) {
const name = pluginProcessName(appName);
const result = enabled ? await startProcess(name, PLATFORM_DIR) : await stopProcess(name, PLATFORM_DIR);
await step(steps, onStep, result.ok ? `sidecar: ${enabled ? 'started' : 'stopped'}` : `sidecar: ${result.error}`);
}
const { mounted } = await refreshPluginMounts();
await step(steps, onStep, `mounts: ${mounted.join(', ') || 'no plugin routes'}`);
if (!enabled) {
const { mounted } = await refreshPluginMounts();
await step(steps, onStep, `mounts: ${mounted.join(', ') || 'no plugin routes'}`);
}
return { ok: true, appName, steps };
}
-1
View File
@@ -24,7 +24,6 @@ import { API_URL } from '../../officer-url.mjs';
// machine-facing interface. Same split officer-email already uses.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
-1
View File
@@ -4,7 +4,6 @@ import { startAnthropicProxy, getProxySecret, ensureProxySecret } from './proxy'
import { createSidecarConnector } from '../connect';
import { API_URL } from '../../officer-url.mjs';
// ── Startup ──
if (!acquireLock()) {
+6 -1
View File
@@ -357,7 +357,12 @@ export function startAnthropicProxy() {
headers.delete('x-api-key');
headers.set('Authorization', `Bearer ${token}`);
const existingBeta = headers.get('anthropic-beta');
const betas = existingBeta ? existingBeta.split(',').map((s) => s.trim()).filter(Boolean) : [];
const betas = existingBeta
? existingBeta
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: [];
if (!betas.includes('oauth-2025-04-20')) betas.push('oauth-2025-04-20');
headers.set('anthropic-beta', betas.join(','));
headers.delete('host');
+1 -4
View File
@@ -21,10 +21,7 @@ async function tick() {
console.log(`[email-cron] ${account.email}: ${result.saved} new emails`);
}
} catch (err) {
console.error(
`[email-cron] Failed to resync ${account.email}:`,
err instanceof Error ? err.message : err,
);
console.error(`[email-cron] Failed to resync ${account.email}:`, err instanceof Error ? err.message : err);
}
}
} catch (err) {
+9 -1
View File
@@ -115,7 +115,15 @@ async function connect(w: Watcher): Promise<void> {
function startWatcher(account: Account): void {
if (watchers.has(account.id)) return;
const w: Watcher = { account, client: null, closing: false, syncing: false, pending: false, backoff: RECONNECT_BASE, reconnectTimer: null };
const w: Watcher = {
account,
client: null,
closing: false,
syncing: false,
pending: false,
backoff: RECONNECT_BASE,
reconnectTimer: null,
};
watchers.set(account.id, w);
void connect(w);
}
+1 -3
View File
@@ -668,9 +668,7 @@ const gmailSyncHandler = {
});
}
console.log(
`[gmail-sync] Done: saved ${result.saved}, skipped ${result.skipped}, errors ${result.errors}`,
);
console.log(`[gmail-sync] Done: saved ${result.saved}, skipped ${result.skipped}, errors ${result.errors}`);
setSyncMeta(db, 'last_sync_date', new Date().toISOString().split('T')[0]!);
setSyncMeta(db, 'last_sync_at', new Date().toISOString());
-1
View File
@@ -6,7 +6,6 @@ import { startEmailServer } from './http';
import { createSidecarConnector } from '../connect';
import { API_URL } from '../../officer-url.mjs';
// The sidecar used to reach BACK into the platform's queue over this socket to get a sync run —
// enqueueViaWs / listJobsViaWs and a pending-response map. Syncs run in this process now
// (sync-runner.ts), so the shim is gone and nothing but a port crosses the socket at startup.
-1
View File
@@ -364,7 +364,6 @@ emailRouter.delete('/messages/:id', async (ctx) => {
}
});
emailRouter.get('/sync-status', async (ctx) => {
const user = ctx.get('user');
+67 -18
View File
@@ -122,7 +122,13 @@ const firstAddress = (value: unknown): string => {
* Groups by normalized subject + the counterpart address, so recurring 1:1 conversations collapse
* while unrelated same-subject mail from different people stays apart. Trivial subjects stay ungrouped.
*/
function fallbackThreadId(row: { id: string; subject: unknown; from_address: unknown; to_address: unknown; email_account: unknown }): string {
function fallbackThreadId(row: {
id: string;
subject: unknown;
from_address: unknown;
to_address: unknown;
email_account: unknown;
}): string {
const norm = normalizeSubject(typeof row.subject === 'string' ? row.subject : '');
if (!norm) return row.id;
const me = typeof row.email_account === 'string' ? row.email_account.toLowerCase() : '';
@@ -202,7 +208,13 @@ function migrate(db: Database): void {
function backfillThreadIds(db: Database): void {
const rows = db
.query('SELECT id, subject, from_address, to_address, email_account FROM emails WHERE thread_id IS NULL')
.all() as Array<{ id: string; subject: unknown; from_address: unknown; to_address: unknown; email_account: unknown }>;
.all() as Array<{
id: string;
subject: unknown;
from_address: unknown;
to_address: unknown;
email_account: unknown;
}>;
if (rows.length === 0) return;
const update = db.prepare('UPDATE emails SET thread_id = ? WHERE id = ?');
@@ -243,9 +255,18 @@ function ensureFts(db: Database): void {
}
const ftsDeleteStmt = 'DELETE FROM emails_fts WHERE id = ?';
const ftsInsertStmt = 'INSERT INTO emails_fts (id, subject, sender, recipients, snippet, body) VALUES (?, ?, ?, ?, ?, ?)';
const ftsInsertStmt =
'INSERT INTO emails_fts (id, subject, sender, recipients, snippet, body) VALUES (?, ?, ?, ?, ?, ?)';
function syncFtsRow(db: Database, id: string, subject: string, sender: string, recipients: string, snippet: string, body: string): void {
function syncFtsRow(
db: Database,
id: string,
subject: string,
sender: string,
recipients: string,
snippet: string,
body: string,
): void {
db.run(ftsDeleteStmt, [id]);
db.run(ftsInsertStmt, [id, subject, sender, recipients, snippet, body]);
}
@@ -319,7 +340,12 @@ function parseBranch(q: string): Branch {
return { fts: fts.join(' '), where, params };
}
export function searchEmails(db: Database, q: string, limit: number, offset: number): { rows: Record<string, unknown>[]; total: number } {
export function searchEmails(
db: Database,
q: string,
limit: number,
offset: number,
): { rows: Record<string, unknown>[]; total: number } {
// Split on top-level uppercase OR into branches (Gmail-style; lowercase "or" stays a search word).
const branches = q
.split(/\s+OR\s+/)
@@ -343,7 +369,9 @@ export function searchEmails(db: Database, q: string, limit: number, offset: num
}
const whereSql = `e.deleted = 0 AND (${conds.join(' OR ')})`;
const rows = db.query(`SELECT e.* FROM emails e WHERE ${whereSql} ORDER BY e.date DESC LIMIT ? OFFSET ?`).all(...params, limit, offset) as Record<string, unknown>[];
const rows = db
.query(`SELECT e.* FROM emails e WHERE ${whereSql} ORDER BY e.date DESC LIMIT ? OFFSET ?`)
.all(...params, limit, offset) as Record<string, unknown>[];
const total = (db.query(`SELECT count(*) AS c FROM emails e WHERE ${whereSql}`).get(...params) as { c: number }).c;
return { rows, total };
}
@@ -372,7 +400,8 @@ const upsertEmailStmt = `
`;
const deleteAttachmentsStmt = 'DELETE FROM attachments WHERE email_id = ?';
const insertAttachmentStmt = 'INSERT INTO attachments (email_id, idx, filename, size, content_type, content) VALUES (?, ?, ?, ?, ?, ?)';
const insertAttachmentStmt =
'INSERT INTO attachments (email_id, idx, filename, size, content_type, content) VALUES (?, ?, ?, ?, ?, ?)';
export function upsertEmail(db: Database, email: ParsedEmail): void {
const domain = extractDomain(email.fromAddress);
@@ -404,7 +433,15 @@ export function upsertEmail(db: Database, email: ParsedEmail): void {
db.run(insertAttachmentStmt, [email.id, i, att.filename, att.size, att.contentType, att.content]);
}
syncFtsRow(db, email.id, email.subject ?? '', `${email.fromName ?? ''} ${email.fromAddress ?? ''}`.trim(), `${email.to ?? ''} ${email.cc ?? ''}`.trim(), email.snippet ?? '', email.text ?? '');
syncFtsRow(
db,
email.id,
email.subject ?? '',
`${email.fromName ?? ''} ${email.fromAddress ?? ''}`.trim(),
`${email.to ?? ''} ${email.cc ?? ''}`.trim(),
email.snippet ?? '',
email.text ?? '',
);
db.exec('COMMIT');
} catch (err) {
@@ -439,7 +476,22 @@ export function upsertFromRawEml({ db, id, raw, integration, emailAccount, label
const threadId = computeThreadId(db, id, raw);
db.run(upsertEmailStmt, [
id, integration, emailAccount, name, address, domain, to, cc, subject, date, snippet, html, text, attachments.length, labelsToString(labels), threadId,
id,
integration,
emailAccount,
name,
address,
domain,
to,
cc,
subject,
date,
snippet,
html,
text,
attachments.length,
labelsToString(labels),
threadId,
]);
if (attachments.length > 0) {
@@ -455,9 +507,7 @@ export function upsertFromRawEml({ db, id, raw, integration, emailAccount, label
/** Convert a db row to an EmailSummary for the API */
export function rowToSummary(row: Record<string, unknown>): EmailSummary {
const from = row.from_name
? `${row.from_name} <${row.from_address}>`
: (row.from_address as string);
const from = row.from_name ? `${row.from_name} <${row.from_address}>` : (row.from_address as string);
const labels = labelsFromString(row.labels);
@@ -604,7 +654,7 @@ function normalizeCharset(charset: string): string {
'windows-1252': 'latin1',
'windows-1254': 'latin1',
'us-ascii': 'ascii',
'ascii': 'ascii',
ascii: 'ascii',
};
return map[charset] ?? charset;
}
@@ -691,7 +741,8 @@ function parseAttachments(raw: string): AttachmentMeta[] {
// Walk backwards to find the start of this MIME part's headers
const partStart = raw.lastIndexOf('\n--', pos);
const headerBlock = partStart !== -1 ? raw.slice(partStart, pos + 500) : raw.slice(Math.max(0, pos - 500), pos + 500);
const headerBlock =
partStart !== -1 ? raw.slice(partStart, pos + 500) : raw.slice(Math.max(0, pos - 500), pos + 500);
// Skip inline parts without a filename (e.g. inline text/plain body parts)
const hasFilename = /filename/i.test(headerBlock);
@@ -731,12 +782,10 @@ function parseAttachments(raw: string): AttachmentMeta[] {
content = bodyRaw.replace(/\s/g, '');
} else {
// For quoted-printable or 7bit/8bit, re-encode to base64
const buf = encoding === 'quoted-printable'
? decodeQuotedPrintableBytes(bodyRaw)
: Buffer.from(bodyRaw);
const buf = encoding === 'quoted-printable' ? decodeQuotedPrintableBytes(bodyRaw) : Buffer.from(bodyRaw);
content = buf.toString('base64');
}
size = Math.floor(content.length * 3 / 4);
size = Math.floor((content.length * 3) / 4);
}
results.push({ filename, size, contentType, content });
-1
View File
@@ -43,7 +43,6 @@ import { API_URL } from '../../officer-url.mjs';
// sidecar from being a general-purpose SSRF hop into whatever else is on that host.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// Everything under /api/v1 the UI legitimately needs.
//
// `/api/v1/admin/*` is excluded ON PURPOSE and should stay excluded. A Gitea token minted by a site
-20
View File
@@ -1,20 +0,0 @@
import { getActiveHeadscaleCredentials } from 'officerdb';
import { createClient, type HeadscaleClient } from './client';
// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That
// choice lives in Postgres (one row, enforced by a partial unique index), not in a request parameter, so
// no client can act on a server the owner isn't currently looking at by guessing an id.
/**
* The client for the active server, or a ready-to-send 409 when there isn't one.
*
* 409 rather than 404: the route exists and the request was well-formed, the account just has no server
* selected yet. The UI maps it to "pick a server", which is a different message from "that node is gone".
*/
export async function activeClient(userId: number): Promise<HeadscaleClient | Response> {
const creds = await getActiveHeadscaleCredentials(userId);
if (!creds) {
return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 });
}
return createClient(creds);
}
-153
View File
@@ -1,153 +0,0 @@
import type { OfficerContext } from './routes';
import { badRequest, methodNotAllowed, readJson } from './routes';
import { activeClient } from './active';
import { arrayField, toNode, toUser } from './normalize';
import { askClaude, ProxyUnavailable } from './claude-proxy';
// `POST /_officer/policy/assist` — describe a change in English, get a complete revised policy back.
//
// The ACL is the one document in this app that nobody can write from memory: HuJSON, Tailscale's grammar,
// and every rule keyed to user, tag and host names that only this server knows. The gap this closes is not
// "typing is slow", it is "I do not know what the file is supposed to look like".
//
// THREE THINGS THIS DELIBERATELY DOES NOT DO.
//
// 1. **It never saves.** The proposal comes back as text and lands in the editor as a draft. Headscale is
// written to by exactly one thing, the Save button, and it is pressed by a person who has read the diff.
// A model that could write the ACL directly is a model that can partition the network the owner is
// connected through — including the SSH route back in to fix it.
// 2. **It never validates.** Same argument as policy.ts: Headscale owns the only parser that counts, and a
// proposal that looks fine here and is refused on save is a normal, visible outcome.
// 3. **It sends no credentials.** The prompt carries user names, node names and tags — the vocabulary the
// rules must reference — and nothing else. No API keys, no pre-auth keys, no node addresses.
//
// The current document is sent in full and the reply must be the full replacement, not a patch. Patches
// against a hand-formatted HuJSON file are where comments and alignment get silently destroyed, and this
// file's whole premise is that those are worth keeping.
const MODEL = 'claude-sonnet-5';
const MAX_TOKENS = 8_000;
/** A prompt long enough to be an essay is a prompt that should be a conversation. Cheap guard, not a limit. */
const MAX_PROMPT_CHARS = 2_000;
/** Enough context to write rules against without pasting an entire large tailnet into the request. */
const MAX_NODES = 60;
const SYSTEM = `You are helping the owner of a self-hosted Headscale server edit their tailnet's ACL policy.
The policy is a HuJSON document (JSON with // comments and trailing commas) in Tailscale's ACL format:
groups, tagOwners, hosts, acls, ssh, autoApprovers. Headscale implements a subset — it has no Tailscale SaaS
features such as nodeAttrs postures, and grants are supported only in recent versions, so prefer classic
"acls" entries unless the existing document already uses grants.
Rules for your reply, in this order:
1. First, one short paragraph of plain English: what you changed and, where it matters, what it now allows or
denies. No preamble, no restating the request.
2. Then the COMPLETE new policy document inside a single fenced code block tagged hujson. Not a patch, not an
excerpt — the whole file, ready to replace what is there.
Preserve the existing document's comments, key order and indentation wherever your change does not touch
them; they are hand-maintained and the owner reads this file. Only reference users, tags and hosts that exist
in the context given to you, or that you also define in the same document. If the request is ambiguous enough
that you would have to guess at something consequential, say so in the paragraph and make the narrower,
safer choice rather than asking a question — the owner reviews a diff before anything is saved.
If the request cannot be expressed in this policy at all, say why in the paragraph and return the document
unchanged in the code block.`;
type TailnetContext = { users: string[]; tags: string[]; nodes: string[] };
/**
* The vocabulary a usable rule has to be written in: who exists, what tags are in use, what the machines are
* called. Best-effort — a server that will not answer these still gets an assistant, just a less informed
* one, which beats failing the request over context that is an optimisation.
*/
async function readContext(userId: number): Promise<TailnetContext> {
const client = await activeClient(userId);
if (client instanceof Response) return { users: [], tags: [], nodes: [] };
try {
const [userBody, nodeBody] = await Promise.all([client.call('/api/v1/user'), client.call('/api/v1/node')]);
const users = arrayField(userBody, 'users')
.map((raw) => toUser(raw)?.name)
.filter((name): name is string => !!name);
const nodes = arrayField(nodeBody, 'nodes').map(toNode);
const tags = [...new Set(nodes.flatMap((node) => node.tags))].sort();
const named = nodes.slice(0, MAX_NODES).map((node) => {
const owner = node.user?.name ?? 'unknown';
const tagged = node.tags.length ? ` [${node.tags.join(' ')}]` : '';
return `${node.name} (user: ${owner})${tagged}`;
});
return { users, tags, nodes: named };
} catch {
return { users: [], tags: [], nodes: [] };
}
}
function buildPrompt(policy: string, request: string, context: TailnetContext): string {
const lines = [
'Current policy document:',
'```hujson',
policy.trim() || '// (this server has no policy yet)',
'```',
'',
'This tailnet:',
`- users: ${context.users.length ? context.users.join(', ') : '(none)'}`,
`- tags in use: ${context.tags.length ? context.tags.join(', ') : '(none)'}`,
`- machines: ${context.nodes.length ? context.nodes.join('; ') : '(none)'}`,
];
if (context.nodes.length === MAX_NODES) lines.push(` (first ${MAX_NODES} shown)`);
lines.push('', 'Requested change:', request.trim());
return lines.join('\n');
}
/**
* Split the reply into the explanation and the document.
*
* The fence is the contract, so a reply without one is a failure to report rather than something to salvage:
* feeding half an answer into the editor as if it were a policy is worse than saying the model didn't comply.
*/
function splitReply(text: string): { explanation: string; policy: string } | null {
const match = text.match(/```(?:hujson|json|jsonc)?\s*\n([\s\S]*?)```/);
if (!match || !match[1]?.trim()) return null;
return { explanation: text.slice(0, match.index).trim(), policy: match[1].replace(/\s+$/, '') };
}
/** `POST /_officer/policy/assist {prompt, policy}` → `{explanation, policy}`. Nothing is written upstream. */
export async function handlePolicyAssistRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
if (rest.length > 0) return badRequest('unexpected path');
if (ctx.req.method !== 'POST') return methodNotAllowed();
const body = await readJson(ctx.req);
const request = typeof body?.prompt === 'string' ? body.prompt.trim() : '';
if (!request) return badRequest('prompt is required');
if (request.length > MAX_PROMPT_CHARS) return badRequest(`prompt must be under ${MAX_PROMPT_CHARS} characters`);
// The draft on screen, not the saved document: the owner may have edited it, and a proposal built against
// a version they cannot see would come back as a diff full of changes they never asked for.
const policy = typeof body?.policy === 'string' ? body.policy : '';
const context = await readContext(ctx.userId);
try {
const reply = await askClaude({
model: MODEL,
maxTokens: MAX_TOKENS,
system: SYSTEM,
prompt: buildPrompt(policy, request, context),
});
const split = splitReply(reply);
if (!split) {
return Response.json({ error: 'the model did not return a policy document — try rephrasing' }, { status: 502 });
}
return Response.json({ explanation: split.explanation, policy: split.policy });
} catch (err) {
if (err instanceof ProxyUnavailable) {
return Response.json({ error: err.message, code: 'assistant_unavailable' }, { status: 503 });
}
throw err;
}
}
@@ -1,97 +0,0 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH } from '../../data-path';
import { ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
// One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent.
//
// The target is `officer-anthropic-proxy` on loopback — the same process Claude Code itself talks to. It
// holds the owner's OAuth credential and refreshes it; callers hold nothing. Its `x-api-key` is a locally
// generated secret it writes to its own state file, so authenticating is a file read, not a credential this
// sidecar is given. That file is written by the proxy and read by everyone else; see claude/state.ts
// (`readProxySecretFromDisk`), which does the same thing for the agent.
//
// Not imported from claude/state.ts on purpose: that module initialises paths and a lock for a sidecar this
// one is not. Twenty lines of file read is a better dependency than another sidecar's lifecycle.
//
// This is a REQUEST-SCOPED call with a timeout, not a session. Anything conversational belongs in the chat
// surface, which already exists and already persists.
const DEFAULT_TIMEOUT_MS = 120_000;
/** The proxy is not running, has no token, or refused us. Distinct from the model declining to answer. */
export class ProxyUnavailable extends Error {}
/**
* The proxy's own generated secret. Empty means "not on disk yet" — it persists on a debounce, so a
* freshly installed machine has a window where the file exists without it.
*/
function readProxySecret(): string {
try {
const file = join(DATA_PATH, 'sidecar', 'claude-state.json');
if (!existsSync(file)) return '';
const parsed = JSON.parse(readFileSync(file, 'utf-8')) as { proxySecret?: unknown };
return typeof parsed.proxySecret === 'string' ? parsed.proxySecret : '';
} catch {
return '';
}
}
type AskParams = { model: string; system: string; prompt: string; maxTokens: number; timeoutMs?: number };
type MessagesResponse = { content?: { type?: string; text?: string }[]; error?: { message?: string } };
/**
* One user turn, one reply, as plain text.
*
* The system prompt is sent as two blocks with Claude Code's own identity first. The proxy authenticates
* with a Claude Pro/Max OAuth token, and that credential is issued to the CLI — asking it to be something
* else is a request the upstream is entitled to refuse. (Measured 2026-08-06: a plain assistant prompt is
* currently accepted too. Keeping the block costs ~14 tokens and removes the question.)
*/
export async function askClaude({ model, system, prompt, maxTokens, timeoutMs }: AskParams): Promise<string> {
const secret = readProxySecret();
if (!secret) throw new ProxyUnavailable('the Claude proxy has not started yet — try again in a moment');
let res: Response;
try {
res = await fetch(`${ANTHROPIC_PROXY_URL}/v1/messages`, {
method: 'POST',
headers: { 'x-api-key': secret, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
body: JSON.stringify({
model,
max_tokens: maxTokens,
system: [
{ type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." },
{ type: 'text', text: system },
],
messages: [{ role: 'user', content: prompt }],
}),
signal: AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS),
});
} catch (err) {
if (err instanceof Error && err.name === 'TimeoutError') throw new ProxyUnavailable('the model took too long');
throw new ProxyUnavailable('the Claude proxy is not reachable');
}
const body = (await res.json().catch(() => null)) as MessagesResponse | null;
if (!res.ok) {
const detail = body?.error?.message;
// 401/429 are the proxy's own credential problems and read as "unavailable"; anything else is upstream
// saying something specific about this request, which is worth passing through.
if (res.status === 401 || res.status === 429) {
throw new ProxyUnavailable(detail ?? `the Claude proxy returned ${res.status}`);
}
throw new Error(detail ?? `the model returned ${res.status}`);
}
const text = (body?.content ?? [])
.filter((block) => block.type === 'text' && typeof block.text === 'string')
.map((block) => block.text)
.join('')
.trim();
if (!text) throw new Error('the model returned an empty reply');
return text;
}
-113
View File
@@ -1,113 +0,0 @@
import type { HeadscaleServerCredentials } from 'officerdb';
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
// wire-level quirks are handled once:
//
// • Auth is `Authorization: Bearer <apiKey>`. Headscale's swagger declares no securityDefinitions at all,
// so a generated client would omit it entirely.
// • 401/403 bodies are PLAIN TEXT ("Unauthorized"), with no content-type — every other error is
// grpc-gateway `{code,message,details}` JSON. Blindly .json()-ing an error body throws on exactly the
// auth failure you most want to report clearly.
// • Every uint64 is serialized as a JSON STRING, not a number: `node.id` arrives as "7". We keep ids as
// strings end to end and never round-trip them through Number, which would silently break above 2^53.
// • The gateway marshals with EmitUnpopulated, so absent values come back as [] / null / "" / false rather
// than being omitted. You cannot distinguish "unset" from "empty" — don't try.
// • It also marshals with DiscardUnknown, so a misspelled request field is IGNORED rather than rejected.
// Silent no-ops are the failure mode; mutations here read the object back where the API returns it.
const DEFAULT_TIMEOUT_MS = 15_000;
/** An upstream failure carrying the HTTP status to surface, mapped to a response at the route boundary. */
export class HeadscaleError extends Error {
constructor(
readonly status: number,
message: string,
/**
* Headscale's own words, kept even when `message` generalizes them.
*
* A 5xx is normally not safe to relay — it leaks internals and rarely helps. The policy endpoints are
* the exception: Headscale answers "policy is read from a file" and reports a HuJSON syntax error's
* line and column with the same 500, and there the message IS the feature. Callers that know their
* endpoint's 5xx is a real answer read this; everyone else keeps getting "headscale error".
*/
readonly detail?: string,
) {
super(message);
this.name = 'HeadscaleError';
}
}
type CallOptions = { method?: string; body?: unknown; timeoutMs?: number };
/**
* Extract a human-usable message from a Headscale error response, tolerating both of its formats.
* Never returned verbatim to the browser for auth failures — see callers.
*/
async function errorMessage(res: Response): Promise<string> {
const text = await res.text().catch(() => '');
if (!text) return `upstream returned ${res.status}`;
try {
const parsed = JSON.parse(text) as { message?: unknown };
if (typeof parsed.message === 'string' && parsed.message) return parsed.message;
} catch {
/* plain text — the 401 case */
}
return text.slice(0, 300);
}
export type HeadscaleClient = {
readonly serverId: number;
/** Call an admin API path (e.g. `/api/v1/node`). Throws HeadscaleError on any non-2xx. */
call: <T>(path: string, opts?: CallOptions) => Promise<T>;
};
/** Build a client bound to one registered server's credentials. */
export function createClient(creds: HeadscaleServerCredentials): HeadscaleClient {
async function call<T>(path: string, opts: CallOptions = {}): Promise<T> {
const { method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
const headers: Record<string, string> = {
authorization: `Bearer ${creds.apiKey}`,
accept: 'application/json',
};
if (body !== undefined) headers['content-type'] = 'application/json';
let res: Response;
try {
res = await fetch(`${creds.url}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
const timedOut = err instanceof Error && err.name === 'TimeoutError';
throw new HeadscaleError(504, timedOut ? 'headscale timed out' : 'headscale unreachable');
}
if (res.status === 401 || res.status === 403) {
// The stored key is wrong, expired, or was revoked on the server. Actionable, and distinct from an
// Officer-side auth problem — the UI should point the owner at re-entering the key.
throw new HeadscaleError(502, 'headscale rejected the stored API key');
}
if (!res.ok) {
const message = await errorMessage(res);
console.error(`[headscale] ${method} ${path} -> ${res.status}: ${message}`);
// 4xx from the admin API is usually a bad argument and safe to relay; 5xx is not, so it's generalized.
const serverSide = res.status >= 500;
throw new HeadscaleError(serverSide ? 502 : res.status, serverSide ? 'headscale error' : message, message);
}
// 204 and empty 200s are legitimate: several mutations return `{}` or nothing at all.
const text = await res.text();
if (!text) return {} as T;
try {
return JSON.parse(text) as T;
} catch {
throw new HeadscaleError(502, 'headscale returned a non-JSON body');
}
}
return { serverId: creds.id, call };
}
-204
View File
@@ -1,204 +0,0 @@
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from 'officerdb';
import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes';
// The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the
// admin API structurally cannot: is the container up, what do its logs say, and start/stop/restart it.
// Contract: COMMS/HEADSCALE_COMPANION_API.md.
//
// Three facts shape everything here.
//
// 1. It lives at `${server.url}/officer-api` and authenticates with the SAME admin API key we already
// store, validated locally against Headscale's own key store — so auth keeps working while Headscale
// is down, which is exactly when `/restart` matters. Nothing new to register, and the key still never
// leaves this sidecar.
//
// 2. It is OPTIONAL and per-server. Of the four servers registered here today, one has it deployed. So
// "no companion" is a normal state, not an error: every route below answers 200 with
// `{available: false, reason}` rather than failing, and the UI degrades to what the admin API can do.
// Distinguishing the two 502s is the whole trick — nginx returns HTML when the companion is down,
// the companion returns JSON when a docker op fails. Branch on whether the body parses.
//
// 3. `GET /health` is ALWAYS 200, at every verdict. Never key anything off its HTTP status; read
// `verdict`. That inversion is deliberate on their side and is preserved on ours.
/**
* Every route answers `{available: true, ...}` or `{available: false, reason}` at HTTP 200. Not having a
* companion is a state to render, not a request that failed — the admin API on the same domain is
* independent and may still be working, so this must not surface as an error the UI swallows.
*/
export const unavailable = (reason: string) => ({ available: false as const, reason });
const DEFAULT_TIMEOUT_MS = 20_000;
type CompanionCall = { path: string; method?: string; body?: unknown; timeoutMs?: number; signal?: AbortSignal };
/**
* One request to a server's companion. Returns the raw Response, or a reason string when the companion
* itself could not be reached — the caller decides how to present that, because for this feature
* "unreachable" is information rather than a failure.
*/
export async function callCompanion(
creds: HeadscaleServerCredentials,
{ path, method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS, signal }: CompanionCall,
): Promise<Response | string> {
let res: Response;
try {
res = await fetch(`${creds.url}/officer-api${path}`, {
method,
headers: {
authorization: `Bearer ${creds.apiKey}`,
accept: 'application/json',
...(body === undefined ? {} : { 'content-type': 'application/json' }),
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: signal ?? AbortSignal.timeout(timeoutMs),
});
} catch (err) {
if (err instanceof Error && err.name === 'TimeoutError') return 'the companion timed out';
// A TLS failure or DNS miss on the server's own domain: the whole host is unreachable, not just this.
return 'could not reach the server';
}
if (res.status === 401) return 'the companion rejected the stored API key';
// Both 404 and 502 are ambiguous, and the same test settles both: a JSON body means the companion
// answered (no such container / the docker op failed) and that answer belongs to the caller; a
// non-JSON body means we never reached it — nginx's own 502 page, or a route that isn't there at all.
const isJson = (res.headers.get('content-type') ?? '').includes('json');
if (res.status === 404 && !isJson) return 'this server has no companion at /officer-api';
if (res.status === 502 && !isJson) return 'the companion is not deployed on this server';
if (res.status >= 500 && !isJson) return `the companion returned ${res.status}`;
return res;
}
/** Parse a companion JSON body, or a reason when it isn't JSON after all. */
export async function readBody(res: Response): Promise<Record<string, unknown> | string> {
const text = await res.text().catch(() => '');
if (!text) return 'the companion returned an empty body';
try {
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== 'object') return 'the companion returned an unexpected body';
return parsed as Record<string, unknown>;
} catch {
return 'the companion returned a non-JSON body';
}
}
/** The active server's credentials, or a 409 the UI already knows how to render. */
export async function activeCreds(userId: number): Promise<HeadscaleServerCredentials | Response> {
const creds = await getActiveHeadscaleCredentials(userId);
if (!creds) {
return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 });
}
return creds;
}
/** `GET /_officer/companion/health` — verdict, container state and, when unhappy, its own diagnosis. */
async function health(creds: HeadscaleServerCredentials): Promise<Response> {
const res = await callCompanion(creds, { path: '/health' });
if (typeof res === 'string') return Response.json(unavailable(res));
const body = await readBody(res);
if (typeof body === 'string') return Response.json(unavailable(body));
// Passed through as-is. The companion owns this vocabulary and versions it; re-shaping it here would mean
// a new verdict or a new likely-cause silently disappearing on the way to the screen.
return Response.json({ available: true, health: body });
}
/** `GET /_officer/companion/logs?tail=N` — a snapshot of the last N lines. */
async function logs(creds: HeadscaleServerCredentials, url: URL): Promise<Response> {
const tail = Number(url.searchParams.get('tail') ?? 200);
if (!Number.isInteger(tail) || tail < 1 || tail > 2000) return badRequest('tail must be 12000');
const res = await callCompanion(creds, { path: `/logs?tail=${tail}` });
if (typeof res === 'string') return Response.json(unavailable(res));
const body = await readBody(res);
if (typeof body === 'string') return Response.json(unavailable(body));
const lines = Array.isArray(body.lines) ? body.lines.filter((l): l is string => typeof l === 'string') : [];
return Response.json({ available: true, lines });
}
/**
* `GET /_officer/companion/logs/stream?tail=N` — the live tail, relayed frame for frame.
*
* The browser cannot open this itself: EventSource sends no Authorization header, and the key it would need
* is one this sidecar exists to keep. So the stream is proxied, and the body is returned UNTOUCHED — a
* ReadableStream that Bun cancels when the client disconnects, which aborts the upstream fetch in turn.
* Buffering it into frames here would break that, and would also mean a log line waiting on our own flush.
*/
async function logStream(creds: HeadscaleServerCredentials, ctx: OfficerContext): Promise<Response> {
const tail = Number(ctx.url.searchParams.get('tail') ?? 200);
if (!Number.isInteger(tail) || tail < 1 || tail > 2000) return badRequest('tail must be 12000');
// No timeout: a quiet log is the normal case and must not look like a dropped connection. The request's
// own signal is the lifetime — when the panel closes, this closes.
const res = await callCompanion(creds, {
path: `/logs?tail=${tail}&follow=1`,
signal: ctx.req.signal,
});
// An unavailable companion still answers in the stream's own vocabulary, so the client has one parser and
// one place to show a problem rather than a second, JSON-shaped failure mode.
if (typeof res === 'string') {
return new Response(`event: error\ndata: ${res}\n\n`, {
headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' },
});
}
return new Response(res.body, {
status: 200,
headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
// Belt and braces through our own proxy chain, matching what the companion already sets.
'x-accel-buffering': 'no',
},
});
}
const ACTIONS = new Set(['restart', 'stop', 'start']);
/**
* `POST /_officer/companion/:action` — restart / stop / start the Headscale container.
*
* Every one of these drops every node's control-plane connection for the duration. That is the intended
* "kill it" behaviour and the reason the UI asks twice; it is not something to retry automatically.
*/
async function action(creds: HeadscaleServerCredentials, name: string): Promise<Response> {
// 60s: docker restart on a busy container is not fast, and a timeout here reads as "did it work?" — the
// one question this feature exists to answer.
const res = await callCompanion(creds, { path: `/${name}`, method: 'POST', timeoutMs: 60_000 });
if (typeof res === 'string') return Response.json(unavailable(res));
const body = await readBody(res);
if (typeof body === 'string') return Response.json(unavailable(body));
return Response.json({ available: true, ...body });
}
/** Dispatch `/_officer/companion/...`. Always acts on the ACTIVE server, like every other domain route. */
export async function handleCompanionRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
const creds = await activeCreds(ctx.userId);
if (creds instanceof Response) return creds;
const [head, tail] = rest;
if (head === 'health' && !tail) {
if (ctx.req.method !== 'GET') return methodNotAllowed();
return health(creds);
}
if (head === 'logs') {
if (ctx.req.method !== 'GET') return methodNotAllowed();
if (!tail) return logs(creds, ctx.url);
if (tail === 'stream') return logStream(creds, ctx);
return notFound();
}
if (head && ACTIONS.has(head) && !tail) {
if (ctx.req.method !== 'POST') return methodNotAllowed();
return action(creds, head);
}
return notFound();
}
-103
View File
@@ -1,103 +0,0 @@
import type { OfficerContext } from './routes';
import type { OfficerUser } from './normalize';
import { getActiveHeadscaleCredentials } from 'officerdb';
import { badRequest, methodNotAllowed, readJson } from './routes';
import { createClient, type HeadscaleClient } from './client';
import { arrayField, toUser } from './normalize';
import { handleInvitesRoute } from './invites';
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
//
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` (deleted 2026-08-14) read
// HEADSCALE_URL, HEADSCALE_API_KEY
// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
// those two before it gets anywhere near the user name. Enrolment acts on the ACTIVE registered server now,
// like every other domain route here, and the platform holds no Headscale credentials at all.
//
// The response shape `{controlUrl, authKey}` is a CONTRACT: enrollVpn() in the mobile core
// (monorepo-mobile/packages/core/src/services/officer-net.ts) destructures exactly those two fields and
// feeds them to configure()/loginWithAuthKey(). Extra fields are safe; renaming those two is not.
/** Short by design: the key is redeemed seconds after it is issued, and a leaked one should die quickly. */
const KEY_TTL_MS = 10 * 60_000;
/**
* Which Headscale user the joining device is filed under.
*
* An explicit `userId` wins. Otherwise the choice is only made when it is UNAMBIGUOUS — one user on the
* server means there is nothing to choose. Several means the caller has to say, because picking silently
* files someone's phone under the wrong owner and the mistake stays invisible until somebody audits the
* tailnet. The old env var picked one name for every server at once, which is precisely that bug.
*/
async function resolveOwner(client: HeadscaleClient, ctx: OfficerContext): Promise<OfficerUser | Response> {
const body = await readJson(ctx.req);
const requested = typeof body?.userId === 'string' ? body.userId.trim() : '';
const listed = await client.call('/api/v1/user');
const users = arrayField(listed, 'users')
.map(toUser)
.filter((u): u is OfficerUser => !!u);
if (requested) {
const match = users.find((u) => u.id === requested);
return match ?? badRequest(`no Headscale user with id ${requested} on the active server`);
}
if (users.length === 1) return users[0]!;
if (users.length === 0) {
return Response.json(
{ error: 'the active Headscale server has no users — create one before enrolling a device', code: 'no_users' },
{ status: 409 },
);
}
return Response.json(
{
error: 'the active Headscale server has several users — pass userId to say which one owns this device',
code: 'ambiguous_user',
users: users.map((u) => ({ id: u.id, name: u.name })),
},
{ status: 409 },
);
}
export async function handleEnrollRoute(ctx: OfficerContext, segments: string[]): Promise<Response | null> {
// `/enroll/invites…` is the admin invite surface — a different flow entirely (see invites.ts): the device
// is not here and there is no Officer session on it. Same prefix because it is the same feature to the
// person using it, and because the spec names it that way.
if (segments[0] === 'invites') return handleInvitesRoute(ctx, segments.slice(1));
if (segments.length > 0) return null;
if (ctx.req.method !== 'POST') return methodNotAllowed();
// Not activeClient(): the control URL goes back to the device, and only the credentials carry it.
const creds = await getActiveHeadscaleCredentials(ctx.userId);
if (!creds) {
return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 });
}
const client = createClient(creds);
const owner = await resolveOwner(client, ctx);
if (owner instanceof Response) return owner;
const created = await client.call<{ preAuthKey?: { key?: string } }>('/api/v1/preauthkey', {
method: 'POST',
body: {
user: owner.id,
reusable: false, // one key, one device
ephemeral: false, // the node stays registered after it disconnects
expiration: new Date(Date.now() + KEY_TTL_MS).toISOString(), // RFC3339
},
});
const authKey = created.preAuthKey?.key;
if (!authKey) return Response.json({ error: 'headscale returned no key' }, { status: 502 });
// `server` and `user` are advisory — for a UI that wants to say what the device just joined.
return Response.json({ controlUrl: creds.url, authKey, server: creds.name, user: owner.name });
}
-149
View File
@@ -1,149 +0,0 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { handleOfficerRoute } from './routes';
import { MIN_VERSION_LABEL } from './version';
import { API_URL } from '../../officer-url.mjs';
// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and
// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform
// API is a thin auth-gated forwarder (src/servers/api/headscale/router.ts) holding no Headscale credentials.
//
// Officer manages MANY Headscale servers, not one. The owner registers each with a URL and an API key
// generated on that server, and switches between them; one is active at a time. So configuration lives in
// Postgres (headscale_servers, keys encrypted at rest), NOT in env vars — this sidecar deliberately reads
// neither HEADSCALE_URL nor HEADSCALE_API_KEY, so a registered server can never be shadowed by host env.
// Device enrollment used to be the exception, minting keys in the platform from those two vars plus
// HEADSCALE_USER; it moved here (enroll.ts) and now acts on the active server like everything else.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/headscale mount prefix before forwarding.
//
// GET /_health ours. Sidecar liveness only. Per-server reachability is a
// different question and needs an owner, so it lives below.
// GET /_officer/servers registered servers (never includes API keys)
// POST /_officer/servers register {name?,url,apiKey} — validated before it is saved
// PATCH /_officer/servers/:id edit; re-validated when url or apiKey changes
// DELETE /_officer/servers/:id deregister; promotes the newest survivor if it was active
// POST /_officer/servers/:id/activate switch the active server
// GET /_officer/servers/:id/health probe: reachable? version? key still accepted?
//
// Everything below acts on the ACTIVE server. 409 when none is selected — see active.ts.
//
// GET /_officer/nodes nodes, normalized; ?user=<username> filters
// GET /_officer/nodes/:id one node
// DELETE /_officer/nodes/:id remove it from the tailnet
// POST /_officer/nodes/:id/rename {name}
// POST /_officer/nodes/:id/tags {tags} — 'tag:' prefix added if missing
// POST /_officer/nodes/:id/routes {routes} whole set, or {route,approved} single toggle (RMW here)
// POST /_officer/nodes/:id/expire expire its key, forcing re-auth (not a delete)
// GET /_officer/users users, each with a node count the admin API doesn't provide
// POST /_officer/users {name, displayName?, email?}
// POST /_officer/users/:id/rename {name}
// DELETE /_officer/users/:id refused upstream while the user still owns nodes
// GET /_officer/keys pre-auth keys, secrets masked, with a derived status
// POST /_officer/keys {userId, reusable?, ephemeral?, expirationDays?, aclTags?}
// → the ONLY response carrying the real secret
// POST /_officer/keys/:id/expire expire without deleting
// DELETE /_officer/keys/:id delete outright
// POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key
// for a joining device. userId is only required when the server
// has more than one user.
// NO CALLER since 2026-08-14: its only door was /api/vpn/enroll,
// which is deleted. Kept because it is the handler a route under
// /api/offscale would reuse, and because `/enroll/invites` — which
// IS live — dispatches through the same function.
// anything else 404
//
// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly
// below 0.29 and its ids are uint64-as-JSON-string, so proxying raw would push all of that into the browser
// — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const p = probe.port;
probe.stop(true);
if (p == null) throw new Error('failed to acquire a free port');
return p;
}
const port = getFreePort();
const server = Bun.serve({
port,
hostname: '127.0.0.1',
async fetch(req) {
const url = new URL(req.url);
// Liveness, not upstream health: with many registered servers there is no single upstream to probe, and
// choosing one would need an authenticated owner. See /_officer/servers/:id/health for that.
if (url.pathname === '/_health') {
return Response.json({ ok: true, minHeadscaleVersion: MIN_VERSION_LABEL });
}
if (url.pathname.startsWith('/_officer/')) {
try {
const res = await handleOfficerRoute(req, url);
return res ?? new Response('not found', { status: 404 });
} catch (err) {
console.error(`[headscale] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
return new Response('not found', { status: 404 });
},
});
console.log(`[headscale] listening on 127.0.0.1:${port} (Headscale >=${MIN_VERSION_LABEL})`);
// ── Command handlers ──
type ReplyFn = (msg: SidecarEvent) => void;
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply({ type: 'pong', id: cmd.id });
break;
default:
reply({
type: 'error',
id: (cmd as SidecarCommand).id,
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
});
}
}
// ── Connect to API server ──
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'headscale',
capabilities: ['headscale'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
onConnected() {
// Tell the API where we're listening, so it can forward /api/headscale/* here.
connection.send({ type: 'headscale:server', port });
console.log(`[headscale] reported port ${port} to API`);
},
});
// ── Graceful shutdown ──
function shutdown(signal: string) {
console.log(`[headscale] ${signal} received, shutting down...`);
try {
server.stop(true);
} catch {
/* already stopped */
}
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
-183
View File
@@ -1,183 +0,0 @@
import type { HeadscaleServerCredentials } from 'officerdb';
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
// Enrolment invites — the admin half of COMMS/OFFSCALE_INVITE_ENROLLMENT.md. An admin mints a single-use
// invite, sends the link to whoever needs to join, and their phone exchanges the claim token for a pre-auth
// key it never had to be told.
//
// WHY THESE PROXY THE COMPANION RATHER THAN LIVING HERE. The invite store has to sit somewhere the joining
// phone can reach without an Officer account, and this sidecar is not that: it binds loopback on an
// ephemeral port behind Officer's auth. The spec's own argument settles it — an invite must still work when
// the platform is down, because the tailnet is often how you reach the platform. So the invite records, the
// token hashing and the claim endpoint belong next to Headscale, on its public origin, which is exactly what
// the Officer Companion already is. Officer is the admin surface and nothing more: create, list, revoke.
//
// Officer therefore stores no invite and no token. §5: "Never display, log or store the claim token beyond
// the moment it is handed to the admin." The create response passes through this process once, in memory,
// on its way to the browser — that is the whole of its life here.
//
// A server without the enrolment API answers `{available: false, reason}` at HTTP 200, like every other
// companion route: most registered servers have no companion at all, and that is a state to render rather
// than a request that failed.
/**
* Where the invite API sits on the companion, under its own `/officer-api` mount — so the full URL is
* `${server.url}/officer-api/api/v1/enroll/invites`. Versioned separately from the companion's container
* routes (`/health`, `/logs`, `/restart`), which are unversioned; one constant so the two cannot drift.
*/
const INVITES_PATH = '/api/v1/enroll/invites';
/** Spec §4.1: default 900, max 86400. The floor is ours — a sub-minute invite cannot be sent to anyone. */
const DEFAULT_TTL_SECONDS = 900;
const MIN_TTL_SECONDS = 60;
const MAX_TTL_SECONDS = 86_400;
type CreateInput = {
user: string;
ttlSeconds: number;
ephemeral: boolean;
tags: string[];
note?: string;
};
/** Validate the admin's form into the companion's request body, or a 400 saying which field was wrong. */
function parseCreate(body: Record<string, unknown> | null): CreateInput | Response {
const user = typeof body?.user === 'string' ? body.user.trim() : '';
if (!user) return badRequest('user is required — an invite files the joining device under one Headscale user');
const raw = body?.ttlSeconds;
const ttlSeconds = raw === undefined || raw === null ? DEFAULT_TTL_SECONDS : Number(raw);
if (!Number.isInteger(ttlSeconds) || ttlSeconds < MIN_TTL_SECONDS || ttlSeconds > MAX_TTL_SECONDS) {
return badRequest(`ttlSeconds must be an integer between ${MIN_TTL_SECONDS} and ${MAX_TTL_SECONDS}`);
}
// Tags are admin-set and passed through opaquely (spec §9.2). The `tag:` prefix is Headscale's, and
// adding it here means the admin can type either form without minting a key that silently has no tag.
const tags = Array.isArray(body?.tags)
? [
...new Set(
body.tags
.filter((t): t is string => typeof t === 'string')
.map((t) => t.trim())
.filter(Boolean)
.map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)),
),
]
: [];
const note = typeof body?.note === 'string' ? body.note.trim().slice(0, 200) : '';
return { user, ttlSeconds, ephemeral: body?.ephemeral === true, tags, ...(note ? { note } : {}) };
}
/**
* Turn a companion answer into ours.
*
* The three cases are distinct and the UI needs them to stay that way: unreachable is `available: false`
* (render an explanation), a companion refusal keeps its own status and message (the admin typed something
* the server rejected), and success is the body with `available: true` on it.
*/
async function relay(res: Response | string, wrap: (body: Record<string, unknown>) => unknown): Promise<Response> {
if (typeof res === 'string') return Response.json(unavailable(res));
const body = await readBody(res);
if (typeof body === 'string') return Response.json(unavailable(body));
if (!res.ok) {
const error = typeof body.error === 'string' ? body.error : `the companion returned ${res.status}`;
return Response.json(
{ error, code: typeof body.code === 'string' ? body.code : undefined },
{ status: res.status },
);
}
return Response.json(wrap(body));
}
/**
* Carry the admin's device name in the link's fragment, as `n=<percent-encoded>`.
*
* The companion already knows the name — it stores the note and hands it back as `suggestedHostname` on
* claim — but a claim only happens when the person taps Join, which is one step AFTER the screen that asks
* them to name the device. So the name has to arrive with the link if the field is to be prefilled, and the
* link is the last thing that passes through here.
*
* Safe at every hop: the fragment is never sent to a server, the companion's /join page copies it verbatim
* into the `officer-offscale://` deep link, and a build of the app that predates this ignores an unknown
* parameter and still gets the name from `suggestedHostname` at claim time. Percent-encoded rather than
* base64url (which `s` uses) because the app's fragment parser already decodeURIComponent()s every value,
* and because base64url of a non-ASCII name would decode to mojibake on Hermes.
*/
function withNameHint(url: unknown, name: string | undefined): unknown {
if (typeof url !== 'string' || !name || !url.includes('#')) return url;
return `${url}&n=${encodeURIComponent(name)}`;
}
/**
* `POST /_officer/enroll/invites` — mint an invite. The response carries the link, and only this once.
*
* `url` is an ordinary HTTPS link to a page on the server's own domain, which bounces into the app; the
* companion also returns `deepLink`, the `officer-offscale://` scheme that page redirects to. That one is
* dropped here rather than passed on: it carries the same claim token in its fragment, and a second copy of
* a single-use credential in the browser is a second chance to leak it. Nothing on our side opens it.
*/
async function create(creds: HeadscaleServerCredentials, ctx: OfficerContext): Promise<Response> {
const input = parseCreate(await readJson(ctx.req));
if (input instanceof Response) return input;
const res = await callCompanion(creds, { path: INVITES_PATH, method: 'POST', body: input });
return relay(res, (body) => {
const raw = body.invite ?? body;
const invite = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
const { deepLink: _deepLink, ...rest } = invite;
return { available: true, invite: { ...rest, url: withNameHint(rest.url, input.note) } };
});
}
/**
* Pull the invite array out of whatever envelope the companion used.
*
* §4.3 specifies the fields but not the wrapper, and the create response came back flat (no `invite` key),
* so the list may equally be a bare array or sit under `invites`/`items`/`data`. Taking the first
* array-valued property is shape-agnostic without being credulous: the body has exactly one array in it.
*/
function pickInvites(body: Record<string, unknown>): unknown[] {
if (Array.isArray(body)) return body;
for (const key of ['invites', 'items', 'data', 'results']) {
const value = body[key];
if (Array.isArray(value)) return value;
}
const found = Object.values(body).find(Array.isArray);
return Array.isArray(found) ? found : [];
}
/** `GET /_officer/enroll/invites` — the admin's audit list. Never carries a token or a key. */
async function list(creds: HeadscaleServerCredentials): Promise<Response> {
const res = await callCompanion(creds, { path: INVITES_PATH });
return relay(res, (body) => ({ available: true, invites: pickInvites(body) }));
}
/** `DELETE /_officer/enroll/invites/:id` — revoke an unclaimed invite. A no-op on a claimed one. */
async function revoke(creds: HeadscaleServerCredentials, id: string): Promise<Response> {
const res = await callCompanion(creds, { path: `${INVITES_PATH}/${encodeURIComponent(id)}`, method: 'DELETE' });
return relay(res, (body) => ({ available: true, ...body }));
}
/** Dispatch `/_officer/enroll/invites...`. Acts on the ACTIVE server, like every other domain route. */
export async function handleInvitesRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
const creds = await activeCreds(ctx.userId);
if (creds instanceof Response) return creds;
const [id, extra] = rest;
if (extra) return notFound();
if (!id) {
if (ctx.req.method === 'POST') return create(creds, ctx);
if (ctx.req.method === 'GET') return list(creds);
return methodNotAllowed();
}
if (ctx.req.method !== 'DELETE') return methodNotAllowed();
return revoke(creds, id);
}
-119
View File
@@ -1,119 +0,0 @@
import type { OfficerContext } from './routes';
import { badRequest, notFound, methodNotAllowed, readJson } from './routes';
import { activeClient } from './active';
import { toPreAuthKey, arrayField } from './normalize';
// Pre-auth key routes — /_officer/keys/*. These are the tokens a machine uses to join the tailnet.
//
// The one thing that matters here: since 0.28 Headscale stores pre-auth keys HASHED and returns the real
// secret ONLY in the create response. Every later list returns it masked as `hskey-auth-<prefix>-***`. A
// creation response that the UI drops is a key the owner can never recover — it has to be shown once, with
// a copy affordance, and the API has to make the difference legible. `key` is non-null exactly once.
//
// That "exactly once" is enforced by call path, not by inspecting the value: keys created before 0.28 are
// still plaintext upstream and Headscale hands them back in full from the LIST endpoint for backwards
// compatibility. So listing passes reveal:false and drops the secret unconditionally; only createKey
// reveals. A server with history in it would otherwise leak live keys into the browser's query cache.
//
// Also note the shape of the delete/expire pair: expire takes the id in a POST BODY, delete takes it in a
// query STRING, and neither is a REST-shaped path. Both are hidden behind ordinary Officer routes.
/** Default lifetime when the caller doesn't pick one; matches Headscale's own CLI default. */
const DEFAULT_EXPIRY_DAYS = 90;
const MAX_EXPIRY_DAYS = 3650;
async function listKeys(ctx: OfficerContext): Promise<Response> {
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
// 0.29 lists every user's keys in one call (pre-0.29 required a ?user= filter and one call per user).
const body = await client.call('/api/v1/preauthkey');
const keys = arrayField(body, 'preAuthKeys').map((raw) => toPreAuthKey(raw, { reveal: false }));
// Usable keys first, then by newest — a spent key is history, an active one is the thing you came for.
const rank = { active: 0, used: 1, expired: 2 } as const;
keys.sort((a, b) => rank[a.status] - rank[b.status] || (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
return Response.json({ keys });
}
async function createKey(ctx: OfficerContext): Promise<Response> {
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON body');
// CreatePreAuthKey takes a numeric user ID — unlike the node list filter, which takes a username. The
// two are easy to confuse and the failure is a confusing upstream error, so it's validated here.
const userId = typeof body.userId === 'string' ? body.userId.trim() : '';
if (!/^\d+$/.test(userId)) return badRequest('userId must be the numeric id of a Headscale user');
const days = body.expirationDays === undefined ? DEFAULT_EXPIRY_DAYS : Number(body.expirationDays);
if (!Number.isFinite(days) || days <= 0 || days > MAX_EXPIRY_DAYS) {
return badRequest(`expirationDays must be between 1 and ${MAX_EXPIRY_DAYS}`);
}
const aclTags = Array.isArray(body.aclTags)
? body.aclTags
.filter((t): t is string => typeof t === 'string')
.map((t) => t.trim())
.filter(Boolean)
.map((t) => (t.startsWith('tag:') ? t : `tag:${t}`))
: [];
const created = await client.call<{ preAuthKey?: Record<string, unknown> }>('/api/v1/preauthkey', {
method: 'POST',
body: {
user: userId,
reusable: body.reusable === true,
ephemeral: body.ephemeral === true,
expiration: new Date(Date.now() + days * 86_400_000).toISOString(),
aclTags,
},
});
if (!created.preAuthKey) return Response.json({ error: 'headscale returned no key' }, { status: 502 });
const key = toPreAuthKey(created.preAuthKey, { reveal: true });
// Stated explicitly rather than left for the client to infer from `key !== null`: this response is the
// only time the secret exists anywhere outside the joining machine.
return Response.json({ key, secretShownOnce: true }, { status: 201 });
}
type KeyActionParams = { ctx: OfficerContext; id: string; action: string | undefined };
async function handleKeyAction({ ctx, id, action }: KeyActionParams): Promise<Response> {
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
if (action === 'expire') {
if (ctx.req.method !== 'POST') return methodNotAllowed();
await client.call('/api/v1/preauthkey/expire', { method: 'POST', body: { id } });
return new Response(null, { status: 204 });
}
if (action !== undefined) return notFound();
if (ctx.req.method === 'DELETE') {
await client.call(`/api/v1/preauthkey?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
return new Response(null, { status: 204 });
}
return methodNotAllowed();
}
/** Dispatch `/_officer/keys/...`. `rest` is the path after `keys`. */
export async function handleKeysRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
if (rest.length === 0) {
if (ctx.req.method === 'GET') return listKeys(ctx);
if (ctx.req.method === 'POST') return createKey(ctx);
return methodNotAllowed();
}
if (rest.length > 2) return notFound();
const id = rest[0];
if (!id || !/^\d+$/.test(id)) return badRequest('key id must be numeric');
return handleKeyAction({ ctx, id, action: rest[1] });
}
-142
View File
@@ -1,142 +0,0 @@
import type { OfficerContext } from './routes';
import { badRequest, notFound, methodNotAllowed, readJson } from './routes';
import type { HeadscaleClient } from './client';
import { activeClient } from './active';
import { toNode, arrayField, type OfficerNode } from './normalize';
// Node routes — /_officer/nodes/*. A "node" is a machine in the tailnet.
//
// Two upstream shapes are worth knowing before reading this:
//
// • Renaming takes the new name in the PATH (`/node/{id}/rename/{newName}`), not a body. It must be
// encodeURIComponent'd or a name with a slash silently becomes a 404 on a different route.
// • Route approval is a whole-SET write (`approve_routes` replaces the approved list), not an
// add/remove. Approving one route means sending every route that should remain approved, so those
// operations are read-modify-write here rather than in the browser — see rule 5 in
// SIDECAR_ARCHITECTURE.md. Doing it client-side would make two admins racing lose each other's edits;
// doing it here still races, but over milliseconds instead of however long a form sits open.
/** Nodes on the active server, newest-registered first within each user. */
async function listNodes(ctx: OfficerContext): Promise<Response> {
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
// The upstream `user` filter takes a USERNAME, not an id — a trap worth keeping out of the browser.
const user = ctx.url.searchParams.get('user');
const path = user ? `/api/v1/node?user=${encodeURIComponent(user)}` : '/api/v1/node';
const body = await client.call(path);
const nodes = arrayField(body, 'nodes').map(toNode);
nodes.sort((a, b) => Number(b.online) - Number(a.online) || a.name.localeCompare(b.name));
return Response.json({ nodes });
}
/** Re-read one node after a mutation. Headscale's mutation responses are inconsistent; a GET never is. */
async function getNode(client: HeadscaleClient, id: string): Promise<OfficerNode | null> {
const body = await client.call<{ node?: Record<string, unknown> }>(`/api/v1/node/${encodeURIComponent(id)}`);
return body.node ? toNode(body.node) : null;
}
type NodeActionParams = { ctx: OfficerContext; id: string; action: string | undefined };
async function handleNodeAction({ ctx, id, action }: NodeActionParams): Promise<Response> {
const { req } = ctx;
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
if (action === undefined) {
if (req.method === 'GET') {
const node = await getNode(client, id);
return node ? Response.json({ node }) : notFound('no such node');
}
if (req.method === 'DELETE') {
await client.call(`/api/v1/node/${encodeURIComponent(id)}`, { method: 'DELETE' });
return new Response(null, { status: 204 });
}
return methodNotAllowed();
}
if (req.method !== 'POST') return methodNotAllowed();
if (action === 'rename') {
const body = await readJson(req);
if (!body) return badRequest('expected a JSON body');
const name = typeof body.name === 'string' ? body.name.trim() : '';
if (!name) return badRequest('name is required');
await client.call(`/api/v1/node/${encodeURIComponent(id)}/rename/${encodeURIComponent(name)}`, { method: 'POST' });
return Response.json({ node: await getNode(client, id) });
}
if (action === 'tags') {
const body = await readJson(req);
if (!body) return badRequest('expected a JSON body');
if (!Array.isArray(body.tags)) return badRequest('tags must be an array of strings');
const tags = body.tags.filter((t): t is string => typeof t === 'string').map((t) => t.trim());
if (tags.some((t) => !t)) return badRequest('tags cannot be empty strings');
// Headscale requires the `tag:` prefix and rejects anything else with a 500, which we'd surface as a
// useless "headscale error". Normalizing here means the UI can accept either form.
const prefixed = tags.map((t) => (t.startsWith('tag:') ? t : `tag:${t}`));
await client.call(`/api/v1/node/${encodeURIComponent(id)}/tags`, { method: 'POST', body: { tags: prefixed } });
return Response.json({ node: await getNode(client, id) });
}
if (action === 'routes') {
const body = await readJson(req);
if (!body) return badRequest('expected a JSON body');
let routes: string[];
if (Array.isArray(body.routes)) {
// Whole-set write: the caller states the complete approved list.
routes = body.routes.filter((r): r is string => typeof r === 'string');
} else if (typeof body.route === 'string' && typeof body.approved === 'boolean') {
// Single-toggle: read the current set, apply one change, write it back.
const current = await getNode(client, id);
if (!current) return notFound('no such node');
const set = new Set(current.approvedRoutes);
if (body.approved) set.add(body.route);
else set.delete(body.route);
routes = [...set];
} else {
return badRequest('expected {routes: string[]} or {route: string, approved: boolean}');
}
await client.call(`/api/v1/node/${encodeURIComponent(id)}/approve_routes`, { method: 'POST', body: { routes } });
return Response.json({ node: await getNode(client, id) });
}
if (action === 'user') {
const body = await readJson(req);
if (!body) return badRequest('expected a JSON body');
// Upstream takes the target user's numeric id, not its name — and uint64-as-string, so it is validated
// by shape and passed through as a string rather than parsed.
const userId = typeof body.userId === 'string' ? body.userId.trim() : '';
if (!/^\d+$/.test(userId)) return badRequest('userId must be numeric');
// Moving a node changes which ACL rules and tag ownership apply to it — the routes it advertises and
// the tags it carries stay put, but what they now MEAN can differ. The UI says so before asking.
await client.call(`/api/v1/node/${encodeURIComponent(id)}/user`, { method: 'POST', body: { user: userId } });
return Response.json({ node: await getNode(client, id) });
}
if (action === 'expire') {
// Expires the node's key, forcing it to re-authenticate. Not a delete: the node stays registered.
await client.call(`/api/v1/node/${encodeURIComponent(id)}/expire`, { method: 'POST' });
return Response.json({ node: await getNode(client, id) });
}
return notFound();
}
/** Dispatch `/_officer/nodes/...`. `rest` is the path after `nodes`. */
export async function handleNodesRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
if (rest.length === 0) {
if (ctx.req.method !== 'GET') return methodNotAllowed();
return listNodes(ctx);
}
if (rest.length > 2) return notFound();
const id = rest[0];
// Upstream ids are uint64-as-string. Validate the shape without parsing — Number() would lose precision.
if (!id || !/^\d+$/.test(id)) return badRequest('node id must be numeric');
return handleNodeAction({ ctx, id, action: rest[1] });
}
-187
View File
@@ -1,187 +0,0 @@
// Officer-shaped views of Headscale's admin API objects, and the quirk handling that gets us there.
//
// Headscale's REST layer is a gRPC gateway marshalling protobuf, which leaks in three ways we normalize
// here so nothing downstream has to know:
//
// 1. Every uint64 is a JSON STRING. Ids stay strings end to end — never Number() them, that breaks
// silently above 2^53 and Headscale's ids are database-assigned, not small by contract.
// 2. Unset timestamps are the protobuf zero value, serialized as '0001-01-01T00:00:00Z' rather than
// omitted. Rendered naively that reads as the year 1 — it means "never", so it becomes null.
// 3. EmitUnpopulated means absent repeated fields arrive as [] and absent messages as null; there is no
// way to distinguish "unset" from "empty", so every accessor tolerates both.
/** Protobuf's zero timestamp. Headscale sends this for "never expires", "never seen", and friends. */
const ZERO_TIME = '0001-01-01T00:00:00Z';
/** An upstream timestamp as an ISO string, or null when it is unset/the protobuf zero value. */
export function isoOrNull(raw: unknown): string | null {
if (typeof raw !== 'string' || !raw || raw === ZERO_TIME) return null;
const ms = Date.parse(raw);
if (Number.isNaN(ms)) return null;
// Some builds emit years far outside anything meaningful; treat pre-1971 as the sentinel too.
return ms < 31_536_000_000 ? null : new Date(ms).toISOString();
}
const str = (raw: unknown): string => (typeof raw === 'string' ? raw : '');
const strArray = (raw: unknown): string[] =>
Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : [];
export type UpstreamUser = Record<string, unknown>;
export type UpstreamNode = Record<string, unknown>;
export type UpstreamPreAuthKey = Record<string, unknown>;
export type OfficerUser = {
id: string;
name: string;
displayName: string | null;
email: string | null;
/** The OIDC provider, when the user came from one. Null for CLI/API-created users. */
provider: string | null;
profilePicUrl: string | null;
createdAt: string | null;
};
export function toUser(raw: UpstreamUser | null | undefined): OfficerUser | null {
if (!raw || typeof raw !== 'object') return null;
const id = str(raw.id);
if (!id) return null;
return {
id,
name: str(raw.name),
displayName: str(raw.displayName) || null,
email: str(raw.email) || null,
provider: str(raw.provider) || null,
profilePicUrl: str(raw.profilePicUrl) || null,
createdAt: isoOrNull(raw.createdAt),
};
}
export type OfficerNode = {
id: string;
/** The name Headscale actually uses in the tailnet — givenName when set, otherwise the reported hostname. */
name: string;
hostname: string;
user: OfficerUser | null;
ipAddresses: string[];
online: boolean;
lastSeen: string | null;
/** When the node's key expires and it must re-authenticate. Null means it never expires. */
expiry: string | null;
createdAt: string | null;
/** How the node joined: 'authkey' | 'cli' | 'oidc' | 'unknown'. */
registerMethod: string;
tags: string[];
/** Routes the node advertises. */
availableRoutes: string[];
/** The subset the admin has approved — the writable one. */
approvedRoutes: string[];
/** Routes actually in effect (approved ∩ available, as Headscale computes it). */
subnetRoutes: string[];
/** True when the node advertises an exit node route. Purely derived, for the UI's badge. */
isExitNode: boolean;
};
const EXIT_ROUTES = new Set(['0.0.0.0/0', '::/0']);
const REGISTER_METHODS: Record<string, string> = {
REGISTER_METHOD_AUTH_KEY: 'authkey',
REGISTER_METHOD_CLI: 'cli',
REGISTER_METHOD_OIDC: 'oidc',
};
export function toNode(raw: UpstreamNode): OfficerNode {
const givenName = str(raw.givenName);
const hostname = str(raw.name);
const availableRoutes = strArray(raw.availableRoutes);
return {
id: str(raw.id),
name: givenName || hostname,
hostname,
user: toUser(raw.user as UpstreamUser),
ipAddresses: strArray(raw.ipAddresses),
online: raw.online === true,
lastSeen: isoOrNull(raw.lastSeen),
expiry: isoOrNull(raw.expiry),
createdAt: isoOrNull(raw.createdAt),
registerMethod: REGISTER_METHODS[str(raw.registerMethod)] ?? 'unknown',
tags: strArray(raw.tags),
availableRoutes,
approvedRoutes: strArray(raw.approvedRoutes),
subnetRoutes: strArray(raw.subnetRoutes),
isExitNode: availableRoutes.some((r) => EXIT_ROUTES.has(r)),
};
}
export type OfficerPreAuthKey = {
id: string;
/**
* The usable secret. Non-null ONLY on the creation response — the list path nulls it unconditionally,
* so a secret can never reach the browser except at the moment it is created and must be shown once.
*/
key: string | null;
/** A never-usable label for identifying a key in a list, e.g. `hskey-auth-a1b2c3-***`. */
keyDisplay: string;
user: OfficerUser | null;
reusable: boolean;
ephemeral: boolean;
used: boolean;
expiration: string | null;
createdAt: string | null;
aclTags: string[];
/** Derived lifecycle, so every surface agrees on what "spent" means. */
status: 'active' | 'used' | 'expired';
};
/**
* A display label that is never a usable secret.
*
* Headscale 0.28+ stores keys bcrypt-hashed and lists them already masked as `hskey-auth-<prefix>-***`.
* But keys created BEFORE 0.28 are still plaintext in its database, and `PreAuthKey.Proto()` returns those
* in full from the list endpoint "for backwards compatibility" — its own source carries a TODO about
* hiding them. So a list response on a server with history in it really does contain live secrets. We mask
* anything that isn't already masked rather than trusting the upstream to have done it.
*/
function displayLabel(key: string): string {
if (!key) return '(no key)';
if (key.endsWith('***')) return key;
return `${key.slice(0, 6)}…-***`;
}
type ToPreAuthKeyOptions = {
/**
* True only on the creation response, where the secret is the entire point and exists nowhere else.
* Everywhere else this is false and the secret is dropped before it can reach a cache or a browser.
*/
reveal: boolean;
};
export function toPreAuthKey(raw: UpstreamPreAuthKey, { reveal }: ToPreAuthKeyOptions): OfficerPreAuthKey {
const expiration = isoOrNull(raw.expiration);
const reusable = raw.reusable === true;
const used = raw.used === true;
const key = str(raw.key);
// A reusable key stays usable after a node has claimed it, so `used` alone doesn't mean spent.
const expired = !!expiration && Date.parse(expiration) < Date.now();
const status: OfficerPreAuthKey['status'] = expired ? 'expired' : used && !reusable ? 'used' : 'active';
return {
id: str(raw.id),
key: reveal ? key || null : null,
keyDisplay: displayLabel(key),
user: toUser(raw.user as UpstreamUser),
reusable,
ephemeral: raw.ephemeral === true,
used,
expiration,
createdAt: isoOrNull(raw.createdAt),
aclTags: strArray(raw.aclTags),
status,
};
}
/** Read an array field out of a gateway response, tolerating the null/absent forms. */
export function arrayField(body: unknown, field: string): Record<string, unknown>[] {
const value = (body as Record<string, unknown> | null)?.[field];
return Array.isArray(value) ? (value.filter((v) => v && typeof v === 'object') as Record<string, unknown>[]) : [];
}
-115
View File
@@ -1,115 +0,0 @@
import type { OfficerContext } from './routes';
import { badRequest, methodNotAllowed, readJson } from './routes';
import { HeadscaleError } from './client';
import { activeClient } from './active';
import { handlePolicyAssistRoute } from './assist';
// The ACL policy — /_officer/policy. One HuJSON document that decides which node may reach which, so it is
// the highest-consequence thing this app can write and the only place a typo silently partitions a network.
//
// Three upstream behaviours drive the shape of this file.
//
// 1. **Readable always, writable sometimes.** Headscale can keep its policy in a file (`policy.mode: file`)
// instead of the database, and then the API still SERVES it — a GET returns the file's contents quite
// happily — but a PUT is refused with "update is disabled for modes other than 'database'". Verified
// against a live server, and it means the mode CANNOT be inferred from a read. There is no endpoint
// that reports it either. So this route makes no claim about writability up front; the first save is
// what finds out, and a refusal is a 409 the UI turns into a persistent read-only banner.
//
// 2. **Validation happens on PUT, in Headscale, and its message is the whole value.** It parses the
// HuJSON, resolves every group and tag reference, and rejects the write with a line and column or a
// "group not defined" naming the offender. Officer must not pre-validate: a second, weaker parser here
// would reject documents Headscale accepts and — worse — accept ones it rejects, and its opinion would
// be the one shown. So the text goes up untouched and Headscale's verdict comes back verbatim.
//
// 3. **Both of those arrive as HTTP 500** from grpc-gateway, which the client layer normally generalizes
// to "headscale error". `HeadscaleError.detail` is how the real message survives that; see client.ts.
/**
* Does this failure mean "writing is turned off here", as opposed to "your document is wrong"?
*
* Matched on the message because Headscale gives no code to match on. Deliberately broad: a false positive
* costs a slightly-wrong banner over a message the owner can still read, while a false negative would tell
* someone their perfectly good ACL was rejected and send them hunting for a syntax error that isn't there.
*/
function isWriteDisabled(detail: string): boolean {
const text = detail.toLowerCase();
if (text.includes('disabled')) return true;
return text.includes('file') && (text.includes('policy') || text.includes('mode'));
}
type PolicyBody = { policy?: unknown; updatedAt?: unknown };
const asText = (value: unknown) => (typeof value === 'string' ? value : '');
const asDate = (value: unknown) => (typeof value === 'string' && value && !value.startsWith('0001-') ? value : null);
/**
* `GET /_officer/policy`.
*
* Answers 200 for every state a running server can be in, including "there is no policy yet" — a fresh
* Headscale has none, and an empty editor is both the honest rendering of that and the thing the owner
* needs to start typing into. Only an unreachable server is an error, because only that leaves nothing
* to say. Note there is no `mode` here on purpose: see the header.
*/
async function getPolicy(ctx: OfficerContext): Promise<Response> {
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
try {
const body = await client.call<PolicyBody>('/api/v1/policy');
return Response.json({ policy: asText(body.policy), updatedAt: asDate(body.updatedAt) });
} catch (err) {
if (!(err instanceof HeadscaleError)) throw err;
const detail = err.detail ?? err.message;
if (err.status === 404 || detail.toLowerCase().includes('not found')) {
return Response.json({ policy: '', updatedAt: null });
}
throw err;
}
}
/**
* `PUT /_officer/policy {policy}`.
*
* The body is sent up byte for byte — no trimming, no reformatting, no parse. Comments and layout are load
* bearing in a hand-maintained ACL, and re-serializing would destroy both.
*/
async function putPolicy(ctx: OfficerContext): Promise<Response> {
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON body');
if (typeof body.policy !== 'string') return badRequest('policy must be a string');
// An empty document would be accepted by some Headscale versions and lock every node out of every other
// one. Deleting a policy is not something to do by leaving a textarea blank and pressing save.
if (!body.policy.trim()) return badRequest('the policy is empty — that would deny every connection');
try {
const saved = await client.call<PolicyBody>('/api/v1/policy', { method: 'PUT', body: { policy: body.policy } });
// Headscale echoes what it stored; fall back to what we sent if it echoes nothing, so a successful save
// never blanks the editor.
return Response.json({ policy: asText(saved.policy) || body.policy, updatedAt: asDate(saved.updatedAt) });
} catch (err) {
if (!(err instanceof HeadscaleError)) throw err;
const detail = err.detail ?? err.message;
if (isWriteDisabled(detail)) {
return Response.json({ error: detail, code: 'policy_read_only' }, { status: 409 });
}
// Everything else on a PUT is Headscale rejecting this document: a syntax error with a position, an
// unresolvable group, an unknown tag owner. 422 rather than 502 — the request is the problem, and the
// message is the one thing that will fix it.
return Response.json({ error: detail, code: 'policy_rejected' }, { status: 422 });
}
}
/** Dispatch `/_officer/policy`. One policy per server, plus the drafting assistant beside it. */
export async function handlePolicyRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
// `/policy/assist` proposes a document; it never writes one. See assist.ts.
if (rest[0] === 'assist') return handlePolicyAssistRoute(ctx, rest.slice(1));
if (rest.length > 0) return badRequest('unexpected path');
if (ctx.req.method === 'GET') return getPolicy(ctx);
if (ctx.req.method === 'PUT') return putPolicy(ctx);
return methodNotAllowed();
}
-80
View File
@@ -1,80 +0,0 @@
import { HeadscaleError } from './client';
import { handleServersRoute } from './servers';
import { handleNodesRoute } from './nodes';
import { handleUsersRoute } from './users';
import { handleKeysRoute } from './keys';
import { handlePolicyRoute } from './policy';
import { handleEnrollRoute } from './enroll';
import { handleSshTestRoute } from './ssh';
import { handleCompanionRoute } from './companion';
// Officer-owned routes for the headscale sidecar — the entire feature surface lives under /_officer/.
//
// Nothing here is a passthrough. The shapes the UI receives are stable and Officer-shaped, ids stay strings,
// dates are normalized, and anything needing more than one upstream call (device counts per user, pre-auth
// keys grouped by user, read-modify-write of a node's approved route set) resolves here rather than in the
// browser. That is the whole reason the sidecar exists: see rule 5 in SIDECAR_ARCHITECTURE.md.
export type OfficerContext = { req: Request; url: URL; userId: number };
/** 400 with a machine-readable reason. */
export const badRequest = (error: string) => Response.json({ error }, { status: 400 });
/** 404 for an unknown /_officer/ path or a missing object. */
export const notFound = (error = 'not found') => Response.json({ error }, { status: 404 });
/** 405 when the path exists but the verb doesn't. */
export const methodNotAllowed = () => Response.json({ error: 'method not allowed' }, { status: 405 });
/** Parse a JSON request body, or null when there isn't one / it isn't an object. */
export async function readJson(req: Request): Promise<Record<string, unknown> | null> {
const body = await req.json().catch(() => null);
return body && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>) : null;
}
/**
* Dispatch an /_officer/* request. Returns null when nothing matches, which the caller turns into a 404.
*
* The platform injects X-Officer-User after authenticating the owner. We bind loopback only, so its presence
* is the trust signal — a request without it did not come through the platform.
*/
export async function handleOfficerRoute(req: Request, url: URL): Promise<Response | null> {
const officerUser = req.headers.get('X-Officer-User');
if (!officerUser) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 });
const userId = Number(officerUser);
if (!Number.isInteger(userId) || userId <= 0) return badRequest('invalid X-Officer-User');
const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean);
if (segments.length === 0) return null;
const ctx: OfficerContext = { req, url, userId };
try {
switch (segments[0]) {
case 'servers':
return await handleServersRoute(ctx, segments.slice(1));
// The domain routes below all act on the ACTIVE server — see active.ts for why that isn't a param.
case 'nodes':
return await handleNodesRoute(ctx, segments.slice(1));
case 'users':
return await handleUsersRoute(ctx, segments.slice(1));
case 'keys':
return await handleKeysRoute(ctx, segments.slice(1));
case 'policy':
return await handlePolicyRoute(ctx, segments.slice(1));
case 'enroll':
return await handleEnrollRoute(ctx, segments.slice(1));
// Not a Headscale call at all — a local `ssh` reachability probe for the console. See ssh.ts.
case 'ssh-test':
return await handleSshTestRoute(ctx, segments.slice(1));
// The active server's Officer Companion: container health, logs and lifecycle. See companion.ts.
case 'companion':
return await handleCompanionRoute(ctx, segments.slice(1));
default:
return null;
}
} catch (err) {
// Upstream failures carry their own status; everything else is ours and is a 500 the caller logs.
if (err instanceof HeadscaleError) return Response.json({ error: err.message }, { status: err.status });
throw err;
}
}
-201
View File
@@ -1,201 +0,0 @@
import type { OfficerContext } from './routes';
import {
listHeadscaleServers,
createHeadscaleServer,
updateHeadscaleServer,
setActiveHeadscaleServer,
deleteHeadscaleServer,
getHeadscaleCredentials,
recordHeadscaleProbe,
} from 'officerdb';
import { createClient, HeadscaleError } from './client';
import { probeVersion, MIN_VERSION_LABEL } from './version';
import { badRequest, notFound, methodNotAllowed } from './routes';
import { normalizeSshHost } from './ssh';
// Server registry routes — /_officer/servers/*. Officer manages any number of Headscale servers; the owner
// registers each with a URL and an admin API key generated on that server, and one is active at a time.
//
// Registration VALIDATES before it saves, in two steps, because a bad registration is otherwise only
// discovered later as a confusing failure on some unrelated screen:
// 1. unauthenticated GET /version — proves something Headscale-shaped is there and enforces the >=0.29 floor
// 2. an authenticated call — proves the key actually works
// Neither step is skippable, and a rejected registration is never written.
/** Normalize a user-supplied base URL, or null if it isn't a usable http(s) origin. */
function normalizeUrl(raw: unknown): string | null {
if (typeof raw !== 'string' || !raw.trim()) return null;
let candidate = raw.trim();
// Bare host/port is the most common paste; assume https rather than rejecting it.
if (!/^https?:\/\//i.test(candidate)) candidate = `https://${candidate}`;
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
return null;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
// Trailing slash would produce `//api/v1/...`; query/hash are meaningless on a base URL.
return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`;
}
function requireString(value: unknown, field: string): string | Response {
if (typeof value !== 'string' || !value.trim()) return badRequest(`${field} is required`);
return value.trim();
}
/**
* Confirm a URL+key pair is a supported, reachable Headscale we can authenticate against.
* Returns the observed version on success, or a ready-to-send error Response.
*/
async function validateServer(url: string, apiKey: string): Promise<string | Response> {
const probe = await probeVersion(url);
if (!probe.ok) return badRequest(probe.error);
if (probe.supported === false) {
return badRequest(`Headscale ${probe.version} is not supported — Officer requires ${MIN_VERSION_LABEL} or newer`);
}
// Cheapest authenticated GET whose path is stable across releases, and the same call Headscale's own
// clients use to test a key. A wrong key surfaces here as HeadscaleError(502, 'rejected the stored key').
const client = createClient({ id: 0, name: 'probe', url, apiKey });
try {
await client.call('/api/v1/apikey');
} catch (err) {
if (err instanceof HeadscaleError) {
return badRequest(err.status === 502 ? 'the API key was rejected by that server' : err.message);
}
throw err;
}
return probe.version;
}
async function handleCollection(ctx: OfficerContext): Promise<Response> {
const { req, userId } = ctx;
if (req.method === 'GET') {
return Response.json({ servers: await listHeadscaleServers(userId) });
}
if (req.method === 'POST') {
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) return badRequest('expected a JSON body');
const url = normalizeUrl(body.url);
if (!url) return badRequest('url must be a valid http(s) URL');
const apiKey = requireString(body.apiKey, 'apiKey');
if (apiKey instanceof Response) return apiKey;
// The name is a label only; default it to the host so registration needs just a URL and a key.
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : new URL(url).host;
// Optional, and never validated by connecting: registration should not fail because a box is rebooting.
const sshHost = normalizeSshHost(body.sshHost);
if (sshHost instanceof Response) return sshHost;
const validated = await validateServer(url, apiKey);
if (validated instanceof Response) return validated;
// First registration becomes active, so the owner is never left with servers but none selected.
const existing = await listHeadscaleServers(userId);
const server = await createHeadscaleServer({
userId,
name,
url,
apiKey,
version: validated,
sshHost,
activate: existing.length === 0,
});
return Response.json({ server }, { status: 201 });
}
return methodNotAllowed();
}
async function handleOne(ctx: OfficerContext, id: number, action: string | undefined): Promise<Response> {
const { req, userId } = ctx;
if (action === 'activate') {
if (req.method !== 'POST') return methodNotAllowed();
const server = await setActiveHeadscaleServer(userId, id);
return server ? Response.json({ server }) : notFound('no such server');
}
if (action === 'health') {
if (req.method !== 'GET') return methodNotAllowed();
const creds = await getHeadscaleCredentials(userId, id);
if (!creds) return notFound('no such server');
const started = Date.now();
const probe = await probeVersion(creds.url);
if (!probe.ok) return Response.json({ ok: false, error: probe.error, ms: Date.now() - started });
// Reachable — confirm the key too, so "healthy" means "we can actually use this server".
try {
await createClient(creds).call('/api/v1/apikey');
} catch (err) {
const message = err instanceof HeadscaleError ? err.message : 'upstream error';
return Response.json({ ok: false, version: probe.version, error: message, ms: Date.now() - started });
}
await recordHeadscaleProbe(userId, id, probe.version);
return Response.json({ ok: true, version: probe.version, supported: probe.supported, ms: Date.now() - started });
}
if (action !== undefined) return notFound();
if (req.method === 'PATCH') {
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) return badRequest('expected a JSON body');
const current = await getHeadscaleCredentials(userId, id);
if (!current) return notFound('no such server');
let url: string | undefined;
if (body.url !== undefined) {
const normalized = normalizeUrl(body.url);
if (!normalized) return badRequest('url must be a valid http(s) URL');
url = normalized;
}
let apiKey: string | undefined;
if (body.apiKey !== undefined) {
const parsed = requireString(body.apiKey, 'apiKey');
if (parsed instanceof Response) return parsed;
apiKey = parsed;
}
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : undefined;
// Absent = leave it; '' or null = clear the console target. normalizeSshHost collapses both to null.
let sshHost: string | null | undefined;
if (body.sshHost !== undefined) {
const parsed = normalizeSshHost(body.sshHost);
if (parsed instanceof Response) return parsed;
sshHost = parsed;
}
// Re-validate whenever either half of the credentials moves — a saved-but-broken server is the exact
// state registration works hard to prevent, and an edit can reintroduce it.
if (url !== undefined || apiKey !== undefined) {
const validated = await validateServer(url ?? current.url, apiKey ?? current.apiKey);
if (validated instanceof Response) return validated;
}
const server = await updateHeadscaleServer(userId, id, { name, url, apiKey, sshHost });
return server ? Response.json({ server }) : notFound('no such server');
}
if (req.method === 'DELETE') {
const deleted = await deleteHeadscaleServer(userId, id);
return deleted ? new Response(null, { status: 204 }) : notFound('no such server');
}
return methodNotAllowed();
}
/** Dispatch `/_officer/servers/...`. `rest` is the path after `servers`. */
export async function handleServersRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
if (rest.length === 0) return handleCollection(ctx);
const id = Number(rest[0]);
if (!Number.isInteger(id) || id <= 0) return badRequest('server id must be a positive integer');
if (rest.length > 2) return notFound();
return handleOne(ctx, id, rest[1]);
}
-86
View File
@@ -1,86 +0,0 @@
import { badRequest, methodNotAllowed, readJson, type OfficerContext } from './routes';
// SSH console support — the escape hatch for when the Headscale API cannot answer.
//
// Officer never handles a password, a key or a port here. The console runs `ssh <host>` in the owner's own
// shell, so it authenticates with whatever `~/.ssh` already knows; the only thing stored is where to point it.
// That is why this file has no credential handling at all, and why it must never grow any: the moment Officer
// starts holding a private key or a password, this stops being "run the command you would have run yourself".
//
// The host string is typed into an interactive shell, so it is validated to a conservative charset rather than
// quoted. Quoting would let a plausible-looking value survive to the shell and be someone else's problem;
// rejecting it says which character is wrong while the form is still open.
/** `user@` plus a hostname or IP. Deliberately no spaces, no flags, no shell metacharacters. */
const SSH_HOST_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*)?(?:@[A-Za-z0-9](?:[A-Za-z0-9._:-]*)?)?$/;
/**
* Validate a console target. Returns the trimmed host, null when the field was blank (meaning "no console"),
* or an error Response.
*/
export function normalizeSshHost(raw: unknown): string | null | Response {
if (raw === null) return null;
if (typeof raw !== 'string') return badRequest('sshHost must be a string');
const host = raw.trim();
if (!host) return null;
if (host.length > 255) return badRequest('sshHost is too long');
if (!SSH_HOST_RE.test(host)) {
return badRequest('sshHost must be a plain host, IP or user@host — no ports, flags or spaces');
}
return host;
}
type SshProbe = { ok: boolean; error?: string; ms: number };
/**
* Prove the machine is reachable with the keys already on this box, without opening a session.
*
* `BatchMode=yes` is what makes this a test rather than a hang: ssh fails instead of prompting for a password
* or a passphrase, which is exactly the outcome the owner needs to see. `accept-new` records an unknown host
* key here rather than leaving the console to open on an interactive "are you sure" prompt the first time —
* it still refuses a CHANGED key, which is the check worth keeping.
*/
export async function probeSsh(host: string): Promise<SshProbe> {
const started = Date.now();
const proc = Bun.spawn(
['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=accept-new', host, 'true'],
{ stdout: 'ignore', stderr: 'pipe' },
);
// ConnectTimeout only bounds the TCP connect; a server that accepts and then stalls would hang forever.
const timer = setTimeout(() => proc.kill(), 15_000);
let stderr = '';
try {
[stderr] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
} finally {
clearTimeout(timer);
}
const ms = Date.now() - started;
if (proc.exitCode === 0) return { ok: true, ms };
// ssh's own first line is the useful one ("Permission denied", "Connection timed out"); the rest is noise.
const first = stderr
.split('\n')
.map((line) => line.trim())
.find((line) => line && !line.startsWith('Warning: Permanently added'));
return { ok: false, error: first || `ssh exited ${proc.exitCode ?? 'on a signal'}`, ms };
}
/**
* `POST /_officer/ssh-test {host}`. Takes the host in the body rather than a server id on purpose: the form
* needs to test a value the owner has typed but not yet saved, which is the case where a typo is still cheap.
*/
export async function handleSshTestRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
if (rest.length > 0) return badRequest('unexpected path');
if (ctx.req.method !== 'POST') return methodNotAllowed();
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON body');
const host = normalizeSshHost(body.host);
if (host instanceof Response) return host;
if (!host) return badRequest('host is required');
return Response.json(await probeSsh(host));
}
-104
View File
@@ -1,104 +0,0 @@
import type { OfficerContext } from './routes';
import { badRequest, notFound, methodNotAllowed, readJson } from './routes';
import { activeClient } from './active';
import { toUser, toNode, arrayField, type OfficerUser } from './normalize';
// User routes — /_officer/users/*. A Headscale "user" is a namespace that owns nodes and pre-auth keys.
//
// The list is enriched with a node count, which the admin API does not provide: deleting a user takes its
// nodes with it, so "3 nodes" next to the delete button is the difference between an informed action and a
// surprise. That is one extra upstream call for the whole list, not one per user.
export type UserWithCounts = OfficerUser & { nodeCount: number; onlineCount: number };
async function listUsers(ctx: OfficerContext): Promise<Response> {
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
const [userBody, nodeBody] = await Promise.all([client.call('/api/v1/user'), client.call('/api/v1/node')]);
const nodes = arrayField(nodeBody, 'nodes').map(toNode);
const counts = new Map<string, { total: number; online: number }>();
for (const node of nodes) {
const id = node.user?.id;
if (!id) continue;
const entry = counts.get(id) ?? { total: 0, online: 0 };
entry.total += 1;
if (node.online) entry.online += 1;
counts.set(id, entry);
}
const users: UserWithCounts[] = arrayField(userBody, 'users')
.map(toUser)
.filter((u): u is OfficerUser => !!u)
.map((u) => ({ ...u, nodeCount: counts.get(u.id)?.total ?? 0, onlineCount: counts.get(u.id)?.online ?? 0 }))
.sort((a, b) => a.name.localeCompare(b.name));
return Response.json({ users });
}
async function createUser(ctx: OfficerContext): Promise<Response> {
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON body');
const name = typeof body.name === 'string' ? body.name.trim() : '';
if (!name) return badRequest('name is required');
const created = await client.call<{ user?: Record<string, unknown> }>('/api/v1/user', {
method: 'POST',
body: {
name,
displayName: typeof body.displayName === 'string' ? body.displayName.trim() : undefined,
email: typeof body.email === 'string' ? body.email.trim() : undefined,
},
});
return Response.json({ user: toUser(created.user) }, { status: 201 });
}
type UserActionParams = { ctx: OfficerContext; id: string; action: string | undefined };
async function handleUserAction({ ctx, id, action }: UserActionParams): Promise<Response> {
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
if (action === 'rename') {
if (ctx.req.method !== 'POST') return methodNotAllowed();
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON body');
const name = typeof body.name === 'string' ? body.name.trim() : '';
if (!name) return badRequest('name is required');
// Rename takes both the id and the new name in the path — encode or a '/' becomes a routing accident.
const renamed = await client.call<{ user?: Record<string, unknown> }>(
`/api/v1/user/${encodeURIComponent(id)}/rename/${encodeURIComponent(name)}`,
{ method: 'POST' },
);
return Response.json({ user: toUser(renamed.user) });
}
if (action !== undefined) return notFound();
if (ctx.req.method === 'DELETE') {
// Headscale refuses to delete a user that still owns nodes, with a message the UI relays verbatim.
await client.call(`/api/v1/user/${encodeURIComponent(id)}`, { method: 'DELETE' });
return new Response(null, { status: 204 });
}
return methodNotAllowed();
}
/** Dispatch `/_officer/users/...`. `rest` is the path after `users`. */
export async function handleUsersRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
if (rest.length === 0) {
if (ctx.req.method === 'GET') return listUsers(ctx);
if (ctx.req.method === 'POST') return createUser(ctx);
return methodNotAllowed();
}
if (rest.length > 2) return notFound();
const id = rest[0];
if (!id || !/^\d+$/.test(id)) return badRequest('user id must be numeric');
return handleUserAction({ ctx, id, action: rest[1] });
}
-79
View File
@@ -1,79 +0,0 @@
// Headscale version detection and the supported floor.
//
// Officer targets Headscale >= 0.29 and nothing older. That is a deliberate, narrow floor: the admin API
// changed shape repeatedly below it — identifiers went name→numeric at 0.26, `/api/v1/routes` was removed at
// 0.26 in favour of node-owned route sets, `forcedTags`/`validTags` collapsed into `tags` at 0.28, pre-auth
// key expiry became id-based at 0.28, and MoveNode was removed at 0.28. Supporting 0.230.28 would mean
// carrying several incompatible data models; refusing them at registration time costs one probe.
//
// Detection uses the server's own unauthenticated `GET /version`, which exists in 0.28 and 0.29 and sits at
// the root — NOT under /api/v1, and not behind the bearer middleware. Do not confuse it with the three
// other similarly-named endpoints: `GET /health` (root, unauthenticated, `{status:'pass'}`) and
// `GET /api/v1/health` (authenticated, `{databaseConnectivity:true}`) carry no version at all.
export const MIN_MAJOR = 0;
export const MIN_MINOR = 29;
export const MIN_VERSION_LABEL = '0.29';
const PROBE_TIMEOUT_MS = 8000;
export type VersionProbe =
| { ok: true; version: string; supported: true }
/** Reached the server but can't judge the version — self-built images report the literal 'dev'. */
| { ok: true; version: string; supported: 'unknown' }
| { ok: true; version: string; supported: false }
| { ok: false; error: string };
/** `major.minor` from a Headscale version string, or null when it isn't semver (e.g. the literal 'dev'). */
export function parseVersion(raw: string): { major: number; minor: number } | null {
const m = raw
.trim()
.replace(/^v/, '')
.match(/^(\d+)\.(\d+)/);
if (!m) return null;
return { major: Number(m[1]), minor: Number(m[2]) };
}
/** Whether a parsed version is at or above the supported floor. */
export function meetsFloor(v: { major: number; minor: number }): boolean {
if (v.major !== MIN_MAJOR) return v.major > MIN_MAJOR;
return v.minor >= MIN_MINOR;
}
/**
* Probe a base URL's `GET /version`. Unauthenticated, so this also doubles as the reachability check during
* registration — it tells us "is there a Headscale here at all" before we bother validating a key.
*
* An unparseable version is reported as `supported: 'unknown'` rather than rejected: a server built without
* VCS build info reports 'dev', and refusing those would lock out legitimately self-built deployments.
*/
export async function probeVersion(baseUrl: string): Promise<VersionProbe> {
let res: Response;
try {
res = await fetch(`${baseUrl}/version`, {
headers: { accept: 'application/json' },
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
});
} catch {
return { ok: false, error: 'server unreachable' };
}
if (!res.ok) {
// A Headscale that answers /version with a non-2xx isn't one we can identify. Most often this is a URL
// pointing at a reverse proxy or an unrelated service rather than at Headscale itself.
return { ok: false, error: `GET /version returned ${res.status} — is this a Headscale server?` };
}
let version: string;
try {
const body = (await res.json()) as { version?: unknown };
if (typeof body.version !== 'string' || !body.version) return { ok: false, error: 'no version in response' };
version = body.version;
} catch {
return { ok: false, error: 'GET /version did not return JSON' };
}
const parsed = parseVersion(version);
if (!parsed) return { ok: true, version, supported: 'unknown' };
return { ok: true, version, supported: meetsFloor(parsed) };
}
@@ -52,7 +52,6 @@ import { API_URL } from '../../officer-url.mjs';
// update/*, installation/*, mail config, settings writes, ownership transfer — is deliberately unreachable.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
-1
View File
@@ -44,7 +44,6 @@ import { API_URL } from '../../officer-url.mjs';
// general proxy, and why HLS forces it to keep Jellyfin's own paths.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
-1
View File
@@ -23,7 +23,6 @@ import { API_URL } from '../../officer-url.mjs';
// SSRF hop into whatever else is on that host.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// Everything under /api/v1 the UI legitimately needs. Auth routes are excluded on purpose: signin and
// signout would mint or destroy sessions on the instance, and this sidecar authenticates with a stored
// token rather than borrowing the owner's Memos session.
-2
View File
@@ -41,7 +41,6 @@ import {
import { DATA_PATH } from '../../data-path';
import { API_URL } from '../../officer-url.mjs';
// ── Per-user state validation ──
// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're
// loopback-only so we trust it). Favorite/playlist `key`s are opaque paths we never interpret.
@@ -115,7 +114,6 @@ const asKeys = (v: unknown): string[] | null =>
// `v` = per-album version stamp; unchanged `v` ⇒ nothing changed ⇒ the phone can skip re-downloading.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// ── Audio-streaming HTTP server ──
/** Grab an ephemeral free port by briefly binding one and releasing it. */
+6 -2
View File
@@ -678,7 +678,9 @@ function logManifestDelta(prev: Manifest, next: Manifest): void {
// A cache-format upgrade rebuilds every album by definition, so the delta is expected and says
// nothing about drift. Label it rather than let it read as 6k albums of rot.
if (prev.version !== next.version) {
console.log(`[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`);
console.log(
`[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`,
);
}
const { added, removed, changed } = diffManifest(prev, next);
@@ -688,7 +690,9 @@ function logManifestDelta(prev: Manifest, next: Manifest): void {
return;
}
console.log(`[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`);
console.log(
`[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`,
);
const sample = (label: string, rels: string[]) => {
for (const rel of rels.slice(0, 5)) console.log(`[music] ${label} ${rel || '.'}`);
if (rels.length > 5) console.log(`[music] ${label} …and ${rels.length - 5} more`);
+3 -1
View File
@@ -21,7 +21,9 @@ export function startNightlyReindex(): void {
const schedule = () => {
const ms = msUntilNextHour(REINDEX_HOUR);
const at = new Date(Date.now() + ms);
console.log(`[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`);
console.log(
`[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`,
);
timer = setTimeout(async () => {
console.log('[music] nightly full reindex starting');
try {
+15 -2
View File
@@ -29,7 +29,16 @@ async function probeDuration(absPath: string, mtimeMs: number): Promise<number |
if (cached !== undefined) return cached;
try {
const proc = Bun.spawn(
['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', absPath],
[
'ffprobe',
'-v',
'error',
'-show_entries',
'format=duration',
'-of',
'default=noprint_wrappers=1:nokey=1',
absPath,
],
{ stdout: 'pipe', stderr: 'ignore' },
);
const out = (await new Response(proc.stdout).text()).trim();
@@ -88,7 +97,11 @@ export async function streamAudioFile(relPath: string, rangeHeader: string | nul
}
return new Response(file.slice(start, end + 1), {
status: 206,
headers: { ...baseHeaders, 'Content-Range': `bytes ${start}-${end}/${total}`, 'Content-Length': String(end - start + 1) },
headers: {
...baseHeaders,
'Content-Range': `bytes ${start}-${end}/${total}`,
'Content-Length': String(end - start + 1),
},
});
}
}
-1
View File
@@ -27,7 +27,6 @@ import { API_URL } from '../../officer-url.mjs';
// the visible string is composed here rather than sent by the producer.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
const VALID_TYPES: NotifyType[] = ['job', 'mail', 'agent', 'download', 'test'];
/** Grab an ephemeral free port by briefly binding one and releasing it. */
-1
View File
@@ -47,7 +47,6 @@ import { API_URL } from '../../officer-url.mjs';
// ETag included. The administrative half of Immich's API is unreachable — see routes.ts for the list.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
+1 -1
View File
@@ -31,7 +31,7 @@ export function startServer() {
// `osUser` scopes both to one account's sessions. The platform sends it for a member and omits it for the
// owner; absent means unscoped. Until this existed these two listed and killed EVERY shell on the box for
// anyone who could reach them, which was safe only because the terminal was owner-only.
const scope = url.searchParams.has('osUser') ? (url.searchParams.get('osUser') || null) : undefined;
const scope = url.searchParams.has('osUser') ? url.searchParams.get('osUser') || null : undefined;
if (url.pathname === '/_officer/sessions' && req.method === 'GET') {
return json(res, 200, { sessions: store.list(scope) });
+4 -1
View File
@@ -237,7 +237,10 @@ async function runJob(job: Job) {
fresh.completedAt = Date.now();
fresh.meta = { ...fresh.meta, ...sharedMeta };
await writeJob(fresh);
console.error(`[sidecar:queue] ✗ job ${fresh.id} failed at step "${step.name}" in ${formatDuration(Date.now() - startTime)}:`, errorMessage);
console.error(
`[sidecar:queue] ✗ job ${fresh.id} failed at step "${step.name}" in ${formatDuration(Date.now() - startTime)}:`,
errorMessage,
);
await notifyFailure(fresh);
return;
}
-1
View File
@@ -38,7 +38,6 @@ import { API_URL } from '../../officer-url.mjs';
// (src/servers/sidecar/vault/index.ts) once the client needs real-time updates. SignalR carries its
// credential as an `?access_token=` query param on the socket, so the key injection differs from HTTP.
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
@@ -46,7 +46,6 @@ import { API_URL } from '../../officer-url.mjs';
// platform, and which daemon to talk to is per-owner data.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
-1
View File
@@ -22,7 +22,6 @@ import { API_URL } from '../../officer-url.mjs';
// The server listens on a random loopback port, reported to the API on connect so it can route here.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
-1
View File
@@ -4,7 +4,6 @@ import { createSidecarConnector } from '../connect';
import { getOwnerHomeDir } from '@@/data-path';
import { API_URL } from '../../officer-url.mjs';
// ── Command handlers ──
type ReplyFn = (msg: SidecarEvent) => void;
+1 -3
View File
@@ -148,9 +148,7 @@ function readDisplayGeometry(xauthority: string, display: string): DisplayGeomet
// "HDMI-A-0 connected primary 3840x2160+0+0 (normal left ..." — the geometry only appears on an
// output that is actually enabled, so a connected-but-off output correctly yields no match.
const p = out.match(/^\S+ connected primary (\d+)x(\d+)\+(\d+)\+(\d+)/m);
const primary = p
? { w: Number(p[1]), h: Number(p[2]), x: Number(p[3]), y: Number(p[4]) }
: null;
const primary = p ? { w: Number(p[1]), h: Number(p[2]), x: Number(p[3]), y: Number(p[4]) } : null;
return { framebufferWidth: fb ? Number(fb[1]) : null, primary, connected };
}
-1
View File
@@ -82,7 +82,6 @@ import { API_URL } from '../../officer-url.mjs';
// WALLET_LOCKED from signing paths only; every read above keeps working.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
+5 -7
View File
@@ -116,11 +116,7 @@ function buildSchema(inputs: Record<string, ToolParam>): TSchema {
switch (param.type) {
case 'enum': {
const raw = param.values;
const values = Array.isArray(raw)
? raw
: typeof raw === 'string'
? raw.split(',').map((v) => v.trim())
: [];
const values = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(',').map((v) => v.trim()) : [];
schema = Type.Union(
values.map((v) => Type.Literal(v)),
{ description: param.description },
@@ -175,7 +171,7 @@ function discoverTools(dir: string): Array<{ toolDir: string; entryFile: string;
continue;
}
if ((meta.targets as string ?? 'all') === 'claude') continue;
if (((meta.targets as string) ?? 'all') === 'claude') continue;
discovered.push({ toolDir, entryFile, meta: meta as ToolMeta });
}
@@ -225,7 +221,9 @@ export default function (pi: ExtensionAPI) {
if (typeof executeFn !== 'function') {
return {
content: [{ type: 'text', text: `[tool-loader] ${meta.name}/index.ts must export an "execute" function` }],
content: [
{ type: 'text', text: `[tool-loader] ${meta.name}/index.ts must export an "execute" function` },
],
details: {},
isError: true,
};
@@ -11,7 +11,6 @@ import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
import { appRegistryMetas as musicMetas } from '../apps/Music';
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
import { appRegistryMetas as headscaleMetas } from '../apps/Headscale';
import { appRegistryMetas as photosMetas } from '../apps/Photos';
import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin';
import { appRegistryMetas as transmissionMetas } from '../apps/Transmission';
@@ -36,7 +35,6 @@ export const apps = [
...desktopMetas,
...musicMetas,
...soulseekMetas,
...headscaleMetas,
...photosMetas,
...jellyfinMetas,
...transmissionMetas,
@@ -1,103 +0,0 @@
import type { ReactNode } from 'react';
// Shared visual language for the /headscale panels, matching the /soulseek grouped views: almost-black
// cards on hairline white borders. Kept local to the app so the look changes in one place.
export const Card = ({ children }: { children: ReactNode }) => (
<div className="overflow-hidden rounded-xl border border-white/10 bg-zinc-950 shadow-sm">{children}</div>
);
export const SectionHeader = ({
title,
subtitle,
action,
}: {
title: string;
subtitle?: string;
action?: ReactNode;
}) => (
<div className="flex items-start justify-between gap-4 px-1 pb-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-100">{title}</h2>
{subtitle && <p className="mt-0.5 text-xs text-zinc-500">{subtitle}</p>}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
);
type ButtonProps = {
children: ReactNode;
onClick?: () => void;
type?: 'button' | 'submit';
variant?: 'primary' | 'ghost' | 'danger';
disabled?: boolean;
title?: string;
};
const VARIANTS: Record<NonNullable<ButtonProps['variant']>, string> = {
primary: 'border-primary/40 bg-primary/15 text-primary hover:bg-primary/25',
ghost: 'border-white/10 bg-white/[0.02] text-zinc-300 hover:bg-white/10 hover:text-zinc-100',
danger: 'border-red-500/30 bg-red-500/10 text-red-300 hover:bg-red-500/20',
};
export const Button = ({ children, onClick, type = 'button', variant = 'ghost', disabled, title }: ButtonProps) => (
<button
type={type}
onClick={onClick}
disabled={disabled}
title={title}
className={`inline-flex cursor-pointer items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-colors disabled:cursor-default disabled:opacity-40 ${VARIANTS[variant]}`}
>
{children}
</button>
);
type FieldProps = {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
hint?: string;
type?: 'text' | 'password';
autoFocus?: boolean;
};
export const Field = ({ label, value, onChange, placeholder, hint, type = 'text', autoFocus }: FieldProps) => (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-zinc-400">{label}</span>
<input
type={type}
value={value}
onChange={(ev) => onChange(ev.target.value)}
placeholder={placeholder}
autoFocus={autoFocus}
spellCheck={false}
autoComplete="off"
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 focus:border-primary/50"
/>
{hint && <span className="text-[11px] leading-snug text-zinc-600">{hint}</span>}
</label>
);
/** Tiny status light: green healthy, amber unknown/unverified, red failing. */
export const Dot = ({ tone }: { tone: 'ok' | 'warn' | 'bad' | 'idle' }) => {
const color =
tone === 'ok' ? 'bg-emerald-400' : tone === 'warn' ? 'bg-amber-400' : tone === 'bad' ? 'bg-red-400' : 'bg-zinc-600';
return <span className={`inline-block h-2 w-2 shrink-0 rounded-full ${color}`} />;
};
export const Badge = ({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'neutral' | 'active' }) => (
<span
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium ${
tone === 'active' ? 'border-primary/40 bg-primary/10 text-primary' : 'border-white/10 text-zinc-400'
}`}
>
{children}
</span>
);
export const ErrorNote = ({ children }: { children: ReactNode }) => (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs leading-snug text-red-300">
{children}
</div>
);
@@ -1,96 +0,0 @@
import { useCallback } from 'react';
import { Link } from 'react-router';
import { Loader2, TerminalSquare } from 'lucide-react';
import { headscaleSectionPath } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers';
import { TerminalView } from '../Terminal/Terminal';
import { Button } from './Cards';
// A shell on the machine behind the active Headscale server — the escape hatch for everything the API cannot
// answer (why headscale won't start, what the logs say, whether the disk is full).
//
// It is deliberately the SAME terminal every other panel uses, driven by nothing more than `ssh <host>` typed
// into a login shell. Officer holds no key, no password and no port: whatever `ssh` on this box can already
// reach, this can reach, and nothing more. If the connection needs a jump host or an odd port, that belongs in
// `~/.ssh/config` as a Host alias — which this field accepts by name.
//
// The session id is derived from the server id rather than minted per panel, so re-opening the Console lands
// back in the shell that is already running and mid-command, and switching servers is a different shell rather
// than the same one re-purposed. TerminalView suppresses its initial input when the sidecar replays a buffer,
// which is what stops a re-attach from typing a second `ssh` inside the first.
const consoleSessionId = (serverId: number) => `headscale-console-${serverId}`;
const Centred = ({ children }: { children: React.ReactNode }) => (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">
<TerminalSquare className="h-6 w-6" />
</div>
{children}
</div>
);
export const ConsoleView = () => {
const { active, isLoading } = useHeadscaleServers();
// The terminal reports its connection state; nothing in this section acts on it yet, but TerminalView wants
// a stable callback and an inline arrow would remount its effect on every render.
const onConnectionChange = useCallback(() => {}, []);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading servers
</div>
);
}
if (!active) {
return (
<Centred>
<div>
<div className="text-base font-semibold text-zinc-100">No server selected</div>
<p className="mt-1 text-sm text-zinc-500">Pick one in the Servers section to open its console.</p>
</div>
</Centred>
);
}
if (!active.sshHost) {
return (
<Centred>
<div>
<div className="text-base font-semibold text-zinc-100">No SSH address for {active.name}</div>
<p className="mt-1 max-w-sm text-sm text-zinc-500">
Add one on the server to open a shell on the machine behind it. Use the machine's own address rather than
the Headscale hostname — the console is most useful exactly when that name has stopped answering.
</p>
</div>
<Link to={headscaleSectionPath('servers')}>
<Button variant="primary">Go to Servers</Button>
</Link>
</Centred>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-3 py-2 text-[11px] text-zinc-500">
<TerminalSquare className="h-3.5 w-3.5" />
<span className="truncate">
ssh <span className="font-mono text-zinc-300">{active.sshHost}</span> · {active.name}
</span>
</div>
<TerminalView
// Remount on a server switch: the session id is a mount-time argument, so without this the panel would
// keep showing the previous server's shell under the new server's name.
key={active.id}
className="min-h-0 flex-1 p-2"
sessionId={consoleSessionId(active.id)}
initialInput={`ssh ${active.sshHost}`}
onConnectionChange={onConnectionChange}
/>
</div>
);
};
@@ -1,326 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import {
Activity,
AlertTriangle,
Loader2,
Play,
PlugZap,
RotateCw,
ScrollText,
Square,
Trash2,
Unplug,
} from 'lucide-react';
import type { CompanionAction, CompanionContainer, CompanionHealthBody, CompanionVerdict } from './shared';
import { timeAgo } from './format';
import { useHeadscaleServers } from './useHeadscaleServers';
import {
useCompanionAction,
useCompanionHealth,
useCompanionLogStream,
useCompanionLogs,
} from './useHeadscaleCompanion';
import { Card, SectionHeader, Button, Dot, ErrorNote } from './Cards';
import { ViewShell } from './ViewShell';
// What the admin API structurally cannot tell you: is the container running, what did it log on the way
// down, and can we bring it back. All of it comes from the Officer Companion deployed alongside the server.
//
// The companion is optional and per-server, so "not deployed" is the ordinary case for a server that has
// never had one, and is rendered as an explanation rather than an error. Note the two inversions this
// section is built around: the companion's /health is ALWAYS HTTP 200 (read `verdict`, never the status),
// and an unavailable companion says nothing about Headscale itself — the admin API on the same domain is
// independent and the other sections may be working perfectly.
const VERDICTS: Record<CompanionVerdict, { tone: 'ok' | 'warn' | 'bad' | 'idle'; label: string; blurb: string }> = {
ok: { tone: 'ok', label: 'Healthy', blurb: 'The container is running and Headscale is answering.' },
degraded: {
tone: 'warn',
label: 'Degraded',
blurb: 'The container is running, but Headscale is not answering properly.',
},
down: { tone: 'bad', label: 'Down', blurb: 'The container is not running.' },
unknown: { tone: 'idle', label: 'Unknown', blurb: 'Docker does not know this container.' },
};
const ACTIONS: { id: CompanionAction; label: string; icon: typeof RotateCw; confirm: string }[] = [
{
id: 'restart',
label: 'Restart',
icon: RotateCw,
confirm: 'Restart the Headscale container? Every node loses its control-plane connection until it is back.',
},
{
id: 'stop',
label: 'Stop',
icon: Square,
confirm: 'Stop the Headscale container? Every node stays disconnected until you start it again.',
},
{ id: 'start', label: 'Start', icon: Play, confirm: 'Start the Headscale container?' },
];
/** The RFC3339 zero date the companion passes through from docker for a container that never finished. */
const isZeroDate = (iso: string) => iso.startsWith('0001-');
const Row = ({ label, value }: { label: string; value: React.ReactNode }) => (
<div className="flex items-baseline justify-between gap-4 py-1.5 text-xs">
<span className="shrink-0 text-zinc-500">{label}</span>
<span className="min-w-0 truncate text-right text-zinc-300">{value}</span>
</div>
);
const ContainerFacts = ({ container }: { container: CompanionContainer }) => (
<div className="divide-y divide-white/5">
<Row label="Container" value={container.status} />
<Row label="Healthcheck" value={container.healthcheck ?? 'none defined'} />
<Row label="Started" value={timeAgo(container.startedAt)} />
{!container.running && !isZeroDate(container.finishedAt) && (
<Row label="Exited" value={`${timeAgo(container.finishedAt)} · code ${container.exitCode}`} />
)}
<Row label="Restarts" value={container.restartCount === 0 ? 'none' : `${container.restartCount} by docker`} />
</div>
);
/** Evidence, shown only when the verdict is not ok — likely causes first, then the raw tail behind them. */
const Evidence = ({ health }: { health: CompanionHealthBody }) => {
const causes = health.likelyCauses ?? [];
const recent = health.recentLogs ?? [];
if (causes.length === 0 && recent.length === 0 && !health.healthcheckOutput) return null;
return (
<div className="flex flex-col gap-3 border-t border-white/10 p-4">
{causes.length > 0 && (
<div>
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-amber-300">
<AlertTriangle className="h-3.5 w-3.5" />
Likely causes
</div>
<ul className="flex flex-col gap-1">
{causes.map((cause) => (
<li key={cause} className="rounded-md bg-amber-500/10 px-2.5 py-1.5 text-xs text-amber-200/90">
{cause}
</li>
))}
</ul>
{/* The companion reads these out of the logs heuristically. Saying so is the difference between a
hint the owner checks and a diagnosis they trust and then chase down the wrong hole. */}
<p className="mt-1 text-[11px] text-zinc-600">Guessed from the logs treat them as leads, not answers.</p>
</div>
)}
{health.healthcheckOutput && (
<div>
<div className="mb-1.5 text-xs font-medium text-zinc-400">Healthcheck output</div>
<pre className="overflow-x-auto rounded-md bg-black/50 p-2.5 font-mono text-[11px] whitespace-pre-wrap text-zinc-400">
{health.healthcheckOutput}
</pre>
</div>
)}
{recent.length > 0 && (
<div>
<div className="mb-1.5 text-xs font-medium text-zinc-400">Last lines before now</div>
<pre className="max-h-48 overflow-auto rounded-md bg-black/50 p-2.5 font-mono text-[11px] text-zinc-400">
{recent.join('\n')}
</pre>
</div>
)}
</div>
);
};
const TAILS = [100, 500, 2000];
const LogViewer = () => {
const [tail, setTail] = useState(200);
const [follow, setFollow] = useState(false);
const snapshot = useCompanionLogs(tail, !follow);
const stream = useCompanionLogStream(follow, tail);
const boxRef = useRef<HTMLPreElement>(null);
const lines = follow ? stream.lines : snapshot.data?.available ? snapshot.data.lines : [];
// Pin to the bottom while following. Only while following: scrolling a snapshot back to the top and having
// it yanked down again would be the viewer fighting the reader.
useEffect(() => {
if (follow && boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight;
}, [follow, lines.length]);
const unavailable = !follow && snapshot.data && !snapshot.data.available ? snapshot.data.reason : null;
return (
<Card>
<div className="flex flex-wrap items-center gap-2 border-b border-white/10 px-3 py-2">
<div className="mr-auto flex items-center gap-1.5 text-xs font-medium text-zinc-300">
<ScrollText className="h-3.5 w-3.5" />
Logs
{follow && (
<span className="ml-1 inline-flex items-center gap-1 text-[11px] text-zinc-500">
<Dot tone={stream.live ? 'ok' : 'idle'} />
{stream.live ? 'live' : 'stopped'}
</span>
)}
</div>
<div className="flex items-center gap-1">
{TAILS.map((n) => (
<Button key={n} variant={tail === n ? 'primary' : 'ghost'} onClick={() => setTail(n)}>
{n}
</Button>
))}
</div>
<Button variant={follow ? 'primary' : 'ghost'} onClick={() => setFollow((on) => !on)}>
{follow ? <Unplug className="h-3.5 w-3.5" /> : <PlugZap className="h-3.5 w-3.5" />}
{follow ? 'Stop' : 'Follow'}
</Button>
{follow ? (
<Button onClick={stream.clear} title="Clear what has been received">
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : (
<Button onClick={() => void snapshot.refetch()} disabled={snapshot.isFetching}>
{snapshot.isFetching ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCw className="h-3.5 w-3.5" />
)}
Refresh
</Button>
)}
</div>
{stream.error && (
<div className="border-b border-white/10 px-3 py-2 text-[11px] text-red-300">{stream.error}</div>
)}
{unavailable && <div className="border-b border-white/10 px-3 py-2 text-[11px] text-zinc-500">{unavailable}</div>}
<pre
ref={boxRef}
className="max-h-[26rem] min-h-[12rem] overflow-auto bg-black/40 p-3 font-mono text-[11px] leading-relaxed text-zinc-400"
>
{lines.length > 0
? lines.join('\n')
: snapshot.isLoading
? 'Loading…'
: follow
? 'Waiting for output…'
: 'No log lines.'}
</pre>
</Card>
);
};
const Lifecycle = ({ running }: { running: boolean | null }) => {
const action = useCompanionAction();
const [pending, setPending] = useState<CompanionAction | null>(null);
const run = async (id: CompanionAction, confirm: string) => {
if (!window.confirm(confirm)) return;
setPending(id);
try {
await action.mutateAsync(id);
} catch {
/* surfaced from action.error below */
} finally {
setPending(null);
}
};
const result = action.data;
return (
<div className="flex flex-col gap-2 border-t border-white/10 p-4">
<div className="flex flex-wrap items-center gap-2">
{ACTIONS.map(({ id, label, icon: Icon, confirm }) => (
<Button
key={id}
variant={id === 'stop' ? 'danger' : 'ghost'}
disabled={pending !== null || (running !== null && (id === 'start' ? running : !running))}
onClick={() => void run(id, confirm)}
title={id === 'start' && running ? 'Already running' : undefined}
>
{pending === id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Icon className="h-3.5 w-3.5" />}
{label}
</Button>
))}
<span className="text-[11px] text-zinc-600">Acts on the container, not on Officer.</span>
</div>
{action.error != null && <ErrorNote>The action could not be sent.</ErrorNote>}
{result && !result.available && <ErrorNote>{result.reason}</ErrorNote>}
{result && result.available && !result.ok && (
<ErrorNote>{result.error ?? 'Docker refused the action.'}</ErrorNote>
)}
{result && result.available && result.ok && (
<div className="text-[11px] text-emerald-300">
{result.action}: {result.result}
</div>
)}
</div>
);
};
export const DiagnosticsView = () => {
const { active } = useHeadscaleServers();
const query = useCompanionHealth();
const result = query.data;
return (
<ViewShell isLoading={query.isLoading} error={query.error} label="diagnostics">
<div className="mx-auto flex max-w-3xl flex-col gap-4">
<SectionHeader
title="Diagnostics"
subtitle={active ? `The container behind ${active.name}, as seen from the machine it runs on.` : undefined}
action={query.isFetching ? <Loader2 className="h-3.5 w-3.5 animate-spin text-zinc-600" /> : undefined}
/>
{result && !result.available ? (
<Card>
<div className="flex flex-col gap-2 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-100">
<Activity className="h-4 w-4 text-zinc-500" />
No companion on this server
</div>
<p className="text-xs leading-relaxed text-zinc-500">
{result.reason}. The Officer Companion is a small service deployed next to Headscale that can see its
container it is what makes health, logs and restart possible from here.
</p>
<p className="text-xs leading-relaxed text-zinc-600">
This says nothing about Headscale itself: it is served by the same domain but a different process, so
the other sections may be working normally. When the companion is missing, the Console section is the
way in.
</p>
</div>
</Card>
) : result ? (
<>
<Card>
<div className="flex items-start gap-3 p-4">
<div className="mt-1">
<Dot tone={VERDICTS[result.health.verdict].tone} />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-zinc-100">{VERDICTS[result.health.verdict].label}</div>
<p className="mt-0.5 text-xs text-zinc-500">
{result.health.reason ?? VERDICTS[result.health.verdict].blurb}
</p>
</div>
</div>
<div className="border-t border-white/10 px-4 py-2">
<Row label="Control plane" value={result.health.connected ? 'answering' : 'not answering'} />
{result.health.probe && <Row label="Probe" value={result.health.probe} />}
{result.health.container && <ContainerFacts container={result.health.container} />}
</div>
<Evidence health={result.health} />
<Lifecycle running={result.health.container?.running ?? null} />
</Card>
<LogViewer />
</>
) : null}
</div>
</ViewShell>
);
};
@@ -1,75 +0,0 @@
import type { LucideIcon } from 'lucide-react';
import { NavLink } from 'react-router';
import { Server, Laptop, Users, KeyRound, Smartphone, ShieldCheck, Activity, TerminalSquare } from 'lucide-react';
import { HEADSCALE_SECTIONS, headscaleSectionPath, type HeadscaleSectionId } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers';
// Lower-left panel of the /headscale workspace: the section list. Which server it all acts on is the panel
// above (HeadscaleServerPicker) — that one mutates, this one navigates, which is why they are separate.
//
// Sections are real links to /headscale/<section>, not channel writes — so they cmd-click into a new tab,
// survive a reload, and answer the back button. Active state comes from react-router's NavLink rather than
// being derived in JS, per the navigation audit's Phase 4.
const ICONS: Record<HeadscaleSectionId, LucideIcon> = {
servers: Server,
nodes: Laptop,
users: Users,
keys: KeyRound,
invites: Smartphone,
policy: ShieldCheck,
diagnostics: Activity,
console: TerminalSquare,
};
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
type SectionBodyProps = { icon: LucideIcon; label: string; selected: boolean };
const SectionBody = ({ icon: Icon, label, selected }: SectionBodyProps) => (
<>
{selected && <span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />}
<Icon
className={`h-4 w-4 shrink-0 ${selected ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
/>
{label}
</>
);
export const HeadscaleNav = () => {
const { active } = useHeadscaleServers();
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<nav className="flex flex-col gap-0.5 px-2 py-3">
{HEADSCALE_SECTIONS.map(({ id, label }) => {
// Without an active server the domain sections have nothing to act on, so they are rendered as
// plain text rather than as anchors — a disabled <a> is not a thing, and a link that goes nowhere
// useful is worse than no link.
if (id !== 'servers' && !active) {
return (
<span key={id} title="Select a server first" className={`${ROW} cursor-default opacity-40`}>
<SectionBody icon={ICONS[id]} label={label} selected={false} />
</span>
);
}
return (
<NavLink
key={id}
to={headscaleSectionPath(id)}
className={({ isActive }) =>
`${ROW} ${
isActive
? 'bg-primary/10 font-medium text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`
}
>
{({ isActive }) => <SectionBody icon={ICONS[id]} label={label} selected={isActive} />}
</NavLink>
);
})}
</nav>
</div>
);
};
@@ -1,60 +0,0 @@
import { Check, Network, Plus } from 'lucide-react';
import { Link } from 'react-router';
import { headscaleSectionPath } from './shared';
import { useHeadscaleServers } from './useHeadscaleServers';
// Top-left panel of the /headscale workspace: which server everything else acts on.
//
// It is its own panel rather than a block inside HeadscaleNav because the two answer different questions —
// "which server" and "which section" — and only one of them is navigation. Activating a server is a mutation
// (a DB write that re-scopes every other query), so these stay buttons with no URL of their own, while the
// section list below is real links.
//
// Every registered server is listed, including when there is only one: the panel's whole job is to say what
// the rest of the screen is talking to, and a picker that hides itself at one server makes that invisible.
export const HeadscaleServerPicker = () => {
const { servers, active, activate, isLoading } = useHeadscaleServers();
return (
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
<div className="flex items-center gap-3 px-4 py-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-indigo-500/15 text-indigo-400 ring-1 ring-black/5">
<Network className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold leading-tight">Headscale</div>
<div className="truncate text-xs text-muted-foreground">{active ? active.name : 'no server'}</div>
</div>
</div>
<div className="flex flex-col gap-0.5 px-2 pb-3">
{servers.map((server) => (
<button
key={server.id}
type="button"
onClick={() => !server.isActive && activate.mutate(server.id)}
disabled={activate.isPending}
title={server.url}
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition-colors ${
server.isActive ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted'
}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${server.isActive ? 'text-primary' : 'opacity-0'}`} />
<span className="min-w-0 flex-1 truncate">{server.name}</span>
</button>
))}
{servers.length === 0 && !isLoading && (
<Link
to={headscaleSectionPath('servers')}
className="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Plus className="h-3.5 w-3.5 shrink-0" />
Register a server
</Link>
)}
</div>
</div>
);
};
@@ -1,37 +0,0 @@
import { useHeadscaleSection } from './useHeadscaleSection';
import { ServersView } from './ServersView';
import { NodesView } from './NodesView';
import { UsersView } from './UsersView';
import { KeysView } from './KeysView';
import { InvitesView } from './InvitesView';
import { PolicyView } from './PolicyView';
import { DiagnosticsView } from './DiagnosticsView';
import { ConsoleView } from './ConsoleView';
// Right panel of the /headscale workspace — renders the section named by the URL.
//
// Every section except `servers` acts on whichever server is active; each handles the "none selected" case
// itself through ViewShell, so there is no gating to do here.
export const HeadscaleView = () => {
const section = useHeadscaleSection();
switch (section) {
case 'nodes':
return <NodesView />;
case 'users':
return <UsersView />;
case 'keys':
return <KeysView />;
case 'invites':
return <InvitesView />;
case 'policy':
return <PolicyView />;
case 'diagnostics':
return <DiagnosticsView />;
case 'console':
return <ConsoleView />;
default:
return <ServersView />;
}
};
@@ -1,23 +0,0 @@
import { Network } from 'lucide-react';
import { useHeadscaleServers } from './useHeadscaleServers';
import { HEADSCALE_SECTIONS } from './shared';
import { useHeadscaleSection } from './useHeadscaleSection';
// Panel header for the right (headscale-view) panel. Shows the section and, crucially, which server it is
// acting on — with several registered, "delete this node" is only safe if the target is unambiguous.
export const HeadscaleViewHeader = () => {
const section = useHeadscaleSection();
const { active } = useHeadscaleServers();
const label = HEADSCALE_SECTIONS.find((s) => s.id === section)?.label ?? 'Headscale';
return (
<>
<Network className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-xs font-medium">
{label}
{active && <span className="ml-1.5 font-normal text-black/50">· {active.name}</span>}
</span>
</>
);
};
@@ -1,394 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import QRCode from 'qrcode';
import { Check, Copy, Loader2, Plus, QrCode, Share2, Smartphone, Trash2 } from 'lucide-react';
import type { HeadscaleInvite, HeadscaleInviteCreated, InviteStatus } from './shared';
import { INVITE_TTL_DEFAULT_SECONDS } from './shared';
import { useHeadscaleInvites } from './useHeadscaleInvites';
import { useHeadscaleUsers } from './useHeadscaleData';
import { headscaleErrorMessage } from './useHeadscaleServers';
import { fullDate, timeAgo, timeUntil } from './format';
import { Badge, Button, Card, Dot, ErrorNote, Field } from './Cards';
import { EmptyBody, ViewShell } from './ViewShell';
import { copyToClipboard } from 'helpers/clipboard';
// Device invites — the admin surface of COMMS/OFFSCALE_INVITE_ENROLLMENT.md §5.
//
// The point of the feature is that the person joining does nothing but tap a link and press Join: no app
// store hunt, no control-server URL typed by hand, no pre-auth key they have no way to generate. The admin
// does all of it here and sends one link.
//
// TWO RULES SHAPE THIS FILE.
//
// 1. The link exists exactly once. Its fragment carries the claim token, and §5 is explicit: never display,
// log or store it beyond the moment it is handed to the admin. So the created invite lives in component
// state only — never in the query cache, never in a URL, never in a toast that outlives the panel — and
// the panel drops it on dismiss. Refreshing the page is meant to lose it; the admin mints another.
// 2. The token is not the key. Nothing here can join a machine to the tailnet: the pre-auth key is minted
// by the server at claim time. A leaked link before it is claimed is revocable, which is the whole
// reason the credential is not in the URL.
const STATUS_TONE: Record<InviteStatus, 'ok' | 'warn' | 'bad' | 'idle'> = {
pending: 'warn',
claimed: 'ok',
expired: 'idle',
revoked: 'bad',
};
/** Presets rather than a free number: every one is inside the spec's 60s24h range by construction. */
const TTL_OPTIONS = [
{ seconds: 300, label: '5 minutes' },
{ seconds: INVITE_TTL_DEFAULT_SECONDS, label: '15 minutes' },
{ seconds: 3600, label: '1 hour' },
{ seconds: 86_400, label: '24 hours' },
] as const;
const CopyButton = ({ value, label }: { value: string; label: string }) => {
const [done, setDone] = useState(false);
const copy = () => {
void copyToClipboard(value);
setDone(true);
window.setTimeout(() => setDone(false), 1500);
};
return (
<Button onClick={copy} variant={done ? 'primary' : 'ghost'}>
{done ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{done ? 'Copied' : label}
</Button>
);
};
/**
* The QR, rendered client-side into a canvas.
*
* It never leaves the browser — an image endpoint would put the claim token in a request line and therefore
* in a server log, which is the exact thing the fragment-only link format exists to prevent. Error
* correction stays low so the modules stay large: this is scanned from a phone held next to the screen, not
* printed and posted.
*/
const InviteQr = ({ url }: { url: string }) => {
const canvas = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!canvas.current) return;
void QRCode.toCanvas(canvas.current, url, { errorCorrectionLevel: 'L', margin: 2, width: 220 });
}, [url]);
return <canvas ref={canvas} className="h-[220px] w-[220px] rounded-lg bg-white p-1" />;
};
type InviteLinkPanelProps = { invite: HeadscaleInviteCreated; onDismiss: () => void };
const InviteLinkPanel = ({ invite, onDismiss }: InviteLinkPanelProps) => {
const [showQr, setShowQr] = useState(true);
// Plain https now — the link lands on a page the companion serves, which bounces into the app. It goes in
// `url` rather than `text` so share targets treat it as a link and preserve the fragment. A cancelled sheet
// rejects — nothing to report there, the link is still on screen.
const share = () => {
void navigator
.share?.({ title: 'Join the tailnet', text: `Tap to join as ${invite.user}`, url: invite.url })
.catch(() => {});
};
return (
<div className="overflow-hidden rounded-xl border border-primary/30 bg-primary/[0.06]">
<div className="flex items-start gap-2.5 border-b border-primary/20 px-4 py-3">
<Smartphone className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<div className="min-w-0">
<div className="text-sm font-semibold text-zinc-100">Send this link to the device</div>
<p className="mt-0.5 text-xs leading-snug text-zinc-400">
It opens OffScale, shows one confirmation screen and joins as{' '}
<span className="text-zinc-200">{invite.user}</span>. Single use, and it stops working{' '}
{timeUntil(invite.expiresAt ?? null)}. Officer does not keep a copy dismiss this and it is gone.
</p>
</div>
</div>
<div className="flex flex-col gap-3 p-4">
<div className="rounded-lg border border-white/10 bg-black/50 px-3 py-2 font-mono text-xs break-all text-zinc-300">
{invite.url}
</div>
{showQr && (
<div className="flex justify-center py-1">
<InviteQr url={invite.url} />
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<CopyButton value={invite.url} label="Copy link" />
{/* Only where the OS actually has a share sheet — a button that silently does nothing is worse
than no button, and on desktop Chrome/Firefox navigator.share is simply absent. */}
{typeof navigator.share === 'function' && (
<Button onClick={share}>
<Share2 className="h-3.5 w-3.5" />
Share
</Button>
)}
<Button onClick={() => setShowQr((v) => !v)}>
<QrCode className="h-3.5 w-3.5" />
{showQr ? 'Hide QR' : 'Show QR'}
</Button>
<Button variant="danger" onClick={onDismiss}>
Done
</Button>
</div>
</div>
</div>
);
};
type CreateInviteFormProps = { onCreated: (invite: HeadscaleInviteCreated) => void; onClose: () => void };
const CreateInviteForm = ({ onCreated, onClose }: CreateInviteFormProps) => {
const { users } = useHeadscaleUsers();
const { create } = useHeadscaleInvites();
const [user, setUser] = useState('');
const [note, setNote] = useState('');
const [ttl, setTtl] = useState(INVITE_TTL_DEFAULT_SECONDS);
const [ephemeral, setEphemeral] = useState(false);
const [tags, setTags] = useState('');
const [error, setError] = useState<string | null>(null);
const submit = async () => {
setError(null);
// The invite API names the Headscale USER, not its uint64 id — the server it is sent to may not be the
// one this list came from by the time it is claimed.
const chosen = user || users[0]?.name;
if (!chosen) return setError('Create a user first — an invite files the joining device under one.');
try {
const invite = await create.mutateAsync({
user: chosen,
ttlSeconds: ttl,
ephemeral,
note: note.trim(),
tags: tags
.split(/[\s,]+/)
.map((t) => t.trim())
.filter(Boolean),
});
onCreated(invite);
onClose();
} catch (err) {
setError(headscaleErrorMessage(err));
}
};
return (
<Card>
<form
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
className="flex flex-col gap-3 p-4"
>
<div className="text-sm font-semibold text-zinc-100">Authorize a new device</div>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-zinc-400">User</span>
<select
value={user || users[0]?.name || ''}
onChange={(ev) => setUser(ev.target.value)}
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-primary/50"
>
{users.map((entry) => (
<option key={entry.id} value={entry.name}>
{entry.name}
</option>
))}
</select>
</label>
<Field
label="Device name (optional)"
value={note}
onChange={setNote}
placeholder="andre-iphone"
hint="Prefilled on the phone's join screen and used as the node's name, which the person can edit. It labels this invite in your list too."
/>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-zinc-400">Link valid for</span>
<select
value={ttl}
onChange={(ev) => setTtl(Number(ev.target.value))}
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-primary/50"
>
{TTL_OPTIONS.map((option) => (
<option key={option.seconds} value={option.seconds}>
{option.label}
</option>
))}
</select>
<span className="text-[11px] leading-snug text-zinc-600">
How long the link can be claimed for. Short is safer you can always mint another.
</span>
</label>
<label className="flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={ephemeral}
onChange={(ev) => setEphemeral(ev.target.checked)}
className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-primary"
/>
<span className="min-w-0">
<span className="block text-xs font-medium text-zinc-300">Ephemeral</span>
<span className="block text-[11px] leading-snug text-zinc-600">
The node is removed when it goes offline. Wrong for a phone; right for a container.
</span>
</span>
</label>
<Field
label="ACL tags (optional)"
value={tags}
onChange={setTags}
placeholder="phone, family"
hint="Comma or space separated. The tag: prefix is added for you, and the device cannot change them."
/>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex items-center gap-2 pt-1">
<Button type="submit" variant="primary" disabled={create.isPending}>
{create.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Create invite
</Button>
<Button onClick={onClose} disabled={create.isPending}>
Cancel
</Button>
</div>
</form>
</Card>
);
};
type InviteRowProps = { invite: HeadscaleInvite; onError: (message: string) => void };
const InviteRow = ({ invite, onError }: InviteRowProps) => {
const { revoke } = useHeadscaleInvites();
const [confirming, setConfirming] = useState(false);
const run = async () => {
try {
await revoke.mutateAsync(invite.id);
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
return (
<Card>
<div className="flex flex-wrap items-center gap-3 p-3.5">
<Dot tone={STATUS_TONE[invite.status] ?? 'idle'} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm text-zinc-200">{invite.note || 'Untitled invite'}</span>
<Badge>{invite.user}</Badge>
{invite.ephemeral && <Badge>ephemeral</Badge>}
{invite.tags?.map((tag) => (
<Badge key={tag} tone="active">
{tag}
</Badge>
))}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
<span>{invite.status}</span>
{invite.status === 'pending' && (
<span title={fullDate(invite.expiresAt ?? null)}>· expires {timeUntil(invite.expiresAt ?? null)}</span>
)}
{invite.status === 'claimed' && (
<span title={fullDate(invite.claimedAt ?? null)}>
· claimed {timeAgo(invite.claimedAt ?? null)}
{invite.claimedFromIp ? ` from ${invite.claimedFromIp}` : ''}
</span>
)}
<span>· created {timeAgo(invite.createdAt ?? null)}</span>
</div>
</div>
{/* Revoking a claimed invite does nothing to the node that used it — that is a separate removal in
Nodes, and conflating the two here would make "revoke" mean two different things. */}
{invite.status === 'pending' && (
<div className="flex shrink-0 flex-wrap items-center gap-2">
{confirming ? (
<>
<Button variant="danger" onClick={() => void run()} disabled={revoke.isPending}>
<Trash2 className="h-3.5 w-3.5" />
Confirm revoke
</Button>
<Button onClick={() => setConfirming(false)} disabled={revoke.isPending}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={revoke.isPending}>
<Trash2 className="h-3.5 w-3.5" />
Revoke
</Button>
)}
</div>
)}
</div>
</Card>
);
};
export const InvitesView = () => {
const { invites, unavailable, isLoading, error } = useHeadscaleInvites();
const [creating, setCreating] = useState(false);
const [created, setCreated] = useState<HeadscaleInviteCreated | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const pending = invites.filter((i) => i.status === 'pending').length;
return (
<ViewShell isLoading={isLoading} error={error} label="device invites">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<div className="flex items-start justify-between gap-4 px-1 pb-1">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-100">Device invites</h2>
<p className="mt-0.5 text-xs text-zinc-500">
{unavailable ? 'Not available on this server' : `${pending} pending of ${invites.length}`}
</p>
</div>
{!creating && !unavailable && (
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="h-3.5 w-3.5" />
Authorize new device
</Button>
)}
</div>
{/* Most registered servers have no enrolment API, and that is a normal state — the rest of the
Headscale sections work regardless, so this must not read as a broken screen. */}
{unavailable && (
<EmptyBody
icon={<Smartphone className="h-6 w-6" />}
title="This server cannot mint invites"
hint={`${unavailable}. Invites are served by the Officer Companion next to Headscale, because the joining phone has to reach it without an Officer account. Until it is deployed, use a pre-auth key.`}
/>
)}
{created && <InviteLinkPanel invite={created} onDismiss={() => setCreated(null)} />}
{creating && <CreateInviteForm onCreated={setCreated} onClose={() => setCreating(false)} />}
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{!unavailable && invites.length === 0 && !creating && (
<EmptyBody
icon={<Smartphone className="h-6 w-6" />}
title="No invites yet"
hint="An invite is a link you send to whoever needs to join. They tap it, confirm once, and they are on the tailnet — no key to paste and nothing to configure."
/>
)}
{invites.map((invite) => (
<InviteRow key={invite.id} invite={invite} onError={setActionError} />
))}
</div>
</ViewShell>
);
};
@@ -1,341 +0,0 @@
import { useState } from 'react';
import { KeyRound, Plus, Trash2, TimerOff, Copy, Check, Loader2, ShieldAlert } from 'lucide-react';
import type { HeadscalePreAuthKey } from './shared';
import { useHeadscaleKeys, useHeadscaleUsers } from './useHeadscaleData';
import { useHeadscaleServers, headscaleErrorMessage } from './useHeadscaleServers';
import { timeAgo, timeUntil, fullDate } from './format';
import { Card, Button, Field, Badge, Dot, ErrorNote } from './Cards';
import { ViewShell, EmptyBody } from './ViewShell';
import { copyToClipboard } from 'helpers/clipboard';
// Pre-auth keys — the tokens a machine presents to join the tailnet.
//
// The whole screen is shaped by one fact: Headscale hashes keys, so the secret exists exactly once, in the
// create response. If this view renders it into a list, or lets a re-render drop it, the key is gone and the
// owner has to make another. Hence the show-once panel: it holds the secret in local state, offers the copy
// and the ready-to-paste join command, and only clears on an explicit dismiss.
//
// The list defaults to active keys because a long-lived server accumulates hundreds of spent ones.
const STATUS_FILTERS = [
{ id: 'active', label: 'Active' },
{ id: 'all', label: 'All' },
] as const;
type StatusFilter = (typeof STATUS_FILTERS)[number]['id'];
const STATUS_TONE = { active: 'ok', used: 'idle', expired: 'bad' } as const;
const CopyButton = ({ value, label }: { value: string; label: string }) => {
const [done, setDone] = useState(false);
const copy = () => {
void copyToClipboard(value);
setDone(true);
window.setTimeout(() => setDone(false), 1500);
};
return (
<Button onClick={copy} variant={done ? 'primary' : 'ghost'}>
{done ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{done ? 'Copied' : label}
</Button>
);
};
type SecretPanelProps = { secret: string; loginServer: string; onDismiss: () => void };
const SecretPanel = ({ secret, loginServer, onDismiss }: SecretPanelProps) => {
const command = `tailscale up --login-server ${loginServer} --authkey ${secret}`;
return (
<div className="overflow-hidden rounded-xl border border-amber-500/30 bg-amber-500/[0.07]">
<div className="flex items-start gap-2.5 border-b border-amber-500/20 px-4 py-3">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
<div className="min-w-0">
<div className="text-sm font-semibold text-amber-200">Copy this key now</div>
<p className="mt-0.5 text-xs leading-snug text-amber-200/70">
Headscale stores it hashed. Once you dismiss this, nothing not Officer, not the server can show it
again.
</p>
</div>
</div>
<div className="flex flex-col gap-3 p-4">
<div>
<div className="pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-200/60">Key</div>
<div className="rounded-lg border border-white/10 bg-black/50 px-3 py-2 font-mono text-xs break-all text-zinc-100">
{secret}
</div>
</div>
<div>
<div className="pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-amber-200/60">Join command</div>
<div className="rounded-lg border border-white/10 bg-black/50 px-3 py-2 font-mono text-xs break-all text-zinc-400">
{command}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<CopyButton value={secret} label="Copy key" />
<CopyButton value={command} label="Copy command" />
<Button variant="danger" onClick={onDismiss}>
I've saved it
</Button>
</div>
</div>
</div>
);
};
type ToggleProps = { checked: boolean; onChange: (value: boolean) => void; label: string; hint: string };
const Toggle = ({ checked, onChange, label, hint }: ToggleProps) => (
<label className="flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
checked={checked}
onChange={(ev) => onChange(ev.target.checked)}
className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-primary"
/>
<span className="min-w-0">
<span className="block text-xs font-medium text-zinc-300">{label}</span>
<span className="block text-[11px] leading-snug text-zinc-600">{hint}</span>
</span>
</label>
);
type CreateKeyFormProps = { onCreated: (secret: string) => void; onClose: () => void };
const CreateKeyForm = ({ onCreated, onClose }: CreateKeyFormProps) => {
const { users } = useHeadscaleUsers();
const { create } = useHeadscaleKeys();
const [userId, setUserId] = useState('');
const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [days, setDays] = useState('90');
const [tags, setTags] = useState('');
const [error, setError] = useState<string | null>(null);
const submit = async () => {
setError(null);
const chosen = userId || users[0]?.id;
if (!chosen) return setError('Create a user first every key belongs to one.');
const expirationDays = Number(days);
if (!Number.isFinite(expirationDays) || expirationDays <= 0)
return setError('Expiry must be a positive number of days');
try {
const result = await create.mutateAsync({
userId: chosen,
reusable,
ephemeral,
expirationDays,
aclTags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
});
if (result.key.key) onCreated(result.key.key);
onClose();
} catch (err) {
setError(headscaleErrorMessage(err));
}
};
return (
<Card>
<form
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
className="flex flex-col gap-3 p-4"
>
<div className="text-sm font-semibold text-zinc-100">New pre-auth key</div>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-zinc-400">User</span>
<select
value={userId || users[0]?.id || ''}
onChange={(ev) => setUserId(ev.target.value)}
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-primary/50"
>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</label>
<div className="flex flex-col gap-2">
<Toggle
checked={reusable}
onChange={setReusable}
label="Reusable"
hint="Any number of machines can join with it, until it expires."
/>
<Toggle
checked={ephemeral}
onChange={setEphemeral}
label="Ephemeral"
hint="Nodes that join with it are removed when they go offline. For containers and CI."
/>
</div>
<Field label="Expires in (days)" value={days} onChange={setDays} placeholder="90" />
<Field
label="ACL tags (optional)"
value={tags}
onChange={setTags}
placeholder="server, ci"
hint="Comma separated. The tag: prefix is added for you."
/>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex items-center gap-2 pt-1">
<Button type="submit" variant="primary" disabled={create.isPending}>
{create.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Create key
</Button>
<Button onClick={onClose} disabled={create.isPending}>
Cancel
</Button>
</div>
</form>
</Card>
);
};
type KeyRowProps = { entry: HeadscalePreAuthKey; onError: (message: string) => void };
const KeyRow = ({ entry, onError }: KeyRowProps) => {
const { expire, remove } = useHeadscaleKeys();
const [confirming, setConfirming] = useState(false);
const busy = expire.isPending || remove.isPending;
const run = async (fn: () => Promise<unknown>) => {
try {
await fn();
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
return (
<Card>
<div className="flex flex-wrap items-center gap-3 p-3.5">
<Dot tone={STATUS_TONE[entry.status]} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-mono text-xs text-zinc-300">{entry.keyDisplay}</span>
{entry.user && <Badge>{entry.user.name}</Badge>}
{entry.reusable && <Badge>reusable</Badge>}
{entry.ephemeral && <Badge>ephemeral</Badge>}
{entry.aclTags.map((tag) => (
<Badge key={tag} tone="active">
{tag}
</Badge>
))}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
<span>{entry.status}</span>
<span title={fullDate(entry.expiration)}>· expires {timeUntil(entry.expiration)}</span>
<span>· created {timeAgo(entry.createdAt)}</span>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{entry.status === 'active' && (
<Button onClick={() => void run(() => expire.mutateAsync(entry.id))} disabled={busy} title="Expire now">
<TimerOff className="h-3.5 w-3.5" />
Expire
</Button>
)}
{confirming ? (
<>
<Button variant="danger" onClick={() => void run(() => remove.mutateAsync(entry.id))} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Confirm delete
</Button>
<Button onClick={() => setConfirming(false)} disabled={busy}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
</div>
</Card>
);
};
export const KeysView = () => {
const { keys, isLoading, error } = useHeadscaleKeys();
const { active } = useHeadscaleServers();
const [creating, setCreating] = useState(false);
const [secret, setSecret] = useState<string | null>(null);
const [filter, setFilter] = useState<StatusFilter>('active');
const [actionError, setActionError] = useState<string | null>(null);
const visible = filter === 'all' ? keys : keys.filter((k) => k.status === 'active');
const activeCount = keys.filter((k) => k.status === 'active').length;
return (
<ViewShell isLoading={isLoading} error={error} label="pre-auth keys">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<div className="flex items-start justify-between gap-4 px-1 pb-1">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-100">Pre-auth keys</h2>
<p className="mt-0.5 text-xs text-zinc-500">
{activeCount} active of {keys.length}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex items-center gap-0.5 rounded-lg border border-white/10 p-0.5">
{STATUS_FILTERS.map((option) => (
<button
key={option.id}
type="button"
onClick={() => setFilter(option.id)}
className={`cursor-pointer rounded-md px-2 py-1 text-[11px] transition-colors ${
filter === option.id ? 'bg-white/10 text-zinc-100' : 'text-zinc-500 hover:text-zinc-300'
}`}
>
{option.label}
</button>
))}
</div>
{!creating && (
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="h-3.5 w-3.5" />
New key
</Button>
)}
</div>
</div>
{secret && <SecretPanel secret={secret} loginServer={active?.url ?? ''} onDismiss={() => setSecret(null)} />}
{creating && <CreateKeyForm onCreated={setSecret} onClose={() => setCreating(false)} />}
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{keys.length === 0 && !creating && (
<EmptyBody
icon={<KeyRound className="h-6 w-6" />}
title="No pre-auth keys"
hint="A key lets a machine join without an interactive login. Create one, then run the join command it gives you."
/>
)}
{keys.length > 0 && visible.length === 0 && (
<div className="py-10 text-center text-sm text-zinc-500">
No active keys. Switch to All to see spent and expired ones.
</div>
)}
{visible.map((entry) => (
<KeyRow key={entry.id} entry={entry} onError={setActionError} />
))}
</div>
</ViewShell>
);
};
@@ -1,436 +0,0 @@
import { useState } from 'react';
import {
Laptop,
Globe,
Trash2,
Pencil,
TimerReset,
Check,
X,
Search,
Copy,
ChevronRight,
UserRound,
ArrowRightLeft,
Tag as TagIcon,
} from 'lucide-react';
import type { HeadscaleNode } from './shared';
import { useHeadscaleNodes, useHeadscaleUsers } from './useHeadscaleData';
import { headscaleErrorMessage } from './useHeadscaleServers';
import { timeAgo, timeUntil, fullDate } from './format';
import { Card, Button, Dot, Badge, ErrorNote } from './Cards';
import { ViewShell, EmptyBody } from './ViewShell';
import { copyToClipboard } from 'helpers/clipboard';
// The nodes section — the machines in the tailnet.
//
// Route approval is the only genuinely dangerous control here, so it is explicit: every route the node
// ADVERTISES is listed, each with its own approve/revoke toggle, and an exit node is called what it is
// rather than shown as the bare 0.0.0.0/0 that it advertises. Approving one route sends the whole approved
// set upstream; the sidecar does that read-modify-write so two panels can't clobber each other's sets.
const copy = (text: string) => void copyToClipboard(text);
type RouteRowProps = { route: string; approved: boolean; busy: boolean; onToggle: (approved: boolean) => void };
const RouteRow = ({ route, approved, busy, onToggle }: RouteRowProps) => {
const isExit = route === '0.0.0.0/0' || route === '::/0';
return (
<div className="flex items-center gap-2 rounded-lg border border-white/5 bg-white/[0.02] px-2.5 py-1.5">
{isExit ? <Globe className="h-3.5 w-3.5 shrink-0 text-amber-400" /> : <Dot tone={approved ? 'ok' : 'idle'} />}
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-zinc-300">{route}</span>
{isExit && <span className="shrink-0 text-[10px] uppercase tracking-wide text-amber-400/80">exit node</span>}
<button
type="button"
disabled={busy}
onClick={() => onToggle(!approved)}
className={`shrink-0 cursor-pointer rounded-md border px-2 py-0.5 text-[11px] transition-colors disabled:opacity-40 ${
approved
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/20'
: 'border-white/10 text-zinc-400 hover:bg-white/10 hover:text-zinc-100'
}`}
>
{approved ? 'Approved' : 'Approve'}
</button>
</div>
);
};
/**
* Tags as Headscale stores them: every one prefixed `tag:`. Typing the prefix every time is noise, so the
* editor accepts either form and normalizes here — which is also how the dirty check stays honest, since
* `web` and `tag:web` are the same tag and neither should look like an edit.
*/
const parseTags = (text: string): string[] => {
const parts = text
.split(/[\s,]+/)
.map((t) => t.trim())
.filter(Boolean);
return [...new Set(parts.map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)))];
};
/** Set comparison, not sequence: Headscale is free to store the tags in an order the owner didn't type. */
const sameTags = (a: string[], b: string[]) => a.length === b.length && a.every((t) => b.includes(t));
type OwnershipProps = { node: HeadscaleNode; busy: boolean; onError: (message: string) => void };
/**
* Owner and tags — the two things that decide which ACL rules apply to a node, which is why they sit
* together behind the disclosure rather than next to Rename.
*
* Mounted only while the card is expanded: it needs the user list, and fetching every user to render a
* collapsed row would be a request per screenful for a control nobody is looking at. The query key is
* shared with the Users section, so an expanded card is usually a cache hit anyway.
*/
const Ownership = ({ node, busy, onError }: OwnershipProps) => {
const { setTags, moveToUser } = useHeadscaleNodes();
const { users } = useHeadscaleUsers();
const [owner, setOwner] = useState(node.user?.id ?? '');
const [draftTags, setDraftTags] = useState(node.tags.join(' '));
const pending = setTags.isPending || moveToUser.isPending;
const nextTags = parseTags(draftTags);
const tagsDirty = !sameTags(nextTags, node.tags);
const ownerDirty = !!owner && owner !== node.user?.id;
const target = users.find((u) => u.id === owner);
const run = async (fn: () => Promise<unknown>) => {
try {
await fn();
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
return (
<div className="flex flex-col gap-2.5">
<div className="text-[10px] font-semibold uppercase tracking-wide text-zinc-500">Owner and tags</div>
<div className="flex flex-wrap items-center gap-2">
<UserRound className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
<select
value={owner}
onChange={(ev) => setOwner(ev.target.value)}
disabled={busy || pending}
className="min-w-0 flex-1 cursor-pointer rounded-md border border-white/10 bg-black/40 px-2 py-1 text-xs text-zinc-200 outline-none focus:border-primary/50 disabled:opacity-40"
>
{!node.user && <option value="">no owner</option>}
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
{ownerDirty && (
<>
<Button
onClick={() => void run(() => moveToUser.mutateAsync({ id: node.id, userId: owner }))}
disabled={busy || pending}
>
<ArrowRightLeft className="h-3.5 w-3.5" />
Move
</Button>
<Button onClick={() => setOwner(node.user?.id ?? '')} disabled={busy || pending}>
Cancel
</Button>
</>
)}
</div>
{/* Said before the move, not after: the node keeps its address and its tags, but the rules that let
anything reach it are written per user, so it can go dark to everything that used to see it. */}
{ownerDirty && (
<p className="text-[11px] leading-snug text-amber-400/90">
Moving this node to <span className="font-medium">{target?.name ?? 'another user'}</span> changes which policy
rules apply to it. Its addresses and tags stay, but anything reaching it through a rule written for{' '}
{node.user?.name ?? 'its current owner'} will stop.
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<TagIcon className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
<input
value={draftTags}
onChange={(ev) => setDraftTags(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter' && tagsDirty) void run(() => setTags.mutateAsync({ id: node.id, tags: nextTags }));
if (ev.key === 'Escape') setDraftTags(node.tags.join(' '));
}}
placeholder="tag:server tag:eu — space separated"
spellCheck={false}
autoComplete="off"
className="min-w-0 flex-1 rounded-md border border-white/10 bg-black/40 px-2 py-1 font-mono text-[11px] text-zinc-200 outline-none placeholder:text-zinc-600 focus:border-primary/50"
/>
{tagsDirty && (
<>
<Button
onClick={() => void run(() => setTags.mutateAsync({ id: node.id, tags: nextTags }))}
disabled={busy || pending}
>
<Check className="h-3.5 w-3.5" />
Apply tags
</Button>
<Button onClick={() => setDraftTags(node.tags.join(' '))} disabled={busy || pending}>
Revert
</Button>
</>
)}
</div>
<p className="text-[11px] leading-snug text-zinc-600">
Tags are what the access policy targets. A tag no rule mentions does nothing; removing one a rule depends on
cuts the node off from it. The <span className="text-zinc-500">tag:</span> prefix is added for you.
</p>
</div>
);
};
type NodeCardProps = { node: HeadscaleNode; onError: (message: string) => void };
const NodeCard = ({ node, onError }: NodeCardProps) => {
const { rename, toggleRoute, expire, remove } = useHeadscaleNodes();
const [open, setOpen] = useState(false);
const [renaming, setRenaming] = useState(false);
const [draftName, setDraftName] = useState(node.name);
const [confirming, setConfirming] = useState(false);
const busy = rename.isPending || toggleRoute.isPending || expire.isPending || remove.isPending;
const run = async (fn: () => Promise<unknown>) => {
try {
await fn();
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
const submitRename = async () => {
const name = draftName.trim();
setRenaming(false);
if (!name || name === node.name) return;
await run(() => rename.mutateAsync({ id: node.id, name }));
};
return (
<Card>
<div className="flex flex-col">
<div className="flex items-center gap-2.5 px-3.5 py-3">
<Dot tone={node.online ? 'ok' : 'idle'} />
<div className="min-w-0 flex-1">
{renaming ? (
<div className="flex items-center gap-1.5">
<input
value={draftName}
onChange={(ev) => setDraftName(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') void submitRename();
if (ev.key === 'Escape') setRenaming(false);
}}
autoFocus
spellCheck={false}
className="min-w-0 flex-1 rounded-md border border-white/10 bg-black/40 px-2 py-1 text-sm text-zinc-100 outline-none focus:border-primary/50"
/>
<button
type="button"
onClick={() => void submitRename()}
className="cursor-pointer p-1 text-emerald-400"
>
<Check className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={() => setRenaming(false)} className="cursor-pointer p-1 text-zinc-500">
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-zinc-100">
<span className="tabular-nums text-zinc-500">{node.id}:</span> {node.hostname}{' '}
<span className="font-normal text-zinc-500">({node.name})</span>
</span>
{node.user && <Badge>{node.user.name}</Badge>}
{node.isExitNode && <Badge>exit</Badge>}
{node.tags.map((tag) => (
<Badge key={tag} tone="active">
{tag}
</Badge>
))}
</div>
)}
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
<span className="font-mono">{node.ipAddresses[0] ?? 'no address'}</span>
<span>· {node.online ? 'online' : `seen ${timeAgo(node.lastSeen)}`}</span>
{node.subnetRoutes.length > 0 && <span>· {node.subnetRoutes.length} route(s) active</span>}
</div>
</div>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-label={open ? 'Collapse' : 'Expand'}
className="shrink-0 cursor-pointer p-1 text-zinc-500 transition-colors hover:text-zinc-200"
>
<ChevronRight className={`h-4 w-4 transition-transform ${open ? 'rotate-90' : ''}`} />
</button>
</div>
{open && (
<div className="flex flex-col gap-3 border-t border-white/10 bg-black/30 p-3">
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]">
<div className="text-zinc-500">Addresses</div>
<div className="flex flex-col gap-0.5">
{node.ipAddresses.map((ip) => (
<button
key={ip}
type="button"
onClick={() => copy(ip)}
title="Copy"
className="group flex cursor-pointer items-center gap-1 text-left font-mono text-zinc-300"
>
{ip}
<Copy className="h-3 w-3 opacity-0 transition-opacity group-hover:opacity-60" />
</button>
))}
</div>
<div className="text-zinc-500">Hostname</div>
<div className="truncate font-mono text-zinc-300">{node.hostname}</div>
<div className="text-zinc-500">Registered</div>
<div className="text-zinc-300">
{timeAgo(node.createdAt)} · {node.registerMethod}
</div>
<div className="text-zinc-500">Key expires</div>
<div className="text-zinc-300" title={fullDate(node.expiry)}>
{timeUntil(node.expiry)}
</div>
<div className="text-zinc-500">Last seen</div>
<div className="text-zinc-300" title={fullDate(node.lastSeen)}>
{node.online ? 'now' : timeAgo(node.lastSeen)}
</div>
</div>
<div>
<div className="pb-1.5 text-[10px] font-semibold uppercase tracking-wide text-zinc-500">
Advertised routes
</div>
{node.availableRoutes.length === 0 ? (
<div className="text-[11px] text-zinc-600">This node advertises no routes.</div>
) : (
<div className="flex flex-col gap-1">
{node.availableRoutes.map((route) => (
<RouteRow
key={route}
route={route}
approved={node.approvedRoutes.includes(route)}
busy={busy}
onToggle={(approved) => void run(() => toggleRoute.mutateAsync({ id: node.id, route, approved }))}
/>
))}
</div>
)}
</div>
<Ownership node={node} busy={busy} onError={onError} />
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => {
setDraftName(node.name);
setRenaming(true);
}}
disabled={busy}
>
<Pencil className="h-3.5 w-3.5" />
Rename
</Button>
<Button
onClick={() => void run(() => expire.mutateAsync(node.id))}
disabled={busy}
title="Expire the node's key — it stays registered but must re-authenticate"
>
<TimerReset className="h-3.5 w-3.5" />
Force re-auth
</Button>
{confirming ? (
<>
<Button variant="danger" onClick={() => void run(() => remove.mutateAsync(node.id))} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Confirm remove
</Button>
<Button onClick={() => setConfirming(false)} disabled={busy}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Remove
</Button>
)}
</div>
</div>
)}
</div>
</Card>
);
};
export const NodesView = () => {
const { nodes, isLoading, error } = useHeadscaleNodes();
const [filter, setFilter] = useState('');
const [actionError, setActionError] = useState<string | null>(null);
const needle = filter.trim().toLowerCase();
const visible = needle
? nodes.filter(
(n) =>
n.id === needle ||
n.name.toLowerCase().includes(needle) ||
n.hostname.toLowerCase().includes(needle) ||
n.user?.name.toLowerCase().includes(needle) ||
n.ipAddresses.some((ip) => ip.includes(needle)) ||
n.tags.some((t) => t.toLowerCase().includes(needle)),
)
: nodes;
const online = nodes.filter((n) => n.online).length;
return (
<ViewShell isLoading={isLoading} error={error} label="nodes">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3">
<div className="flex items-center gap-3 px-1 pb-1">
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-zinc-100">Nodes</h2>
<p className="mt-0.5 text-xs text-zinc-500">
{nodes.length} registered · {online} online
</p>
</div>
<div className="relative w-56 shrink-0">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-zinc-600" />
<input
value={filter}
onChange={(ev) => setFilter(ev.target.value)}
placeholder="Filter by id, name, user, IP, tag"
spellCheck={false}
className="w-full rounded-lg border border-white/10 bg-black/40 py-1.5 pl-8 pr-2.5 text-xs text-zinc-100 outline-none placeholder:text-zinc-600 focus:border-primary/50"
/>
</div>
</div>
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{nodes.length === 0 && (
<EmptyBody
icon={<Laptop className="h-6 w-6" />}
title="No nodes yet"
hint="Create a pre-auth key and run `tailscale up --login-server <your server> --authkey <key>` on a machine to join it."
/>
)}
{nodes.length > 0 && visible.length === 0 && (
<div className="py-10 text-center text-sm text-zinc-500">Nothing matches {filter}.</div>
)}
{visible.map((node) => (
<NodeCard key={node.id} node={node} onError={setActionError} />
))}
</div>
</ViewShell>
);
};
@@ -1,177 +0,0 @@
import { useMemo, useState } from 'react';
import { Check, Loader2, Sparkles, Wand2, X } from 'lucide-react';
import { useHeadscalePolicyAssist, assistFailure } from './useHeadscalePolicy';
import { collapseUnchanged, diffCounts, diffLines } from './diff';
import { Button, Card, ErrorNote } from './Cards';
// Ask for a policy change in English; read the diff; decide.
//
// The whole point of this panel is the middle step. The model is good at the grammar — HuJSON, tagOwners,
// the src/dst shapes — and has no idea which of the owner's machines matter, so its proposal is a draft to
// be read, not an answer to be trusted. Nothing here writes to Headscale: Apply puts the text in the editor
// above and the existing Save button is still the only thing that leaves the browser.
//
// The diff is against what is CURRENTLY in the editor, which is also what was sent up, so it always shows
// exactly what accepting would change on screen — including edits the owner made and hasn't saved.
const EXAMPLES = [
'let everyone reach the machines tagged tag:server on port 22',
'stop the phones from reaching anything except the DNS server',
'add a group for family with just my own user in it',
];
const DiffBody = ({ before, after }: { before: string; after: string }) => {
const lines = useMemo(() => diffLines(before, after), [before, after]);
const rows = useMemo(() => collapseUnchanged(lines), [lines]);
const { added, removed } = useMemo(() => diffCounts(lines), [lines]);
if (!added && !removed) {
return <p className="px-3 py-2.5 text-[11px] text-zinc-500">No change the proposal matches what you have.</p>;
}
return (
<>
<div className="flex items-center gap-3 border-b border-white/10 px-3 py-1.5 text-[11px]">
<span className="text-emerald-400">+{added}</span>
<span className="text-red-400">{removed}</span>
<span className="text-zinc-600">unchanged lines collapsed</span>
</div>
<div className="max-h-72 overflow-auto p-1 font-mono text-[11px] leading-relaxed">
{rows.map((row, index) =>
row === null ? (
<div key={index} className="px-2 py-1 text-center text-zinc-700 select-none">
</div>
) : (
<div
key={index}
className={`px-2 whitespace-pre-wrap ${
row.kind === 'add'
? 'bg-emerald-500/10 text-emerald-300'
: row.kind === 'remove'
? 'bg-red-500/10 text-red-300'
: 'text-zinc-500'
}`}
>
{row.kind === 'add' ? '+' : row.kind === 'remove' ? '' : ' '} {row.text}
</div>
),
)}
</div>
</>
);
};
type PolicyAssistantProps = {
/** The text on screen right now. Sent up as the base, and diffed against. */
policy: string;
/** Accepting a proposal — puts it in the editor's draft. Never saves. */
onApply: (policy: string) => void;
disabled?: boolean;
};
export const PolicyAssistant = ({ policy, onApply, disabled }: PolicyAssistantProps) => {
const assist = useHeadscalePolicyAssist();
const [prompt, setPrompt] = useState('');
// The proposal lives here, not in the mutation's `data`, so that discarding it is a real state change and
// a second ask doesn't briefly show the previous answer against the new base.
const [proposal, setProposal] = useState<{ explanation: string; policy: string } | null>(null);
const ask = async () => {
const request = prompt.trim();
if (!request || assist.isPending) return;
setProposal(null);
try {
setProposal(await assist.mutateAsync({ prompt: request, policy }));
} catch {
// Rendered from `assist.error` below — mutateAsync rejecting is the same failure twice.
}
};
const apply = () => {
if (!proposal) return;
onApply(proposal.policy);
setProposal(null);
setPrompt('');
assist.reset();
};
return (
<Card>
<div className="flex items-center gap-2 border-b border-white/10 px-3 py-2">
<Sparkles className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-medium text-zinc-200">Describe the change</span>
<span className="ml-auto text-[11px] text-zinc-600">Proposes a document never saves it</span>
</div>
<div className="flex flex-col gap-2 p-3">
<textarea
value={prompt}
onChange={(ev) => setPrompt(ev.target.value)}
onKeyDown={(ev) => {
// Enter sends: this is a one-line instruction far more often than a paragraph, and shift-enter
// is still there for the times it isn't.
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault();
void ask();
}
}}
rows={2}
spellCheck={false}
disabled={disabled}
placeholder="e.g. give my laptop SSH access to everything tagged tag:server"
className="w-full resize-y rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm leading-relaxed text-zinc-100 outline-none transition-colors placeholder:text-zinc-600 focus:border-primary/50 disabled:opacity-50"
/>
<div className="flex flex-wrap items-center gap-2">
{!prompt.trim() &&
EXAMPLES.map((example) => (
<button
key={example}
type="button"
onClick={() => setPrompt(example)}
disabled={disabled}
className="cursor-pointer rounded-full border border-white/10 px-2.5 py-1 text-[11px] text-zinc-500 transition-colors hover:border-white/20 hover:text-zinc-300 disabled:opacity-40"
>
{example}
</button>
))}
<div className="ml-auto">
<Button variant="primary" onClick={() => void ask()} disabled={!prompt.trim() || assist.isPending}>
{assist.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
{assist.isPending ? 'Drafting…' : 'Ask'}
</Button>
</div>
</div>
{assist.error && <ErrorNote>{assistFailure(assist.error)}</ErrorNote>}
</div>
{proposal && (
<div className="border-t border-white/10">
{proposal.explanation && (
<p className="px-3 py-2.5 text-xs leading-relaxed whitespace-pre-wrap text-zinc-300">
{proposal.explanation}
</p>
)}
<div className="border-t border-white/10">
<DiffBody before={policy} after={proposal.policy} />
</div>
<div className="flex items-center gap-2 border-t border-white/10 px-3 py-2">
<span className="text-[11px] text-zinc-600">Applying only fills the editor you still press Save.</span>
<span className="ml-auto flex items-center gap-2">
<Button onClick={() => setProposal(null)}>
<X className="h-3.5 w-3.5" />
Discard
</Button>
<Button variant="primary" onClick={apply}>
<Check className="h-3.5 w-3.5" />
Apply to editor
</Button>
</span>
</div>
</div>
)}
</Card>
);
};
@@ -1,210 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { AlertTriangle, FileLock2, Loader2, Pencil, RotateCcw, Save, ShieldCheck } from 'lucide-react';
import { timeAgo } from './format';
import { useHeadscalePolicy, policySaveFailure, type PolicySaveFailure } from './useHeadscalePolicy';
import { PolicyAssistant } from './PolicyAssistant';
import { Card, SectionHeader, Button, ErrorNote } from './Cards';
import { ViewShell } from './ViewShell';
// The tailnet's ACL document. A plain textarea on purpose — this is HuJSON, where the comments and the
// hand-kept alignment are half the document's value to whoever maintains it, and a rich editor that
// reformats or a client-side parser that disagrees with Headscale would both destroy more than they add.
//
// It opens READ-ONLY behind an Edit button. This is the document that decides which machine can reach
// which, it is usually being looked at rather than changed, and a textarea focused by a stray click is a
// way to alter it without meaning to. Edit mode also brings up the assistant, because "I do not know what
// this file should look like" is the actual reason this screen was hard to use.
//
// Validation is entirely Headscale's. It has the only parser that counts: it resolves groups, tags and
// host aliases, and it is what will actually enforce the result. Officer sends the text up untouched and
// shows the verdict verbatim — including the line and column, which is the whole reason to show it at all.
//
// Two failures, deliberately styled differently. A REJECTED document is a normal part of editing and stays
// inline next to the save button. A READ-ONLY server means this screen cannot do its job at all and says so
// at the top, permanently, because the owner needs to go and edit a file on the server instead.
/** Ctrl/Cmd-S while the textarea has focus. An ACL is long enough that reaching for the button breaks flow. */
function useSaveShortcut(onSave: () => void, enabled: boolean) {
const handler = useRef(onSave);
handler.current = onSave;
useEffect(() => {
if (!enabled) return;
const onKeyDown = (ev: KeyboardEvent) => {
if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === 's') {
ev.preventDefault();
handler.current();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [enabled]);
}
const ReadOnlyBanner = ({ message }: { message: string }) => (
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs leading-relaxed text-amber-200">
<FileLock2 className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium text-amber-100">This server's policy is read-only</div>
<p className="mt-1 text-amber-200/80">
Headscale said: <span className="font-mono">{message}</span>
</p>
<p className="mt-1.5 text-amber-200/70">
It is reading its policy from a file on disk rather than from its database, so the API refuses writes — a save
here would be overwritten on the next restart anyway. Edit the file on the server (the Console section is one
way in) and reload it there. Everything below is still the live document, and still readable.
</p>
</div>
</div>
);
const Rejected = ({ message }: { message: string }) => (
<div className="flex items-start gap-2.5 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-xs leading-relaxed text-red-300">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div>
<div className="font-medium text-red-200">Headscale rejected this policy</div>
{/* Verbatim, monospaced: it usually carries a line and column, and re-wording it would throw that away. */}
<pre className="mt-1 font-mono text-[11px] whitespace-pre-wrap text-red-300/90">{message}</pre>
<p className="mt-1.5 text-red-300/70">Nothing was saved — the tailnet is still running the previous policy.</p>
</div>
</div>
);
export const PolicyView = () => {
const { policy, isLoading, error, save } = useHeadscalePolicy();
const [draft, setDraft] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [failure, setFailure] = useState<PolicySaveFailure | null>(null);
// Sticky for the session: once a server has refused a write, every later save would refuse identically,
// and re-discovering that by pressing save again is not information.
const [readOnly, setReadOnly] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<number | null>(null);
// The fetched document seeds the editor once. After that the draft owns the text — a refetch must never
// reach in and replace what someone is typing.
const text = draft ?? policy?.policy ?? '';
const dirty = draft !== null && draft !== (policy?.policy ?? '');
const submit = async () => {
if (!dirty || readOnly || save.isPending) return;
setFailure(null);
try {
await save.mutateAsync(text);
setDraft(null);
setSavedAt(Date.now());
// A clean save is the end of the edit, not the start of the next one — back to reading.
setEditing(false);
} catch (err) {
const parsed = policySaveFailure(err);
setFailure(parsed);
if (parsed.kind === 'readOnly') setReadOnly(parsed.message);
}
};
useSaveShortcut(() => void submit(), editing && dirty && !readOnly);
const revert = () => {
setDraft(null);
setFailure(null);
};
/** Leaving edit mode throws the draft away — there is nowhere else for unsaved text to go. */
const stopEditing = () => {
revert();
setEditing(false);
};
return (
<ViewShell isLoading={isLoading} error={error} label="the access policy">
<div className="mx-auto flex max-w-4xl flex-col gap-3">
<SectionHeader
title="Access policy"
subtitle="HuJSON — JSON with comments and trailing commas. Headscale validates it on save; nothing is stored unless it passes."
action={
editing ? (
<div className="flex items-center gap-2">
<Button onClick={stopEditing} disabled={save.isPending}>
<RotateCcw className="h-3.5 w-3.5" />
{dirty ? 'Discard' : 'Done'}
</Button>
<Button
variant="primary"
onClick={() => void submit()}
disabled={!dirty || !!readOnly || save.isPending}
>
{save.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
{save.isPending ? 'Validating' : 'Save'}
</Button>
</div>
) : (
<Button
onClick={() => setEditing(true)}
disabled={!!readOnly}
title={readOnly ? 'This server will not accept written policies' : undefined}
>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
)
}
/>
{readOnly && <ReadOnlyBanner message={readOnly} />}
{failure?.kind === 'rejected' && <Rejected message={failure.message} />}
{failure?.kind === 'unknown' && <ErrorNote>{failure.message}</ErrorNote>}
{editing && (
<PolicyAssistant
policy={text}
onApply={(proposed) => {
setDraft(proposed);
setFailure(null);
setSavedAt(null);
}}
disabled={save.isPending}
/>
)}
<Card>
<textarea
value={text}
onChange={(ev) => {
setDraft(ev.target.value);
setFailure(null);
setSavedAt(null);
}}
spellCheck={false}
autoComplete="off"
readOnly={!editing}
placeholder={'{\n "acls": [\n { "action": "accept", "src": ["*"], "dst": ["*:*"] },\n ],\n}'}
className={`block h-[28rem] w-full resize-y p-4 font-mono text-[12px] leading-relaxed outline-none placeholder:text-zinc-700 ${
editing ? 'bg-black/40 text-zinc-200' : 'bg-black/20 text-zinc-400'
}`}
/>
<div className="flex flex-wrap items-center gap-3 border-t border-white/10 px-3 py-2 text-[11px] text-zinc-500">
<span>
{text.split('\n').length} lines · {text.length} characters
</span>
<span className="ml-auto flex items-center gap-3">
{savedAt !== null && !dirty && (
<span className="flex items-center gap-1 text-emerald-400">
<ShieldCheck className="h-3.5 w-3.5" />
Saved and accepted
</span>
)}
{dirty && <span className="text-amber-400">Unsaved changes</span>}
{policy?.updatedAt && <span>Last changed {timeAgo(policy.updatedAt)}</span>}
</span>
</div>
</Card>
<p className="px-1 text-[11px] leading-relaxed text-zinc-600">
This document decides which node may reach which. A policy that saves cleanly can still cut a machine off —
Headscale checks that the document is valid, not that it is what you meant.
{editing ? ' Ctrl/Cmd-S saves.' : ' Press Edit to change it.'}
</p>
</div>
</ViewShell>
);
};
@@ -1,226 +0,0 @@
import { useState } from 'react';
import { Loader2, Terminal, Check, X } from 'lucide-react';
import type { HeadscaleServer, HeadscaleSshTest } from './shared';
import { MIN_HEADSCALE_VERSION } from './shared';
import { useHeadscaleServers, useHeadscaleSshTest, headscaleErrorMessage } from './useHeadscaleServers';
import { Card, Button, Field, ErrorNote } from './Cards';
// Register / edit one Headscale server. The sidecar validates before it saves — reachable, >= 0.29, and the
// key actually accepted — so this form is genuinely slow on submit and genuinely fails. Both are shown:
// a pending state saying what is being checked, and the server's own reason inline on rejection.
//
// On edit the API key field is intentionally blank rather than pre-filled. Officer cannot pre-fill it (the
// key is encrypted at rest and never leaves the sidecar), and leaving it empty means "keep the current key".
//
// The SSH host is the odd one out: it is NOT validated on save. A control server that is down is exactly when
// you want the console, so refusing to save the escape hatch because the machine is unreachable would be
// precisely backwards. Test is a separate, explicit button.
/** The host part of the control-server URL, for the "you have typed the same machine" warning. */
function urlHost(url: string): string | null {
try {
return new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).hostname.toLowerCase();
} catch {
return null;
}
}
/** Strip any `user@` so `root@1.2.3.4` still matches the control-server hostname. */
const sshTarget = (host: string) => host.trim().split('@').pop()?.toLowerCase() ?? '';
type ServerFormProps = { server?: HeadscaleServer | null; onClose: () => void };
export const ServerForm = ({ server, onClose }: ServerFormProps) => {
const { register, update } = useHeadscaleServers();
const sshTest = useHeadscaleSshTest();
const editing = !!server;
const [name, setName] = useState(server?.name ?? '');
const [url, setUrl] = useState(server?.url ?? '');
const [apiKey, setApiKey] = useState('');
const [sshHost, setSshHost] = useState(server?.sshHost ?? '');
const [error, setError] = useState<string | null>(null);
const [sshResult, setSshResult] = useState<HeadscaleSshTest | null>(null);
const mutation = editing ? update : register;
const pending = mutation.isPending;
// A rejection leaves its reason under the button, and the reason is about values that have since been
// corrected. Editing anything clears it, so a stale message can never make a live form look dead.
const edit =
<T,>(set: (value: T) => void) =>
(value: T) => {
set(value);
setError(null);
};
// The point of a separate SSH address is reaching the box when the tailnet or Headscale itself is down. If
// it resolves through the same name the control server does, it goes down with it — which is the one thing
// this field is supposed to survive.
const sameAsControl = !!sshHost.trim() && !!urlHost(url) && sshTarget(sshHost) === urlHost(url);
const runSshTest = async () => {
setSshResult(null);
try {
setSshResult(await sshTest.mutateAsync(sshHost.trim()));
} catch (err) {
setSshResult({ ok: false, error: headscaleErrorMessage(err), ms: 0 });
}
};
const submit = async () => {
if (pending) return;
setError(null);
// Drop the previous rejection from the mutation too — this is a fresh attempt, not a retry of that one.
mutation.reset();
if (!url.trim()) return setError('A server URL is required');
if (!editing && !apiKey.trim()) return setError('An API key is required');
try {
if (editing && server) {
// Send only what changed: an unchanged url+key pair skips the sidecar's re-validation round trips.
await update.mutateAsync({
id: server.id,
name: name.trim() || undefined,
url: url.trim() === server.url ? undefined : url.trim(),
apiKey: apiKey.trim() || undefined,
// '' is meaningful here — it clears the console target — so this is sent whenever it differs.
sshHost: sshHost.trim() === (server.sshHost ?? '') ? undefined : sshHost.trim(),
});
} else {
await register.mutateAsync({
name: name.trim() || undefined,
url: url.trim(),
apiKey: apiKey.trim(),
sshHost: sshHost.trim() || undefined,
});
}
onClose();
} catch (err) {
setError(headscaleErrorMessage(err));
}
};
return (
<Card>
<form
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
className="flex flex-col gap-3 p-4"
>
<div className="text-sm font-semibold text-zinc-100">
{editing ? `Edit ${server?.name}` : 'Register a Headscale server'}
</div>
<Field
label="Server URL"
value={url}
onChange={edit(setUrl)}
placeholder="https://headscale.example.com"
hint={`The control server's base URL. Officer requires Headscale ${MIN_HEADSCALE_VERSION} or newer.`}
autoFocus={!editing}
/>
<Field
label={editing ? 'API key (leave blank to keep the current one)' : 'API key'}
value={apiKey}
onChange={edit(setApiKey)}
type="password"
placeholder="hskey-api-..."
hint="Generate one on the server with `headscale apikeys create`. It is stored encrypted and never leaves Officer."
/>
<Field
label="Name (optional)"
value={name}
onChange={edit(setName)}
placeholder="defaults to the hostname"
hint="A label for switching between servers."
/>
<div className="flex flex-col gap-2 rounded-lg border border-white/10 bg-white/[0.02] p-3">
<div className="flex items-center gap-2 text-xs font-medium text-zinc-300">
<Terminal className="h-3.5 w-3.5" />
SSH console (optional)
</div>
<p className="text-[11px] leading-snug text-zinc-500">
The last resort for when the API cannot answer Headscale crashed, the tailnet is down, the logs are the
only evidence. The Console section runs plain <code className="font-mono">ssh</code> here in a terminal,
using the keys already on this machine. Officer stores no password, key or port.
</p>
<Field
label="SSH address"
value={sshHost}
onChange={edit((value: string) => {
setSshHost(value);
setSshResult(null);
})}
placeholder="203.0.113.10 or root@203.0.113.10"
hint="Use the machine's own address, not the Headscale hostname. Leave blank for no console."
/>
{sameAsControl && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] leading-snug text-amber-300">
That is the same host as the server URL. If Headscale is what resolves or routes that name, the console
will be unreachable in exactly the situations you would need it. Prefer the machine's raw IP on a path
that does not depend on the tailnet.
</div>
)}
{sshResult && (
<div
className={`flex items-start gap-2 rounded-lg border px-3 py-2 text-[11px] leading-snug ${
sshResult.ok
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300'
: 'border-red-500/30 bg-red-500/10 text-red-300'
}`}
>
{sshResult.ok ? (
<Check className="mt-px h-3.5 w-3.5 shrink-0" />
) : (
<X className="mt-px h-3.5 w-3.5 shrink-0" />
)}
<span>
{sshResult.ok ? (
<>Connected and ran a command in {sshResult.ms}ms.</>
) : (
<>
{sshResult.error ?? 'Could not connect'}
<span className="mt-1 block text-red-300/70">
The test never prompts, so a key that needs a passphrase, or one this machine does not have, fails
here as “Permission denied”.
</span>
</>
)}
</span>
</div>
)}
<div>
<Button onClick={() => void runSshTest()} disabled={!sshHost.trim() || sshTest.isPending}>
{sshTest.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Terminal className="h-3.5 w-3.5" />
)}
{sshTest.isPending ? 'Connecting' : 'Test connection'}
</Button>
</div>
</div>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex items-center gap-2 pt-1">
<Button type="submit" variant="primary" disabled={pending}>
{pending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{pending ? 'Verifying' : editing ? 'Save changes' : 'Register server'}
</Button>
<Button onClick={onClose} disabled={pending}>
Cancel
</Button>
{pending && <span className="text-[11px] text-zinc-500">Checking the server and the key</span>}
</div>
</form>
</Card>
);
};
@@ -1,262 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Plus, Loader2, Server, Check, Activity, Pencil, Trash2 } from 'lucide-react';
import type { HeadscaleServer, HeadscaleHealth } from './shared';
import { MIN_HEADSCALE_VERSION } from './shared';
import { useHeadscaleServers, useHeadscaleHealth, headscaleErrorMessage } from './useHeadscaleServers';
import { Card, SectionHeader, Button, Dot, Badge, ErrorNote } from './Cards';
import { ServerForm } from './ServerForm';
// The servers section — register Headscale servers and switch between them. Exactly one is active at a
// time (a DB invariant, not a UI convention), and every other section in this workspace reads it.
//
// EVERY server is probed when this section opens, in parallel, and again for any server registered while
// it is open. A probe costs two upstream round trips (an unauthenticated /version plus an authenticated
// call to prove the stored key still works) — cheap enough at this scale, and the alternative was worse:
// a grey "not checked" dot is the one thing this list must never show, because the reason to look at it
// is to find out which servers are up. A dot that says nothing makes the whole page say nothing.
function timeAgo(iso: string): string {
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (!Number.isFinite(seconds)) return 'unknown';
if (seconds < 60) return 'just now';
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.round(hours / 24)}d ago`;
}
type ServerRowProps = {
server: HeadscaleServer;
health: HeadscaleHealth | undefined;
testing: boolean;
busy: boolean;
onActivate: () => void;
onTest: () => void;
onEdit: () => void;
onRemove: () => void;
};
const ServerRow = ({ server, health, testing, busy, onActivate, onTest, onEdit, onRemove }: ServerRowProps) => {
const [confirming, setConfirming] = useState(false);
// Amber means "asking"; grey should only ever be the frame before the automatic probe starts.
const tone = health ? (health.ok ? 'ok' : 'bad') : testing ? 'warn' : 'idle';
return (
<Card>
<div className="flex flex-col gap-3 p-3.5">
<div className="flex items-start gap-2.5">
<span className="mt-1.5">
<Dot tone={tone} />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-zinc-100">{server.name}</span>
{server.isActive && <Badge tone="active">Active</Badge>}
</div>
<div className="mt-0.5 truncate text-xs text-zinc-500" title={server.url}>
{server.url}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-zinc-600">
<span>{server.version ? `Headscale ${server.version}` : 'version unknown'}</span>
{server.lastSeenAt && <span>· reached {timeAgo(server.lastSeenAt)}</span>}
{health?.ok && <span className="text-emerald-400/80">· responded in {health.ms}ms</span>}
</div>
</div>
</div>
{health && !health.ok && <ErrorNote>{health.error ?? 'The server did not respond'}</ErrorNote>}
{health?.ok && health.supported === 'unknown' && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] leading-snug text-amber-300">
This server reports its version as {health.version}, so Officer cannot confirm it is{' '}
{MIN_HEADSCALE_VERSION} or newer. Self-built images do this; features may behave unexpectedly on older
builds.
</div>
)}
<div className="flex flex-wrap items-center gap-2">
{!server.isActive && (
<Button variant="primary" onClick={onActivate} disabled={busy}>
<Check className="h-3.5 w-3.5" />
Use this server
</Button>
)}
<Button onClick={onTest} disabled={testing}>
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Activity className="h-3.5 w-3.5" />}
{testing ? 'Testing…' : 'Test'}
</Button>
<Button onClick={onEdit} disabled={busy}>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
{confirming ? (
<>
<Button variant="danger" onClick={onRemove} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Confirm remove
</Button>
<Button onClick={() => setConfirming(false)} disabled={busy}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Remove
</Button>
)}
</div>
</div>
</Card>
);
};
const EmptyState = ({ onRegister }: { onRegister: () => void }) => (
<div className="flex flex-col items-center gap-4 py-16 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">
<Server className="h-6 w-6" />
</div>
<div>
<div className="text-base font-semibold text-zinc-100">No Headscale servers yet</div>
<p className="mt-1 text-sm text-zinc-500">
Register a server with its URL and an API key to manage its nodes, users and pre-auth keys from here. Officer
supports Headscale {MIN_HEADSCALE_VERSION} and newer.
</p>
</div>
<Button variant="primary" onClick={onRegister}>
<Plus className="h-3.5 w-3.5" />
Register a server
</Button>
</div>
);
export const ServersView = () => {
const { servers, isLoading, error, refetch, activate, remove } = useHeadscaleServers();
const healthProbe = useHeadscaleHealth();
const [formFor, setFormFor] = useState<'new' | HeadscaleServer | null>(null);
const [health, setHealth] = useState<Record<number, HeadscaleHealth>>({});
// Several probes are in flight at once now, so this is a set of ids rather than the one id it used to be.
const [testingIds, setTestingIds] = useState<readonly number[]>([]);
const [actionError, setActionError] = useState<string | null>(null);
const test = async (id: number) => {
setTestingIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
setActionError(null);
try {
const result = await healthProbe.mutateAsync(id);
setHealth((prev) => ({ ...prev, [id]: result }));
} catch (err) {
// A probe that throws is still an answer about the server: record it as a red dot rather than as a
// page-level error, which would blame the whole screen for one unreachable box.
setHealth((prev) => ({ ...prev, [id]: { ok: false, error: headscaleErrorMessage(err), ms: 0 } }));
} finally {
setTestingIds((prev) => prev.filter((t) => t !== id));
}
};
// Probe every server once per visit to this section, and any server that appears while it is open. The
// ref is what makes "once" true: the list identity changes when a probe writes lastSeenAt, and without
// it each result would trigger the next round forever.
const probed = useRef(new Set<number>());
useEffect(() => {
for (const server of servers) {
if (probed.current.has(server.id)) continue;
probed.current.add(server.id);
void test(server.id);
}
}, [servers]);
const run = async (fn: () => Promise<unknown>) => {
setActionError(null);
try {
await fn();
} catch (err) {
setActionError(headscaleErrorMessage(err));
}
};
const busy = activate.isPending || remove.isPending;
return (
<div className="h-full overflow-y-auto p-4">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<SectionHeader
title="Servers"
subtitle="One server is active at a time; every other section acts on it."
action={
!formFor && (
<Button variant="primary" onClick={() => setFormFor('new')} disabled={isLoading}>
<Plus className="h-3.5 w-3.5" />
Register a server
</Button>
)
}
/>
{/* A failed list fetch is reported here, NOT as a replacement for the whole section. It used to be
an early return, which unmounted the form mid-registration and threw away everything typed into
it — leaving a reload as the only way to try again. Nothing on this screen may take the form
off the page except the owner. */}
{error && (
<ErrorNote>
<div className="flex flex-wrap items-center gap-2">
<span>
Could not reach the Headscale sidecar: {headscaleErrorMessage(error)}. If it is not running, start it
with <code className="font-mono">pm2 start ecosystem.config.cjs --only officer-headscale</code>.
</span>
<button
type="button"
onClick={() => void refetch()}
className="ml-auto shrink-0 cursor-pointer rounded-lg border border-red-500/30 px-2 py-1 font-medium transition-colors hover:bg-red-500/20"
>
Try again
</button>
</div>
</ErrorNote>
)}
{/* Keyed by which server it edits: the form seeds its fields from the prop once, at mount, so
switching straight from one server's Edit to another's would otherwise keep the first one's
values — and submit diffs those stale values against the NEW server, writing them to it. */}
{formFor && (
<ServerForm
key={formFor === 'new' ? 'new' : formFor.id}
server={formFor === 'new' ? null : formFor}
onClose={() => setFormFor(null)}
/>
)}
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{isLoading && (
<div className="flex items-center justify-center py-16 text-sm text-zinc-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading servers
</div>
)}
{/* Empty state doubles as the registration prompt — there is nothing else to do here without a
server. Hidden while the form is open, because it is then the same offer twice. */}
{!isLoading && !error && servers.length === 0 && !formFor && (
<EmptyState onRegister={() => setFormFor('new')} />
)}
{servers.map((server) => (
<ServerRow
key={server.id}
server={server}
health={health[server.id]}
testing={testingIds.includes(server.id)}
busy={busy}
onActivate={() => void run(() => activate.mutateAsync(server.id))}
onTest={() => void test(server.id)}
onEdit={() => setFormFor(server)}
onRemove={() => void run(() => remove.mutateAsync(server.id))}
/>
))}
</div>
</div>
);
};
@@ -1,203 +0,0 @@
import { useState } from 'react';
import { Users, Plus, Pencil, Trash2, Check, X, Loader2 } from 'lucide-react';
import type { HeadscaleUserWithCounts } from './shared';
import { useHeadscaleUsers } from './useHeadscaleData';
import { headscaleErrorMessage } from './useHeadscaleServers';
import { timeAgo } from './format';
import { Card, Button, Field, Badge, ErrorNote, Dot } from './Cards';
import { ViewShell, EmptyBody } from './ViewShell';
// The users section. A Headscale user is a namespace that owns nodes and pre-auth keys — not a login.
//
// The node count next to each user is the point of this screen: Headscale refuses to delete a user that
// still owns nodes, and without the count that refusal arrives as a surprise after the confirm click.
type UserRowProps = { user: HeadscaleUserWithCounts; onError: (message: string) => void };
const UserRow = ({ user, onError }: UserRowProps) => {
const { rename, remove } = useHeadscaleUsers();
const [renaming, setRenaming] = useState(false);
const [draft, setDraft] = useState(user.name);
const [confirming, setConfirming] = useState(false);
const busy = rename.isPending || remove.isPending;
const run = async (fn: () => Promise<unknown>) => {
try {
await fn();
} catch (err) {
onError(headscaleErrorMessage(err));
}
};
const submitRename = async () => {
const name = draft.trim();
setRenaming(false);
if (!name || name === user.name) return;
await run(() => rename.mutateAsync({ id: user.id, name }));
};
return (
<Card>
<div className="flex flex-wrap items-center gap-3 p-3.5">
<Dot tone={user.onlineCount > 0 ? 'ok' : 'idle'} />
<div className="min-w-0 flex-1">
{renaming ? (
<div className="flex items-center gap-1.5">
<input
value={draft}
onChange={(ev) => setDraft(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') void submitRename();
if (ev.key === 'Escape') setRenaming(false);
}}
autoFocus
spellCheck={false}
className="min-w-0 flex-1 rounded-md border border-white/10 bg-black/40 px-2 py-1 text-sm text-zinc-100 outline-none focus:border-primary/50"
/>
<button type="button" onClick={() => void submitRename()} className="cursor-pointer p-1 text-emerald-400">
<Check className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={() => setRenaming(false)} className="cursor-pointer p-1 text-zinc-500">
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-zinc-100">{user.name}</span>
{user.provider && <Badge>{user.provider}</Badge>}
</div>
)}
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-zinc-500">
<span>
{user.nodeCount} node{user.nodeCount === 1 ? '' : 's'}
{user.onlineCount > 0 && `, ${user.onlineCount} online`}
</span>
{user.email && <span>· {user.email}</span>}
<span>· created {timeAgo(user.createdAt)}</span>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
<Button
onClick={() => {
setDraft(user.name);
setRenaming(true);
}}
disabled={busy}
>
<Pencil className="h-3.5 w-3.5" />
Rename
</Button>
{confirming ? (
<>
<Button variant="danger" onClick={() => void run(() => remove.mutateAsync(user.id))} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
{user.nodeCount > 0 ? `Delete with ${user.nodeCount} node(s)` : 'Confirm delete'}
</Button>
<Button onClick={() => setConfirming(false)} disabled={busy}>
Cancel
</Button>
</>
) : (
<Button variant="danger" onClick={() => setConfirming(true)} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
</div>
</Card>
);
};
const CreateUserForm = ({ onClose }: { onClose: () => void }) => {
const { create } = useHeadscaleUsers();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [error, setError] = useState<string | null>(null);
const submit = async () => {
setError(null);
if (!name.trim()) return setError('A name is required');
try {
await create.mutateAsync({ name: name.trim(), email: email.trim() || undefined });
onClose();
} catch (err) {
setError(headscaleErrorMessage(err));
}
};
return (
<Card>
<form
onSubmit={(ev) => {
ev.preventDefault();
void submit();
}}
className="flex flex-col gap-3 p-4"
>
<div className="text-sm font-semibold text-zinc-100">New user</div>
<Field
label="Name"
value={name}
onChange={setName}
placeholder="laptop-fleet"
hint="Lowercase, no spaces. This is the namespace nodes and keys belong to."
autoFocus
/>
<Field label="Email (optional)" value={email} onChange={setEmail} placeholder="someone@example.com" />
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex items-center gap-2 pt-1">
<Button type="submit" variant="primary" disabled={create.isPending}>
{create.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Create user
</Button>
<Button onClick={onClose} disabled={create.isPending}>
Cancel
</Button>
</div>
</form>
</Card>
);
};
export const UsersView = () => {
const { users, isLoading, error } = useHeadscaleUsers();
const [creating, setCreating] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
return (
<ViewShell isLoading={isLoading} error={error} label="users">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3">
<div className="flex items-start justify-between gap-4 px-1 pb-1">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-zinc-100">Users</h2>
<p className="mt-0.5 text-xs text-zinc-500">Namespaces that own nodes and pre-auth keys.</p>
</div>
{!creating && (
<Button variant="primary" onClick={() => setCreating(true)}>
<Plus className="h-3.5 w-3.5" />
New user
</Button>
)}
</div>
{creating && <CreateUserForm onClose={() => setCreating(false)} />}
{actionError && <ErrorNote>{actionError}</ErrorNote>}
{users.length === 0 && !creating && (
<EmptyBody
icon={<Users className="h-6 w-6" />}
title="No users yet"
hint="Every node belongs to a user. Create one before issuing a pre-auth key."
/>
)}
{users.map((user) => (
<UserRow key={user.id} user={user} onError={setActionError} />
))}
</div>
</ViewShell>
);
};
@@ -1,77 +0,0 @@
import type { ReactNode } from 'react';
import { Loader2, ServerOff } from 'lucide-react';
import { NO_ACTIVE_SERVER } from './shared';
import { headscaleErrorMessage } from './useHeadscaleServers';
import { ErrorNote } from './Cards';
// The loading / no-server / failed states every domain section shares.
//
// "No active server" is a 409 carrying a `code`, deliberately not a 404 and deliberately not an empty
// list — an empty node table would read as "your tailnet is empty", which is a very different and much
// more alarming statement than "you haven't picked a server".
function isNoActiveServer(err: unknown): boolean {
const raw = (err as { message?: unknown } | null)?.message;
if (typeof raw !== 'string') return false;
try {
return (JSON.parse(raw) as { code?: unknown }).code === NO_ACTIVE_SERVER;
} catch {
return false;
}
}
type ViewShellProps = {
isLoading: boolean;
error: unknown;
/** What this section is called, for the loading and empty copy. */
label: string;
children: ReactNode;
};
export const ViewShell = ({ isLoading, error, label, children }: ViewShellProps) => {
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading {label}
</div>
);
}
if (error && isNoActiveServer(error)) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">
<ServerOff className="h-6 w-6" />
</div>
<div>
<div className="text-base font-semibold text-zinc-100">No server selected</div>
<p className="mt-1 text-sm text-zinc-500">Pick one in the Servers section to see its {label}.</p>
</div>
</div>
);
}
if (error) {
return (
<div className="p-4">
<ErrorNote>
Could not load {label}: {headscaleErrorMessage(error)}
</ErrorNote>
</div>
);
}
return <div className="h-full overflow-y-auto p-4">{children}</div>;
};
/** Centred "nothing here yet" body for a section whose fetch succeeded but returned nothing. */
export const EmptyBody = ({ icon, title, hint }: { icon: ReactNode; title: string; hint: string }) => (
<div className="flex flex-col items-center gap-3 py-16 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/5 text-zinc-400">{icon}</div>
<div>
<div className="text-base font-semibold text-zinc-100">{title}</div>
<p className="mt-1 max-w-sm text-sm text-zinc-500">{hint}</p>
</div>
</div>
);
@@ -1,77 +0,0 @@
// A line diff, for showing what a proposed policy actually changes before anyone saves it.
//
// Hand-rolled rather than a dependency: this is one screen showing one document, the inputs are a few
// hundred lines at most, and the alternative is adding a package to the frozen lockfile for forty lines of
// well-understood algorithm. If a second surface ever needs a diff, that trade flips.
export type DiffLine = { kind: 'same' | 'add' | 'remove'; text: string };
/** Longest common subsequence table over the two line arrays. O(n·m) — fine at document scale. */
function lcsLengths(a: string[], b: string[]): number[][] {
const table: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0));
for (let i = a.length - 1; i >= 0; i--) {
for (let j = b.length - 1; j >= 0; j--) {
table[i]![j] = a[i] === b[j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!);
}
}
return table;
}
/** Every line of both documents, in order, tagged with what happened to it. */
export function diffLines(before: string, after: string): DiffLine[] {
const a = before.split('\n');
const b = after.split('\n');
const table = lcsLengths(a, b);
const out: DiffLine[] = [];
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
if (a[i] === b[j]) {
out.push({ kind: 'same', text: a[i]! });
i++;
j++;
} else if (table[i + 1]![j]! >= table[i]![j + 1]!) {
out.push({ kind: 'remove', text: a[i]! });
i++;
} else {
out.push({ kind: 'add', text: b[j]! });
j++;
}
}
while (i < a.length) out.push({ kind: 'remove', text: a[i++]! });
while (j < b.length) out.push({ kind: 'add', text: b[j++]! });
return out;
}
/**
* Drop long runs of unchanged lines, keeping `context` either side of every change.
*
* A policy is mostly unchanged by any one edit, and an unabridged diff buries the three lines that matter.
* `null` marks each elision so the view can draw a gap rather than pretend the lines are adjacent.
*/
export function collapseUnchanged(lines: DiffLine[], context = 3): (DiffLine | null)[] {
const keep = new Array<boolean>(lines.length).fill(false);
lines.forEach((line, index) => {
if (line.kind === 'same') return;
for (let k = Math.max(0, index - context); k <= Math.min(lines.length - 1, index + context); k++) keep[k] = true;
});
const out: (DiffLine | null)[] = [];
let gap = false;
lines.forEach((line, index) => {
if (keep[index]) {
out.push(line);
gap = false;
} else if (!gap) {
out.push(null);
gap = true;
}
});
return out;
}
export const diffCounts = (lines: DiffLine[]) => ({
added: lines.filter((l) => l.kind === 'add').length,
removed: lines.filter((l) => l.kind === 'remove').length,
});

Some files were not shown because too many files have changed in this diff Show More