mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
refactor(server): replace AppContext undefined sentinels with Feature<T>
Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019e5550-4435-7118-8393-cdcc97042178
This commit is contained in:
+1
-4
@@ -31,10 +31,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
|
|||||||
|
|
||||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
try {
|
try {
|
||||||
const principal = await context.auth.require(request);
|
const { principal, api } = await context.apiForRequest(request);
|
||||||
|
|
||||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
|
||||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
|
||||||
|
|
||||||
const user =
|
const user =
|
||||||
principal.kind === "oidc"
|
principal.kind === "oidc"
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
const { api } = await context.apiForRequest(request);
|
||||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
|
||||||
try {
|
try {
|
||||||
const { policy, updatedAt } = await api.setPolicy(policyData);
|
const { policy, updatedAt } = await api.setPolicy(policyData);
|
||||||
return data({
|
return data({
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Try to load the ACL policy from the API.
|
// Try to load the ACL policy from the API.
|
||||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
const { api } = await context.apiForRequest(request);
|
||||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
|
||||||
try {
|
try {
|
||||||
const { policy, updatedAt } = await api.getPolicy();
|
const { policy, updatedAt } = await api.getPolicy();
|
||||||
flags.writable = updatedAt !== null;
|
flags.writable = updatedAt !== null;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
const qp = new URL(request.url).searchParams;
|
const qp = new URL(request.url).searchParams;
|
||||||
const urlState = qp.get("s") ?? undefined;
|
const urlState = qp.get("s") ?? undefined;
|
||||||
|
|
||||||
const oidcService = context.oidc?.service;
|
const oidcService = context.oidc.state === "enabled" ? context.oidc.value : undefined;
|
||||||
const oidcStatus = oidcService
|
const oidcStatus = oidcService
|
||||||
? await oidcService.discover().then(
|
? await oidcService.discover().then(
|
||||||
(r) => (r.ok ? oidcService.status() : oidcService.status()),
|
(r) => (r.ok ? oidcService.status() : oidcService.status()),
|
||||||
@@ -32,7 +32,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (context.oidc?.disableApiKeyLogin && oidcStatus?.state === "ready" && urlState !== "logout") {
|
if (
|
||||||
|
oidcService &&
|
||||||
|
context.config.oidc?.disable_api_key_login &&
|
||||||
|
oidcStatus?.state === "ready" &&
|
||||||
|
urlState !== "logout"
|
||||||
|
) {
|
||||||
return redirect("/oidc/start");
|
return redirect("/oidc/start");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,15 +23,20 @@ export async function action({ request, context }: ActionFunctionArgs<AppContext
|
|||||||
// ended. Disabled by default because the post_logout_redirect_uri must be
|
// ended. Disabled by default because the post_logout_redirect_uri must be
|
||||||
// pre-registered on the IdP — turning this on without registering it would
|
// pre-registered on the IdP — turning this on without registering it would
|
||||||
// strand users on the IdP's error page.
|
// strand users on the IdP's error page.
|
||||||
if (principal?.kind === "oidc" && context.oidc?.useEndSession && context.oidc.service) {
|
if (
|
||||||
const status = context.oidc.service.status();
|
principal?.kind === "oidc" &&
|
||||||
|
context.oidc.state === "enabled" &&
|
||||||
|
context.config.oidc?.use_end_session
|
||||||
|
) {
|
||||||
|
const service = context.oidc.value;
|
||||||
|
const status = service.status();
|
||||||
if (status.state !== "ready") {
|
if (status.state !== "ready") {
|
||||||
// Trigger discovery if it hasn't happened yet so we can find the
|
// Trigger discovery if it hasn't happened yet so we can find the
|
||||||
// end_session_endpoint without forcing a re-login.
|
// end_session_endpoint without forcing a re-login.
|
||||||
await context.oidc.service.discover();
|
await service.discover();
|
||||||
}
|
}
|
||||||
|
|
||||||
const endSessionUrl = context.oidc.service.buildEndSessionUrl(principal.idToken);
|
const endSessionUrl = service.buildEndSessionUrl(principal.idToken);
|
||||||
if (endSessionUrl) {
|
if (endSessionUrl) {
|
||||||
url = endSessionUrl;
|
url = endSessionUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import { createOidcStateCookie } from "~/utils/oidc-state";
|
|||||||
import type { Route } from "./+types/oidc-callback";
|
import type { Route } from "./+types/oidc-callback";
|
||||||
|
|
||||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
const service = context.oidc?.service;
|
if (context.oidc.state !== "enabled") {
|
||||||
if (!service) {
|
throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 });
|
||||||
throw data("OIDC is not enabled or misconfigured", { status: 501 });
|
|
||||||
}
|
}
|
||||||
|
const service = context.oidc.value;
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.searchParams.toString().length === 0) {
|
if (url.searchParams.toString().length === 0) {
|
||||||
@@ -68,7 +68,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
// Only persist the id_token when RP-initiated logout is enabled — otherwise
|
// Only persist the id_token when RP-initiated logout is enabled — otherwise
|
||||||
// we'd be storing a credential we never use.
|
// we'd be storing a credential we never use.
|
||||||
const idToken = context.oidc?.useEndSession ? identity.idToken : undefined;
|
const idToken = context.config.oidc?.use_end_session ? identity.idToken : undefined;
|
||||||
|
|
||||||
return redirect("/", {
|
return redirect("/", {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
return redirect("/");
|
return redirect("/");
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const service = context.oidc?.service;
|
if (context.oidc.state !== "enabled") {
|
||||||
if (!service) {
|
throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 });
|
||||||
throw data("OIDC is not enabled or misconfigured", { status: 501 });
|
|
||||||
}
|
}
|
||||||
|
const service = context.oidc.value;
|
||||||
|
|
||||||
const result = await service.startFlow();
|
const result = await service.startFlow();
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
|||||||
+3
-5
@@ -24,12 +24,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
// Unclaimed users they can pick from before anything else.
|
// Unclaimed users they can pick from before anything else.
|
||||||
let unlinked = false;
|
let unlinked = false;
|
||||||
if (
|
if (
|
||||||
typeof context.oidc === "object" &&
|
context.oidc.state === "enabled" &&
|
||||||
principal.kind === "oidc" &&
|
principal.kind === "oidc" &&
|
||||||
!principal.user.headscaleUserId
|
!principal.user.headscaleUserId
|
||||||
) {
|
) {
|
||||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
const { api } = await context.apiForRequest(request);
|
||||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
|
||||||
|
|
||||||
let headscaleUsers: { id: string; name: string }[] = [];
|
let headscaleUsers: { id: string; name: string }[] = [];
|
||||||
try {
|
try {
|
||||||
@@ -64,8 +63,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No UI access — show the download/connect page
|
// No UI access — show the download/connect page
|
||||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
const { api } = await context.apiForRequest(request);
|
||||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
|
||||||
|
|
||||||
let linkedUserName: string | undefined;
|
let linkedUserName: string | undefined;
|
||||||
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
|
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ import { Capabilities } from "~/server/web/roles";
|
|||||||
import type { Route } from "./+types/machine";
|
import type { Route } from "./+types/machine";
|
||||||
|
|
||||||
export async function machineAction({ request, context }: Route.ActionArgs) {
|
export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||||
const principal = await context.auth.require(request);
|
const { principal, api } = await context.apiForRequest(request);
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
|
|
||||||
|
|
||||||
const action = formData.get("action_id")?.toString();
|
const action = formData.get("action_id")?.toString();
|
||||||
if (!action) {
|
if (!action) {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import Routes from "./dialogs/routes";
|
|||||||
import { machineAction } from "./machine-actions";
|
import { machineAction } from "./machine-actions";
|
||||||
|
|
||||||
export async function loader({ request, params, context }: Route.LoaderArgs) {
|
export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||||
const principal = await context.auth.require(request);
|
|
||||||
if (!params.id) {
|
if (!params.id) {
|
||||||
throw new Error("No machine ID provided");
|
throw new Error("No machine ID provided");
|
||||||
}
|
}
|
||||||
@@ -38,7 +37,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
|
const { api } = await context.apiForRequest(request);
|
||||||
const [nodesSnap, usersSnap] = await Promise.all([
|
const [nodesSnap, usersSnap] = await Promise.all([
|
||||||
context.hsLive.get(nodesResource, api),
|
context.hsLive.get(nodesResource, api),
|
||||||
context.hsLive.get(usersResource, api),
|
context.hsLive.get(usersResource, api),
|
||||||
@@ -50,18 +49,19 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
|||||||
throw data(null, { status: 404 });
|
throw data(null, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const lookup = await context.agents?.lookup([node.nodeKey]);
|
const agents = context.agents.state === "enabled" ? context.agents.value : undefined;
|
||||||
|
const lookup = await agents?.lookup([node.nodeKey]);
|
||||||
const [enhancedNode] = mapNodes([node], lookup);
|
const [enhancedNode] = mapNodes([node], lookup);
|
||||||
const tags = [...node.tags].toSorted();
|
const tags = [...node.tags].toSorted();
|
||||||
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
|
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
|
||||||
const agentSync = context.agents?.lastSync();
|
const agentSync = agents?.lastSync();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
agent: agentSync
|
agent: agentSync
|
||||||
? {
|
? {
|
||||||
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
|
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
|
||||||
nodeCount: agentSync.nodeCount,
|
nodeCount: agentSync.nodeCount,
|
||||||
nodeKey: context.agents?.agentNodeKey(),
|
nodeKey: agents?.agentNodeKey(),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
existingTags: sortNodeTags(nodes),
|
existingTags: sortNodeTags(nodes),
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
const writablePermission = context.auth.can(principal, Capabilities.write_machines);
|
const writablePermission = context.auth.can(principal, Capabilities.write_machines);
|
||||||
|
|
||||||
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
|
const { api } = await context.apiForRequest(request);
|
||||||
const [nodesSnap, usersSnap] = await Promise.all([
|
const [nodesSnap, usersSnap] = await Promise.all([
|
||||||
context.hsLive.get(nodesResource, api),
|
context.hsLive.get(nodesResource, api),
|
||||||
context.hsLive.get(usersResource, api),
|
context.hsLive.get(usersResource, api),
|
||||||
@@ -45,17 +45,18 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
|
const agents = context.agents.state === "enabled" ? context.agents.value : undefined;
|
||||||
|
const stats = await agents?.lookup(nodes.map((node) => node.nodeKey));
|
||||||
const populatedNodes = mapNodes(nodes, stats);
|
const populatedNodes = mapNodes(nodes, stats);
|
||||||
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
|
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
|
||||||
const agentSync = context.agents?.lastSync();
|
const agentSync = agents?.lastSync();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
agent: agentSync
|
agent: agentSync
|
||||||
? {
|
? {
|
||||||
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
|
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
|
||||||
nodeCount: agentSync.nodeCount,
|
nodeCount: agentSync.nodeCount,
|
||||||
nodeKey: context.agents?.agentNodeKey(),
|
nodeKey: agents?.agentNodeKey(),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
|
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ import { formatTimeDelta } from "~/utils/time";
|
|||||||
import type { Route } from "./+types/agent";
|
import type { Route } from "./+types/agent";
|
||||||
|
|
||||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
const principal = await context.auth.require(request);
|
await context.auth.require(request);
|
||||||
|
|
||||||
if (!context.agents) {
|
if (context.agents.state !== "enabled") {
|
||||||
return { enabled: false as const };
|
return { enabled: false as const, reason: context.agents.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
const sync = context.agents.lastSync();
|
const sync = context.agents.value.lastSync();
|
||||||
return {
|
return {
|
||||||
enabled: true as const,
|
enabled: true as const,
|
||||||
syncedAt: sync.syncedAt?.toISOString() ?? null,
|
syncedAt: sync.syncedAt?.toISOString() ?? null,
|
||||||
@@ -29,12 +29,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
export async function action({ request, context }: Route.ActionArgs) {
|
export async function action({ request, context }: Route.ActionArgs) {
|
||||||
await context.auth.require(request);
|
await context.auth.require(request);
|
||||||
|
|
||||||
if (!context.agents) {
|
if (context.agents.state !== "enabled") {
|
||||||
return { success: false, error: "Agent is not enabled" };
|
return { success: false, error: context.agents.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
await context.agents.triggerSync();
|
await context.agents.value.triggerSync();
|
||||||
const sync = context.agents.lastSync();
|
const sync = context.agents.value.lastSync();
|
||||||
return { success: !sync.error, error: sync.error };
|
return { success: !sync.error, error: sync.error };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
|
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
|
||||||
<Title>Headplane Agent</Title>
|
<Title>Headplane Agent</Title>
|
||||||
<Notice title="Agent Not Enabled">
|
<Notice title="Agent Not Enabled">
|
||||||
The Headplane Agent is not enabled. To learn how to set up the agent, visit the{" "}
|
{loaderData.reason}. To learn how to set up the agent, visit the{" "}
|
||||||
<Link external styled to="https://headplane.dev/docs/agent">
|
<Link external styled to="https://headplane.dev/docs/agent">
|
||||||
documentation
|
documentation
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ import type { PreAuthKey } from "~/types";
|
|||||||
import type { Route } from "./+types/overview";
|
import type { Route } from "./+types/overview";
|
||||||
|
|
||||||
export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
||||||
const principal = await context.auth.require(request);
|
const { principal, api } = await context.apiForRequest(request);
|
||||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
|
||||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
|
||||||
|
|
||||||
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
|
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
|
||||||
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
|
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
|
||||||
|
|||||||
@@ -19,9 +19,7 @@ import AuthKeyRow from "./auth-key-row";
|
|||||||
import AddAuthKey from "./dialogs/add-auth-key";
|
import AddAuthKey from "./dialogs/add-auth-key";
|
||||||
|
|
||||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
const principal = await context.auth.require(request);
|
const { principal, api } = await context.apiForRequest(request);
|
||||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
|
||||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
|
||||||
|
|
||||||
const usersSnap = await context.hsLive.get(usersResource, api);
|
const usersSnap = await context.hsLive.get(usersResource, api);
|
||||||
const users = usersSnap.data;
|
const users = usersSnap.data;
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import type { Route } from "./+types/overview";
|
|||||||
export async function loader({ context }: Route.LoaderArgs) {
|
export async function loader({ context }: Route.LoaderArgs) {
|
||||||
return {
|
return {
|
||||||
config: context.hs.writable(),
|
config: context.hs.writable(),
|
||||||
isOidcEnabled: context.oidc?.service.status().state === "ready",
|
isOidcEnabled:
|
||||||
|
context.oidc.state === "enabled" && context.oidc.value.status().state === "ready",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,18 +37,15 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
|||||||
throw data(sshErrors.wasm_missing, 405);
|
throw data(sshErrors.wasm_missing, 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.agents == null) {
|
if (context.agents.state !== "enabled") {
|
||||||
throw data(sshErrors.agent_required, 400);
|
throw data(sshErrors.agent_required, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const principal = await context.auth.require(request);
|
const { principal, api } = await context.apiForRequest(request);
|
||||||
if (principal.kind === "api_key") {
|
if (principal.kind === "api_key") {
|
||||||
throw data(sshErrors.oidc_required, 403);
|
throw data(sshErrors.oidc_required, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
|
||||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
|
||||||
|
|
||||||
const hostname = params.id;
|
const hostname = params.id;
|
||||||
const username = new URL(request.url).searchParams.get("user") || undefined;
|
const username = new URL(request.url).searchParams.get("user") || undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -54,8 +54,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
|||||||
let apiError: string | undefined;
|
let apiError: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
const { api } = await context.apiForRequest(request);
|
||||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
|
||||||
const [nodesSnap, usersSnap] = await Promise.all([
|
const [nodesSnap, usersSnap] = await Promise.all([
|
||||||
context.hsLive.get(nodesResource, api),
|
context.hsLive.get(nodesResource, api),
|
||||||
context.hsLive.get(usersResource, api),
|
context.hsLive.get(usersResource, api),
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
const { api } = await context.apiForRequest(request);
|
||||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "create_user": {
|
case "create_user": {
|
||||||
const name = formData.get("username")?.toString();
|
const name = formData.get("username")?.toString();
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import log from "~/utils/log";
|
|||||||
import type { Route } from "./+types/live";
|
import type { Route } from "./+types/live";
|
||||||
|
|
||||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
const principal = await context.auth.require(request);
|
const { api } = await context.apiForRequest(request);
|
||||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
|
||||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
|
||||||
|
|
||||||
// Ensure resources are loaded before streaming
|
// Ensure resources are loaded before streaming
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|||||||
+10
-1
@@ -36,10 +36,19 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ctx = await createAppContext(config);
|
const ctx = await createAppContext(config);
|
||||||
ctx.auth.start();
|
ctx.startServices();
|
||||||
|
|
||||||
export { config };
|
export { config };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disposes the per-process context. Invoked by the production
|
||||||
|
* supervisor on SIGTERM/SIGINT and by the dev Vite plugin on HMR
|
||||||
|
* reload.
|
||||||
|
*/
|
||||||
|
export async function dispose(): Promise<void> {
|
||||||
|
await ctx.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: `getLoadContext` is the right place to handle reverse proxy
|
// TODO: `getLoadContext` is the right place to handle reverse proxy
|
||||||
// translation — better than doing it in the OIDC client because it
|
// translation — better than doing it in the OIDC client because it
|
||||||
// applies to all requests, not just OIDC ones.
|
// applies to all requests, not just OIDC ones.
|
||||||
|
|||||||
+110
-37
@@ -5,12 +5,14 @@ import log from "~/utils/log";
|
|||||||
import type { HeadplaneConfig } from "./config/config-schema";
|
import type { HeadplaneConfig } from "./config/config-schema";
|
||||||
import { loadIntegration } from "./config/integration";
|
import { loadIntegration } from "./config/integration";
|
||||||
import { createDbClient } from "./db/client.server";
|
import { createDbClient } from "./db/client.server";
|
||||||
|
import { disabled, enabled, type Feature } from "./feature";
|
||||||
import { createHeadscaleInterface } from "./headscale/api";
|
import { createHeadscaleInterface } from "./headscale/api";
|
||||||
|
import type { RuntimeApiClient } from "./headscale/api/endpoints";
|
||||||
import { loadHeadscaleConfig } from "./headscale/config-loader";
|
import { loadHeadscaleConfig } from "./headscale/config-loader";
|
||||||
import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store";
|
import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store";
|
||||||
import { createAgentManager } from "./hp-agent";
|
import { type AgentManager, createAgentManager } from "./hp-agent";
|
||||||
import { createOidcService } from "./oidc/provider";
|
import { createOidcService, type OidcService } from "./oidc/provider";
|
||||||
import { createAuthService } from "./web/auth";
|
import { createAuthService, type Principal } from "./web/auth";
|
||||||
|
|
||||||
export type AppContext = Awaited<ReturnType<typeof createAppContext>>;
|
export type AppContext = Awaited<ReturnType<typeof createAppContext>>;
|
||||||
|
|
||||||
@@ -29,18 +31,12 @@ export async function createAppContext(config: HeadplaneConfig) {
|
|||||||
// falling back to the deprecated oidc.headscale_api_key for compatibility.
|
// falling back to the deprecated oidc.headscale_api_key for compatibility.
|
||||||
const headscaleApiKey = config.headscale.api_key ?? config.oidc?.headscale_api_key;
|
const headscaleApiKey = config.headscale.api_key ?? config.oidc?.headscale_api_key;
|
||||||
|
|
||||||
let agents;
|
const agents = await buildAgents(
|
||||||
if (headscaleApiKey) {
|
config,
|
||||||
agents = await createAgentManager(
|
|
||||||
config.integration?.agent,
|
|
||||||
config.headscale.url,
|
|
||||||
hsApi.getRuntimeClient(headscaleApiKey),
|
|
||||||
hsApi.clientHelpers.isAtleast("0.28.0"),
|
hsApi.clientHelpers.isAtleast("0.28.0"),
|
||||||
|
headscaleApiKey ? hsApi.getRuntimeClient(headscaleApiKey) : undefined,
|
||||||
db,
|
db,
|
||||||
);
|
);
|
||||||
} else if (config.integration?.agent?.enabled) {
|
|
||||||
log.warn("agent", "Agent is enabled but no headscale.api_key is configured");
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = createAuthService({
|
const auth = createAuthService({
|
||||||
secret: config.server.cookie_secret,
|
secret: config.server.cookie_secret,
|
||||||
@@ -54,10 +50,77 @@ export async function createAppContext(config: HeadplaneConfig) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const oidc =
|
const oidc = buildOidc(config, headscaleApiKey);
|
||||||
config.oidc && config.oidc.enabled !== false && headscaleApiKey
|
|
||||||
? {
|
const hsLive = createLiveStore([nodesResource, usersResource]);
|
||||||
service: createOidcService({
|
const hs = await loadHeadscaleConfig(
|
||||||
|
config.headscale.config_path,
|
||||||
|
config.headscale.config_strict,
|
||||||
|
config.headscale.dns_records_path,
|
||||||
|
);
|
||||||
|
const integration = await loadIntegration(config.integration);
|
||||||
|
|
||||||
|
// Disposers run in reverse-registration order on shutdown.
|
||||||
|
const disposers: Array<() => Promise<void> | void> = [() => auth.stop(), () => hsLive.dispose()];
|
||||||
|
if (agents.state === "enabled") {
|
||||||
|
disposers.push(() => agents.value.dispose());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiForRequest(
|
||||||
|
request: Request,
|
||||||
|
): Promise<{ principal: Principal; api: RuntimeApiClient }> {
|
||||||
|
const principal = await auth.require(request);
|
||||||
|
const apiKey = auth.getHeadscaleApiKey(principal);
|
||||||
|
return { principal, api: hsApi.getRuntimeClient(apiKey) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function startServices() {
|
||||||
|
auth.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispose() {
|
||||||
|
for (const d of [...disposers].reverse()) {
|
||||||
|
try {
|
||||||
|
await d();
|
||||||
|
} catch (error) {
|
||||||
|
log.warn("server", "Error during shutdown: %s", String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
db,
|
||||||
|
hsApi,
|
||||||
|
headscaleApiKey,
|
||||||
|
agents,
|
||||||
|
auth,
|
||||||
|
oidc,
|
||||||
|
hsLive,
|
||||||
|
hs,
|
||||||
|
integration,
|
||||||
|
apiForRequest,
|
||||||
|
startServices,
|
||||||
|
dispose,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOidc(
|
||||||
|
config: HeadplaneConfig,
|
||||||
|
headscaleApiKey: string | undefined,
|
||||||
|
): Feature<OidcService> {
|
||||||
|
if (!config.oidc) {
|
||||||
|
return disabled("OIDC is not configured");
|
||||||
|
}
|
||||||
|
if (config.oidc.enabled === false) {
|
||||||
|
return disabled("OIDC is disabled in the configuration");
|
||||||
|
}
|
||||||
|
if (!headscaleApiKey) {
|
||||||
|
return disabled("OIDC requires headscale.api_key to be configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
return enabled(
|
||||||
|
createOidcService({
|
||||||
issuer: config.oidc.issuer,
|
issuer: config.oidc.issuer,
|
||||||
clientId: config.oidc.client_id,
|
clientId: config.oidc.client_id,
|
||||||
clientSecret: config.oidc.client_secret,
|
clientSecret: config.oidc.client_secret,
|
||||||
@@ -78,25 +141,35 @@ export async function createAppContext(config: HeadplaneConfig) {
|
|||||||
profilePictureSource: config.oidc.profile_picture_source,
|
profilePictureSource: config.oidc.profile_picture_source,
|
||||||
postLogoutRedirectUri: config.oidc.post_logout_redirect_uri,
|
postLogoutRedirectUri: config.oidc.post_logout_redirect_uri,
|
||||||
}),
|
}),
|
||||||
disableApiKeyLogin: config.oidc.disable_api_key_login,
|
);
|
||||||
useEndSession: config.oidc.use_end_session,
|
}
|
||||||
}
|
|
||||||
: undefined;
|
async function buildAgents(
|
||||||
|
config: HeadplaneConfig,
|
||||||
return {
|
supportsTagOnlyKeys: boolean,
|
||||||
config,
|
apiClient: RuntimeApiClient | undefined,
|
||||||
db,
|
db: Awaited<ReturnType<typeof createDbClient>>,
|
||||||
hsApi,
|
): Promise<Feature<AgentManager>> {
|
||||||
headscaleApiKey,
|
const agentConfig = config.integration?.agent;
|
||||||
agents,
|
if (!agentConfig?.enabled) {
|
||||||
auth,
|
return disabled("Agent is not enabled in the configuration");
|
||||||
oidc,
|
}
|
||||||
hsLive: createLiveStore([nodesResource, usersResource]),
|
if (!apiClient) {
|
||||||
hs: await loadHeadscaleConfig(
|
return disabled("Agent requires headscale.api_key to be configured");
|
||||||
config.headscale.config_path,
|
}
|
||||||
config.headscale.config_strict,
|
if (!supportsTagOnlyKeys) {
|
||||||
config.headscale.dns_records_path,
|
return disabled("Agent requires Headscale 0.28 or newer");
|
||||||
),
|
}
|
||||||
integration: await loadIntegration(config.integration),
|
|
||||||
};
|
const manager = await createAgentManager(
|
||||||
|
agentConfig,
|
||||||
|
config.headscale.url,
|
||||||
|
apiClient,
|
||||||
|
supportsTagOnlyKeys,
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
if (!manager) {
|
||||||
|
return disabled("Agent failed to initialize (see logs)");
|
||||||
|
}
|
||||||
|
return enabled(manager);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// MARK: Feature<T>
|
||||||
|
//
|
||||||
|
// A two-state tagged union used in place of `T | undefined` for
|
||||||
|
// optional features on the AppContext. Carries a human-readable
|
||||||
|
// reason when the feature is disabled so loaders can surface it (or
|
||||||
|
// choose to ignore it).
|
||||||
|
//
|
||||||
|
// This is deliberately *not* a Result/Either. It does not represent
|
||||||
|
// async readiness or retryable failure — it represents "is this
|
||||||
|
// feature wired up at all." Services that have their own runtime
|
||||||
|
// status (e.g. OIDC discovery) keep that on the service itself.
|
||||||
|
|
||||||
|
export type Feature<T> = { state: "enabled"; value: T } | { state: "disabled"; reason: string };
|
||||||
|
|
||||||
|
export const enabled = <T>(value: T): Feature<T> => ({ state: "enabled", value });
|
||||||
|
|
||||||
|
export const disabled = (reason: string): Feature<never> => ({
|
||||||
|
state: "disabled",
|
||||||
|
reason,
|
||||||
|
});
|
||||||
+2
-1
@@ -12,7 +12,7 @@ import { dirname, resolve } from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { composeListener, startHttpServer } from "../../runtime/http";
|
import { composeListener, startHttpServer } from "../../runtime/http";
|
||||||
import requestListener, { config } from "./app";
|
import requestListener, { config, dispose } from "./app";
|
||||||
|
|
||||||
// `import.meta.url` resolves to `build/server/index.js`; the built
|
// `import.meta.url` resolves to `build/server/index.js`; the built
|
||||||
// client lives next to it at `build/client/`.
|
// client lives next to it at `build/client/`.
|
||||||
@@ -27,4 +27,5 @@ startHttpServer({
|
|||||||
immutableAssets: true,
|
immutableAssets: true,
|
||||||
requestListener,
|
requestListener,
|
||||||
}),
|
}),
|
||||||
|
onShutdown: dispose,
|
||||||
});
|
});
|
||||||
|
|||||||
+20
-4
@@ -172,6 +172,13 @@ export interface StartOptions {
|
|||||||
listener: RequestListener;
|
listener: RequestListener;
|
||||||
tls?: HttpsServerOptions;
|
tls?: HttpsServerOptions;
|
||||||
logger?: Logger;
|
logger?: Logger;
|
||||||
|
/**
|
||||||
|
* Optional async hook invoked on SIGINT/SIGTERM after the HTTP
|
||||||
|
* server stops accepting new connections but before the process
|
||||||
|
* exits. Use this to dispose long-lived resources (timers,
|
||||||
|
* subprocesses, DB handles).
|
||||||
|
*/
|
||||||
|
onShutdown?: () => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -189,15 +196,24 @@ export function startHttpServer(opts: StartOptions): Server {
|
|||||||
log.info("Listening on %s://%s:%s", proto, opts.host, opts.port);
|
log.info("Listening on %s://%s:%s", proto, opts.host, opts.port);
|
||||||
});
|
});
|
||||||
|
|
||||||
const shutdown = (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
log.info("Received %s, shutting down...", signal);
|
log.info("Received %s, shutting down...", signal);
|
||||||
server.close(() => process.exit(0));
|
server.close(async () => {
|
||||||
|
if (opts.onShutdown) {
|
||||||
|
try {
|
||||||
|
await opts.onShutdown();
|
||||||
|
} catch (err) {
|
||||||
|
log.error("Error during shutdown hook: %o", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
// Force exit if connections don't drain in time.
|
// Force exit if connections don't drain in time.
|
||||||
setTimeout(() => process.exit(0), 5_000).unref();
|
setTimeout(() => process.exit(0), 5_000).unref();
|
||||||
};
|
};
|
||||||
|
|
||||||
process.once("SIGINT", () => shutdown("SIGINT"));
|
process.once("SIGINT", () => void shutdown("SIGINT"));
|
||||||
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
process.once("SIGTERM", () => void shutdown("SIGTERM"));
|
||||||
|
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-3
@@ -18,6 +18,11 @@ export interface DevServerOptions {
|
|||||||
publicDir: string;
|
publicDir: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AppModule {
|
||||||
|
default: RequestListener;
|
||||||
|
dispose?: () => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
export function headplaneDevServer(options: DevServerOptions): Plugin {
|
export function headplaneDevServer(options: DevServerOptions): Plugin {
|
||||||
return {
|
return {
|
||||||
name: "headplane:dev-server",
|
name: "headplane:dev-server",
|
||||||
@@ -30,6 +35,12 @@ export function headplaneDevServer(options: DevServerOptions): Plugin {
|
|||||||
res.end("Server entry not loaded yet");
|
res.end("Server entry not loaded yet");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Track the last-loaded module identity so we can call its
|
||||||
|
// `dispose` when Vite swaps in a new one on HMR. Without this
|
||||||
|
// the LiveStore and other long-lived services leak intervals
|
||||||
|
// and subprocesses on every reload.
|
||||||
|
let currentModule: AppModule | null = null;
|
||||||
|
|
||||||
const composed = composeListener({
|
const composed = composeListener({
|
||||||
basename: options.basename,
|
basename: options.basename,
|
||||||
staticRoot: options.publicDir,
|
staticRoot: options.publicDir,
|
||||||
@@ -43,9 +54,15 @@ export function headplaneDevServer(options: DevServerOptions): Plugin {
|
|||||||
return () => {
|
return () => {
|
||||||
server.middlewares.use(async (req, res, next) => {
|
server.middlewares.use(async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const mod = (await server.ssrLoadModule(options.entry)) as {
|
const mod = (await server.ssrLoadModule(options.entry)) as AppModule;
|
||||||
default: RequestListener;
|
if (currentModule && currentModule !== mod) {
|
||||||
};
|
try {
|
||||||
|
await currentModule.dispose?.();
|
||||||
|
} catch (err) {
|
||||||
|
server.config.logger.warn(`[headplane:dev-server] dispose failed: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentModule = mod;
|
||||||
appListener = mod.default;
|
appListener = mod.default;
|
||||||
composed(req, res);
|
composed(req, res);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user