feat: initial auth rework

This commit is contained in:
Aarnav Tale
2026-03-07 17:10:34 -05:00
parent 6d70497758
commit 0f8e192b5c
39 changed files with 2110 additions and 1684 deletions
+53
View File
@@ -0,0 +1,53 @@
# Core Concepts
Headplane is a web application to manage Headscale, a self-hosted implementation
of the Tailscale control server. There are a few tenets that guide the entire
development of the project:
- **Simple starts**: We want to make it as easy as possible to set up and use
Headplane, while still providing powerful features for advanced users. This
means that we prioritize a clean and intuitive user interface, as well as
straightforward installation and configuration processes.
- **No breaking changes**: We want to avoid making breaking changes to the
project as much as possible. This means that we will strive to maintain
backward compatibility and provide clear migration paths when necessary.
- **Documentation**: This is the most important part of the project, without it
the entire project falls apart and is hard to use.
## Project Management
It's hard to manage this project easily, use the `gh` CLI when responding to
prompts to get context. Some common issue tags to keep track of include a
"Needs Triage", "Needs Info", "Bug", "Enhancement", and several other tags based
on what parts of the project are affected.
## Headplane Agent
The Headplane Agent is a lightweight component that runs on the same server as
Headplane and connects directly to the Tailnet in order to pull in details about
nodes that aren't available through the Headscale API such as versions, etc.
## WebSSH
This is an ephemeral WASM shim that runs in the browser and connects directly
to the Tailnet using Tailscale's go packages. It allows anyone to open up an
ephemeral machine in the Tailnet that directly SSHes into a target node.
## Build/Tooling
Headplane is a React Router 7 (framework mode) project built with Vite. Take
care to use our preferred PNPM version and Node version as defined in the
`engines` field of `package.json`. We also use TypeScript Go and Oxfmt for
type-checking and formatting respectively.
You can also run Headscale CLI commands with
`docker exec headscale headscale <command>` when the dev environment is running.
## Docs
The project has a documentation site available at the `docs/` directory built
with VitePress. The documentation is written in Markdown and can be easily
edited and extended. If making changes to staple features, please take care to
also update the documentation to reflect any changes in functionality or usage.
+43 -42
View File
@@ -1,53 +1,54 @@
import { Outlet, redirect } from 'react-router'; import { Outlet, redirect } from "react-router";
import { ErrorBanner } from '~/components/error-banner';
import { pruneEphemeralNodes } from '~/server/db/pruner'; import { ErrorBanner } from "~/components/error-banner";
import { isDataUnauthorizedError } from '~/server/headscale/api/error-client'; import { pruneEphemeralNodes } from "~/server/db/pruner";
import log from '~/utils/log'; import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
import type { Route } from './+types/dashboard'; import log from "~/utils/log";
import type { Route } from "./+types/dashboard";
export async function loader({ request, context, ...rest }: Route.LoaderArgs) { export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
const session = await context.sessions.auth(request); const principal = await context.auth.require(request);
const api = context.hsApi.getRuntimeClient(session.api_key); const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
// MARK: The session should stay valid if Headscale isn't healthy // MARK: The session should stay valid if Headscale isn't healthy
const healthy = await api.isHealthy(); const healthy = await api.isHealthy();
if (healthy) { if (healthy) {
try { try {
await api.getApiKeys(); await api.getApiKeys();
await pruneEphemeralNodes({ context, request, ...rest }); await pruneEphemeralNodes({ context, request, ...rest });
} catch (error) { } catch (error) {
if (isDataUnauthorizedError(error)) { if (isDataUnauthorizedError(error)) {
log.warn( const displayName =
'auth', principal.kind === "oidc" ? principal.profile.name : principal.displayName;
'Logging out %s due to expired API key', log.warn("auth", "Logging out %s due to expired API key", displayName);
session.user.name, return redirect("/login", {
); headers: {
return redirect('/login', { "Set-Cookie": await context.auth.destroySession(request),
headers: { },
'Set-Cookie': await context.sessions.destroySession(), });
}, }
}); }
} }
}
}
return { return {
healthy, healthy,
}; };
} }
export default function Layout() { export default function Layout() {
return ( return (
<main className="container mx-auto overscroll-contain mt-4 mb-24"> <main className="container mx-auto mt-4 mb-24 overscroll-contain">
<Outlet /> <Outlet />
</main> </main>
); );
} }
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return ( return (
<div className="w-fit mx-auto overscroll-contain my-24"> <div className="mx-auto my-24 w-fit overscroll-contain">
<ErrorBanner className="max-w-2xl" error={error} /> <ErrorBanner className="max-w-2xl" error={error} />
</div> </div>
); );
} }
+28 -29
View File
@@ -1,9 +1,7 @@
import { eq } from "drizzle-orm";
import { Outlet, redirect } from "react-router"; import { Outlet, redirect } from "react-router";
import Footer from "~/components/Footer"; import Footer from "~/components/Footer";
import Header from "~/components/Header"; import Header from "~/components/Header";
import { users } from "~/server/db/schema";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
import { Route } from "./+types/shell"; import { Route } from "./+types/shell";
@@ -12,48 +10,49 @@ import { Route } from "./+types/shell";
// So we know that if context fails to load then well, oops? // So we know that if context fails to load then well, oops?
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
try { try {
const session = await context.sessions.auth(request); const principal = await context.auth.require(request);
if ( if (
typeof context.oidc === "object" && typeof context.oidc === "object" &&
session.user.subject !== "unknown-non-oauth" && principal.kind === "oidc" &&
!principal.user.onboarded &&
!request.url.endsWith("/onboarding") !request.url.endsWith("/onboarding")
) { ) {
const [user] = await context.db return redirect("/onboarding");
.select()
.from(users)
.where(eq(users.sub, session.user.subject))
.limit(1);
if (!user?.onboarded) {
return redirect("/onboarding");
}
} }
const api = context.hsApi.getRuntimeClient(session.api_key); const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const check = await context.sessions.check(request, Capabilities.ui_access); const api = context.hsApi.getRuntimeClient(apiKey);
const check = context.auth.can(principal, Capabilities.ui_access);
// OIDC users without ui_access go to pending approval if (!check && principal.kind === "oidc" && !request.url.endsWith("/onboarding")) {
if ( throw new Error("You do not have permission to access the UI");
!check &&
session.user.subject !== "unknown-non-oauth" &&
!request.url.endsWith("/onboarding")
) {
return redirect("/pending-approval");
} }
const user =
principal.kind === "oidc"
? {
subject: principal.user.subject,
name: principal.profile.name,
email: principal.profile.email,
username: principal.profile.username,
picture: principal.profile.picture,
}
: { subject: "api_key", name: principal.displayName };
return { return {
config: context.hs.c, config: context.hs.c,
url: context.config.headscale.public_url ?? context.config.headscale.url, url: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(), configAvailable: context.hs.readable(),
debug: context.config.debug, debug: context.config.debug,
user: session.user, user,
access: { access: {
ui: check, ui: check,
dns: await context.sessions.check(request, Capabilities.read_network), dns: context.auth.can(principal, Capabilities.read_network),
users: await context.sessions.check(request, Capabilities.read_users), users: context.auth.can(principal, Capabilities.read_users),
policy: await context.sessions.check(request, Capabilities.read_policy), policy: context.auth.can(principal, Capabilities.read_policy),
machines: await context.sessions.check(request, Capabilities.read_machines), machines: context.auth.can(principal, Capabilities.read_machines),
settings: await context.sessions.check(request, Capabilities.read_feature), settings: context.auth.can(principal, Capabilities.read_feature),
}, },
onboarding: request.url.endsWith("/onboarding"), onboarding: request.url.endsWith("/onboarding"),
healthy: await api.isHealthy(), healthy: await api.isHealthy(),
@@ -61,7 +60,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
} catch { } catch {
return redirect("/login", { return redirect("/login", {
headers: { headers: {
"Set-Cookie": await context.sessions.destroySession(), "Set-Cookie": await context.auth.destroySession(request),
}, },
}); });
} }
-1
View File
@@ -13,7 +13,6 @@ export default [
route("/logout", "routes/auth/logout.ts"), route("/logout", "routes/auth/logout.ts"),
route("/oidc/callback", "routes/auth/oidc-callback.ts"), route("/oidc/callback", "routes/auth/oidc-callback.ts"),
route("/oidc/start", "routes/auth/oidc-start.ts"), route("/oidc/start", "routes/auth/oidc-start.ts"),
route("/pending-approval", "routes/auth/pending-approval.tsx"),
route("/ssh", "routes/ssh/console.tsx"), route("/ssh", "routes/ssh/console.tsx"),
// All the main logged-in dashboard routes // All the main logged-in dashboard routes
+111 -121
View File
@@ -1,139 +1,129 @@
import { data } from 'react-router'; import { data } from "react-router";
import { isDataWithApiError } from '~/server/headscale/api/error-client';
import { Capabilities } from '~/server/web/roles'; import { isDataWithApiError } from "~/server/headscale/api/error-client";
import type { Route } from './+types/overview'; import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
// We only check capabilities here and assume it is writable // We only check capabilities here and assume it is writable
// If it isn't, it'll gracefully error anyways, since this means some // If it isn't, it'll gracefully error anyways, since this means some
// fishy client manipulation is happening. // fishy client manipulation is happening.
export async function aclAction({ request, context }: Route.ActionArgs) { export async function aclAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request); const principal = await context.auth.require(request);
const check = await context.sessions.check( const check = context.auth.can(principal, Capabilities.write_policy);
request, if (!check) {
Capabilities.write_policy, throw data("You do not have permission to write to the ACL policy", {
); status: 403,
if (!check) { });
throw data('You do not have permission to write to the ACL policy', { }
status: 403,
});
}
// Try to write to the ACL policy via the API or via config file (TODO). // Try to write to the ACL policy via the API or via config file (TODO).
const formData = await request.formData(); const formData = await request.formData();
const policyData = formData.get('policy')?.toString(); const policyData = formData.get("policy")?.toString();
if (!policyData) { if (!policyData) {
throw data('Missing `policy` in the form data.', { throw data("Missing `policy` in the form data.", {
status: 400, status: 400,
}); });
} }
const api = context.hsApi.getRuntimeClient(session.api_key); const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
try { const api = context.hsApi.getRuntimeClient(apiKey);
const { policy, updatedAt } = await api.setPolicy(policyData); try {
return data({ const { policy, updatedAt } = await api.setPolicy(policyData);
success: true, return data({
error: undefined, success: true,
policy, error: undefined,
updatedAt, policy,
}); updatedAt,
} catch (error) { });
if (isDataWithApiError(error)) { } catch (error) {
const rawData = error.data.rawData; if (isDataWithApiError(error)) {
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11 const rawData = error.data.rawData;
if (rawData.includes('update is disabled')) { // https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11
throw data('Policy is not writable', { status: 403 }); if (rawData.includes("update is disabled")) {
} throw data("Policy is not writable", { status: 403 });
}
const message = const message =
error.data.data != null && error.data.data != null &&
'message' in error.data.data && "message" in error.data.data &&
typeof error.data.data.message === 'string' typeof error.data.data.message === "string"
? error.data.data.message ? error.data.data.message
: undefined; : undefined;
if (message == null) { if (message == null) {
throw error; throw error;
} }
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning // Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
// we need to reference other error messages based on API version. // we need to reference other error messages based on API version.
if (context.hsApi.clientHelpers.isAtleast('0.27.0')) { if (context.hsApi.clientHelpers.isAtleast("0.27.0")) {
if (message.includes('parsing HuJSON:')) { if (message.includes("parsing HuJSON:")) {
const cutIndex = message.indexOf('parsing HuJSON:'); const cutIndex = message.indexOf("parsing HuJSON:");
const trimmed = const trimmed =
cutIndex > -1 cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
? `Syntax error: ${message.slice(cutIndex + 16).trim()}`
: message;
return data( return data(
{ {
success: false, success: false,
error: trimmed, error: trimmed,
policy: undefined, policy: undefined,
updatedAt: undefined, updatedAt: undefined,
}, },
400, 400,
); );
} }
if (message.includes('parsing policy from bytes:')) { if (message.includes("parsing policy from bytes:")) {
const cutIndex = message.indexOf('parsing policy from bytes:'); const cutIndex = message.indexOf("parsing policy from bytes:");
const trimmed = const trimmed =
cutIndex > -1 cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
? `Syntax error: ${message.slice(cutIndex + 26).trim()}`
: message;
return data( return data(
{ {
success: false, success: false,
error: trimmed, error: trimmed,
policy: undefined, policy: undefined,
updatedAt: undefined, updatedAt: undefined,
}, },
400, 400,
); );
} }
} else { } else {
// Pre-0.27.0 error messages // Pre-0.27.0 error messages
if (message.includes('parsing hujson')) { if (message.includes("parsing hujson")) {
const cutIndex = message.indexOf('err: hujson:'); const cutIndex = message.indexOf("err: hujson:");
const trimmed = const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 12)}` : message;
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 12)}`
: message;
return data( return data(
{ {
success: false, success: false,
error: trimmed, error: trimmed,
policy: undefined, policy: undefined,
updatedAt: undefined, updatedAt: undefined,
}, },
400, 400,
); );
} }
if (message.includes('unmarshalling policy')) { if (message.includes("unmarshalling policy")) {
const cutIndex = message.indexOf('err:'); const cutIndex = message.indexOf("err:");
const trimmed = const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 5)}` : message;
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 5)}`
: message;
return data( return data(
{ {
success: false, success: false,
error: trimmed, error: trimmed,
policy: undefined, policy: undefined,
updatedAt: undefined, updatedAt: undefined,
}, },
400, 400,
); );
} }
} }
} }
// Otherwise, this is a Headscale error that we can just propagate. // Otherwise, this is a Headscale error that we can just propagate.
throw error; throw error;
} }
} }
+38 -35
View File
@@ -1,7 +1,9 @@
import { data } from 'react-router'; import { data } from "react-router";
import { isDataWithApiError } from '~/server/headscale/api/error-client';
import { Capabilities } from '~/server/web/roles'; import { isDataWithApiError } from "~/server/headscale/api/error-client";
import type { Route } from './+types/overview'; import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
// The logic for deciding policy factors is very complicated because // The logic for deciding policy factors is very complicated because
// there are so many factors that need to be accounted for: // there are so many factors that need to be accounted for:
@@ -11,38 +13,39 @@ import type { Route } from './+types/overview';
// If database, we can read/write easily via the API. // If database, we can read/write easily via the API.
// If in file mode, we can only write if context.config is available. // If in file mode, we can only write if context.config is available.
export async function aclLoader({ request, context }: Route.LoaderArgs) { export async function aclLoader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request); const principal = await context.auth.require(request);
const check = await context.sessions.check(request, Capabilities.read_policy); const check = context.auth.can(principal, Capabilities.read_policy);
if (!check) { if (!check) {
throw data('You do not have permission to read the ACL policy.', { throw data("You do not have permission to read the ACL policy.", {
status: 403, status: 403,
}); });
} }
const flags = { const flags = {
// Can the user write to the ACL policy // Can the user write to the ACL policy
access: await context.sessions.check(request, Capabilities.write_policy), access: context.auth.can(principal, Capabilities.write_policy),
writable: false, writable: false,
policy: '', policy: "",
}; };
// Try to load the ACL policy from the API. // Try to load the ACL policy from the API.
const api = context.hsApi.getRuntimeClient(session.api_key); const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
try { const api = context.hsApi.getRuntimeClient(apiKey);
const { policy, updatedAt } = await api.getPolicy(); try {
flags.writable = updatedAt !== null; const { policy, updatedAt } = await api.getPolicy();
flags.policy = policy; flags.writable = updatedAt !== null;
return flags; flags.policy = policy;
} catch (error) { return flags;
if (isDataWithApiError(error)) { } catch (error) {
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10 if (isDataWithApiError(error)) {
if (error.data.rawData.includes('acl policy not found')) { // https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10
flags.policy = ''; if (error.data.rawData.includes("acl policy not found")) {
flags.writable = true; flags.policy = "";
return flags; flags.writable = true;
} return flags;
} }
}
throw error; throw error;
} }
} }
+3 -11
View File
@@ -66,19 +66,11 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
}; };
} }
const expiresDays = Math.round((expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24);
return redirect("/machines", { return redirect("/machines", {
headers: { headers: {
"Set-Cookie": await context.sessions.createSession( "Set-Cookie": await context.auth.createApiKeySession(
{ apiKey,
api_key: apiKey, `${lookup.prefix}...`,
user: {
subject: "unknown-non-oauth",
name: `${lookup.prefix}...`,
email: `expires@${expiresDays.toString()}-days`,
},
},
expiry.getTime() - Date.now(), expiry.getTime() - Date.now(),
), ),
}, },
+2 -3
View File
@@ -10,7 +10,6 @@ import Link from "~/components/Link";
import { useLiveData } from "~/utils/live-data"; import { useLiveData } from "~/utils/live-data";
import type { Route } from "./+types/page"; import type { Route } from "./+types/page";
import { loginAction } from "./action"; import { loginAction } from "./action";
import { OidcConfigErrorNotice, OidcDiscoveryFailedNotice } from "./config-error"; import { OidcConfigErrorNotice, OidcDiscoveryFailedNotice } from "./config-error";
import Logout from "./logout"; import Logout from "./logout";
@@ -18,14 +17,14 @@ import { OidcErrorNotice } from "./oidc-error";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
try { try {
await context.sessions.auth(request); await context.auth.require(request);
return redirect("/machines"); return redirect("/machines");
} catch {} } catch {}
const qp = new URL(request.url).searchParams; const qp = new URL(request.url).searchParams;
const urlState = qp.get("s") ?? undefined; const urlState = qp.get("s") ?? undefined;
const oidcConnector = await context.oidcConnector?.get(); const oidcConnector = await context.oidc?.connector.get();
// MARK: This works because the OIDC connector will always return false // MARK: This works because the OIDC connector will always return false
// for `isExclusive` if the OIDC config isn't usable. // for `isExclusive` if the OIDC config isn't usable.
+18 -22
View File
@@ -1,29 +1,25 @@
import { type ActionFunctionArgs, redirect } from 'react-router'; import { type ActionFunctionArgs, redirect } from "react-router";
import type { LoadContext } from '~/server';
import type { LoadContext } from "~/server";
export async function loader() { export async function loader() {
return redirect('/machines'); return redirect("/machines");
} }
export async function action({ export async function action({ request, context }: ActionFunctionArgs<LoadContext>) {
request, try {
context, await context.auth.require(request);
}: ActionFunctionArgs<LoadContext>) { } catch {
try { redirect("/login");
await context.sessions.auth(request); }
} catch {
redirect('/login');
}
// When API key is disabled, we need to explicitly redirect // When API key is disabled, we need to explicitly redirect
// with a logout state to prevent auto login again. // with a logout state to prevent auto login again.
const url = context.config.oidc?.disable_api_key_login const url = context.config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login";
? '/login?s=logout'
: '/login';
return redirect(url, { return redirect(url, {
headers: { headers: {
'Set-Cookie': await context.sessions.destroySession(), "Set-Cookie": await context.auth.destroySession(request),
}, },
}); });
} }
+20 -41
View File
@@ -1,18 +1,16 @@
import { count, eq } from "drizzle-orm";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import * as oidc from "openid-client"; import * as oidc from "openid-client";
import { data, redirect } from "react-router"; import { data, redirect } from "react-router";
import { ulid } from "ulidx";
import { users } from "~/server/db/schema"; import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import { Roles } from "~/server/web/roles";
import log from "~/utils/log"; import log from "~/utils/log";
import { createOidcStateCookie } from "~/utils/oidc-state"; import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-callback"; import type { Route } from "./+types/oidc-callback";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const oidcConnector = await context.oidcConnector?.get(); const oidcConnector = await context.oidc?.connector.get();
if (!oidcConnector?.isValid) { if (!oidcConnector?.isValid) {
throw data("OIDC is not enabled or misconfigured", { status: 501 }); throw data("OIDC is not enabled or misconfigured", { status: 501 });
} }
@@ -82,47 +80,28 @@ export async function loader({ request, context }: Route.LoaderArgs) {
})() })()
: userInfo.picture; : userInfo.picture;
const [{ count: ownerCount }] = await context.db const hasUsers = await context.auth.hasAnyUsers();
.select({ count: count() }) const defaultRole = hasUsers ? "member" : "owner";
.from(users) const userId = await context.auth.findOrCreateUser(claims.sub, defaultRole);
.where(eq(users.caps, Roles.owner));
const needsOwner = ownerCount === 0; try {
const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey);
if (needsOwner) { const hsUsers = await hsApi.getUsers();
await context.db const hsUser = findHeadscaleUserBySubject(hsUsers, claims.sub, userInfo.email);
.insert(users) if (hsUser) {
.values({ await context.auth.linkHeadscaleUser(userId, hsUser.id);
id: ulid(), }
sub: claims.sub, } catch (error) {
caps: Roles.owner, log.warn("auth", "Failed to link Headscale user: %s", String(error));
})
.onConflictDoUpdate({
target: users.sub,
set: { caps: Roles.owner },
});
} else {
await context.db
.insert(users)
.values({
id: ulid(),
sub: claims.sub,
caps: Roles.member,
})
.onConflictDoNothing();
} }
return redirect("/", { return redirect("/", {
headers: { headers: {
"Set-Cookie": await context.sessions.createSession({ "Set-Cookie": await context.auth.createOidcSession(userId, {
api_key: oidcConnector.apiKey, name,
user: { email: userInfo.email,
subject: claims.sub, username,
username, picture,
name,
email: userInfo.email,
picture,
},
}), }),
}, },
}); });
+2 -2
View File
@@ -8,11 +8,11 @@ import type { Route } from "./+types/oidc-start";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
try { try {
await context.sessions.auth(request); await context.auth.require(request);
return redirect("/"); return redirect("/");
} catch {} } catch {}
const oidcConnector = await context.oidcConnector?.get(); const oidcConnector = await context.oidc?.connector.get();
if (!oidcConnector?.isValid) { if (!oidcConnector?.isValid) {
throw data("OIDC is not enabled or misconfigured", { status: 501 }); throw data("OIDC is not enabled or misconfigured", { status: 501 });
} }
-110
View File
@@ -1,110 +0,0 @@
import { ClockIcon, LogOut, RefreshCw, UserCheck } from "lucide-react";
import { Form, redirect } from "react-router";
import Button from "~/components/Button";
import Card from "~/components/Card";
import { Capabilities } from "~/server/web/roles";
import toast from "~/utils/toast";
import type { Route } from "./+types/pending-approval";
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const session = await context.sessions.auth(request);
// API key users skip this page
if (session.user.subject === "unknown-non-oauth") {
return redirect("/machines");
}
const hasAccess = await context.sessions.check(request, Capabilities.ui_access);
if (hasAccess) {
return redirect("/machines");
}
const url = context.config.headscale.public_url ?? context.config.headscale.url;
return {
user: session.user,
url,
};
} catch {
return redirect("/login", {
headers: {
"Set-Cookie": await context.sessions.destroySession(),
},
});
}
}
export default function PendingApproval({ loaderData }: Route.ComponentProps) {
return (
<div className="flex h-screen w-screen items-center justify-center">
<Card className="m-4 max-w-md sm:m-0">
<div className="mb-4 flex items-center gap-3">
<div className="rounded-full bg-amber-100 p-3 dark:bg-amber-900">
<ClockIcon className="h-8 w-8 text-amber-600 dark:text-amber-400" />
</div>
<div>
<Card.Title className="mb-0 text-xl">Approval Required</Card.Title>
<p className="text-headplane-500 text-sm">
{loaderData.user.email ?? loaderData.user.name}
</p>
</div>
</div>
<Card.Text className="mb-4">
Your account has been created but requires approval from an administrator before you can
access the management console.
</Card.Text>
<div className="bg-headplane-50 dark:bg-headplane-900 mb-4 rounded-lg p-4">
<div className="mb-2 flex items-center gap-2">
<UserCheck className="text-headplane-500 h-5 w-5" />
<p className="font-medium">What happens next?</p>
</div>
<ul className="text-headplane-600 dark:text-headplane-400 list-inside list-disc space-y-1 text-sm">
<li>An administrator will review your account</li>
<li>Once approved, you will receive the appropriate access level</li>
<li>This page will automatically redirect you once approved</li>
</ul>
</div>
<Card.Text className="mb-4 text-sm">
In the meantime, you can still connect your devices to the Tailnet using the command
below:
</Card.Text>
<Button
className="w-full font-mono text-sm"
variant="light"
onPress={async () => {
await navigator.clipboard.writeText(`tailscale up --login-server=${loaderData.url}`);
toast("Copied to clipboard");
}}
>
tailscale up --login-server={loaderData.url}
</Button>
<p className="mt-1 text-center text-xs opacity-50">Click to copy the command</p>
<div className="bg-headplane-100 dark:bg-headplane-800 mb-4 flex items-center justify-center gap-2 rounded-lg p-3 text-sm">
<RefreshCw className="text-headplane-500 h-4 w-4 animate-spin" />
<span className="text-headplane-600 dark:text-headplane-400">
Checking for approval automatically...
</span>
</div>
<Form action="/logout" method="post">
<Button
type="submit"
variant="heavy"
className="flex w-full items-center justify-center gap-2"
>
<LogOut className="h-4 w-4" />
Sign Out
</Button>
</Form>
</Card>
</div>
);
}
+194 -194
View File
@@ -1,231 +1,231 @@
import { data } from 'react-router'; import { data } from "react-router";
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview'; import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
export async function dnsAction({ request, context }: Route.ActionArgs) { export async function dnsAction({ request, context }: Route.ActionArgs) {
const check = await context.sessions.check( const principal = await context.auth.require(request);
request, const check = context.auth.can(principal, Capabilities.write_network);
Capabilities.write_network,
);
if (!check) { if (!check) {
return data({ success: false }, 403); return data({ success: false }, 403);
} }
if (!context.hs.writable()) { if (!context.hs.writable()) {
return data({ success: false }, 403); return data({ success: false }, 403);
} }
// We only need it for health checks which don't require auth // We only need it for health checks which don't require auth
const api = context.hsApi.getRuntimeClient('fake-api-key'); const api = context.hsApi.getRuntimeClient("fake-api-key");
const formData = await request.formData(); const formData = await request.formData();
const action = formData.get('action_id')?.toString(); const action = formData.get("action_id")?.toString();
if (!action) { if (!action) {
return data({ success: false }, 400); return data({ success: false }, 400);
} }
switch (action) { switch (action) {
case 'rename_tailnet': { case "rename_tailnet": {
const newName = formData.get('new_name')?.toString(); const newName = formData.get("new_name")?.toString();
if (!newName) { if (!newName) {
return data({ success: false }, 400); return data({ success: false }, 400);
} }
await context.hs.patch([ await context.hs.patch([
{ {
path: 'dns.base_domain', path: "dns.base_domain",
value: newName, value: newName,
}, },
]); ]);
await context.integration?.onConfigChange(api); await context.integration?.onConfigChange(api);
return { message: 'Tailnet renamed successfully' }; return { message: "Tailnet renamed successfully" };
} }
case 'toggle_magic': { case "toggle_magic": {
const newState = formData.get('new_state')?.toString(); const newState = formData.get("new_state")?.toString();
if (!newState) { if (!newState) {
return data({ success: false }, 400); return data({ success: false }, 400);
} }
await context.hs.patch([ await context.hs.patch([
{ {
path: 'dns.magic_dns', path: "dns.magic_dns",
value: newState === 'enabled', value: newState === "enabled",
}, },
]); ]);
await context.integration?.onConfigChange(api); await context.integration?.onConfigChange(api);
return { message: 'Magic DNS state updated successfully' }; return { message: "Magic DNS state updated successfully" };
} }
case 'remove_ns': { case "remove_ns": {
const config = context.hs.c!; const config = context.hs.c!;
const ns = formData.get('ns')?.toString(); const ns = formData.get("ns")?.toString();
const splitName = formData.get('split_name')?.toString(); const splitName = formData.get("split_name")?.toString();
if (!ns || !splitName) { if (!ns || !splitName) {
return data({ success: false }, 400); return data({ success: false }, 400);
} }
if (splitName === 'global') { if (splitName === "global") {
const servers = config.dns.nameservers.global.filter((i) => i !== ns); const servers = config.dns.nameservers.global.filter((i) => i !== ns);
await context.hs.patch([ await context.hs.patch([
{ {
path: 'dns.nameservers.global', path: "dns.nameservers.global",
value: servers, value: servers,
}, },
]); ]);
} else { } else {
const splits = config.dns.nameservers.split; const splits = config.dns.nameservers.split;
const servers = splits[splitName].filter((i) => i !== ns); const servers = splits[splitName].filter((i) => i !== ns);
await context.hs.patch([ await context.hs.patch([
{ {
path: `dns.nameservers.split."${splitName}"`, path: `dns.nameservers.split."${splitName}"`,
value: servers.length > 0 ? servers : null, value: servers.length > 0 ? servers : null,
}, },
]); ]);
} }
await context.integration?.onConfigChange(api); await context.integration?.onConfigChange(api);
return { message: 'Nameserver removed successfully' }; return { message: "Nameserver removed successfully" };
} }
case 'add_ns': { case "add_ns": {
const config = context.hs.c!; const config = context.hs.c!;
const ns = formData.get('ns')?.toString(); const ns = formData.get("ns")?.toString();
const splitName = formData.get('split_name')?.toString(); const splitName = formData.get("split_name")?.toString();
if (!ns || !splitName) { if (!ns || !splitName) {
return data({ success: false }, 400); return data({ success: false }, 400);
} }
if (splitName === 'global') { if (splitName === "global") {
const servers = config.dns.nameservers.global; const servers = config.dns.nameservers.global;
servers.push(ns); servers.push(ns);
await context.hs.patch([ await context.hs.patch([
{ {
path: 'dns.nameservers.global', path: "dns.nameservers.global",
value: servers, value: servers,
}, },
]); ]);
} else { } else {
const splits = config.dns.nameservers.split; const splits = config.dns.nameservers.split;
const servers = splits[splitName] ?? []; const servers = splits[splitName] ?? [];
servers.push(ns); servers.push(ns);
await context.hs.patch([ await context.hs.patch([
{ {
path: `dns.nameservers.split."${splitName}"`, path: `dns.nameservers.split."${splitName}"`,
value: servers, value: servers,
}, },
]); ]);
} }
await context.integration?.onConfigChange(api); await context.integration?.onConfigChange(api);
return { message: 'Nameserver added successfully' }; return { message: "Nameserver added successfully" };
} }
case 'remove_domain': { case "remove_domain": {
const config = context.hs.c!; const config = context.hs.c!;
const domain = formData.get('domain')?.toString(); const domain = formData.get("domain")?.toString();
if (!domain) { if (!domain) {
return data({ success: false }, 400); return data({ success: false }, 400);
} }
const domains = config.dns.search_domains.filter((i) => i !== domain); const domains = config.dns.search_domains.filter((i) => i !== domain);
await context.hs.patch([ await context.hs.patch([
{ {
path: 'dns.search_domains', path: "dns.search_domains",
value: domains, value: domains,
}, },
]); ]);
await context.integration?.onConfigChange(api); await context.integration?.onConfigChange(api);
return { message: 'Domain removed successfully' }; return { message: "Domain removed successfully" };
} }
case 'add_domain': { case "add_domain": {
const config = context.hs.c!; const config = context.hs.c!;
const domain = formData.get('domain')?.toString(); const domain = formData.get("domain")?.toString();
if (!domain) { if (!domain) {
return data({ success: false }, 400); return data({ success: false }, 400);
} }
const domains = config.dns.search_domains; const domains = config.dns.search_domains;
domains.push(domain); domains.push(domain);
await context.hs.patch([ await context.hs.patch([
{ {
path: 'dns.search_domains', path: "dns.search_domains",
value: domains, value: domains,
}, },
]); ]);
await context.integration?.onConfigChange(api); await context.integration?.onConfigChange(api);
return { message: 'Domain added successfully' }; return { message: "Domain added successfully" };
} }
case 'remove_record': { case "remove_record": {
const recordName = formData.get('record_name')?.toString(); const recordName = formData.get("record_name")?.toString();
const recordType = formData.get('record_type')?.toString(); const recordType = formData.get("record_type")?.toString();
if (!recordName || !recordType) { if (!recordName || !recordType) {
return data({ success: false }, 400); return data({ success: false }, 400);
} }
// Value is not needed for removal // Value is not needed for removal
const restart = await context.hs.removeDNS({ const restart = await context.hs.removeDNS({
name: recordName, name: recordName,
type: recordType, type: recordType,
value: '', value: "",
}); });
if (!restart) { if (!restart) {
return; return;
} }
await context.integration?.onConfigChange(api); await context.integration?.onConfigChange(api);
return { message: 'DNS record removed successfully' }; return { message: "DNS record removed successfully" };
} }
case 'add_record': { case "add_record": {
const recordName = formData.get('record_name')?.toString(); const recordName = formData.get("record_name")?.toString();
const recordType = formData.get('record_type')?.toString(); const recordType = formData.get("record_type")?.toString();
const recordValue = formData.get('record_value')?.toString(); const recordValue = formData.get("record_value")?.toString();
if (!recordName || !recordType || !recordValue) { if (!recordName || !recordType || !recordValue) {
return data({ success: false }, 400); return data({ success: false }, 400);
} }
const restart = await context.hs.addDNS({ const restart = await context.hs.addDNS({
name: recordName, name: recordName,
type: recordType, type: recordType,
value: recordValue, value: recordValue,
}); });
if (!restart) { if (!restart) {
return; return;
} }
await context.integration?.onConfigChange(api); await context.integration?.onConfigChange(api);
return { message: 'DNS record added successfully' }; return { message: "DNS record added successfully" };
} }
case 'override_dns': { case "override_dns": {
const override = formData.get('override_dns')?.toString(); const override = formData.get("override_dns")?.toString();
if (!override) { if (!override) {
return data({ success: false }, 400); return data({ success: false }, 400);
} }
const overrideValue = override === 'true'; const overrideValue = override === "true";
await context.hs.patch([ await context.hs.patch([
{ {
path: 'dns.override_local_dns', path: "dns.override_local_dns",
value: overrideValue, value: overrideValue,
}, },
]); ]);
await context.integration?.onConfigChange(api); await context.integration?.onConfigChange(api);
return { message: 'DNS override updated successfully' }; return { message: "DNS override updated successfully" };
} }
default: default:
return data({ success: false }, 400); return data({ success: false }, 400);
} }
} }
+86 -98
View File
@@ -1,115 +1,103 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { useLoaderData } from 'react-router'; import { useLoaderData } from "react-router";
import Code from '~/components/Code';
import Notice from '~/components/Notice'; import Code from "~/components/Code";
import type { LoadContext } from '~/server'; import Notice from "~/components/Notice";
import { Capabilities } from '~/server/web/roles'; import type { LoadContext } from "~/server";
import ManageDomains from './components/manage-domains'; import { Capabilities } from "~/server/web/roles";
import ManageNS from './components/manage-ns';
import ManageRecords from './components/manage-records'; import ManageDomains from "./components/manage-domains";
import RenameTailnet from './components/rename-tailnet'; import ManageNS from "./components/manage-ns";
import ToggleMagic from './components/toggle-magic'; import ManageRecords from "./components/manage-records";
import { dnsAction } from './dns-actions'; import RenameTailnet from "./components/rename-tailnet";
import ToggleMagic from "./components/toggle-magic";
import { dnsAction } from "./dns-actions";
// We do not want to expose every config value // We do not want to expose every config value
export async function loader({ export async function loader({ request, context }: LoaderFunctionArgs<LoadContext>) {
request, if (!context.hs.readable()) {
context, throw new Error("No configuration is available");
}: LoaderFunctionArgs<LoadContext>) { }
if (!context.hs.readable()) {
throw new Error('No configuration is available');
}
const check = await context.sessions.check( const principal = await context.auth.require(request);
request, const check = context.auth.can(principal, Capabilities.read_network);
Capabilities.read_network, if (!check) {
); // Not authorized to view this page
if (!check) { throw new Error(
// Not authorized to view this page "You do not have permission to view this page. Please contact your administrator.",
throw new Error( );
'You do not have permission to view this page. Please contact your administrator.', }
);
}
const writablePermission = await context.sessions.check( const writablePermission = context.auth.can(principal, Capabilities.write_network);
request,
Capabilities.write_network,
);
const config = context.hs.c!; const config = context.hs.c!;
const dns = { const dns = {
prefixes: config.prefixes, prefixes: config.prefixes,
magicDns: config.dns.magic_dns, magicDns: config.dns.magic_dns,
baseDomain: config.dns.base_domain, baseDomain: config.dns.base_domain,
nameservers: config.dns.nameservers.global, nameservers: config.dns.nameservers.global,
splitDns: config.dns.nameservers.split, splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains, searchDomains: config.dns.search_domains,
overrideDns: config.dns.override_local_dns, overrideDns: config.dns.override_local_dns,
extraRecords: context.hs.d, extraRecords: context.hs.d,
}; };
return { return {
...dns, ...dns,
access: writablePermission, access: writablePermission,
writable: context.hs.writable(), writable: context.hs.writable(),
}; };
} }
export async function action(data: ActionFunctionArgs) { export async function action(data: ActionFunctionArgs) {
return dnsAction(data); return dnsAction(data);
} }
export default function Page() { export default function Page() {
const data = useLoaderData<typeof loader>(); const data = useLoaderData<typeof loader>();
const allNs: Record<string, string[]> = {}; const allNs: Record<string, string[]> = {};
for (const key of Object.keys(data.splitDns)) { for (const key of Object.keys(data.splitDns)) {
allNs[key] = data.splitDns[key]; allNs[key] = data.splitDns[key];
} }
allNs.global = data.nameservers; allNs.global = data.nameservers;
const isDisabled = data.access === false || data.writable === false; const isDisabled = data.access === false || data.writable === false;
return ( return (
<div className="flex flex-col gap-16 max-w-(--breakpoint-lg)"> <div className="flex max-w-(--breakpoint-lg) flex-col gap-16">
{data.writable ? undefined : ( {data.writable ? undefined : (
<Notice> <Notice>
The Headscale configuration is read-only. You cannot make changes to The Headscale configuration is read-only. You cannot make changes to the configuration
the configuration </Notice>
</Notice> )}
)} {data.access ? undefined : (
{data.access ? undefined : ( <Notice>
<Notice> Your permissions do not allow you to modify the DNS settings for this tailnet.
Your permissions do not allow you to modify the DNS settings for this </Notice>
tailnet. )}
</Notice> <RenameTailnet isDisabled={isDisabled} name={data.baseDomain} />
)} <ManageNS isDisabled={isDisabled} nameservers={allNs} overrideLocalDns={data.overrideDns} />
<RenameTailnet isDisabled={isDisabled} name={data.baseDomain} /> <ManageRecords isDisabled={isDisabled} records={data.extraRecords} />
<ManageNS <ManageDomains
isDisabled={isDisabled} isDisabled={isDisabled}
nameservers={allNs} magic={data.magicDns ? data.baseDomain : undefined}
overrideLocalDns={data.overrideDns} searchDomains={data.searchDomains}
/> />
<ManageRecords isDisabled={isDisabled} records={data.extraRecords} />
<ManageDomains
isDisabled={isDisabled}
magic={data.magicDns ? data.baseDomain : undefined}
searchDomains={data.searchDomains}
/>
<div className="flex flex-col w-full sm:w-2/3"> <div className="flex w-full flex-col sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1> <h1 className="mb-4 text-2xl font-medium">Magic DNS</h1>
<p className="mb-4"> <p className="mb-4">
Automatically register domain names for each device on the tailnet. Automatically register domain names for each device on the tailnet. Devices will be
Devices will be accessible at{' '} accessible at{" "}
<Code> <Code>
[device]. [device].
{data.baseDomain} {data.baseDomain}
</Code>{' '} </Code>{" "}
when Magic DNS is enabled. when Magic DNS is enabled.
</p> </p>
<ToggleMagic isDisabled={isDisabled} isEnabled={data.magicDns} /> <ToggleMagic isDisabled={isDisabled} isEnabled={data.magicDns} />
</div> </div>
</div> </div>
); );
} }
+6 -8
View File
@@ -6,11 +6,12 @@ import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/machine"; import type { Route } from "./+types/machine";
export async function machineAction({ request, context }: Route.ActionArgs) { export async function machineAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request); const principal = await context.auth.require(request);
const check = await context.sessions.check(request, Capabilities.write_machines);
const formData = await request.formData(); const formData = await request.formData();
const api = context.hsApi.getRuntimeClient(session.api_key); const api = context.hsApi.getRuntimeClient(
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
);
const action = formData.get("action_id")?.toString(); const action = formData.get("action_id")?.toString();
if (!action) { if (!action) {
@@ -21,7 +22,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
// Fast track register since it doesn't require an existing machine // Fast track register since it doesn't require an existing machine
if (action === "register") { if (action === "register") {
if (!check) { if (!context.auth.can(principal, Capabilities.write_machines)) {
throw data("You do not have permission to manage machines", { throw data("You do not have permission to manage machines", {
status: 403, status: 403,
}); });
@@ -60,10 +61,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
}); });
} }
// Tag-only nodes (Headscale 0.28+) have no user — only role-based permissions apply if (!context.auth.canManageNode(principal, node)) {
const nodeOwnerId = node.user?.providerId?.split("/").pop();
const isOwner = nodeOwnerId !== undefined && nodeOwnerId === session.user.subject;
if (!isOwner && !check) {
throw data("You do not have permission to act on this machine", { throw data("You do not have permission to act on this machine", {
status: 403, status: 403,
}); });
+4 -2
View File
@@ -21,7 +21,7 @@ import Routes from "./dialogs/routes";
import { machineAction } from "./machine-actions"; import { machineAction } from "./machine-actions";
export async function loader({ request, params, context }: Route.LoaderArgs) { export async function loader({ request, params, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request); const principal = await context.auth.require(request);
if (!params.id) { if (!params.id) {
throw new Error("No machine ID provided"); throw new Error("No machine ID provided");
} }
@@ -37,7 +37,9 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
} }
} }
const api = context.hsApi.getRuntimeClient(session.api_key); const api = context.hsApi.getRuntimeClient(
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
);
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]); const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
const node = nodes.find((node) => node.id === params.id); const node = nodes.find((node) => node.id === params.id);
+9 -15
View File
@@ -10,30 +10,24 @@ import cn from "~/utils/cn";
import { mapNodes, sortNodeTags } from "~/utils/node-info"; import { mapNodes, sortNodeTags } from "~/utils/node-info";
import type { Route } from "./+types/overview"; import type { Route } from "./+types/overview";
import MachineRow from "./components/machine-row"; import MachineRow from "./components/machine-row";
import NewMachine from "./dialogs/new"; import NewMachine from "./dialogs/new";
import { machineAction } from "./machine-actions"; import { machineAction } from "./machine-actions";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request); const principal = await context.auth.require(request);
const user = session.user;
if (!user) {
throw new Error("Missing user session. Please log in again.");
}
const check = await context.sessions.check(request, Capabilities.read_machines); if (!context.auth.can(principal, Capabilities.read_machines)) {
if (!check) {
// Not authorized to view this page
throw new Error( throw new Error(
"You do not have permission to view this page. Please contact your administrator.", "You do not have permission to view this page. Please contact your administrator.",
); );
} }
const writablePermission = await context.sessions.check(request, Capabilities.write_machines); const writablePermission = context.auth.can(principal, Capabilities.write_machines);
const api = context.hsApi.getRuntimeClient(session.api_key); const api = context.hsApi.getRuntimeClient(
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
);
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]); const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
let magic: string | undefined; let magic: string | undefined;
@@ -56,8 +50,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
publicServer: context.config.headscale.public_url, publicServer: context.config.headscale.public_url,
agent: context.agents?.agentID(), agent: context.agents?.agentID(),
writable: writablePermission, writable: writablePermission,
preAuth: await context.sessions.check(request, Capabilities.generate_authkeys), preAuth: context.auth.can(principal, Capabilities.generate_authkeys),
subject: user.subject, headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
supportsNodeOwnerChange: supportsNodeOwnerChange, supportsNodeOwnerChange: supportsNodeOwnerChange,
}; };
} }
@@ -363,7 +357,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
isDisabled={ isDisabled={
loaderData.writable loaderData.writable
? false // If the user has write permissions, they can edit all machines ? false // If the user has write permissions, they can edit all machines
: node.user?.providerId?.split("/").pop() !== loaderData.subject : node.user?.id !== loaderData.headscaleUserId
} }
key={node.id} key={node.id}
magic={loaderData.magic} magic={loaderData.magic}
+8 -6
View File
@@ -1,15 +1,17 @@
import { data } from "react-router"; import { data } from "react-router";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview"; import type { Route } from "./+types/overview";
export async function authKeysAction({ request, context }: Route.ActionArgs) { export async function authKeysAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request); const principal = await context.auth.require(request);
const api = context.hsApi.getRuntimeClient(session.api_key); const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const canGenerateAny = await context.sessions.check(request, Capabilities.generate_authkeys); const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = await context.sessions.check(request, Capabilities.generate_own_authkeys); const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
if (!canGenerateAny && !canGenerateOwn) { if (!canGenerateAny && !canGenerateOwn) {
throw data("You do not have permission to manage pre-auth keys", { throw data("You do not have permission to manage pre-auth keys", {
@@ -23,8 +25,8 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
if (!targetUser) { if (!targetUser) {
throw data("User not found.", { status: 404 }); throw data("User not found.", { status: 404 });
} }
const targetSubject = targetUser.providerId?.split("/").pop(); const targetSubject = getOidcSubject(targetUser);
if (targetSubject !== session.user.subject) { if (principal.kind !== "oidc" || targetSubject !== principal.user.subject) {
throw data("You do not have permission to manage this user's pre-auth keys", { throw data("You do not have permission to manage this user's pre-auth keys", {
status: 403, status: 403,
}); });
@@ -17,11 +17,15 @@ interface AddAuthKeyProps {
users: User[]; users: User[];
url: string; url: string;
selfServiceOnly: boolean; selfServiceOnly: boolean;
currentSubject: string; currentSubject?: string;
} }
function findCurrentUser(users: User[], subject: string): User | undefined { function findCurrentUser(users: User[], subject: string | undefined): User | undefined {
return users.find((u) => u.providerId?.split("/").pop() === subject); if (!subject) return undefined;
return users.find((u) => {
if (u.provider !== "oidc" || !u.providerId) return false;
return u.providerId.split("/").pop() === subject;
});
} }
export default function AddAuthKey({ export default function AddAuthKey({
+6 -5
View File
@@ -19,8 +19,9 @@ import AuthKeyRow from "./auth-key-row";
import AddAuthKey from "./dialogs/add-auth-key"; import AddAuthKey from "./dialogs/add-auth-key";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request); const principal = await context.auth.require(request);
const api = context.hsApi.getRuntimeClient(session.api_key); const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const users = await api.getUsers(); const users = await api.getUsers();
@@ -83,8 +84,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
.map(({ user, error }) => ({ user, error })); .map(({ user, error }) => ({ user, error }));
} }
const canGenerateAny = await context.sessions.check(request, Capabilities.generate_authkeys); const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = await context.sessions.check(request, Capabilities.generate_own_authkeys); const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
return { return {
keys, keys,
@@ -92,7 +93,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
users, users,
access: canGenerateAny || canGenerateOwn, access: canGenerateAny || canGenerateOwn,
selfServiceOnly: !canGenerateAny && canGenerateOwn, selfServiceOnly: !canGenerateAny && canGenerateOwn,
currentSubject: session.user.subject, currentSubject: principal.kind === "oidc" ? principal.user.subject : undefined,
url: context.config.headscale.public_url ?? context.config.headscale.url, url: context.config.headscale.public_url ?? context.config.headscale.url,
}; };
} }
+1 -1
View File
@@ -6,7 +6,7 @@ import Link from "~/components/Link";
import type { Route } from "./+types/overview"; import type { Route } from "./+types/overview";
export async function loader({ context }: Route.LoaderArgs) { export async function loader({ context }: Route.LoaderArgs) {
const oidcConnector = await context.oidcConnector?.get(); const oidcConnector = await context.oidc?.connector.get();
return { return {
config: context.hs.writable(), config: context.hs.writable(),
isOidcEnabled: oidcConnector?.isValid ?? false, isOidcEnabled: oidcConnector?.isValid ?? false,
+160 -169
View File
@@ -1,198 +1,189 @@
import { data } from 'react-router'; import { data } from "react-router";
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
export async function restrictionAction({ import { Capabilities } from "~/server/web/roles";
request,
context,
}: Route.ActionArgs) {
const check = await context.sessions.check(
request,
Capabilities.configure_iam,
);
if (!check) { import type { Route } from "./+types/overview";
throw data('You do not have permission to modify IAM settings.', {
status: 403,
});
}
if (!context.hs.writable()) { export async function restrictionAction({ request, context }: Route.ActionArgs) {
throw data('The Headscale configuration file is not editable.', { const principal = await context.auth.require(request);
status: 403, const check = context.auth.can(principal, Capabilities.configure_iam);
});
}
const formData = await request.formData(); if (!check) {
const action = formData.get('action_id')?.toString(); throw data("You do not have permission to modify IAM settings.", {
if (!action) { status: 403,
throw data('No action provided.', { });
status: 400, }
});
}
// We only need healthchecks which don't rely on an API key if (!context.hs.writable()) {
const api = context.hsApi.getRuntimeClient('fake-api-key'); throw data("The Headscale configuration file is not editable.", {
switch (action) { status: 403,
case 'add_domain': { });
const domain = formData.get('domain')?.toString()?.trim(); }
if (!domain) {
throw data('No domain provided.', {
status: 400,
});
}
const domains = [ const formData = await request.formData();
...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain]), const action = formData.get("action_id")?.toString();
]; if (!action) {
throw data("No action provided.", {
status: 400,
});
}
await context.hs.patch([ // We only need healthchecks which don't rely on an API key
{ const api = context.hsApi.getRuntimeClient("fake-api-key");
path: 'oidc.allowed_domains', switch (action) {
value: domains, case "add_domain": {
}, const domain = formData.get("domain")?.toString()?.trim();
]); if (!domain) {
throw data("No domain provided.", {
status: 400,
});
}
context.integration?.onConfigChange(api); const domains = [...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain])];
return data('Domain added successfully.');
}
case 'remove_domain': { await context.hs.patch([
const domain = formData.get('domain')?.toString()?.trim(); {
if (!domain) { path: "oidc.allowed_domains",
throw data('No domain provided.', { value: domains,
status: 400, },
}); ]);
}
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? []; context.integration?.onConfigChange(api);
if (!storedDomains.includes(domain)) { return data("Domain added successfully.");
// Domain not found in the list }
throw data(`Domain "${domain}" not found in allowed domains.`, {
status: 400,
});
}
// Filter out the domain to remove it from the list case "remove_domain": {
const domains = storedDomains.filter((d: string) => d !== domain); const domain = formData.get("domain")?.toString()?.trim();
await context.hs.patch([ if (!domain) {
{ throw data("No domain provided.", {
path: 'oidc.allowed_domains', status: 400,
value: domains, });
}, }
]);
context.integration?.onConfigChange(api);
return data('Domain removed successfully.');
}
case 'add_group': { const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
const group = formData.get('group')?.toString()?.trim(); if (!storedDomains.includes(domain)) {
if (!group) { // Domain not found in the list
throw data('No group provided.', { throw data(`Domain "${domain}" not found in allowed domains.`, {
status: 400, status: 400,
}); });
} }
const groups = [ // Filter out the domain to remove it from the list
...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group]), const domains = storedDomains.filter((d: string) => d !== domain);
]; await context.hs.patch([
{
path: "oidc.allowed_domains",
value: domains,
},
]);
context.integration?.onConfigChange(api);
return data("Domain removed successfully.");
}
await context.hs.patch([ case "add_group": {
{ const group = formData.get("group")?.toString()?.trim();
path: 'oidc.allowed_groups', if (!group) {
value: groups, throw data("No group provided.", {
}, status: 400,
]); });
}
context.integration?.onConfigChange(api); const groups = [...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group])];
return data('Group added successfully.');
}
case 'remove_group': { await context.hs.patch([
const group = formData.get('group')?.toString()?.trim(); {
if (!group) { path: "oidc.allowed_groups",
throw data('No group provided.', { value: groups,
status: 400, },
}); ]);
}
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? []; context.integration?.onConfigChange(api);
if (!storedGroups.includes(group)) { return data("Group added successfully.");
// Group not found in the list }
throw data(`Group "${group}" not found in allowed groups.`, {
status: 400,
});
}
// Filter out the group to remove it from the list case "remove_group": {
const groups = storedGroups.filter((d: string) => d !== group); const group = formData.get("group")?.toString()?.trim();
await context.hs.patch([ if (!group) {
{ throw data("No group provided.", {
path: 'oidc.allowed_groups', status: 400,
value: groups, });
}, }
]);
context.integration?.onConfigChange(api); const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
return data('Group removed successfully.'); if (!storedGroups.includes(group)) {
} // Group not found in the list
throw data(`Group "${group}" not found in allowed groups.`, {
status: 400,
});
}
case 'add_user': { // Filter out the group to remove it from the list
const user = formData.get('user')?.toString()?.trim(); const groups = storedGroups.filter((d: string) => d !== group);
if (!user) { await context.hs.patch([
throw data('No user provided.', { {
status: 400, path: "oidc.allowed_groups",
}); value: groups,
} },
]);
const users = [ context.integration?.onConfigChange(api);
...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user]), return data("Group removed successfully.");
]; }
await context.hs.patch([ case "add_user": {
{ const user = formData.get("user")?.toString()?.trim();
path: 'oidc.allowed_users', if (!user) {
value: users, throw data("No user provided.", {
}, status: 400,
]); });
}
context.integration?.onConfigChange(api); const users = [...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user])];
return data('User added successfully.');
}
case 'remove_user': { await context.hs.patch([
const user = formData.get('user')?.toString()?.trim(); {
if (!user) { path: "oidc.allowed_users",
throw data('No user provided.', { value: users,
status: 400, },
}); ]);
}
const storedUsers = context.hs.c?.oidc?.allowed_users ?? []; context.integration?.onConfigChange(api);
if (!storedUsers.includes(user)) { return data("User added successfully.");
// User not found in the list }
throw data(`User "${user}" not found in allowed users.`, {
status: 400,
});
}
// Filter out the user to remove it from the list case "remove_user": {
const users = storedUsers.filter((d: string) => d !== user); const user = formData.get("user")?.toString()?.trim();
await context.hs.patch([ if (!user) {
{ throw data("No user provided.", {
path: 'oidc.allowed_users', status: 400,
value: users, });
}, }
]);
context.integration?.onConfigChange(api); const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
return data('User removed successfully.'); if (!storedUsers.includes(user)) {
} // User not found in the list
throw data(`User "${user}" not found in allowed users.`, {
status: 400,
});
}
default: { // Filter out the user to remove it from the list
throw data('Invalid action provided.', { const users = storedUsers.filter((d: string) => d !== user);
status: 400, await context.hs.patch([
}); {
} path: "oidc.allowed_users",
} value: users,
},
]);
context.integration?.onConfigChange(api);
return data("User removed successfully.");
}
default: {
throw data("Invalid action provided.", {
status: 400,
});
}
}
} }
+81 -98
View File
@@ -1,108 +1,91 @@
import { data, Link as RemixLink } from 'react-router'; import { data, Link as RemixLink } from "react-router";
import Link from '~/components/Link';
import Notice from '~/components/Notice'; import Link from "~/components/Link";
import { Capabilities } from '~/server/web/roles'; import Notice from "~/components/Notice";
import type { Route } from './+types/overview'; import { Capabilities } from "~/server/web/roles";
import { restrictionAction } from './actions';
import AddDomain from './dialogs/add-domain'; import type { Route } from "./+types/overview";
import AddGroup from './dialogs/add-group'; import { restrictionAction } from "./actions";
import AddUser from './dialogs/add-user'; import AddDomain from "./dialogs/add-domain";
import RestrictionTable from './table'; import AddGroup from "./dialogs/add-group";
import AddUser from "./dialogs/add-user";
import RestrictionTable from "./table";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const check = await context.sessions.check(request, Capabilities.read_users); const principal = await context.auth.require(request);
if (!check) { const check = context.auth.can(principal, Capabilities.read_users);
throw data('You do not have permission to view IAM settings.', { if (!check) {
status: 403, throw data("You do not have permission to view IAM settings.", {
}); status: 403,
} });
}
if (!context.hs.c?.oidc) { if (!context.hs.c?.oidc) {
throw data('OIDC is not configured on this Headscale instance.', { throw data("OIDC is not configured on this Headscale instance.", {
status: 501, status: 501,
}); });
} }
return { return {
access: await context.sessions.check(request, Capabilities.configure_iam), access: context.auth.can(principal, Capabilities.configure_iam),
writable: context.hs.writable(), writable: context.hs.writable(),
settings: { settings: {
domains: [...new Set(context.hs.c.oidc.allowed_domains)], domains: [...new Set(context.hs.c.oidc.allowed_domains)],
groups: [...new Set(context.hs.c.oidc.allowed_groups)], groups: [...new Set(context.hs.c.oidc.allowed_groups)],
users: [...new Set(context.hs.c.oidc.allowed_users)], users: [...new Set(context.hs.c.oidc.allowed_users)],
}, },
}; };
} }
export const action = restrictionAction; export const action = restrictionAction;
export default function Page({ export default function Page({ loaderData: { access, writable, settings } }: Route.ComponentProps) {
loaderData: { access, writable, settings }, const isDisabled = writable ? !access : true;
}: Route.ComponentProps) {
const isDisabled = writable ? !access : true;
return ( return (
<div className="flex flex-col gap-4 max-w-(--breakpoint-lg)"> <div className="flex max-w-(--breakpoint-lg) flex-col gap-4">
<div className="flex flex-col w-full sm:w-2/3"> <div className="flex w-full flex-col sm:w-2/3">
<p className="mb-4 text-md"> <p className="text-md mb-4">
<RemixLink className="font-medium" to="/settings"> <RemixLink className="font-medium" to="/settings">
Settings Settings
</RemixLink> </RemixLink>
<span className="mx-2">/</span> Authentication Restrictions <span className="mx-2">/</span> Authentication Restrictions
</p> </p>
{!access ? ( {!access ? (
<Notice <Notice title="Authentication permissions restricted" variant="warning">
title="Authentication permissions restricted" You do not have the necessary permissions to edit the Authentication Restrictions
variant="warning" settings. Please contact your administrator to request access or to make changes to
> these settings.
You do not have the necessary permissions to edit the Authentication </Notice>
Restrictions settings. Please contact your administrator to request ) : !writable ? (
access or to make changes to these settings. <Notice title="Configuration Locked" variant="error">
</Notice> The Headscale configuration file is not editable through the web interface. Please
) : !writable ? ( ensure that you have correctly given Headplane write access to the file.
<Notice title="Configuration Locked" variant="error"> </Notice>
The Headscale configuration file is not editable through the web ) : undefined}
interface. Please ensure that you have correctly given Headplane <h1 className="mt-4 mb-2 text-2xl font-medium">Authentication Restrictions</h1>
write access to the file. <p>
</Notice> Headscale supports restricting OIDC authentication to only allow certain email domains,
) : undefined} groups, or users to authenticate. This can be used to limit access to your Tailnet to only
<h1 className="text-2xl font-medium mb-2 mt-4"> certain users or groups and Headplane will also respect these settings when
Authentication Restrictions authenticating.{" "}
</h1> <Link
<p> name="Headscale OIDC documentation"
Headscale supports restricting OIDC authentication to only allow to="https://headscale.net/stable/ref/oidc/#basic-configuration"
certain email domains, groups, or users to authenticate. This can be >
used to limit access to your Tailnet to only certain users or groups Learn More
and Headplane will also respect these settings when authenticating.{' '} </Link>
<Link </p>
name="Headscale OIDC documentation" </div>
to="https://headscale.net/stable/ref/oidc/#basic-configuration" <RestrictionTable isDisabled={isDisabled} type="domain" values={settings.domains}>
> <AddDomain domains={settings.domains} isDisabled={isDisabled} />
Learn More </RestrictionTable>
</Link> <RestrictionTable isDisabled={isDisabled} type="group" values={settings.groups}>
</p> <AddGroup groups={settings.groups} isDisabled={isDisabled} />
</div> </RestrictionTable>
<RestrictionTable <RestrictionTable isDisabled={isDisabled} type="user" values={settings.users}>
isDisabled={isDisabled} <AddUser isDisabled={isDisabled} users={settings.users} />
type="domain" </RestrictionTable>
values={settings.domains} </div>
> );
<AddDomain domains={settings.domains} isDisabled={isDisabled} />
</RestrictionTable>
<RestrictionTable
isDisabled={isDisabled}
type="group"
values={settings.groups}
>
<AddGroup groups={settings.groups} isDisabled={isDisabled} />
</RestrictionTable>
<RestrictionTable
isDisabled={isDisabled}
type="user"
values={settings.users}
>
<AddUser isDisabled={isDisabled} users={settings.users} />
</RestrictionTable>
</div>
);
} }
+8 -13
View File
@@ -6,10 +6,10 @@ import { data, type ShouldRevalidateFunction, useSubmit } from "react-router";
import { ExternalScriptsHandle } from "remix-utils/external-scripts"; import { ExternalScriptsHandle } from "remix-utils/external-scripts";
import { EphemeralNodeInsert, ephemeralNodes } from "~/server/db/schema"; import { EphemeralNodeInsert, ephemeralNodes } from "~/server/db/schema";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import { useLiveData } from "~/utils/live-data"; import { useLiveData } from "~/utils/live-data";
import type { Route } from "./+types/console"; import type { Route } from "./+types/console";
import UserPrompt from "./user-prompt"; import UserPrompt from "./user-prompt";
import XTerm from "./xterm.client"; import XTerm from "./xterm.client";
@@ -35,28 +35,23 @@ export async function loader({ request, context }: Route.LoaderArgs) {
throw data("WebSSH is only available with the Headplane agent integration", 400); throw data("WebSSH is only available with the Headplane agent integration", 400);
} }
const session = await context.sessions.auth(request); const principal = await context.auth.require(request);
if (session.user.subject === "unknown-non-oauth") { if (principal.kind === "api_key") {
throw data("Only OAuth users are allowed to use WebSSH", 403); throw data("Only OAuth users are allowed to use WebSSH", 403);
} }
const api = context.hsApi.getRuntimeClient(session.api_key); const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const users = await api.getUsers(); const users = await api.getUsers();
// MARK: This assumes that a user has authenticated with Headscale first // MARK: This assumes that a user has authenticated with Headscale first
// Since the only way to enforce permissions via ACLs is to generate a // Since the only way to enforce permissions via ACLs is to generate a
// pre-authkey which REQUIRES a user ID, meaning the user has to have // pre-authkey which REQUIRES a user ID, meaning the user has to have
// authenticated with Headscale first. // authenticated with Headscale first.
const lookup = users.find((u) => { const lookup = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
const subject = u.providerId?.split("/").pop();
if (!subject) {
return false;
}
return subject === session.user.subject;
});
if (!lookup) { if (!lookup) {
throw data(`User with subject ${session.user.subject} not found within Headscale`, 404); throw data(`User with subject ${principal.user.subject} not found within Headscale`, 404);
} }
const preAuthKey = await api.createPreAuthKey( const preAuthKey = await api.createPreAuthKey(
@@ -157,7 +152,7 @@ function generateHostname(username: string) {
} }
export async function action({ request, context }: Route.ActionArgs) { export async function action({ request, context }: Route.ActionArgs) {
await context.sessions.auth(request); await context.auth.require(request);
if (!context.agents?.agentID()) { if (!context.agents?.agentID()) {
throw data("WebSSH is only available with the Headplane agent integration", 400); throw data("WebSSH is only available with the Headplane agent integration", 400);
} }
+45 -16
View File
@@ -1,20 +1,49 @@
import { eq } from 'drizzle-orm'; import { eq } from "drizzle-orm";
import { redirect } from 'react-router'; import { redirect } from "react-router";
import { users } from '~/server/db/schema';
import type { Route } from './+types/onboarding-skip'; import { users } from "~/server/db/schema";
import type { Route } from "./+types/onboarding-skip";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
try { try {
const { user } = await context.sessions.auth(request); const principal = await context.auth.require(request);
await context.db if (principal.kind !== "oidc") {
.update(users) return redirect("/machines");
.set({ }
onboarded: true,
})
.where(eq(users.sub, user.subject));
return redirect('/machines'); await context.db
} catch { .update(users)
return redirect('/login'); .set({ onboarded: true })
} .where(eq(users.sub, principal.user.subject));
return redirect("/machines");
} catch {
return redirect("/login");
}
}
export async function action({ request, context }: Route.ActionArgs) {
try {
const principal = await context.auth.require(request);
if (principal.kind !== "oidc") {
return redirect("/machines");
}
const formData = await request.formData();
const headscaleUserId = formData.get("headscale_user_id")?.toString();
if (headscaleUserId) {
await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId);
}
await context.db
.update(users)
.set({ onboarded: true })
.where(eq(users.sub, principal.user.subject));
return redirect("/machines");
} catch {
return redirect("/login");
}
} }
+75 -30
View File
@@ -1,27 +1,29 @@
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { NavLink } from "react-router"; import { Form, NavLink } from "react-router";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Card from "~/components/Card"; import Card from "~/components/Card";
import Link from "~/components/Link"; import Link from "~/components/Link";
import Options from "~/components/Options"; import Options from "~/components/Options";
import StatusCircle from "~/components/StatusCircle"; import StatusCircle from "~/components/StatusCircle";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import { Machine } from "~/types"; import { Machine } from "~/types";
import cn from "~/utils/cn"; import cn from "~/utils/cn";
import { useLiveData } from "~/utils/live-data"; import { useLiveData } from "~/utils/live-data";
import log from "~/utils/log"; import log from "~/utils/log";
import toast from "~/utils/toast"; import toast from "~/utils/toast";
import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/onboarding"; import type { Route } from "./+types/onboarding";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request); const principal = await context.auth.require(request);
if (principal.kind !== "oidc") {
throw new Error("Onboarding is only available for OIDC users.");
}
// Try to determine the OS split between Linux, Windows, macOS, iOS, and Android
// We need to convert this to a known value to return it to the client so we can
// automatically tab to the correct download button.
const userAgent = request.headers.get("user-agent"); const userAgent = request.headers.get("user-agent");
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/); const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
let osValue = "linux"; let osValue = "linux";
@@ -47,45 +49,58 @@ export async function loader({ request, context }: Route.LoaderArgs) {
break; break;
} }
const api = context.hsApi.getRuntimeClient(session.api_key); const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const hsUserId = principal.user.headscaleUserId;
let firstMachine: Machine | undefined; let firstMachine: Machine | undefined;
let needsUserLink = false;
let headscaleUsers: { id: string; name: string }[] = [];
try { try {
const nodes = await api.getNodes(); const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
const node = nodes.find((n) => {
// Tag-only nodes have no user if (hsUserId) {
if (!n.user || n.user.provider !== "oidc") { firstMachine = nodes.find((n) => n.user?.id === hsUserId);
return false; } else {
const matched = findHeadscaleUserBySubject(
apiUsers,
principal.user.subject,
principal.profile.email,
);
if (matched) {
await context.auth.linkHeadscaleUser(principal.user.id, matched.id);
firstMachine = nodes.find((n) => n.user?.id === matched.id);
} else {
needsUserLink = true;
headscaleUsers = apiUsers.map((u) => ({
id: u.id,
name: getUserDisplayName(u),
}));
} }
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = n.user.providerId?.split("/").pop();
if (!subject) {
return false;
}
if (subject !== session.user.subject) {
return false;
}
return true;
});
firstMachine = node;
} catch (e) { } catch (e) {
// If we cannot lookup nodes, we cannot proceed
log.debug("api", "Failed to lookup nodes %o", e); log.debug("api", "Failed to lookup nodes %o", e);
} }
return { return {
user: session.user, user: {
subject: principal.user.subject,
name: principal.profile.name,
email: principal.profile.email,
username: principal.profile.username,
picture: principal.profile.picture,
},
osValue, osValue,
firstMachine, firstMachine,
needsUserLink,
headscaleUsers,
}; };
} }
export default function Page({ export default function Page({
loaderData: { user, osValue, firstMachine }, loaderData: { user, osValue, firstMachine, needsUserLink, headscaleUsers },
}: Route.ComponentProps) { }: Route.ComponentProps) {
const { pause, resume } = useLiveData(); const { pause, resume } = useLiveData();
useEffect(() => { useEffect(() => {
@@ -107,6 +122,36 @@ export default function Page({
return ( return (
<div className="fixed flex h-screen w-full items-center px-4"> <div className="fixed flex h-screen w-full items-center px-4">
<div className="mx-auto mb-24 grid w-fit grid-cols-1 gap-4 md:grid-cols-2"> <div className="mx-auto mb-24 grid w-fit grid-cols-1 gap-4 md:grid-cols-2">
{needsUserLink && headscaleUsers.length > 0 ? (
<Card className="col-span-2 mx-auto max-w-lg" variant="flat">
<Card.Title className="mb-4">Link your Headscale account</Card.Title>
<Card.Text className="mb-4">
Headplane couldn't automatically match your SSO identity to a Headscale user. Select
which Headscale user you are to continue.
</Card.Text>
<Form method="POST" action="/onboarding/skip">
<select
className={cn(
"w-full rounded-lg border p-2 mb-4",
"border-headplane-200 dark:border-headplane-700",
"bg-headplane-50 dark:bg-headplane-900",
)}
name="headscale_user_id"
required
>
<option value="">Select a user...</option>
{headscaleUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
<Button className="w-full" type="submit" variant="heavy">
Link and Continue
</Button>
</Form>
</Card>
) : undefined}
<Card className="max-w-lg" variant="flat"> <Card className="max-w-lg" variant="flat">
<Card.Title className="mb-8"> <Card.Title className="mb-8">
Welcome! Welcome!
+13 -21
View File
@@ -1,13 +1,13 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { Machine, User } from "~/types"; import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
import type { Machine, User } from "~/types";
import cn from "~/utils/cn"; import cn from "~/utils/cn";
import type { Route } from "./+types/overview"; import type { Route } from "./+types/overview";
import ManageBanner from "./components/manage-banner"; import ManageBanner from "./components/manage-banner";
import UserRow from "./components/user-row"; import UserRow from "./components/user-row";
import { userAction } from "./user-actions"; import { userAction } from "./user-actions";
@@ -17,8 +17,8 @@ interface UserMachine extends User {
} }
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request); const principal = await context.auth.require(request);
const check = await context.sessions.check(request, Capabilities.read_users); const check = await context.auth.can(principal, Capabilities.read_users);
if (!check) { if (!check) {
// Not authorized to view this page // Not authorized to view this page
throw new Error( throw new Error(
@@ -26,9 +26,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
); );
} }
const writablePermission = await context.sessions.check(request, Capabilities.write_users); const writablePermission = await context.auth.can(principal, Capabilities.write_users);
const api = context.hsApi.getRuntimeClient(session.api_key); const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]); const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
const users = apiUsers.map((user) => ({ const users = apiUsers.map((user) => ({
@@ -56,22 +57,13 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return "no-oidc"; return "no-oidc";
} }
if (user.provider === "oidc" && user.providerId) { const subject = getOidcSubject(user);
// For some reason, headscale makes providerID a url where the if (!subject) {
// last component is the subject, so we need to strip that out return "invalid-oidc";
const subject = user.providerId.split("/").pop();
if (!subject) {
return "invalid-oidc";
}
const role = await context.sessions.roleForSubject(subject);
return role ?? "no-role";
} }
// No role means the user is not registered in Headplane, but they const role = await context.auth.roleForSubject(subject);
// are in Headscale. We also need to handle what happens if someone return role ?? "no-role";
// logs into the UI and they don't have a Headscale setup.
return "no-role";
}), }),
); );
+96 -99
View File
@@ -1,115 +1,112 @@
import { data } from 'react-router'; import { data } from "react-router";
import { Capabilities, Roles } from '~/server/web/roles';
import type { Route } from './+types/overview'; import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
import type { Route } from "./+types/overview";
export async function userAction({ request, context }: Route.ActionArgs) { export async function userAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request); const principal = await context.auth.require(request);
const check = await context.sessions.check(request, Capabilities.write_users); const check = await context.auth.can(principal, Capabilities.write_users);
if (!check) { if (!check) {
throw data('You do not have permission to update users', { throw data("You do not have permission to update users", {
status: 403, status: 403,
}); });
} }
const formData = await request.formData(); const formData = await request.formData();
const action = formData.get('action_id')?.toString(); const action = formData.get("action_id")?.toString();
if (!action) { if (!action) {
throw data('Missing `action_id` in the form data.', { throw data("Missing `action_id` in the form data.", {
status: 404, status: 404,
}); });
} }
const api = context.hsApi.getRuntimeClient(session.api_key); const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
switch (action) { const api = context.hsApi.getRuntimeClient(apiKey);
case 'create_user': { switch (action) {
const name = formData.get('username')?.toString(); case "create_user": {
const displayName = formData.get('display_name')?.toString(); const name = formData.get("username")?.toString();
const email = formData.get('email')?.toString(); const displayName = formData.get("display_name")?.toString();
const email = formData.get("email")?.toString();
if (!name) { if (!name) {
throw data('Missing `username` in the form data.', { throw data("Missing `username` in the form data.", {
status: 400, status: 400,
}); });
} }
await api.createUser(name, email, displayName); await api.createUser(name, email, displayName);
return { message: 'User created successfully' }; return { message: "User created successfully" };
} }
case 'delete_user': { case "delete_user": {
const userId = formData.get('user_id')?.toString(); const userId = formData.get("user_id")?.toString();
if (!userId) { if (!userId) {
throw data('Missing `user_id` in the form data.', { throw data("Missing `user_id` in the form data.", {
status: 400, status: 400,
}); });
} }
await api.deleteUser(userId); await api.deleteUser(userId);
return { message: 'User deleted successfully' }; return { message: "User deleted successfully" };
} }
case 'rename_user': { case "rename_user": {
const userId = formData.get('user_id')?.toString(); const userId = formData.get("user_id")?.toString();
const newName = formData.get('new_name')?.toString(); const newName = formData.get("new_name")?.toString();
if (!userId || !newName) { if (!userId || !newName) {
return data({ success: false }, 400); return data({ success: false }, 400);
} }
const users = await api.getUsers(userId); const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId); const user = users.find((user) => user.id === userId);
if (!user) { if (!user) {
throw data(`No user found with id: ${userId}`, { status: 400 }); throw data(`No user found with id: ${userId}`, { status: 400 });
} }
if (user.provider === 'oidc') { if (user.provider === "oidc") {
// OIDC users cannot be renamed via this endpoint, return an error // OIDC users cannot be renamed via this endpoint, return an error
throw data('Users managed by OIDC cannot be renamed', { throw data("Users managed by OIDC cannot be renamed", {
status: 403, status: 403,
}); });
} }
await api.renameUser(userId, newName); await api.renameUser(userId, newName);
return { message: 'User renamed successfully' }; return { message: "User renamed successfully" };
} }
case 'reassign_user': { case "reassign_user": {
const userId = formData.get('user_id')?.toString(); const userId = formData.get("user_id")?.toString();
const newRole = formData.get('new_role')?.toString(); const newRole = formData.get("new_role")?.toString();
if (!userId || !newRole) { if (!userId || !newRole) {
throw data('Missing `user_id` or `new_role` in the form data.', { throw data("Missing `user_id` or `new_role` in the form data.", {
status: 400, status: 400,
}); });
} }
const users = await api.getUsers(userId); const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId); const user = users.find((user) => user.id === userId);
if (!user?.providerId) { if (!user) {
throw data('Specified user is not an OIDC user', { throw data("Specified user not found", {
status: 400, status: 400,
}); });
} }
// For some reason, headscale makes providerID a url where the const subject = getOidcSubject(user);
// last component is the subject, so we need to strip that out if (!subject) {
const subject = user.providerId?.split('/').pop(); throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
if (!subject) { }
throw data(
'Malformed `providerId` for the specified user. Cannot find subject.',
{ status: 400 },
);
}
const result = await context.sessions.reassignSubject( const result = await context.auth.reassignSubject(subject, newRole as Role);
subject,
newRole as keyof typeof Roles,
);
if (!result) { if (!result) {
throw data('Failed to reassign user role.', { status: 500 }); throw data("Failed to reassign user role.", { status: 500 });
} }
return { message: 'User reassigned successfully' }; return { message: "User reassigned successfully" };
} }
default: default:
throw data('Invalid `action_id` provided.', { throw data("Invalid `action_id` provided.", {
status: 400, status: 400,
}); });
} }
} }
+39 -41
View File
@@ -1,50 +1,48 @@
import { eq, isNotNull } from 'drizzle-orm'; import { eq, isNotNull } from "drizzle-orm";
import log from '~/utils/log';
import type { Route } from '../../layouts/+types/dashboard';
import { ephemeralNodes } from './schema';
export async function pruneEphemeralNodes({ import log from "~/utils/log";
context,
request,
}: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const ephemerals = await context.db
.select()
.from(ephemeralNodes)
.where(isNotNull(ephemeralNodes.node_key));
if (ephemerals.length === 0) { import type { Route } from "../../layouts/+types/dashboard";
log.debug('api', 'No ephemeral nodes to prune'); import { ephemeralNodes } from "./schema";
return;
}
const api = context.hsApi.getRuntimeClient(session.api_key); export async function pruneEphemeralNodes({ context, request }: Route.LoaderArgs) {
const nodes = await api.getNodes(); const principal = await context.auth.require(request);
const toPrune = nodes.filter((node) => { const ephemerals = await context.db
if (node.online) { .select()
return false; .from(ephemeralNodes)
} .where(isNotNull(ephemeralNodes.node_key));
return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key); if (ephemerals.length === 0) {
}); log.debug("api", "No ephemeral nodes to prune");
return;
}
if (toPrune.length === 0) { const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
log.debug('api', 'No SSH nodes to prune'); const api = context.hsApi.getRuntimeClient(apiKey);
return; const nodes = await api.getNodes();
} const toPrune = nodes.filter((node) => {
if (node.online) {
return false;
}
// Delete from the Headscale nodes list and then from the database return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key);
const promises = toPrune.map((node) => { });
return async () => {
log.debug('api', `Pruning node ${node.name}`);
await api.deleteNode(node.id);
await context.db if (toPrune.length === 0) {
.delete(ephemeralNodes) log.debug("api", "No SSH nodes to prune");
.where(eq(ephemeralNodes.node_key, node.nodeKey)); return;
log.debug('api', `Node ${node.name} pruned successfully`); }
};
});
await Promise.all(promises.map((p) => p())); // Delete from the Headscale nodes list and then from the database
const promises = toPrune.map((node) => {
return async () => {
log.debug("api", `Pruning node ${node.name}`);
await api.deleteNode(node.id);
await context.db.delete(ephemeralNodes).where(eq(ephemeralNodes.node_key, node.nodeKey));
log.debug("api", `Node ${node.name} pruned successfully`);
};
});
await Promise.all(promises.map((p) => p()));
} }
+37 -18
View File
@@ -1,31 +1,50 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { HostInfo } from '~/types';
export const ephemeralNodes = sqliteTable('ephemeral_nodes', { import { HostInfo } from "~/types";
auth_key: text('auth_key').primaryKey(),
node_key: text('node_key'), export const ephemeralNodes = sqliteTable("ephemeral_nodes", {
auth_key: text("auth_key").primaryKey(),
node_key: text("node_key"),
}); });
export type EphemeralNode = typeof ephemeralNodes.$inferSelect; export type EphemeralNode = typeof ephemeralNodes.$inferSelect;
export type EphemeralNodeInsert = typeof ephemeralNodes.$inferInsert; export type EphemeralNodeInsert = typeof ephemeralNodes.$inferInsert;
export const hostInfo = sqliteTable('host_info', { export const hostInfo = sqliteTable("host_info", {
host_id: text('host_id').primaryKey(), host_id: text("host_id").primaryKey(),
payload: text('payload', { mode: 'json' }).$type<HostInfo>(), payload: text("payload", { mode: "json" }).$type<HostInfo>(),
updated_at: integer('updated_at', { mode: 'timestamp' }).$default( updated_at: integer("updated_at", { mode: "timestamp" }).$default(() => new Date()),
() => new Date(),
),
}); });
export type HostInfoRecord = typeof hostInfo.$inferSelect; export type HostInfoRecord = typeof hostInfo.$inferSelect;
export type HostInfoInsert = typeof hostInfo.$inferInsert; export type HostInfoInsert = typeof hostInfo.$inferInsert;
export const users = sqliteTable('users', { export const users = sqliteTable("users", {
id: text('id').primaryKey(), id: text("id").primaryKey(),
sub: text('sub').notNull().unique(), sub: text("sub").notNull().unique(),
caps: integer('caps').notNull().default(0), role: text("role").notNull().default("member"),
onboarded: integer('onboarded', { mode: 'boolean' }).notNull().default(false), headscale_user_id: text("headscale_user_id"),
onboarded: integer("onboarded", { mode: "boolean" }).notNull().default(false),
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
updated_at: integer("updated_at", { mode: "timestamp" }).$default(() => new Date()),
last_login_at: integer("last_login_at", { mode: "timestamp" }),
// Deprecated: kept for migration compatibility, will be removed in 1.0
caps: integer("caps").notNull().default(0),
}); });
export type User = typeof users.$inferSelect; export type HeadplaneUser = typeof users.$inferSelect;
export type UserInsert = typeof users.$inferInsert; export type HeadplaneUserInsert = typeof users.$inferInsert;
export const authSessions = sqliteTable("auth_sessions", {
id: text("id").primaryKey(),
kind: text("kind").notNull(), // 'oidc' | 'api_key'
user_id: text("user_id"),
api_key_hash: text("api_key_hash"),
api_key_display: text("api_key_display"),
expires_at: integer("expires_at", { mode: "timestamp" }).notNull(),
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
});
export type AuthSessionRecord = typeof authSessions.$inferSelect;
export type AuthSessionInsert = typeof authSessions.$inferInsert;
+12 -10
View File
@@ -1,5 +1,6 @@
import { join } from "node:path"; import { join } from "node:path";
import { exit, versions } from "node:process"; import { exit, versions } from "node:process";
import { createHonoServer } from "react-router-hono-server/node"; import { createHonoServer } from "react-router-hono-server/node";
import log from "~/utils/log"; import log from "~/utils/log";
@@ -10,7 +11,7 @@ import { createDbClient } from "./db/client.server";
import { createHeadscaleInterface } from "./headscale/api"; import { createHeadscaleInterface } from "./headscale/api";
import { loadHeadscaleConfig } from "./headscale/config-loader"; import { loadHeadscaleConfig } from "./headscale/config-loader";
import { createHeadplaneAgent } from "./hp-agent"; import { createHeadplaneAgent } from "./hp-agent";
import { createSessionStorage } from "./web/sessions"; import { createAuthService } from "./web/auth";
declare global { declare global {
const __PREFIX__: string; const __PREFIX__: string;
@@ -60,11 +61,9 @@ const appLoadContext = {
config.headscale.dns_records_path, config.headscale.dns_records_path,
), ),
// TODO: Better cookie options in config auth: createAuthService({
sessions: await createSessionStorage({
secret: config.server.cookie_secret, secret: config.server.cookie_secret,
db, db,
oidcUsersFile: config.oidc?.user_storage_file,
cookie: { cookie: {
name: "_hp_auth", name: "_hp_auth",
secure: config.server.cookie_secure, secure: config.server.cookie_secure,
@@ -76,13 +75,16 @@ const appLoadContext = {
hsApi, hsApi,
agents, agents,
integration: await loadIntegration(config.integration), integration: await loadIntegration(config.integration),
oidcConnector: oidc:
config.oidc && config.oidc.enabled !== false config.oidc && config.oidc.enabled !== false
? createLazyOidcConnector( ? {
config.server.base_url, apiKey: config.oidc.headscale_api_key,
config.oidc, connector: createLazyOidcConnector(
hsApi.getRuntimeClient(config.oidc.headscale_api_key), config.server.base_url,
) config.oidc,
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
),
}
: undefined, : undefined,
db, db,
}; };
+429
View File
@@ -0,0 +1,429 @@
import { createHash } from "node:crypto";
import { eq, lt } from "drizzle-orm";
import { LibSQLDatabase } from "drizzle-orm/libsql/driver";
import { createCookie } from "react-router";
import { ulid } from "ulidx";
import type { Machine } from "~/types";
import { authSessions, users } from "../db/schema";
import { Capabilities, type Role, Roles, capsForRole } from "./roles";
// ── Principal ────────────────────────────────────────────────────────
// The per-request identity object. Discriminated on `kind` so routes
// can branch structurally instead of checking magic strings.
export type Principal =
| {
kind: "api_key";
sessionId: string;
displayName: string;
apiKey: string;
}
| {
kind: "oidc";
sessionId: string;
user: {
id: string;
subject: string;
role: Role;
headscaleUserId: string | undefined;
onboarded: boolean;
};
profile: {
name: string;
email?: string;
username?: string;
picture?: string;
};
};
// ── Cookie payload ───────────────────────────────────────────────────
// The cookie contains only a session ID + minimal profile data for
// SSR rendering. Credentials never leave the server.
interface CookiePayload {
sid: string;
// API key is stored in the cookie ONLY for api_key sessions.
// OIDC sessions use the server-side oidc.headscale_api_key.
api_key?: string;
profile?: {
name: string;
email?: string;
username?: string;
picture?: string;
};
}
// ── AuthService ──────────────────────────────────────────────────────
export interface AuthServiceOptions {
secret: string;
db: LibSQLDatabase;
cookie: {
name: string;
secure: boolean;
maxAge: number;
domain?: string;
};
}
export class AuthService {
private opts: AuthServiceOptions;
private requestCache = new WeakMap<Request, Promise<Principal>>();
constructor(opts: AuthServiceOptions) {
this.opts = opts;
}
// ── Authentication ─────────────────────────────────────────────
/**
* Resolve the principal for a request. Throws if no valid session.
* Results are cached per-request so multiple calls in the same
* loader don't hit the DB repeatedly.
*/
require(request: Request): Promise<Principal> {
const cached = this.requestCache.get(request);
if (cached) {
return cached;
}
const promise = this.resolve(request);
this.requestCache.set(request, promise);
return promise;
}
private async resolve(request: Request): Promise<Principal> {
const payload = await this.decodeCookie(request);
const [session] = await this.opts.db
.select()
.from(authSessions)
.where(eq(authSessions.id, payload.sid))
.limit(1);
if (!session) {
throw new Error("Session not found");
}
if (session.expires_at < new Date()) {
await this.opts.db.delete(authSessions).where(eq(authSessions.id, session.id));
throw new Error("Session expired");
}
if (session.kind === "api_key") {
if (!payload.api_key) {
throw new Error("API key session missing credential");
}
return {
kind: "api_key",
sessionId: session.id,
displayName: session.api_key_display ?? "API Key",
apiKey: payload.api_key,
};
}
if (!session.user_id) {
throw new Error("OIDC session missing user_id");
}
const [user] = await this.opts.db
.select()
.from(users)
.where(eq(users.id, session.user_id))
.limit(1);
if (!user) {
throw new Error("User record not found");
}
const role = (user.role in Roles ? user.role : "member") as Role;
return {
kind: "oidc",
sessionId: session.id,
user: {
id: user.id,
subject: user.sub,
role,
headscaleUserId: user.headscale_user_id ?? undefined,
onboarded: user.onboarded,
},
profile: payload.profile ?? {
name: user.sub,
},
};
}
// ── Authorization ──────────────────────────────────────────────
/**
* Check if a principal has a given set of capabilities.
* API key principals always have full access.
*/
can(principal: Principal, capabilities: Capabilities): boolean {
if (principal.kind === "api_key") {
return true;
}
const roleCaps = Roles[principal.user.role];
return (capabilities & roleCaps) === capabilities;
}
/**
* Check if a principal can act on a machine. Owners of the machine
* can act on it even without write_machines capability.
*/
canManageNode(principal: Principal, node: Machine): boolean {
if (principal.kind === "api_key") {
return true;
}
const caps = Roles[principal.user.role];
if ((caps & Capabilities.write_machines) !== 0) {
return true;
}
const hsUserId = principal.user.headscaleUserId;
return hsUserId !== undefined && node.user?.id === hsUserId;
}
// ── Session management ─────────────────────────────────────────
/**
* Create a new OIDC session. Returns the Set-Cookie header value.
*/
async createOidcSession(
userId: string,
profile: NonNullable<CookiePayload["profile"]>,
maxAge = this.opts.cookie.maxAge,
): Promise<string> {
const sid = ulid();
await this.opts.db.insert(authSessions).values({
id: sid,
kind: "oidc",
user_id: userId,
expires_at: new Date(Date.now() + maxAge * 1000),
});
return this.encodeCookie({ sid, profile }, maxAge);
}
/**
* Create a new API key session. The API key is stored server-side
* as a SHA-256 hash — it never appears in the cookie.
* Returns the Set-Cookie header value.
*/
async createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise<string> {
const sid = ulid();
await this.opts.db.insert(authSessions).values({
id: sid,
kind: "api_key",
api_key_hash: this.hashApiKey(apiKey),
api_key_display: displayName,
expires_at: new Date(Date.now() + maxAge),
});
return this.encodeCookie({ sid, api_key: apiKey }, Math.floor(maxAge / 1000));
}
/**
* Get the Headscale API key for making API calls.
* OIDC sessions use the configured oidc.headscale_api_key.
* API key sessions use the user-provided key stored in the cookie.
*/
getHeadscaleApiKey(principal: Principal, oidcApiKey?: string): string {
if (principal.kind === "api_key") {
return principal.apiKey;
}
if (!oidcApiKey) {
throw new Error("OIDC sessions require oidc.headscale_api_key");
}
return oidcApiKey;
}
/**
* Destroy the current session. Returns the Set-Cookie header that
* clears the cookie.
*/
async destroySession(request?: Request): Promise<string> {
if (request) {
try {
const payload = await this.decodeCookie(request);
await this.opts.db.delete(authSessions).where(eq(authSessions.id, payload.sid));
} catch {
// Cookie already invalid, just clear it
}
}
const cookie = createCookie(this.opts.cookie.name, {
...this.opts.cookie,
path: __PREFIX__,
});
return cookie.serialize("", { expires: new Date(0) });
}
// ── User management ────────────────────────────────────────────
/**
* Find or create a Headplane user by OIDC subject. Returns the
* user ID. Used during OIDC callback to establish identity.
*/
async findOrCreateUser(subject: string, defaultRole: Role): Promise<string> {
const [existing] = await this.opts.db
.select()
.from(users)
.where(eq(users.sub, subject))
.limit(1);
if (existing) {
await this.opts.db
.update(users)
.set({ last_login_at: new Date(), updated_at: new Date() })
.where(eq(users.id, existing.id));
return existing.id;
}
const id = ulid();
await this.opts.db.insert(users).values({
id,
sub: subject,
role: defaultRole,
caps: capsForRole(defaultRole),
onboarded: false,
});
return id;
}
/**
* Check if there are any users in the database (for bootstrap).
*/
async hasAnyUsers(): Promise<boolean> {
const [row] = await this.opts.db.select({ id: users.id }).from(users).limit(1);
return row !== undefined;
}
/**
* Update the Headscale user link for a Headplane user.
*/
async linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<void> {
await this.opts.db
.update(users)
.set({ headscale_user_id: headscaleUserId, updated_at: new Date() })
.where(eq(users.id, userId));
}
/**
* Get the role for a given OIDC subject. Used by the users overview
* to display roles for Headscale users.
*/
async roleForSubject(subject: string): Promise<Role | undefined> {
const [user] = await this.opts.db.select().from(users).where(eq(users.sub, subject)).limit(1);
if (!user) {
return;
}
return (user.role in Roles ? user.role : "member") as Role;
}
/**
* Reassign the role of a user identified by their OIDC subject.
* Cannot reassign the owner role.
*/
async reassignSubject(subject: string, role: Role): Promise<boolean> {
const currentRole = await this.roleForSubject(subject);
if (currentRole === "owner") {
return false;
}
await this.opts.db
.insert(users)
.values({
id: ulid(),
sub: subject,
role,
caps: capsForRole(role),
onboarded: false,
})
.onConflictDoUpdate({
target: users.sub,
set: { role, caps: capsForRole(role), updated_at: new Date() },
});
return true;
}
/**
* Clean up expired sessions. Should be called periodically.
*/
async pruneExpiredSessions(): Promise<void> {
await this.opts.db.delete(authSessions).where(lt(authSessions.expires_at, new Date()));
}
// ── Private helpers ────────────────────────────────────────────
private async encodeCookie(payload: CookiePayload, maxAge: number): Promise<string> {
const cookie = createCookie(this.opts.cookie.name, {
...this.opts.cookie,
path: __PREFIX__,
maxAge,
});
const signed = Buffer.from(JSON.stringify(payload)).toString("base64url");
const hmac = createHash("sha256")
.update(this.opts.secret + signed)
.digest("base64url");
return cookie.serialize(`${signed}.${hmac}`);
}
private async decodeCookie(request: Request): Promise<CookiePayload> {
const cookieHeader = request.headers.get("cookie");
if (!cookieHeader) {
throw new Error("No session cookie found");
}
const cookie = createCookie(this.opts.cookie.name, {
...this.opts.cookie,
path: __PREFIX__,
});
const raw = (await cookie.parse(cookieHeader)) as string | null;
if (!raw) {
throw new Error("Session cookie is empty");
}
const dotIndex = raw.lastIndexOf(".");
if (dotIndex === -1) {
throw new Error("Malformed session cookie");
}
const signed = raw.slice(0, dotIndex);
const hmac = raw.slice(dotIndex + 1);
const expected = createHash("sha256")
.update(this.opts.secret + signed)
.digest("base64url");
if (hmac !== expected) {
throw new Error("Invalid session cookie signature");
}
return JSON.parse(Buffer.from(signed, "base64url").toString("utf-8")) as CookiePayload;
}
private hashApiKey(key: string): string {
return createHash("sha256").update(key).digest("hex");
}
}
export function createAuthService(opts: AuthServiceOptions): AuthService {
return new AuthService(opts);
}
+39
View File
@@ -0,0 +1,39 @@
import type { User } from "~/types/User";
/**
* Extracts the OIDC subject from a Headscale user's providerId.
* Headscale stores providerId as a URL where the last path segment
* is the subject (e.g. "https://idp.example.com/<uuid>"). This is
* the ONLY place this parsing should occur — all other code should
* use the stable headscale_user_id link on the Headplane user record.
*/
export function getOidcSubject(user: User): string | undefined {
if (user.provider !== "oidc" || !user.providerId) {
return;
}
return user.providerId.split("/").pop();
}
/**
* Finds the Headscale user matching the given OIDC identity.
* Tries subject match first (providerId last segment), then falls
* back to email match. The fallback is needed because some IDPs
* issue different subjects per client application.
*/
export function findHeadscaleUserBySubject(
users: User[],
subject: string,
email?: string,
): User | undefined {
const bySubject = users.find((u) => getOidcSubject(u) === subject);
if (bySubject) {
return bySubject;
}
if (!email) {
return;
}
return users.find((u) => u.email === email);
}
+12 -37
View File
@@ -1,60 +1,24 @@
export type Capabilities = (typeof Capabilities)[keyof typeof Capabilities]; export type Capabilities = (typeof Capabilities)[keyof typeof Capabilities];
export const Capabilities = { export const Capabilities = {
// Can access the admin console
ui_access: 1 << 0, ui_access: 1 << 0,
// Read tailnet policy file (unimplemented)
read_policy: 1 << 1, read_policy: 1 << 1,
// Write tailnet policy file (unimplemented)
write_policy: 1 << 2, write_policy: 1 << 2,
// Read network configurations
read_network: 1 << 3, read_network: 1 << 3,
// Write network configurations, for example, enable MagicDNS, split DNS,
// make subnet, or allow a node to be an exit node, enable HTTPS
write_network: 1 << 4, write_network: 1 << 4,
// Read feature configuration (unimplemented)
read_feature: 1 << 5, read_feature: 1 << 5,
// Write feature configuration, for example, enable Taildrop (unimplemented)
write_feature: 1 << 6, write_feature: 1 << 6,
// Configure user & group provisioning
configure_iam: 1 << 7, configure_iam: 1 << 7,
// Read machines, for example, see machine names and status
read_machines: 1 << 8, read_machines: 1 << 8,
// Write machines, for example, approve, rename, and remove machines
write_machines: 1 << 9, write_machines: 1 << 9,
// Read users and user roles
read_users: 1 << 10, read_users: 1 << 10,
// Write users and user roles, for example, remove users,
// approve users, make Admin
write_users: 1 << 11, write_users: 1 << 11,
// Can generate authkeys for any user
generate_authkeys: 1 << 12, generate_authkeys: 1 << 12,
// Can generate authkeys for own user only
generate_own_authkeys: 1 << 16, generate_own_authkeys: 1 << 16,
// Can use any tag (without being tag owner) (unimplemented)
use_tags: 1 << 13, use_tags: 1 << 13,
// Write tailnet name (unimplemented)
write_tailnet: 1 << 14, write_tailnet: 1 << 14,
// Owner flag
owner: 1 << 15, owner: 1 << 15,
} as const; } as const;
export type Roles = [keyof typeof Roles];
export const Roles = { export const Roles = {
owner: owner:
Capabilities.ui_access | Capabilities.ui_access |
@@ -126,12 +90,19 @@ export const Roles = {
Capabilities.read_users | Capabilities.read_users |
Capabilities.generate_own_authkeys, Capabilities.generate_own_authkeys,
// Default role for new users with 0 capabilities on the UI side of things viewer:
Capabilities.ui_access |
Capabilities.read_machines |
Capabilities.read_users |
Capabilities.generate_own_authkeys,
// No access — user exists but has not been granted any role
member: 0, member: 0,
} as const; } as const;
export type Role = keyof typeof Roles; export type Role = keyof typeof Roles;
export type Capability = keyof typeof Capabilities; export type Capability = keyof typeof Capabilities;
export function hasCapability(role: Role, capability: Capability): boolean { export function hasCapability(role: Role, capability: Capability): boolean {
return (Roles[role] & Capabilities[capability]) !== 0; return (Roles[role] & Capabilities[capability]) !== 0;
} }
@@ -146,3 +117,7 @@ export function getRoleFromCapabilities(capabilities: Capabilities): Role {
return "member"; return "member";
} }
export function capsForRole(role: Role): number {
return Roles[role];
}
-306
View File
@@ -1,306 +0,0 @@
import { eq } from "drizzle-orm";
import { LibSQLDatabase } from "drizzle-orm/libsql/driver";
import { EncryptJWT, jwtDecrypt } from "jose";
import { createHash } from "node:crypto";
import { open, readFile, rm } from "node:fs/promises";
import { resolve } from "node:path";
import { createCookie } from "react-router";
import { ulid } from "ulidx";
import log from "~/utils/log";
import { users } from "../db/schema";
import { Capabilities, Roles } from "./roles";
export interface AuthSession {
state: "auth";
api_key: string;
user: {
subject: string;
name: string;
email?: string;
username?: string;
picture?: string;
};
}
interface JWTSession {
api_key: string;
user: {
subject: string;
name: string;
email?: string;
username?: string;
picture?: string;
};
}
export interface OidcFlowSession {
state: "flow";
oidc: {
state: string;
nonce: string;
code_verifier: string;
redirect_uri: string;
};
}
interface AuthSessionOptions {
secret: string;
db: LibSQLDatabase;
oidcUsersFile?: string;
cookie: {
name: string;
secure: boolean;
maxAge: number;
domain?: string;
};
}
class Sessionizer {
private options: AuthSessionOptions;
constructor(options: AuthSessionOptions) {
this.options = options;
}
// This throws on the assumption that auth is already checked correctly
// on something that wraps the route calling auth. The top-level routes
// that call this are wrapped with try/catch to handle the error.
async auth(request: Request) {
return decodeSession(request, this.options);
}
async createSession(payload: JWTSession, maxAge = this.options.cookie.maxAge) {
// TODO: What the hell is this garbage
return createSession(payload, {
...this.options,
cookie: {
...this.options.cookie,
maxAge,
},
});
}
async destroySession() {
return destroySession(this.options);
}
async roleForSubject(subject: string): Promise<keyof typeof Roles | undefined> {
const [user] = await this.options.db
.select()
.from(users)
.where(eq(users.sub, subject))
.limit(1);
if (!user) {
return;
}
// We need this in string form based on Object.keys of the roles
for (const [key, value] of Object.entries(Roles)) {
if (value === user.caps) {
return key as keyof typeof Roles;
}
}
}
// Given an OR of capabilities, check if the session has the required
// capabilities. If not, return false. Can throw since it calls auth()
async check(request: Request, capabilities: Capabilities) {
const session = await this.auth(request);
// This is the subject we set on API key based sessions. API keys
// inherently imply admin access so we return true for all checks.
if (session.user.subject === "unknown-non-oauth") {
return true;
}
const [user] = await this.options.db
.select()
.from(users)
.where(eq(users.sub, session.user.subject))
.limit(1);
if (!user) {
return false;
}
return (capabilities & user.caps) === capabilities;
}
// Updates the capabilities and roles of a subject
// Creates the user record if it doesn't exist yet
async reassignSubject(subject: string, role: keyof typeof Roles) {
// Check if we are owner
const subjectRole = await this.roleForSubject(subject);
if (subjectRole === "owner") {
return false;
}
// Use upsert to handle users who exist in Headscale but haven't
// logged into Headplane yet (no DB record)
await this.options.db
.insert(users)
.values({
id: ulid(),
sub: subject,
caps: Roles[role],
onboarded: false,
})
.onConflictDoUpdate({
target: users.sub,
set: { caps: Roles[role] },
});
return true;
}
}
async function createSession(payload: JWTSession, options: AuthSessionOptions) {
const now = Math.floor(Date.now() / 1000);
const secret = createHash("sha256").update(options.secret, "utf8").digest();
const jwt = await new EncryptJWT({
...payload,
})
.setProtectedHeader({ alg: "dir", enc: "A256GCM", typ: "JWT" })
.setIssuedAt()
.setExpirationTime(now + options.cookie.maxAge)
.setIssuer("urn:tale:headplane")
.setAudience("urn:tale:headplane")
.setJti(ulid())
.encrypt(secret);
const cookie = createCookie(options.cookie.name, {
...options.cookie,
path: __PREFIX__,
});
return cookie.serialize(jwt);
}
async function decodeSession(request: Request, options: AuthSessionOptions) {
const cookieHeader = request.headers.get("cookie");
if (cookieHeader === null) {
throw new Error("No session cookie found");
}
const cookie = createCookie(options.cookie.name, {
...options.cookie,
path: __PREFIX__,
});
const cookieValue = (await cookie.parse(cookieHeader)) as string | null;
if (cookieValue === null) {
throw new Error("Session cookie is empty");
}
const secret = createHash("sha256").update(options.secret, "utf8").digest();
const { payload } = await jwtDecrypt(cookieValue, secret, {
issuer: "urn:tale:headplane",
audience: "urn:tale:headplane",
});
// Safe since we encode the session directly into the JWT
return payload as unknown as JWTSession;
}
async function destroySession(options: AuthSessionOptions) {
const cookie = createCookie(options.cookie.name, {
...options.cookie,
path: __PREFIX__,
});
return cookie.serialize("", {
expires: new Date(0),
});
}
export async function createSessionStorage(options: AuthSessionOptions) {
if (options.oidcUsersFile) {
await migrateUserDatabase(options.oidcUsersFile, options.db);
}
return new Sessionizer(options);
}
async function migrateUserDatabase(path: string, db: LibSQLDatabase) {
const realPath = resolve(path);
try {
const handle = await open(realPath, "a+");
await handle.close();
} catch (error) {
if (error != null && typeof error === "object" && "code" in error && error.code === "ENOENT") {
log.debug("config", "No old user database file found at %s", realPath);
return;
}
log.warn("config", "Failed to migrate old user database at %s", realPath);
log.warn("config", "This is not an error, but existing users will not be migrated");
log.warn("config", "Unable to open user database file: %s", String(error));
log.debug("config", "Error details: %s", error);
return;
}
log.info("config", "Found old user database file at %s", realPath);
log.info("config", "Migrating user database to the new SQL database");
let migratableUsers: {
u: string;
c: number;
oo?: boolean;
}[];
try {
const data = await readFile(realPath, "utf8");
if (data.trim().length === 0) {
log.info("config", "Old user database file is empty, nothing to migrate");
log.info("config", "You SHOULD remove oidc.user_storage_file from your config!");
await rm(realPath, { force: true });
return;
}
const users = JSON.parse(data.trim()) as {
u?: string;
c?: number;
oo?: boolean;
}[];
migratableUsers = users.filter((user) => user.u !== undefined && user.c !== undefined) as {
u: string;
c: number;
oo?: boolean;
}[];
} catch (error) {
log.warn("config", "Error reading old user database file: %s", error);
log.warn("config", "Not migrating any users");
return;
}
if (migratableUsers.length === 0) {
log.info("config", "No users found in the old database to migrate");
return;
}
log.info("config", "Migrating %d users from the old database", migratableUsers.length);
const updated = await db
.insert(users)
.values(
migratableUsers.map((user) => ({
id: ulid(),
sub: user.u,
caps: user.c,
onboarded: user.oo ?? false,
})),
)
.onConflictDoNothing({
target: users.sub,
})
.returning();
log.info("config", "Migrated %d users successfully", updated.length);
log.info("config", "Removed old user database file %s", realPath);
await rm(realPath, { force: true });
}
+157 -52
View File
@@ -13,58 +13,45 @@ outline: [2, 3]
</figure> </figure>
Single Sign-On allows users to authenticate with Headplane through an external Single Sign-On allows users to authenticate with Headplane through an external
Identity Provider (IdP). It does this using the OpenID Connect (OIDC) protocol, Identity Provider (IdP) using the OpenID Connect (OIDC) protocol. When enabled,
which is widely supported by many popular IdPs. users sign in through your IdP and Headplane automatically links them to their
Headscale identity, assigns a role, and manages their session.
## Getting Started ## Getting Started
To set up Single Sign-On (SSO) with Headplane, there are several steps involved.
As a general recommendation, please read through the entire guide before
beginning the process as there are several important factors to consider.
### Requirements ### Requirements
::: warning You'll need the following before proceeding:
If you are also using OpenID Connect (OIDC) authentication with Headscale, it is
**fundamentally important** that both Headscale and Headplane are configured to
use the *exact same client* in your Identity Provider (IdP). This means that
both services should share the same client ID and secret.
This is necessary because Headplane relies on the user IDs provided by the IdP
to match users with their equivalent Headscale users. If Headscale and Headplane
are using different clients, the user IDs may not match up correctly, preventing
a user from viewing their devices in Headplane.
:::
You'll need the following things set up before proceeding:
- A working Headplane installation that is already configured. - A working Headplane installation that is already configured.
- An Identity Provider (IdP) that supports OAuth2 and OpenID Connect (OIDC). - An Identity Provider (IdP) that supports OAuth2 and OpenID Connect (OIDC).
- `server.base_url` set to the public URL of your Headplane instance in your - `server.base_url` set to the public URL of your Headplane instance in your
configuration file (ie. the domain that's visible in the browser). configuration file (the domain visible in the browser).
- A Headscale API key with a relatively long expiration time (eg. 1 year). - A Headscale API key with a relatively long expiration time (eg. 1 year).
### Configuring the Client ### Configuring the Client
You'll need to create a client in your Identity Provider (IdP) that Headplane
can use for authentication. A part of that step involves giving an allowed
"redirect URL" to your IdP. This URL is where the IdP will send users back to
after they have authenticated.
For Headplane, the redirect URL will be in the following format, where the You'll need to create a client in your Identity Provider that Headplane can use
domain is replaced with the value set for `server.base_url` in your Headplane for authentication. As part of that step, you'll need to register a "redirect
configuration: URL" — this is where the IdP sends users after they authenticate.
For Headplane, the redirect URL will be in the following format (replace the
domain with the value set for `server.base_url`):
``` ```
https://headplane.example.com/admin/oidc/callback https://headplane.example.com/admin/oidc/callback
``` ```
Once you have created the client in your IdP, make note of the following Once you have created the client, make note of the following:
information as you'll need it for the Headplane configuration:
- Client ID - Client ID
- Client Secret (if applicable) - Client Secret (if applicable)
- Issuer URL - Issuer URL
### OIDC Configuration ### OIDC Configuration
To enable OIDC authentication in Headplane, you'll need to add the necessary
configuration options via the file or environment variables. See below: To enable OIDC authentication in Headplane, add the following to your
configuration file:
```yaml ```yaml
oidc: oidc:
@@ -75,18 +62,18 @@ oidc:
# You can also provide the client secret via a file: # You can also provide the client secret via a file:
# client_secret_path: "${HOME}/secrets/headplane_oidc_client_secret.txt" # client_secret_path: "${HOME}/secrets/headplane_oidc_client_secret.txt"
# Those options should generally be sufficient, but you can also set these: # These are usually auto-discovered, but can be set manually:
# authorization_endpoint: "" # authorization_endpoint: ""
# token_endpoint: "" # token_endpoint: ""
# userinfo_endpoint: "" # userinfo_endpoint: ""
# scope: "openid email profile" # scope: "openid email profile"
# extra_params: # extra_params:
# foo: "bar" # foo: "bar"
# baz: "qux"
``` ```
Headplane automatically tries to discover the necessary OIDC endpoints but if Headplane automatically discovers OIDC endpoints from your issuer's
your IdP does not support discovery, you may need to manually specify them. `/.well-known/openid-configuration`. If your IdP does not support discovery,
you'll need to set the endpoints manually.
### PKCE ### PKCE
@@ -96,29 +83,147 @@ You may need to ensure that your Identity Provider is configured to accept this
method. method.
::: :::
By default, Headplane does not use PKCE (Proof Key for Code Exchange) when By default, Headplane does not use PKCE (Proof Key for Code Exchange). PKCE is
communicating with the Identity Provider. PKCE is generally a best practice for a best practice for OIDC and enhances security — some IdPs even require it. To
OIDC and can enhance security. *Some Identity Providers may even require PKCE enable PKCE:
to be used.* To enable PKCE you'll need to set `oidc.use_pkce`
to `true` in your Headplane configuration file:
```yaml ```yaml
oidc: oidc:
use_pkce: true use_pkce: true
``` ```
## Troubleshooting ## How User Matching Works
Some of the common issues you may encounter when configuring OIDC with Headplane
include:
- **Invalid API Key**: Ensure that the API key provided to Headplane is valid When a user signs in via OIDC, Headplane needs to link them to their
and has not expired. corresponding Headscale user. This is important for features like showing a
- **Missing [some]_endpoint**: If your IdP does not provide standard OIDC user's own machines, self-service pre-auth keys, and WebSSH.
endpoints, you may need to manually specify them in the Headplane configuration.
- **Missing the `sub` claim**: Ensure that your IdP is configured to include the ### Matching Strategy
`sub` claim in the ID token, as this is required for Headplane to identify users.
- **Redirect URI Mismatch**: Ensure that the redirect URI configured in your IdP Headplane uses a two-step matching strategy:
and that `server.base_url` in Headplane match exactly.
- **Cookie Issues**: The OIDC authentication relies on your cookie configuration 1. **Subject match (primary)**: Headscale stores the IdP's `provider_id` for
for Headplane. If OIDC cannot complete due to a missing session or invalid each OIDC user (e.g. `https://idp.example.com/3d6f6e3f-...`). Headplane
session then please check your cookie settings. extracts the last path segment and compares it to the `sub` claim from the
OIDC token. If they match, the user is linked.
2. **Email match (fallback)**: If the subject doesn't match, Headplane falls
back to comparing the user's email address from the OIDC `userinfo` endpoint
against the email stored on the Headscale user record.
Once a link is established, it's stored as a `headscale_user_id` in Headplane's
database and reused on subsequent logins — so the matching only needs to succeed
once.
### Headscale Without OIDC
If your Headscale instance uses **local users** (created via
`headscale users create`) rather than OIDC, automatic matching cannot work —
local users have no `provider_id` or email to compare against.
In this case, Headplane will prompt the user during onboarding to manually
select which Headscale user they are. This selection is persisted, so it only
needs to happen once. After linking, all ownership-based features (viewing your
own machines, self-service pre-auth keys, WebSSH) work normally.
::: tip
If you skip the user selection during onboarding, you can still use Headplane
— you just won't have ownership-based features. An admin can manage everything
regardless of whether users are linked.
:::
### Same Client vs. Different Clients
::: tip Recommended
Using the **same OIDC client** for both Headscale and Headplane is the simplest
and most reliable setup. The `sub` claim will be identical for both services,
so subject matching always works.
:::
If your Headscale and Headplane use **different OIDC clients**, some Identity
Providers (notably Azure AD / Entra ID) may issue different `sub` values per
client application. In this case:
- Subject matching will fail on the first login.
- Headplane will fall back to email matching, which requires that the `email`
claim is available from both your IdP's `userinfo` endpoint and Headscale's
user record.
- Once the link is established, subsequent logins will work regardless because
the link is persisted.
::: warning
If you use different clients **and** your IdP does not provide an `email` claim,
Headplane will not be able to match users to their Headscale identity. Users
will still be able to sign in, but they won't be linked to a Headscale user —
meaning features like viewing their own machines or self-service pre-auth keys
won't work.
:::
## Roles and Permissions
When SSO is enabled, Headplane uses a role-based access control system to
determine what each user can do in the UI.
### Available Roles
| Role | Description |
| ----------------- | ------------------------------------------------------------------------------------------------------ |
| **Owner** | Full access to everything. Cannot be reassigned. Automatically granted to the first user who signs in. |
| **Admin** | Full access except the owner-specific flag. Can manage all users, machines, ACLs, DNS, and settings. |
| **Network Admin** | Can manage ACLs, DNS, and network settings. Can view machines and users. Can generate pre-auth keys. |
| **IT Admin** | Can manage machines, users, and feature settings. Can configure IAM. Cannot modify ACLs or DNS. |
| **Auditor** | Read-only access to everything. Can generate their own pre-auth keys. |
| **Viewer** | Can view machines and users. Can generate their own pre-auth keys. |
| **Member** | No UI access. The user exists in Headplane's database but has not been granted any permissions. |
### First Login (Owner Bootstrap)
The very first user to sign in via OIDC is automatically assigned the **Owner**
role. All subsequent users are assigned the **Member** role (no access) by
default. An owner or admin must then assign them an appropriate role through
the Users page.
### API Key Sessions
Users who sign in with a Headscale API key (instead of OIDC) are treated as
having full access. API key sessions bypass the role system entirely since
possession of the API key already implies administrative access to Headscale.
### Onboarding
When a new OIDC user signs in for the first time, they go through a brief
onboarding flow that helps them connect their first device to the Tailnet. This
flow can be skipped. Once completed, users are taken to the main dashboard.
## Troubleshooting
### Common Issues
- **"OIDC is not enabled or misconfigured"**: Check that your `oidc` section
is present in the config and that the issuer URL is reachable from the
Headplane server.
- **User signs in but can't see their machines**: The user's Headscale identity
wasn't matched. Check that either the `sub` claim matches or the `email`
claim is available (see [How User Matching Works](#how-user-matching-works)).
- **"Session cookie is empty" or login loop**: Check your `cookie_secure`
setting. If Headplane is behind a reverse proxy with HTTPS, set it to `true`.
If running without HTTPS (eg. local development), set it to `false`.
- **Invalid API Key**: The `oidc.headscale_api_key` may have expired. Generate
a new one with `headscale apikeys create --expiration 999d`.
- **Missing the `sub` claim**: Ensure your IdP includes the `sub` claim in the
ID token. This is required by the OIDC spec but some providers need explicit
configuration.
- **Redirect URI Mismatch**: Ensure the redirect URI registered in your IdP
matches `{server.base_url}/admin/oidc/callback` exactly.
- **PKCE errors**: If your IdP requires PKCE, set `oidc.use_pkce: true`. If
you see errors mentioning `code_verifier`, this is almost always the cause.
- **Missing endpoints**: If your IdP does not support OIDC discovery, you'll
need to set `authorization_endpoint`, `token_endpoint`, and
`userinfo_endpoint` manually in the config.
+22
View File
@@ -0,0 +1,22 @@
CREATE TABLE `auth_sessions` (
`id` text PRIMARY KEY NOT NULL,
`kind` text NOT NULL,
`user_id` text,
`api_key_hash` text,
`api_key_display` text,
`expires_at` integer NOT NULL,
`created_at` integer
);
--> statement-breakpoint
ALTER TABLE `users` ADD `role` text DEFAULT 'member' NOT NULL;--> statement-breakpoint
ALTER TABLE `users` ADD `headscale_user_id` text;--> statement-breakpoint
ALTER TABLE `users` ADD `created_at` integer;--> statement-breakpoint
ALTER TABLE `users` ADD `updated_at` integer;--> statement-breakpoint
ALTER TABLE `users` ADD `last_login_at` integer;--> statement-breakpoint
-- Backfill role from caps for existing users
UPDATE `users` SET `role` = 'owner' WHERE `caps` = 65535;--> statement-breakpoint
UPDATE `users` SET `role` = 'admin' WHERE `caps` = 32767;--> statement-breakpoint
UPDATE `users` SET `role` = 'network_admin' WHERE `caps` = 30015;--> statement-breakpoint
UPDATE `users` SET `role` = 'it_admin' WHERE `caps` = 8171;--> statement-breakpoint
UPDATE `users` SET `role` = 'auditor' WHERE `caps` = 66859;
+214
View File
@@ -0,0 +1,214 @@
{
"version": "6",
"dialect": "sqlite",
"id": "e397c1d9-19a4-494a-9b87-5a94a093286a",
"prevId": "2c18fbcb-d5f5-47c0-962d-54121cbb2e71",
"tables": {
"auth_sessions": {
"name": "auth_sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"api_key_hash": {
"name": "api_key_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"api_key_display": {
"name": "api_key_display",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ephemeral_nodes": {
"name": "ephemeral_nodes",
"columns": {
"auth_key": {
"name": "auth_key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"node_key": {
"name": "node_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"host_info": {
"name": "host_info",
"columns": {
"host_id": {
"name": "host_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"sub": {
"name": "sub",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'member'"
},
"headscale_user_id": {
"name": "headscale_user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"onboarded": {
"name": "onboarded",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"caps": {
"name": "caps",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"users_sub_unique": {
"name": "users_sub_unique",
"columns": ["sub"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+32 -25
View File
@@ -1,27 +1,34 @@
{ {
"version": "7", "version": "7",
"dialect": "sqlite", "dialect": "sqlite",
"entries": [ "entries": [
{ {
"idx": 0, "idx": 0,
"version": "6", "version": "6",
"when": 1750355487927, "when": 1750355487927,
"tag": "0000_spicy_bloodscream", "tag": "0000_spicy_bloodscream",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 1, "idx": 1,
"version": "6", "version": "6",
"when": 1755554742267, "when": 1755554742267,
"tag": "0001_naive_lilith", "tag": "0001_naive_lilith",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 2, "idx": 2,
"version": "6", "version": "6",
"when": 1755617607599, "when": 1755617607599,
"tag": "0002_square_bloodstorm", "tag": "0002_square_bloodstorm",
"breakpoints": true "breakpoints": true
} },
] {
"idx": 3,
"version": "6",
"when": 1772917638504,
"tag": "0003_thick_otto_octavius",
"breakpoints": true
}
]
} }