feat: switch agent to a periodic dump rather than long running process

This commit is contained in:
Aarnav Tale
2026-03-27 13:19:16 -04:00
parent 2c57187628
commit ee59a2d06d
17 changed files with 521 additions and 464 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ export default [
index("routes/settings/overview.tsx"),
route("/auth-keys", "routes/settings/auth-keys/overview.tsx"),
route("/restrictions", "routes/settings/restrictions/overview.tsx"),
// route('/local-agent', 'routes/settings/local-agent.tsx'),
route("/agent", "routes/settings/agent.tsx"),
]),
]),
];
+9 -2
View File
@@ -56,9 +56,16 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
const [enhancedNode] = mapNodes([node], lookup);
const tags = [...node.tags].toSorted();
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
const agentSync = context.agents?.lastSync();
return {
agent: context.agents?.agentID(),
agent: agentSync
? {
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
nodeCount: agentSync.nodeCount,
nodeKey: context.agents?.agentNodeKey(),
}
: undefined,
existingTags: sortNodeTags(nodes),
magic,
node: enhancedNode,
@@ -77,7 +84,7 @@ export default function Page({
const [showRouting, setShowRouting] = useState(false);
const uiTags = useMemo(() => {
const tags = uiTagsForNode(node, agent === node.nodeKey);
const tags = uiTagsForNode(node, agent?.nodeKey === node.nodeKey);
return tags;
}, [node, agent]);
+14 -3
View File
@@ -47,9 +47,16 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
const populatedNodes = mapNodes(nodes, stats);
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
const agentSync = context.agents?.lastSync();
return {
agent: context.agents?.agentID(),
agent: agentSync
? {
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
nodeCount: agentSync.nodeCount,
nodeKey: context.agents?.agentNodeKey(),
}
: undefined,
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
magic,
nodes,
@@ -211,7 +218,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] table-auto rounded-lg">
<table className="w-full min-w-160 table-auto rounded-lg">
<thead className="text-mist-600 dark:text-mist-300">
<tr className="px-0.5 text-left">
<th
@@ -371,7 +378,11 @@ export default function Page({ loaderData }: Route.ComponentProps) {
filteredAndSortedNodes.map((node) => (
<MachineRow
existingTags={sortNodeTags(loaderData.nodes)}
isAgent={loaderData.agent ? loaderData.agent === node.nodeKey : undefined}
isAgent={
loaderData.agent !== undefined
? node.nodeKey === loaderData.agent.nodeKey
: undefined
}
isDisabled={
loaderData.writable
? false // If the user has write permissions, they can edit all machines
+104
View File
@@ -0,0 +1,104 @@
import { useFetcher } from "react-router";
import Button from "~/components/button";
import Link from "~/components/link";
import Notice from "~/components/notice";
import StatusCircle from "~/components/status-circle";
import Text from "~/components/text";
import Title from "~/components/title";
import { formatTimeDelta } from "~/utils/time";
import type { Route } from "./+types/agent";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
if (!context.agents) {
return { enabled: false as const };
}
const sync = context.agents.lastSync();
return {
enabled: true as const,
syncedAt: sync.syncedAt?.toISOString() ?? null,
nodeCount: sync.nodeCount,
error: sync.error,
};
}
export async function action({ request, context }: Route.ActionArgs) {
await context.auth.require(request);
if (!context.agents) {
return { success: false, error: "Agent is not enabled" };
}
await context.agents.triggerSync();
const sync = context.agents.lastSync();
return { success: !sync.error, error: sync.error };
}
export default function Page({ loaderData }: Route.ComponentProps) {
const fetcher = useFetcher<typeof action>();
const isSyncing = fetcher.state !== "idle";
if (!loaderData.enabled) {
return (
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
<Title>Headplane Agent</Title>
<Notice title="Agent Not Enabled">
The Headplane Agent is not enabled. To learn how to set up the agent, visit the{" "}
<Link external styled to="https://headplane.dev/docs/agent">
documentation
</Link>
</Notice>
</div>
);
}
const hasError = Boolean(loaderData.error);
return (
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
<div className="flex w-full flex-col sm:w-2/3">
<Title>Headplane Agent</Title>
<Text>
The Headplane Agent syncs node information like OS version and connectivity details from
your Tailnet.
</Text>
</div>
<div className="flex items-center gap-3">
<StatusCircle isOnline={!hasError} className="h-5 w-5" />
<span className="text-lg font-medium">{hasError ? "Error" : "Healthy"}</span>
</div>
<div className="flex flex-col gap-2">
<Text>
<span className="font-medium">Last synced: </span>
{loaderData.syncedAt ? (
<span suppressHydrationWarning>{formatTimeDelta(new Date(loaderData.syncedAt))}</span>
) : (
"Never"
)}
</Text>
<Text>
<span className="font-medium">Nodes synced: </span>
{loaderData.nodeCount}
</Text>
</div>
{loaderData.error ? (
<Notice variant="error" title="Sync Error">
{loaderData.error}
</Notice>
) : undefined}
<fetcher.Form method="post">
<Button type="submit" variant="heavy" disabled={isSyncing}>
{isSyncing ? "Syncing…" : "Sync Now"}
</Button>
</fetcher.Form>
</div>
);
}
+13
View File
@@ -40,6 +40,19 @@ export default function Page({ loaderData: { config, isOidcEnabled } }: Route.Co
<ArrowRight className="ml-2 h-5 w-5" />
</div>
</Link>
<div className="flex w-full flex-col sm:w-2/3">
<h1 className="mb-4 text-2xl font-medium">Headplane Agent</h1>
<p>
The Headplane Agent syncs node information like OS version and connectivity details from
your Tailnet.
</p>
</div>
<Link to="/settings/agent">
<div className="flex items-center text-lg font-medium">
Agent Settings
<ArrowRight className="ml-2 h-5 w-5" />
</div>
</Link>
{config && isOidcEnabled ? (
<>
<div className="flex w-full flex-col sm:w-2/3">
+7 -2
View File
@@ -8,6 +8,7 @@ import { ExternalScriptsHandle } from "remix-utils/external-scripts";
import { EphemeralNodeInsert, ephemeralNodes } from "~/server/db/schema";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import { useLiveData } from "~/utils/live-data";
import log from "~/utils/log";
import type { Route } from "./+types/console";
import UserPrompt from "./user-prompt";
@@ -31,7 +32,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
throw data("WebSSH is not configured in this build.", 405);
}
if (!context.agents?.agentID()) {
if (!context.agents) {
throw data("WebSSH is only available with the Headplane agent integration", 400);
}
@@ -153,7 +154,7 @@ function generateHostname(username: string) {
export async function action({ request, context }: Route.ActionArgs) {
await context.auth.require(request);
if (!context.agents?.agentID()) {
if (!context.agents) {
throw data("WebSSH is only available with the Headplane agent integration", 400);
}
@@ -175,6 +176,10 @@ export async function action({ request, context }: Route.ActionArgs) {
node_key: nodeKey,
})
.where(eq(ephemeralNodes.auth_key, authKey));
context.agents?.triggerSync().catch((err) => {
log.debug("agent", "Background agent sync failed: %s", err);
});
}
export const links: Route.LinksFunction = () => [
+4 -1
View File
@@ -19,8 +19,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
let closed = false;
const encoder = new TextEncoder();
const send = (event: string, data: unknown) => {
if (!closed) {
if (closed) return;
try {
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
} catch {
closed = true;
}
};
+247 -343
View File
@@ -1,393 +1,297 @@
import { ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import EventEmitter from "node:events";
import { access, constants, mkdir, open } from "node:fs/promises";
import { getegid, geteuid } from "node:process";
import { createInterface, Interface } from "node:readline";
import { execFile } from "node:child_process";
import { access, constants, mkdir, rm, stat } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { inArray } from "drizzle-orm";
import { inArray, notInArray } from "drizzle-orm";
import { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
import { HostInfo } from "~/types";
import log from "~/utils/log";
import { HeadplaneConfig } from "./config/config-schema";
import { hostInfo } from "./db/schema";
import { ephemeralNodes, hostInfo } from "./db/schema";
import { RuntimeApiClient } from "./headscale/api/endpoints";
export async function createHeadplaneAgent(
config: NonNullable<HeadplaneConfig["integration"]>["agent"] | undefined,
const execFileAsync = promisify(execFile);
export interface AgentManager {
lookup(nodeKeys: string[]): Promise<Record<string, HostInfo>>;
lastSync(): { syncedAt: Date | null; nodeCount: number; error?: string };
agentNodeKey(): string | undefined;
triggerSync(): Promise<void>;
dispose(): void;
}
interface AgentOutput {
self: string;
hosts: Record<string, HostInfo>;
}
interface SyncState {
syncedAt: Date | null;
nodeCount: number;
selfKey?: string;
error?: string;
isSyncing: boolean;
pendingResync: boolean;
}
async function hasExistingState(workDir: string): Promise<boolean> {
try {
await stat(join(workDir, "tailscaled.state"));
return true;
} catch {
return false;
}
}
export async function createAgentManager(
agentConfig: NonNullable<NonNullable<HeadplaneConfig["integration"]>["agent"]> | undefined,
headscaleUrl: string,
apiClient: RuntimeApiClient,
supportsTagOnlyKeys: boolean,
db: NodeSQLiteDatabase,
) {
if (!config?.enabled) {
): Promise<AgentManager | undefined> {
if (!agentConfig?.enabled) {
return;
}
if (!config.pre_authkey) {
log.error("agent", "Agent `pre_authkey` is not set");
log.warn("agent", "The agent will not run until resolved");
if (!supportsTagOnlyKeys) {
log.error("agent", "The Headplane agent requires Headscale 0.28 or newer");
log.error("agent", "The agent will not run without support for tag-only keys");
return;
}
try {
await access(config.work_dir, constants.R_OK | constants.W_OK);
log.debug("config", "Using agent work dir at %s", config.work_dir);
} catch (error) {
// Try to create the directory just in case
try {
await mkdir(config.work_dir, { recursive: true });
log.debug("config", "Created agent work dir at %s", config.work_dir);
log.info("config", "Created missing agent work dir at %s", config.work_dir);
await access(agentConfig.executable_path, constants.X_OK);
} catch {
log.error("agent", "Agent executable not accessible at %s", agentConfig.executable_path);
return;
}
try {
await access(agentConfig.work_dir, constants.R_OK | constants.W_OK);
} catch {
try {
await mkdir(agentConfig.work_dir, { recursive: true });
log.info("agent", "Created agent work dir at %s", agentConfig.work_dir);
} catch (innerError) {
log.error("config", "Failed to create agent work dir at %s", config.work_dir);
log.info("config", "Agent work dir not accessible at %s", config.work_dir);
log.debug("config", "Error details: %s", error);
log.debug("config", "Create error details: %s", innerError);
return;
}
}
try {
const handle = await open(config.cache_path, "a+");
log.info("agent", "Using agent cache file at %s", config.cache_path);
await handle.close();
} catch (error) {
log.info("agent", "Agent cache file not accessible at %s", config.cache_path);
log.debug("agent", "Error details: %s", error);
return;
}
const agent = new HeadplaneAgent({
...config,
headscaleUrl,
});
agent.on("spawn", () => {
log.info("agent", "Headplane agent started");
});
agent.on("ready", () => {
log.info("agent", "Headplane agent is ready and serving queries");
});
agent.on("error", (err) => {
log.warn("agent", "Headplane agent experienced an error: %s", err.message);
log.debug("agent", "Error details: %o", err);
});
agent.on("exit", ({ code, signal }) => {
log.warn("agent", "Headplane agent exited with code %s and signal %s", code, signal);
});
agent.on("restart", ({ delay, attempt }) => {
log.warn(
log.error(
"agent",
"Headplane agent will restart in %f seconds (attempt %d)",
delay / 1000,
attempt,
"Failed to create agent work dir at %s: %s",
agentConfig.work_dir,
innerError instanceof Error ? innerError.message : String(innerError),
);
return;
}
}
const hostName = agentConfig.host_name ?? "headplane-agent";
const cacheTtl = agentConfig.cache_ttl ?? 180_000;
const executablePath = agentConfig.executable_path;
const workDir = agentConfig.work_dir;
const state: SyncState = {
syncedAt: null,
nodeCount: 0,
isSyncing: false,
pendingResync: false,
};
async function generateAuthKey(): Promise<string> {
const expiration = new Date(Date.now() + 5 * 60_000);
const pak = await apiClient.createPreAuthKey(null, false, false, expiration, [
`tag:${hostName}`,
]);
return pak.key;
}
async function runAgent(authKey: string): Promise<string> {
const env: Record<string, string> = {
HOME: process.env.HOME ?? "",
HEADPLANE_AGENT_WORK_DIR: workDir,
HEADPLANE_AGENT_TS_SERVER: headscaleUrl,
HEADPLANE_AGENT_HOSTNAME: hostName,
HEADPLANE_AGENT_DEBUG: log.debugEnabled ? "true" : "false",
};
if (authKey) {
env.HEADPLANE_AGENT_TS_AUTHKEY = authKey;
}
const { stdout } = await execFileAsync(executablePath, [], {
timeout: 60_000,
env,
});
agent.on("stderr", (data) => {
log.error("agent", "Headplane agent stderr:", data);
});
return stdout;
}
agent.on("info", async ({ id, info }) => {
log.debug("agent", "Received HostInfo for %s", id);
async function sync() {
if (state.isSyncing) {
state.pendingResync = true;
log.debug("agent", "Sync already in progress, queued resync");
return;
}
state.isSyncing = true;
try {
const parsedInfo = JSON.parse(info) as HostInfo;
const stateExists = await hasExistingState(workDir);
const authKey = stateExists ? "" : await generateAuthKey();
if (stateExists) {
log.debug("agent", "Reusing existing tsnet identity");
}
let stdout: string;
try {
stdout = await runAgent(authKey);
} catch (err) {
if (stateExists) {
log.info("agent", "Agent failed with existing state, clearing and retrying");
await rm(join(workDir, "tailscaled.state"), { force: true });
const freshKey = await generateAuthKey();
stdout = await runAgent(freshKey);
} else {
throw err;
}
}
const output = JSON.parse(stdout) as AgentOutput;
const keys = Object.keys(output.hosts);
for (const [nodeKey, payload] of Object.entries(output.hosts)) {
await db
.insert(hostInfo)
.values({
host_id: id,
payload: parsedInfo,
host_id: nodeKey,
payload,
updated_at: new Date(),
})
.onConflictDoUpdate({
target: hostInfo.host_id,
set: {
payload: parsedInfo,
payload,
updated_at: new Date(),
},
});
}
await pruneStaleHostInfo();
await pruneEphemeralNodes();
state.syncedAt = new Date();
state.nodeCount = keys.length;
state.selfKey = output.self || undefined;
state.error = undefined;
log.info("agent", "Sync complete: %d nodes updated", keys.length);
} catch (error) {
log.error(
"agent",
"Failed to parse HostInfo for %s: %s",
id,
error instanceof Error ? error.message : String(error),
);
const message = error instanceof Error ? error.message : String(error);
state.error = message;
log.error("agent", "Sync failed: %s", message);
} finally {
state.isSyncing = false;
if (state.pendingResync) {
state.pendingResync = false;
sync();
}
}
}
async function pruneStaleHostInfo() {
try {
const nodes = await apiClient.getNodes();
const activeKeys = nodes.map((n) => n.nodeKey);
if (activeKeys.length === 0) {
return;
}
});
agent.start();
const deleted = await db
.delete(hostInfo)
.where(notInArray(hostInfo.host_id, activeKeys))
.returning();
process.on("SIGTERM", () => agent.shutdown());
process.on("SIGINT", () => agent.shutdown());
if (deleted.length > 0) {
log.info("agent", "Pruned %d stale hostinfo entries", deleted.length);
}
} catch (error) {
log.debug(
"agent",
"Failed to prune stale hostinfo: %s",
error instanceof Error ? error.message : String(error),
);
}
}
async function pruneEphemeralNodes() {
try {
const rows = await db.select().from(ephemeralNodes);
if (rows.length === 0) {
return;
}
const nodes = await apiClient.getNodes();
const activeKeys = new Set(nodes.map((n) => n.nodeKey));
for (const row of rows) {
if (!row.node_key) {
continue;
}
if (!activeKeys.has(row.node_key)) {
await db.delete(ephemeralNodes).where(inArray(ephemeralNodes.auth_key, [row.auth_key]));
log.info("agent", "Pruned ephemeral SSH node %s", row.node_key);
}
}
} catch (error) {
log.debug(
"agent",
"Failed to prune ephemeral nodes: %s",
error instanceof Error ? error.message : String(error),
);
}
}
sync();
const interval = setInterval(() => {
sync();
}, cacheTtl);
return {
agentID: () => agent.agentID(),
lookup: async (nodes: string[]) => {
const results = await db.select().from(hostInfo).where(inArray(hostInfo.host_id, nodes));
async lookup(nodeKeys) {
if (nodeKeys.length === 0) {
return {};
}
const results = await db.select().from(hostInfo).where(inArray(hostInfo.host_id, nodeKeys));
return Object.fromEntries(
results.filter((r) => r.payload).map((r) => [r.host_id, r.payload]),
) as Record<string, HostInfo>;
},
lastSync() {
return {
syncedAt: state.syncedAt,
nodeCount: state.nodeCount,
error: state.error,
};
},
agentNodeKey() {
return state.selfKey;
},
async triggerSync() {
await sync();
},
dispose() {
clearInterval(interval);
},
};
}
type AgentOptions = NonNullable<NonNullable<HeadplaneConfig["integration"]>["agent"]> & {
headscaleUrl: string;
};
interface AgentEvents {
ready: [];
spawn: [];
error: [Error];
exit: [{ code?: number; signal?: NodeJS.Signals }];
restart: [{ delay: number; attempt: number }];
stderr: [string];
info: [{ id: string; info: string }];
}
/**
* A custom class that turns the lifecycle of the agent into an event emitter.
* It has many different responsibilities ensuring that:
* - The agent is spawned with the correct configuration
* - The agent is ready and still running (ping/heartbeat)
* - The agent is restarted on a backoff strategy
*/
class HeadplaneAgent extends EventEmitter<AgentEvents> {
private child?: ChildProcessWithoutNullStreams;
private readline?: Interface;
private options: AgentOptions;
private hbInterval?: NodeJS.Timeout;
private hbDeadline?: NodeJS.Timeout;
private restartTimer?: NodeJS.Timeout;
private refreshInterval?: NodeJS.Timeout;
private isWaitingForAck = false;
private isShuttingDown = false;
private backoffAttempt = 0;
private agentId?: string;
private BASE_BACKOFF_MS = 1.5 * 1000; // 1.5 seconds
private MAX_BACKOFF_MS = 30 * 1000; // 30 seconds
private PROBE_COOLDOWN_MS = 5 * 60_000; // 5 minutes
private PROBE_ATTEMPT_INTERVAL = 10; // Every 10th attempt
private HEARTBEAT_INTERVAL_MS = 5 * 1000; // 5 seconds
private HEARTBEAT_TIMEOUT_MS = 3 * 1000; // 3 seconds
constructor(options: AgentOptions) {
super();
this.options = options;
}
agentID() {
return this.agentId;
}
start() {
this.isShuttingDown = false;
this.spawnInternalChild();
}
shutdown() {
this.isShuttingDown = true;
this.agentId = undefined;
clearTimeout(this.restartTimer);
clearInterval(this.hbInterval);
clearInterval(this.refreshInterval);
clearTimeout(this.hbDeadline);
this.isWaitingForAck = false;
this.send("SHUTDOWN");
this.child?.kill("SIGTERM");
this.readline?.close();
}
private spawnInternalChild() {
this.child = spawn(this.options.executable_path, {
stdio: ["pipe", "pipe", "pipe"],
uid: geteuid?.() ?? undefined,
gid: getegid?.() ?? undefined,
env: {
HOME: process.env.HOME,
HEADPLANE_AGENT_WORK_DIR: this.options.work_dir,
HEADPLANE_AGENT_DEBUG: log.debugEnabled ? "true" : "false",
HEADPLANE_AGENT_HOSTNAME: this.options.host_name,
HEADPLANE_AGENT_TS_SERVER: this.options.headscaleUrl,
HEADPLANE_AGENT_TS_AUTHKEY: this.options.pre_authkey,
},
});
this.emit("spawn");
this.child.on("error", (err) => this.emit("error", err));
this.child.stderr.on("data", (data) => this.emit("stderr", data.toString()));
this.child.on("exit", (code, signal) => {
this.agentId = undefined;
this.emit("exit", {
code: code ?? undefined,
signal: signal ?? undefined,
});
this.readline?.close();
clearInterval(this.hbInterval);
clearInterval(this.refreshInterval);
clearTimeout(this.hbDeadline);
this.isWaitingForAck = false;
if (this.isShuttingDown) {
log.info("agent", "Child process exited gracefully");
return;
}
this.backoffAttempt++;
const delay = this.calculateBackoff();
this.emit("restart", { delay, attempt: this.backoffAttempt });
this.restartTimer = setTimeout(() => this.spawnInternalChild(), delay);
});
this.readline = createInterface({ input: this.child.stdout });
this.readline.on("line", (line) => this.readlineHandler(line));
this.send("START");
// Start the heartbeat loop with our custom interval
this.hbInterval = setInterval(() => {
if (!this.child || this.child.killed) return;
// If we get here, we missed the last PONG response and can die
if (this.isWaitingForAck) {
this.agentId = undefined;
this.emit("error", new Error("Agent heartbeat missed"));
this.child.kill("SIGTERM");
return;
}
this.isWaitingForAck = true;
this.send("PING");
clearTimeout(this.hbDeadline);
this.hbDeadline = setTimeout(() => {
if (this.isWaitingForAck) {
this.agentId = undefined;
this.emit("error", new Error("Agent heartbeat timeout"));
this.child?.kill("SIGTERM");
}
}, this.HEARTBEAT_TIMEOUT_MS);
}, this.HEARTBEAT_INTERVAL_MS);
}
private send(s: string) {
if (!this.child || this.child.killed) return;
const ok = this.child.stdin.write(`${s}\n`);
if (!ok) this.child.stdin.once("drain", () => {});
}
/**
* Calculates a backoff time based on the current attempt.
* Supports a randomized jitter to avoid thundering herd problems.
*
* @param min The minimum backoff time in milliseconds.
* @param max The maximum backoff time in milliseconds.
* @returns The calculated backoff time in milliseconds.
*/
private calculateBackoff() {
const attempt = this.backoffAttempt;
if (attempt > 0 && attempt % this.PROBE_ATTEMPT_INTERVAL === 0) {
const jitter = Math.floor(Math.random() * (this.MAX_BACKOFF_MS + 1));
const sign = Math.random() < 0.5 ? -1 : 1;
return Math.max(0, this.PROBE_COOLDOWN_MS + jitter * sign);
}
const cap = Math.min(this.MAX_BACKOFF_MS, this.BASE_BACKOFF_MS * 2 ** attempt);
return Math.floor(Math.random() * (cap + 1));
}
/**
* Processes and dispatches the appropriate response based on the message.
* @param line The message to process (piped straight from readline)
*/
private readlineHandler(line: string) {
// When we are ready we force a refresh so that the UI has the most
// up-to-date information and will gracefully handle new info being sent
if (line.startsWith("READY")) {
this.backoffAttempt = 0;
this.send("REFRESH");
this.emit("ready");
// Start periodic refresh using cache_ttl
clearInterval(this.refreshInterval);
this.refreshInterval = setInterval(() => {
if (!this.child || this.child.killed) return;
log.debug("agent", "Sending periodic REFRESH");
this.send("REFRESH");
}, this.options.cache_ttl);
const agentId = line.slice(5).trim();
if (this.agentId && this.agentId !== agentId) {
log.warn("agent", "Agent ID changed from %s to %s", this.agentId, agentId);
}
this.agentId = agentId;
return;
}
if (line.startsWith("PONG")) {
this.isWaitingForAck = false;
clearTimeout(this.hbDeadline);
const agentId = line.slice(5).trim();
if (this.agentId && this.agentId !== agentId) {
log.warn("agent", "Agent ID changed from %s to %s", this.agentId, agentId);
}
this.agentId = agentId;
return;
}
if (line.startsWith("HOSTINFO")) {
const data = line.slice(9).trim();
const [id, ...infoParts] = data.split(" ");
const info = infoParts.join(" ");
this.emit("info", { id, info });
return;
}
if (line.startsWith("ERROR")) {
const error = line.slice(6).trim();
this.emit("error", new Error(error));
return;
}
if (line.startsWith("LOG")) {
const logSnippet = line.slice(4).trim();
const [level, ...messageParts] = logSnippet.split(" ");
const message = messageParts.join(" ");
switch (level) {
case "INFO":
log.info("agent", message);
break;
case "WARN":
log.warn("agent", message);
break;
case "ERROR":
log.error("agent", message);
break;
default:
log.debug("agent", message);
}
return;
}
}
}
+2 -6
View File
@@ -46,12 +46,8 @@ const agents = headscaleApiKey
? await createAgentManager(
config.integration?.agent,
config.headscale.url,
headscaleApiKey,
(user, ephemeral, reusable, expiration, aclTags) =>
hsApi
.getRuntimeClient(headscaleApiKey)
.createPreAuthKey(user, ephemeral, reusable, expiration, aclTags),
() => hsApi.getRuntimeClient(headscaleApiKey).getUsers(),
hsApi.getRuntimeClient(headscaleApiKey),
hsApi.clientHelpers.isAtleast("0.28.0"),
db,
)
: (() => {
+14 -32
View File
@@ -1,22 +1,15 @@
package main
import (
"bufio"
"context"
"fmt"
"encoding/json"
"os"
"strings"
"github.com/tale/headplane/internal/config"
"github.com/tale/headplane/internal/tsnet"
"github.com/tale/headplane/internal/util"
)
type Register struct {
Type string
ID string
}
func main() {
log := util.GetLogger()
cfg, err := config.Load()
@@ -28,34 +21,23 @@ func main() {
agent := tsnet.NewAgent(cfg)
defer agent.Shutdown()
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := scanner.Bytes()
directive := strings.TrimSpace(string(line))
log.Debug("Received directive: %s", directive)
switch directive {
case "START":
agent.Connect()
fmt.Printf("READY %s\n", agent.ID)
case "SHUTDOWN":
agent.Shutdown()
os.Exit(0)
case "PING":
fmt.Printf("PONG %s\n", agent.ID)
case "REFRESH":
err := agent.DispatchHostInfo(context.Background())
hosts, err := agent.FetchAllHostInfo(context.Background())
if err != nil {
fmt.Printf("ERROR %s\n", err)
}
}
log.Fatal("Failed to fetch host info: %s", err)
}
if err := scanner.Err(); err != nil {
fmt.Printf("ERROR %s\n", err)
os.Exit(1)
output := struct {
Self string `json:"self"`
Hosts map[string]json.RawMessage `json:"hosts"`
}{
Self: agent.ID,
Hosts: hosts,
}
enc := json.NewEncoder(os.Stdout)
if err := enc.Encode(output); err != nil {
log.Fatal("Failed to encode result: %s", err)
}
}
+5 -6
View File
@@ -1,6 +1,7 @@
# Nix
[flake.nix](https://github.com/tale/headplane/blob/main/flake.nix) provided:
```sh
$ nix flake show github:tale/headplane --all-systems
github:tale/headplane/ec6d455461955242393b60d9ce60c5123fa9784f?narHash=sha256-CM/vXzUiOed7i1Pp15KyV4FuIvumRlXnpF33dSWZZH4%3D
@@ -45,10 +46,12 @@ github:tale/headplane/ec6d455461955242393b60d9ce60c5123fa9784f?narHash=sha256-CM
```
## NixOS module options
Defined as `services.headplane.*`, check the `./nix/` directory for details.\
The full (generated by `mise run nixos-docs`) list of `services.headplane.settings.*` options: [./NixOS-options.md](./NixOS-options.md)
## Usage
1. Add the `github:tale/headplane` flake input.
2. Import a default overlay to add `pkgs.headplane`.
3. Import NixOS module for `services.headplane.*`.
@@ -104,11 +107,11 @@ The full (generated by `mise run nixos-docs`) list of `services.headplane.settin
headscale = {
url = "https://example.com";
config_path = "${headscaleConfig}";
# Using `sops-nix` as an example, can be a path to any file with a secret.
api_key_path = config.sops.secrets."headplane/headscaleApiKey".path;
};
integration.agent = {
enabled = true;
# Using `sops-nix` as an example, can be a path to any file with a secret.
pre_authkey_path = config.sops.secrets."headplane/integrationAgentPreAuthkeyPath".path;
};
oidc = {
issuer = "https://oidc.example.com";
@@ -118,9 +121,6 @@ The full (generated by `mise run nixos-docs`) list of `services.headplane.settin
disable_api_key_login = true;
# Might needed when integrating with Authelia.
token_endpoint_auth_method = "client_secret_basic";
# Using `sops-nix` as an example, can be a path to any file with a secret.
headscale_api_key_path = config.sops.secrets."headplane/oidcHeadscaleApiKey".path;
redirect_uri = "https://oidc.example.com/admin/oidc/callback";
};
};
};
@@ -131,4 +131,3 @@ The full (generated by `mise run nixos-docs`) list of `services.headplane.settin
};
}
```
+33 -27
View File
@@ -1,4 +1,5 @@
# Configuration
> Previous versions of Headplane used environment variables without a configuration file.
> Since 0.5, you will need to manually migrate your configuration to the new format.
@@ -12,8 +13,9 @@ This can be configured on a per-section basis in the configuration file, but
it is very important this directory is persistent and writable by Headplane.
## Environment Variables
It is also possible to override the configuration file using environment variables.
These changes get merged *after* the configuration file is loaded, so they will take precedence.
These changes get merged _after_ the configuration file is loaded, so they will take precedence.
Environment variables follow this pattern: **`HEADPLANE_<SECTION>__<KEY_NAME>`**.
For example, to override `oidc.client_secret`, you would set `HEADPLANE_OIDC__CLIENT_SECRET`
to the value that you want.
@@ -26,11 +28,13 @@ Here are a few more examples:
**This functionality is NOT enabled by default!**
To enable it, set the environment variable **`HEADPLANE_LOAD_ENV_OVERRIDES=true`**.
Setting this also tells Headplane to load the relative `.env` file into the environment.
> Also note that this is **only** for configuration overrides, not for general
> environment variables meaning you cannot specify variables such as
> `HEADPLANE_DEBUG_LOG=true` or `HEADPLANE_CONFIG_PATH=/etc/headplane/config.yaml`.
## Sensitive Values
Headplane supports a dual-mode pattern for providing certain sensitive configuration values, such as secrets, keys, and private certificates. For each such field, you can either:
1. Set the value directly in the configuration file (e.g., `cookie_secret: "your-32-character-long-secret"`)
@@ -39,6 +43,7 @@ Headplane supports a dual-mode pattern for providing certain sensitive configura
When using a `_path` option, the content of the specified file will be read and used as the value for the setting. These paths can include environment variable interpolation (e.g., `${CREDENTIALS_DIRECTORY}/my_secret_file`), which is useful for integration with tools like systemd's `LoadCredential`.
**Important Rules for Dual-Mode Fields:**
- You **cannot** set both the direct value (e.g., `cookie_secret`) and its corresponding `_path` (e.g., `cookie_secret_path`) simultaneously. Doing so will result in a configuration error.
- If a `_path` is provided, the corresponding direct value field (if also present and not null) will usually be ignored or may cause validation errors depending on the specific field and loader logic. It's best to provide only one.
- The same dual-mode pattern works with environment variables. You can set `HEADPLANE_OIDC__CLIENT_SECRET` for a direct value or `HEADPLANE_OIDC__CLIENT_SECRET_PATH` to specify a path to a file containing the secret.
@@ -50,39 +55,39 @@ The following configuration options in Headplane are treated as secret paths:
- **Server Settings (`server.*`):**
- `cookie_secret_path` (for web session encoding)
- *Note:* Either `cookie_secret` or `cookie_secret_path` must be provided for web session security.
- _Note:_ Either `cookie_secret` or `cookie_secret_path` must be provided for web session security.
- **Headscale Connection Settings (`headscale.*`):**
- `api_key_path` (Headscale API key for server-side operations like OIDC and agent sync)
- _Note:_ Either `api_key` or `api_key_path` must be provided when using OIDC or the agent.
- `tls_cert_path` (custom TLS certificate for connecting to Headscale)
- *Note:* This is treated as a regular path, not a secret path, so it will not have its content loaded.
- **Integration Agent Settings (`integration.agent.*`):**
- `pre_authkey_path` (pre-auth key for the Headplane agent to connect to the Tailnet)
- *Note:* Either `pre_authkey` or `pre_authkey_path` must be provided when the agent is enabled.
- _Note:_ This is treated as a regular path, not a secret path, so it will not have its content loaded.
- **OIDC Settings (`oidc.*`):**
- `client_secret_path` (OIDC client secret)
- *Note:* Either `client_secret` or `client_secret_path` must be provided for OIDC authentication.
- `headscale_api_key_path` (Headscale API key used by Headplane during the OIDC authentication flow)
- *Note:* Either `headscale_api_key` or `headscale_api_key_path` must be provided for OIDC integration.
- _Note:_ Either `client_secret` or `client_secret_path` must be provided for OIDC authentication.
- `headscale_api_key_path` (**deprecated**, use `headscale.api_key_path` instead)
**Distinction for Non-Secret Paths:**
Other configuration fields that end with `_path` are treated as regular paths and will not have their content loaded. For example:
- `server.agent.cache_path` (default: `/var/lib/headplane/agent_cache.json`): This is a direct file path where Headplane will *store* its agent cache data.
- `headscale.config_path`: This is an optional path to Headscale's `config.yaml` file, which Headplane might read or use for validation.
All path fields (both secret and non-secret) support environment variable interpolation using the `${VAR_NAME}` syntax.
The path-based secret loading mechanism also works with environment variables. For instance:
- `HEADPLANE_OIDC__CLIENT_SECRET_PATH=/path/to/secret/file` will load the OIDC client secret from the specified file
- `HEADPLANE_SERVER__COOKIE_SECRET_PATH=${CREDENTIALS_DIRECTORY}/cookie_secret` will use environment variable interpolation in the path
## Debugging
To enable debug logging, set the **`HEADPLANE_DEBUG_LOG=true`** environment variable.
This will enable all debug logs for Headplane, which could fill up log space very quickly.
This is not recommended in production environments.
## Reverse Proxying
Reverse proxying is very common when deploying web applications. Headscale and
Headplane are very similar in this regard. You can use the same configuration
of any reverse proxy you are familiar with. Here is an example of how to do it
@@ -97,44 +102,45 @@ using Traefik:
http:
routers:
headscale:
rule: 'Host(`headscale.tale.me`)'
service: 'headscale'
rule: "Host(`headscale.tale.me`)"
service: "headscale"
middlewares:
- 'cors'
- "cors"
rewrite:
rule: 'Host(`headscale.tale.me`) && Path(`/`)'
service: 'headscale'
rule: "Host(`headscale.tale.me`) && Path(`/`)"
service: "headscale"
middlewares:
- 'rewrite'
- "rewrite"
headplane:
rule: 'Host(`headscale.tale.me`) && PathPrefix(`/admin`)'
service: 'headplane'
rule: "Host(`headscale.tale.me`) && PathPrefix(`/admin`)"
service: "headplane"
services:
headscale:
loadBalancer:
servers:
- url: 'http://headscale:8080'
- url: "http://headscale:8080"
headplane:
loadBalancer:
servers:
- url: 'http://headplane:3000'
- url: "http://headplane:3000"
middlewares:
rewrite:
addPrefix:
prefix: '/admin'
prefix: "/admin"
cors:
headers:
accessControlAllowHeaders: '*'
accessControlAllowHeaders: "*"
accessControlAllowMethods:
- 'GET'
- 'POST'
- 'PUT'
- "GET"
- "POST"
- "PUT"
accessControlAllowOriginList:
- 'https://headscale.tale.me'
- "https://headscale.tale.me"
accessControlMaxAge: 100
addVaryHeader: true
```
+21 -8
View File
@@ -10,9 +10,21 @@ description: Configure the Headplane Agent for enhanced functionality.
<figcaption>SSH access via the browser</figcaption>
</figure>
The Headplane Agent is an optional component that can be enabled on your
Headplane instance in order to unlock additional features such as remote
web-based SSH and detailed machine information.
The Headplane Agent is an optional component that periodically syncs node
information (such as version and OS details) from the Tailnet. Unlike previous
versions, the agent no longer runs as a persistent Tailnet node — it
auto-generates pre-auth keys and performs periodic syncs instead.
## Prerequisites
Before enabling the agent, ensure the following:
1. **Headscale 0.28 or newer** is required. The agent uses tag-only pre-auth
keys which are only available in Headscale 0.28+.
2. **`headscale.api_key`** must be set in your Headplane configuration file.
The agent uses this key to auto-generate ephemeral pre-auth keys for
connecting to the Tailnet.
## Configuration
@@ -23,14 +35,15 @@ please refer to the
for details.
| Field | Description |
|---------------------|--------------------------------------------------------|
| ----------------------------------- | ------------------------------------------------------------------------------- |
| **`integration.agent.enabled`** | Set to `true` to enable the agent. |
| **`integration.agent.pre_authkey`** | A reusable pre-auth-key (see below). |
| `integration.agent.host_name` | *Optional*. Host name to register as. |
| `integration.agent.cache_path` | *Optional*. Cache path for the agent. |
| `integration.agent.work_dir` | *Optional*. Working directory for the agent. |
| `integration.agent.host_name` | _Optional_. Headscale user name for the agent (default: `headplane-agent`). |
| `integration.agent.cache_ttl` | _Optional_. How often to sync in milliseconds (default: `180000` / 3 minutes). |
| `integration.agent.work_dir` | _Optional_. Working directory for the agent's tailnet state. |
| `integration.agent.executable_path` | _Optional_. Path to the agent binary (default: `/usr/libexec/headplane/agent`). |
## Native Mode Configuration
Once you've built Headplane locally, there will be a binary in the `./build`
folder called `hp_agent`. Please move this binary to
`/usr/libexec/headplane/agent` and ensure that it is executable.
+5 -2
View File
@@ -54,8 +54,11 @@ To enable OIDC authentication in Headplane, add the following to your
configuration file:
```yaml
headscale:
url: "http://headscale:8080"
api_key: "<generated-api-key>"
oidc:
headscale_api_key: "<generated-api-key>"
issuer: "https://your-idp.com"
client_id: "your-client-id"
client_secret: "your-client-secret"
@@ -211,7 +214,7 @@ flow can be skipped. Once completed, users are taken to the main dashboard.
setting. If Headplane is behind a reverse proxy with HTTPS, set it to `true`.
If running without HTTPS (eg. local development), set it to `false`.
- **Invalid API Key**: The `oidc.headscale_api_key` may have expired. Generate
- **Invalid API Key**: The `headscale.api_key` may have expired. Generate
a new one with `headscale apikeys create --expiration 999d`.
- **Missing the `sub` claim**: Ensure your IdP includes the `sub` claim in the
+14 -5
View File
@@ -3,10 +3,13 @@ package config
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
)
// Checks to make sure all required environment variables are set
// Checks to make sure all required environment variables are set.
// TSAuthKey is only required when there is no existing tsnet state.
func validateRequired(config *Config) error {
if config.Hostname == "" {
return fmt.Errorf("%s is required", HostnameEnv)
@@ -16,17 +19,23 @@ func validateRequired(config *Config) error {
return fmt.Errorf("%s is required", TSControlURLEnv)
}
if config.TSAuthKey == "" {
return fmt.Errorf("%s is required", TSAuthKeyEnv)
}
if config.WorkDir == "" {
return fmt.Errorf("%s is required", WorkDirEnv)
}
if config.TSAuthKey == "" && !hasExistingState(config.WorkDir) {
return fmt.Errorf("%s is required for first run (no existing state in %s)", TSAuthKeyEnv, config.WorkDir)
}
return nil
}
// hasExistingState checks if tsnet has previously stored identity state.
func hasExistingState(workDir string) bool {
_, err := os.Stat(filepath.Join(workDir, "tailscaled.state"))
return err == nil
}
// Pings the Tailscale control server to make sure it's up and running
func validateTSReady(config *Config) error {
testURL := config.TSControlURL
+8 -6
View File
@@ -69,17 +69,17 @@ func (s *TSAgent) GetStatusForPeer(id string) (*tailcfg.HostinfoView, error) {
return &whois.Node.Hostinfo, nil
}
// Dispatches ALL the HostInfo entries in our Tailnet to the master
func (s *TSAgent) DispatchHostInfo(ctx context.Context) error {
// FetchAllHostInfo fetches hostinfo for all peers and returns them as a map
// keyed by node public key (e.g., "nodekey:abc123...").
func (s *TSAgent) FetchAllHostInfo(ctx context.Context) (map[string]json.RawMessage, error) {
log := util.GetLogger()
stat, err := s.Lc.Status(ctx)
if err != nil {
log.Debug("Failed to get status: %s", err)
return fmt.Errorf("failed to get status: %w", err)
return nil, fmt.Errorf("failed to get status: %w", err)
}
// Do lookups for all peers with a hint of parallelism for speed!
const maxParallel = 8
sema := make(chan struct{}, maxParallel)
var wg sync.WaitGroup
@@ -96,6 +96,8 @@ func (s *TSAgent) DispatchHostInfo(ctx context.Context) error {
nodeMap[nodeKey] = peer
}
result := make(map[string]json.RawMessage)
for nodeKey, peer := range nodeMap {
idBytes, err := nodeKey.MarshalText()
if err != nil {
@@ -138,11 +140,11 @@ func (s *TSAgent) DispatchHostInfo(ctx context.Context) error {
}
mu.Lock()
fmt.Println("HOSTINFO " + nodeID + " " + string(data))
result[nodeID] = json.RawMessage(data)
mu.Unlock()
}()
}
wg.Wait()
return nil
return result, nil
}
+1 -1
View File
@@ -59,7 +59,7 @@ func (l *Logger) SetDebug(enabled bool) {
func (l *Logger) log(level LogLevel, format string, v ...any) {
msg := fmt.Sprintf(format, v...)
fmt.Printf("LOG %s %s\n", level, msg)
fmt.Fprintf(os.Stderr, "LOG %s %s\n", level, msg)
if level == LevelFatal {
os.Exit(1)
}