+ {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)