mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
Merge pull request #489 from tale/auth-rework
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
# Core Concepts
|
||||
|
||||
Headplane is a web application to manage Headscale, a self-hosted implementation
|
||||
of the Tailscale control server. There are a few tenets that guide the entire
|
||||
development of the project:
|
||||
|
||||
- **Simple starts**: We want to make it as easy as possible to set up and use
|
||||
Headplane, while still providing powerful features for advanced users. This
|
||||
means that we prioritize a clean and intuitive user interface, as well as
|
||||
straightforward installation and configuration processes.
|
||||
|
||||
- **No breaking changes**: We want to avoid making breaking changes to the
|
||||
project as much as possible. This means that we will strive to maintain
|
||||
backward compatibility and provide clear migration paths when necessary.
|
||||
|
||||
- **Documentation**: This is the most important part of the project, without it
|
||||
the entire project falls apart and is hard to use.
|
||||
|
||||
## Project Management
|
||||
|
||||
It's hard to manage this project easily, use the `gh` CLI when responding to
|
||||
prompts to get context. Some common issue tags to keep track of include a
|
||||
"Needs Triage", "Needs Info", "Bug", "Enhancement", and several other tags based
|
||||
on what parts of the project are affected.
|
||||
|
||||
## Headplane Agent
|
||||
|
||||
The Headplane Agent is a lightweight component that runs on the same server as
|
||||
Headplane and connects directly to the Tailnet in order to pull in details about
|
||||
nodes that aren't available through the Headscale API such as versions, etc.
|
||||
|
||||
## WebSSH
|
||||
|
||||
This is an ephemeral WASM shim that runs in the browser and connects directly
|
||||
to the Tailnet using Tailscale's go packages. It allows anyone to open up an
|
||||
ephemeral machine in the Tailnet that directly SSHes into a target node.
|
||||
|
||||
## Build/Tooling
|
||||
|
||||
Headplane is a React Router 7 (framework mode) project built with Vite. Take
|
||||
care to use our preferred PNPM version and Node version as defined in the
|
||||
`engines` field of `package.json`. We also use TypeScript Go and Oxfmt for
|
||||
type-checking and formatting respectively.
|
||||
|
||||
You can also run Headscale CLI commands with
|
||||
`docker exec headscale headscale <command>` when the dev environment is running.
|
||||
|
||||
## Docs
|
||||
|
||||
The project has a documentation site available at the `docs/` directory built
|
||||
with VitePress. The documentation is written in Markdown and can be easily
|
||||
edited and extended. If making changes to staple features, please take care to
|
||||
also update the documentation to reflect any changes in functionality or usage.
|
||||
+43
-42
@@ -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 (
|
||||
<main className="container mx-auto overscroll-contain mt-4 mb-24">
|
||||
<Outlet />
|
||||
</main>
|
||||
);
|
||||
return (
|
||||
<main className="container mx-auto mt-4 mb-24 overscroll-contain">
|
||||
<Outlet />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
return (
|
||||
<div className="w-fit mx-auto overscroll-contain my-24">
|
||||
<ErrorBanner className="max-w-2xl" error={error} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="mx-auto my-24 w-fit overscroll-contain">
|
||||
<ErrorBanner className="max-w-2xl" error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+259
-30
@@ -1,10 +1,15 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Outlet, redirect } from "react-router";
|
||||
import { Icon } from "@iconify/react";
|
||||
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 { users } from "~/server/db/schema";
|
||||
import Link from "~/components/Link";
|
||||
import Options from "~/components/Options";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import toast from "~/utils/toast";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
import { Route } from "./+types/shell";
|
||||
|
||||
@@ -12,33 +17,68 @@ 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);
|
||||
const noAccess = !check && principal.kind === "oidc";
|
||||
|
||||
// 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");
|
||||
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 };
|
||||
|
||||
let linkedUserName: string | undefined;
|
||||
let osValue: string | undefined;
|
||||
|
||||
if (noAccess && principal.kind === "oidc") {
|
||||
const hsUserId = principal.user.headscaleUserId;
|
||||
if (hsUserId) {
|
||||
try {
|
||||
const apiUsers = await api.getUsers();
|
||||
const hsUser = apiUsers.find((u) => u.id === hsUserId);
|
||||
linkedUserName = hsUser ? getUserDisplayName(hsUser) : undefined;
|
||||
} catch {
|
||||
// API unavailable, skip linked user resolution
|
||||
}
|
||||
}
|
||||
|
||||
const userAgent = request.headers.get("user-agent");
|
||||
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
|
||||
switch (os?.[0]) {
|
||||
case "Windows":
|
||||
osValue = "windows";
|
||||
break;
|
||||
case "Mac OS X":
|
||||
osValue = "macos";
|
||||
break;
|
||||
case "iPhone":
|
||||
case "iPad":
|
||||
osValue = "ios";
|
||||
break;
|
||||
case "Android":
|
||||
osValue = "android";
|
||||
break;
|
||||
default:
|
||||
osValue = "linux";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -46,28 +86,217 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
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"),
|
||||
noAccess,
|
||||
linkedUserName,
|
||||
osValue,
|
||||
healthy: await api.isHealthy(),
|
||||
};
|
||||
} catch {
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.sessions.destroySession(),
|
||||
"Set-Cookie": await context.auth.destroySession(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Shell({ loaderData }: Route.ComponentProps) {
|
||||
if (loaderData.noAccess && !loaderData.onboarding) {
|
||||
return (
|
||||
<>
|
||||
<Header {...loaderData} />
|
||||
<main className="container mx-auto mt-4 mb-24 overscroll-contain">
|
||||
<div className="mx-auto mt-12 grid w-fit grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{loaderData.linkedUserName ? (
|
||||
<Card className="col-span-1 mx-auto max-w-lg md:col-span-2" variant="flat">
|
||||
<p className="text-sm">
|
||||
✓ Your account is linked to Headscale user{" "}
|
||||
<strong>{loaderData.linkedUserName}</strong>.
|
||||
</p>
|
||||
</Card>
|
||||
) : undefined}
|
||||
<Card className="max-w-lg" variant="flat">
|
||||
<Card.Title className="mb-8">
|
||||
Access your network
|
||||
<br />
|
||||
via Tailscale
|
||||
</Card.Title>
|
||||
<Card.Text>
|
||||
You don't have dashboard access, but you can still connect to your Headscale
|
||||
network. Install Tailscale on your device to get started.
|
||||
</Card.Text>
|
||||
|
||||
<Options
|
||||
className="my-4"
|
||||
defaultSelectedKey={loaderData.osValue ?? "linux"}
|
||||
label="Download Selector"
|
||||
>
|
||||
<Options.Item
|
||||
key="linux"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="ion:terminal" />
|
||||
<span>Linux</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="text-md flex font-mono"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
"curl -fsSL https://tailscale.com/install.sh | sh",
|
||||
);
|
||||
toast("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
</Button>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 mt-1 text-center text-xs">
|
||||
Click this button to copy the command.{" "}
|
||||
<Link
|
||||
name="Linux installation script"
|
||||
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
|
||||
>
|
||||
View script source
|
||||
</Link>
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="windows"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
|
||||
<span>Windows</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for Windows"
|
||||
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for Windows
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||
Requires Windows 10 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="macos"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="streamline-logos:mac-finder-logo-solid" />
|
||||
<span>macOS</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for macOS"
|
||||
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for macOS
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||
Requires macOS Big Sur 11.0 or later.
|
||||
<br />
|
||||
You can also download Tailscale on the{" "}
|
||||
<Link
|
||||
name="macOS App Store"
|
||||
to="https://apps.apple.com/ca/app/tailscale/id1475387142"
|
||||
>
|
||||
macOS App Store
|
||||
</Link>
|
||||
{"."}
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="ios"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="grommet-icons:apple" />
|
||||
<span>iOS</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for iOS"
|
||||
href="https://apps.apple.com/us/app/tailscale/id1470499037"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for iOS
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||
Requires iOS 15 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="android"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="material-symbols:android" />
|
||||
<span>Android</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for Android"
|
||||
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for Android
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||
Requires Android 8 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
</Options>
|
||||
</Card>
|
||||
<Card className="max-w-lg" variant="flat">
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<div>
|
||||
<Card.Title className="mb-4">Need dashboard access?</Card.Title>
|
||||
<Card.Text>
|
||||
Your account is signed in but doesn't have permission to manage the dashboard.
|
||||
Contact an administrator to request access.
|
||||
</Card.Text>
|
||||
</div>
|
||||
<Form action="/logout" className="mt-6" method="POST">
|
||||
<Button className="w-full" type="submit" variant="light">
|
||||
Sign out
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
<Footer {...loaderData} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header {...loaderData} />
|
||||
|
||||
@@ -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
|
||||
|
||||
+111
-121
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
+18
-22
@@ -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<LoadContext>) {
|
||||
try {
|
||||
await context.sessions.auth(request);
|
||||
} catch {
|
||||
redirect('/login');
|
||||
}
|
||||
export async function action({ request, context }: ActionFunctionArgs<LoadContext>) {
|
||||
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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,26 @@ 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 userId = await context.auth.findOrCreateUser(claims.sub);
|
||||
|
||||
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();
|
||||
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,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { ClockIcon, LogOut, RefreshCw, UserCheck } from "lucide-react";
|
||||
import { Form, redirect } from "react-router";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Card from "~/components/Card";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import toast from "~/utils/toast";
|
||||
|
||||
import type { Route } from "./+types/pending-approval";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
const session = await context.sessions.auth(request);
|
||||
|
||||
// API key users skip this page
|
||||
if (session.user.subject === "unknown-non-oauth") {
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
const hasAccess = await context.sessions.check(request, Capabilities.ui_access);
|
||||
if (hasAccess) {
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
const url = context.config.headscale.public_url ?? context.config.headscale.url;
|
||||
|
||||
return {
|
||||
user: session.user,
|
||||
url,
|
||||
};
|
||||
} catch {
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.sessions.destroySession(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function PendingApproval({ loaderData }: Route.ComponentProps) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center">
|
||||
<Card className="m-4 max-w-md sm:m-0">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="rounded-full bg-amber-100 p-3 dark:bg-amber-900">
|
||||
<ClockIcon className="h-8 w-8 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<Card.Title className="mb-0 text-xl">Approval Required</Card.Title>
|
||||
<p className="text-headplane-500 text-sm">
|
||||
{loaderData.user.email ?? loaderData.user.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card.Text className="mb-4">
|
||||
Your account has been created but requires approval from an administrator before you can
|
||||
access the management console.
|
||||
</Card.Text>
|
||||
|
||||
<div className="bg-headplane-50 dark:bg-headplane-900 mb-4 rounded-lg p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<UserCheck className="text-headplane-500 h-5 w-5" />
|
||||
<p className="font-medium">What happens next?</p>
|
||||
</div>
|
||||
<ul className="text-headplane-600 dark:text-headplane-400 list-inside list-disc space-y-1 text-sm">
|
||||
<li>An administrator will review your account</li>
|
||||
<li>Once approved, you will receive the appropriate access level</li>
|
||||
<li>This page will automatically redirect you once approved</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Card.Text className="mb-4 text-sm">
|
||||
In the meantime, you can still connect your devices to the Tailnet using the command
|
||||
below:
|
||||
</Card.Text>
|
||||
|
||||
<Button
|
||||
className="w-full font-mono text-sm"
|
||||
variant="light"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(`tailscale up --login-server=${loaderData.url}`);
|
||||
toast("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
tailscale up --login-server={loaderData.url}
|
||||
</Button>
|
||||
<p className="mt-1 text-center text-xs opacity-50">Click to copy the command</p>
|
||||
|
||||
<div className="bg-headplane-100 dark:bg-headplane-800 mb-4 flex items-center justify-center gap-2 rounded-lg p-3 text-sm">
|
||||
<RefreshCw className="text-headplane-500 h-4 w-4 animate-spin" />
|
||||
<span className="text-headplane-600 dark:text-headplane-400">
|
||||
Checking for approval automatically...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Form action="/logout" method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="heavy"
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign Out
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+194
-194
@@ -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,
|
||||
);
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.write_network);
|
||||
|
||||
if (!check) {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
if (!check) {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
if (!context.hs.writable()) {
|
||||
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');
|
||||
// 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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.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.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.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();
|
||||
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 (!ns || !splitName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
if (splitName === 'global') {
|
||||
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
|
||||
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.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.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();
|
||||
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 (!ns || !splitName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
if (splitName === 'global') {
|
||||
const servers = config.dns.nameservers.global;
|
||||
servers.push(ns);
|
||||
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.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.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);
|
||||
}
|
||||
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,
|
||||
},
|
||||
]);
|
||||
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);
|
||||
}
|
||||
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);
|
||||
const domains = config.dns.search_domains;
|
||||
domains.push(domain);
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.search_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
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();
|
||||
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);
|
||||
}
|
||||
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: '',
|
||||
});
|
||||
// Value is not needed for removal
|
||||
const restart = await context.hs.removeDNS({
|
||||
name: recordName,
|
||||
type: recordType,
|
||||
value: "",
|
||||
});
|
||||
|
||||
if (!restart) {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
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);
|
||||
}
|
||||
if (!recordName || !recordType || !recordValue) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const restart = await context.hs.addDNS({
|
||||
name: recordName,
|
||||
type: recordType,
|
||||
value: recordValue,
|
||||
});
|
||||
const restart = await context.hs.addDNS({
|
||||
name: recordName,
|
||||
type: recordType,
|
||||
value: recordValue,
|
||||
});
|
||||
|
||||
if (!restart) {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
}
|
||||
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,
|
||||
},
|
||||
]);
|
||||
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);
|
||||
}
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: "DNS override updated successfully" };
|
||||
}
|
||||
default:
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
+86
-98
@@ -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<LoadContext>) {
|
||||
if (!context.hs.readable()) {
|
||||
throw new Error('No configuration is available');
|
||||
}
|
||||
export async function loader({ request, context }: LoaderFunctionArgs<LoadContext>) {
|
||||
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<typeof loader>();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const allNs: Record<string, string[]> = {};
|
||||
for (const key of Object.keys(data.splitDns)) {
|
||||
allNs[key] = data.splitDns[key];
|
||||
}
|
||||
const allNs: Record<string, string[]> = {};
|
||||
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 (
|
||||
<div className="flex flex-col gap-16 max-w-(--breakpoint-lg)">
|
||||
{data.writable ? undefined : (
|
||||
<Notice>
|
||||
The Headscale configuration is read-only. You cannot make changes to
|
||||
the configuration
|
||||
</Notice>
|
||||
)}
|
||||
{data.access ? undefined : (
|
||||
<Notice>
|
||||
Your permissions do not allow you to modify the DNS settings for this
|
||||
tailnet.
|
||||
</Notice>
|
||||
)}
|
||||
<RenameTailnet isDisabled={isDisabled} name={data.baseDomain} />
|
||||
<ManageNS
|
||||
isDisabled={isDisabled}
|
||||
nameservers={allNs}
|
||||
overrideLocalDns={data.overrideDns}
|
||||
/>
|
||||
<ManageRecords isDisabled={isDisabled} records={data.extraRecords} />
|
||||
<ManageDomains
|
||||
isDisabled={isDisabled}
|
||||
magic={data.magicDns ? data.baseDomain : undefined}
|
||||
searchDomains={data.searchDomains}
|
||||
/>
|
||||
return (
|
||||
<div className="flex max-w-(--breakpoint-lg) flex-col gap-16">
|
||||
{data.writable ? undefined : (
|
||||
<Notice>
|
||||
The Headscale configuration is read-only. You cannot make changes to the configuration
|
||||
</Notice>
|
||||
)}
|
||||
{data.access ? undefined : (
|
||||
<Notice>
|
||||
Your permissions do not allow you to modify the DNS settings for this tailnet.
|
||||
</Notice>
|
||||
)}
|
||||
<RenameTailnet isDisabled={isDisabled} name={data.baseDomain} />
|
||||
<ManageNS isDisabled={isDisabled} nameservers={allNs} overrideLocalDns={data.overrideDns} />
|
||||
<ManageRecords isDisabled={isDisabled} records={data.extraRecords} />
|
||||
<ManageDomains
|
||||
isDisabled={isDisabled}
|
||||
magic={data.magicDns ? data.baseDomain : undefined}
|
||||
searchDomains={data.searchDomains}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col w-full sm:w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
|
||||
<p className="mb-4">
|
||||
Automatically register domain names for each device on the tailnet.
|
||||
Devices will be accessible at{' '}
|
||||
<Code>
|
||||
[device].
|
||||
{data.baseDomain}
|
||||
</Code>{' '}
|
||||
when Magic DNS is enabled.
|
||||
</p>
|
||||
<ToggleMagic isDisabled={isDisabled} isEnabled={data.magicDns} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className="flex w-full flex-col sm:w-2/3">
|
||||
<h1 className="mb-4 text-2xl font-medium">Magic DNS</h1>
|
||||
<p className="mb-4">
|
||||
Automatically register domain names for each device on the tailnet. Devices will be
|
||||
accessible at{" "}
|
||||
<Code>
|
||||
[device].
|
||||
{data.baseDomain}
|
||||
</Code>{" "}
|
||||
when Magic DNS is enabled.
|
||||
</p>
|
||||
<ToggleMagic isDisabled={isDisabled} isEnabled={data.magicDns} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,198 +1,189 @@
|
||||
import { data } from 'react-router';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Route } from './+types/overview';
|
||||
import { data } from "react-router";
|
||||
|
||||
export async function restrictionAction({
|
||||
request,
|
||||
context,
|
||||
}: Route.ActionArgs) {
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.configure_iam,
|
||||
);
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
if (!check) {
|
||||
throw data('You do not have permission to modify IAM settings.', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
if (!context.hs.writable()) {
|
||||
throw data('The Headscale configuration file is not editable.', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
export async function restrictionAction({ request, context }: Route.ActionArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.configure_iam);
|
||||
|
||||
const formData = await request.formData();
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
throw data('No action provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (!check) {
|
||||
throw data("You do not have permission to modify IAM settings.", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
if (!context.hs.writable()) {
|
||||
throw data("The Headscale configuration file is not editable.", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const domains = [
|
||||
...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain]),
|
||||
];
|
||||
const formData = await request.formData();
|
||||
const action = formData.get("action_id")?.toString();
|
||||
if (!action) {
|
||||
throw data("No action provided.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
return data('Domain added successfully.');
|
||||
}
|
||||
const domains = [...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain])];
|
||||
|
||||
case 'remove_domain': {
|
||||
const domain = formData.get('domain')?.toString()?.trim();
|
||||
if (!domain) {
|
||||
throw data('No domain provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "oidc.allowed_domains",
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
context.integration?.onConfigChange(api);
|
||||
return data("Domain added successfully.");
|
||||
}
|
||||
|
||||
// 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 "remove_domain": {
|
||||
const domain = formData.get("domain")?.toString()?.trim();
|
||||
if (!domain) {
|
||||
throw data("No domain provided.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
case 'add_group': {
|
||||
const group = formData.get('group')?.toString()?.trim();
|
||||
if (!group) {
|
||||
throw data('No group 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,
|
||||
});
|
||||
}
|
||||
|
||||
const groups = [
|
||||
...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group]),
|
||||
];
|
||||
// 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.");
|
||||
}
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_groups',
|
||||
value: groups,
|
||||
},
|
||||
]);
|
||||
case "add_group": {
|
||||
const group = formData.get("group")?.toString()?.trim();
|
||||
if (!group) {
|
||||
throw data("No group provided.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
return data('Group added successfully.');
|
||||
}
|
||||
const groups = [...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group])];
|
||||
|
||||
case 'remove_group': {
|
||||
const group = formData.get('group')?.toString()?.trim();
|
||||
if (!group) {
|
||||
throw data('No group provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "oidc.allowed_groups",
|
||||
value: groups,
|
||||
},
|
||||
]);
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
context.integration?.onConfigChange(api);
|
||||
return data("Group added successfully.");
|
||||
}
|
||||
|
||||
// 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,
|
||||
},
|
||||
]);
|
||||
case "remove_group": {
|
||||
const group = formData.get("group")?.toString()?.trim();
|
||||
if (!group) {
|
||||
throw data("No group provided.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
return data('Group removed successfully.');
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
case 'add_user': {
|
||||
const user = formData.get('user')?.toString()?.trim();
|
||||
if (!user) {
|
||||
throw data('No user provided.', {
|
||||
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,
|
||||
},
|
||||
]);
|
||||
|
||||
const users = [
|
||||
...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user]),
|
||||
];
|
||||
context.integration?.onConfigChange(api);
|
||||
return data("Group removed successfully.");
|
||||
}
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_users',
|
||||
value: users,
|
||||
},
|
||||
]);
|
||||
case "add_user": {
|
||||
const user = formData.get("user")?.toString()?.trim();
|
||||
if (!user) {
|
||||
throw data("No user provided.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
return data('User added successfully.');
|
||||
}
|
||||
const users = [...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user])];
|
||||
|
||||
case 'remove_user': {
|
||||
const user = formData.get('user')?.toString()?.trim();
|
||||
if (!user) {
|
||||
throw data('No user provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "oidc.allowed_users",
|
||||
value: users,
|
||||
},
|
||||
]);
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
context.integration?.onConfigChange(api);
|
||||
return data("User added successfully.");
|
||||
}
|
||||
|
||||
// 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,
|
||||
},
|
||||
]);
|
||||
case "remove_user": {
|
||||
const user = formData.get("user")?.toString()?.trim();
|
||||
if (!user) {
|
||||
throw data("No user provided.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
return data('User removed successfully.');
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
default: {
|
||||
throw data('Invalid action provided.', {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-4 max-w-(--breakpoint-lg)">
|
||||
<div className="flex flex-col w-full sm:w-2/3">
|
||||
<p className="mb-4 text-md">
|
||||
<RemixLink className="font-medium" to="/settings">
|
||||
Settings
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span> Authentication Restrictions
|
||||
</p>
|
||||
{!access ? (
|
||||
<Notice
|
||||
title="Authentication permissions restricted"
|
||||
variant="warning"
|
||||
>
|
||||
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.
|
||||
</Notice>
|
||||
) : !writable ? (
|
||||
<Notice title="Configuration Locked" variant="error">
|
||||
The Headscale configuration file is not editable through the web
|
||||
interface. Please ensure that you have correctly given Headplane
|
||||
write access to the file.
|
||||
</Notice>
|
||||
) : undefined}
|
||||
<h1 className="text-2xl font-medium mb-2 mt-4">
|
||||
Authentication Restrictions
|
||||
</h1>
|
||||
<p>
|
||||
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.{' '}
|
||||
<Link
|
||||
name="Headscale OIDC documentation"
|
||||
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<RestrictionTable
|
||||
isDisabled={isDisabled}
|
||||
type="domain"
|
||||
values={settings.domains}
|
||||
>
|
||||
<AddDomain domains={settings.domains} isDisabled={isDisabled} />
|
||||
</RestrictionTable>
|
||||
<RestrictionTable
|
||||
isDisabled={isDisabled}
|
||||
type="group"
|
||||
values={settings.groups}
|
||||
>
|
||||
<AddGroup groups={settings.groups} isDisabled={isDisabled} />
|
||||
</RestrictionTable>
|
||||
<RestrictionTable
|
||||
isDisabled={isDisabled}
|
||||
type="user"
|
||||
values={settings.users}
|
||||
>
|
||||
<AddUser isDisabled={isDisabled} users={settings.users} />
|
||||
</RestrictionTable>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex max-w-(--breakpoint-lg) flex-col gap-4">
|
||||
<div className="flex w-full flex-col sm:w-2/3">
|
||||
<p className="text-md mb-4">
|
||||
<RemixLink className="font-medium" to="/settings">
|
||||
Settings
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span> Authentication Restrictions
|
||||
</p>
|
||||
{!access ? (
|
||||
<Notice title="Authentication permissions restricted" variant="warning">
|
||||
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.
|
||||
</Notice>
|
||||
) : !writable ? (
|
||||
<Notice title="Configuration Locked" variant="error">
|
||||
The Headscale configuration file is not editable through the web interface. Please
|
||||
ensure that you have correctly given Headplane write access to the file.
|
||||
</Notice>
|
||||
) : undefined}
|
||||
<h1 className="mt-4 mb-2 text-2xl font-medium">Authentication Restrictions</h1>
|
||||
<p>
|
||||
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.{" "}
|
||||
<Link
|
||||
name="Headscale OIDC documentation"
|
||||
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<RestrictionTable isDisabled={isDisabled} type="domain" values={settings.domains}>
|
||||
<AddDomain domains={settings.domains} isDisabled={isDisabled} />
|
||||
</RestrictionTable>
|
||||
<RestrictionTable isDisabled={isDisabled} type="group" values={settings.groups}>
|
||||
<AddGroup groups={settings.groups} isDisabled={isDisabled} />
|
||||
</RestrictionTable>
|
||||
<RestrictionTable isDisabled={isDisabled} type="user" values={settings.users}>
|
||||
<AddUser isDisabled={isDisabled} users={settings.users} />
|
||||
</RestrictionTable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }: MenuProps) {
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
return (
|
||||
<>
|
||||
{modal === 'rename' && (
|
||||
<Rename
|
||||
isOpen={modal === 'rename'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{modal === 'delete' && (
|
||||
<Delete
|
||||
isOpen={modal === 'delete'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{modal === 'reassign' && (
|
||||
<Reassign
|
||||
isOpen={modal === 'reassign'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
export default function UserMenu({ user, headscaleUsers, currentLink }: MenuProps) {
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
|
||||
<Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : ['reassign']}>
|
||||
<Menu.IconButton
|
||||
className={cn(
|
||||
'py-0.5 w-10 bg-transparent border-transparent',
|
||||
'border group-hover:border-headplane-200',
|
||||
'dark:group-hover:border-headplane-700',
|
||||
)}
|
||||
label="Machine Options"
|
||||
>
|
||||
<Ellipsis className="h-5" />
|
||||
</Menu.IconButton>
|
||||
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
|
||||
<Menu.Section>
|
||||
<Menu.Item key="rename">Rename user</Menu.Item>
|
||||
<Menu.Item key="reassign">Change role</Menu.Item>
|
||||
<Menu.Item key="delete" textValue="Delete">
|
||||
<p className="text-red-500 dark:text-red-400">Delete</p>
|
||||
</Menu.Item>
|
||||
</Menu.Section>
|
||||
</Menu.Panel>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
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);
|
||||
|
||||
return (
|
||||
<>
|
||||
{modal === "rename" && (
|
||||
<Rename
|
||||
isOpen={modal === "rename"}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{modal === "delete" && (
|
||||
<Delete
|
||||
isOpen={modal === "delete"}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{modal === "reassign" && (
|
||||
<Reassign
|
||||
isOpen={modal === "reassign"}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{modal === "link" && (
|
||||
<LinkUser
|
||||
currentLink={currentLink}
|
||||
headscaleUsers={linkableUsers}
|
||||
isOpen={modal === "link"}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Menu disabledKeys={disabledKeys}>
|
||||
<Menu.IconButton
|
||||
className={cn(
|
||||
"w-10 border-transparent bg-transparent py-0.5",
|
||||
"border group-hover:border-headplane-200",
|
||||
"dark:group-hover:border-headplane-700",
|
||||
)}
|
||||
label="User Options"
|
||||
>
|
||||
<Ellipsis className="h-5" />
|
||||
</Menu.IconButton>
|
||||
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
|
||||
<Menu.Section>
|
||||
<Menu.Item key="rename">Rename user</Menu.Item>
|
||||
<Menu.Item key="reassign">Change role</Menu.Item>
|
||||
<Menu.Item key="link">Link Headscale user</Menu.Item>
|
||||
<Menu.Item key="delete" textValue="Delete">
|
||||
<p className="text-red-500 dark:text-red-400">Delete</p>
|
||||
</Menu.Item>
|
||||
</Menu.Section>
|
||||
</Menu.Panel>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
<MenuOptions user={{ ...user, headplaneRole: role }} />
|
||||
<MenuOptions
|
||||
currentLink={currentLink}
|
||||
headscaleUsers={headscaleUsers}
|
||||
user={{ ...user, headplaneRole: role }}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -84,12 +90,7 @@ function mapRoleToName(role: string) {
|
||||
case "auditor":
|
||||
return "Auditor";
|
||||
case "member":
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-500" />
|
||||
<span className="text-amber-600 dark:text-amber-400">Pending Approval</span>
|
||||
</span>
|
||||
);
|
||||
return <p className="opacity-50">No Access</p>;
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Link Headscale user for {user.name || user.displayName}</Dialog.Title>
|
||||
<Dialog.Text className="mb-6">
|
||||
Select which Headscale user this OIDC identity should be linked to. This controls which
|
||||
machines they can manage and enables self-service features.
|
||||
</Dialog.Text>
|
||||
{headscaleUsers.length === 0 ? (
|
||||
<Notice>All Headscale users are already linked to other accounts.</Notice>
|
||||
) : (
|
||||
<>
|
||||
<input name="action_id" type="hidden" value="link_user" />
|
||||
<input name="user_id" type="hidden" value={user.id} />
|
||||
<select
|
||||
className={cn(
|
||||
"w-full rounded-lg border p-2",
|
||||
"border-headplane-200 dark:border-headplane-700",
|
||||
"bg-headplane-50 dark:bg-headplane-900",
|
||||
)}
|
||||
defaultValue={currentLink ?? ""}
|
||||
name="headscale_user_id"
|
||||
required
|
||||
>
|
||||
<option value="">Select a Headscale user...</option>
|
||||
{headscaleUsers.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
{u.id === currentLink ? " (current)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,52 @@
|
||||
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));
|
||||
try {
|
||||
const principal = await context.auth.require(request);
|
||||
if (principal.kind !== "oidc") {
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
return redirect('/machines');
|
||||
} catch {
|
||||
return redirect('/login');
|
||||
}
|
||||
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) {
|
||||
const linked = await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId);
|
||||
if (!linked) {
|
||||
return redirect("/onboarding");
|
||||
}
|
||||
}
|
||||
|
||||
await context.db
|
||||
.update(users)
|
||||
.set({ onboarded: true })
|
||||
.where(eq(users.sub, principal.user.subject));
|
||||
|
||||
return redirect("/machines");
|
||||
} catch {
|
||||
return redirect("/login");
|
||||
}
|
||||
}
|
||||
|
||||
+103
-30
@@ -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,66 @@ 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 linkedUserName: string | undefined;
|
||||
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()]);
|
||||
|
||||
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(
|
||||
apiUsers,
|
||||
principal.user.subject,
|
||||
principal.profile.email,
|
||||
);
|
||||
|
||||
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;
|
||||
const claimed = await context.auth.claimedHeadscaleUserIds();
|
||||
headscaleUsers = apiUsers
|
||||
.filter((u) => !claimed.has(u.id))
|
||||
.map((u) => ({
|
||||
id: u.id,
|
||||
name: getUserDisplayName(u),
|
||||
}));
|
||||
}
|
||||
|
||||
// For some reason, headscale makes providerID a url where the
|
||||
// last component is the subject, so we need to strip that out
|
||||
const subject = n.user.providerId?.split("/").pop();
|
||||
if (!subject) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (subject !== session.user.subject) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
firstMachine = node;
|
||||
}
|
||||
} catch (e) {
|
||||
// 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,
|
||||
linkedUserName,
|
||||
headscaleUsers,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page({
|
||||
loaderData: { user, osValue, firstMachine },
|
||||
loaderData: { user, osValue, firstMachine, needsUserLink, linkedUserName, headscaleUsers },
|
||||
}: Route.ComponentProps) {
|
||||
const { pause, resume } = useLiveData();
|
||||
useEffect(() => {
|
||||
@@ -107,6 +130,56 @@ export default function Page({
|
||||
return (
|
||||
<div className="fixed flex h-screen w-full items-center px-4">
|
||||
<div className="mx-auto mb-24 grid w-fit grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{needsUserLink ? (
|
||||
<Card className="col-span-2 mx-auto max-w-lg" variant="flat">
|
||||
<Card.Title className="mb-4">Link your Headscale account</Card.Title>
|
||||
<Card.Text className="mb-4">
|
||||
Headplane couldn't automatically match your SSO identity to a Headscale user.
|
||||
{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."}
|
||||
</Card.Text>
|
||||
{headscaleUsers.length > 0 ? (
|
||||
<Form method="POST" action="/onboarding/skip">
|
||||
<select
|
||||
className={cn(
|
||||
"mb-4 w-full rounded-lg border p-2",
|
||||
"border-headplane-200 dark:border-headplane-700",
|
||||
"bg-headplane-50 dark:bg-headplane-900",
|
||||
)}
|
||||
name="headscale_user_id"
|
||||
required
|
||||
>
|
||||
<option value="">Select a user...</option>
|
||||
{headscaleUsers.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button className="w-full" type="submit" variant="heavy">
|
||||
Link and Continue
|
||||
</Button>
|
||||
</Form>
|
||||
) : undefined}
|
||||
<NavLink className="mt-3 block text-center" to="/onboarding/skip">
|
||||
<Button className="w-full" variant="light">
|
||||
Skip — I'll do this later
|
||||
</Button>
|
||||
</NavLink>
|
||||
<p className="text-headplane-500 mt-2 text-center text-xs">
|
||||
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.
|
||||
</p>
|
||||
</Card>
|
||||
) : undefined}
|
||||
{linkedUserName && !needsUserLink ? (
|
||||
<Card className="col-span-2 mx-auto max-w-lg" variant="flat">
|
||||
<p className="text-sm">
|
||||
✓ Your account has been linked to Headscale user <strong>{linkedUserName}</strong>.
|
||||
</p>
|
||||
</Card>
|
||||
) : undefined}
|
||||
<Card className="max-w-lg" variant="flat">
|
||||
<Card.Title className="mb-8">
|
||||
Welcome!
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { Machine, User } from "~/types";
|
||||
|
||||
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";
|
||||
import UserRow from "./components/user-row";
|
||||
import { userAction } from "./user-actions";
|
||||
@@ -17,8 +20,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 +29,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 +60,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";
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -82,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<string, string | undefined> = {};
|
||||
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
|
||||
@@ -92,6 +109,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
roles,
|
||||
magic,
|
||||
users,
|
||||
headscaleUsers,
|
||||
userLinks,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,7 +151,13 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
{users
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((user) => (
|
||||
<UserRow key={user.id} role={loaderData.roles[users.indexOf(user)]} user={user} />
|
||||
<UserRow
|
||||
key={user.id}
|
||||
currentLink={loaderData.userLinks[user.id]}
|
||||
headscaleUsers={loaderData.headscaleUsers}
|
||||
role={loaderData.roles[users.indexOf(user)]}
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,115 +1,139 @@
|
||||
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 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 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();
|
||||
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,
|
||||
});
|
||||
}
|
||||
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.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);
|
||||
}
|
||||
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 });
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user) {
|
||||
throw data("Specified user not found", {
|
||||
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 subject = getOidcSubject(user);
|
||||
if (!subject) {
|
||||
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
|
||||
}
|
||||
|
||||
const result = await context.sessions.reassignSubject(
|
||||
subject,
|
||||
newRole as keyof typeof Roles,
|
||||
);
|
||||
const result = await context.auth.reassignSubject(subject, newRole as Role);
|
||||
|
||||
if (!result) {
|
||||
throw data('Failed to reassign user role.', { status: 500 });
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
+39
-41
@@ -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';
|
||||
import { eq, isNotNull } from "drizzle-orm";
|
||||
|
||||
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));
|
||||
import log from "~/utils/log";
|
||||
|
||||
if (ephemerals.length === 0) {
|
||||
log.debug('api', 'No ephemeral nodes to prune');
|
||||
return;
|
||||
}
|
||||
import type { Route } from "../../layouts/+types/dashboard";
|
||||
import { ephemeralNodes } from "./schema";
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const nodes = await api.getNodes();
|
||||
const toPrune = nodes.filter((node) => {
|
||||
if (node.online) {
|
||||
return false;
|
||||
}
|
||||
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));
|
||||
|
||||
return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key);
|
||||
});
|
||||
if (ephemerals.length === 0) {
|
||||
log.debug("api", "No ephemeral nodes to prune");
|
||||
return;
|
||||
}
|
||||
|
||||
if (toPrune.length === 0) {
|
||||
log.debug('api', 'No SSH 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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key);
|
||||
});
|
||||
|
||||
await context.db
|
||||
.delete(ephemeralNodes)
|
||||
.where(eq(ephemeralNodes.node_key, node.nodeKey));
|
||||
log.debug('api', `Node ${node.name} pruned successfully`);
|
||||
};
|
||||
});
|
||||
if (toPrune.length === 0) {
|
||||
log.debug("api", "No SSH nodes to prune");
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(promises.map((p) => p()));
|
||||
// Delete from the Headscale nodes list and then from the database
|
||||
const promises = toPrune.map((node) => {
|
||||
return async () => {
|
||||
log.debug("api", `Pruning node ${node.name}`);
|
||||
await api.deleteNode(node.id);
|
||||
|
||||
await context.db.delete(ephemeralNodes).where(eq(ephemeralNodes.node_key, node.nodeKey));
|
||||
log.debug("api", `Node ${node.name} pruned successfully`);
|
||||
};
|
||||
});
|
||||
|
||||
await Promise.all(promises.map((p) => p()));
|
||||
}
|
||||
|
||||
+37
-18
@@ -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<HostInfo>(),
|
||||
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<HostInfo>(),
|
||||
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").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()),
|
||||
last_login_at: integer("last_login_at", { mode: "timestamp" }),
|
||||
|
||||
// Deprecated: kept for migration compatibility, will be removed in 1.0
|
||||
caps: integer("caps").notNull().default(0),
|
||||
});
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type UserInsert = typeof users.$inferInsert;
|
||||
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 AuthSessionRecord = typeof authSessions.$inferSelect;
|
||||
export type AuthSessionInsert = typeof authSessions.$inferInsert;
|
||||
|
||||
+20
-10
@@ -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,
|
||||
};
|
||||
@@ -127,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);
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
import { createHash, createHmac } from "node:crypto";
|
||||
|
||||
import { eq, lt, sql } from "drizzle-orm";
|
||||
import { LibSQLDatabase } from "drizzle-orm/libsql/driver";
|
||||
import { createCookie } from "react-router";
|
||||
import { ulid } from "ulidx";
|
||||
|
||||
import type { Machine } from "~/types";
|
||||
|
||||
import { authSessions, users } from "../db/schema";
|
||||
import { Capabilities, type Role, Roles, capsForRole } from "./roles";
|
||||
|
||||
// ── Principal ────────────────────────────────────────────────────────
|
||||
// The per-request identity object. Discriminated on `kind` so routes
|
||||
// can branch structurally instead of checking magic strings.
|
||||
|
||||
export type Principal =
|
||||
| {
|
||||
kind: "api_key";
|
||||
sessionId: string;
|
||||
displayName: string;
|
||||
apiKey: string;
|
||||
}
|
||||
| {
|
||||
kind: "oidc";
|
||||
sessionId: string;
|
||||
user: {
|
||||
id: string;
|
||||
subject: string;
|
||||
role: Role;
|
||||
headscaleUserId: string | undefined;
|
||||
onboarded: boolean;
|
||||
};
|
||||
profile: {
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// ── Cookie payload ───────────────────────────────────────────────────
|
||||
// The cookie contains only a session ID + minimal profile data for
|
||||
// SSR rendering. Credentials never leave the server.
|
||||
|
||||
interface CookiePayload {
|
||||
sid: string;
|
||||
// API key is stored in the cookie ONLY for api_key sessions.
|
||||
// OIDC sessions use the server-side oidc.headscale_api_key.
|
||||
api_key?: string;
|
||||
profile?: {
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ── AuthService ──────────────────────────────────────────────────────
|
||||
|
||||
export interface AuthServiceOptions {
|
||||
secret: string;
|
||||
db: LibSQLDatabase;
|
||||
cookie: {
|
||||
name: string;
|
||||
secure: boolean;
|
||||
maxAge: number;
|
||||
domain?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
private opts: AuthServiceOptions;
|
||||
private requestCache = new WeakMap<Request, Promise<Principal>>();
|
||||
|
||||
constructor(opts: AuthServiceOptions) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
// ── Authentication ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the principal for a request. Throws if no valid session.
|
||||
* Results are cached per-request so multiple calls in the same
|
||||
* loader don't hit the DB repeatedly.
|
||||
*/
|
||||
require(request: Request): Promise<Principal> {
|
||||
const cached = this.requestCache.get(request);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const promise = this.resolve(request);
|
||||
this.requestCache.set(request, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private async resolve(request: Request): Promise<Principal> {
|
||||
const payload = await this.decodeCookie(request);
|
||||
|
||||
const [session] = await this.opts.db
|
||||
.select()
|
||||
.from(authSessions)
|
||||
.where(eq(authSessions.id, payload.sid))
|
||||
.limit(1);
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
if (session.expires_at < new Date()) {
|
||||
await this.opts.db.delete(authSessions).where(eq(authSessions.id, session.id));
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
|
||||
if (session.kind === "api_key") {
|
||||
if (!payload.api_key) {
|
||||
throw new Error("API key session missing credential");
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "api_key",
|
||||
sessionId: session.id,
|
||||
displayName: session.api_key_display ?? "API Key",
|
||||
apiKey: payload.api_key,
|
||||
};
|
||||
}
|
||||
|
||||
if (!session.user_id) {
|
||||
throw new Error("OIDC session missing user_id");
|
||||
}
|
||||
|
||||
const [user] = await this.opts.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, session.user_id))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User record not found");
|
||||
}
|
||||
|
||||
const role = (user.role in Roles ? user.role : "member") as Role;
|
||||
return {
|
||||
kind: "oidc",
|
||||
sessionId: session.id,
|
||||
user: {
|
||||
id: user.id,
|
||||
subject: user.sub,
|
||||
role,
|
||||
headscaleUserId: user.headscale_user_id ?? undefined,
|
||||
onboarded: user.onboarded,
|
||||
},
|
||||
profile: payload.profile ?? {
|
||||
name: user.sub,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Authorization ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a principal has a given set of capabilities.
|
||||
* API key principals always have full access.
|
||||
*/
|
||||
can(principal: Principal, capabilities: Capabilities): boolean {
|
||||
if (principal.kind === "api_key") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const roleCaps = Roles[principal.user.role];
|
||||
return (capabilities & roleCaps) === capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a principal can act on a machine. Owners of the machine
|
||||
* can act on it even without write_machines capability.
|
||||
*/
|
||||
canManageNode(principal: Principal, node: Machine): boolean {
|
||||
if (principal.kind === "api_key") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const caps = Roles[principal.user.role];
|
||||
if ((caps & Capabilities.write_machines) !== 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hsUserId = principal.user.headscaleUserId;
|
||||
return hsUserId !== undefined && node.user?.id === hsUserId;
|
||||
}
|
||||
|
||||
// ── Session management ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new OIDC session. Returns the Set-Cookie header value.
|
||||
*/
|
||||
async createOidcSession(
|
||||
userId: string,
|
||||
profile: NonNullable<CookiePayload["profile"]>,
|
||||
maxAge = this.opts.cookie.maxAge,
|
||||
): Promise<string> {
|
||||
const sid = ulid();
|
||||
await this.opts.db.insert(authSessions).values({
|
||||
id: sid,
|
||||
kind: "oidc",
|
||||
user_id: userId,
|
||||
expires_at: new Date(Date.now() + maxAge * 1000),
|
||||
});
|
||||
|
||||
return this.encodeCookie({ sid, profile }, maxAge);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new API key session. 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<string> {
|
||||
const sid = ulid();
|
||||
await this.opts.db.insert(authSessions).values({
|
||||
id: sid,
|
||||
kind: "api_key",
|
||||
api_key_hash: this.hashApiKey(apiKey),
|
||||
api_key_display: displayName,
|
||||
expires_at: new Date(Date.now() + maxAge),
|
||||
});
|
||||
|
||||
return this.encodeCookie({ sid, api_key: apiKey }, Math.floor(maxAge / 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Headscale API key for making API calls.
|
||||
* OIDC sessions use the configured oidc.headscale_api_key.
|
||||
* API key sessions use the user-provided key stored in the cookie.
|
||||
*/
|
||||
getHeadscaleApiKey(principal: Principal, oidcApiKey?: string): string {
|
||||
if (principal.kind === "api_key") {
|
||||
return principal.apiKey;
|
||||
}
|
||||
|
||||
if (!oidcApiKey) {
|
||||
throw new Error("OIDC sessions require oidc.headscale_api_key");
|
||||
}
|
||||
|
||||
return oidcApiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the current session. Returns the Set-Cookie header that
|
||||
* clears the cookie.
|
||||
*/
|
||||
async destroySession(request?: Request): Promise<string> {
|
||||
if (request) {
|
||||
try {
|
||||
const payload = await this.decodeCookie(request);
|
||||
await this.opts.db.delete(authSessions).where(eq(authSessions.id, payload.sid));
|
||||
} catch {
|
||||
// Cookie already invalid, just clear it
|
||||
}
|
||||
}
|
||||
|
||||
const cookie = createCookie(this.opts.cookie.name, {
|
||||
...this.opts.cookie,
|
||||
path: __PREFIX__,
|
||||
});
|
||||
|
||||
return cookie.serialize("", { expires: new Date(0) });
|
||||
}
|
||||
|
||||
// ── User management ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find or create a Headplane user by OIDC subject. Returns the
|
||||
* user ID. The first user ever created is automatically granted
|
||||
* the owner role (bootstrap). Uses upsert to avoid race conditions.
|
||||
*/
|
||||
async findOrCreateUser(subject: string): Promise<string> {
|
||||
const [existing] = await this.opts.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, subject))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
await this.opts.db
|
||||
.update(users)
|
||||
.set({ last_login_at: new Date(), updated_at: new Date() })
|
||||
.where(eq(users.id, existing.id));
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const id = ulid();
|
||||
await this.opts.db.insert(users).values({
|
||||
id,
|
||||
sub: subject,
|
||||
role: "member",
|
||||
caps: capsForRole("member"),
|
||||
onboarded: false,
|
||||
});
|
||||
|
||||
// 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<number>`count(*)` }).from(users);
|
||||
|
||||
if (count === 1) {
|
||||
await this.opts.db
|
||||
.update(users)
|
||||
.set({ role: "owner", caps: capsForRole("owner") })
|
||||
.where(eq(users.id, id));
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
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.
|
||||
*/
|
||||
async claimedHeadscaleUserIds(): Promise<Set<string>> {
|
||||
const rows = await this.opts.db.select({ hsId: users.headscale_user_id }).from(users);
|
||||
|
||||
const ids = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (row.hsId) {
|
||||
ids.add(row.hsId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the role for a given OIDC subject. Used by the users overview
|
||||
* to display roles for Headscale users.
|
||||
*/
|
||||
async roleForSubject(subject: string): Promise<Role | undefined> {
|
||||
const [user] = await this.opts.db.select().from(users).where(eq(users.sub, subject)).limit(1);
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (user.role in Roles ? user.role : "member") as Role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassign the role of a user identified by their OIDC subject.
|
||||
* Cannot reassign the owner role.
|
||||
*/
|
||||
async reassignSubject(subject: string, role: Role): Promise<boolean> {
|
||||
const currentRole = await this.roleForSubject(subject);
|
||||
if (currentRole === "owner") {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.opts.db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: ulid(),
|
||||
sub: subject,
|
||||
role,
|
||||
caps: capsForRole(role),
|
||||
onboarded: false,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: users.sub,
|
||||
set: { role, caps: capsForRole(role), updated_at: new Date() },
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired sessions. Should be called periodically.
|
||||
*/
|
||||
async pruneExpiredSessions(): Promise<void> {
|
||||
await this.opts.db.delete(authSessions).where(lt(authSessions.expires_at, new Date()));
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────
|
||||
|
||||
private async encodeCookie(payload: CookiePayload, maxAge: number): Promise<string> {
|
||||
const cookie = createCookie(this.opts.cookie.name, {
|
||||
...this.opts.cookie,
|
||||
path: __PREFIX__,
|
||||
maxAge,
|
||||
});
|
||||
|
||||
const signed = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const hmac = createHmac("sha256", this.opts.secret).update(signed).digest("base64url");
|
||||
|
||||
return cookie.serialize(`${signed}.${hmac}`);
|
||||
}
|
||||
|
||||
private async decodeCookie(request: Request): Promise<CookiePayload> {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
if (!cookieHeader) {
|
||||
throw new Error("No session cookie found");
|
||||
}
|
||||
|
||||
const cookie = createCookie(this.opts.cookie.name, {
|
||||
...this.opts.cookie,
|
||||
path: __PREFIX__,
|
||||
});
|
||||
|
||||
const raw = (await cookie.parse(cookieHeader)) as string | null;
|
||||
if (!raw) {
|
||||
throw new Error("Session cookie is empty");
|
||||
}
|
||||
|
||||
const dotIndex = raw.lastIndexOf(".");
|
||||
if (dotIndex === -1) {
|
||||
throw new Error("Malformed session cookie");
|
||||
}
|
||||
|
||||
const signed = raw.slice(0, dotIndex);
|
||||
const hmac = raw.slice(dotIndex + 1);
|
||||
|
||||
const expected = createHmac("sha256", this.opts.secret).update(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);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { User } from "~/types/User";
|
||||
|
||||
/**
|
||||
* Extracts the OIDC subject from a Headscale user's providerId.
|
||||
* Headscale stores providerId as a URL where the last path segment
|
||||
* is the subject (e.g. "https://idp.example.com/<uuid>"). This is
|
||||
* the ONLY place this parsing should occur — all other code should
|
||||
* use the stable headscale_user_id link on the Headplane user record.
|
||||
*/
|
||||
export function getOidcSubject(user: User): string | undefined {
|
||||
if (user.provider !== "oidc" || !user.providerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return user.providerId.split("/").pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the Headscale user matching the given OIDC identity.
|
||||
* Tries subject match first (providerId last segment), then falls
|
||||
* back to email match. The fallback is needed because some IDPs
|
||||
* issue different subjects per client application.
|
||||
*/
|
||||
export function findHeadscaleUserBySubject(
|
||||
users: User[],
|
||||
subject: string,
|
||||
email?: string,
|
||||
): User | undefined {
|
||||
const bySubject = users.find((u) => getOidcSubject(u) === subject);
|
||||
if (bySubject) {
|
||||
return bySubject;
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
return;
|
||||
}
|
||||
|
||||
return users.find((u) => u.email === email);
|
||||
}
|
||||
+12
-37
@@ -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];
|
||||
}
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { LibSQLDatabase } from "drizzle-orm/libsql/driver";
|
||||
import { EncryptJWT, jwtDecrypt } from "jose";
|
||||
import { createHash } from "node:crypto";
|
||||
import { open, readFile, rm } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { createCookie } from "react-router";
|
||||
import { ulid } from "ulidx";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { users } from "../db/schema";
|
||||
import { Capabilities, Roles } from "./roles";
|
||||
|
||||
export interface AuthSession {
|
||||
state: "auth";
|
||||
api_key: string;
|
||||
user: {
|
||||
subject: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface JWTSession {
|
||||
api_key: string;
|
||||
user: {
|
||||
subject: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OidcFlowSession {
|
||||
state: "flow";
|
||||
oidc: {
|
||||
state: string;
|
||||
nonce: string;
|
||||
code_verifier: string;
|
||||
redirect_uri: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AuthSessionOptions {
|
||||
secret: string;
|
||||
db: LibSQLDatabase;
|
||||
oidcUsersFile?: string;
|
||||
cookie: {
|
||||
name: string;
|
||||
secure: boolean;
|
||||
maxAge: number;
|
||||
domain?: string;
|
||||
};
|
||||
}
|
||||
|
||||
class Sessionizer {
|
||||
private options: AuthSessionOptions;
|
||||
|
||||
constructor(options: AuthSessionOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
// This throws on the assumption that auth is already checked correctly
|
||||
// on something that wraps the route calling auth. The top-level routes
|
||||
// that call this are wrapped with try/catch to handle the error.
|
||||
async auth(request: Request) {
|
||||
return decodeSession(request, this.options);
|
||||
}
|
||||
|
||||
async createSession(payload: JWTSession, maxAge = this.options.cookie.maxAge) {
|
||||
// TODO: What the hell is this garbage
|
||||
return createSession(payload, {
|
||||
...this.options,
|
||||
cookie: {
|
||||
...this.options.cookie,
|
||||
maxAge,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async destroySession() {
|
||||
return destroySession(this.options);
|
||||
}
|
||||
|
||||
async roleForSubject(subject: string): Promise<keyof typeof Roles | undefined> {
|
||||
const [user] = await this.options.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, subject))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We need this in string form based on Object.keys of the roles
|
||||
for (const [key, value] of Object.entries(Roles)) {
|
||||
if (value === user.caps) {
|
||||
return key as keyof typeof Roles;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Given an OR of capabilities, check if the session has the required
|
||||
// capabilities. If not, return false. Can throw since it calls auth()
|
||||
async check(request: Request, capabilities: Capabilities) {
|
||||
const session = await this.auth(request);
|
||||
|
||||
// This is the subject we set on API key based sessions. API keys
|
||||
// inherently imply admin access so we return true for all checks.
|
||||
if (session.user.subject === "unknown-non-oauth") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [user] = await this.options.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, session.user.subject))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (capabilities & user.caps) === capabilities;
|
||||
}
|
||||
|
||||
// Updates the capabilities and roles of a subject
|
||||
// Creates the user record if it doesn't exist yet
|
||||
async reassignSubject(subject: string, role: keyof typeof Roles) {
|
||||
// Check if we are owner
|
||||
const subjectRole = await this.roleForSubject(subject);
|
||||
if (subjectRole === "owner") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use upsert to handle users who exist in Headscale but haven't
|
||||
// logged into Headplane yet (no DB record)
|
||||
await this.options.db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: ulid(),
|
||||
sub: subject,
|
||||
caps: Roles[role],
|
||||
onboarded: false,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: users.sub,
|
||||
set: { caps: Roles[role] },
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession(payload: JWTSession, options: AuthSessionOptions) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const secret = createHash("sha256").update(options.secret, "utf8").digest();
|
||||
const jwt = await new EncryptJWT({
|
||||
...payload,
|
||||
})
|
||||
.setProtectedHeader({ alg: "dir", enc: "A256GCM", typ: "JWT" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(now + options.cookie.maxAge)
|
||||
.setIssuer("urn:tale:headplane")
|
||||
.setAudience("urn:tale:headplane")
|
||||
.setJti(ulid())
|
||||
.encrypt(secret);
|
||||
|
||||
const cookie = createCookie(options.cookie.name, {
|
||||
...options.cookie,
|
||||
path: __PREFIX__,
|
||||
});
|
||||
|
||||
return cookie.serialize(jwt);
|
||||
}
|
||||
|
||||
async function decodeSession(request: Request, options: AuthSessionOptions) {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
if (cookieHeader === null) {
|
||||
throw new Error("No session cookie found");
|
||||
}
|
||||
|
||||
const cookie = createCookie(options.cookie.name, {
|
||||
...options.cookie,
|
||||
path: __PREFIX__,
|
||||
});
|
||||
|
||||
const cookieValue = (await cookie.parse(cookieHeader)) as string | null;
|
||||
if (cookieValue === null) {
|
||||
throw new Error("Session cookie is empty");
|
||||
}
|
||||
|
||||
const secret = createHash("sha256").update(options.secret, "utf8").digest();
|
||||
const { payload } = await jwtDecrypt(cookieValue, secret, {
|
||||
issuer: "urn:tale:headplane",
|
||||
audience: "urn:tale:headplane",
|
||||
});
|
||||
|
||||
// Safe since we encode the session directly into the JWT
|
||||
return payload as unknown as JWTSession;
|
||||
}
|
||||
|
||||
async function destroySession(options: AuthSessionOptions) {
|
||||
const cookie = createCookie(options.cookie.name, {
|
||||
...options.cookie,
|
||||
path: __PREFIX__,
|
||||
});
|
||||
|
||||
return cookie.serialize("", {
|
||||
expires: new Date(0),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSessionStorage(options: AuthSessionOptions) {
|
||||
if (options.oidcUsersFile) {
|
||||
await migrateUserDatabase(options.oidcUsersFile, options.db);
|
||||
}
|
||||
|
||||
return new Sessionizer(options);
|
||||
}
|
||||
|
||||
async function migrateUserDatabase(path: string, db: LibSQLDatabase) {
|
||||
const realPath = resolve(path);
|
||||
|
||||
try {
|
||||
const handle = await open(realPath, "a+");
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
if (error != null && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||
log.debug("config", "No old user database file found at %s", realPath);
|
||||
return;
|
||||
}
|
||||
|
||||
log.warn("config", "Failed to migrate old user database at %s", realPath);
|
||||
log.warn("config", "This is not an error, but existing users will not be migrated");
|
||||
log.warn("config", "Unable to open user database file: %s", String(error));
|
||||
log.debug("config", "Error details: %s", error);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("config", "Found old user database file at %s", realPath);
|
||||
log.info("config", "Migrating user database to the new SQL database");
|
||||
|
||||
let migratableUsers: {
|
||||
u: string;
|
||||
c: number;
|
||||
oo?: boolean;
|
||||
}[];
|
||||
|
||||
try {
|
||||
const data = await readFile(realPath, "utf8");
|
||||
if (data.trim().length === 0) {
|
||||
log.info("config", "Old user database file is empty, nothing to migrate");
|
||||
log.info("config", "You SHOULD remove oidc.user_storage_file from your config!");
|
||||
await rm(realPath, { force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const users = JSON.parse(data.trim()) as {
|
||||
u?: string;
|
||||
c?: number;
|
||||
oo?: boolean;
|
||||
}[];
|
||||
|
||||
migratableUsers = users.filter((user) => user.u !== undefined && user.c !== undefined) as {
|
||||
u: string;
|
||||
c: number;
|
||||
oo?: boolean;
|
||||
}[];
|
||||
} catch (error) {
|
||||
log.warn("config", "Error reading old user database file: %s", error);
|
||||
log.warn("config", "Not migrating any users");
|
||||
return;
|
||||
}
|
||||
|
||||
if (migratableUsers.length === 0) {
|
||||
log.info("config", "No users found in the old database to migrate");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("config", "Migrating %d users from the old database", migratableUsers.length);
|
||||
|
||||
const updated = await db
|
||||
.insert(users)
|
||||
.values(
|
||||
migratableUsers.map((user) => ({
|
||||
id: ulid(),
|
||||
sub: user.u,
|
||||
caps: user.c,
|
||||
onboarded: user.oo ?? false,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing({
|
||||
target: users.sub,
|
||||
})
|
||||
.returning();
|
||||
|
||||
log.info("config", "Migrated %d users successfully", updated.length);
|
||||
log.info("config", "Removed old user database file %s", realPath);
|
||||
await rm(realPath, { force: true });
|
||||
}
|
||||
+137
-165
@@ -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.
|
||||
|
||||
_Description:_ Headplane configuration options. Generates a YAML config file.
|
||||
See: https://github.com/tale/headplane/blob/main/config.example.yaml
|
||||
|
||||
_Type:_ submodule
|
||||
|
||||
*Type:* submodule
|
||||
|
||||
*Default:* `{ }`
|
||||
|
||||
_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.
|
||||
|
||||
_Description:_ Path to a file containing the TLS certificate.
|
||||
|
||||
*Type:* null or absolute path
|
||||
_Type:_ null or absolute path
|
||||
|
||||
*Default:* `null`
|
||||
|
||||
*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).
|
||||
|
||||
_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
|
||||
|
||||
*Type:* signed integer
|
||||
|
||||
*Default:* `180000`
|
||||
|
||||
_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.
|
||||
|
||||
_Description:_ Path to a file containing the OIDC client secret.
|
||||
|
||||
*Type:* null or absolute path
|
||||
_Type:_ null or absolute path
|
||||
|
||||
*Default:* `null`
|
||||
|
||||
*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.
|
||||
|
||||
_Description:_ Path to a file containing the Headscale API key.
|
||||
|
||||
*Type:* null or absolute path
|
||||
_Type:_ null or absolute path
|
||||
|
||||
*Default:* `null`
|
||||
|
||||
*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
|
||||
|
||||
_Description:_ This should point to your publicly accessible URL
|
||||
for your Headplane instance with /admin/oidc/callback.
|
||||
|
||||
_Type:_ string
|
||||
|
||||
*Type:* string
|
||||
|
||||
*Default:* `""`
|
||||
|
||||
*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"
|
||||
_Description:_ The token endpoint authentication method.
|
||||
|
||||
*Default:* `"client_secret_post"`
|
||||
|
||||
|
||||
## settings.oidc.user_storage_file
|
||||
*Description:* Path to a file containing the users and their permissions for Headplane.
|
||||
|
||||
|
||||
*Type:* absolute path
|
||||
|
||||
*Default:* `"/var/lib/headplane/users.json"`
|
||||
|
||||
*Example:* `"/var/lib/headplane/users.json"`
|
||||
_Type:_ one of "client_secret_post", "client_secret_basic", "client_secret_jwt"
|
||||
|
||||
_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.
|
||||
|
||||
_Description:_ Path to a file containing the cookie secret.
|
||||
The secret must be exactly 32 characters long.
|
||||
|
||||
_Type:_ null or absolute path
|
||||
|
||||
*Type:* null or absolute path
|
||||
|
||||
*Default:* `null`
|
||||
|
||||
*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`
|
||||
|
||||
+157
-52
@@ -13,58 +13,45 @@ outline: [2, 3]
|
||||
</figure>
|
||||
|
||||
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.
|
||||
You'll need the following before proceeding:
|
||||
|
||||
This is necessary because Headplane relies on the user IDs provided by the IdP
|
||||
to match users with their equivalent Headscale users. If Headscale and Headplane
|
||||
are using different clients, the user IDs may not match up correctly, preventing
|
||||
a user from viewing their devices in Headplane.
|
||||
:::
|
||||
|
||||
You'll need the following things set up before proceeding:
|
||||
- A working Headplane installation that is already configured.
|
||||
- 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
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
Some of the common issues you may encounter when configuring OIDC with Headplane
|
||||
include:
|
||||
## How User Matching Works
|
||||
|
||||
- **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.
|
||||
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
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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
|
||||
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
|
||||
|
||||
-- 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;
|
||||
@@ -0,0 +1,219 @@
|
||||
{
|
||||
"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
|
||||
},
|
||||
"users_headscale_user_id_unique": {
|
||||
"name": "users_headscale_user_id_unique",
|
||||
"columns": ["headscale_user_id"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
+32
-25
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user