feat: update to the v8 middleware api

This commit is contained in:
Aarnav Tale
2026-06-20 12:38:09 -04:00
parent 3f9dcd5eb4
commit 5d6eef5843
30 changed files with 467 additions and 239 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ import { createReadableStreamFromReadable } from "@react-router/node";
import { isbot } from "isbot"; import { isbot } from "isbot";
import type { RenderToPipeableStreamOptions } from "react-dom/server"; import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server"; import { renderToPipeableStream } from "react-dom/server";
import type { AppLoadContext, EntryContext } from "react-router"; import type { EntryContext, RouterContextProvider } from "react-router";
import { ServerRouter } from "react-router"; import { ServerRouter } from "react-router";
import log from "~/utils/log"; import log from "~/utils/log";
@@ -15,7 +15,7 @@ export default function handleRequest(
responseStatusCode: number, responseStatusCode: number,
responseHeaders: Headers, responseHeaders: Headers,
routerContext: EntryContext, routerContext: EntryContext,
_loadContext: AppLoadContext, _loadContext: RouterContextProvider,
) { ) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let shellRendered = false; let shellRendered = false;
+30 -15
View File
@@ -2,6 +2,14 @@ import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router";
import { ErrorBanner } from "~/components/error-banner"; import { ErrorBanner } from "~/components/error-banner";
import StatusBanner from "~/components/status-banner"; import StatusBanner from "~/components/status-banner";
import {
appConfigContext,
authContext,
headscaleConfigContext,
headscaleContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client"; import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
import { usersResource } from "~/server/headscale/live-store"; import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth"; import { isUserPrincipal } from "~/server/web/auth";
@@ -31,8 +39,15 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
}; };
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const getRequestApi = context.get(requestApiContext);
const headscale = context.get(headscaleContext);
const headscaleConfig = context.get(headscaleConfigContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
try { try {
const { principal, api } = await context.apiForRequest(request); const { principal, api } = await getRequestApi(request);
const user = isUserPrincipal(principal) const user = isUserPrincipal(principal)
? { ? {
@@ -45,7 +60,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
: { name: principal.displayName, subject: "api_key" }; : { name: principal.displayName, subject: "api_key" };
// MARK: The session should stay valid if Headscale isn't healthy // MARK: The session should stay valid if Headscale isn't healthy
const isHealthy = await context.headscale.health(); const isHealthy = await headscale.health();
if (isHealthy) { if (isHealthy) {
try { try {
await api.apiKeys.list(); await api.apiKeys.list();
@@ -57,7 +72,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
log.warn("auth", "Logging out %s due to expired API key", displayName); log.warn("auth", "Logging out %s due to expired API key", displayName);
return redirect("/login", { return redirect("/login", {
headers: { headers: {
"Set-Cookie": await context.auth.destroySession(request), "Set-Cookie": await auth.destroySession(request),
}, },
}); });
} }
@@ -67,9 +82,9 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// stale link so the user gets prompted to re-link. // stale link so the user gets prompted to re-link.
if (isUserPrincipal(principal) && principal.user.headscaleUserId) { if (isUserPrincipal(principal) && principal.user.headscaleUserId) {
try { try {
const usersSnap = await context.hsLive.get(usersResource, api); const usersSnap = await headscaleLiveStore.get(usersResource, api);
if (!usersSnap.data.some((u) => u.id === principal.user.headscaleUserId)) { if (!usersSnap.data.some((u) => u.id === principal.user.headscaleUserId)) {
await context.auth.unlinkHeadscaleUser(principal.user.id); await auth.unlinkHeadscaleUser(principal.user.id);
} }
} catch { } catch {
// API call failed, skip validation // API call failed, skip validation
@@ -79,23 +94,23 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return { return {
access: { access: {
dns: context.auth.can(principal, Capabilities.read_network), dns: auth.can(principal, Capabilities.read_network),
machines: context.auth.can(principal, Capabilities.read_machines), machines: auth.can(principal, Capabilities.read_machines),
policy: context.auth.can(principal, Capabilities.read_policy), policy: auth.can(principal, Capabilities.read_policy),
settings: context.auth.can(principal, Capabilities.read_feature), settings: auth.can(principal, Capabilities.read_feature),
ui: context.auth.can(principal, Capabilities.ui_access), ui: auth.can(principal, Capabilities.ui_access),
users: context.auth.can(principal, Capabilities.read_users), users: auth.can(principal, Capabilities.read_users),
}, },
baseUrl: context.config.headscale.public_url ?? context.config.headscale.url, baseUrl: config.headscale.public_url ?? config.headscale.url,
configAvailable: context.hs.readable(), configAvailable: headscaleConfig.readable(),
isDebug: context.config.debug, isDebug: config.debug,
isHealthy, isHealthy,
user, user,
}; };
} catch { } catch {
return redirect("/login", { return redirect("/login", {
headers: { headers: {
"Set-Cookie": await context.auth.destroySession(request), "Set-Cookie": await auth.destroySession(request),
}, },
}); });
} }
+7 -3
View File
@@ -1,5 +1,6 @@
import { data } from "react-router"; import { data } from "react-router";
import { authContext, requestApiContext } from "~/server/context";
import { isDataWithApiError } from "~/server/headscale/api/error-client"; import { isDataWithApiError } from "~/server/headscale/api/error-client";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
@@ -9,8 +10,11 @@ import type { Route } from "./+types/overview";
// If it isn't, it'll gracefully error anyways, since this means some // If it isn't, it'll gracefully error anyways, since this means some
// fishy client manipulation is happening. // fishy client manipulation is happening.
export async function aclAction({ request, context }: Route.ActionArgs) { export async function aclAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request); const auth = context.get(authContext);
const check = context.auth.can(principal, Capabilities.write_policy); const getRequestApi = context.get(requestApiContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.write_policy);
if (!check) { if (!check) {
throw data("You do not have permission to write to the ACL policy", { throw data("You do not have permission to write to the ACL policy", {
status: 403, status: 403,
@@ -26,7 +30,7 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
}); });
} }
const { api } = await context.apiForRequest(request); const { api } = await getRequestApi(request);
try { try {
const { policy, updatedAt } = await api.policy.set(policyData); const { policy, updatedAt } = await api.policy.set(policyData);
return data({ return data({
+9 -5
View File
@@ -1,5 +1,6 @@
import { data } from "react-router"; import { data } from "react-router";
import { authContext, requestApiContext } from "~/server/context";
import { isDataWithApiError } from "~/server/headscale/api/error-client"; import { isDataWithApiError } from "~/server/headscale/api/error-client";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
@@ -11,10 +12,13 @@ import type { Route } from "./+types/overview";
// 2. Does the user have permission to write to the policy? // 2. Does the user have permission to write to the policy?
// 3. Is the Headscale policy in file or database mode? // 3. Is the Headscale policy in file or database mode?
// If database, we can read/write easily via the API. // If database, we can read/write easily via the API.
// If in file mode, we can only write if context.config is available. // If in file mode, we can only write if the Headscale config is available.
export async function aclLoader({ request, context }: Route.LoaderArgs) { export async function aclLoader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request); const auth = context.get(authContext);
const check = context.auth.can(principal, Capabilities.read_policy); const getRequestApi = context.get(requestApiContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.read_policy);
if (!check) { if (!check) {
throw data("You do not have permission to read the ACL policy.", { throw data("You do not have permission to read the ACL policy.", {
status: 403, status: 403,
@@ -23,13 +27,13 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
const flags = { const flags = {
// Can the user write to the ACL policy // Can the user write to the ACL policy
access: context.auth.can(principal, Capabilities.write_policy), access: auth.can(principal, Capabilities.write_policy),
writable: false, writable: false,
policy: "", policy: "",
}; };
// Try to load the ACL policy from the API. // Try to load the ACL policy from the API.
const { api } = await context.apiForRequest(request); const { api } = await getRequestApi(request);
try { try {
const { policy, updatedAt } = await api.policy.get(); const { policy, updatedAt } = await api.policy.get();
flags.writable = updatedAt !== null; flags.writable = updatedAt !== null;
+6 -2
View File
@@ -1,11 +1,15 @@
import { redirect } from "react-router"; import { redirect } from "react-router";
import { authContext, headscaleContext } from "~/server/context";
import { isDataWithApiError } from "~/server/headscale/api/error-client"; import { isDataWithApiError } from "~/server/headscale/api/error-client";
import log from "~/utils/log"; import log from "~/utils/log";
import type { Route } from "./+types/page"; import type { Route } from "./+types/page";
export async function loginAction({ request, context }: Route.LoaderArgs) { export async function loginAction({ request, context }: Route.LoaderArgs) {
const auth = context.get(authContext);
const headscale = context.get(headscaleContext);
const formData = await request.formData(); const formData = await request.formData();
const apiKey = formData.has("api_key") ? String(formData.get("api_key")) : undefined; const apiKey = formData.has("api_key") ? String(formData.get("api_key")) : undefined;
@@ -35,7 +39,7 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
// Build a client with the candidate API key the user just submitted, so the // Build a client with the candidate API key the user just submitted, so the
// GET /api/v1/apikey call below validates the key against Headscale itself. // GET /api/v1/apikey call below validates the key against Headscale itself.
const api = context.headscale.client(apiKey); const api = headscale.client(apiKey);
try { try {
const apiKeys = await api.apiKeys.list(); const apiKeys = await api.apiKeys.list();
@@ -70,7 +74,7 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
return redirect("/machines", { return redirect("/machines", {
headers: { headers: {
"Set-Cookie": await context.auth.createApiKeySession( "Set-Cookie": await auth.createApiKeySession(
apiKey, apiKey,
`${lookup.prefix}...`, `${lookup.prefix}...`,
expiry.getTime() - Date.now(), expiry.getTime() - Date.now(),
+9 -4
View File
@@ -7,6 +7,7 @@ import Card from "~/components/card";
import Code from "~/components/code"; import Code from "~/components/code";
import Input from "~/components/input"; import Input from "~/components/input";
import Link from "~/components/link"; import Link from "~/components/link";
import { appConfigContext, authContext, oidcContext } from "~/server/context";
import { useLiveData } from "~/utils/live-data"; import { useLiveData } from "~/utils/live-data";
import type { Route } from "./+types/page"; import type { Route } from "./+types/page";
@@ -16,15 +17,19 @@ import Logout from "./logout";
import { OidcErrorNotice } from "./oidc-error"; import { OidcErrorNotice } from "./oidc-error";
export async function loader({ request, context, url }: Route.LoaderArgs) { export async function loader({ request, context, url }: Route.LoaderArgs) {
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const oidc = context.get(oidcContext);
try { try {
await context.auth.require(request); await auth.require(request);
return redirect("/machines"); return redirect("/machines");
} catch {} } catch {}
const qp = url.searchParams; const qp = url.searchParams;
const urlState = qp.get("s") ?? undefined; const urlState = qp.get("s") ?? undefined;
const oidcService = context.oidc.state === "enabled" ? context.oidc.value : undefined; const oidcService = oidc.state === "enabled" ? 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()),
@@ -34,7 +39,7 @@ export async function loader({ request, context, url }: Route.LoaderArgs) {
if ( if (
oidcService && oidcService &&
context.config.oidc?.disable_api_key_login && config.oidc?.disable_api_key_login &&
oidcStatus?.state === "ready" && oidcStatus?.state === "ready" &&
urlState !== "logout" urlState !== "logout"
) { ) {
@@ -45,7 +50,7 @@ export async function loader({ request, context, url }: Route.LoaderArgs) {
const oidcErrorCodes = oidcStatus?.state === "error" ? [oidcStatus.error.code] : []; const oidcErrorCodes = oidcStatus?.state === "error" ? [oidcStatus.error.code] : [];
return { return {
isCookieSecureEnabled: context.config.server.cookie_secure, isCookieSecureEnabled: config.server.cookie_secure,
isOidcConnectorEnabled, isOidcConnectorEnabled,
oidcErrorCodes, oidcErrorCodes,
urlState, urlState,
+12 -12
View File
@@ -1,34 +1,34 @@
import { type ActionFunctionArgs, redirect } from "react-router"; import { type ActionFunctionArgs, redirect } from "react-router";
import type { AppContext } from "~/server/context"; import { appConfigContext, authContext, oidcContext } from "~/server/context";
export async function loader() { export async function loader() {
return redirect("/machines"); return redirect("/machines");
} }
export async function action({ request, context }: ActionFunctionArgs<AppContext>) { export async function action({ request, context }: ActionFunctionArgs) {
let principal: Awaited<ReturnType<typeof context.auth.require>> | undefined; const auth = context.get(authContext);
const config = context.get(appConfigContext);
const oidc = context.get(oidcContext);
let principal: Awaited<ReturnType<typeof auth.require>> | undefined;
try { try {
principal = await context.auth.require(request); principal = await auth.require(request);
} catch { } catch {
return redirect("/login"); return redirect("/login");
} }
// When API key is disabled, we need to explicitly redirect // When API key is disabled, we need to explicitly redirect
// with a logout state to prevent auto login again. // with a logout state to prevent auto login again.
let url = context.config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login"; let url = config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login";
// For OIDC sessions, redirect to the provider's RP-initiated logout // For OIDC sessions, redirect to the provider's RP-initiated logout
// endpoint when explicitly enabled, so the upstream IdP session is also // endpoint when explicitly enabled, so the upstream IdP session is also
// 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 ( if (principal?.kind === "oidc" && oidc.state === "enabled" && config.oidc?.use_end_session) {
principal?.kind === "oidc" && const service = oidc.value;
context.oidc.state === "enabled" &&
context.config.oidc?.use_end_session
) {
const service = context.oidc.value;
const status = service.status(); 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
@@ -44,7 +44,7 @@ export async function action({ request, context }: ActionFunctionArgs<AppContext
return redirect(url, { return redirect(url, {
headers: { headers: {
"Set-Cookie": await context.auth.destroySession(request), "Set-Cookie": await auth.destroySession(request),
}, },
}); });
} }
+23 -10
View File
@@ -1,5 +1,12 @@
import { data, redirect } from "react-router"; import { data, redirect } from "react-router";
import {
appConfigContext,
authContext,
headscaleApiKeyContext,
headscaleContext,
oidcContext,
} from "~/server/context";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity"; import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import { Roles } from "~/server/web/roles"; import { Roles } from "~/server/web/roles";
import log from "~/utils/log"; import log from "~/utils/log";
@@ -8,16 +15,22 @@ 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, url }: Route.LoaderArgs) { export async function loader({ request, context, url }: Route.LoaderArgs) {
if (context.oidc.state !== "enabled") { const auth = context.get(authContext);
throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 }); const config = context.get(appConfigContext);
const headscale = context.get(headscaleContext);
const headscaleApiKey = context.get(headscaleApiKeyContext);
const oidc = context.get(oidcContext);
if (oidc.state !== "enabled") {
throw data(`OIDC is unavailable: ${oidc.reason}`, { status: 501 });
} }
const service = context.oidc.value; const service = oidc.value;
if (url.searchParams.toString().length === 0) { if (url.searchParams.toString().length === 0) {
return redirect("/login?s=error_no_query"); return redirect("/login?s=error_no_query");
} }
const cookie = createOidcStateCookie(context.config); const cookie = createOidcStateCookie(config);
const oidcCookieState = await cookie.parse(request.headers.get("Cookie")); const oidcCookieState = await cookie.parse(request.headers.get("Cookie"));
if (oidcCookieState == null) { if (oidcCookieState == null) {
@@ -53,7 +66,7 @@ export async function loader({ request, context, url }: Route.LoaderArgs) {
? identity.role ? identity.role
: undefined; : undefined;
const userId = await context.auth.findOrCreateUser( const userId = await auth.findOrCreateUser(
identity.subject, identity.subject,
{ {
name: identity.name, name: identity.name,
@@ -61,7 +74,7 @@ export async function loader({ request, context, url }: Route.LoaderArgs) {
picture: identity.picture, picture: identity.picture,
}, },
{ {
initialRole: claimedRole ?? context.config.oidc?.default_role, initialRole: claimedRole ?? config.oidc?.default_role,
}, },
); );
@@ -69,11 +82,11 @@ export async function loader({ request, context, url }: Route.LoaderArgs) {
// Looks up the Headscale user that matches this OIDC identity. We use // Looks up the Headscale user that matches this OIDC identity. We use
// the configured admin API key here — not a per-request one — because // the configured admin API key here — not a per-request one — because
// there is no per-request key yet (the session is being created). // there is no per-request key yet (the session is being created).
const hsApi = context.headscale.client(context.headscaleApiKey!); const hsApi = headscale.client(headscaleApiKey!);
const hsUsers = await hsApi.users.list(); const hsUsers = await hsApi.users.list();
const hsUser = findHeadscaleUserBySubject(hsUsers, identity.subject, identity.email); const hsUser = findHeadscaleUserBySubject(hsUsers, identity.subject, identity.email);
if (hsUser) { if (hsUser) {
await context.auth.linkHeadscaleUser(userId, hsUser.id); await auth.linkHeadscaleUser(userId, hsUser.id);
} }
} catch (error) { } catch (error) {
log.warn("auth", "Failed to link Headscale user: %s", String(error)); log.warn("auth", "Failed to link Headscale user: %s", String(error));
@@ -81,11 +94,11 @@ export async function loader({ request, context, url }: 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.config.oidc?.use_end_session ? identity.idToken : undefined; const idToken = config.oidc?.use_end_session ? identity.idToken : undefined;
return redirect("/", { return redirect("/", {
headers: { headers: {
"Set-Cookie": await context.auth.createOidcSession( "Set-Cookie": await auth.createOidcSession(
userId, userId,
{ {
name: identity.name, name: identity.name,
+10 -5
View File
@@ -1,19 +1,24 @@
import { data, redirect } from "react-router"; import { data, redirect } from "react-router";
import { appConfigContext, authContext, oidcContext } from "~/server/context";
import { createOidcStateCookie } from "~/utils/oidc-state"; import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-start"; import type { Route } from "./+types/oidc-start";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const oidc = context.get(oidcContext);
try { try {
await context.auth.require(request); await auth.require(request);
return redirect("/"); return redirect("/");
} catch {} } catch {}
if (context.oidc.state !== "enabled") { if (oidc.state !== "enabled") {
throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 }); throw data(`OIDC is unavailable: ${oidc.reason}`, { status: 501 });
} }
const service = context.oidc.value; const service = oidc.value;
const result = await service.startFlow(); const result = await service.startFlow();
if (!result.ok) { if (!result.ok) {
@@ -21,7 +26,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
} }
const { url, flowState } = result.value; const { url, flowState } = result.value;
const cookie = createOidcStateCookie(context.config); const cookie = createOidcStateCookie(config);
return redirect(url, { return redirect(url, {
status: 302, status: 302,
+38 -27
View File
@@ -1,18 +1,29 @@
import { data } from "react-router"; import { data } from "react-router";
import {
authContext,
headscaleConfigContext,
headscaleContext,
integrationContext,
} from "~/server/context";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview"; import type { Route } from "./+types/overview";
export async function dnsAction({ request, context }: Route.ActionArgs) { export async function dnsAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request); const auth = context.get(authContext);
const check = context.auth.can(principal, Capabilities.write_network); const headscale = context.get(headscaleContext);
const headscaleConfig = context.get(headscaleConfigContext);
const integration = context.get(integrationContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.write_network);
if (!check) { if (!check) {
return data({ success: false }, 403); return data({ success: false }, 403);
} }
if (!context.hs.writable()) { if (!headscaleConfig.writable()) {
return data({ success: false }, 403); return data({ success: false }, 403);
} }
@@ -29,14 +40,14 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return data({ success: false }, 400); return data({ success: false }, 400);
} }
await context.hs.patch([ await headscaleConfig.patch([
{ {
path: "dns.base_domain", path: "dns.base_domain",
value: newName, value: newName,
}, },
]); ]);
await context.integration?.onConfigChange(context.headscale); await integration?.onConfigChange(headscale);
return { message: "Tailnet renamed successfully" }; return { message: "Tailnet renamed successfully" };
} }
case "toggle_magic": { case "toggle_magic": {
@@ -45,18 +56,18 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return data({ success: false }, 400); return data({ success: false }, 400);
} }
await context.hs.patch([ await headscaleConfig.patch([
{ {
path: "dns.magic_dns", path: "dns.magic_dns",
value: newState === "enabled", value: newState === "enabled",
}, },
]); ]);
await context.integration?.onConfigChange(context.headscale); await integration?.onConfigChange(headscale);
return { message: "Magic DNS state updated successfully" }; return { message: "Magic DNS state updated successfully" };
} }
case "remove_ns": { case "remove_ns": {
const config = context.hs.c!; const config = headscaleConfig.c!;
const ns = formData.get("ns")?.toString(); const ns = formData.get("ns")?.toString();
const splitName = formData.get("split_name")?.toString(); const splitName = formData.get("split_name")?.toString();
@@ -67,7 +78,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
if (splitName === "global") { if (splitName === "global") {
const servers = config.dns.nameservers.global.filter((i) => i !== ns); const servers = config.dns.nameservers.global.filter((i) => i !== ns);
await context.hs.patch([ await headscaleConfig.patch([
{ {
path: "dns.nameservers.global", path: "dns.nameservers.global",
value: servers, value: servers,
@@ -77,7 +88,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
const splits = config.dns.nameservers.split; const splits = config.dns.nameservers.split;
const servers = splits[splitName].filter((i) => i !== ns); const servers = splits[splitName].filter((i) => i !== ns);
await context.hs.patch([ await headscaleConfig.patch([
{ {
path: `dns.nameservers.split."${splitName}"`, path: `dns.nameservers.split."${splitName}"`,
value: servers.length > 0 ? servers : null, value: servers.length > 0 ? servers : null,
@@ -85,11 +96,11 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
]); ]);
} }
await context.integration?.onConfigChange(context.headscale); await integration?.onConfigChange(headscale);
return { message: "Nameserver removed successfully" }; return { message: "Nameserver removed successfully" };
} }
case "add_ns": { case "add_ns": {
const config = context.hs.c!; const config = headscaleConfig.c!;
const ns = formData.get("ns")?.toString(); const ns = formData.get("ns")?.toString();
const splitName = formData.get("split_name")?.toString(); const splitName = formData.get("split_name")?.toString();
@@ -101,7 +112,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
const servers = config.dns.nameservers.global; const servers = config.dns.nameservers.global;
servers.push(ns); servers.push(ns);
await context.hs.patch([ await headscaleConfig.patch([
{ {
path: "dns.nameservers.global", path: "dns.nameservers.global",
value: servers, value: servers,
@@ -112,7 +123,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
const servers = splits[splitName] ?? []; const servers = splits[splitName] ?? [];
servers.push(ns); servers.push(ns);
await context.hs.patch([ await headscaleConfig.patch([
{ {
path: `dns.nameservers.split."${splitName}"`, path: `dns.nameservers.split."${splitName}"`,
value: servers, value: servers,
@@ -120,29 +131,29 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
]); ]);
} }
await context.integration?.onConfigChange(context.headscale); await integration?.onConfigChange(headscale);
return { message: "Nameserver added successfully" }; return { message: "Nameserver added successfully" };
} }
case "remove_domain": { case "remove_domain": {
const config = context.hs.c!; const config = headscaleConfig.c!;
const domain = formData.get("domain")?.toString(); const domain = formData.get("domain")?.toString();
if (!domain) { if (!domain) {
return data({ success: false }, 400); return data({ success: false }, 400);
} }
const domains = config.dns.search_domains.filter((i) => i !== domain); const domains = config.dns.search_domains.filter((i) => i !== domain);
await context.hs.patch([ await headscaleConfig.patch([
{ {
path: "dns.search_domains", path: "dns.search_domains",
value: domains, value: domains,
}, },
]); ]);
await context.integration?.onConfigChange(context.headscale); await integration?.onConfigChange(headscale);
return { message: "Domain removed successfully" }; return { message: "Domain removed successfully" };
} }
case "add_domain": { case "add_domain": {
const config = context.hs.c!; const config = headscaleConfig.c!;
const domain = formData.get("domain")?.toString(); const domain = formData.get("domain")?.toString();
if (!domain) { if (!domain) {
return data({ success: false }, 400); return data({ success: false }, 400);
@@ -151,14 +162,14 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
const domains = config.dns.search_domains; const domains = config.dns.search_domains;
domains.push(domain); domains.push(domain);
await context.hs.patch([ await headscaleConfig.patch([
{ {
path: "dns.search_domains", path: "dns.search_domains",
value: domains, value: domains,
}, },
]); ]);
await context.integration?.onConfigChange(context.headscale); await integration?.onConfigChange(headscale);
return { message: "Domain added successfully" }; return { message: "Domain added successfully" };
} }
case "remove_record": { case "remove_record": {
@@ -170,7 +181,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
} }
// Value is not needed for removal // Value is not needed for removal
const restart = await context.hs.removeDNS({ const restart = await headscaleConfig.removeDNS({
name: recordName, name: recordName,
type: recordType, type: recordType,
value: "", value: "",
@@ -180,7 +191,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return; return;
} }
await context.integration?.onConfigChange(context.headscale); await integration?.onConfigChange(headscale);
return { message: "DNS record removed successfully" }; return { message: "DNS record removed successfully" };
} }
case "add_record": { case "add_record": {
@@ -192,7 +203,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return data({ success: false }, 400); return data({ success: false }, 400);
} }
const restart = await context.hs.addDNS({ const restart = await headscaleConfig.addDNS({
name: recordName, name: recordName,
type: recordType, type: recordType,
value: recordValue, value: recordValue,
@@ -202,7 +213,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return; return;
} }
await context.integration?.onConfigChange(context.headscale); await integration?.onConfigChange(headscale);
return { message: "DNS record added successfully" }; return { message: "DNS record added successfully" };
} }
case "override_dns": { case "override_dns": {
@@ -212,14 +223,14 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
} }
const overrideValue = override === "true"; const overrideValue = override === "true";
await context.hs.patch([ await headscaleConfig.patch([
{ {
path: "dns.override_local_dns", path: "dns.override_local_dns",
value: overrideValue, value: overrideValue,
}, },
]); ]);
await context.integration?.onConfigChange(context.headscale); await integration?.onConfigChange(headscale);
return { message: "DNS override updated successfully" }; return { message: "DNS override updated successfully" };
} }
default: default:
+14 -10
View File
@@ -1,12 +1,13 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
import { useLoaderData } from "react-router"; import { useLoaderData } from "react-router";
import Code from "~/components/code"; import Code from "~/components/code";
import Notice from "~/components/notice"; import Notice from "~/components/notice";
import PageError from "~/components/page-error"; import PageError from "~/components/page-error";
import type { AppContext } from "~/server/context"; import { authContext, headscaleConfigContext } from "~/server/context";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
import ManageDomains from "./components/manage-domains"; import ManageDomains from "./components/manage-domains";
import ManageNS from "./components/manage-ns"; import ManageNS from "./components/manage-ns";
import ManageRecords from "./components/manage-records"; import ManageRecords from "./components/manage-records";
@@ -15,13 +16,16 @@ import ToggleMagic from "./components/toggle-magic";
import { dnsAction } from "./dns-actions"; import { dnsAction } from "./dns-actions";
// We do not want to expose every config value // We do not want to expose every config value
export async function loader({ request, context }: LoaderFunctionArgs<AppContext>) { export async function loader({ request, context }: Route.LoaderArgs) {
if (!context.hs.readable()) { const auth = context.get(authContext);
const headscaleConfig = context.get(headscaleConfigContext);
if (!headscaleConfig.readable()) {
throw new Error("No configuration is available"); throw new Error("No configuration is available");
} }
const principal = await context.auth.require(request); const principal = await auth.require(request);
const check = context.auth.can(principal, Capabilities.read_network); const check = auth.can(principal, Capabilities.read_network);
if (!check) { if (!check) {
// Not authorized to view this page // Not authorized to view this page
throw new Error( throw new Error(
@@ -29,9 +33,9 @@ export async function loader({ request, context }: LoaderFunctionArgs<AppContext
); );
} }
const writablePermission = context.auth.can(principal, Capabilities.write_network); const writablePermission = auth.can(principal, Capabilities.write_network);
const config = context.hs.c!; const config = headscaleConfig.c!;
const dns = { const dns = {
prefixes: config.prefixes, prefixes: config.prefixes,
magicDns: config.dns.magic_dns, magicDns: config.dns.magic_dns,
@@ -40,13 +44,13 @@ export async function loader({ request, context }: LoaderFunctionArgs<AppContext
splitDns: config.dns.nameservers.split, splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains, searchDomains: config.dns.search_domains,
overrideDns: config.dns.override_local_dns, overrideDns: config.dns.override_local_dns,
extraRecords: context.hs.d, extraRecords: headscaleConfig.d,
}; };
return { return {
...dns, ...dns,
access: writablePermission, access: writablePermission,
writable: context.hs.writable(), writable: headscaleConfig.writable(),
}; };
} }
+23 -10
View File
@@ -10,6 +10,12 @@ import Card from "~/components/card";
import CodeBlock from "~/components/code-block"; import CodeBlock from "~/components/code-block";
import Link from "~/components/link"; import Link from "~/components/link";
import LinkAccount from "~/layout/link-account"; import LinkAccount from "~/layout/link-account";
import {
authContext,
headscaleConfigContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { usersResource } from "~/server/headscale/live-store"; import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth"; import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
@@ -19,19 +25,24 @@ import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/home"; import type { Route } from "./+types/home";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request); const auth = context.get(authContext);
const getRequestApi = context.get(requestApiContext);
const headscaleConfig = context.get(headscaleConfigContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
const principal = await auth.require(request);
// If the signed-in Headplane user has no linked Headscale user, // If the signed-in Headplane user has no linked Headscale user,
// check for unclaimed users they can pick from before anything else. // check for unclaimed users they can pick from before anything else.
let unlinked = false; let unlinked = false;
if (isUserPrincipal(principal) && !principal.user.headscaleUserId) { if (isUserPrincipal(principal) && !principal.user.headscaleUserId) {
const { api } = await context.apiForRequest(request); const { api } = await getRequestApi(request);
let headscaleUsers: { id: string; name: string }[] = []; let headscaleUsers: { id: string; name: string }[] = [];
try { try {
const [usersSnap, claimed] = await Promise.all([ const [usersSnap, claimed] = await Promise.all([
context.hsLive.get(usersResource, api), headscaleLiveStore.get(usersResource, api),
context.auth.claimedHeadscaleUserIds(), auth.claimedHeadscaleUserIds(),
]); ]);
const apiUsers = usersSnap.data; const apiUsers = usersSnap.data;
@@ -50,22 +61,22 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// Only warn if Headscale isn't using OIDC — if it is, the user // Only warn if Headscale isn't using OIDC — if it is, the user
// Just needs to connect a device and Headscale will auto-create // Just needs to connect a device and Headscale will auto-create
// Their account, at which point auto-link will pick it up. // Their account, at which point auto-link will pick it up.
if (!context.hs.c?.oidc) { if (!headscaleConfig.c?.oidc) {
unlinked = true; unlinked = true;
} }
} }
if (context.auth.can(principal, Capabilities.ui_access)) { if (auth.can(principal, Capabilities.ui_access)) {
return redirect("/machines"); return redirect("/machines");
} }
// No UI access — show the download/connect page // No UI access — show the download/connect page
const { api } = await context.apiForRequest(request); const { api } = await getRequestApi(request);
let linkedUserName: string | undefined; let linkedUserName: string | undefined;
if (isUserPrincipal(principal) && principal.user.headscaleUserId) { if (isUserPrincipal(principal) && principal.user.headscaleUserId) {
try { try {
const usersSnap = await context.hsLive.get(usersResource, api); const usersSnap = await headscaleLiveStore.get(usersResource, api);
const hsUser = usersSnap.data.find((u) => u.id === principal.user.headscaleUserId); const hsUser = usersSnap.data.find((u) => u.id === principal.user.headscaleUserId);
linkedUserName = hsUser?.name; linkedUserName = hsUser?.name;
} catch { } catch {
@@ -77,7 +88,9 @@ export async function loader({ request, context }: Route.LoaderArgs) {
} }
export async function action({ request, context }: Route.ActionArgs) { export async function action({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request); const auth = context.get(authContext);
const principal = await auth.require(request);
if (!isUserPrincipal(principal)) { if (!isUserPrincipal(principal)) {
return redirect("/"); return redirect("/");
} }
@@ -86,7 +99,7 @@ export async function action({ request, context }: Route.ActionArgs) {
const headscaleUserId = formData.get("headscale_user_id")?.toString(); const headscaleUserId = formData.get("headscale_user_id")?.toString();
if (headscaleUserId) { if (headscaleUserId) {
await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId); await auth.linkHeadscaleUser(principal.user.id, headscaleUserId);
} }
return redirect("/"); return redirect("/");
+15 -10
View File
@@ -1,5 +1,6 @@
import { data, redirect } from "react-router"; import { data, redirect } from "react-router";
import { authContext, headscaleLiveStoreContext, requestApiContext } from "~/server/context";
import { isDataWithApiError } from "~/server/headscale/api/error-client"; import { isDataWithApiError } from "~/server/headscale/api/error-client";
import { nodesResource } from "~/server/headscale/live-store"; import { nodesResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
@@ -7,7 +8,11 @@ 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, api } = await context.apiForRequest(request); const auth = context.get(authContext);
const getRequestApi = context.get(requestApiContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
const { principal, api } = await getRequestApi(request);
const formData = await request.formData(); const formData = await request.formData();
@@ -20,7 +25,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
// Fast track register since it doesn't require an existing machine // Fast track register since it doesn't require an existing machine
if (action === "register") { if (action === "register") {
if (!context.auth.can(principal, Capabilities.write_machines)) { if (!auth.can(principal, Capabilities.write_machines)) {
throw data("You do not have permission to manage machines", { throw data("You do not have permission to manage machines", {
status: 403, status: 403,
}); });
@@ -41,7 +46,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
} }
const node = await api.nodes.register(user, registrationKey); const node = await api.nodes.register(user, registrationKey);
await context.hsLive.refresh(nodesResource, api); await headscaleLiveStore.refresh(nodesResource, api);
return redirect(`/machines/${node.id}`); return redirect(`/machines/${node.id}`);
} }
@@ -60,7 +65,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}); });
} }
if (!context.auth.canManageNode(principal, node)) { if (!auth.canManageNode(principal, node)) {
throw data("You do not have permission to act on this machine", { throw data("You do not have permission to act on this machine", {
status: 403, status: 403,
}); });
@@ -84,19 +89,19 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
} }
await api.nodes.rename(nodeId, name); await api.nodes.rename(nodeId, name);
await context.hsLive.refresh(nodesResource, api); await headscaleLiveStore.refresh(nodesResource, api);
return { message: "Machine renamed" }; return { message: "Machine renamed" };
} }
case "delete": { case "delete": {
await api.nodes.delete(nodeId); await api.nodes.delete(nodeId);
await context.hsLive.refresh(nodesResource, api); await headscaleLiveStore.refresh(nodesResource, api);
return redirect("/machines"); return redirect("/machines");
} }
case "expire": { case "expire": {
await api.nodes.expire(nodeId); await api.nodes.expire(nodeId);
await context.hsLive.refresh(nodesResource, api); await headscaleLiveStore.refresh(nodesResource, api);
return { message: "Machine expired" }; return { message: "Machine expired" };
} }
@@ -114,7 +119,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
tags.map((tag) => tag.trim()).filter((tag) => tag !== ""), tags.map((tag) => tag.trim()).filter((tag) => tag !== ""),
); );
await context.hsLive.refresh(nodesResource, api); await headscaleLiveStore.refresh(nodesResource, api);
return { success: true as const, message: "Tags updated" }; return { success: true as const, message: "Tags updated" };
} catch (error) { } catch (error) {
if (isDataWithApiError(error) && error.data.statusCode === 400) { if (isDataWithApiError(error) && error.data.statusCode === 400) {
@@ -180,7 +185,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
} }
await api.nodes.approveRoutes(nodeId, newApproved); await api.nodes.approveRoutes(nodeId, newApproved);
await context.hsLive.refresh(nodesResource, api); await headscaleLiveStore.refresh(nodesResource, api);
return { message: "Routes updated" }; return { message: "Routes updated" };
} }
@@ -198,7 +203,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}); });
} }
await api.nodes.reassignUser(nodeId, user); await api.nodes.reassignUser(nodeId, user);
await context.hsLive.refresh(nodesResource, api); await headscaleLiveStore.refresh(nodesResource, api);
return { message: "Machine reassigned" }; return { message: "Machine reassigned" };
} }
+21 -8
View File
@@ -9,6 +9,13 @@ import Chip from "~/components/chip";
import Link from "~/components/link"; import Link from "~/components/link";
import StatusCircle from "~/components/status-circle"; import StatusCircle from "~/components/status-circle";
import Tooltip from "~/components/tooltip"; import Tooltip from "~/components/tooltip";
import {
agentsContext,
headscaleConfigContext,
headscaleContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { nodesResource, usersResource } from "~/server/headscale/live-store"; import { nodesResource, usersResource } from "~/server/headscale/live-store";
import cn from "~/utils/cn"; import cn from "~/utils/cn";
import { getOSInfo, getTSVersion } from "~/utils/host-info"; import { getOSInfo, getTSVersion } from "~/utils/host-info";
@@ -22,6 +29,12 @@ 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 agentsFeature = context.get(agentsContext);
const getRequestApi = context.get(requestApiContext);
const headscale = context.get(headscaleContext);
const headscaleConfig = context.get(headscaleConfigContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
if (!params.id) { if (!params.id) {
throw new Error("No machine ID provided"); throw new Error("No machine ID provided");
} }
@@ -31,16 +44,16 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
} }
let magic: string | undefined; let magic: string | undefined;
if (context.hs.readable()) { if (headscaleConfig.readable()) {
if (context.hs.c?.dns.magic_dns) { if (headscaleConfig.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain; magic = headscaleConfig.c.dns.base_domain;
} }
} }
const { api } = await context.apiForRequest(request); const { api } = await getRequestApi(request);
const [nodesSnap, usersSnap] = await Promise.all([ const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api), headscaleLiveStore.get(nodesResource, api),
context.hsLive.get(usersResource, api), headscaleLiveStore.get(usersResource, api),
]); ]);
const nodes = nodesSnap.data; const nodes = nodesSnap.data;
const users = usersSnap.data; const users = usersSnap.data;
@@ -49,7 +62,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
throw data(null, { status: 404 }); throw data(null, { status: 404 });
} }
const agents = context.agents.state === "enabled" ? context.agents.value : undefined; const agents = agentsFeature.state === "enabled" ? agentsFeature.value : undefined;
const [lookup, policyResult] = await Promise.allSettled([ const [lookup, policyResult] = await Promise.allSettled([
agents?.lookup([node.nodeKey]), agents?.lookup([node.nodeKey]),
api.policy.get(), api.policy.get(),
@@ -57,7 +70,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
const stats = lookup.status === "fulfilled" ? lookup.value : undefined; const stats = lookup.status === "fulfilled" ? lookup.value : undefined;
const [enhancedNode] = mapNodes([node], stats); const [enhancedNode] = mapNodes([node], stats);
const tags = [...node.tags].toSorted(); const tags = [...node.tags].toSorted();
const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable; const supportsNodeOwnerChange = !headscale.capabilities.nodeOwnerIsImmutable;
const agentSync = agents?.lastSync(); const agentSync = agents?.lastSync();
const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined; const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined;
+31 -14
View File
@@ -7,6 +7,15 @@ import Input from "~/components/input";
import Link from "~/components/link"; import Link from "~/components/link";
import PageError from "~/components/page-error"; import PageError from "~/components/page-error";
import Tooltip from "~/components/tooltip"; import Tooltip from "~/components/tooltip";
import {
agentsContext,
appConfigContext,
authContext,
headscaleConfigContext,
headscaleContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { nodesResource, usersResource } from "~/server/headscale/live-store"; import { nodesResource, usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth"; import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
@@ -21,32 +30,40 @@ import { useMachineFilterParams } from "./hooks/use-machine-filter-params";
import { machineAction } from "./machine-actions"; import { machineAction } from "./machine-actions";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request); const agentsFeature = context.get(agentsContext);
const auth = context.get(authContext);
const config = context.get(appConfigContext);
const getRequestApi = context.get(requestApiContext);
const headscale = context.get(headscaleContext);
const headscaleConfig = context.get(headscaleConfigContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
if (!context.auth.can(principal, Capabilities.read_machines)) { const principal = await auth.require(request);
if (!auth.can(principal, Capabilities.read_machines)) {
throw new Error( throw new Error(
"You do not have permission to view this page. Please contact your administrator.", "You do not have permission to view this page. Please contact your administrator.",
); );
} }
const writablePermission = context.auth.can(principal, Capabilities.write_machines); const writablePermission = auth.can(principal, Capabilities.write_machines);
const { api } = await context.apiForRequest(request); const { api } = await getRequestApi(request);
const [nodesSnap, usersSnap] = await Promise.all([ const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api), headscaleLiveStore.get(nodesResource, api),
context.hsLive.get(usersResource, api), headscaleLiveStore.get(usersResource, api),
]); ]);
const nodes = nodesSnap.data; const nodes = nodesSnap.data;
const users = usersSnap.data; const users = usersSnap.data;
let magic: string | undefined; let magic: string | undefined;
if (context.hs.readable()) { if (headscaleConfig.readable()) {
if (context.hs.c?.dns.magic_dns) { if (headscaleConfig.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain; magic = headscaleConfig.c.dns.base_domain;
} }
} }
const agents = context.agents.state === "enabled" ? context.agents.value : undefined; const agents = agentsFeature.state === "enabled" ? agentsFeature.value : undefined;
const [statsResult, policyResult] = await Promise.allSettled([ const [statsResult, policyResult] = await Promise.allSettled([
agents?.lookup(nodes.map((node) => node.nodeKey)), agents?.lookup(nodes.map((node) => node.nodeKey)),
api.policy.get(), api.policy.get(),
@@ -54,7 +71,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const stats = statsResult.status === "fulfilled" ? statsResult.value : undefined; const stats = statsResult.status === "fulfilled" ? statsResult.value : undefined;
const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined; const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined;
const populatedNodes = mapNodes(nodes, stats); const populatedNodes = mapNodes(nodes, stats);
const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable; const supportsNodeOwnerChange = !headscale.capabilities.nodeOwnerIsImmutable;
const agentSync = agents?.lastSync(); const agentSync = agents?.lastSync();
return { return {
@@ -70,9 +87,9 @@ export async function loader({ request, context }: Route.LoaderArgs) {
magic, magic,
nodes, nodes,
populatedNodes, populatedNodes,
preAuth: context.auth.can(principal, Capabilities.generate_authkeys), preAuth: auth.can(principal, Capabilities.generate_authkeys),
publicServer: context.config.headscale.public_url, publicServer: config.headscale.public_url,
server: context.config.headscale.url, server: config.headscale.url,
supportsNodeOwnerChange: supportsNodeOwnerChange, supportsNodeOwnerChange: supportsNodeOwnerChange,
users, users,
writable: writablePermission, writable: writablePermission,
+16 -9
View File
@@ -6,18 +6,22 @@ import Notice from "~/components/notice";
import StatusCircle from "~/components/status-circle"; import StatusCircle from "~/components/status-circle";
import Text from "~/components/text"; import Text from "~/components/text";
import Title from "~/components/title"; import Title from "~/components/title";
import { agentsContext, authContext } from "~/server/context";
import { formatTimeDelta } from "~/utils/time"; 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) {
await context.auth.require(request); const agents = context.get(agentsContext);
const auth = context.get(authContext);
if (context.agents.state !== "enabled") { await auth.require(request);
return { enabled: false as const, reason: context.agents.reason };
if (agents.state !== "enabled") {
return { enabled: false as const, reason: agents.reason };
} }
const sync = context.agents.value.lastSync(); const sync = agents.value.lastSync();
return { return {
enabled: true as const, enabled: true as const,
syncedAt: sync.syncedAt?.toISOString() ?? null, syncedAt: sync.syncedAt?.toISOString() ?? null,
@@ -27,14 +31,17 @@ 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); const agents = context.get(agentsContext);
const auth = context.get(authContext);
if (context.agents.state !== "enabled") { await auth.require(request);
return { success: false, error: context.agents.reason };
if (agents.state !== "enabled") {
return { success: false, error: agents.reason };
} }
await context.agents.value.triggerSync(); await agents.value.triggerSync();
const sync = context.agents.value.lastSync(); const sync = agents.value.lastSync();
return { success: !sync.error, error: sync.error }; return { success: !sync.error, error: sync.error };
} }
+7 -3
View File
@@ -1,5 +1,6 @@
import { data } from "react-router"; import { data } from "react-router";
import { authContext, requestApiContext } from "~/server/context";
import { isUserPrincipal } from "~/server/web/auth"; import { isUserPrincipal } from "~/server/web/auth";
import { getOidcSubject } from "~/server/web/headscale-identity"; import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
@@ -8,10 +9,13 @@ 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, api } = await context.apiForRequest(request); const auth = context.get(authContext);
const getRequestApi = context.get(requestApiContext);
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys); const { principal, api } = await getRequestApi(request);
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
const canGenerateAny = auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = auth.can(principal, Capabilities.generate_own_authkeys);
if (!canGenerateAny && !canGenerateOwn) { if (!canGenerateAny && !canGenerateOwn) {
throw data("You do not have permission to manage pre-auth keys", { throw data("You do not have permission to manage pre-auth keys", {
+16 -5
View File
@@ -6,6 +6,12 @@ import Link from "~/components/link";
import Notice from "~/components/notice"; import Notice from "~/components/notice";
import Select from "~/components/select"; import Select from "~/components/select";
import TableList from "~/components/table-list"; import TableList from "~/components/table-list";
import {
appConfigContext,
authContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { usersResource } from "~/server/headscale/live-store"; import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth"; import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
@@ -20,9 +26,14 @@ 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, api } = await context.apiForRequest(request); const auth = context.get(authContext);
const config = context.get(appConfigContext);
const getRequestApi = context.get(requestApiContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
const usersSnap = await context.hsLive.get(usersResource, api); const { principal, api } = await getRequestApi(request);
const usersSnap = await headscaleLiveStore.get(usersResource, api);
const users = usersSnap.data; const users = usersSnap.data;
let keys: { user: User | null; preAuthKeys: PreAuthKey[] }[]; let keys: { user: User | null; preAuthKeys: PreAuthKey[] }[];
@@ -86,8 +97,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
.map(({ user, error }) => ({ error, user })); .map(({ user, error }) => ({ error, user }));
} }
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys); const canGenerateAny = auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys); const canGenerateOwn = auth.can(principal, Capabilities.generate_own_authkeys);
return { return {
access: canGenerateAny || canGenerateOwn, access: canGenerateAny || canGenerateOwn,
@@ -96,7 +107,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
keys, keys,
missing, missing,
selfServiceOnly: !canGenerateAny && canGenerateOwn, selfServiceOnly: !canGenerateAny && canGenerateOwn,
url: context.config.headscale.public_url ?? context.config.headscale.url, url: config.headscale.public_url ?? config.headscale.url,
users, users,
}; };
} }
+6 -3
View File
@@ -2,14 +2,17 @@ import { ArrowRight } from "lucide-react";
import Link from "~/components/link"; import Link from "~/components/link";
import PageError from "~/components/page-error"; import PageError from "~/components/page-error";
import { headscaleConfigContext, oidcContext } from "~/server/context";
import type { Route } from "./+types/overview"; import type { Route } from "./+types/overview";
export async function loader({ context }: Route.LoaderArgs) { export async function loader({ context }: Route.LoaderArgs) {
const headscaleConfig = context.get(headscaleConfigContext);
const oidc = context.get(oidcContext);
return { return {
config: context.hs.writable(), config: headscaleConfig.writable(),
isOidcEnabled: isOidcEnabled: oidc.state === "enabled" && oidc.value.status().state === "ready",
context.oidc.state === "enabled" && context.oidc.value.status().state === "ready",
}; };
} }
+32 -21
View File
@@ -1,12 +1,23 @@
import { data } from "react-router"; import { data } from "react-router";
import {
authContext,
headscaleConfigContext,
headscaleContext,
integrationContext,
} from "~/server/context";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview"; import type { Route } from "./+types/overview";
export async function restrictionAction({ request, context }: Route.ActionArgs) { export async function restrictionAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request); const auth = context.get(authContext);
const check = context.auth.can(principal, Capabilities.configure_iam); const headscale = context.get(headscaleContext);
const headscaleConfig = context.get(headscaleConfigContext);
const integration = context.get(integrationContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.configure_iam);
if (!check) { if (!check) {
throw data("You do not have permission to modify IAM settings.", { throw data("You do not have permission to modify IAM settings.", {
@@ -14,7 +25,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
}); });
} }
if (!context.hs.writable()) { if (!headscaleConfig.writable()) {
throw data("The Headscale configuration file is not editable.", { throw data("The Headscale configuration file is not editable.", {
status: 403, status: 403,
}); });
@@ -37,16 +48,16 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
}); });
} }
const domains = [...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain])]; const domains = [...new Set([...(headscaleConfig.c?.oidc?.allowed_domains ?? []), domain])];
await context.hs.patch([ await headscaleConfig.patch([
{ {
path: "oidc.allowed_domains", path: "oidc.allowed_domains",
value: domains, value: domains,
}, },
]); ]);
context.integration?.onConfigChange(context.headscale); integration?.onConfigChange(headscale);
return data("Domain added successfully."); return data("Domain added successfully.");
} }
@@ -58,7 +69,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
}); });
} }
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? []; const storedDomains = headscaleConfig.c?.oidc?.allowed_domains ?? [];
if (!storedDomains.includes(domain)) { if (!storedDomains.includes(domain)) {
// Domain not found in the list // Domain not found in the list
throw data(`Domain "${domain}" not found in allowed domains.`, { throw data(`Domain "${domain}" not found in allowed domains.`, {
@@ -68,13 +79,13 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
// Filter out the domain to remove it from the list // Filter out the domain to remove it from the list
const domains = storedDomains.filter((d: string) => d !== domain); const domains = storedDomains.filter((d: string) => d !== domain);
await context.hs.patch([ await headscaleConfig.patch([
{ {
path: "oidc.allowed_domains", path: "oidc.allowed_domains",
value: domains, value: domains,
}, },
]); ]);
context.integration?.onConfigChange(context.headscale); integration?.onConfigChange(headscale);
return data("Domain removed successfully."); return data("Domain removed successfully.");
} }
@@ -86,16 +97,16 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
}); });
} }
const groups = [...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group])]; const groups = [...new Set([...(headscaleConfig.c?.oidc?.allowed_groups ?? []), group])];
await context.hs.patch([ await headscaleConfig.patch([
{ {
path: "oidc.allowed_groups", path: "oidc.allowed_groups",
value: groups, value: groups,
}, },
]); ]);
context.integration?.onConfigChange(context.headscale); integration?.onConfigChange(headscale);
return data("Group added successfully."); return data("Group added successfully.");
} }
@@ -107,7 +118,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
}); });
} }
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? []; const storedGroups = headscaleConfig.c?.oidc?.allowed_groups ?? [];
if (!storedGroups.includes(group)) { if (!storedGroups.includes(group)) {
// Group not found in the list // Group not found in the list
throw data(`Group "${group}" not found in allowed groups.`, { throw data(`Group "${group}" not found in allowed groups.`, {
@@ -117,14 +128,14 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
// Filter out the group to remove it from the list // Filter out the group to remove it from the list
const groups = storedGroups.filter((d: string) => d !== group); const groups = storedGroups.filter((d: string) => d !== group);
await context.hs.patch([ await headscaleConfig.patch([
{ {
path: "oidc.allowed_groups", path: "oidc.allowed_groups",
value: groups, value: groups,
}, },
]); ]);
context.integration?.onConfigChange(context.headscale); integration?.onConfigChange(headscale);
return data("Group removed successfully."); return data("Group removed successfully.");
} }
@@ -136,16 +147,16 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
}); });
} }
const users = [...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user])]; const users = [...new Set([...(headscaleConfig.c?.oidc?.allowed_users ?? []), user])];
await context.hs.patch([ await headscaleConfig.patch([
{ {
path: "oidc.allowed_users", path: "oidc.allowed_users",
value: users, value: users,
}, },
]); ]);
context.integration?.onConfigChange(context.headscale); integration?.onConfigChange(headscale);
return data("User added successfully."); return data("User added successfully.");
} }
@@ -157,7 +168,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
}); });
} }
const storedUsers = context.hs.c?.oidc?.allowed_users ?? []; const storedUsers = headscaleConfig.c?.oidc?.allowed_users ?? [];
if (!storedUsers.includes(user)) { if (!storedUsers.includes(user)) {
// User not found in the list // User not found in the list
throw data(`User "${user}" not found in allowed users.`, { throw data(`User "${user}" not found in allowed users.`, {
@@ -167,14 +178,14 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
// Filter out the user to remove it from the list // Filter out the user to remove it from the list
const users = storedUsers.filter((d: string) => d !== user); const users = storedUsers.filter((d: string) => d !== user);
await context.hs.patch([ await headscaleConfig.patch([
{ {
path: "oidc.allowed_users", path: "oidc.allowed_users",
value: users, value: users,
}, },
]); ]);
context.integration?.onConfigChange(context.headscale); integration?.onConfigChange(headscale);
return data("User removed successfully."); return data("User removed successfully.");
} }
+12 -8
View File
@@ -2,6 +2,7 @@ import { data } from "react-router";
import Link from "~/components/link"; import Link from "~/components/link";
import Notice from "~/components/notice"; import Notice from "~/components/notice";
import { authContext, headscaleConfigContext } from "~/server/context";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview"; import type { Route } from "./+types/overview";
@@ -12,28 +13,31 @@ import AddUser from "./dialogs/add-user";
import RestrictionTable from "./table"; import RestrictionTable from "./table";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request); const auth = context.get(authContext);
const check = context.auth.can(principal, Capabilities.read_users); const headscaleConfig = context.get(headscaleConfigContext);
const principal = await auth.require(request);
const check = auth.can(principal, Capabilities.read_users);
if (!check) { if (!check) {
throw data("You do not have permission to view IAM settings.", { throw data("You do not have permission to view IAM settings.", {
status: 403, status: 403,
}); });
} }
if (!context.hs.c?.oidc) { if (!headscaleConfig.c?.oidc) {
throw data("OIDC is not configured on this Headscale instance.", { throw data("OIDC is not configured on this Headscale instance.", {
status: 501, status: 501,
}); });
} }
return { return {
access: context.auth.can(principal, Capabilities.configure_iam), access: auth.can(principal, Capabilities.configure_iam),
settings: { settings: {
domains: [...new Set(context.hs.c.oidc.allowed_domains)], domains: [...new Set(headscaleConfig.c.oidc.allowed_domains)],
groups: [...new Set(context.hs.c.oidc.allowed_groups)], groups: [...new Set(headscaleConfig.c.oidc.allowed_groups)],
users: [...new Set(context.hs.c.oidc.allowed_users)], users: [...new Set(headscaleConfig.c.oidc.allowed_users)],
}, },
writable: context.hs.writable(), writable: headscaleConfig.writable(),
}; };
} }
+8 -3
View File
@@ -5,6 +5,7 @@ import { data, isRouteErrorResponse, type ShouldRevalidateFunction } from "react
import Button from "~/components/button"; import Button from "~/components/button";
import Card from "~/components/card"; import Card from "~/components/card";
import Code from "~/components/code"; import Code from "~/components/code";
import { agentsContext, appConfigContext, requestApiContext } from "~/server/context";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity"; import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import type { Route } from "./+types/page"; import type { Route } from "./+types/page";
@@ -23,6 +24,10 @@ export const shouldRevalidate: ShouldRevalidateFunction = () => {
}; };
export async function loader({ request, params, context, url }: Route.LoaderArgs) { export async function loader({ request, params, context, url }: Route.LoaderArgs) {
const agents = context.get(agentsContext);
const config = context.get(appConfigContext);
const getRequestApi = context.get(requestApiContext);
const origin = url.origin; const origin = url.origin;
const assets = [WASM_HELPER_URL, WASM_MODULE_URL]; const assets = [WASM_HELPER_URL, WASM_MODULE_URL];
const missing: string[] = []; const missing: string[] = [];
@@ -38,11 +43,11 @@ export async function loader({ request, params, context, url }: Route.LoaderArgs
throw data(sshErrors.wasm_missing, 405); throw data(sshErrors.wasm_missing, 405);
} }
if (context.agents.state !== "enabled") { if (agents.state !== "enabled") {
throw data(sshErrors.agent_required, 400); throw data(sshErrors.agent_required, 400);
} }
const { principal, api } = await context.apiForRequest(request); const { principal, api } = await getRequestApi(request);
if (principal.kind === "api_key") { if (principal.kind === "api_key") {
throw data(sshErrors.oidc_required, 403); throw data(sshErrors.oidc_required, 403);
} }
@@ -82,7 +87,7 @@ export async function loader({ request, params, context, url }: Route.LoaderArgs
aclTags: null, aclTags: null,
}); });
const controlURL = context.config.headscale.public_url ?? context.config.headscale.url; const controlURL = config.headscale.public_url ?? config.headscale.url;
return { return {
hostname, hostname,
username, username,
+25 -12
View File
@@ -1,6 +1,13 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import PageError from "~/components/page-error"; import PageError from "~/components/page-error";
import {
appConfigContext,
authContext,
headscaleConfigContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { nodesResource, usersResource } from "~/server/headscale/live-store"; import { nodesResource, usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth"; import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities, Roles } from "~/server/web/roles"; import { Capabilities, Roles } from "~/server/web/roles";
@@ -36,18 +43,24 @@ export interface UnlinkedHeadscaleUser extends User {
} }
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request); const auth = context.get(authContext);
const check = await context.auth.can(principal, Capabilities.read_users); const config = context.get(appConfigContext);
const getRequestApi = context.get(requestApiContext);
const headscaleConfig = context.get(headscaleConfigContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
const principal = await auth.require(request);
const check = await auth.can(principal, Capabilities.read_users);
if (!check) { if (!check) {
throw new Error( throw new Error(
"You do not have permission to view this page. Please contact your administrator.", "You do not have permission to view this page. Please contact your administrator.",
); );
} }
const writablePermission = await context.auth.can(principal, Capabilities.write_users); const writablePermission = await auth.can(principal, Capabilities.write_users);
// Primary data: Headplane users from the database (always available) // Primary data: Headplane users from the database (always available)
const hpUsers = await context.auth.listUsers(); const hpUsers = await auth.listUsers();
// Secondary data: Headscale API (may fail) // Secondary data: Headscale API (may fail)
let apiUsers: User[] = []; let apiUsers: User[] = [];
@@ -55,10 +68,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
let apiError: string | undefined; let apiError: string | undefined;
try { try {
const { api } = await context.apiForRequest(request); const { api } = await getRequestApi(request);
const [nodesSnap, usersSnap] = await Promise.all([ const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api), headscaleLiveStore.get(nodesResource, api),
context.hsLive.get(usersResource, api), headscaleLiveStore.get(usersResource, api),
]); ]);
nodes = nodesSnap.data; nodes = nodesSnap.data;
apiUsers = usersSnap.data; apiUsers = usersSnap.data;
@@ -68,7 +81,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
"Could not connect to the Headscale API. Headscale user data and machine information are unavailable."; "Could not connect to the Headscale API. Headscale user data and machine information are unavailable.";
} }
const useGravatar = context.config.oidc?.profile_picture_source === "gravatar"; const useGravatar = config.oidc?.profile_picture_source === "gravatar";
function resolveProfilePic(email?: string, profilePicUrl?: string): string | undefined { function resolveProfilePic(email?: string, profilePicUrl?: string): string | undefined {
if (!useGravatar) return profilePicUrl; if (!useGravatar) return profilePicUrl;
@@ -126,9 +139,9 @@ export async function loader({ request, context }: Route.LoaderArgs) {
})); }));
let magic: string | undefined; let magic: string | undefined;
if (context.hs.readable()) { if (headscaleConfig.readable()) {
if (context.hs.c?.dns.magic_dns) { if (headscaleConfig.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain; magic = headscaleConfig.c.dns.base_domain;
} }
} }
@@ -138,7 +151,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
writable: writablePermission, writable: writablePermission,
currentUserId: isUserPrincipal(principal) ? principal.user.id : undefined, currentUserId: isUserPrincipal(principal) ? principal.user.id : undefined,
isOwner, isOwner,
oidc: context.config.oidc ? { issuer: context.config.oidc.issuer } : undefined, oidc: config.oidc ? { issuer: config.oidc.issuer } : undefined,
magic, magic,
apiError, apiError,
headplaneUsers, headplaneUsers,
+14 -9
View File
@@ -1,5 +1,6 @@
import { data } from "react-router"; import { data } from "react-router";
import { authContext, headscaleLiveStoreContext, requestApiContext } from "~/server/context";
import { usersResource } from "~/server/headscale/live-store"; import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth"; import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
@@ -8,8 +9,12 @@ import type { Role } from "~/server/web/roles";
import type { Route } from "./+types/overview"; import type { Route } from "./+types/overview";
export async function userAction({ request, context }: Route.ActionArgs) { export async function userAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request); const auth = context.get(authContext);
const check = await context.auth.can(principal, Capabilities.write_users); const getRequestApi = context.get(requestApiContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
const principal = await auth.require(request);
const check = await auth.can(principal, Capabilities.write_users);
if (!check) { if (!check) {
throw data("You do not have permission to update users", { throw data("You do not have permission to update users", {
status: 403, status: 403,
@@ -24,7 +29,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
}); });
} }
const { api } = await context.apiForRequest(request); const { api } = await getRequestApi(request);
switch (action) { switch (action) {
case "create_user": { case "create_user": {
const name = formData.get("username")?.toString(); const name = formData.get("username")?.toString();
@@ -38,7 +43,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
} }
await api.users.create({ name, email, displayName }); await api.users.create({ name, email, displayName });
await context.hsLive.refresh(usersResource, api); await headscaleLiveStore.refresh(usersResource, api);
return { message: "User created successfully" }; return { message: "User created successfully" };
} }
case "delete_user": { case "delete_user": {
@@ -50,7 +55,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
} }
await api.users.delete(headscaleUserId); await api.users.delete(headscaleUserId);
await context.hsLive.refresh(usersResource, api); await headscaleLiveStore.refresh(usersResource, api);
return { message: "User deleted successfully" }; return { message: "User deleted successfully" };
} }
case "rename_user": { case "rename_user": {
@@ -74,7 +79,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
} }
await api.users.rename(headscaleUserId, newName); await api.users.rename(headscaleUserId, newName);
await context.hsLive.refresh(usersResource, api); await headscaleLiveStore.refresh(usersResource, api);
return { message: "User renamed successfully" }; return { message: "User renamed successfully" };
} }
case "reassign_user": { case "reassign_user": {
@@ -86,7 +91,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
}); });
} }
const result = await context.auth.reassignUser(headplaneUserId, newRole as Role); const result = await auth.reassignUser(headplaneUserId, newRole as Role);
if (!result) { if (!result) {
throw data("Failed to reassign user role.", { status: 500 }); throw data("Failed to reassign user role.", { status: 500 });
} }
@@ -103,7 +108,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
throw data("Missing `headplane_user_id` in the form data.", { status: 400 }); throw data("Missing `headplane_user_id` in the form data.", { status: 400 });
} }
const result = await context.auth.transferOwnership(principal.user.id, headplaneUserId); const result = await auth.transferOwnership(principal.user.id, headplaneUserId);
if (!result) { if (!result) {
throw data("Failed to transfer ownership.", { status: 500 }); throw data("Failed to transfer ownership.", { status: 500 });
} }
@@ -119,7 +124,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
}); });
} }
const linked = await context.auth.linkHeadscaleUser(headplaneUserId, headscaleUserId); const linked = await auth.linkHeadscaleUser(headplaneUserId, headscaleUserId);
if (!linked) { if (!linked) {
throw data("That Headscale user is already linked to another account.", { status: 409 }); throw data("That Headscale user is already linked to another account.", { status: 409 });
} }
+5 -1
View File
@@ -1,7 +1,11 @@
import { headscaleContext } from "~/server/context";
import type { Route } from "./+types/healthz"; import type { Route } from "./+types/healthz";
export async function loader({ context }: Route.LoaderArgs) { export async function loader({ context }: Route.LoaderArgs) {
const healthy = await context.headscale.health(); const headscale = context.get(headscaleContext);
const healthy = await headscale.health();
return new Response(JSON.stringify({ status: healthy ? "OK" : "ERROR" }), { return new Response(JSON.stringify({ status: healthy ? "OK" : "ERROR" }), {
status: healthy ? 200 : 500, status: healthy ? 200 : 500,
+9 -4
View File
@@ -2,10 +2,15 @@ import { versions } from "node:process";
import { data } from "react-router"; import { data } from "react-router";
import { appConfigContext, headscaleContext } from "~/server/context";
import type { Route } from "./+types/info"; import type { Route } from "./+types/info";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
if (context.config.server.info_secret == null) { const config = context.get(appConfigContext);
const headscale = context.get(headscaleContext);
if (config.server.info_secret == null) {
throw data( throw data(
{ {
status: "Forbidden", status: "Forbidden",
@@ -25,7 +30,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
} }
const token = bearer.slice("Bearer ".length).trim(); const token = bearer.slice("Bearer ".length).trim();
if (token !== context.config.server.info_secret) { if (token !== config.server.info_secret) {
throw data( throw data(
{ {
status: "Forbidden", status: "Forbidden",
@@ -34,12 +39,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
); );
} }
const healthy = await context.headscale.health(); const healthy = await headscale.health();
const body = { const body = {
status: healthy ? "healthy" : "unhealthy", status: healthy ? "healthy" : "unhealthy",
headplane_version: __VERSION__, headplane_version: __VERSION__,
headscale_canonical_version: healthy ? context.headscale.version.raw : "unknown", headscale_canonical_version: healthy ? headscale.version.raw : "unknown",
internal_versions: { internal_versions: {
node: versions.node, node: versions.node,
v8: versions.v8, v8: versions.v8,
+9 -5
View File
@@ -1,15 +1,19 @@
import { headscaleLiveStoreContext, requestApiContext } from "~/server/context";
import { nodesResource, usersResource } from "~/server/headscale/live-store"; import { nodesResource, usersResource } from "~/server/headscale/live-store";
import log from "~/utils/log"; 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 { api } = await context.apiForRequest(request); const getRequestApi = context.get(requestApiContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
const { api } = await getRequestApi(request);
// Ensure resources are loaded before streaming // Ensure resources are loaded before streaming
await Promise.all([ await Promise.all([
context.hsLive.get(nodesResource, api), headscaleLiveStore.get(nodesResource, api),
context.hsLive.get(usersResource, api), headscaleLiveStore.get(usersResource, api),
]); ]);
const stream = new ReadableStream({ const stream = new ReadableStream({
@@ -25,11 +29,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
} }
}; };
const versions = context.hsLive.getVersions(); const versions = headscaleLiveStore.getVersions();
log.debug("sse", "Client connected, sending hello with versions: %o", versions); log.debug("sse", "Client connected, sending hello with versions: %o", versions);
send("hello", versions); send("hello", versions);
const unsubscribe = context.hsLive.subscribe((resource, version) => { const unsubscribe = headscaleLiveStore.subscribe((resource, version) => {
log.debug("sse", "Sending change event: %s v%s", resource, version); log.debug("sse", "Sending change event: %s v%s", resource, version);
send("changed", { resource, version }); send("changed", { resource, version });
}); });
+16 -13
View File
@@ -9,7 +9,7 @@ runs only on the Node process — never in the browser.
app/server/ app/server/
├── app.ts ← The Headplane application (load context, RR listener) ├── app.ts ← The Headplane application (load context, RR listener)
├── main.ts ← Production bootstrap (binds an http(s) server) ├── main.ts ← Production bootstrap (binds an http(s) server)
├── context.ts ← createAppContext() — assembles the AppLoadContext ├── context.ts ← createAppContext() — assembles the RouterContextProvider data
├── result.ts ← Result<T, E> helper used across the server modules ├── result.ts ← Result<T, E> helper used across the server modules
├── config/ ← YAML config loading, schema, env-overrides, integrations ├── config/ ← YAML config loading, schema, env-overrides, integrations
@@ -26,9 +26,10 @@ There are two SSR entries; both are picked up by Vite via `vite.config.ts`.
### `app.ts` — the application module ### `app.ts` — the application module
Loads config → builds the `AppLoadContext` (via [`context.ts`](./context.ts)) Loads config → builds the application context (via [`context.ts`](./context.ts))
exports the React Router `RequestListener` as `default`, plus the seeds React Router's `RouterContextProvider` with the named service contexts
resolved `config` as a named export. → exports the React Router `RequestListener` as `default`, plus the resolved
`config` as a named export.
This module has no opinions about how the server is hosted. It does not This module has no opinions about how the server is hosted. It does not
listen on a socket, doesn't compose static-asset serving, and doesn't listen on a socket, doesn't compose static-asset serving, and doesn't
@@ -71,17 +72,19 @@ process:
- the (best-effort) parsed Headscale config (`hs`) - the (best-effort) parsed Headscale config (`hs`)
- the integration adapter (`integration`) - the integration adapter (`integration`)
The returned object is the `AppLoadContext` exposed to every React The returned object owns process-lifetime services, but route handlers consume
Router loader/action. The module also `declare module "react-router" { interface AppLoadContext extends AppContext {} }` those services through named React Router contexts such as `authContext`,
so route handlers get full type inference on `context`. `headscaleContext`, and `headscaleConfigContext`:
When a route needs the type, import it from `~/server/context`: When a route needs a service, import the matching context from
`~/server/context`:
```ts ```ts
import type { AppContext } from "~/server/context"; import { authContext } from "~/server/context";
export async function loader({ context }: LoaderFunctionArgs<AppContext>) { export async function loader({ context, request }: Route.LoaderArgs) {
// … const auth = context.get(authContext);
const principal = await auth.require(request);
} }
``` ```
@@ -105,7 +108,7 @@ file is loaded, not by runtime conditionals.
that names a coherent concern, e.g. `metrics/`, `ratelimit/`). that names a coherent concern, e.g. `metrics/`, `ratelimit/`).
2. If it owns process-lifetime state (a connection pool, a service 2. If it owns process-lifetime state (a connection pool, a service
client, …), construct it in [`context.ts`](./context.ts) and add it client, …), construct it in [`context.ts`](./context.ts) and add it
to the returned object — this gives every route automatic access via to the returned object. Expose it through a named React Router context
`context.<name>`. and seed that context in [`app.ts`](./app.ts)'s `getLoadContext`.
3. If it's purely a helper (pure functions, type definitions), import 3. If it's purely a helper (pure functions, type definitions), import
it directly from the module that needs it. it directly from the module that needs it.
+29 -2
View File
@@ -12,6 +12,7 @@
import { exit, versions } from "node:process"; import { exit, versions } from "node:process";
import { createRequestListener } from "@react-router/node"; import { createRequestListener } from "@react-router/node";
import { RouterContextProvider } from "react-router";
import * as build from "virtual:react-router/server-build"; import * as build from "virtual:react-router/server-build";
import log from "~/utils/log"; import log from "~/utils/log";
@@ -19,7 +20,20 @@ import log from "~/utils/log";
import type { HeadplaneConfig } from "./config/config-schema"; import type { HeadplaneConfig } from "./config/config-schema";
import { ConfigError } from "./config/error"; import { ConfigError } from "./config/error";
import { loadConfig } from "./config/load"; import { loadConfig } from "./config/load";
import { createAppContext } from "./context"; import {
agentsContext,
appConfigContext,
authContext,
createAppContext,
dbContext,
headscaleApiKeyContext,
headscaleConfigContext,
headscaleContext,
headscaleLiveStoreContext,
integrationContext,
oidcContext,
requestApiContext,
} from "./context";
log.info("server", "Running Node.js %s", versions.node); log.info("server", "Running Node.js %s", versions.node);
@@ -65,6 +79,19 @@ export default createRequestListener({
mode: import.meta.env.MODE, mode: import.meta.env.MODE,
getLoadContext: (request, client) => { getLoadContext: (request, client) => {
ctx.auth.registerRequestClientAddress(request, client.address); ctx.auth.registerRequestClientAddress(request, client.address);
return ctx;
const routerContext = new RouterContextProvider();
routerContext.set(agentsContext, ctx.agents);
routerContext.set(appConfigContext, ctx.config);
routerContext.set(authContext, ctx.auth);
routerContext.set(dbContext, ctx.db);
routerContext.set(headscaleContext, ctx.headscale);
routerContext.set(headscaleApiKeyContext, ctx.headscaleApiKey);
routerContext.set(headscaleConfigContext, ctx.hs);
routerContext.set(headscaleLiveStoreContext, ctx.hsLive);
routerContext.set(integrationContext, ctx.integration);
routerContext.set(oidcContext, ctx.oidc);
routerContext.set(requestApiContext, ctx.apiForRequest);
return routerContext;
}, },
}); });
+13 -4
View File
@@ -1,5 +1,7 @@
import { join } from "node:path"; import { join } from "node:path";
import { createContext } from "react-router";
import log from "~/utils/log"; import log from "~/utils/log";
import type { HeadplaneConfig } from "./config/config-schema"; import type { HeadplaneConfig } from "./config/config-schema";
@@ -14,10 +16,17 @@ import { createOidcService, type OidcService } from "./oidc/provider";
import { createAuthService, type Principal } from "./web/auth"; import { createAuthService, type Principal } from "./web/auth";
export type AppContext = Awaited<ReturnType<typeof createAppContext>>; export type AppContext = Awaited<ReturnType<typeof createAppContext>>;
export const agentsContext = createContext<AppContext["agents"]>();
declare module "react-router" { export const appConfigContext = createContext<AppContext["config"]>();
interface AppLoadContext extends AppContext {} export const authContext = createContext<AppContext["auth"]>();
} export const dbContext = createContext<AppContext["db"]>();
export const headscaleContext = createContext<AppContext["headscale"]>();
export const headscaleApiKeyContext = createContext<AppContext["headscaleApiKey"]>();
export const headscaleConfigContext = createContext<AppContext["hs"]>();
export const headscaleLiveStoreContext = createContext<AppContext["hsLive"]>();
export const integrationContext = createContext<AppContext["integration"]>();
export const oidcContext = createContext<AppContext["oidc"]>();
export const requestApiContext = createContext<AppContext["apiForRequest"]>();
export async function createAppContext(config: HeadplaneConfig) { export async function createAppContext(config: HeadplaneConfig) {
const db = await createDbClient(join(config.server.data_path, "hp_persist.db")); const db = await createDbClient(join(config.server.data_path, "hp_persist.db"));