mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
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:
@@ -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
|
# 0.7.0
|
||||||
|
|
||||||
- Switched to structured JSON logging (closes [#279](https://github.com/tale/headplane/issues/279)).
|
- Switched to structured JSON logging (closes [#279](https://github.com/tale/headplane/issues/279)).
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
syncedAt: sync.syncedAt?.toISOString() ?? null,
|
syncedAt: sync.syncedAt?.toISOString() ?? null,
|
||||||
nodeCount: sync.nodeCount,
|
nodeCount: sync.nodeCount,
|
||||||
error: sync.error,
|
error: sync.error,
|
||||||
|
authUrl: sync.authUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +43,11 @@ export async function action({ request, context }: Route.ActionArgs) {
|
|||||||
|
|
||||||
await agents.value.triggerSync();
|
await agents.value.triggerSync();
|
||||||
const sync = agents.value.lastSync();
|
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) {
|
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);
|
const hasError = Boolean(loaderData.error);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,8 +82,10 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<StatusCircle isOnline={!hasError} className="h-5 w-5" />
|
<StatusCircle isOnline={!hasError && !isPending} className="h-5 w-5" />
|
||||||
<span className="text-lg font-medium">{hasError ? "Error" : "Healthy"}</span>
|
<span className="text-lg font-medium">
|
||||||
|
{hasError ? "Error" : isPending ? "Waiting for approval" : "Healthy"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -95,6 +103,17 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</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 ? (
|
{loaderData.error ? (
|
||||||
<Notice variant="error" title="Sync Error">
|
<Notice variant="error" title="Sync Error">
|
||||||
{loaderData.error}
|
{loaderData.error}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import log from "~/utils/log";
|
|||||||
import { type Capabilities, capabilitiesFor } from "./capabilities";
|
import { type Capabilities, capabilitiesFor } from "./capabilities";
|
||||||
import { isDataWithApiError } from "./error-client";
|
import { isDataWithApiError } from "./error-client";
|
||||||
import { type ApiKeyApi, makeApiKeyApi } from "./resources/api-keys";
|
import { type ApiKeyApi, makeApiKeyApi } from "./resources/api-keys";
|
||||||
|
import { type AuthApi, makeAuthApi } from "./resources/auth";
|
||||||
import { makeNodeApi, type NodeApi } from "./resources/nodes";
|
import { makeNodeApi, type NodeApi } from "./resources/nodes";
|
||||||
import { makePolicyApi, type PolicyApi } from "./resources/policy";
|
import { makePolicyApi, type PolicyApi } from "./resources/policy";
|
||||||
import { makePreAuthKeyApi, type PreAuthKeyApi } from "./resources/pre-auth-keys";
|
import { makePreAuthKeyApi, type PreAuthKeyApi } from "./resources/pre-auth-keys";
|
||||||
@@ -48,6 +49,7 @@ export interface HeadscaleClient {
|
|||||||
policy: PolicyApi;
|
policy: PolicyApi;
|
||||||
preAuthKeys: PreAuthKeyApi;
|
preAuthKeys: PreAuthKeyApi;
|
||||||
apiKeys: ApiKeyApi;
|
apiKeys: ApiKeyApi;
|
||||||
|
auth: AuthApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateHeadscaleOptions {
|
export interface CreateHeadscaleOptions {
|
||||||
@@ -149,6 +151,7 @@ export async function createHeadscale(opts: CreateHeadscaleOptions): Promise<Hea
|
|||||||
policy: makePolicyApi(transport, capabilities, apiKey),
|
policy: makePolicyApi(transport, capabilities, apiKey),
|
||||||
preAuthKeys: makePreAuthKeyApi(transport, capabilities, apiKey),
|
preAuthKeys: makePreAuthKeyApi(transport, capabilities, apiKey),
|
||||||
apiKeys: makeApiKeyApi(transport, capabilities, apiKey),
|
apiKeys: makeApiKeyApi(transport, capabilities, apiKey),
|
||||||
|
auth: makeAuthApi(transport, capabilities, apiKey),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async dispose() {
|
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 },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+66
-10
@@ -1,5 +1,5 @@
|
|||||||
import { type ChildProcess, spawn } from "node:child_process";
|
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 { join } from "node:path";
|
||||||
import { createInterface } from "node:readline";
|
import { createInterface } from "node:readline";
|
||||||
|
|
||||||
@@ -15,7 +15,12 @@ import type { HeadscaleClient } from "./headscale/api";
|
|||||||
|
|
||||||
export interface AgentManager {
|
export interface AgentManager {
|
||||||
lookup(nodeKeys: string[]): Promise<Record<string, HostInfo>>;
|
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;
|
agentNodeKey(): string | undefined;
|
||||||
triggerSync(): Promise<void>;
|
triggerSync(): Promise<void>;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
@@ -32,6 +37,7 @@ interface SyncState {
|
|||||||
nodeCount: number;
|
nodeCount: number;
|
||||||
selfKey?: string;
|
selfKey?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
authUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hasExistingState(workDir: string): Promise<boolean> {
|
async function hasExistingState(workDir: string): Promise<boolean> {
|
||||||
@@ -98,6 +104,7 @@ export async function createAgentManager(
|
|||||||
let responseHandler: ((line: string) => void) | null = null;
|
let responseHandler: ((line: string) => void) | null = null;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let consecutiveErrors = 0;
|
let consecutiveErrors = 0;
|
||||||
|
let approvingAuthId: string | undefined;
|
||||||
|
|
||||||
async function generateAuthKey(): Promise<string> {
|
async function generateAuthKey(): Promise<string> {
|
||||||
const expiration = new Date(Date.now() + 5 * 60_000);
|
const expiration = new Date(Date.now() + 5 * 60_000);
|
||||||
@@ -122,6 +129,9 @@ export async function createAgentManager(
|
|||||||
|
|
||||||
if (authKey) {
|
if (authKey) {
|
||||||
env.HEADPLANE_AGENT_TS_AUTHKEY = 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, [], {
|
const child = spawn(executablePath, [], {
|
||||||
@@ -131,8 +141,40 @@ export async function createAgentManager(
|
|||||||
|
|
||||||
child.stderr?.on("data", (chunk: Buffer) => {
|
child.stderr?.on("data", (chunk: Buffer) => {
|
||||||
const text = chunk.toString().trim();
|
const text = chunk.toString().trim();
|
||||||
if (text) {
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
log.debug("agent", "%s", text);
|
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) => {
|
child.on("exit", (code, signal) => {
|
||||||
if (!disposed) {
|
if (!disposed) {
|
||||||
log.warn("agent", "Agent process exited (code=%s, signal=%s)", code, signal);
|
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;
|
proc = null;
|
||||||
|
|
||||||
@@ -169,13 +218,18 @@ export async function createAgentManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stateExists = await hasExistingState(workDir);
|
const stateExists = await hasExistingState(workDir);
|
||||||
|
const authKey = await generateAuthKey();
|
||||||
if (stateExists) {
|
if (stateExists) {
|
||||||
log.debug("agent", "Reusing existing tsnet identity");
|
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(authKey);
|
||||||
return spawnAgent(await generateAuthKey());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendSync(child: ChildProcess): Promise<string> {
|
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);
|
log.error("agent", "Sync error from agent (%d/5): %s", consecutiveErrors, output.error);
|
||||||
|
|
||||||
if (consecutiveErrors >= 5 && proc) {
|
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.kill("SIGTERM");
|
||||||
proc = null;
|
proc = null;
|
||||||
await rm(join(workDir, "tailscaled.state"), { force: true });
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -258,8 +311,10 @@ export async function createAgentManager(
|
|||||||
log.error("agent", "Sync failed (%d/5): %s", consecutiveErrors, message);
|
log.error("agent", "Sync failed (%d/5): %s", consecutiveErrors, message);
|
||||||
|
|
||||||
if (consecutiveErrors >= 5) {
|
if (consecutiveErrors >= 5) {
|
||||||
log.warn("agent", "Too many consecutive failures, clearing state for next attempt");
|
log.warn(
|
||||||
await rm(join(workDir, "tailscaled.state"), { force: true });
|
"agent",
|
||||||
|
"Too many consecutive failures; agent state is being preserved to avoid creating a new host",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isSyncing = false;
|
isSyncing = false;
|
||||||
@@ -342,6 +397,7 @@ export async function createAgentManager(
|
|||||||
syncedAt: state.syncedAt,
|
syncedAt: state.syncedAt,
|
||||||
nodeCount: state.nodeCount,
|
nodeCount: state.nodeCount,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
|
authUrl: state.authUrl,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.SetDebug(cfg.Debug)
|
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)
|
agent := tsnet.NewAgent(cfg)
|
||||||
defer agent.Shutdown()
|
defer agent.Shutdown()
|
||||||
|
|
||||||
|
|||||||
+20
-4
@@ -7,8 +7,9 @@ description: Configure the Headplane Agent for enhanced functionality.
|
|||||||
|
|
||||||
The Headplane Agent is an optional component that periodically syncs node
|
The Headplane Agent is an optional component that periodically syncs node
|
||||||
information (such as version and OS details) from the Tailnet. Unlike previous
|
information (such as version and OS details) from the Tailnet. Unlike previous
|
||||||
versions, the agent no longer runs as a persistent Tailnet node — it
|
versions, the agent does not require you to manually create or manage pre-auth
|
||||||
auto-generates pre-auth keys and performs periodic syncs instead.
|
keys — Headplane generates a fresh key for each agent startup and reuses the
|
||||||
|
agent's existing Tailnet state across restarts.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -18,8 +19,9 @@ Before enabling the agent, ensure the following:
|
|||||||
keys which are only available in Headscale 0.28+.
|
keys which are only available in Headscale 0.28+.
|
||||||
|
|
||||||
2. **`headscale.api_key`** must be set in your Headplane configuration file.
|
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
|
The agent uses this key to auto-generate pre-auth keys for connecting to the
|
||||||
connecting to the Tailnet.
|
Tailnet and to auto-approve its own registration when Headscale is configured
|
||||||
|
to require manual approval.
|
||||||
|
|
||||||
## Configuration
|
## 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
|
that the specified directory exists and is writable by the user running
|
||||||
Headplane.
|
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
|
## Usage
|
||||||
|
|
||||||
<figure>
|
<figure>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ func (s *TSAgent) Connect() {
|
|||||||
log := util.GetLogger()
|
log := util.GetLogger()
|
||||||
|
|
||||||
// Waits until the agent is up and running.
|
// 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())
|
status, err := s.Up(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Failed to connect to Tailnet: %s", err)
|
log.Fatal("Failed to connect to Tailnet: %s", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user