diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 0125eda..74889c7 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -4,7 +4,7 @@ import { createReadableStreamFromReadable } from "@react-router/node"; import { isbot } from "isbot"; import type { RenderToPipeableStreamOptions } 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 log from "~/utils/log"; @@ -15,7 +15,7 @@ export default function handleRequest( responseStatusCode: number, responseHeaders: Headers, routerContext: EntryContext, - _loadContext: AppLoadContext, + _loadContext: RouterContextProvider, ) { return new Promise((resolve, reject) => { let shellRendered = false; diff --git a/app/layout/app.tsx b/app/layout/app.tsx index cb6e027..299e9cd 100644 --- a/app/layout/app.tsx +++ b/app/layout/app.tsx @@ -2,6 +2,14 @@ import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router"; import { ErrorBanner } from "~/components/error-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 { usersResource } from "~/server/headscale/live-store"; import { isUserPrincipal } from "~/server/web/auth"; @@ -31,8 +39,15 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({ }; 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 { - const { principal, api } = await context.apiForRequest(request); + const { principal, api } = await getRequestApi(request); const user = isUserPrincipal(principal) ? { @@ -45,7 +60,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { : { name: principal.displayName, subject: "api_key" }; // MARK: The session should stay valid if Headscale isn't healthy - const isHealthy = await context.headscale.health(); + const isHealthy = await headscale.health(); if (isHealthy) { try { 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); return redirect("/login", { 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. if (isUserPrincipal(principal) && principal.user.headscaleUserId) { 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)) { - await context.auth.unlinkHeadscaleUser(principal.user.id); + await auth.unlinkHeadscaleUser(principal.user.id); } } catch { // API call failed, skip validation @@ -79,23 +94,23 @@ export async function loader({ request, context }: Route.LoaderArgs) { return { access: { - dns: context.auth.can(principal, Capabilities.read_network), - machines: context.auth.can(principal, Capabilities.read_machines), - policy: context.auth.can(principal, Capabilities.read_policy), - settings: context.auth.can(principal, Capabilities.read_feature), - ui: context.auth.can(principal, Capabilities.ui_access), - users: context.auth.can(principal, Capabilities.read_users), + dns: auth.can(principal, Capabilities.read_network), + machines: auth.can(principal, Capabilities.read_machines), + policy: auth.can(principal, Capabilities.read_policy), + settings: auth.can(principal, Capabilities.read_feature), + ui: auth.can(principal, Capabilities.ui_access), + users: auth.can(principal, Capabilities.read_users), }, - baseUrl: context.config.headscale.public_url ?? context.config.headscale.url, - configAvailable: context.hs.readable(), - isDebug: context.config.debug, + baseUrl: config.headscale.public_url ?? config.headscale.url, + configAvailable: headscaleConfig.readable(), + isDebug: config.debug, isHealthy, user, }; } catch { return redirect("/login", { headers: { - "Set-Cookie": await context.auth.destroySession(request), + "Set-Cookie": await auth.destroySession(request), }, }); } diff --git a/app/routes/acls/acl-action.ts b/app/routes/acls/acl-action.ts index 7bb1e2c..66b4068 100644 --- a/app/routes/acls/acl-action.ts +++ b/app/routes/acls/acl-action.ts @@ -1,5 +1,6 @@ import { data } from "react-router"; +import { authContext, requestApiContext } from "~/server/context"; import { isDataWithApiError } from "~/server/headscale/api/error-client"; 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 // fishy client manipulation is happening. export async function aclAction({ request, context }: Route.ActionArgs) { - const principal = await context.auth.require(request); - const check = context.auth.can(principal, Capabilities.write_policy); + const auth = context.get(authContext); + const getRequestApi = context.get(requestApiContext); + + const principal = await auth.require(request); + const check = auth.can(principal, Capabilities.write_policy); if (!check) { throw data("You do not have permission to write to the ACL policy", { 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 { const { policy, updatedAt } = await api.policy.set(policyData); return data({ diff --git a/app/routes/acls/acl-loader.ts b/app/routes/acls/acl-loader.ts index 12eb9d7..63d4b85 100644 --- a/app/routes/acls/acl-loader.ts +++ b/app/routes/acls/acl-loader.ts @@ -1,5 +1,6 @@ import { data } from "react-router"; +import { authContext, requestApiContext } from "~/server/context"; import { isDataWithApiError } from "~/server/headscale/api/error-client"; 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? // 3. Is the Headscale policy in file or database mode? // 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) { - const principal = await context.auth.require(request); - const check = context.auth.can(principal, Capabilities.read_policy); + const auth = context.get(authContext); + const getRequestApi = context.get(requestApiContext); + + const principal = await auth.require(request); + const check = auth.can(principal, Capabilities.read_policy); if (!check) { throw data("You do not have permission to read the ACL policy.", { status: 403, @@ -23,13 +27,13 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) { const flags = { // 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, policy: "", }; // Try to load the ACL policy from the API. - const { api } = await context.apiForRequest(request); + const { api } = await getRequestApi(request); try { const { policy, updatedAt } = await api.policy.get(); flags.writable = updatedAt !== null; diff --git a/app/routes/auth/login/action.ts b/app/routes/auth/login/action.ts index b86fbd2..b2a6606 100644 --- a/app/routes/auth/login/action.ts +++ b/app/routes/auth/login/action.ts @@ -1,11 +1,15 @@ import { redirect } from "react-router"; +import { authContext, headscaleContext } from "~/server/context"; import { isDataWithApiError } from "~/server/headscale/api/error-client"; import log from "~/utils/log"; import type { Route } from "./+types/page"; export async function loginAction({ request, context }: Route.LoaderArgs) { + const auth = context.get(authContext); + const headscale = context.get(headscaleContext); + const formData = await request.formData(); 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 // GET /api/v1/apikey call below validates the key against Headscale itself. - const api = context.headscale.client(apiKey); + const api = headscale.client(apiKey); try { const apiKeys = await api.apiKeys.list(); @@ -70,7 +74,7 @@ export async function loginAction({ request, context }: Route.LoaderArgs) { return redirect("/machines", { headers: { - "Set-Cookie": await context.auth.createApiKeySession( + "Set-Cookie": await auth.createApiKeySession( apiKey, `${lookup.prefix}...`, expiry.getTime() - Date.now(), diff --git a/app/routes/auth/login/page.tsx b/app/routes/auth/login/page.tsx index feba919..c414413 100644 --- a/app/routes/auth/login/page.tsx +++ b/app/routes/auth/login/page.tsx @@ -7,6 +7,7 @@ import Card from "~/components/card"; import Code from "~/components/code"; import Input from "~/components/input"; import Link from "~/components/link"; +import { appConfigContext, authContext, oidcContext } from "~/server/context"; import { useLiveData } from "~/utils/live-data"; import type { Route } from "./+types/page"; @@ -16,15 +17,19 @@ import Logout from "./logout"; import { OidcErrorNotice } from "./oidc-error"; 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 { - await context.auth.require(request); + await auth.require(request); return redirect("/machines"); } catch {} const qp = url.searchParams; 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 ? await oidcService.discover().then( (r) => (r.ok ? oidcService.status() : oidcService.status()), @@ -34,7 +39,7 @@ export async function loader({ request, context, url }: Route.LoaderArgs) { if ( oidcService && - context.config.oidc?.disable_api_key_login && + config.oidc?.disable_api_key_login && oidcStatus?.state === "ready" && urlState !== "logout" ) { @@ -45,7 +50,7 @@ export async function loader({ request, context, url }: Route.LoaderArgs) { const oidcErrorCodes = oidcStatus?.state === "error" ? [oidcStatus.error.code] : []; return { - isCookieSecureEnabled: context.config.server.cookie_secure, + isCookieSecureEnabled: config.server.cookie_secure, isOidcConnectorEnabled, oidcErrorCodes, urlState, diff --git a/app/routes/auth/logout.ts b/app/routes/auth/logout.ts index 8151bb3..cc5f7ba 100644 --- a/app/routes/auth/logout.ts +++ b/app/routes/auth/logout.ts @@ -1,34 +1,34 @@ import { type ActionFunctionArgs, redirect } from "react-router"; -import type { AppContext } from "~/server/context"; +import { appConfigContext, authContext, oidcContext } from "~/server/context"; export async function loader() { return redirect("/machines"); } -export async function action({ request, context }: ActionFunctionArgs) { - let principal: Awaited> | undefined; +export async function action({ request, context }: ActionFunctionArgs) { + const auth = context.get(authContext); + const config = context.get(appConfigContext); + const oidc = context.get(oidcContext); + + let principal: Awaited> | undefined; try { - principal = await context.auth.require(request); + principal = await auth.require(request); } catch { return redirect("/login"); } // When API key is disabled, we need to explicitly redirect // 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 // endpoint when explicitly enabled, so the upstream IdP session is also // ended. Disabled by default because the post_logout_redirect_uri must be // pre-registered on the IdP — turning this on without registering it would // strand users on the IdP's error page. - if ( - principal?.kind === "oidc" && - context.oidc.state === "enabled" && - context.config.oidc?.use_end_session - ) { - const service = context.oidc.value; + if (principal?.kind === "oidc" && oidc.state === "enabled" && config.oidc?.use_end_session) { + const service = oidc.value; const status = service.status(); if (status.state !== "ready") { // Trigger discovery if it hasn't happened yet so we can find the @@ -44,7 +44,7 @@ export async function action({ request, context }: ActionFunctionArgs i !== ns); - await context.hs.patch([ + await headscaleConfig.patch([ { path: "dns.nameservers.global", value: servers, @@ -77,7 +88,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { const splits = config.dns.nameservers.split; const servers = splits[splitName].filter((i) => i !== ns); - await context.hs.patch([ + await headscaleConfig.patch([ { path: `dns.nameservers.split."${splitName}"`, 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" }; } case "add_ns": { - const config = context.hs.c!; + const config = headscaleConfig.c!; const ns = formData.get("ns")?.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; servers.push(ns); - await context.hs.patch([ + await headscaleConfig.patch([ { path: "dns.nameservers.global", value: servers, @@ -112,7 +123,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { const servers = splits[splitName] ?? []; servers.push(ns); - await context.hs.patch([ + await headscaleConfig.patch([ { path: `dns.nameservers.split."${splitName}"`, 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" }; } case "remove_domain": { - const config = context.hs.c!; + const config = headscaleConfig.c!; const domain = formData.get("domain")?.toString(); if (!domain) { return data({ success: false }, 400); } const domains = config.dns.search_domains.filter((i) => i !== domain); - await context.hs.patch([ + await headscaleConfig.patch([ { path: "dns.search_domains", value: domains, }, ]); - await context.integration?.onConfigChange(context.headscale); + await integration?.onConfigChange(headscale); return { message: "Domain removed successfully" }; } case "add_domain": { - const config = context.hs.c!; + const config = headscaleConfig.c!; const domain = formData.get("domain")?.toString(); if (!domain) { return data({ success: false }, 400); @@ -151,14 +162,14 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { const domains = config.dns.search_domains; domains.push(domain); - await context.hs.patch([ + await headscaleConfig.patch([ { path: "dns.search_domains", value: domains, }, ]); - await context.integration?.onConfigChange(context.headscale); + await integration?.onConfigChange(headscale); return { message: "Domain added successfully" }; } case "remove_record": { @@ -170,7 +181,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { } // Value is not needed for removal - const restart = await context.hs.removeDNS({ + const restart = await headscaleConfig.removeDNS({ name: recordName, type: recordType, value: "", @@ -180,7 +191,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { return; } - await context.integration?.onConfigChange(context.headscale); + await integration?.onConfigChange(headscale); return { message: "DNS record removed successfully" }; } case "add_record": { @@ -192,7 +203,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { return data({ success: false }, 400); } - const restart = await context.hs.addDNS({ + const restart = await headscaleConfig.addDNS({ name: recordName, type: recordType, value: recordValue, @@ -202,7 +213,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { return; } - await context.integration?.onConfigChange(context.headscale); + await integration?.onConfigChange(headscale); return { message: "DNS record added successfully" }; } case "override_dns": { @@ -212,14 +223,14 @@ export async function dnsAction({ request, context }: Route.ActionArgs) { } const overrideValue = override === "true"; - await context.hs.patch([ + await headscaleConfig.patch([ { path: "dns.override_local_dns", value: overrideValue, }, ]); - await context.integration?.onConfigChange(context.headscale); + await integration?.onConfigChange(headscale); return { message: "DNS override updated successfully" }; } default: diff --git a/app/routes/dns/overview.tsx b/app/routes/dns/overview.tsx index 2e2fbf7..183f29a 100644 --- a/app/routes/dns/overview.tsx +++ b/app/routes/dns/overview.tsx @@ -1,12 +1,13 @@ -import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import type { ActionFunctionArgs } from "react-router"; import { useLoaderData } from "react-router"; import Code from "~/components/code"; import Notice from "~/components/notice"; 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 type { Route } from "./+types/overview"; import ManageDomains from "./components/manage-domains"; import ManageNS from "./components/manage-ns"; import ManageRecords from "./components/manage-records"; @@ -15,13 +16,16 @@ import ToggleMagic from "./components/toggle-magic"; import { dnsAction } from "./dns-actions"; // We do not want to expose every config value -export async function loader({ request, context }: LoaderFunctionArgs) { - if (!context.hs.readable()) { +export async function loader({ request, context }: Route.LoaderArgs) { + const auth = context.get(authContext); + const headscaleConfig = context.get(headscaleConfigContext); + + if (!headscaleConfig.readable()) { throw new Error("No configuration is available"); } - const principal = await context.auth.require(request); - const check = context.auth.can(principal, Capabilities.read_network); + const principal = await auth.require(request); + const check = auth.can(principal, Capabilities.read_network); if (!check) { // Not authorized to view this page throw new Error( @@ -29,9 +33,9 @@ export async function loader({ request, context }: LoaderFunctionArgs u.id === principal.user.headscaleUserId); linkedUserName = hsUser?.name; } catch { @@ -77,7 +88,9 @@ export async function loader({ request, context }: Route.LoaderArgs) { } 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)) { return redirect("/"); } @@ -86,7 +99,7 @@ export async function action({ request, context }: Route.ActionArgs) { const headscaleUserId = formData.get("headscale_user_id")?.toString(); if (headscaleUserId) { - await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId); + await auth.linkHeadscaleUser(principal.user.id, headscaleUserId); } return redirect("/"); diff --git a/app/routes/machines/machine-actions.ts b/app/routes/machines/machine-actions.ts index 624adc8..8d15ff2 100644 --- a/app/routes/machines/machine-actions.ts +++ b/app/routes/machines/machine-actions.ts @@ -1,5 +1,6 @@ import { data, redirect } from "react-router"; +import { authContext, headscaleLiveStoreContext, requestApiContext } from "~/server/context"; import { isDataWithApiError } from "~/server/headscale/api/error-client"; import { nodesResource } from "~/server/headscale/live-store"; import { Capabilities } from "~/server/web/roles"; @@ -7,7 +8,11 @@ import { Capabilities } from "~/server/web/roles"; import type { Route } from "./+types/machine"; 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(); @@ -20,7 +25,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) { // Fast track register since it doesn't require an existing machine 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", { status: 403, }); @@ -41,7 +46,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) { } const node = await api.nodes.register(user, registrationKey); - await context.hsLive.refresh(nodesResource, api); + await headscaleLiveStore.refresh(nodesResource, api); 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", { status: 403, }); @@ -84,19 +89,19 @@ export async function machineAction({ request, context }: Route.ActionArgs) { } await api.nodes.rename(nodeId, name); - await context.hsLive.refresh(nodesResource, api); + await headscaleLiveStore.refresh(nodesResource, api); return { message: "Machine renamed" }; } case "delete": { await api.nodes.delete(nodeId); - await context.hsLive.refresh(nodesResource, api); + await headscaleLiveStore.refresh(nodesResource, api); return redirect("/machines"); } case "expire": { await api.nodes.expire(nodeId); - await context.hsLive.refresh(nodesResource, api); + await headscaleLiveStore.refresh(nodesResource, api); return { message: "Machine expired" }; } @@ -114,7 +119,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) { 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" }; } catch (error) { 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 context.hsLive.refresh(nodesResource, api); + await headscaleLiveStore.refresh(nodesResource, api); return { message: "Routes updated" }; } @@ -198,7 +203,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) { }); } await api.nodes.reassignUser(nodeId, user); - await context.hsLive.refresh(nodesResource, api); + await headscaleLiveStore.refresh(nodesResource, api); return { message: "Machine reassigned" }; } diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index fbbfaac..14ba7bb 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -9,6 +9,13 @@ import Chip from "~/components/chip"; import Link from "~/components/link"; import StatusCircle from "~/components/status-circle"; import Tooltip from "~/components/tooltip"; +import { + agentsContext, + headscaleConfigContext, + headscaleContext, + headscaleLiveStoreContext, + requestApiContext, +} from "~/server/context"; import { nodesResource, usersResource } from "~/server/headscale/live-store"; import cn from "~/utils/cn"; import { getOSInfo, getTSVersion } from "~/utils/host-info"; @@ -22,6 +29,12 @@ import Routes from "./dialogs/routes"; import { machineAction } from "./machine-actions"; 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) { throw new Error("No machine ID provided"); } @@ -31,16 +44,16 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { } let magic: string | undefined; - if (context.hs.readable()) { - if (context.hs.c?.dns.magic_dns) { - magic = context.hs.c.dns.base_domain; + if (headscaleConfig.readable()) { + if (headscaleConfig.c?.dns.magic_dns) { + magic = headscaleConfig.c.dns.base_domain; } } - const { api } = await context.apiForRequest(request); + const { api } = await getRequestApi(request); const [nodesSnap, usersSnap] = await Promise.all([ - context.hsLive.get(nodesResource, api), - context.hsLive.get(usersResource, api), + headscaleLiveStore.get(nodesResource, api), + headscaleLiveStore.get(usersResource, api), ]); const nodes = nodesSnap.data; const users = usersSnap.data; @@ -49,7 +62,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { 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([ agents?.lookup([node.nodeKey]), 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 [enhancedNode] = mapNodes([node], stats); const tags = [...node.tags].toSorted(); - const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable; + const supportsNodeOwnerChange = !headscale.capabilities.nodeOwnerIsImmutable; const agentSync = agents?.lastSync(); const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined; diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index 4dc1a32..d543c1c 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -7,6 +7,15 @@ import Input from "~/components/input"; import Link from "~/components/link"; import PageError from "~/components/page-error"; 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 { isUserPrincipal } from "~/server/web/auth"; import { Capabilities } from "~/server/web/roles"; @@ -21,32 +30,40 @@ import { useMachineFilterParams } from "./hooks/use-machine-filter-params"; import { machineAction } from "./machine-actions"; 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( "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([ - context.hsLive.get(nodesResource, api), - context.hsLive.get(usersResource, api), + headscaleLiveStore.get(nodesResource, api), + headscaleLiveStore.get(usersResource, api), ]); const nodes = nodesSnap.data; const users = usersSnap.data; let magic: string | undefined; - if (context.hs.readable()) { - if (context.hs.c?.dns.magic_dns) { - magic = context.hs.c.dns.base_domain; + if (headscaleConfig.readable()) { + if (headscaleConfig.c?.dns.magic_dns) { + 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([ agents?.lookup(nodes.map((node) => node.nodeKey)), api.policy.get(), @@ -54,7 +71,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { const stats = statsResult.status === "fulfilled" ? statsResult.value : undefined; const policy = policyResult.status === "fulfilled" ? policyResult.value.policy : undefined; const populatedNodes = mapNodes(nodes, stats); - const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable; + const supportsNodeOwnerChange = !headscale.capabilities.nodeOwnerIsImmutable; const agentSync = agents?.lastSync(); return { @@ -70,9 +87,9 @@ export async function loader({ request, context }: Route.LoaderArgs) { magic, nodes, populatedNodes, - preAuth: context.auth.can(principal, Capabilities.generate_authkeys), - publicServer: context.config.headscale.public_url, - server: context.config.headscale.url, + preAuth: auth.can(principal, Capabilities.generate_authkeys), + publicServer: config.headscale.public_url, + server: config.headscale.url, supportsNodeOwnerChange: supportsNodeOwnerChange, users, writable: writablePermission, diff --git a/app/routes/settings/agent.tsx b/app/routes/settings/agent.tsx index 9fac44e..27cfeec 100644 --- a/app/routes/settings/agent.tsx +++ b/app/routes/settings/agent.tsx @@ -6,18 +6,22 @@ import Notice from "~/components/notice"; import StatusCircle from "~/components/status-circle"; import Text from "~/components/text"; import Title from "~/components/title"; +import { agentsContext, authContext } from "~/server/context"; import { formatTimeDelta } from "~/utils/time"; import type { Route } from "./+types/agent"; 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") { - return { enabled: false as const, reason: context.agents.reason }; + await auth.require(request); + + if (agents.state !== "enabled") { + return { enabled: false as const, reason: agents.reason }; } - const sync = context.agents.value.lastSync(); + const sync = agents.value.lastSync(); return { enabled: true as const, 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) { - await context.auth.require(request); + const agents = context.get(agentsContext); + const auth = context.get(authContext); - if (context.agents.state !== "enabled") { - return { success: false, error: context.agents.reason }; + await auth.require(request); + + if (agents.state !== "enabled") { + return { success: false, error: agents.reason }; } - await context.agents.value.triggerSync(); - const sync = context.agents.value.lastSync(); + await agents.value.triggerSync(); + const sync = agents.value.lastSync(); return { success: !sync.error, error: sync.error }; } diff --git a/app/routes/settings/auth-keys/actions.ts b/app/routes/settings/auth-keys/actions.ts index 358c000..dbff875 100644 --- a/app/routes/settings/auth-keys/actions.ts +++ b/app/routes/settings/auth-keys/actions.ts @@ -1,5 +1,6 @@ import { data } from "react-router"; +import { authContext, requestApiContext } from "~/server/context"; import { isUserPrincipal } from "~/server/web/auth"; import { getOidcSubject } from "~/server/web/headscale-identity"; import { Capabilities } from "~/server/web/roles"; @@ -8,10 +9,13 @@ import type { PreAuthKey } from "~/types"; import type { Route } from "./+types/overview"; 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 canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys); + const { principal, api } = await getRequestApi(request); + + const canGenerateAny = auth.can(principal, Capabilities.generate_authkeys); + const canGenerateOwn = auth.can(principal, Capabilities.generate_own_authkeys); if (!canGenerateAny && !canGenerateOwn) { throw data("You do not have permission to manage pre-auth keys", { diff --git a/app/routes/settings/auth-keys/overview.tsx b/app/routes/settings/auth-keys/overview.tsx index a0faa33..fee9088 100644 --- a/app/routes/settings/auth-keys/overview.tsx +++ b/app/routes/settings/auth-keys/overview.tsx @@ -6,6 +6,12 @@ import Link from "~/components/link"; import Notice from "~/components/notice"; import Select from "~/components/select"; import TableList from "~/components/table-list"; +import { + appConfigContext, + authContext, + headscaleLiveStoreContext, + requestApiContext, +} from "~/server/context"; import { usersResource } from "~/server/headscale/live-store"; import { isUserPrincipal } from "~/server/web/auth"; import { Capabilities } from "~/server/web/roles"; @@ -20,9 +26,14 @@ import AuthKeyRow from "./auth-key-row"; import AddAuthKey from "./dialogs/add-auth-key"; 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; let keys: { user: User | null; preAuthKeys: PreAuthKey[] }[]; @@ -86,8 +97,8 @@ export async function loader({ request, context }: Route.LoaderArgs) { .map(({ user, error }) => ({ error, user })); } - const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys); - 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); return { access: canGenerateAny || canGenerateOwn, @@ -96,7 +107,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { keys, missing, selfServiceOnly: !canGenerateAny && canGenerateOwn, - url: context.config.headscale.public_url ?? context.config.headscale.url, + url: config.headscale.public_url ?? config.headscale.url, users, }; } diff --git a/app/routes/settings/overview.tsx b/app/routes/settings/overview.tsx index e90324a..4a69f5e 100644 --- a/app/routes/settings/overview.tsx +++ b/app/routes/settings/overview.tsx @@ -2,14 +2,17 @@ import { ArrowRight } from "lucide-react"; import Link from "~/components/link"; import PageError from "~/components/page-error"; +import { headscaleConfigContext, oidcContext } from "~/server/context"; import type { Route } from "./+types/overview"; export async function loader({ context }: Route.LoaderArgs) { + const headscaleConfig = context.get(headscaleConfigContext); + const oidc = context.get(oidcContext); + return { - config: context.hs.writable(), - isOidcEnabled: - context.oidc.state === "enabled" && context.oidc.value.status().state === "ready", + config: headscaleConfig.writable(), + isOidcEnabled: oidc.state === "enabled" && oidc.value.status().state === "ready", }; } diff --git a/app/routes/settings/restrictions/actions.ts b/app/routes/settings/restrictions/actions.ts index 9348855..763d882 100644 --- a/app/routes/settings/restrictions/actions.ts +++ b/app/routes/settings/restrictions/actions.ts @@ -1,12 +1,23 @@ import { data } from "react-router"; +import { + authContext, + headscaleConfigContext, + headscaleContext, + integrationContext, +} from "~/server/context"; import { Capabilities } from "~/server/web/roles"; import type { Route } from "./+types/overview"; export async function restrictionAction({ request, context }: Route.ActionArgs) { - const principal = await context.auth.require(request); - const check = context.auth.can(principal, Capabilities.configure_iam); + const auth = context.get(authContext); + 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) { 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.", { 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", value: domains, }, ]); - context.integration?.onConfigChange(context.headscale); + integration?.onConfigChange(headscale); 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)) { // Domain not found in the list 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 const domains = storedDomains.filter((d: string) => d !== domain); - await context.hs.patch([ + await headscaleConfig.patch([ { path: "oidc.allowed_domains", value: domains, }, ]); - context.integration?.onConfigChange(context.headscale); + integration?.onConfigChange(headscale); 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", value: groups, }, ]); - context.integration?.onConfigChange(context.headscale); + integration?.onConfigChange(headscale); 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)) { // Group not found in the list 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 const groups = storedGroups.filter((d: string) => d !== group); - await context.hs.patch([ + await headscaleConfig.patch([ { path: "oidc.allowed_groups", value: groups, }, ]); - context.integration?.onConfigChange(context.headscale); + integration?.onConfigChange(headscale); 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", value: users, }, ]); - context.integration?.onConfigChange(context.headscale); + integration?.onConfigChange(headscale); 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)) { // User not found in the list 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 const users = storedUsers.filter((d: string) => d !== user); - await context.hs.patch([ + await headscaleConfig.patch([ { path: "oidc.allowed_users", value: users, }, ]); - context.integration?.onConfigChange(context.headscale); + integration?.onConfigChange(headscale); return data("User removed successfully."); } diff --git a/app/routes/settings/restrictions/overview.tsx b/app/routes/settings/restrictions/overview.tsx index c9f7aad..a1d9ddf 100644 --- a/app/routes/settings/restrictions/overview.tsx +++ b/app/routes/settings/restrictions/overview.tsx @@ -2,6 +2,7 @@ import { data } from "react-router"; import Link from "~/components/link"; import Notice from "~/components/notice"; +import { authContext, headscaleConfigContext } from "~/server/context"; import { Capabilities } from "~/server/web/roles"; import type { Route } from "./+types/overview"; @@ -12,28 +13,31 @@ import AddUser from "./dialogs/add-user"; import RestrictionTable from "./table"; export async function loader({ request, context }: Route.LoaderArgs) { - const principal = await context.auth.require(request); - const check = context.auth.can(principal, Capabilities.read_users); + const auth = context.get(authContext); + const headscaleConfig = context.get(headscaleConfigContext); + + const principal = await auth.require(request); + const check = auth.can(principal, Capabilities.read_users); if (!check) { throw data("You do not have permission to view IAM settings.", { status: 403, }); } - if (!context.hs.c?.oidc) { + if (!headscaleConfig.c?.oidc) { throw data("OIDC is not configured on this Headscale instance.", { status: 501, }); } return { - access: context.auth.can(principal, Capabilities.configure_iam), + access: auth.can(principal, Capabilities.configure_iam), settings: { - domains: [...new Set(context.hs.c.oidc.allowed_domains)], - groups: [...new Set(context.hs.c.oidc.allowed_groups)], - users: [...new Set(context.hs.c.oidc.allowed_users)], + domains: [...new Set(headscaleConfig.c.oidc.allowed_domains)], + groups: [...new Set(headscaleConfig.c.oidc.allowed_groups)], + users: [...new Set(headscaleConfig.c.oidc.allowed_users)], }, - writable: context.hs.writable(), + writable: headscaleConfig.writable(), }; } diff --git a/app/routes/ssh/page.tsx b/app/routes/ssh/page.tsx index ce44f05..1332f8c 100644 --- a/app/routes/ssh/page.tsx +++ b/app/routes/ssh/page.tsx @@ -5,6 +5,7 @@ import { data, isRouteErrorResponse, type ShouldRevalidateFunction } from "react import Button from "~/components/button"; import Card from "~/components/card"; import Code from "~/components/code"; +import { agentsContext, appConfigContext, requestApiContext } from "~/server/context"; import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity"; import type { Route } from "./+types/page"; @@ -23,6 +24,10 @@ export const shouldRevalidate: ShouldRevalidateFunction = () => { }; 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 assets = [WASM_HELPER_URL, WASM_MODULE_URL]; const missing: string[] = []; @@ -38,11 +43,11 @@ export async function loader({ request, params, context, url }: Route.LoaderArgs throw data(sshErrors.wasm_missing, 405); } - if (context.agents.state !== "enabled") { + if (agents.state !== "enabled") { throw data(sshErrors.agent_required, 400); } - const { principal, api } = await context.apiForRequest(request); + const { principal, api } = await getRequestApi(request); if (principal.kind === "api_key") { throw data(sshErrors.oidc_required, 403); } @@ -82,7 +87,7 @@ export async function loader({ request, params, context, url }: Route.LoaderArgs aclTags: null, }); - const controlURL = context.config.headscale.public_url ?? context.config.headscale.url; + const controlURL = config.headscale.public_url ?? config.headscale.url; return { hostname, username, diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx index 5bdae72..1817f7c 100644 --- a/app/routes/users/overview.tsx +++ b/app/routes/users/overview.tsx @@ -1,6 +1,13 @@ import { createHash } from "node:crypto"; import PageError from "~/components/page-error"; +import { + appConfigContext, + authContext, + headscaleConfigContext, + headscaleLiveStoreContext, + requestApiContext, +} from "~/server/context"; import { nodesResource, usersResource } from "~/server/headscale/live-store"; import { isUserPrincipal } from "~/server/web/auth"; import { Capabilities, Roles } from "~/server/web/roles"; @@ -36,18 +43,24 @@ export interface UnlinkedHeadscaleUser extends User { } export async function loader({ request, context }: Route.LoaderArgs) { - const principal = await context.auth.require(request); - const check = await context.auth.can(principal, Capabilities.read_users); + const auth = context.get(authContext); + 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) { throw new Error( "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) - const hpUsers = await context.auth.listUsers(); + const hpUsers = await auth.listUsers(); // Secondary data: Headscale API (may fail) let apiUsers: User[] = []; @@ -55,10 +68,10 @@ export async function loader({ request, context }: Route.LoaderArgs) { let apiError: string | undefined; try { - const { api } = await context.apiForRequest(request); + const { api } = await getRequestApi(request); const [nodesSnap, usersSnap] = await Promise.all([ - context.hsLive.get(nodesResource, api), - context.hsLive.get(usersResource, api), + headscaleLiveStore.get(nodesResource, api), + headscaleLiveStore.get(usersResource, api), ]); nodes = nodesSnap.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."; } - 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 { if (!useGravatar) return profilePicUrl; @@ -126,9 +139,9 @@ export async function loader({ request, context }: Route.LoaderArgs) { })); let magic: string | undefined; - if (context.hs.readable()) { - if (context.hs.c?.dns.magic_dns) { - magic = context.hs.c.dns.base_domain; + if (headscaleConfig.readable()) { + if (headscaleConfig.c?.dns.magic_dns) { + magic = headscaleConfig.c.dns.base_domain; } } @@ -138,7 +151,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { writable: writablePermission, currentUserId: isUserPrincipal(principal) ? principal.user.id : undefined, isOwner, - oidc: context.config.oidc ? { issuer: context.config.oidc.issuer } : undefined, + oidc: config.oidc ? { issuer: config.oidc.issuer } : undefined, magic, apiError, headplaneUsers, diff --git a/app/routes/users/user-actions.ts b/app/routes/users/user-actions.ts index 106e8c7..1978951 100644 --- a/app/routes/users/user-actions.ts +++ b/app/routes/users/user-actions.ts @@ -1,5 +1,6 @@ import { data } from "react-router"; +import { authContext, headscaleLiveStoreContext, requestApiContext } from "~/server/context"; import { usersResource } from "~/server/headscale/live-store"; import { isUserPrincipal } from "~/server/web/auth"; import { Capabilities } from "~/server/web/roles"; @@ -8,8 +9,12 @@ import type { Role } from "~/server/web/roles"; import type { Route } from "./+types/overview"; export async function userAction({ request, context }: Route.ActionArgs) { - const principal = await context.auth.require(request); - const check = await context.auth.can(principal, Capabilities.write_users); + const auth = context.get(authContext); + 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) { throw data("You do not have permission to update users", { 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) { case "create_user": { 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 context.hsLive.refresh(usersResource, api); + await headscaleLiveStore.refresh(usersResource, api); return { message: "User created successfully" }; } case "delete_user": { @@ -50,7 +55,7 @@ export async function userAction({ request, context }: Route.ActionArgs) { } await api.users.delete(headscaleUserId); - await context.hsLive.refresh(usersResource, api); + await headscaleLiveStore.refresh(usersResource, api); return { message: "User deleted successfully" }; } case "rename_user": { @@ -74,7 +79,7 @@ export async function userAction({ request, context }: Route.ActionArgs) { } await api.users.rename(headscaleUserId, newName); - await context.hsLive.refresh(usersResource, api); + await headscaleLiveStore.refresh(usersResource, api); return { message: "User renamed successfully" }; } 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) { 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 }); } - const result = await context.auth.transferOwnership(principal.user.id, headplaneUserId); + const result = await auth.transferOwnership(principal.user.id, headplaneUserId); if (!result) { 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) { throw data("That Headscale user is already linked to another account.", { status: 409 }); } diff --git a/app/routes/util/healthz.ts b/app/routes/util/healthz.ts index 48b456d..62f2428 100644 --- a/app/routes/util/healthz.ts +++ b/app/routes/util/healthz.ts @@ -1,7 +1,11 @@ +import { headscaleContext } from "~/server/context"; + import type { Route } from "./+types/healthz"; 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" }), { status: healthy ? 200 : 500, diff --git a/app/routes/util/info.ts b/app/routes/util/info.ts index 2f64a4f..c45f20c 100644 --- a/app/routes/util/info.ts +++ b/app/routes/util/info.ts @@ -2,10 +2,15 @@ import { versions } from "node:process"; import { data } from "react-router"; +import { appConfigContext, headscaleContext } from "~/server/context"; + import type { Route } from "./+types/info"; 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( { status: "Forbidden", @@ -25,7 +30,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { } const token = bearer.slice("Bearer ".length).trim(); - if (token !== context.config.server.info_secret) { + if (token !== config.server.info_secret) { throw data( { 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 = { status: healthy ? "healthy" : "unhealthy", headplane_version: __VERSION__, - headscale_canonical_version: healthy ? context.headscale.version.raw : "unknown", + headscale_canonical_version: healthy ? headscale.version.raw : "unknown", internal_versions: { node: versions.node, v8: versions.v8, diff --git a/app/routes/util/live.ts b/app/routes/util/live.ts index 67af56e..3afd26c 100644 --- a/app/routes/util/live.ts +++ b/app/routes/util/live.ts @@ -1,15 +1,19 @@ +import { headscaleLiveStoreContext, requestApiContext } from "~/server/context"; import { nodesResource, usersResource } from "~/server/headscale/live-store"; import log from "~/utils/log"; import type { Route } from "./+types/live"; 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 await Promise.all([ - context.hsLive.get(nodesResource, api), - context.hsLive.get(usersResource, api), + headscaleLiveStore.get(nodesResource, api), + headscaleLiveStore.get(usersResource, api), ]); 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); 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); send("changed", { resource, version }); }); diff --git a/app/server/README.md b/app/server/README.md index ecc1f83..4504eda 100644 --- a/app/server/README.md +++ b/app/server/README.md @@ -9,7 +9,7 @@ runs only on the Node process — never in the browser. app/server/ ├── app.ts ← The Headplane application (load context, RR listener) ├── 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 helper used across the server modules │ ├── 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 -Loads config → builds the `AppLoadContext` (via [`context.ts`](./context.ts)) -→ exports the React Router `RequestListener` as `default`, plus the -resolved `config` as a named export. +Loads config → builds the application context (via [`context.ts`](./context.ts)) +→ seeds React Router's `RouterContextProvider` with the named service contexts +→ 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 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 integration adapter (`integration`) -The returned object is the `AppLoadContext` exposed to every React -Router loader/action. The module also `declare module "react-router" { interface AppLoadContext extends AppContext {} }` -so route handlers get full type inference on `context`. +The returned object owns process-lifetime services, but route handlers consume +those services through named React Router contexts such as `authContext`, +`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 -import type { AppContext } from "~/server/context"; +import { authContext } from "~/server/context"; -export async function loader({ context }: LoaderFunctionArgs) { - // … +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/`). 2. If it owns process-lifetime state (a connection pool, a service client, …), construct it in [`context.ts`](./context.ts) and add it - to the returned object — this gives every route automatic access via - `context.`. + to the returned object. Expose it through a named React Router context + and seed that context in [`app.ts`](./app.ts)'s `getLoadContext`. 3. If it's purely a helper (pure functions, type definitions), import it directly from the module that needs it. diff --git a/app/server/app.ts b/app/server/app.ts index 7d5bc91..512c779 100644 --- a/app/server/app.ts +++ b/app/server/app.ts @@ -12,6 +12,7 @@ import { exit, versions } from "node:process"; import { createRequestListener } from "@react-router/node"; +import { RouterContextProvider } from "react-router"; import * as build from "virtual:react-router/server-build"; import log from "~/utils/log"; @@ -19,7 +20,20 @@ import log from "~/utils/log"; import type { HeadplaneConfig } from "./config/config-schema"; import { ConfigError } from "./config/error"; 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); @@ -65,6 +79,19 @@ export default createRequestListener({ mode: import.meta.env.MODE, getLoadContext: (request, client) => { 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; }, }); diff --git a/app/server/context.ts b/app/server/context.ts index ebbbdae..2263ded 100644 --- a/app/server/context.ts +++ b/app/server/context.ts @@ -1,5 +1,7 @@ import { join } from "node:path"; +import { createContext } from "react-router"; + import log from "~/utils/log"; 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"; export type AppContext = Awaited>; - -declare module "react-router" { - interface AppLoadContext extends AppContext {} -} +export const agentsContext = createContext(); +export const appConfigContext = createContext(); +export const authContext = createContext(); +export const dbContext = createContext(); +export const headscaleContext = createContext(); +export const headscaleApiKeyContext = createContext(); +export const headscaleConfigContext = createContext(); +export const headscaleLiveStoreContext = createContext(); +export const integrationContext = createContext(); +export const oidcContext = createContext(); +export const requestApiContext = createContext(); export async function createAppContext(config: HeadplaneConfig) { const db = await createDbClient(join(config.server.data_path, "hp_persist.db"));