The tailnet plugin — machines, users, pre-auth keys, access policy and device invites. Moved out of officerdev/platform, where it had lived in plugins/ since the plugin system was built. Until now this code existed in exactly one place: the platform repository. That made "gitignore the plugins directory" impossible to do safely, because untracking it would have left 49 files on a single disk with no remote. This repository is what makes that move safe. Same extraction as plugins/music before it: source only, no history. The platform's history still holds every commit that shaped this, and the SHAs cited across the codebase keep resolving — replaying it here would have created a second, divergent account of the same work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
154 lines
7.7 KiB
TypeScript
154 lines
7.7 KiB
TypeScript
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;
|
|
}
|
|
}
|