From b3c0c1c691c721f2f3af4b84a18afccf8d9e4a24 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Mon, 13 Jul 2026 10:58:35 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 4 ++ app/routes/settings/agent.tsx | 25 ++++++- app/server/headscale/api/index.ts | 3 + app/server/headscale/api/resources/auth.ts | 27 ++++++++ app/server/hp-agent.ts | 78 +++++++++++++++++++--- cmd/hp_agent/hp_agent.go | 11 +++ docs/features/agent.md | 24 +++++-- internal/tsnet/server.go | 1 + 8 files changed, 155 insertions(+), 18 deletions(-) create mode 100644 app/server/headscale/api/resources/auth.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fc15fdb..cb859b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)). diff --git a/app/routes/settings/agent.tsx b/app/routes/settings/agent.tsx index f0725ee..62df57f 100644 --- a/app/routes/settings/agent.tsx +++ b/app/routes/settings/agent.tsx @@ -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) {
- - {hasError ? "Error" : "Healthy"} + + + {hasError ? "Error" : isPending ? "Waiting for approval" : "Healthy"} +
@@ -95,6 +103,17 @@ export default function Page({ loaderData }: Route.ComponentProps) {
+ {isPending ? ( + + 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{" "} + + this link + + . + + ) : undefined} + {loaderData.error ? ( {loaderData.error} diff --git a/app/server/headscale/api/index.ts b/app/server/headscale/api/index.ts index 64c5c89..eb621b0 100644 --- a/app/server/headscale/api/index.ts +++ b/app/server/headscale/api/index.ts @@ -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; +} + +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 }, + }); + }, + }; +} diff --git a/app/server/hp-agent.ts b/app/server/hp-agent.ts index 627b875..10fa49f 100644 --- a/app/server/hp-agent.ts +++ b/app/server/hp-agent.ts @@ -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>; - lastSync(): { syncedAt: Date | null; nodeCount: number; error?: string }; + lastSync(): { + syncedAt: Date | null; + nodeCount: number; + error?: string; + authUrl?: string; + }; agentNodeKey(): string | undefined; triggerSync(): Promise; dispose(): void; @@ -32,6 +37,7 @@ interface SyncState { nodeCount: number; selfKey?: string; error?: string; + authUrl?: string; } async function hasExistingState(workDir: string): Promise { @@ -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 { 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 { @@ -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, }; }, diff --git a/cmd/hp_agent/hp_agent.go b/cmd/hp_agent/hp_agent.go index d2d4aaf..786c430 100644 --- a/cmd/hp_agent/hp_agent.go +++ b/cmd/hp_agent/hp_agent.go @@ -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() diff --git a/docs/features/agent.md b/docs/features/agent.md index 50755dc..a63e3eb 100644 --- a/docs/features/agent.md +++ b/docs/features/agent.md @@ -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
diff --git a/internal/tsnet/server.go b/internal/tsnet/server.go index 2909911..ac88023 100644 --- a/internal/tsnet/server.go +++ b/internal/tsnet/server.go @@ -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)