fix(agent): always spawn agent with pre-auth key and auto-approve

- Generate a fresh pre-auth key for every agent startup
- Preserve existing tailscale state to avoid creating a new host
- Auto-approve pending auth requests via /api/v1/auth/approve
- Show approval link in settings UI as fallback

Closes HP-558
This commit is contained in:
Aarnav Tale
2026-07-13 10:58:35 -04:00
parent 3d9b9b0f7e
commit b3c0c1c691
8 changed files with 155 additions and 18 deletions
+4
View File
@@ -1,3 +1,7 @@
# Next
- Fixed the Headplane agent falling back to an interactive Tailscale login. The agent now starts with a pre-auth-key, preserves its existing state across restarts, and auto-approves itself when Headscale requires manual approval (closes [#582](https://github.com/tale/headplane/issues/582)).
# 0.7.0
- Switched to structured JSON logging (closes [#279](https://github.com/tale/headplane/issues/279)).
+22 -3
View File
@@ -27,6 +27,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
syncedAt: sync.syncedAt?.toISOString() ?? null,
nodeCount: sync.nodeCount,
error: sync.error,
authUrl: sync.authUrl,
};
}
@@ -42,7 +43,11 @@ export async function action({ request, context }: Route.ActionArgs) {
await agents.value.triggerSync();
const sync = agents.value.lastSync();
return { success: !sync.error, error: sync.error };
return {
success: !sync.error,
error: sync.error,
authUrl: sync.authUrl,
};
}
export default function Page({ loaderData }: Route.ComponentProps) {
@@ -63,6 +68,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
);
}
const isPending = !loaderData.syncedAt && loaderData.authUrl;
const hasError = Boolean(loaderData.error);
return (
@@ -76,8 +82,10 @@ export default function Page({ loaderData }: Route.ComponentProps) {
</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>
<StatusCircle isOnline={!hasError && !isPending} className="h-5 w-5" />
<span className="text-lg font-medium">
{hasError ? "Error" : isPending ? "Waiting for approval" : "Healthy"}
</span>
</div>
<div className="flex flex-col gap-2">
@@ -95,6 +103,17 @@ export default function Page({ loaderData }: Route.ComponentProps) {
</Text>
</div>
{isPending ? (
<Notice variant="warning" title="Agent Needs Approval">
The agent is waiting for its Tailnet registration to be approved. Headplane will attempt
to auto-approve it, but if that fails, you can complete approval by visiting{" "}
<Link external styled to={loaderData.authUrl!}>
this link
</Link>
.
</Notice>
) : undefined}
{loaderData.error ? (
<Notice variant="error" title="Sync Error">
{loaderData.error}
+3
View File
@@ -22,6 +22,7 @@ import log from "~/utils/log";
import { type Capabilities, capabilitiesFor } from "./capabilities";
import { isDataWithApiError } from "./error-client";
import { type ApiKeyApi, makeApiKeyApi } from "./resources/api-keys";
import { type AuthApi, makeAuthApi } from "./resources/auth";
import { makeNodeApi, type NodeApi } from "./resources/nodes";
import { makePolicyApi, type PolicyApi } from "./resources/policy";
import { makePreAuthKeyApi, type PreAuthKeyApi } from "./resources/pre-auth-keys";
@@ -48,6 +49,7 @@ export interface HeadscaleClient {
policy: PolicyApi;
preAuthKeys: PreAuthKeyApi;
apiKeys: ApiKeyApi;
auth: AuthApi;
}
export interface CreateHeadscaleOptions {
@@ -149,6 +151,7 @@ export async function createHeadscale(opts: CreateHeadscaleOptions): Promise<Hea
policy: makePolicyApi(transport, capabilities, apiKey),
preAuthKeys: makePreAuthKeyApi(transport, capabilities, apiKey),
apiKeys: makeApiKeyApi(transport, capabilities, apiKey),
auth: makeAuthApi(transport, capabilities, apiKey),
};
},
async dispose() {
@@ -0,0 +1,27 @@
import type { Capabilities } from "../capabilities";
import type { Transport } from "../transport";
export interface AuthApi {
/**
* Approve a pending Headscale authentication request.
* Used by the Headplane agent to auto-approve its own registration.
*/
approve(authId: string): Promise<void>;
}
export function makeAuthApi(
transport: Transport,
_capabilities: Capabilities,
apiKey: string,
): AuthApi {
return {
approve: async (authId) => {
await transport.request({
method: "POST",
path: "v1/auth/approve",
apiKey,
body: { authId },
});
},
};
}
+67 -11
View File
@@ -1,5 +1,5 @@
import { type ChildProcess, spawn } from "node:child_process";
import { access, constants, mkdir, rm, stat } from "node:fs/promises";
import { access, constants, mkdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { createInterface } from "node:readline";
@@ -15,7 +15,12 @@ import type { HeadscaleClient } from "./headscale/api";
export interface AgentManager {
lookup(nodeKeys: string[]): Promise<Record<string, HostInfo>>;
lastSync(): { syncedAt: Date | null; nodeCount: number; error?: string };
lastSync(): {
syncedAt: Date | null;
nodeCount: number;
error?: string;
authUrl?: string;
};
agentNodeKey(): string | undefined;
triggerSync(): Promise<void>;
dispose(): void;
@@ -32,6 +37,7 @@ interface SyncState {
nodeCount: number;
selfKey?: string;
error?: string;
authUrl?: string;
}
async function hasExistingState(workDir: string): Promise<boolean> {
@@ -98,6 +104,7 @@ export async function createAgentManager(
let responseHandler: ((line: string) => void) | null = null;
let disposed = false;
let consecutiveErrors = 0;
let approvingAuthId: string | undefined;
async function generateAuthKey(): Promise<string> {
const expiration = new Date(Date.now() + 5 * 60_000);
@@ -122,6 +129,9 @@ export async function createAgentManager(
if (authKey) {
env.HEADPLANE_AGENT_TS_AUTHKEY = authKey;
log.info("agent", "Spawning agent with pre-auth key (prefix: %s)", authKey.slice(0, 16));
} else {
log.info("agent", "Spawning agent without pre-auth key (reusing existing state)");
}
const child = spawn(executablePath, [], {
@@ -131,8 +141,40 @@ export async function createAgentManager(
child.stderr?.on("data", (chunk: Buffer) => {
const text = chunk.toString().trim();
if (text) {
log.debug("agent", "%s", text);
if (!text) {
return;
}
log.debug("agent", "%s", text);
// tsnet prints an auth URL when it falls back to interactive login.
// Capture it so the UI can surface the approval link if needed, and try
// to auto-approve the agent using Headplane's admin API access.
const authMatch = text.match(
/To start this tsnet server, restart with TS_AUTHKEY set, or go to: (https:\/\/\S+)/,
);
if (authMatch) {
state.authUrl = authMatch[1];
log.warn("agent", "Agent is waiting for interactive approval; visit: %s", state.authUrl);
const authId = state.authUrl.split("/").pop();
if (authId && authId !== approvingAuthId) {
approvingAuthId = authId;
log.info("agent", "Attempting to auto-approve auth request %s", authId);
apiClient.auth
.approve(authId)
.then(() => {
log.info("agent", "Auto-approved auth request %s", authId);
})
.catch((error) => {
log.warn(
"agent",
"Failed to auto-approve auth request %s: %s",
authId,
error instanceof Error ? error.message : String(error),
);
});
}
}
});
@@ -148,6 +190,13 @@ export async function createAgentManager(
child.on("exit", (code, signal) => {
if (!disposed) {
log.warn("agent", "Agent process exited (code=%s, signal=%s)", code, signal);
} else {
log.info(
"agent",
"Agent process exited during disposal (code=%s, signal=%s)",
code,
signal,
);
}
proc = null;
@@ -169,13 +218,18 @@ export async function createAgentManager(
}
const stateExists = await hasExistingState(workDir);
const authKey = await generateAuthKey();
if (stateExists) {
log.debug("agent", "Reusing existing tsnet identity");
return spawnAgent("");
log.info(
"agent",
"Existing state found; agent will use it and fall back to the pre-auth key if needed",
);
} else {
log.info("agent", "No tsnet state found, agent will register with a pre-auth key");
}
log.info("agent", "No tsnet state found, generating pre-auth key");
return spawnAgent(await generateAuthKey());
return spawnAgent(authKey);
}
function sendSync(child: ChildProcess): Promise<string> {
@@ -214,10 +268,9 @@ export async function createAgentManager(
log.error("agent", "Sync error from agent (%d/5): %s", consecutiveErrors, output.error);
if (consecutiveErrors >= 5 && proc) {
log.warn("agent", "Too many consecutive errors, killing agent and clearing state");
log.warn("agent", "Too many consecutive errors, killing agent process for retry");
proc.kill("SIGTERM");
proc = null;
await rm(join(workDir, "tailscaled.state"), { force: true });
}
return;
}
@@ -258,8 +311,10 @@ export async function createAgentManager(
log.error("agent", "Sync failed (%d/5): %s", consecutiveErrors, message);
if (consecutiveErrors >= 5) {
log.warn("agent", "Too many consecutive failures, clearing state for next attempt");
await rm(join(workDir, "tailscaled.state"), { force: true });
log.warn(
"agent",
"Too many consecutive failures; agent state is being preserved to avoid creating a new host",
);
}
} finally {
isSyncing = false;
@@ -342,6 +397,7 @@ export async function createAgentManager(
syncedAt: state.syncedAt,
nodeCount: state.nodeCount,
error: state.error,
authUrl: state.authUrl,
};
},
+11
View File
@@ -30,6 +30,17 @@ func main() {
}
log.SetDebug(cfg.Debug)
if cfg.TSAuthKey == "" {
log.Info("No TS_AUTHKEY provided; tsnet will use existing state or prompt for interactive login")
} else {
prefix := cfg.TSAuthKey
if len(prefix) > 16 {
prefix = prefix[:16]
}
log.Info("TS_AUTHKEY provided (prefix: %s); connecting with pre-auth key", prefix)
}
agent := tsnet.NewAgent(cfg)
defer agent.Shutdown()
+20 -4
View File
@@ -7,8 +7,9 @@ description: Configure the Headplane Agent for enhanced functionality.
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.
versions, the agent does not require you to manually create or manage pre-auth
keys — Headplane generates a fresh key for each agent startup and reuses the
agent's existing Tailnet state across restarts.
## Prerequisites
@@ -18,8 +19,9 @@ Before enabling the agent, ensure the following:
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.
The agent uses this key to auto-generate pre-auth keys for connecting to the
Tailnet and to auto-approve its own registration when Headscale is configured
to require manual approval.
## Configuration
@@ -55,6 +57,20 @@ default. You can change this location by defining
that the specified directory exists and is writable by the user running
Headplane.
Headplane preserves the agent's `tailscaled.state` in this directory. This lets
the agent retain its Tailnet identity across Headplane restarts instead of
registering as a new host each time. If the agent's state is lost or unusable,
Headplane falls back to the pre-auth key and registers a new agent node.
## Interactive approval
Under normal circumstances, the agent connects headlessly using the auto-generated
pre-auth key and no manual interaction is required. If your Headscale server is
configured to require interactive approval, Headplane detects the auth URL the
agent prints and automatically approves the request using the configured
`headscale.api_key`. The Settings page still shows the approval link as a
fallback in case auto-approval fails.
## Usage
<figure>
+1
View File
@@ -52,6 +52,7 @@ func (s *TSAgent) Connect() {
log := util.GetLogger()
// Waits until the agent is up and running.
log.Info("Connecting to Tailnet at %s as %s", s.ControlURL, s.Hostname)
status, err := s.Up(context.Background())
if err != nil {
log.Fatal("Failed to connect to Tailnet: %s", err)