mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
feat: update to the v8 middleware api
This commit is contained in:
@@ -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
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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("/");
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user