From 0f8e192b5c800d672ed1f9be24a83b89dda1a394 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sat, 7 Mar 2026 17:10:34 -0500 Subject: [PATCH 1/7] feat: initial auth rework --- AGENTS.md | 53 ++ app/layouts/dashboard.tsx | 85 ++-- app/layouts/shell.tsx | 57 ++- app/routes.ts | 1 - app/routes/acls/acl-action.ts | 232 +++++---- app/routes/acls/acl-loader.ts | 73 +-- app/routes/auth/login/action.ts | 14 +- app/routes/auth/login/page.tsx | 5 +- app/routes/auth/logout.ts | 40 +- app/routes/auth/oidc-callback.ts | 63 +-- app/routes/auth/oidc-start.ts | 4 +- app/routes/auth/pending-approval.tsx | 110 ----- app/routes/dns/dns-actions.ts | 456 +++++++++--------- app/routes/dns/overview.tsx | 184 ++++--- app/routes/machines/machine-actions.ts | 14 +- app/routes/machines/machine.tsx | 6 +- app/routes/machines/overview.tsx | 24 +- app/routes/settings/auth-keys/actions.ts | 14 +- .../auth-keys/dialogs/add-auth-key.tsx | 10 +- app/routes/settings/auth-keys/overview.tsx | 11 +- app/routes/settings/overview.tsx | 2 +- app/routes/settings/restrictions/actions.ts | 385 ++++++++------- app/routes/settings/restrictions/overview.tsx | 179 ++++--- app/routes/ssh/console.tsx | 21 +- app/routes/users/onboarding-skip.tsx | 63 ++- app/routes/users/onboarding.tsx | 101 ++-- app/routes/users/overview.tsx | 34 +- app/routes/users/user-actions.ts | 221 +++++---- app/server/db/pruner.ts | 96 ++-- app/server/db/schema.ts | 55 ++- app/server/index.ts | 22 +- app/server/web/auth.ts | 429 ++++++++++++++++ app/server/web/headscale-identity.ts | 39 ++ app/server/web/roles.ts | 49 +- app/server/web/sessions.ts | 306 ------------ docs/features/sso.md | 209 ++++++-- drizzle/0003_thick_otto_octavius.sql | 22 + drizzle/meta/0003_snapshot.json | 214 ++++++++ drizzle/meta/_journal.json | 57 ++- 39 files changed, 2193 insertions(+), 1767 deletions(-) create mode 100644 AGENTS.md delete mode 100644 app/routes/auth/pending-approval.tsx create mode 100644 app/server/web/auth.ts create mode 100644 app/server/web/headscale-identity.ts delete mode 100644 app/server/web/sessions.ts create mode 100644 drizzle/0003_thick_otto_octavius.sql create mode 100644 drizzle/meta/0003_snapshot.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..f5c0afce --- /dev/null +++ b/AGENTS.md @@ -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 ` 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. diff --git a/app/layouts/dashboard.tsx b/app/layouts/dashboard.tsx index 8b9a9e67..611c0288 100644 --- a/app/layouts/dashboard.tsx +++ b/app/layouts/dashboard.tsx @@ -1,53 +1,54 @@ -import { Outlet, redirect } from 'react-router'; -import { ErrorBanner } from '~/components/error-banner'; -import { pruneEphemeralNodes } from '~/server/db/pruner'; -import { isDataUnauthorizedError } from '~/server/headscale/api/error-client'; -import log from '~/utils/log'; -import type { Route } from './+types/dashboard'; +import { Outlet, redirect } from "react-router"; + +import { ErrorBanner } from "~/components/error-banner"; +import { pruneEphemeralNodes } from "~/server/db/pruner"; +import { isDataUnauthorizedError } from "~/server/headscale/api/error-client"; +import log from "~/utils/log"; + +import type { Route } from "./+types/dashboard"; export async function loader({ request, context, ...rest }: Route.LoaderArgs) { - const session = await context.sessions.auth(request); - const api = context.hsApi.getRuntimeClient(session.api_key); + const principal = await context.auth.require(request); + 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 - const healthy = await api.isHealthy(); - if (healthy) { - try { - await api.getApiKeys(); - await pruneEphemeralNodes({ context, request, ...rest }); - } catch (error) { - if (isDataUnauthorizedError(error)) { - log.warn( - 'auth', - 'Logging out %s due to expired API key', - session.user.name, - ); - return redirect('/login', { - headers: { - 'Set-Cookie': await context.sessions.destroySession(), - }, - }); - } - } - } + // MARK: The session should stay valid if Headscale isn't healthy + const healthy = await api.isHealthy(); + if (healthy) { + try { + await api.getApiKeys(); + await pruneEphemeralNodes({ context, request, ...rest }); + } catch (error) { + if (isDataUnauthorizedError(error)) { + const displayName = + principal.kind === "oidc" ? principal.profile.name : principal.displayName; + log.warn("auth", "Logging out %s due to expired API key", displayName); + return redirect("/login", { + headers: { + "Set-Cookie": await context.auth.destroySession(request), + }, + }); + } + } + } - return { - healthy, - }; + return { + healthy, + }; } export default function Layout() { - return ( -
- -
- ); + return ( +
+ +
+ ); } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/app/layouts/shell.tsx b/app/layouts/shell.tsx index 67861ec2..eb0c85da 100644 --- a/app/layouts/shell.tsx +++ b/app/layouts/shell.tsx @@ -1,9 +1,7 @@ -import { eq } from "drizzle-orm"; import { Outlet, redirect } from "react-router"; import Footer from "~/components/Footer"; import Header from "~/components/Header"; -import { users } from "~/server/db/schema"; import { Capabilities } from "~/server/web/roles"; 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? export async function loader({ request, context }: Route.LoaderArgs) { try { - const session = await context.sessions.auth(request); + const principal = await context.auth.require(request); + if ( typeof context.oidc === "object" && - session.user.subject !== "unknown-non-oauth" && + principal.kind === "oidc" && + !principal.user.onboarded && !request.url.endsWith("/onboarding") ) { - const [user] = await context.db - .select() - .from(users) - .where(eq(users.sub, session.user.subject)) - .limit(1); - - if (!user?.onboarded) { - return redirect("/onboarding"); - } + return redirect("/onboarding"); } - const api = context.hsApi.getRuntimeClient(session.api_key); - const check = await context.sessions.check(request, Capabilities.ui_access); + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + 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 && - session.user.subject !== "unknown-non-oauth" && - !request.url.endsWith("/onboarding") - ) { - return redirect("/pending-approval"); + if (!check && principal.kind === "oidc" && !request.url.endsWith("/onboarding")) { + throw new Error("You do not have permission to access the UI"); } + 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 { config: context.hs.c, url: context.config.headscale.public_url ?? context.config.headscale.url, configAvailable: context.hs.readable(), debug: context.config.debug, - user: session.user, + user, access: { ui: check, - dns: await context.sessions.check(request, Capabilities.read_network), - users: await context.sessions.check(request, Capabilities.read_users), - policy: await context.sessions.check(request, Capabilities.read_policy), - machines: await context.sessions.check(request, Capabilities.read_machines), - settings: await context.sessions.check(request, Capabilities.read_feature), + dns: context.auth.can(principal, Capabilities.read_network), + users: context.auth.can(principal, Capabilities.read_users), + policy: context.auth.can(principal, Capabilities.read_policy), + machines: context.auth.can(principal, Capabilities.read_machines), + settings: context.auth.can(principal, Capabilities.read_feature), }, onboarding: request.url.endsWith("/onboarding"), healthy: await api.isHealthy(), @@ -61,7 +60,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { } catch { return redirect("/login", { headers: { - "Set-Cookie": await context.sessions.destroySession(), + "Set-Cookie": await context.auth.destroySession(request), }, }); } diff --git a/app/routes.ts b/app/routes.ts index cd4bfff3..8058b7ce 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -13,7 +13,6 @@ export default [ route("/logout", "routes/auth/logout.ts"), route("/oidc/callback", "routes/auth/oidc-callback.ts"), route("/oidc/start", "routes/auth/oidc-start.ts"), - route("/pending-approval", "routes/auth/pending-approval.tsx"), route("/ssh", "routes/ssh/console.tsx"), // All the main logged-in dashboard routes diff --git a/app/routes/acls/acl-action.ts b/app/routes/acls/acl-action.ts index b3b0ffa2..a5fa6c30 100644 --- a/app/routes/acls/acl-action.ts +++ b/app/routes/acls/acl-action.ts @@ -1,139 +1,129 @@ -import { data } from 'react-router'; -import { isDataWithApiError } from '~/server/headscale/api/error-client'; -import { Capabilities } from '~/server/web/roles'; -import type { Route } from './+types/overview'; +import { data } from "react-router"; + +import { isDataWithApiError } from "~/server/headscale/api/error-client"; +import { Capabilities } from "~/server/web/roles"; + +import type { Route } from "./+types/overview"; // We only check capabilities here and assume it is writable // If it isn't, it'll gracefully error anyways, since this means some // fishy client manipulation is happening. export async function aclAction({ request, context }: Route.ActionArgs) { - const session = await context.sessions.auth(request); - const check = await context.sessions.check( - request, - Capabilities.write_policy, - ); - if (!check) { - throw data('You do not have permission to write to the ACL policy', { - status: 403, - }); - } + const principal = await context.auth.require(request); + const check = context.auth.can(principal, Capabilities.write_policy); + 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). - const formData = await request.formData(); - const policyData = formData.get('policy')?.toString(); - if (!policyData) { - throw data('Missing `policy` in the form data.', { - status: 400, - }); - } + // Try to write to the ACL policy via the API or via config file (TODO). + const formData = await request.formData(); + const policyData = formData.get("policy")?.toString(); + if (!policyData) { + throw data("Missing `policy` in the form data.", { + status: 400, + }); + } - const api = context.hsApi.getRuntimeClient(session.api_key); - try { - const { policy, updatedAt } = await api.setPolicy(policyData); - return data({ - success: true, - error: undefined, - policy, - updatedAt, - }); - } catch (error) { - if (isDataWithApiError(error)) { - const rawData = error.data.rawData; - // https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11 - if (rawData.includes('update is disabled')) { - throw data('Policy is not writable', { status: 403 }); - } + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); + try { + const { policy, updatedAt } = await api.setPolicy(policyData); + return data({ + success: true, + error: undefined, + policy, + updatedAt, + }); + } catch (error) { + if (isDataWithApiError(error)) { + const rawData = error.data.rawData; + // https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11 + if (rawData.includes("update is disabled")) { + throw data("Policy is not writable", { status: 403 }); + } - const message = - error.data.data != null && - 'message' in error.data.data && - typeof error.data.data.message === 'string' - ? error.data.data.message - : undefined; + const message = + error.data.data != null && + "message" in error.data.data && + typeof error.data.data.message === "string" + ? error.data.data.message + : undefined; - if (message == null) { - throw error; - } + if (message == null) { + throw error; + } - // Starting in Headscale 0.27.0 the ACLs parsing was changed meaning - // we need to reference other error messages based on API version. - if (context.hsApi.clientHelpers.isAtleast('0.27.0')) { - if (message.includes('parsing HuJSON:')) { - const cutIndex = message.indexOf('parsing HuJSON:'); - const trimmed = - cutIndex > -1 - ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` - : message; + // Starting in Headscale 0.27.0 the ACLs parsing was changed meaning + // we need to reference other error messages based on API version. + if (context.hsApi.clientHelpers.isAtleast("0.27.0")) { + if (message.includes("parsing HuJSON:")) { + const cutIndex = message.indexOf("parsing HuJSON:"); + const trimmed = + cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message; - return data( - { - success: false, - error: trimmed, - policy: undefined, - updatedAt: undefined, - }, - 400, - ); - } + return data( + { + success: false, + error: trimmed, + policy: undefined, + updatedAt: undefined, + }, + 400, + ); + } - if (message.includes('parsing policy from bytes:')) { - const cutIndex = message.indexOf('parsing policy from bytes:'); - const trimmed = - cutIndex > -1 - ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` - : message; + if (message.includes("parsing policy from bytes:")) { + const cutIndex = message.indexOf("parsing policy from bytes:"); + const trimmed = + cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message; - return data( - { - success: false, - error: trimmed, - policy: undefined, - updatedAt: undefined, - }, - 400, - ); - } - } else { - // Pre-0.27.0 error messages - if (message.includes('parsing hujson')) { - const cutIndex = message.indexOf('err: hujson:'); - const trimmed = - cutIndex > -1 - ? `Syntax error: ${message.slice(cutIndex + 12)}` - : message; + return data( + { + success: false, + error: trimmed, + policy: undefined, + updatedAt: undefined, + }, + 400, + ); + } + } else { + // Pre-0.27.0 error messages + if (message.includes("parsing hujson")) { + const cutIndex = message.indexOf("err: hujson:"); + const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 12)}` : message; - return data( - { - success: false, - error: trimmed, - policy: undefined, - updatedAt: undefined, - }, - 400, - ); - } + return data( + { + success: false, + error: trimmed, + policy: undefined, + updatedAt: undefined, + }, + 400, + ); + } - if (message.includes('unmarshalling policy')) { - const cutIndex = message.indexOf('err:'); - const trimmed = - cutIndex > -1 - ? `Syntax error: ${message.slice(cutIndex + 5)}` - : message; + if (message.includes("unmarshalling policy")) { + const cutIndex = message.indexOf("err:"); + const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 5)}` : message; - return data( - { - success: false, - error: trimmed, - policy: undefined, - updatedAt: undefined, - }, - 400, - ); - } - } - } + return data( + { + success: false, + error: trimmed, + policy: undefined, + updatedAt: undefined, + }, + 400, + ); + } + } + } - // Otherwise, this is a Headscale error that we can just propagate. - throw error; - } + // Otherwise, this is a Headscale error that we can just propagate. + throw error; + } } diff --git a/app/routes/acls/acl-loader.ts b/app/routes/acls/acl-loader.ts index 3712d68a..83faca03 100644 --- a/app/routes/acls/acl-loader.ts +++ b/app/routes/acls/acl-loader.ts @@ -1,7 +1,9 @@ -import { data } from 'react-router'; -import { isDataWithApiError } from '~/server/headscale/api/error-client'; -import { Capabilities } from '~/server/web/roles'; -import type { Route } from './+types/overview'; +import { data } from "react-router"; + +import { isDataWithApiError } from "~/server/headscale/api/error-client"; +import { Capabilities } from "~/server/web/roles"; + +import type { Route } from "./+types/overview"; // The logic for deciding policy factors is very complicated because // 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 in file mode, we can only write if context.config is available. export async function aclLoader({ request, context }: Route.LoaderArgs) { - const session = await context.sessions.auth(request); - const check = await context.sessions.check(request, Capabilities.read_policy); - if (!check) { - throw data('You do not have permission to read the ACL policy.', { - status: 403, - }); - } + const principal = await context.auth.require(request); + const check = context.auth.can(principal, Capabilities.read_policy); + if (!check) { + throw data("You do not have permission to read the ACL policy.", { + status: 403, + }); + } - const flags = { - // Can the user write to the ACL policy - access: await context.sessions.check(request, Capabilities.write_policy), - writable: false, - policy: '', - }; + const flags = { + // Can the user write to the ACL policy + access: context.auth.can(principal, Capabilities.write_policy), + writable: false, + policy: "", + }; - // Try to load the ACL policy from the API. - const api = context.hsApi.getRuntimeClient(session.api_key); - try { - const { policy, updatedAt } = await api.getPolicy(); - flags.writable = updatedAt !== null; - flags.policy = policy; - return flags; - } catch (error) { - if (isDataWithApiError(error)) { - // https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10 - if (error.data.rawData.includes('acl policy not found')) { - flags.policy = ''; - flags.writable = true; - return flags; - } - } + // Try to load the ACL policy from the API. + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); + try { + const { policy, updatedAt } = await api.getPolicy(); + flags.writable = updatedAt !== null; + flags.policy = policy; + return flags; + } catch (error) { + if (isDataWithApiError(error)) { + // https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10 + if (error.data.rawData.includes("acl policy not found")) { + flags.policy = ""; + flags.writable = true; + return flags; + } + } - throw error; - } + throw error; + } } diff --git a/app/routes/auth/login/action.ts b/app/routes/auth/login/action.ts index b310eb8f..9455f7bf 100644 --- a/app/routes/auth/login/action.ts +++ b/app/routes/auth/login/action.ts @@ -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", { headers: { - "Set-Cookie": await context.sessions.createSession( - { - api_key: apiKey, - user: { - subject: "unknown-non-oauth", - name: `${lookup.prefix}...`, - email: `expires@${expiresDays.toString()}-days`, - }, - }, + "Set-Cookie": await context.auth.createApiKeySession( + apiKey, + `${lookup.prefix}...`, expiry.getTime() - Date.now(), ), }, diff --git a/app/routes/auth/login/page.tsx b/app/routes/auth/login/page.tsx index 1ad5ea01..3bc6727f 100644 --- a/app/routes/auth/login/page.tsx +++ b/app/routes/auth/login/page.tsx @@ -10,7 +10,6 @@ import Link from "~/components/Link"; import { useLiveData } from "~/utils/live-data"; import type { Route } from "./+types/page"; - import { loginAction } from "./action"; import { OidcConfigErrorNotice, OidcDiscoveryFailedNotice } from "./config-error"; import Logout from "./logout"; @@ -18,14 +17,14 @@ import { OidcErrorNotice } from "./oidc-error"; export async function loader({ request, context }: Route.LoaderArgs) { try { - await context.sessions.auth(request); + await context.auth.require(request); return redirect("/machines"); } catch {} const qp = new URL(request.url).searchParams; 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 // for `isExclusive` if the OIDC config isn't usable. diff --git a/app/routes/auth/logout.ts b/app/routes/auth/logout.ts index e46e7a21..541e5392 100644 --- a/app/routes/auth/logout.ts +++ b/app/routes/auth/logout.ts @@ -1,29 +1,25 @@ -import { type ActionFunctionArgs, redirect } from 'react-router'; -import type { LoadContext } from '~/server'; +import { type ActionFunctionArgs, redirect } from "react-router"; + +import type { LoadContext } from "~/server"; export async function loader() { - return redirect('/machines'); + return redirect("/machines"); } -export async function action({ - request, - context, -}: ActionFunctionArgs) { - try { - await context.sessions.auth(request); - } catch { - redirect('/login'); - } +export async function action({ request, context }: ActionFunctionArgs) { + try { + await context.auth.require(request); + } catch { + redirect("/login"); + } - // When API key is disabled, we need to explicitly redirect - // with a logout state to prevent auto login again. - const url = context.config.oidc?.disable_api_key_login - ? '/login?s=logout' - : '/login'; + // When API key is disabled, we need to explicitly redirect + // with a logout state to prevent auto login again. + const url = context.config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login"; - return redirect(url, { - headers: { - 'Set-Cookie': await context.sessions.destroySession(), - }, - }); + return redirect(url, { + headers: { + "Set-Cookie": await context.auth.destroySession(request), + }, + }); } diff --git a/app/routes/auth/oidc-callback.ts b/app/routes/auth/oidc-callback.ts index 015b9da5..2821501a 100644 --- a/app/routes/auth/oidc-callback.ts +++ b/app/routes/auth/oidc-callback.ts @@ -1,18 +1,16 @@ -import { count, eq } from "drizzle-orm"; import { createHash } from "node:crypto"; + import * as oidc from "openid-client"; import { data, redirect } from "react-router"; -import { ulid } from "ulidx"; -import { users } from "~/server/db/schema"; -import { Roles } from "~/server/web/roles"; +import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity"; import log from "~/utils/log"; import { createOidcStateCookie } from "~/utils/oidc-state"; import type { Route } from "./+types/oidc-callback"; export async function loader({ request, context }: Route.LoaderArgs) { - const oidcConnector = await context.oidcConnector?.get(); + const oidcConnector = await context.oidc?.connector.get(); if (!oidcConnector?.isValid) { throw data("OIDC is not enabled or misconfigured", { status: 501 }); } @@ -82,47 +80,28 @@ export async function loader({ request, context }: Route.LoaderArgs) { })() : userInfo.picture; - const [{ count: ownerCount }] = await context.db - .select({ count: count() }) - .from(users) - .where(eq(users.caps, Roles.owner)); - - const needsOwner = ownerCount === 0; - - if (needsOwner) { - await context.db - .insert(users) - .values({ - id: ulid(), - sub: claims.sub, - caps: Roles.owner, - }) - .onConflictDoUpdate({ - target: users.sub, - set: { caps: Roles.owner }, - }); - } else { - await context.db - .insert(users) - .values({ - id: ulid(), - sub: claims.sub, - caps: Roles.member, - }) - .onConflictDoNothing(); + const hasUsers = await context.auth.hasAnyUsers(); + const defaultRole = hasUsers ? "member" : "owner"; + const userId = await context.auth.findOrCreateUser(claims.sub, defaultRole); + + try { + const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey); + const hsUsers = await hsApi.getUsers(); + const hsUser = findHeadscaleUserBySubject(hsUsers, claims.sub, userInfo.email); + if (hsUser) { + await context.auth.linkHeadscaleUser(userId, hsUser.id); + } + } catch (error) { + log.warn("auth", "Failed to link Headscale user: %s", String(error)); } return redirect("/", { headers: { - "Set-Cookie": await context.sessions.createSession({ - api_key: oidcConnector.apiKey, - user: { - subject: claims.sub, - username, - name, - email: userInfo.email, - picture, - }, + "Set-Cookie": await context.auth.createOidcSession(userId, { + name, + email: userInfo.email, + username, + picture, }), }, }); diff --git a/app/routes/auth/oidc-start.ts b/app/routes/auth/oidc-start.ts index 135a7ecb..3c55d4ef 100644 --- a/app/routes/auth/oidc-start.ts +++ b/app/routes/auth/oidc-start.ts @@ -8,11 +8,11 @@ import type { Route } from "./+types/oidc-start"; export async function loader({ request, context }: Route.LoaderArgs) { try { - await context.sessions.auth(request); + await context.auth.require(request); return redirect("/"); } catch {} - const oidcConnector = await context.oidcConnector?.get(); + const oidcConnector = await context.oidc?.connector.get(); if (!oidcConnector?.isValid) { throw data("OIDC is not enabled or misconfigured", { status: 501 }); } diff --git a/app/routes/auth/pending-approval.tsx b/app/routes/auth/pending-approval.tsx deleted file mode 100644 index fb5aaf06..00000000 --- a/app/routes/auth/pending-approval.tsx +++ /dev/null @@ -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 ( -
- -
-
- -
-
- Approval Required -

- {loaderData.user.email ?? loaderData.user.name} -

-
-
- - - Your account has been created but requires approval from an administrator before you can - access the management console. - - -
-
- -

What happens next?

-
-
    -
  • An administrator will review your account
  • -
  • Once approved, you will receive the appropriate access level
  • -
  • This page will automatically redirect you once approved
  • -
-
- - - In the meantime, you can still connect your devices to the Tailnet using the command - below: - - - -

Click to copy the command

- -
- - - Checking for approval automatically... - -
- -
- -
-
-
- ); -} diff --git a/app/routes/dns/dns-actions.ts b/app/routes/dns/dns-actions.ts index 692b9de9..a65571c5 100644 --- a/app/routes/dns/dns-actions.ts +++ b/app/routes/dns/dns-actions.ts @@ -1,231 +1,231 @@ -import { data } from 'react-router'; -import { Capabilities } from '~/server/web/roles'; -import type { Route } from './+types/overview'; +import { data } from "react-router"; + +import { Capabilities } from "~/server/web/roles"; + +import type { Route } from "./+types/overview"; export async function dnsAction({ request, context }: Route.ActionArgs) { - const check = await context.sessions.check( - request, - Capabilities.write_network, - ); - - if (!check) { - return data({ success: false }, 403); - } - - if (!context.hs.writable()) { - return data({ success: false }, 403); - } - - // We only need it for health checks which don't require auth - const api = context.hsApi.getRuntimeClient('fake-api-key'); - - const formData = await request.formData(); - const action = formData.get('action_id')?.toString(); - if (!action) { - return data({ success: false }, 400); - } - - switch (action) { - case 'rename_tailnet': { - const newName = formData.get('new_name')?.toString(); - if (!newName) { - return data({ success: false }, 400); - } - - await context.hs.patch([ - { - path: 'dns.base_domain', - value: newName, - }, - ]); - - await context.integration?.onConfigChange(api); - return { message: 'Tailnet renamed successfully' }; - } - case 'toggle_magic': { - const newState = formData.get('new_state')?.toString(); - if (!newState) { - return data({ success: false }, 400); - } - - await context.hs.patch([ - { - path: 'dns.magic_dns', - value: newState === 'enabled', - }, - ]); - - await context.integration?.onConfigChange(api); - return { message: 'Magic DNS state updated successfully' }; - } - case 'remove_ns': { - const config = context.hs.c!; - const ns = formData.get('ns')?.toString(); - const splitName = formData.get('split_name')?.toString(); - - if (!ns || !splitName) { - return data({ success: false }, 400); - } - - if (splitName === 'global') { - const servers = config.dns.nameservers.global.filter((i) => i !== ns); - - await context.hs.patch([ - { - path: 'dns.nameservers.global', - value: servers, - }, - ]); - } else { - const splits = config.dns.nameservers.split; - const servers = splits[splitName].filter((i) => i !== ns); - - await context.hs.patch([ - { - path: `dns.nameservers.split."${splitName}"`, - value: servers.length > 0 ? servers : null, - }, - ]); - } - - await context.integration?.onConfigChange(api); - return { message: 'Nameserver removed successfully' }; - } - case 'add_ns': { - const config = context.hs.c!; - const ns = formData.get('ns')?.toString(); - const splitName = formData.get('split_name')?.toString(); - - if (!ns || !splitName) { - return data({ success: false }, 400); - } - - if (splitName === 'global') { - const servers = config.dns.nameservers.global; - servers.push(ns); - - await context.hs.patch([ - { - path: 'dns.nameservers.global', - value: servers, - }, - ]); - } else { - const splits = config.dns.nameservers.split; - const servers = splits[splitName] ?? []; - servers.push(ns); - - await context.hs.patch([ - { - path: `dns.nameservers.split."${splitName}"`, - value: servers, - }, - ]); - } - - await context.integration?.onConfigChange(api); - return { message: 'Nameserver added successfully' }; - } - case 'remove_domain': { - const config = context.hs.c!; - const domain = formData.get('domain')?.toString(); - if (!domain) { - return data({ success: false }, 400); - } - - const domains = config.dns.search_domains.filter((i) => i !== domain); - await context.hs.patch([ - { - path: 'dns.search_domains', - value: domains, - }, - ]); - - await context.integration?.onConfigChange(api); - return { message: 'Domain removed successfully' }; - } - case 'add_domain': { - const config = context.hs.c!; - const domain = formData.get('domain')?.toString(); - if (!domain) { - return data({ success: false }, 400); - } - - const domains = config.dns.search_domains; - domains.push(domain); - - await context.hs.patch([ - { - path: 'dns.search_domains', - value: domains, - }, - ]); - - await context.integration?.onConfigChange(api); - return { message: 'Domain added successfully' }; - } - case 'remove_record': { - const recordName = formData.get('record_name')?.toString(); - const recordType = formData.get('record_type')?.toString(); - - if (!recordName || !recordType) { - return data({ success: false }, 400); - } - - // Value is not needed for removal - const restart = await context.hs.removeDNS({ - name: recordName, - type: recordType, - value: '', - }); - - if (!restart) { - return; - } - - await context.integration?.onConfigChange(api); - return { message: 'DNS record removed successfully' }; - } - case 'add_record': { - const recordName = formData.get('record_name')?.toString(); - const recordType = formData.get('record_type')?.toString(); - const recordValue = formData.get('record_value')?.toString(); - - if (!recordName || !recordType || !recordValue) { - return data({ success: false }, 400); - } - - const restart = await context.hs.addDNS({ - name: recordName, - type: recordType, - value: recordValue, - }); - - if (!restart) { - return; - } - - await context.integration?.onConfigChange(api); - return { message: 'DNS record added successfully' }; - } - case 'override_dns': { - const override = formData.get('override_dns')?.toString(); - if (!override) { - return data({ success: false }, 400); - } - - const overrideValue = override === 'true'; - await context.hs.patch([ - { - path: 'dns.override_local_dns', - value: overrideValue, - }, - ]); - - await context.integration?.onConfigChange(api); - return { message: 'DNS override updated successfully' }; - } - default: - return data({ success: false }, 400); - } + const principal = await context.auth.require(request); + const check = context.auth.can(principal, Capabilities.write_network); + + if (!check) { + return data({ success: false }, 403); + } + + if (!context.hs.writable()) { + return data({ success: false }, 403); + } + + // We only need it for health checks which don't require auth + const api = context.hsApi.getRuntimeClient("fake-api-key"); + + const formData = await request.formData(); + const action = formData.get("action_id")?.toString(); + if (!action) { + return data({ success: false }, 400); + } + + switch (action) { + case "rename_tailnet": { + const newName = formData.get("new_name")?.toString(); + if (!newName) { + return data({ success: false }, 400); + } + + await context.hs.patch([ + { + path: "dns.base_domain", + value: newName, + }, + ]); + + await context.integration?.onConfigChange(api); + return { message: "Tailnet renamed successfully" }; + } + case "toggle_magic": { + const newState = formData.get("new_state")?.toString(); + if (!newState) { + return data({ success: false }, 400); + } + + await context.hs.patch([ + { + path: "dns.magic_dns", + value: newState === "enabled", + }, + ]); + + await context.integration?.onConfigChange(api); + return { message: "Magic DNS state updated successfully" }; + } + case "remove_ns": { + const config = context.hs.c!; + const ns = formData.get("ns")?.toString(); + const splitName = formData.get("split_name")?.toString(); + + if (!ns || !splitName) { + return data({ success: false }, 400); + } + + if (splitName === "global") { + const servers = config.dns.nameservers.global.filter((i) => i !== ns); + + await context.hs.patch([ + { + path: "dns.nameservers.global", + value: servers, + }, + ]); + } else { + const splits = config.dns.nameservers.split; + const servers = splits[splitName].filter((i) => i !== ns); + + await context.hs.patch([ + { + path: `dns.nameservers.split."${splitName}"`, + value: servers.length > 0 ? servers : null, + }, + ]); + } + + await context.integration?.onConfigChange(api); + return { message: "Nameserver removed successfully" }; + } + case "add_ns": { + const config = context.hs.c!; + const ns = formData.get("ns")?.toString(); + const splitName = formData.get("split_name")?.toString(); + + if (!ns || !splitName) { + return data({ success: false }, 400); + } + + if (splitName === "global") { + const servers = config.dns.nameservers.global; + servers.push(ns); + + await context.hs.patch([ + { + path: "dns.nameservers.global", + value: servers, + }, + ]); + } else { + const splits = config.dns.nameservers.split; + const servers = splits[splitName] ?? []; + servers.push(ns); + + await context.hs.patch([ + { + path: `dns.nameservers.split."${splitName}"`, + value: servers, + }, + ]); + } + + await context.integration?.onConfigChange(api); + return { message: "Nameserver added successfully" }; + } + case "remove_domain": { + const config = context.hs.c!; + const domain = formData.get("domain")?.toString(); + if (!domain) { + return data({ success: false }, 400); + } + + const domains = config.dns.search_domains.filter((i) => i !== domain); + await context.hs.patch([ + { + path: "dns.search_domains", + value: domains, + }, + ]); + + await context.integration?.onConfigChange(api); + return { message: "Domain removed successfully" }; + } + case "add_domain": { + const config = context.hs.c!; + const domain = formData.get("domain")?.toString(); + if (!domain) { + return data({ success: false }, 400); + } + + const domains = config.dns.search_domains; + domains.push(domain); + + await context.hs.patch([ + { + path: "dns.search_domains", + value: domains, + }, + ]); + + await context.integration?.onConfigChange(api); + return { message: "Domain added successfully" }; + } + case "remove_record": { + const recordName = formData.get("record_name")?.toString(); + const recordType = formData.get("record_type")?.toString(); + + if (!recordName || !recordType) { + return data({ success: false }, 400); + } + + // Value is not needed for removal + const restart = await context.hs.removeDNS({ + name: recordName, + type: recordType, + value: "", + }); + + if (!restart) { + return; + } + + await context.integration?.onConfigChange(api); + return { message: "DNS record removed successfully" }; + } + case "add_record": { + const recordName = formData.get("record_name")?.toString(); + const recordType = formData.get("record_type")?.toString(); + const recordValue = formData.get("record_value")?.toString(); + + if (!recordName || !recordType || !recordValue) { + return data({ success: false }, 400); + } + + const restart = await context.hs.addDNS({ + name: recordName, + type: recordType, + value: recordValue, + }); + + if (!restart) { + return; + } + + await context.integration?.onConfigChange(api); + return { message: "DNS record added successfully" }; + } + case "override_dns": { + const override = formData.get("override_dns")?.toString(); + if (!override) { + return data({ success: false }, 400); + } + + const overrideValue = override === "true"; + await context.hs.patch([ + { + path: "dns.override_local_dns", + value: overrideValue, + }, + ]); + + await context.integration?.onConfigChange(api); + return { message: "DNS override updated successfully" }; + } + default: + return data({ success: false }, 400); + } } diff --git a/app/routes/dns/overview.tsx b/app/routes/dns/overview.tsx index 6648274e..6d605015 100644 --- a/app/routes/dns/overview.tsx +++ b/app/routes/dns/overview.tsx @@ -1,115 +1,103 @@ -import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; -import { useLoaderData } from 'react-router'; -import Code from '~/components/Code'; -import Notice from '~/components/Notice'; -import type { LoadContext } from '~/server'; -import { Capabilities } from '~/server/web/roles'; -import ManageDomains from './components/manage-domains'; -import ManageNS from './components/manage-ns'; -import ManageRecords from './components/manage-records'; -import RenameTailnet from './components/rename-tailnet'; -import ToggleMagic from './components/toggle-magic'; -import { dnsAction } from './dns-actions'; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { useLoaderData } from "react-router"; + +import Code from "~/components/Code"; +import Notice from "~/components/Notice"; +import type { LoadContext } from "~/server"; +import { Capabilities } from "~/server/web/roles"; + +import ManageDomains from "./components/manage-domains"; +import ManageNS from "./components/manage-ns"; +import ManageRecords from "./components/manage-records"; +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 -export async function loader({ - request, - context, -}: LoaderFunctionArgs) { - if (!context.hs.readable()) { - throw new Error('No configuration is available'); - } +export async function loader({ request, context }: LoaderFunctionArgs) { + if (!context.hs.readable()) { + throw new Error("No configuration is available"); + } - const check = await context.sessions.check( - request, - Capabilities.read_network, - ); - if (!check) { - // Not authorized to view this page - throw new Error( - 'You do not have permission to view this page. Please contact your administrator.', - ); - } + const principal = await context.auth.require(request); + const check = context.auth.can(principal, Capabilities.read_network); + if (!check) { + // Not authorized to view this page + throw new Error( + "You do not have permission to view this page. Please contact your administrator.", + ); + } - const writablePermission = await context.sessions.check( - request, - Capabilities.write_network, - ); + const writablePermission = context.auth.can(principal, Capabilities.write_network); - const config = context.hs.c!; - const dns = { - prefixes: config.prefixes, - magicDns: config.dns.magic_dns, - baseDomain: config.dns.base_domain, - nameservers: config.dns.nameservers.global, - splitDns: config.dns.nameservers.split, - searchDomains: config.dns.search_domains, - overrideDns: config.dns.override_local_dns, - extraRecords: context.hs.d, - }; + const config = context.hs.c!; + const dns = { + prefixes: config.prefixes, + magicDns: config.dns.magic_dns, + baseDomain: config.dns.base_domain, + nameservers: config.dns.nameservers.global, + splitDns: config.dns.nameservers.split, + searchDomains: config.dns.search_domains, + overrideDns: config.dns.override_local_dns, + extraRecords: context.hs.d, + }; - return { - ...dns, - access: writablePermission, - writable: context.hs.writable(), - }; + return { + ...dns, + access: writablePermission, + writable: context.hs.writable(), + }; } export async function action(data: ActionFunctionArgs) { - return dnsAction(data); + return dnsAction(data); } export default function Page() { - const data = useLoaderData(); + const data = useLoaderData(); - const allNs: Record = {}; - for (const key of Object.keys(data.splitDns)) { - allNs[key] = data.splitDns[key]; - } + const allNs: Record = {}; + for (const key of Object.keys(data.splitDns)) { + allNs[key] = data.splitDns[key]; + } - allNs.global = data.nameservers; - const isDisabled = data.access === false || data.writable === false; + allNs.global = data.nameservers; + const isDisabled = data.access === false || data.writable === false; - return ( -
- {data.writable ? undefined : ( - - The Headscale configuration is read-only. You cannot make changes to - the configuration - - )} - {data.access ? undefined : ( - - Your permissions do not allow you to modify the DNS settings for this - tailnet. - - )} - - - - + return ( +
+ {data.writable ? undefined : ( + + The Headscale configuration is read-only. You cannot make changes to the configuration + + )} + {data.access ? undefined : ( + + Your permissions do not allow you to modify the DNS settings for this tailnet. + + )} + + + + -
-

Magic DNS

-

- Automatically register domain names for each device on the tailnet. - Devices will be accessible at{' '} - - [device]. - {data.baseDomain} - {' '} - when Magic DNS is enabled. -

- -
-
- ); +
+

Magic DNS

+

+ Automatically register domain names for each device on the tailnet. Devices will be + accessible at{" "} + + [device]. + {data.baseDomain} + {" "} + when Magic DNS is enabled. +

+ +
+
+ ); } diff --git a/app/routes/machines/machine-actions.ts b/app/routes/machines/machine-actions.ts index 8952ec19..0b8254f2 100644 --- a/app/routes/machines/machine-actions.ts +++ b/app/routes/machines/machine-actions.ts @@ -6,11 +6,12 @@ import { Capabilities } from "~/server/web/roles"; import type { Route } from "./+types/machine"; export async function machineAction({ request, context }: Route.ActionArgs) { - const session = await context.sessions.auth(request); - const check = await context.sessions.check(request, Capabilities.write_machines); + const principal = await context.auth.require(request); 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(); 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 if (action === "register") { - if (!check) { + if (!context.auth.can(principal, Capabilities.write_machines)) { throw data("You do not have permission to manage machines", { 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 - const nodeOwnerId = node.user?.providerId?.split("/").pop(); - const isOwner = nodeOwnerId !== undefined && nodeOwnerId === session.user.subject; - if (!isOwner && !check) { + if (!context.auth.canManageNode(principal, node)) { throw data("You do not have permission to act on this machine", { status: 403, }); diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index 79d30181..165d9f12 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -21,7 +21,7 @@ import Routes from "./dialogs/routes"; import { machineAction } from "./machine-actions"; 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) { 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 node = nodes.find((node) => node.id === params.id); diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index 5158f397..3e0d3a27 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -10,30 +10,24 @@ import cn from "~/utils/cn"; import { mapNodes, sortNodeTags } from "~/utils/node-info"; import type { Route } from "./+types/overview"; - import MachineRow from "./components/machine-row"; import NewMachine from "./dialogs/new"; import { machineAction } from "./machine-actions"; export async function loader({ request, context }: Route.LoaderArgs) { - const session = await context.sessions.auth(request); - const user = session.user; - if (!user) { - throw new Error("Missing user session. Please log in again."); - } + const principal = await context.auth.require(request); - const check = await context.sessions.check(request, Capabilities.read_machines); - - if (!check) { - // Not authorized to view this page + if (!context.auth.can(principal, Capabilities.read_machines)) { throw new Error( "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()]); let magic: string | undefined; @@ -56,8 +50,8 @@ export async function loader({ request, context }: Route.LoaderArgs) { publicServer: context.config.headscale.public_url, agent: context.agents?.agentID(), writable: writablePermission, - preAuth: await context.sessions.check(request, Capabilities.generate_authkeys), - subject: user.subject, + preAuth: context.auth.can(principal, Capabilities.generate_authkeys), + headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined, supportsNodeOwnerChange: supportsNodeOwnerChange, }; } @@ -363,7 +357,7 @@ export default function Page({ loaderData }: Route.ComponentProps) { isDisabled={ loaderData.writable ? 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} magic={loaderData.magic} diff --git a/app/routes/settings/auth-keys/actions.ts b/app/routes/settings/auth-keys/actions.ts index 89a1db5a..8702a8e7 100644 --- a/app/routes/settings/auth-keys/actions.ts +++ b/app/routes/settings/auth-keys/actions.ts @@ -1,15 +1,17 @@ import { data } from "react-router"; +import { getOidcSubject } from "~/server/web/headscale-identity"; import { Capabilities } from "~/server/web/roles"; import type { Route } from "./+types/overview"; export async function authKeysAction({ request, context }: Route.ActionArgs) { - const session = await context.sessions.auth(request); - const api = context.hsApi.getRuntimeClient(session.api_key); + const principal = await context.auth.require(request); + 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 canGenerateOwn = await context.sessions.check(request, Capabilities.generate_own_authkeys); + const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys); + const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys); if (!canGenerateAny && !canGenerateOwn) { 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) { throw data("User not found.", { status: 404 }); } - const targetSubject = targetUser.providerId?.split("/").pop(); - if (targetSubject !== session.user.subject) { + const targetSubject = getOidcSubject(targetUser); + if (principal.kind !== "oidc" || targetSubject !== principal.user.subject) { throw data("You do not have permission to manage this user's pre-auth keys", { status: 403, }); diff --git a/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx b/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx index e9420f31..5811151d 100644 --- a/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx +++ b/app/routes/settings/auth-keys/dialogs/add-auth-key.tsx @@ -17,11 +17,15 @@ interface AddAuthKeyProps { users: User[]; url: string; selfServiceOnly: boolean; - currentSubject: string; + currentSubject?: string; } -function findCurrentUser(users: User[], subject: string): User | undefined { - return users.find((u) => u.providerId?.split("/").pop() === subject); +function findCurrentUser(users: User[], subject: string | undefined): User | undefined { + 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({ diff --git a/app/routes/settings/auth-keys/overview.tsx b/app/routes/settings/auth-keys/overview.tsx index 301ce92f..4d829912 100644 --- a/app/routes/settings/auth-keys/overview.tsx +++ b/app/routes/settings/auth-keys/overview.tsx @@ -19,8 +19,9 @@ import AuthKeyRow from "./auth-key-row"; import AddAuthKey from "./dialogs/add-auth-key"; export async function loader({ request, context }: Route.LoaderArgs) { - const session = await context.sessions.auth(request); - const api = context.hsApi.getRuntimeClient(session.api_key); + const principal = await context.auth.require(request); + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); const users = await api.getUsers(); @@ -83,8 +84,8 @@ export async function loader({ request, context }: Route.LoaderArgs) { .map(({ user, error }) => ({ user, error })); } - const canGenerateAny = await context.sessions.check(request, Capabilities.generate_authkeys); - const canGenerateOwn = await context.sessions.check(request, Capabilities.generate_own_authkeys); + const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys); + const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys); return { keys, @@ -92,7 +93,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { users, access: 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, }; } diff --git a/app/routes/settings/overview.tsx b/app/routes/settings/overview.tsx index a81593fb..f7dfe245 100644 --- a/app/routes/settings/overview.tsx +++ b/app/routes/settings/overview.tsx @@ -6,7 +6,7 @@ import Link from "~/components/Link"; import type { Route } from "./+types/overview"; export async function loader({ context }: Route.LoaderArgs) { - const oidcConnector = await context.oidcConnector?.get(); + const oidcConnector = await context.oidc?.connector.get(); return { config: context.hs.writable(), isOidcEnabled: oidcConnector?.isValid ?? false, diff --git a/app/routes/settings/restrictions/actions.ts b/app/routes/settings/restrictions/actions.ts index 26acd1bc..c953d808 100644 --- a/app/routes/settings/restrictions/actions.ts +++ b/app/routes/settings/restrictions/actions.ts @@ -1,198 +1,189 @@ -import { data } from 'react-router'; -import { Capabilities } from '~/server/web/roles'; -import type { Route } from './+types/overview'; - -export async function restrictionAction({ - request, - context, -}: Route.ActionArgs) { - const check = await context.sessions.check( - request, - Capabilities.configure_iam, - ); - - if (!check) { - throw data('You do not have permission to modify IAM settings.', { - status: 403, - }); - } - - if (!context.hs.writable()) { - throw data('The Headscale configuration file is not editable.', { - status: 403, - }); - } - - const formData = await request.formData(); - const action = formData.get('action_id')?.toString(); - if (!action) { - throw data('No action provided.', { - status: 400, - }); - } - - // We only need healthchecks which don't rely on an API key - const api = context.hsApi.getRuntimeClient('fake-api-key'); - switch (action) { - case 'add_domain': { - const domain = formData.get('domain')?.toString()?.trim(); - if (!domain) { - throw data('No domain provided.', { - status: 400, - }); - } - - const domains = [ - ...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain]), - ]; - - await context.hs.patch([ - { - path: 'oidc.allowed_domains', - value: domains, - }, - ]); - - context.integration?.onConfigChange(api); - return data('Domain added successfully.'); - } - - case 'remove_domain': { - const domain = formData.get('domain')?.toString()?.trim(); - if (!domain) { - throw data('No domain provided.', { - status: 400, - }); - } - - const storedDomains = context.hs.c?.oidc?.allowed_domains ?? []; - if (!storedDomains.includes(domain)) { - // 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 - 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.'); - } - - case 'add_group': { - const group = formData.get('group')?.toString()?.trim(); - if (!group) { - throw data('No group provided.', { - status: 400, - }); - } - - const groups = [ - ...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group]), - ]; - - await context.hs.patch([ - { - path: 'oidc.allowed_groups', - value: groups, - }, - ]); - - context.integration?.onConfigChange(api); - return data('Group added successfully.'); - } - - case 'remove_group': { - const group = formData.get('group')?.toString()?.trim(); - if (!group) { - throw data('No group provided.', { - status: 400, - }); - } - - const storedGroups = context.hs.c?.oidc?.allowed_groups ?? []; - if (!storedGroups.includes(group)) { - // 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 - const groups = storedGroups.filter((d: string) => d !== group); - await context.hs.patch([ - { - path: 'oidc.allowed_groups', - value: groups, - }, - ]); - - context.integration?.onConfigChange(api); - return data('Group removed successfully.'); - } - - case 'add_user': { - const user = formData.get('user')?.toString()?.trim(); - if (!user) { - throw data('No user provided.', { - status: 400, - }); - } - - const users = [ - ...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user]), - ]; - - await context.hs.patch([ - { - path: 'oidc.allowed_users', - value: users, - }, - ]); - - context.integration?.onConfigChange(api); - return data('User added successfully.'); - } - - case 'remove_user': { - const user = formData.get('user')?.toString()?.trim(); - if (!user) { - throw data('No user provided.', { - status: 400, - }); - } - - const storedUsers = context.hs.c?.oidc?.allowed_users ?? []; - if (!storedUsers.includes(user)) { - // 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 - const users = storedUsers.filter((d: string) => d !== user); - 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, - }); - } - } +import { data } from "react-router"; + +import { Capabilities } from "~/server/web/roles"; + +import type { Route } from "./+types/overview"; + +export async function restrictionAction({ request, context }: Route.ActionArgs) { + const principal = await context.auth.require(request); + const check = context.auth.can(principal, Capabilities.configure_iam); + + if (!check) { + throw data("You do not have permission to modify IAM settings.", { + status: 403, + }); + } + + if (!context.hs.writable()) { + throw data("The Headscale configuration file is not editable.", { + status: 403, + }); + } + + const formData = await request.formData(); + const action = formData.get("action_id")?.toString(); + if (!action) { + throw data("No action provided.", { + status: 400, + }); + } + + // We only need healthchecks which don't rely on an API key + const api = context.hsApi.getRuntimeClient("fake-api-key"); + switch (action) { + case "add_domain": { + const domain = formData.get("domain")?.toString()?.trim(); + if (!domain) { + throw data("No domain provided.", { + status: 400, + }); + } + + const domains = [...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain])]; + + await context.hs.patch([ + { + path: "oidc.allowed_domains", + value: domains, + }, + ]); + + context.integration?.onConfigChange(api); + return data("Domain added successfully."); + } + + case "remove_domain": { + const domain = formData.get("domain")?.toString()?.trim(); + if (!domain) { + throw data("No domain provided.", { + status: 400, + }); + } + + const storedDomains = context.hs.c?.oidc?.allowed_domains ?? []; + if (!storedDomains.includes(domain)) { + // 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 + 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."); + } + + case "add_group": { + const group = formData.get("group")?.toString()?.trim(); + if (!group) { + throw data("No group provided.", { + status: 400, + }); + } + + const groups = [...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group])]; + + await context.hs.patch([ + { + path: "oidc.allowed_groups", + value: groups, + }, + ]); + + context.integration?.onConfigChange(api); + return data("Group added successfully."); + } + + case "remove_group": { + const group = formData.get("group")?.toString()?.trim(); + if (!group) { + throw data("No group provided.", { + status: 400, + }); + } + + const storedGroups = context.hs.c?.oidc?.allowed_groups ?? []; + if (!storedGroups.includes(group)) { + // 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 + const groups = storedGroups.filter((d: string) => d !== group); + await context.hs.patch([ + { + path: "oidc.allowed_groups", + value: groups, + }, + ]); + + context.integration?.onConfigChange(api); + return data("Group removed successfully."); + } + + case "add_user": { + const user = formData.get("user")?.toString()?.trim(); + if (!user) { + throw data("No user provided.", { + status: 400, + }); + } + + const users = [...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user])]; + + await context.hs.patch([ + { + path: "oidc.allowed_users", + value: users, + }, + ]); + + context.integration?.onConfigChange(api); + return data("User added successfully."); + } + + case "remove_user": { + const user = formData.get("user")?.toString()?.trim(); + if (!user) { + throw data("No user provided.", { + status: 400, + }); + } + + const storedUsers = context.hs.c?.oidc?.allowed_users ?? []; + if (!storedUsers.includes(user)) { + // 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 + const users = storedUsers.filter((d: string) => d !== user); + 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, + }); + } + } } diff --git a/app/routes/settings/restrictions/overview.tsx b/app/routes/settings/restrictions/overview.tsx index 5dabccc4..f177ad0c 100644 --- a/app/routes/settings/restrictions/overview.tsx +++ b/app/routes/settings/restrictions/overview.tsx @@ -1,108 +1,91 @@ -import { data, Link as RemixLink } from 'react-router'; -import Link from '~/components/Link'; -import Notice from '~/components/Notice'; -import { Capabilities } from '~/server/web/roles'; -import type { Route } from './+types/overview'; -import { restrictionAction } from './actions'; -import AddDomain from './dialogs/add-domain'; -import AddGroup from './dialogs/add-group'; -import AddUser from './dialogs/add-user'; -import RestrictionTable from './table'; +import { data, Link as RemixLink } from "react-router"; + +import Link from "~/components/Link"; +import Notice from "~/components/Notice"; +import { Capabilities } from "~/server/web/roles"; + +import type { Route } from "./+types/overview"; +import { restrictionAction } from "./actions"; +import AddDomain from "./dialogs/add-domain"; +import AddGroup from "./dialogs/add-group"; +import AddUser from "./dialogs/add-user"; +import RestrictionTable from "./table"; export async function loader({ request, context }: Route.LoaderArgs) { - const check = await context.sessions.check(request, Capabilities.read_users); - if (!check) { - throw data('You do not have permission to view IAM settings.', { - status: 403, - }); - } + const principal = await context.auth.require(request); + const check = context.auth.can(principal, Capabilities.read_users); + if (!check) { + throw data("You do not have permission to view IAM settings.", { + status: 403, + }); + } - if (!context.hs.c?.oidc) { - throw data('OIDC is not configured on this Headscale instance.', { - status: 501, - }); - } + if (!context.hs.c?.oidc) { + throw data("OIDC is not configured on this Headscale instance.", { + status: 501, + }); + } - return { - access: await context.sessions.check(request, Capabilities.configure_iam), - writable: context.hs.writable(), - settings: { - domains: [...new Set(context.hs.c.oidc.allowed_domains)], - groups: [...new Set(context.hs.c.oidc.allowed_groups)], - users: [...new Set(context.hs.c.oidc.allowed_users)], - }, - }; + return { + access: context.auth.can(principal, Capabilities.configure_iam), + writable: context.hs.writable(), + settings: { + domains: [...new Set(context.hs.c.oidc.allowed_domains)], + groups: [...new Set(context.hs.c.oidc.allowed_groups)], + users: [...new Set(context.hs.c.oidc.allowed_users)], + }, + }; } export const action = restrictionAction; -export default function Page({ - loaderData: { access, writable, settings }, -}: Route.ComponentProps) { - const isDisabled = writable ? !access : true; +export default function Page({ loaderData: { access, writable, settings } }: Route.ComponentProps) { + const isDisabled = writable ? !access : true; - return ( -
-
-

- - Settings - - / Authentication Restrictions -

- {!access ? ( - - You do not have the necessary permissions to edit the Authentication - Restrictions settings. Please contact your administrator to request - access or to make changes to these settings. - - ) : !writable ? ( - - The Headscale configuration file is not editable through the web - interface. Please ensure that you have correctly given Headplane - write access to the file. - - ) : undefined} -

- Authentication Restrictions -

-

- Headscale supports restricting OIDC authentication to only allow - certain email domains, groups, or users to authenticate. This can be - used to limit access to your Tailnet to only certain users or groups - and Headplane will also respect these settings when authenticating.{' '} - - Learn More - -

-
- - - - - - - - - -
- ); + return ( +
+
+

+ + Settings + + / Authentication Restrictions +

+ {!access ? ( + + You do not have the necessary permissions to edit the Authentication Restrictions + settings. Please contact your administrator to request access or to make changes to + these settings. + + ) : !writable ? ( + + The Headscale configuration file is not editable through the web interface. Please + ensure that you have correctly given Headplane write access to the file. + + ) : undefined} +

Authentication Restrictions

+

+ Headscale supports restricting OIDC authentication to only allow certain email domains, + groups, or users to authenticate. This can be used to limit access to your Tailnet to only + certain users or groups and Headplane will also respect these settings when + authenticating.{" "} + + Learn More + +

+
+ + + + + + + + + +
+ ); } diff --git a/app/routes/ssh/console.tsx b/app/routes/ssh/console.tsx index b68c2232..d2fabab3 100644 --- a/app/routes/ssh/console.tsx +++ b/app/routes/ssh/console.tsx @@ -6,10 +6,10 @@ import { data, type ShouldRevalidateFunction, useSubmit } from "react-router"; import { ExternalScriptsHandle } from "remix-utils/external-scripts"; import { EphemeralNodeInsert, ephemeralNodes } from "~/server/db/schema"; +import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity"; import { useLiveData } from "~/utils/live-data"; import type { Route } from "./+types/console"; - import UserPrompt from "./user-prompt"; 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); } - const session = await context.sessions.auth(request); - if (session.user.subject === "unknown-non-oauth") { + const principal = await context.auth.require(request); + if (principal.kind === "api_key") { 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(); // MARK: This assumes that a user has authenticated with Headscale first // 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 // authenticated with Headscale first. - const lookup = users.find((u) => { - const subject = u.providerId?.split("/").pop(); - if (!subject) { - return false; - } - return subject === session.user.subject; - }); + const lookup = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email); 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( @@ -157,7 +152,7 @@ function generateHostname(username: string) { } export async function action({ request, context }: Route.ActionArgs) { - await context.sessions.auth(request); + await context.auth.require(request); if (!context.agents?.agentID()) { throw data("WebSSH is only available with the Headplane agent integration", 400); } diff --git a/app/routes/users/onboarding-skip.tsx b/app/routes/users/onboarding-skip.tsx index f909ab5a..42c261bf 100644 --- a/app/routes/users/onboarding-skip.tsx +++ b/app/routes/users/onboarding-skip.tsx @@ -1,20 +1,49 @@ -import { eq } from 'drizzle-orm'; -import { redirect } from 'react-router'; -import { users } from '~/server/db/schema'; -import type { Route } from './+types/onboarding-skip'; +import { eq } from "drizzle-orm"; +import { redirect } from "react-router"; + +import { users } from "~/server/db/schema"; + +import type { Route } from "./+types/onboarding-skip"; export async function loader({ request, context }: Route.LoaderArgs) { - try { - const { user } = await context.sessions.auth(request); - await context.db - .update(users) - .set({ - onboarded: true, - }) - .where(eq(users.sub, user.subject)); - - return redirect('/machines'); - } catch { - return redirect('/login'); - } + try { + const principal = await context.auth.require(request); + if (principal.kind !== "oidc") { + return redirect("/machines"); + } + + await context.db + .update(users) + .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"); + } } diff --git a/app/routes/users/onboarding.tsx b/app/routes/users/onboarding.tsx index dbbedd62..dd84af2b 100644 --- a/app/routes/users/onboarding.tsx +++ b/app/routes/users/onboarding.tsx @@ -1,27 +1,29 @@ import { Icon } from "@iconify/react"; import { ArrowRight } from "lucide-react"; import { useEffect } from "react"; -import { NavLink } from "react-router"; +import { Form, NavLink } from "react-router"; import Button from "~/components/Button"; import Card from "~/components/Card"; import Link from "~/components/Link"; import Options from "~/components/Options"; import StatusCircle from "~/components/StatusCircle"; +import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity"; import { Machine } from "~/types"; import cn from "~/utils/cn"; import { useLiveData } from "~/utils/live-data"; import log from "~/utils/log"; import toast from "~/utils/toast"; +import { getUserDisplayName } from "~/utils/user"; import type { Route } from "./+types/onboarding"; 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 os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/); let osValue = "linux"; @@ -47,45 +49,58 @@ export async function loader({ request, context }: Route.LoaderArgs) { 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 needsUserLink = false; + let headscaleUsers: { id: string; name: string }[] = []; + try { - const nodes = await api.getNodes(); - const node = nodes.find((n) => { - // Tag-only nodes have no user - if (!n.user || n.user.provider !== "oidc") { - return false; - } + const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]); - // 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 (hsUserId) { + firstMachine = nodes.find((n) => n.user?.id === hsUserId); + } else { + const matched = findHeadscaleUserBySubject( + apiUsers, + principal.user.subject, + principal.profile.email, + ); - if (subject !== session.user.subject) { - return false; + 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), + })); } - - return true; - }); - - firstMachine = node; + } } catch (e) { - // If we cannot lookup nodes, we cannot proceed log.debug("api", "Failed to lookup nodes %o", e); } 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, firstMachine, + needsUserLink, + headscaleUsers, }; } export default function Page({ - loaderData: { user, osValue, firstMachine }, + loaderData: { user, osValue, firstMachine, needsUserLink, headscaleUsers }, }: Route.ComponentProps) { const { pause, resume } = useLiveData(); useEffect(() => { @@ -107,6 +122,36 @@ export default function Page({ return (
+ {needsUserLink && headscaleUsers.length > 0 ? ( + + Link your Headscale account + + Headplane couldn't automatically match your SSO identity to a Headscale user. Select + which Headscale user you are to continue. + +
+ + +
+
+ ) : undefined} Welcome! diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx index eb14eb0a..66d0a626 100644 --- a/app/routes/users/overview.tsx +++ b/app/routes/users/overview.tsx @@ -1,13 +1,13 @@ import { createHash } from "node:crypto"; -import { useEffect, useState } from "react"; -import type { Machine, User } from "~/types"; +import { useEffect, useState } from "react"; +import { getOidcSubject } from "~/server/web/headscale-identity"; import { Capabilities } from "~/server/web/roles"; +import type { Machine, User } from "~/types"; import cn from "~/utils/cn"; import type { Route } from "./+types/overview"; - import ManageBanner from "./components/manage-banner"; import UserRow from "./components/user-row"; import { userAction } from "./user-actions"; @@ -17,8 +17,8 @@ interface UserMachine extends User { } export async function loader({ request, context }: Route.LoaderArgs) { - const session = await context.sessions.auth(request); - const check = await context.sessions.check(request, Capabilities.read_users); + const principal = await context.auth.require(request); + const check = await context.auth.can(principal, Capabilities.read_users); if (!check) { // Not authorized to view this page 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 users = apiUsers.map((user) => ({ @@ -56,22 +57,13 @@ export async function loader({ request, context }: Route.LoaderArgs) { return "no-oidc"; } - if (user.provider === "oidc" && user.providerId) { - // For some reason, headscale makes providerID a url where the - // last component is the subject, so we need to strip that out - const subject = user.providerId.split("/").pop(); - if (!subject) { - return "invalid-oidc"; - } - - const role = await context.sessions.roleForSubject(subject); - return role ?? "no-role"; + const subject = getOidcSubject(user); + if (!subject) { + return "invalid-oidc"; } - // No role means the user is not registered in Headplane, but they - // are in Headscale. We also need to handle what happens if someone - // logs into the UI and they don't have a Headscale setup. - return "no-role"; + const role = await context.auth.roleForSubject(subject); + return role ?? "no-role"; }), ); diff --git a/app/routes/users/user-actions.ts b/app/routes/users/user-actions.ts index 901d4b03..4f9a3f90 100644 --- a/app/routes/users/user-actions.ts +++ b/app/routes/users/user-actions.ts @@ -1,115 +1,112 @@ -import { data } from 'react-router'; -import { Capabilities, Roles } from '~/server/web/roles'; -import type { Route } from './+types/overview'; +import { data } from "react-router"; + +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) { - const session = await context.sessions.auth(request); - const check = await context.sessions.check(request, Capabilities.write_users); - if (!check) { - throw data('You do not have permission to update users', { - status: 403, - }); - } - - const formData = await request.formData(); - const action = formData.get('action_id')?.toString(); - if (!action) { - throw data('Missing `action_id` in the form data.', { - status: 404, - }); - } - - const api = context.hsApi.getRuntimeClient(session.api_key); - switch (action) { - case 'create_user': { - const name = formData.get('username')?.toString(); - const displayName = formData.get('display_name')?.toString(); - const email = formData.get('email')?.toString(); - - if (!name) { - throw data('Missing `username` in the form data.', { - status: 400, - }); - } - - await api.createUser(name, email, displayName); - return { message: 'User created successfully' }; - } - case 'delete_user': { - const userId = formData.get('user_id')?.toString(); - if (!userId) { - throw data('Missing `user_id` in the form data.', { - status: 400, - }); - } - - await api.deleteUser(userId); - return { message: 'User deleted successfully' }; - } - case 'rename_user': { - const userId = formData.get('user_id')?.toString(); - const newName = formData.get('new_name')?.toString(); - if (!userId || !newName) { - return data({ success: false }, 400); - } - - const users = await api.getUsers(userId); - const user = users.find((user) => user.id === userId); - if (!user) { - throw data(`No user found with id: ${userId}`, { status: 400 }); - } - - if (user.provider === 'oidc') { - // OIDC users cannot be renamed via this endpoint, return an error - throw data('Users managed by OIDC cannot be renamed', { - status: 403, - }); - } - - await api.renameUser(userId, newName); - return { message: 'User renamed successfully' }; - } - case 'reassign_user': { - const userId = formData.get('user_id')?.toString(); - const newRole = formData.get('new_role')?.toString(); - if (!userId || !newRole) { - throw data('Missing `user_id` or `new_role` in the form data.', { - status: 400, - }); - } - - const users = await api.getUsers(userId); - const user = users.find((user) => user.id === userId); - if (!user?.providerId) { - throw data('Specified user is not an OIDC user', { - status: 400, - }); - } - - // For some reason, headscale makes providerID a url where the - // last component is the subject, so we need to strip that out - const subject = user.providerId?.split('/').pop(); - if (!subject) { - throw data( - 'Malformed `providerId` for the specified user. Cannot find subject.', - { status: 400 }, - ); - } - - const result = await context.sessions.reassignSubject( - subject, - newRole as keyof typeof Roles, - ); - - if (!result) { - throw data('Failed to reassign user role.', { status: 500 }); - } - - return { message: 'User reassigned successfully' }; - } - default: - throw data('Invalid `action_id` provided.', { - status: 400, - }); - } + const principal = await context.auth.require(request); + const check = await context.auth.can(principal, Capabilities.write_users); + if (!check) { + throw data("You do not have permission to update users", { + status: 403, + }); + } + + const formData = await request.formData(); + const action = formData.get("action_id")?.toString(); + if (!action) { + throw data("Missing `action_id` in the form data.", { + status: 404, + }); + } + + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); + switch (action) { + case "create_user": { + const name = formData.get("username")?.toString(); + const displayName = formData.get("display_name")?.toString(); + const email = formData.get("email")?.toString(); + + if (!name) { + throw data("Missing `username` in the form data.", { + status: 400, + }); + } + + await api.createUser(name, email, displayName); + return { message: "User created successfully" }; + } + case "delete_user": { + const userId = formData.get("user_id")?.toString(); + if (!userId) { + throw data("Missing `user_id` in the form data.", { + status: 400, + }); + } + + await api.deleteUser(userId); + return { message: "User deleted successfully" }; + } + case "rename_user": { + const userId = formData.get("user_id")?.toString(); + const newName = formData.get("new_name")?.toString(); + if (!userId || !newName) { + return data({ success: false }, 400); + } + + const users = await api.getUsers(userId); + const user = users.find((user) => user.id === userId); + if (!user) { + throw data(`No user found with id: ${userId}`, { status: 400 }); + } + + if (user.provider === "oidc") { + // OIDC users cannot be renamed via this endpoint, return an error + throw data("Users managed by OIDC cannot be renamed", { + status: 403, + }); + } + + await api.renameUser(userId, newName); + return { message: "User renamed successfully" }; + } + case "reassign_user": { + const userId = formData.get("user_id")?.toString(); + const newRole = formData.get("new_role")?.toString(); + if (!userId || !newRole) { + throw data("Missing `user_id` or `new_role` in the form data.", { + status: 400, + }); + } + + const users = await api.getUsers(userId); + const user = users.find((user) => user.id === userId); + if (!user) { + throw data("Specified user not found", { + status: 400, + }); + } + + const subject = getOidcSubject(user); + if (!subject) { + throw data("Specified user is not an OIDC user or has no subject.", { status: 400 }); + } + + const result = await context.auth.reassignSubject(subject, newRole as Role); + + if (!result) { + throw data("Failed to reassign user role.", { status: 500 }); + } + + return { message: "User reassigned successfully" }; + } + default: + throw data("Invalid `action_id` provided.", { + status: 400, + }); + } } diff --git a/app/server/db/pruner.ts b/app/server/db/pruner.ts index b6865762..8193c739 100644 --- a/app/server/db/pruner.ts +++ b/app/server/db/pruner.ts @@ -1,50 +1,48 @@ -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({ - 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) { - log.debug('api', 'No ephemeral nodes to prune'); - return; - } - - const api = context.hsApi.getRuntimeClient(session.api_key); - const nodes = await api.getNodes(); - const toPrune = nodes.filter((node) => { - if (node.online) { - return false; - } - - return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key); - }); - - if (toPrune.length === 0) { - log.debug('api', 'No SSH nodes to prune'); - return; - } - - // 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())); +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({ context, request }: Route.LoaderArgs) { + const principal = await context.auth.require(request); + const ephemerals = await context.db + .select() + .from(ephemeralNodes) + .where(isNotNull(ephemeralNodes.node_key)); + + if (ephemerals.length === 0) { + log.debug("api", "No ephemeral nodes to prune"); + return; + } + + const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey); + const api = context.hsApi.getRuntimeClient(apiKey); + const nodes = await api.getNodes(); + const toPrune = nodes.filter((node) => { + if (node.online) { + return false; + } + + return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key); + }); + + if (toPrune.length === 0) { + log.debug("api", "No SSH nodes to prune"); + return; + } + + // 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())); } diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index 1f24d1f2..b3bc8801 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -1,31 +1,50 @@ -import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; -import { HostInfo } from '~/types'; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; -export const ephemeralNodes = sqliteTable('ephemeral_nodes', { - auth_key: text('auth_key').primaryKey(), - node_key: text('node_key'), +import { HostInfo } from "~/types"; + +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 EphemeralNodeInsert = typeof ephemeralNodes.$inferInsert; -export const hostInfo = sqliteTable('host_info', { - host_id: text('host_id').primaryKey(), - payload: text('payload', { mode: 'json' }).$type(), - updated_at: integer('updated_at', { mode: 'timestamp' }).$default( - () => new Date(), - ), +export const hostInfo = sqliteTable("host_info", { + host_id: text("host_id").primaryKey(), + payload: text("payload", { mode: "json" }).$type(), + updated_at: integer("updated_at", { mode: "timestamp" }).$default(() => new Date()), }); export type HostInfoRecord = typeof hostInfo.$inferSelect; export type HostInfoInsert = typeof hostInfo.$inferInsert; -export const users = sqliteTable('users', { - id: text('id').primaryKey(), - sub: text('sub').notNull().unique(), - caps: integer('caps').notNull().default(0), - onboarded: integer('onboarded', { mode: 'boolean' }).notNull().default(false), +export const users = sqliteTable("users", { + id: text("id").primaryKey(), + sub: text("sub").notNull().unique(), + role: text("role").notNull().default("member"), + 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 HeadplaneUser = typeof users.$inferSelect; +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 User = typeof users.$inferSelect; -export type UserInsert = typeof users.$inferInsert; +export type AuthSessionRecord = typeof authSessions.$inferSelect; +export type AuthSessionInsert = typeof authSessions.$inferInsert; diff --git a/app/server/index.ts b/app/server/index.ts index aedfc309..b2ed5b33 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -1,5 +1,6 @@ import { join } from "node:path"; import { exit, versions } from "node:process"; + import { createHonoServer } from "react-router-hono-server/node"; import log from "~/utils/log"; @@ -10,7 +11,7 @@ import { createDbClient } from "./db/client.server"; import { createHeadscaleInterface } from "./headscale/api"; import { loadHeadscaleConfig } from "./headscale/config-loader"; import { createHeadplaneAgent } from "./hp-agent"; -import { createSessionStorage } from "./web/sessions"; +import { createAuthService } from "./web/auth"; declare global { const __PREFIX__: string; @@ -60,11 +61,9 @@ const appLoadContext = { config.headscale.dns_records_path, ), - // TODO: Better cookie options in config - sessions: await createSessionStorage({ + auth: createAuthService({ secret: config.server.cookie_secret, db, - oidcUsersFile: config.oidc?.user_storage_file, cookie: { name: "_hp_auth", secure: config.server.cookie_secure, @@ -76,13 +75,16 @@ const appLoadContext = { hsApi, agents, integration: await loadIntegration(config.integration), - oidcConnector: + oidc: config.oidc && config.oidc.enabled !== false - ? createLazyOidcConnector( - config.server.base_url, - config.oidc, - hsApi.getRuntimeClient(config.oidc.headscale_api_key), - ) + ? { + apiKey: config.oidc.headscale_api_key, + connector: createLazyOidcConnector( + config.server.base_url, + config.oidc, + hsApi.getRuntimeClient(config.oidc.headscale_api_key), + ), + } : undefined, db, }; diff --git a/app/server/web/auth.ts b/app/server/web/auth.ts new file mode 100644 index 00000000..e4479717 --- /dev/null +++ b/app/server/web/auth.ts @@ -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>(); + + 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 { + 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 { + 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, + maxAge = this.opts.cookie.maxAge, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.opts.db.delete(authSessions).where(lt(authSessions.expires_at, new Date())); + } + + // ── Private helpers ──────────────────────────────────────────── + + private async encodeCookie(payload: CookiePayload, maxAge: number): Promise { + 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 { + 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); +} diff --git a/app/server/web/headscale-identity.ts b/app/server/web/headscale-identity.ts new file mode 100644 index 00000000..ef72ff40 --- /dev/null +++ b/app/server/web/headscale-identity.ts @@ -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/"). 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); +} diff --git a/app/server/web/roles.ts b/app/server/web/roles.ts index 3454814f..02d6a53c 100644 --- a/app/server/web/roles.ts +++ b/app/server/web/roles.ts @@ -1,60 +1,24 @@ export type Capabilities = (typeof Capabilities)[keyof typeof Capabilities]; export const Capabilities = { - // Can access the admin console ui_access: 1 << 0, - - // Read tailnet policy file (unimplemented) read_policy: 1 << 1, - - // Write tailnet policy file (unimplemented) write_policy: 1 << 2, - - // Read network configurations 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, - - // Read feature configuration (unimplemented) read_feature: 1 << 5, - - // Write feature configuration, for example, enable Taildrop (unimplemented) write_feature: 1 << 6, - - // Configure user & group provisioning configure_iam: 1 << 7, - - // Read machines, for example, see machine names and status read_machines: 1 << 8, - - // Write machines, for example, approve, rename, and remove machines write_machines: 1 << 9, - - // Read users and user roles read_users: 1 << 10, - - // Write users and user roles, for example, remove users, - // approve users, make Admin write_users: 1 << 11, - - // Can generate authkeys for any user generate_authkeys: 1 << 12, - - // Can generate authkeys for own user only generate_own_authkeys: 1 << 16, - - // Can use any tag (without being tag owner) (unimplemented) use_tags: 1 << 13, - - // Write tailnet name (unimplemented) write_tailnet: 1 << 14, - - // Owner flag owner: 1 << 15, } as const; -export type Roles = [keyof typeof Roles]; export const Roles = { owner: Capabilities.ui_access | @@ -126,12 +90,19 @@ export const Roles = { Capabilities.read_users | 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, } as const; export type Role = keyof typeof Roles; export type Capability = keyof typeof Capabilities; + export function hasCapability(role: Role, capability: Capability): boolean { return (Roles[role] & Capabilities[capability]) !== 0; } @@ -146,3 +117,7 @@ export function getRoleFromCapabilities(capabilities: Capabilities): Role { return "member"; } + +export function capsForRole(role: Role): number { + return Roles[role]; +} diff --git a/app/server/web/sessions.ts b/app/server/web/sessions.ts deleted file mode 100644 index 4dbc2b7c..00000000 --- a/app/server/web/sessions.ts +++ /dev/null @@ -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 { - 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 }); -} diff --git a/docs/features/sso.md b/docs/features/sso.md index 67cac9a3..fd7740f5 100644 --- a/docs/features/sso.md +++ b/docs/features/sso.md @@ -13,58 +13,45 @@ outline: [2, 3] Single Sign-On allows users to authenticate with Headplane through an external -Identity Provider (IdP). It does this using the OpenID Connect (OIDC) protocol, -which is widely supported by many popular IdPs. +Identity Provider (IdP) using the OpenID Connect (OIDC) protocol. When enabled, +users sign in through your IdP and Headplane automatically links them to their +Headscale identity, assigns a role, and manages their session. ## 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 -::: warning -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 before proceeding: -You'll need the following things set up before proceeding: - A working Headplane installation that is already configured. - 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 -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). ### 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 -domain is replaced with the value set for `server.base_url` in your Headplane -configuration: +You'll need to create a client in your Identity Provider that Headplane can use +for authentication. As part of that step, you'll need to register a "redirect +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 ``` -Once you have created the client in your IdP, make note of the following -information as you'll need it for the Headplane configuration: +Once you have created the client, make note of the following: + - Client ID - Client Secret (if applicable) - Issuer URL ### 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 oidc: @@ -75,18 +62,18 @@ oidc: # You can also provide the client secret via a file: # 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: "" # token_endpoint: "" # userinfo_endpoint: "" # scope: "openid email profile" # extra_params: # foo: "bar" - # baz: "qux" ``` -Headplane automatically tries to discover the necessary OIDC endpoints but if -your IdP does not support discovery, you may need to manually specify them. +Headplane automatically discovers OIDC endpoints from your issuer's +`/.well-known/openid-configuration`. If your IdP does not support discovery, +you'll need to set the endpoints manually. ### PKCE @@ -96,29 +83,147 @@ You may need to ensure that your Identity Provider is configured to accept this method. ::: -By default, Headplane does not use PKCE (Proof Key for Code Exchange) when -communicating with the Identity Provider. PKCE is generally a best practice for -OIDC and can enhance security. *Some Identity Providers may even require PKCE -to be used.* To enable PKCE you'll need to set `oidc.use_pkce` -to `true` in your Headplane configuration file: +By default, Headplane does not use PKCE (Proof Key for Code Exchange). PKCE is +a best practice for OIDC and enhances security — some IdPs even require it. To +enable PKCE: ```yaml oidc: use_pkce: true ``` +## How User Matching Works + +When a user signs in via OIDC, Headplane needs to link them to their +corresponding Headscale user. This is important for features like showing a +user's own machines, self-service pre-auth keys, and WebSSH. + +### Matching Strategy + +Headplane uses a two-step matching strategy: + +1. **Subject match (primary)**: Headscale stores the IdP's `provider_id` for + each OIDC user (e.g. `https://idp.example.com/3d6f6e3f-...`). Headplane + 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 -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 -and has not expired. -- **Missing [some]_endpoint**: If your IdP does not provide standard OIDC -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 -`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 -and that `server.base_url` in Headplane match exactly. -- **Cookie Issues**: The OIDC authentication relies on your cookie configuration -for Headplane. If OIDC cannot complete due to a missing session or invalid -session then please check your cookie settings. + +### 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. diff --git a/drizzle/0003_thick_otto_octavius.sql b/drizzle/0003_thick_otto_octavius.sql new file mode 100644 index 00000000..f6118c02 --- /dev/null +++ b/drizzle/0003_thick_otto_octavius.sql @@ -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; \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..c0c1835f --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -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": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 78491b64..c80130c9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -1,27 +1,34 @@ { - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1750355487927, - "tag": "0000_spicy_bloodscream", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1755554742267, - "tag": "0001_naive_lilith", - "breakpoints": true - }, - { - "idx": 2, - "version": "6", - "when": 1755617607599, - "tag": "0002_square_bloodstorm", - "breakpoints": true - } - ] + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1750355487927, + "tag": "0000_spicy_bloodscream", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1755554742267, + "tag": "0001_naive_lilith", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1755617607599, + "tag": "0002_square_bloodstorm", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1772917638504, + "tag": "0003_thick_otto_octavius", + "breakpoints": true + } + ] } From 45984ec63975f0736c8ec87788ee66c265f6769f Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sat, 7 Mar 2026 17:31:34 -0500 Subject: [PATCH 2/7] chore: remove deprecated oidc.user_storage_file option The flat-file user store has been deprecated for several versions. All user data now lives in the SQL database. Removes the config field, nix option, docs, and migration logic. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019cce57-c9e1-7732-9709-8288127573a9 --- app/server/config/config-schema.ts | 2 - app/server/web/auth.ts | 47 ++--- docs/NixOS-options.md | 302 +++++++++++++---------------- nix/options.nix | 9 - 4 files changed, 162 insertions(+), 198 deletions(-) diff --git a/app/server/config/config-schema.ts b/app/server/config/config-schema.ts index 6e0875b2..2b64c839 100644 --- a/app/server/config/config-schema.ts +++ b/app/server/config/config-schema.ts @@ -100,7 +100,6 @@ const oidcConfig = type({ token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?', // Old/deprecated options - user_storage_file: 'string.lower = "/var/lib/headplane/users.json"', strict_validation: type("unknown").narrow(deprecatedField()).optional(), }); @@ -123,7 +122,6 @@ const partialOidcConfig = type({ token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?', // Old/deprecated options - user_storage_file: "string.lower?", strict_validation: type("unknown").narrow(deprecatedField()).optional(), }); diff --git a/app/server/web/auth.ts b/app/server/web/auth.ts index e4479717..e3f1f806 100644 --- a/app/server/web/auth.ts +++ b/app/server/web/auth.ts @@ -1,6 +1,6 @@ -import { createHash } from "node:crypto"; +import { createHash, createHmac } from "node:crypto"; -import { eq, lt } from "drizzle-orm"; +import { eq, lt, sql } from "drizzle-orm"; import { LibSQLDatabase } from "drizzle-orm/libsql/driver"; import { createCookie } from "react-router"; import { ulid } from "ulidx"; @@ -212,8 +212,9 @@ export class AuthService { } /** - * Create a new API key session. The API key is stored server-side - * as a SHA-256 hash — it never appears in the cookie. + * Create a new API key session. A SHA-256 hash of the key is stored + * server-side for auditing. The plaintext key is carried in the + * HMAC-signed cookie so it can be used for Headscale API calls. * Returns the Set-Cookie header value. */ async createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise { @@ -272,9 +273,10 @@ export class AuthService { /** * Find or create a Headplane user by OIDC subject. Returns the - * user ID. Used during OIDC callback to establish identity. + * user ID. The first user ever created is automatically granted + * the owner role (bootstrap). Uses upsert to avoid race conditions. */ - async findOrCreateUser(subject: string, defaultRole: Role): Promise { + async findOrCreateUser(subject: string): Promise { const [existing] = await this.opts.db .select() .from(users) @@ -293,20 +295,25 @@ export class AuthService { await this.opts.db.insert(users).values({ id, sub: subject, - role: defaultRole, - caps: capsForRole(defaultRole), + role: "member", + caps: capsForRole("member"), onboarded: false, }); - return id; - } + // If this is the only user in the table, promote to owner. + // The unique constraint on `sub` prevents two concurrent inserts + // for the same subject; for different subjects, COUNT atomically + // reflects all committed rows so at most one will see count === 1. + const [{ count }] = await this.opts.db.select({ count: sql`count(*)` }).from(users); - /** - * Check if there are any users in the database (for bootstrap). - */ - async hasAnyUsers(): Promise { - const [row] = await this.opts.db.select({ id: users.id }).from(users).limit(1); - return row !== undefined; + if (count === 1) { + await this.opts.db + .update(users) + .set({ role: "owner", caps: capsForRole("owner") }) + .where(eq(users.id, id)); + } + + return id; } /** @@ -377,9 +384,7 @@ export class AuthService { }); const signed = Buffer.from(JSON.stringify(payload)).toString("base64url"); - const hmac = createHash("sha256") - .update(this.opts.secret + signed) - .digest("base64url"); + const hmac = createHmac("sha256", this.opts.secret).update(signed).digest("base64url"); return cookie.serialize(`${signed}.${hmac}`); } @@ -408,9 +413,7 @@ export class AuthService { const signed = raw.slice(0, dotIndex); const hmac = raw.slice(dotIndex + 1); - const expected = createHash("sha256") - .update(this.opts.secret + signed) - .digest("base64url"); + const expected = createHmac("sha256", this.opts.secret).update(signed).digest("base64url"); if (hmac !== expected) { throw new Error("Invalid session cookie signature"); diff --git a/docs/NixOS-options.md b/docs/NixOS-options.md index 275b8ba7..fabbfb57 100644 --- a/docs/NixOS-options.md +++ b/docs/NixOS-options.md @@ -5,374 +5,346 @@ All options must be under `services.headplane`. For example: `settings.headscale.config_path` becomes `services.headplane.settings.headscale.config_path`. ## debug -*Description:* Enable debug logging -*Type:* boolean +_Description:_ Enable debug logging -*Default:* `false` +_Type:_ boolean +_Default:_ `false` ## enable -*Description:* Whether to enable headplane. -*Type:* boolean +_Description:_ Whether to enable headplane. -*Default:* `false` +_Type:_ boolean -*Example:* `true` +_Default:_ `false` +_Example:_ `true` ## package -*Description:* The headplane package to use. -*Type:* package +_Description:_ The headplane package to use. -*Default:* `pkgs.headplane` +_Type:_ package +_Default:_ `pkgs.headplane` ## settings -*Description:* Headplane configuration options. Generates a YAML config file. -See: https://github.com/tale/headplane/blob/main/config.example.yaml - -*Type:* submodule +_Description:_ Headplane configuration options. Generates a YAML config file. +See: https://github.com/tale/headplane/blob/main/config.example.yaml -*Default:* `{ }` +_Type:_ submodule +_Default:_ `{ }` ## settings.headscale -*Description:* Headscale specific settings for Headplane integration. -*Type:* submodule +_Description:_ Headscale specific settings for Headplane integration. -*Default:* `{ }` +_Type:_ submodule +_Default:_ `{ }` ## settings.headscale.config_path -*Description:* Path to the Headscale configuration file. + +_Description:_ Path to the Headscale configuration file. This is optional, but HIGHLY recommended for the best experience. If this is read only, Headplane will show your configuration settings in the Web UI, but they cannot be changed. +_Type:_ null or absolute path -*Type:* null or absolute path - -*Default:* `null` - -*Example:* `"/etc/headscale/config.yaml"` +_Default:_ `null` +_Example:_ `"/etc/headscale/config.yaml"` ## settings.headscale.config_strict -*Description:* Headplane internally validates the Headscale configuration + +_Description:_ Headplane internally validates the Headscale configuration to ensure that it changes the configuration in a safe way. If you want to disable this validation, set this to false. +_Type:_ boolean -*Type:* boolean - -*Default:* `true` - +_Default:_ `true` ## settings.headscale.dns_records_path -*Description:* If you are using `dns.extra_records_path` in your Headscale configuration, you need to set this to the path for Headplane to be able to read the DNS records. + +_Description:_ If you are using `dns.extra_records_path` in your Headscale configuration, you need to set this to the path for Headplane to be able to read the DNS records. Ensure that the file is both readable and writable by the Headplane process. When using this, Headplane will no longer need to automatically restart Headscale for DNS record changes. +_Type:_ null or absolute path -*Type:* null or absolute path - -*Default:* `null` - -*Example:* `"/var/lib/headplane/extra_records.json"` +_Default:_ `null` +_Example:_ `"/var/lib/headplane/extra_records.json"` ## settings.headscale.public_url -*Description:* Public URL if differrent. This affects certain parts of the web UI. -*Type:* null or string +_Description:_ Public URL if differrent. This affects certain parts of the web UI. -*Default:* `null` +_Type:_ null or string -*Example:* `"https://headscale.example.com"` +_Default:_ `null` +_Example:_ `"https://headscale.example.com"` ## settings.headscale.tls_cert_path -*Description:* Path to a file containing the TLS certificate. - -*Type:* null or absolute path +_Description:_ Path to a file containing the TLS certificate. -*Default:* `null` +_Type:_ null or absolute path -*Example:* `"config.sops.secrets.tls_cert.path"` +_Default:_ `null` +_Example:_ `"config.sops.secrets.tls_cert.path"` ## settings.headscale.url -*Description:* The URL to your Headscale instance. + +_Description:_ The URL to your Headscale instance. All API requests are routed through this URL. THIS IS NOT the gRPC endpoint, but the HTTP endpoint. IMPORTANT: If you are using TLS this MUST be set to `https://`. +_Type:_ string -*Type:* string - -*Default:* `"http://127.0.0.1:8080"` - -*Example:* `"https://headscale.example.com"` +_Default:_ `"http://127.0.0.1:8080"` +_Example:_ `"https://headscale.example.com"` ## settings.integration -*Description:* Integration configurations for Headplane to interact with Headscale. -*Type:* submodule +_Description:_ Integration configurations for Headplane to interact with Headscale. -*Default:* `{ }` +_Type:_ submodule +_Default:_ `{ }` ## settings.integration.agent -*Description:* Agent configuration for the Headplane agent. -*Type:* submodule +_Description:_ Agent configuration for the Headplane agent. -*Default:* `{ }` +_Type:_ submodule +_Default:_ `{ }` ## settings.integration.agent.cache_path -*Description:* Where to store the agent cache. -*Type:* absolute path +_Description:_ Where to store the agent cache. -*Default:* `"/var/lib/headplane/agent_cache.json"` +_Type:_ absolute path +_Default:_ `"/var/lib/headplane/agent_cache.json"` ## settings.integration.agent.cache_ttl -*Description:* How long to cache agent information (in milliseconds). -If you want data to update faster, reduce the TTL, but this will increase the frequency of requests to Headscale. +_Description:_ How long to cache agent information (in milliseconds). +If you want data to update faster, reduce the TTL, but this will increase the frequency of requests to Headscale. -*Type:* signed integer - -*Default:* `180000` +_Type:_ signed integer +_Default:_ `180000` ## settings.integration.agent.enabled -*Description:* The Headplane agent allows retrieving information about nodes. + +_Description:_ The Headplane agent allows retrieving information about nodes. This allows the UI to display version, OS, and connectivity data. You will see the Headplane agent in your Tailnet as a node when it connects. +_Type:_ boolean -*Type:* boolean - -*Default:* `false` - +_Default:_ `false` ## settings.integration.agent.host_name -*Description:* Optionally change the name of the agent in the Tailnet -*Type:* string +_Description:_ Optionally change the name of the agent in the Tailnet -*Default:* `"headplane-agent"` +_Type:_ string +_Default:_ `"headplane-agent"` ## settings.integration.agent.package -*Description:* The headplane-agent package to use. -*Type:* package +_Description:_ The headplane-agent package to use. -*Default:* `pkgs.headplane-agent` +_Type:_ package +_Default:_ `pkgs.headplane-agent` ## settings.integration.agent.pre_authkey_path -*Description:* Path to a file containing the agent preauth key. + +_Description:_ Path to a file containing the agent preauth key. To connect to your Tailnet, you need to generate a pre-auth key. This can be done via the web UI or through the `headscale` CLI. +_Type:_ null or absolute path -*Type:* null or absolute path - -*Default:* `null` - -*Example:* `"config.sops.secrets.agent_pre_authkey.path"` +_Default:_ `null` +_Example:_ `"config.sops.secrets.agent_pre_authkey.path"` ## settings.integration.agent.work_dir -*Description:* Do not change this unless you are running a custom deployment. + +_Description:_ Do not change this unless you are running a custom deployment. The work_dir represents where the agent will store its data to be able to automatically reauthenticate with your Tailnet. It needs to be writable by the user running the Headplane process. +_Type:_ absolute path -*Type:* absolute path - -*Default:* `"/var/lib/headplane/agent"` - +_Default:_ `"/var/lib/headplane/agent"` ## settings.integration.proc -*Description:* Native process integration settings. -*Type:* submodule +_Description:_ Native process integration settings. -*Default:* `{ }` +_Type:_ submodule +_Default:_ `{ }` ## settings.integration.proc.enabled -*Description:* Enable "Native" integration that works when Headscale and + +_Description:_ Enable "Native" integration that works when Headscale and Headplane are running outside of a container. There is no additional configuration, but you need to ensure that the Headplane process can terminate the Headscale process. +_Type:_ boolean -*Type:* boolean - -*Default:* `true` - +_Default:_ `true` ## settings.oidc -*Description:* OIDC Configuration for authentication. -*Type:* submodule +_Description:_ OIDC Configuration for authentication. -*Default:* `{ }` +_Type:_ submodule +_Default:_ `{ }` ## settings.oidc.client_id -*Description:* The client ID for the OIDC client. -*Type:* string +_Description:_ The client ID for the OIDC client. -*Default:* `""` +_Type:_ string -*Example:* `"your-client-id"` +_Default:_ `""` +_Example:_ `"your-client-id"` ## settings.oidc.client_secret_path -*Description:* Path to a file containing the OIDC client secret. - -*Type:* null or absolute path +_Description:_ Path to a file containing the OIDC client secret. -*Default:* `null` +_Type:_ null or absolute path -*Example:* `"config.sops.secrets.oidc_client_secret.path"` +_Default:_ `null` +_Example:_ `"config.sops.secrets.oidc_client_secret.path"` ## settings.oidc.disable_api_key_login -*Description:* Whether to disable API key login. -*Type:* boolean +_Description:_ Whether to disable API key login. -*Default:* `false` +_Type:_ boolean +_Default:_ `false` ## settings.oidc.headscale_api_key_path -*Description:* Path to a file containing the Headscale API key. - -*Type:* null or absolute path +_Description:_ Path to a file containing the Headscale API key. -*Default:* `null` +_Type:_ null or absolute path -*Example:* `"config.sops.secrets.headscale_api_key.path"` +_Default:_ `null` +_Example:_ `"config.sops.secrets.headscale_api_key.path"` ## settings.oidc.issuer -*Description:* URL to OpenID issuer. -*Type:* string +_Description:_ URL to OpenID issuer. -*Default:* `""` +_Type:_ string -*Example:* `"https://provider.example.com/issuer-url"` +_Default:_ `""` +_Example:_ `"https://provider.example.com/issuer-url"` ## settings.oidc.redirect_uri -*Description:* This should point to your publicly accessible URL -for your Headplane instance with /admin/oidc/callback. - -*Type:* string +_Description:_ This should point to your publicly accessible URL +for your Headplane instance with /admin/oidc/callback. -*Default:* `""` +_Type:_ string -*Example:* `"https://headscale.example.com/admin/oidc/callback"` +_Default:_ `""` +_Example:_ `"https://headscale.example.com/admin/oidc/callback"` ## settings.oidc.token_endpoint_auth_method -*Description:* The token endpoint authentication method. - -*Type:* one of "client_secret_post", "client_secret_basic", "client_secret_jwt" - -*Default:* `"client_secret_post"` - -## settings.oidc.user_storage_file -*Description:* Path to a file containing the users and their permissions for Headplane. +_Description:_ The token endpoint authentication method. +_Type:_ one of "client_secret_post", "client_secret_basic", "client_secret_jwt" -*Type:* absolute path - -*Default:* `"/var/lib/headplane/users.json"` - -*Example:* `"/var/lib/headplane/users.json"` - +_Default:_ `"client_secret_post"` ## settings.server -*Description:* Server configuration for Headplane web application. -*Type:* submodule +_Description:_ Server configuration for Headplane web application. -*Default:* `{ }` +_Type:_ submodule +_Default:_ `{ }` ## settings.server.cookie_secret_path -*Description:* Path to a file containing the cookie secret. -The secret must be exactly 32 characters long. - -*Type:* null or absolute path +_Description:_ Path to a file containing the cookie secret. +The secret must be exactly 32 characters long. -*Default:* `null` +_Type:_ null or absolute path -*Example:* `"config.sops.secrets.headplane_cookie.path"` +_Default:_ `null` +_Example:_ `"config.sops.secrets.headplane_cookie.path"` ## settings.server.cookie_secure -*Description:* Should the cookies only work over HTTPS? + +_Description:_ Should the cookies only work over HTTPS? Set to false if running via HTTP without a proxy. Recommended to be true in production. +_Type:_ boolean -*Type:* boolean - -*Default:* `true` - +_Default:_ `true` ## settings.server.data_path -*Description:* The path to persist Headplane specific data. + +_Description:_ The path to persist Headplane specific data. All data going forward is stored in this directory, including the internal database and any cache related files. Data formats prior to 0.6.1 will automatically be migrated. +_Type:_ absolute path -*Type:* absolute path - -*Default:* `"/var/lib/headplane"` - -*Example:* `"/var/lib/headplane"` +_Default:_ `"/var/lib/headplane"` +_Example:_ `"/var/lib/headplane"` ## settings.server.host -*Description:* The host address to bind to. -*Type:* string +_Description:_ The host address to bind to. -*Default:* `"127.0.0.1"` +_Type:_ string -*Example:* `"0.0.0.0"` +_Default:_ `"127.0.0.1"` +_Example:_ `"0.0.0.0"` ## settings.server.port -*Description:* The port to listen on. -*Type:* 16 bit unsigned integer; between 0 and 65535 (both inclusive) +_Description:_ The port to listen on. -*Default:* `3000` +_Type:_ 16 bit unsigned integer; between 0 and 65535 (both inclusive) +_Default:_ `3000` diff --git a/nix/options.nix b/nix/options.nix index 20ee37cd..b53dd9e5 100644 --- a/nix/options.nix +++ b/nix/options.nix @@ -343,15 +343,6 @@ in { example = "config.sops.secrets.headscale_api_key.path"; }; - user_storage_file = mkOption { - type = types.path; - default = "/var/lib/headplane/users.json"; - description = '' - Path to a file containing the users and their permissions for Headplane. - ''; - example = "/var/lib/headplane/users.json"; - }; - use_pkce = mkOption { type = types.bool; default = false; From 684a95b5e8fc86f831567d9d4083e5d2646d7c16 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sun, 8 Mar 2026 00:48:32 -0500 Subject: [PATCH 3/7] fix: harden user linking and session pruning Security: - Add unique constraint on headscale_user_id to prevent hijacking - linkHeadscaleUser now rejects already-claimed Headscale users - Onboarding dropdown filters out claimed users - onboarding-skip action redirects on rejected claims Maintenance: - Replace probabilistic session pruning with setInterval (15m) - Move pruning out of request path into server startup Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019cce57-c9e1-7732-9709-8288127573a9 --- app/routes/auth/oidc-callback.ts | 4 +--- app/routes/users/onboarding-skip.tsx | 5 ++++- app/routes/users/onboarding.tsx | 11 ++++++---- app/server/db/schema.ts | 2 +- app/server/index.ts | 8 +++++++ app/server/web/auth.ts | 33 ++++++++++++++++++++++++++-- drizzle/0003_thick_otto_octavius.sql | 1 + drizzle/meta/0003_snapshot.json | 5 +++++ 8 files changed, 58 insertions(+), 11 deletions(-) diff --git a/app/routes/auth/oidc-callback.ts b/app/routes/auth/oidc-callback.ts index 2821501a..b8e41ec9 100644 --- a/app/routes/auth/oidc-callback.ts +++ b/app/routes/auth/oidc-callback.ts @@ -80,9 +80,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { })() : userInfo.picture; - const hasUsers = await context.auth.hasAnyUsers(); - const defaultRole = hasUsers ? "member" : "owner"; - const userId = await context.auth.findOrCreateUser(claims.sub, defaultRole); + const userId = await context.auth.findOrCreateUser(claims.sub); try { const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey); diff --git a/app/routes/users/onboarding-skip.tsx b/app/routes/users/onboarding-skip.tsx index 42c261bf..ccf72db7 100644 --- a/app/routes/users/onboarding-skip.tsx +++ b/app/routes/users/onboarding-skip.tsx @@ -34,7 +34,10 @@ export async function action({ request, context }: Route.ActionArgs) { const headscaleUserId = formData.get("headscale_user_id")?.toString(); if (headscaleUserId) { - await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId); + const linked = await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId); + if (!linked) { + return redirect("/onboarding"); + } } await context.db diff --git a/app/routes/users/onboarding.tsx b/app/routes/users/onboarding.tsx index dd84af2b..b0ff8de4 100644 --- a/app/routes/users/onboarding.tsx +++ b/app/routes/users/onboarding.tsx @@ -74,10 +74,13 @@ export async function loader({ request, context }: Route.LoaderArgs) { firstMachine = nodes.find((n) => n.user?.id === matched.id); } else { needsUserLink = true; - headscaleUsers = apiUsers.map((u) => ({ - id: u.id, - name: getUserDisplayName(u), - })); + const claimed = await context.auth.claimedHeadscaleUserIds(); + headscaleUsers = apiUsers + .filter((u) => !claimed.has(u.id)) + .map((u) => ({ + id: u.id, + name: getUserDisplayName(u), + })); } } } catch (e) { diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index b3bc8801..e9977d9e 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -23,7 +23,7 @@ export const users = sqliteTable("users", { id: text("id").primaryKey(), sub: text("sub").notNull().unique(), role: text("role").notNull().default("member"), - headscale_user_id: text("headscale_user_id"), + headscale_user_id: text("headscale_user_id").unique(), 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()), diff --git a/app/server/index.ts b/app/server/index.ts index b2ed5b33..f66cbc55 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -129,6 +129,14 @@ export default createHonoServer({ }, }); +// Prune expired auth sessions every 15 minutes +setInterval( + () => { + appLoadContext.auth.pruneExpiredSessions(); + }, + 15 * 60 * 1000, +); + process.on("SIGINT", () => { log.info("server", "Received SIGINT, shutting down..."); process.exit(0); diff --git a/app/server/web/auth.ts b/app/server/web/auth.ts index e3f1f806..cc88c9ec 100644 --- a/app/server/web/auth.ts +++ b/app/server/web/auth.ts @@ -317,13 +317,42 @@ export class AuthService { } /** - * Update the Headscale user link for a Headplane user. + * Link a Headplane user to a Headscale user. Returns false if the + * Headscale user is already claimed by another Headplane user. */ - async linkHeadscaleUser(userId: string, headscaleUserId: string): Promise { + async linkHeadscaleUser(userId: string, headscaleUserId: string): Promise { + const [existing] = await this.opts.db + .select({ id: users.id }) + .from(users) + .where(eq(users.headscale_user_id, headscaleUserId)) + .limit(1); + + if (existing && existing.id !== userId) { + return false; + } + await this.opts.db .update(users) .set({ headscale_user_id: headscaleUserId, updated_at: new Date() }) .where(eq(users.id, userId)); + + return true; + } + + /** + * Returns the set of Headscale user IDs that are already claimed + * by a Headplane user. Used to filter the onboarding dropdown. + */ + async claimedHeadscaleUserIds(): Promise> { + const rows = await this.opts.db.select({ hsId: users.headscale_user_id }).from(users); + + const ids = new Set(); + for (const row of rows) { + if (row.hsId) { + ids.add(row.hsId); + } + } + return ids; } /** diff --git a/drizzle/0003_thick_otto_octavius.sql b/drizzle/0003_thick_otto_octavius.sql index f6118c02..ff95d74d 100644 --- a/drizzle/0003_thick_otto_octavius.sql +++ b/drizzle/0003_thick_otto_octavius.sql @@ -10,6 +10,7 @@ CREATE TABLE `auth_sessions` ( --> statement-breakpoint ALTER TABLE `users` ADD `role` text DEFAULT 'member' NOT NULL;--> statement-breakpoint ALTER TABLE `users` ADD `headscale_user_id` text;--> statement-breakpoint +CREATE UNIQUE INDEX `users_headscale_user_id_unique` ON `users` (`headscale_user_id`);--> 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 diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json index c0c1835f..fadb54e8 100644 --- a/drizzle/meta/0003_snapshot.json +++ b/drizzle/meta/0003_snapshot.json @@ -193,6 +193,11 @@ "name": "users_sub_unique", "columns": ["sub"], "isUnique": true + }, + "users_headscale_user_id_unique": { + "name": "users_headscale_user_id_unique", + "columns": ["headscale_user_id"], + "isUnique": true } }, "foreignKeys": {}, From 434f8860348c7b0410f4903dd9f4d492a72e33dd Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sun, 8 Mar 2026 00:51:23 -0500 Subject: [PATCH 4/7] feat: admin UI for Headscale user linking + skip onboarding - Add 'Link Headscale user' option in user menu (admin-only, OIDC users) - Dialog shows only unclaimed Headscale users - New link_user action in user-actions with claim validation - Onboarding now allows skipping the link step with clear messaging - Users who skip are told they can ask an admin to link later Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019cce57-c9e1-7732-9709-8288127573a9 --- app/routes/users/components/menu.tsx | 162 +++++++++++++---------- app/routes/users/components/user-row.tsx | 10 +- app/routes/users/dialogs/link-user.tsx | 58 ++++++++ app/routes/users/onboarding.tsx | 59 +++++---- app/routes/users/overview.tsx | 35 ++++- app/routes/users/user-actions.ts | 27 ++++ app/server/web/auth.ts | 19 +++ 7 files changed, 277 insertions(+), 93 deletions(-) create mode 100644 app/routes/users/dialogs/link-user.tsx diff --git a/app/routes/users/components/menu.tsx b/app/routes/users/components/menu.tsx index a3fb12be..0cb4ef9f 100644 --- a/app/routes/users/components/menu.tsx +++ b/app/routes/users/components/menu.tsx @@ -1,74 +1,102 @@ -import { Ellipsis } from 'lucide-react'; -import { useState } from 'react'; -import Menu from '~/components/Menu'; -import type { Machine, User } from '~/types'; -import cn from '~/utils/cn'; -import Delete from '../dialogs/delete-user'; -import Reassign from '../dialogs/reassign-user'; -import Rename from '../dialogs/rename-user'; +import { Ellipsis } from "lucide-react"; +import { useState } from "react"; + +import Menu from "~/components/Menu"; +import type { Machine, User } from "~/types"; +import cn from "~/utils/cn"; + +import Delete from "../dialogs/delete-user"; +import LinkUser from "../dialogs/link-user"; +import Reassign from "../dialogs/reassign-user"; +import Rename from "../dialogs/rename-user"; interface MenuProps { - user: User & { - headplaneRole: string; - machines: Machine[]; - }; + user: User & { + headplaneRole: string; + machines: Machine[]; + }; + headscaleUsers: { id: string; name: string; claimed: boolean }[]; + currentLink?: string; } -type Modal = 'rename' | 'delete' | 'reassign' | null; +type Modal = "rename" | "delete" | "reassign" | "link" | null; + +export default function UserMenu({ user, headscaleUsers, currentLink }: MenuProps) { + const [modal, setModal] = useState(null); + + const disabledKeys: string[] = []; + if (user.provider === "oidc") { + disabledKeys.push("rename"); + } else { + disabledKeys.push("reassign", "link"); + } + + // Filter linkable users: unclaimed, or the one currently linked to this user + const linkableUsers = headscaleUsers.filter((u) => !u.claimed || u.id === currentLink); -export default function UserMenu({ user }: MenuProps) { - const [modal, setModal] = useState(null); - return ( - <> - {modal === 'rename' && ( - { - if (!isOpen) setModal(null); - }} - user={user} - /> - )} - {modal === 'delete' && ( - { - if (!isOpen) setModal(null); - }} - user={user} - /> - )} - {modal === 'reassign' && ( - { - if (!isOpen) setModal(null); - }} - user={user} - /> - )} + return ( + <> + {modal === "rename" && ( + { + if (!isOpen) setModal(null); + }} + user={user} + /> + )} + {modal === "delete" && ( + { + if (!isOpen) setModal(null); + }} + user={user} + /> + )} + {modal === "reassign" && ( + { + if (!isOpen) setModal(null); + }} + user={user} + /> + )} + {modal === "link" && ( + { + if (!isOpen) setModal(null); + }} + user={user} + /> + )} - - - - - setModal(key as Modal)}> - - Rename user - Change role - -

Delete

-
-
-
-
- - ); + + + + + setModal(key as Modal)}> + + Rename user + Change role + Link Headscale user + +

Delete

+
+
+
+
+ + ); } diff --git a/app/routes/users/components/user-row.tsx b/app/routes/users/components/user-row.tsx index 2e5586c7..2be65f6d 100644 --- a/app/routes/users/components/user-row.tsx +++ b/app/routes/users/components/user-row.tsx @@ -9,9 +9,11 @@ import MenuOptions from "./menu"; interface UserRowProps { role: string; user: User & { machines: Machine[] }; + headscaleUsers: { id: string; name: string; claimed: boolean }[]; + currentLink?: string; } -export default function UserRow({ user, role }: UserRowProps) { +export default function UserRow({ user, role, headscaleUsers, currentLink }: UserRowProps) { const isOnline = user.machines.some((machine) => machine.online); const lastSeen = user.machines.reduce( (acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()), @@ -59,7 +61,11 @@ export default function UserRow({ user, role }: UserRowProps) { - + ); diff --git a/app/routes/users/dialogs/link-user.tsx b/app/routes/users/dialogs/link-user.tsx new file mode 100644 index 00000000..e1d9fae5 --- /dev/null +++ b/app/routes/users/dialogs/link-user.tsx @@ -0,0 +1,58 @@ +import Dialog from "~/components/Dialog"; +import Notice from "~/components/Notice"; +import type { User } from "~/types"; +import cn from "~/utils/cn"; + +interface LinkUserProps { + user: User & { headplaneRole: string }; + headscaleUsers: { id: string; name: string }[]; + currentLink?: string; + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; +} + +export default function LinkUser({ + user, + headscaleUsers, + currentLink, + isOpen, + setIsOpen, +}: LinkUserProps) { + return ( + + + Link Headscale user for {user.name || user.displayName} + + Select which Headscale user this OIDC identity should be linked to. This controls which + machines they can manage and enables self-service features. + + {headscaleUsers.length === 0 ? ( + All Headscale users are already linked to other accounts. + ) : ( + <> + + + + + )} + + + ); +} diff --git a/app/routes/users/onboarding.tsx b/app/routes/users/onboarding.tsx index b0ff8de4..48beb6ee 100644 --- a/app/routes/users/onboarding.tsx +++ b/app/routes/users/onboarding.tsx @@ -125,34 +125,47 @@ export default function Page({ return (
- {needsUserLink && headscaleUsers.length > 0 ? ( + {needsUserLink ? ( Link your Headscale account - Headplane couldn't automatically match your SSO identity to a Headscale user. Select - which Headscale user you are to continue. + Headplane couldn't automatically match your SSO identity to a Headscale user. + {headscaleUsers.length > 0 + ? " Select which Headscale user you are, or skip to continue without linking." + : " All Headscale users are already linked. You can skip this step and ask an admin to link your account later."} -
- - +
+ ) : undefined} + + - + +

+ Without linking, you won't be able to see your own machines or generate pre-auth keys. + An admin can link your account later from the Users page. +

) : undefined} diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx index 66d0a626..451aec3c 100644 --- a/app/routes/users/overview.tsx +++ b/app/routes/users/overview.tsx @@ -1,11 +1,14 @@ import { createHash } from "node:crypto"; +import { eq } from "drizzle-orm"; import { useEffect, useState } from "react"; +import { users as usersTable } from "~/server/db/schema"; import { getOidcSubject } from "~/server/web/headscale-identity"; import { Capabilities } from "~/server/web/roles"; import type { Machine, User } from "~/types"; import cn from "~/utils/cn"; +import { getUserDisplayName } from "~/utils/user"; import type { Route } from "./+types/overview"; import ManageBanner from "./components/manage-banner"; @@ -74,6 +77,28 @@ export async function loader({ request, context }: Route.LoaderArgs) { } } + // Build linkable Headscale users for admin link dialog + const claimed = await context.auth.claimedHeadscaleUserIds(); + const headscaleUsers = apiUsers.map((u) => ({ + id: u.id, + name: getUserDisplayName(u), + claimed: claimed.has(u.id), + })); + + // Build a map of Headscale user -> linked Headplane subject + const userLinks: Record = {}; + for (const u of apiUsers) { + const subject = getOidcSubject(u); + if (subject) { + const [hp] = await context.db + .select({ hsId: usersTable.headscale_user_id }) + .from(usersTable) + .where(eq(usersTable.sub, subject)) + .limit(1); + userLinks[u.id] = hp?.hsId ?? undefined; + } + } + return { writable: writablePermission, // whether the user can write to the API oidc: context.config.oidc @@ -84,6 +109,8 @@ export async function loader({ request, context }: Route.LoaderArgs) { roles, magic, users, + headscaleUsers, + userLinks, }; } @@ -124,7 +151,13 @@ export default function Page({ loaderData }: Route.ComponentProps) { {users .sort((a, b) => a.name.localeCompare(b.name)) .map((user) => ( - + ))} diff --git a/app/routes/users/user-actions.ts b/app/routes/users/user-actions.ts index 4f9a3f90..bb9a9a45 100644 --- a/app/routes/users/user-actions.ts +++ b/app/routes/users/user-actions.ts @@ -104,6 +104,33 @@ export async function userAction({ request, context }: Route.ActionArgs) { return { message: "User reassigned successfully" }; } + case "link_user": { + const userId = formData.get("user_id")?.toString(); + const headscaleUserId = formData.get("headscale_user_id")?.toString(); + if (!userId || !headscaleUserId) { + throw data("Missing `user_id` or `headscale_user_id` in the form data.", { + status: 400, + }); + } + + const users = await api.getUsers(userId); + const user = users.find((user) => user.id === userId); + if (!user) { + throw data("Specified user not found", { status: 400 }); + } + + const subject = getOidcSubject(user); + if (!subject) { + throw data("Specified user is not an OIDC user or has no subject.", { status: 400 }); + } + + const linked = await context.auth.linkHeadscaleUserBySubject(subject, headscaleUserId); + if (!linked) { + throw data("That Headscale user is already linked to another account.", { status: 409 }); + } + + return { message: "Headscale user linked successfully" }; + } default: throw data("Invalid `action_id` provided.", { status: 400, diff --git a/app/server/web/auth.ts b/app/server/web/auth.ts index cc88c9ec..48bb0c93 100644 --- a/app/server/web/auth.ts +++ b/app/server/web/auth.ts @@ -339,6 +339,25 @@ export class AuthService { return true; } + /** + * Link a Headplane user (identified by OIDC subject) to a Headscale + * user. Used by admin UI when subjects are more accessible than + * internal Headplane IDs. Returns false if already claimed. + */ + async linkHeadscaleUserBySubject(subject: string, headscaleUserId: string): Promise { + const [user] = await this.opts.db + .select({ id: users.id }) + .from(users) + .where(eq(users.sub, subject)) + .limit(1); + + if (!user) { + return false; + } + + return this.linkHeadscaleUser(user.id, headscaleUserId); + } + /** * Returns the set of Headscale user IDs that are already claimed * by a Headplane user. Used to filter the onboarding dropdown. From 270b99f0632c44af523ff79ee642072f07f91af8 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sun, 8 Mar 2026 01:21:11 -0500 Subject: [PATCH 5/7] fix: show linked user in onboarding + pending approval screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Onboarding now shows which Headscale user was auto-linked - Members (no ui_access) see a 'Pending Approval' page instead of being silently logged out — their session stays valid - Sign out button on the pending page so users can switch accounts Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019cce57-c9e1-7732-9709-8288127573a9 --- app/layouts/shell.tsx | 37 ++++++++++++++++++++++++++++----- app/routes/users/onboarding.tsx | 14 ++++++++++++- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/app/layouts/shell.tsx b/app/layouts/shell.tsx index eb0c85da..5ec00bee 100644 --- a/app/layouts/shell.tsx +++ b/app/layouts/shell.tsx @@ -1,5 +1,7 @@ -import { Outlet, redirect } from "react-router"; +import { Form, Outlet, redirect } from "react-router"; +import Button from "~/components/Button"; +import Card from "~/components/Card"; import Footer from "~/components/Footer"; import Header from "~/components/Header"; import { Capabilities } from "~/server/web/roles"; @@ -25,10 +27,6 @@ export async function loader({ request, context }: Route.LoaderArgs) { const api = context.hsApi.getRuntimeClient(apiKey); const check = context.auth.can(principal, Capabilities.ui_access); - if (!check && principal.kind === "oidc" && !request.url.endsWith("/onboarding")) { - throw new Error("You do not have permission to access the UI"); - } - const user = principal.kind === "oidc" ? { @@ -55,6 +53,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { settings: context.auth.can(principal, Capabilities.read_feature), }, onboarding: request.url.endsWith("/onboarding"), + pendingApproval: !check && principal.kind === "oidc", healthy: await api.isHealthy(), }; } catch { @@ -67,6 +66,34 @@ export async function loader({ request, context }: Route.LoaderArgs) { } export default function Shell({ loaderData }: Route.ComponentProps) { + if (loaderData.pendingApproval && !loaderData.onboarding) { + return ( + <> +
+
+
+ + Pending Approval + + Your account has been created but you don't have access to the UI yet. An + administrator needs to assign you a role before you can continue. + + + If you believe this is a mistake, please contact your administrator. + +
+ +
+
+
+
+
+ + ); + } + return ( <>
diff --git a/app/routes/users/onboarding.tsx b/app/routes/users/onboarding.tsx index 48beb6ee..d2d1d103 100644 --- a/app/routes/users/onboarding.tsx +++ b/app/routes/users/onboarding.tsx @@ -55,12 +55,15 @@ export async function loader({ request, context }: Route.LoaderArgs) { const hsUserId = principal.user.headscaleUserId; let firstMachine: Machine | undefined; let needsUserLink = false; + let linkedUserName: string | undefined; let headscaleUsers: { id: string; name: string }[] = []; try { const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]); if (hsUserId) { + const hsUser = apiUsers.find((u) => u.id === hsUserId); + linkedUserName = hsUser ? getUserDisplayName(hsUser) : undefined; firstMachine = nodes.find((n) => n.user?.id === hsUserId); } else { const matched = findHeadscaleUserBySubject( @@ -71,6 +74,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { if (matched) { await context.auth.linkHeadscaleUser(principal.user.id, matched.id); + linkedUserName = getUserDisplayName(matched); firstMachine = nodes.find((n) => n.user?.id === matched.id); } else { needsUserLink = true; @@ -98,12 +102,13 @@ export async function loader({ request, context }: Route.LoaderArgs) { osValue, firstMachine, needsUserLink, + linkedUserName, headscaleUsers, }; } export default function Page({ - loaderData: { user, osValue, firstMachine, needsUserLink, headscaleUsers }, + loaderData: { user, osValue, firstMachine, needsUserLink, linkedUserName, headscaleUsers }, }: Route.ComponentProps) { const { pause, resume } = useLiveData(); useEffect(() => { @@ -168,6 +173,13 @@ export default function Page({

) : undefined} + {linkedUserName && !needsUserLink ? ( + +

+ ✓ Your account has been linked to Headscale user {linkedUserName}. +

+
+ ) : undefined} Welcome! From a1efe36ff19cc10de467ed9bd135ff040c8ad1c2 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sun, 8 Mar 2026 01:55:38 -0500 Subject: [PATCH 6/7] fix: rename pending approval to no access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Member role simply has no UI permissions — not a pending state. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-019cce57-c9e1-7732-9709-8288127573a9 --- app/layouts/shell.tsx | 13 +++++-------- app/routes/users/components/user-row.tsx | 7 +------ 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/app/layouts/shell.tsx b/app/layouts/shell.tsx index 5ec00bee..1eb7d857 100644 --- a/app/layouts/shell.tsx +++ b/app/layouts/shell.tsx @@ -53,7 +53,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { settings: context.auth.can(principal, Capabilities.read_feature), }, onboarding: request.url.endsWith("/onboarding"), - pendingApproval: !check && principal.kind === "oidc", + noAccess: !check && principal.kind === "oidc", healthy: await api.isHealthy(), }; } catch { @@ -66,20 +66,17 @@ export async function loader({ request, context }: Route.LoaderArgs) { } export default function Shell({ loaderData }: Route.ComponentProps) { - if (loaderData.pendingApproval && !loaderData.onboarding) { + if (loaderData.noAccess && !loaderData.onboarding) { return ( <>
- Pending Approval + No Access - Your account has been created but you don't have access to the UI yet. An - administrator needs to assign you a role before you can continue. - - - If you believe this is a mistake, please contact your administrator. + Your account doesn't have permission to access the dashboard. Contact an + administrator if you need access.
-
+ + + + + Linux +
+ } + > + +

+ Click this button to copy the command.{" "} + + View script source + +

+ + + + Windows +
+ } + > + + + +

+ Requires Windows 10 or later. +

+ + + + macOS +
+ } + > + + + +

+ Requires macOS Big Sur 11.0 or later. +
+ You can also download Tailscale on the{" "} + + macOS App Store + + {"."} +

+ + + + iOS +
+ } + > + + + +

+ Requires iOS 15 or later. +

+ + + + Android +
+ } + > + + + +

+ Requires Android 8 or later. +

+ + + + +
+
+ Need dashboard access? + + Your account is signed in but doesn't have permission to manage the dashboard. + Contact an administrator to request access. + +
+
+ +
+