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 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;
+30 -15
View File
@@ -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),
},
});
}
+7 -3
View File
@@ -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({
+9 -5
View File
@@ -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;
+6 -2
View File
@@ -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(),
+9 -4
View File
@@ -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,
+12 -12
View File
@@ -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<AppContext>) {
let principal: Awaited<ReturnType<typeof context.auth.require>> | 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<ReturnType<typeof auth.require>> | 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<AppContext
return redirect(url, {
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 {
appConfigContext,
authContext,
headscaleApiKeyContext,
headscaleContext,
oidcContext,
} from "~/server/context";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import { Roles } from "~/server/web/roles";
import log from "~/utils/log";
@@ -8,16 +15,22 @@ import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-callback";
export async function loader({ request, context, url }: Route.LoaderArgs) {
if (context.oidc.state !== "enabled") {
throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 });
const auth = context.get(authContext);
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) {
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"));
if (oidcCookieState == null) {
@@ -53,7 +66,7 @@ export async function loader({ request, context, url }: Route.LoaderArgs) {
? identity.role
: undefined;
const userId = await context.auth.findOrCreateUser(
const userId = await auth.findOrCreateUser(
identity.subject,
{
name: identity.name,
@@ -61,7 +74,7 @@ export async function loader({ request, context, url }: Route.LoaderArgs) {
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
// the configured admin API key here — not a per-request one — because
// 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 hsUser = findHeadscaleUserBySubject(hsUsers, identity.subject, identity.email);
if (hsUser) {
await context.auth.linkHeadscaleUser(userId, hsUser.id);
await auth.linkHeadscaleUser(userId, hsUser.id);
}
} catch (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
// 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("/", {
headers: {
"Set-Cookie": await context.auth.createOidcSession(
"Set-Cookie": await auth.createOidcSession(
userId,
{
name: identity.name,
+10 -5
View File
@@ -1,19 +1,24 @@
import { data, redirect } from "react-router";
import { appConfigContext, authContext, oidcContext } from "~/server/context";
import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-start";
export async function loader({ request, context }: 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("/");
} catch {}
if (context.oidc.state !== "enabled") {
throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 });
if (oidc.state !== "enabled") {
throw data(`OIDC is unavailable: ${oidc.reason}`, { status: 501 });
}
const service = context.oidc.value;
const service = oidc.value;
const result = await service.startFlow();
if (!result.ok) {
@@ -21,7 +26,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
const { url, flowState } = result.value;
const cookie = createOidcStateCookie(context.config);
const cookie = createOidcStateCookie(config);
return redirect(url, {
status: 302,
+38 -27
View File
@@ -1,18 +1,29 @@
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 dnsAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.write_network);
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.write_network);
if (!check) {
return data({ success: false }, 403);
}
if (!context.hs.writable()) {
if (!headscaleConfig.writable()) {
return data({ success: false }, 403);
}
@@ -29,14 +40,14 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return data({ success: false }, 400);
}
await context.hs.patch([
await headscaleConfig.patch([
{
path: "dns.base_domain",
value: newName,
},
]);
await context.integration?.onConfigChange(context.headscale);
await integration?.onConfigChange(headscale);
return { message: "Tailnet renamed successfully" };
}
case "toggle_magic": {
@@ -45,18 +56,18 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return data({ success: false }, 400);
}
await context.hs.patch([
await headscaleConfig.patch([
{
path: "dns.magic_dns",
value: newState === "enabled",
},
]);
await context.integration?.onConfigChange(context.headscale);
await integration?.onConfigChange(headscale);
return { message: "Magic DNS state updated successfully" };
}
case "remove_ns": {
const config = context.hs.c!;
const config = headscaleConfig.c!;
const ns = formData.get("ns")?.toString();
const splitName = formData.get("split_name")?.toString();
@@ -67,7 +78,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
if (splitName === "global") {
const servers = config.dns.nameservers.global.filter((i) => 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:
+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 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<AppContext>) {
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<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 = {
prefixes: config.prefixes,
magicDns: config.dns.magic_dns,
@@ -40,13 +44,13 @@ export async function loader({ request, context }: LoaderFunctionArgs<AppContext
splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains,
overrideDns: config.dns.override_local_dns,
extraRecords: context.hs.d,
extraRecords: headscaleConfig.d,
};
return {
...dns,
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 Link from "~/components/link";
import LinkAccount from "~/layout/link-account";
import {
authContext,
headscaleConfigContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
@@ -19,19 +25,24 @@ import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/home";
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,
// check for unclaimed users they can pick from before anything else.
let unlinked = false;
if (isUserPrincipal(principal) && !principal.user.headscaleUserId) {
const { api } = await context.apiForRequest(request);
const { api } = await getRequestApi(request);
let headscaleUsers: { id: string; name: string }[] = [];
try {
const [usersSnap, claimed] = await Promise.all([
context.hsLive.get(usersResource, api),
context.auth.claimedHeadscaleUserIds(),
headscaleLiveStore.get(usersResource, api),
auth.claimedHeadscaleUserIds(),
]);
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
// Just needs to connect a device and Headscale will auto-create
// Their account, at which point auto-link will pick it up.
if (!context.hs.c?.oidc) {
if (!headscaleConfig.c?.oidc) {
unlinked = true;
}
}
if (context.auth.can(principal, Capabilities.ui_access)) {
if (auth.can(principal, Capabilities.ui_access)) {
return redirect("/machines");
}
// No UI access — show the download/connect page
const { api } = await context.apiForRequest(request);
const { api } = await getRequestApi(request);
let linkedUserName: string | undefined;
if (isUserPrincipal(principal) && principal.user.headscaleUserId) {
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);
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("/");
+15 -10
View File
@@ -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" };
}
+21 -8
View File
@@ -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;
+31 -14
View File
@@ -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,
+16 -9
View File
@@ -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 };
}
+7 -3
View File
@@ -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", {
+16 -5
View File
@@ -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,
};
}
+6 -3
View File
@@ -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",
};
}
+32 -21
View File
@@ -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.");
}
+12 -8
View File
@@ -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(),
};
}
+8 -3
View File
@@ -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,
+25 -12
View File
@@ -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,
+14 -9
View File
@@ -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 });
}
+5 -1
View File
@@ -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,
+9 -4
View File
@@ -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,
+9 -5
View File
@@ -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 });
});
+16 -13
View File
@@ -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<T, E> 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<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/`).
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.<name>`.
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.
+29 -2
View File
@@ -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;
},
});
+13 -4
View File
@@ -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<ReturnType<typeof createAppContext>>;
declare module "react-router" {
interface AppLoadContext extends AppContext {}
}
export const agentsContext = createContext<AppContext["agents"]>();
export const appConfigContext = createContext<AppContext["config"]>();
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) {
const db = await createDbClient(join(config.server.data_path, "hp_persist.db"));