feat: begin working on user auth

This commit is contained in:
Aarnav Tale 2025-03-29 14:12:15 -04:00
parent 8429b19c4a
commit bf02015dc7
No known key found for this signature in database
11 changed files with 562 additions and 118 deletions

View File

@ -15,6 +15,7 @@ import cn from '~/utils/cn';
interface Props { interface Props {
configAvailable: boolean; configAvailable: boolean;
uiAccess: boolean;
user?: AuthSession['user']; user?: AuthSession['user'];
} }
@ -135,29 +136,31 @@ export default function Header(data: Props) {
) : undefined} ) : undefined}
</div> </div>
</div> </div>
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold"> {data.uiAccess ? (
<TabLink <nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
to="/machines" <TabLink
name="Machines" to="/machines"
icon={<Server className="w-5" />} name="Machines"
/> icon={<Server className="w-5" />}
<TabLink to="/users" name="Users" icon={<Users className="w-5" />} /> />
<TabLink <TabLink to="/users" name="Users" icon={<Users className="w-5" />} />
to="/acls" <TabLink
name="Access Control" to="/acls"
icon={<Lock className="w-5" />} name="Access Control"
/> icon={<Lock className="w-5" />}
{data.configAvailable ? ( />
<> {data.configAvailable ? (
<TabLink to="/dns" name="DNS" icon={<Globe2 className="w-5" />} /> <>
<TabLink <TabLink to="/dns" name="DNS" icon={<Globe2 className="w-5" />} />
to="/settings" <TabLink
name="Settings" to="/settings"
icon={<Settings className="w-5" />} name="Settings"
/> icon={<Settings className="w-5" />}
</> />
) : undefined} </>
</nav> ) : undefined}
</nav>
) : undefined}
</header> </header>
); );
} }

View File

@ -1,12 +1,15 @@
import { BanIcon } from 'lucide-react';
import { import {
LoaderFunctionArgs, LoaderFunctionArgs,
Outlet, Outlet,
redirect, redirect,
useLoaderData, useLoaderData,
} from 'react-router'; } from 'react-router';
import Card from '~/components/Card';
import Footer from '~/components/Footer'; import Footer from '~/components/Footer';
import Header from '~/components/Header'; import Header from '~/components/Header';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
// This loads the bare minimum for the application to function // This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops? // So we know that if context fails to load then well, oops?
@ -25,12 +28,14 @@ export async function loader({
}); });
} }
const check = await context.sessions.check(request, Capabilities.ui_access);
return { return {
config: context.hs.c, config: context.hs.c,
url: context.config.headscale.public_url ?? context.config.headscale.url, url: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(), configAvailable: context.hs.readable(),
debug: context.config.debug, debug: context.config.debug,
user: session.get('user'), user: session.get('user'),
uiAccess: check,
}; };
} catch { } catch {
// No session, so we can just return // No session, so we can just return
@ -40,10 +45,24 @@ export async function loader({
export default function Shell() { export default function Shell() {
const data = useLoaderData<typeof loader>(); const data = useLoaderData<typeof loader>();
return ( return (
<> <>
<Header {...data} /> <Header {...data} />
<Outlet /> {data.uiAccess ? (
<Outlet />
) : (
<Card className="mx-auto w-fit mt-24">
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">Access Denied</Card.Title>
<BanIcon className="w-10 h-10" />
</div>
<Card.Text className="mt-4 text-lg">
Your account does not have access to the UI. Please contact your
administrator.
</Card.Text>
</Card>
)}
<Footer {...data} /> <Footer {...data} />
</> </>
); );

View File

@ -0,0 +1,83 @@
import { CircleUser } from 'lucide-react';
import StatusCircle from '~/components/StatusCircle';
import { Machine, User } from '~/types';
import cn from '~/utils/cn';
interface UserRowProps {
role: string;
user: User & { machines: Machine[] };
}
export default function UserRow({ user, role }: UserRowProps) {
const isOnline = user.machines.some((machine) => machine.online);
const lastSeen = user.machines.reduce(
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
0,
);
return (
<tr key={user.id}>
<td className="pl-0.5 py-2">
<div className="flex items-center">
{user.profilePicUrl ? (
<img
src={user.profilePicUrl}
alt={user.name}
className="w-10 h-10 rounded-full"
/>
) : (
<CircleUser className="w-10 h-10" />
)}
<div className="ml-4">
<p className={cn('font-semibold leading-snug')}>{user.name}</p>
<p className="text-sm opacity-50">{user.email}</p>
</div>
</div>
</td>
<td className="pl-0.5 py-2">
<p>{mapRoleToName(role)}</p>
</td>
<td className="pl-0.5 py-2">
<p className="text-sm text-headplane-600 dark:text-headplane-300">
{new Date(user.createdAt).toLocaleDateString()}
</p>
</td>
<td className="pl-0.5 py-2">
<span
className={cn(
'flex items-center gap-x-1 text-sm',
'text-headplane-600 dark:text-headplane-300',
)}
>
<StatusCircle isOnline={isOnline} className="w-4 h-4" />
<p>{isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()}</p>
</span>
</td>
</tr>
);
}
function mapRoleToName(role: string) {
switch (role) {
case 'no-oidc':
return <p className="opacity-50">Unmanaged</p>;
case 'invalid-oidc':
return <p className="opacity-50">Invalid</p>;
case 'no-role':
return <p className="opacity-50">No Role</p>;
case 'owner':
return 'Owner';
case 'admin':
return 'Admin';
case 'network_admin':
return 'Network Admin';
case 'it_admin':
return 'IT Admin';
case 'auditor':
return 'Auditor';
case 'member':
return 'Member';
default:
return 'Unknown';
}
}

View File

@ -12,6 +12,7 @@ import type { LoadContext } from '~/server';
import type { Machine, User } from '~/types'; import type { Machine, User } from '~/types';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import ManageBanner from './components/manage-banner'; import ManageBanner from './components/manage-banner';
import UserRow from './components/user-row';
import DeleteUser from './dialogs/delete-user'; import DeleteUser from './dialogs/delete-user';
import RenameUser from './dialogs/rename-user'; import RenameUser from './dialogs/rename-user';
import { userAction } from './user-actions'; import { userAction } from './user-actions';
@ -34,6 +35,28 @@ export async function loader({
machines: machines.nodes.filter((machine) => machine.user.id === user.id), machines: machines.nodes.filter((machine) => machine.user.id === user.id),
})); }));
const roles = users
.sort((a, b) => a.name.localeCompare(b.name))
.map((user) => {
if (user.provider !== 'oidc') {
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 = context.sessions.roleForSubject(subject);
return role ?? 'no-role';
}
return 'no-role';
});
let magic: string | undefined; let magic: string | undefined;
if (context.hs.readable()) { if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) { if (context.hs.c?.dns.magic_dns) {
@ -43,6 +66,7 @@ export async function loader({
return { return {
oidc: context.config.oidc, oidc: context.config.oidc,
roles,
magic, magic,
users, users,
}; };
@ -72,7 +96,35 @@ export default function Page() {
drag machines between users to change ownership. drag machines between users to change ownership.
</p> </p>
<ManageBanner oidc={data.oidc} /> <ManageBanner oidc={data.oidc} />
<ClientOnly fallback={<Users users={users} />}> <table className="table-auto w-full rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
<th className="uppercase text-xs font-bold pb-2">User</th>
<th className="uppercase text-xs font-bold pb-2">Role</th>
<th className="uppercase text-xs font-bold pb-2">Created At</th>
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
</tr>
</thead>
<tbody
className={cn(
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
'border-t border-headplane-100 dark:border-headplane-800',
)}
>
{users
.sort((a, b) => a.name.localeCompare(b.name))
.map((user) => (
<UserRow
key={user.id}
user={user}
role={data.roles[users.indexOf(user)]}
/>
))}
</tbody>
</table>
{/* <Users users={users} /> */}
{/* <ClientOnly fallback={<Users users={users} />}>
{() => ( {() => (
<InteractiveUsers <InteractiveUsers
users={users} users={users}
@ -80,7 +132,7 @@ export default function Page() {
magic={data.magic} magic={data.magic}
/> />
)} )}
</ClientOnly> </ClientOnly> */}
</> </>
); );
} }

View File

@ -18,4 +18,5 @@ server
├── web/ ├── web/
│ ├── agent.ts: Handles setting up the agent WebSocket if needed. │ ├── agent.ts: Handles setting up the agent WebSocket if needed.
│ ├── oidc.ts: Loads and validates an OIDC configuration (if available). │ ├── oidc.ts: Loads and validates an OIDC configuration (if available).
│ ├── roles.ts: Contains information about authentication permissions.
│ ├── sessions.ts: Initializes the session store and methods to manage it. │ ├── sessions.ts: Initializes the session store and methods to manage it.

View File

@ -27,6 +27,7 @@ const oidcConfig = type({
token_endpoint_auth_method: token_endpoint_auth_method:
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"', '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"',
redirect_uri: 'string.url?', redirect_uri: 'string.url?',
user_storage_file: 'string = "/var/lib/headplane/users.json"',
disable_api_key_login: stringToBool, disable_api_key_login: stringToBool,
headscale_api_key: 'string', headscale_api_key: 'string',
strict_validation: stringToBool.default(true), strict_validation: stringToBool.default(true),

View File

@ -40,12 +40,15 @@ const appLoadContext = {
), ),
// TODO: Better cookie options in config // TODO: Better cookie options in config
sessions: createSessionStorage({ sessions: await createSessionStorage(
name: '_hp_session', {
maxAge: 60 * 60 * 24, // 24 hours name: '_hp_session',
secure: config.server.cookie_secure, maxAge: 60 * 60 * 24, // 24 hours
secrets: [config.server.cookie_secret], secure: config.server.cookie_secure,
}), secrets: [config.server.cookie_secret],
},
config.oidc?.user_storage_file,
),
client: await createApiClient( client: await createApiClient(
config.headscale.url, config.headscale.url,

144
app/server/web/roles.ts Normal file
View File

@ -0,0 +1,144 @@
export type Capabilities = (typeof Capabilities)[keyof typeof Capabilities];
export const Capabilities = {
// Can access the admin console
ui_access: 1 << 0,
// Read tailnet policy file
read_policy: 1 << 1,
// Write tailnet policy file
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
read_feature: 1 << 5,
// Write feature configuration, for example, enable Taildrop
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
generate_authkeys: 1 << 12,
// Can use any tag (without being tag owner)
use_tags: 1 << 13,
// Write tailnet name
write_tailnet: 1 << 14,
// Owner flag
owner: 1 << 15,
} as const;
export type Roles = [keyof typeof Roles];
export const Roles = {
owner:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.write_policy |
Capabilities.read_network |
Capabilities.write_network |
Capabilities.read_feature |
Capabilities.write_feature |
Capabilities.configure_iam |
Capabilities.read_machines |
Capabilities.write_machines |
Capabilities.read_users |
Capabilities.write_users |
Capabilities.generate_authkeys |
Capabilities.use_tags |
Capabilities.write_tailnet |
Capabilities.owner,
admin:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.write_policy |
Capabilities.read_network |
Capabilities.write_network |
Capabilities.read_feature |
Capabilities.write_feature |
Capabilities.configure_iam |
Capabilities.read_machines |
Capabilities.write_machines |
Capabilities.read_users |
Capabilities.write_users |
Capabilities.generate_authkeys |
Capabilities.use_tags |
Capabilities.write_tailnet,
network_admin:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.write_policy |
Capabilities.read_network |
Capabilities.write_network |
Capabilities.read_feature |
Capabilities.read_machines |
Capabilities.read_users |
Capabilities.generate_authkeys |
Capabilities.use_tags |
Capabilities.write_tailnet,
it_admin:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.read_network |
Capabilities.read_feature |
Capabilities.write_feature |
Capabilities.configure_iam |
Capabilities.read_machines |
Capabilities.write_machines |
Capabilities.read_users |
Capabilities.write_users |
Capabilities.generate_authkeys,
auditor:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.read_network |
Capabilities.read_feature |
Capabilities.read_machines |
Capabilities.read_users,
// Default role for new users with 0 capabilities on the UI side of things
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;
}
export function getRoleFromCapabilities(capabilities: Capabilities): Role {
const iterable = Roles as Record<string, Capabilities>;
for (const role in iterable) {
if (iterable[role] === capabilities) {
return role as Role;
}
}
return 'member';
}

View File

@ -1,9 +1,13 @@
import { open, readFile } from 'node:fs/promises';
import { exit } from 'node:process';
import { import {
CookieSerializeOptions, CookieSerializeOptions,
Session, Session,
SessionStorage, SessionStorage,
createCookieSessionStorage, createCookieSessionStorage,
} from 'react-router'; } from 'react-router';
import log from '~/utils/log';
import { Capabilities, Roles } from './roles';
export interface AuthSession { export interface AuthSession {
state: 'auth'; state: 'auth';
@ -42,7 +46,16 @@ interface CookieOptions {
class Sessionizer { class Sessionizer {
private storage: SessionStorage<JoinedSession, Error>; private storage: SessionStorage<JoinedSession, Error>;
constructor(options: CookieOptions) { private caps: Record<string, Capabilities>;
private capsPath?: string;
constructor(
options: CookieOptions,
caps: Record<string, Capabilities>,
capsPath?: string,
) {
this.caps = caps;
this.capsPath = capsPath;
this.storage = createCookieSessionStorage({ this.storage = createCookieSessionStorage({
cookie: { cookie: {
...options, ...options,
@ -71,6 +84,84 @@ class Sessionizer {
return session as Session<AuthSession, Error>; return session as Session<AuthSession, Error>;
} }
roleForSubject(subject: string) {
const role = this.caps[subject];
// We need this in string form based on Object.keys of the roles
for (const [key, value] of Object.entries(Roles)) {
if (value === role) {
return key;
}
}
}
// 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);
const { subject } = session.get('user') ?? {};
if (!subject) {
return false;
}
// 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 (subject === 'unknown-non-oauth') {
return true;
}
// If the role does not exist, then this is a new subject that we have
// not seen before. Since this is new, we set access to the lowest
// level by default which is the member role.
//
// This also allows us to avoid configuring preventing sign ups with
// OIDC, since the default sign up logic gives member which does not
// have access to the UI whatsoever.
const role = this.caps[subject];
if (!role) {
const memberRole = await this.registerSubject(subject);
return (capabilities & memberRole) === capabilities;
}
return (capabilities & role) === capabilities;
}
// This code is very simple, if the user does not exist in the database
// file then we register it with the lowest level of access. If the user
// database is empty, the first user to sign in will be given the owner
// role.
private async registerSubject(subject: string) {
if (this.caps[subject]) {
return this.caps[subject];
}
if (Object.keys(this.caps).length === 0) {
log.debug('auth', 'First user registered as owner: %s', subject);
this.caps[subject] = Roles.owner;
await this.flushUserDatabase();
return this.caps[subject];
}
log.debug('auth', 'New user registered as member: %s', subject);
this.caps[subject] = Roles.member;
await this.flushUserDatabase();
return this.caps[subject];
}
private async flushUserDatabase() {
if (!this.capsPath) {
return;
}
const data = Object.entries(this.caps).map(([u, c]) => ({ u, c }));
try {
const handle = await open(this.capsPath, 'w');
await handle.write(JSON.stringify(data));
await handle.close();
} catch (error) {
log.error('config', 'Error writing user database file: %s', error);
}
}
getOrCreate<T extends JoinedSession = AuthSession>(request: Request) { getOrCreate<T extends JoinedSession = AuthSession>(request: Request) {
return this.storage.getSession(request.headers.get('cookie')) as Promise< return this.storage.getSession(request.headers.get('cookie')) as Promise<
Session<T, Error> Session<T, Error>
@ -86,6 +177,49 @@ class Sessionizer {
} }
} }
export function createSessionStorage(options: CookieOptions) { export async function createSessionStorage(
return new Sessionizer(options); options: CookieOptions,
usersPath?: string,
) {
const map: Record<string, Capabilities> = {};
if (usersPath) {
// We need to load our users from the file (default to empty map)
// We then translate each user into a capability object using the helper
// method defined in the roles.ts file
const data = await loadUserFile(usersPath);
log.debug('config', 'Loaded %d users from database', data.length);
for (const user of data) {
map[user.u] = user.c;
}
}
return new Sessionizer(options, map, usersPath);
}
async function loadUserFile(path: string) {
try {
const handle = await open(path, 'w');
log.info('config', 'Using user database file at %s', path);
await handle.close();
} catch (error) {
log.info('config', 'User database file not accessible at %s', path);
log.debug('config', 'Error details: %s', error);
exit(1);
}
try {
const data = await readFile(path, 'utf8');
const users = JSON.parse(data) as { u?: string; c?: number }[];
// Never trust user input
return users.filter((user) => user.u && user.c) as {
u: string;
c: number;
}[];
} catch (error) {
log.debug('config', 'Error reading user database file: %s', error);
log.debug('config', 'Using empty user database');
return [];
}
} }

View File

@ -4,7 +4,7 @@
// disable debug logging if the `HEADPLANE_DEBUG_LOG` specifies as such. // disable debug logging if the `HEADPLANE_DEBUG_LOG` specifies as such.
const levels = ['info', 'warn', 'error', 'debug'] as const; const levels = ['info', 'warn', 'error', 'debug'] as const;
type Category = 'server' | 'config' | 'agent' | 'api'; type Category = 'server' | 'config' | 'agent' | 'api' | 'auth';
export interface Logger export interface Logger
extends Record< extends Record<

View File

@ -1,107 +1,111 @@
# Configuration for the Headplane server and web application # Configuration for the Headplane server and web application
server: server:
host: "0.0.0.0" host: "0.0.0.0"
port: 3000 port: 3000
# The secret used to encode and decode web sessions # The secret used to encode and decode web sessions
# Ensure that this is exactly 32 characters long # Ensure that this is exactly 32 characters long
cookie_secret: "<change_me_to_something_secure!>" cookie_secret: "<change_me_to_something_secure!>"
# Should the cookies only work over HTTPS? # Should the cookies only work over HTTPS?
# Set to false if running via HTTP without a proxy # Set to false if running via HTTP without a proxy
# (I recommend this is true in production) # (I recommend this is true in production)
cookie_secure: true cookie_secure: true
# Headscale specific settings to allow Headplane to talk # Headscale specific settings to allow Headplane to talk
# to Headscale and access deep integration features # to Headscale and access deep integration features
headscale: headscale:
# The URL to your Headscale instance # The URL to your Headscale instance
# (All API requests are routed through this URL) # (All API requests are routed through this URL)
# (THIS IS NOT the gRPC endpoint, but the HTTP endpoint) # (THIS IS NOT the gRPC endpoint, but the HTTP endpoint)
# #
# IMPORTANT: If you are using TLS this MUST be set to `https://` # IMPORTANT: If you are using TLS this MUST be set to `https://`
url: "http://headscale:5000" url: "http://headscale:5000"
# If you use the TLS configuration in Headscale, and you are not using # If you use the TLS configuration in Headscale, and you are not using
# Let's Encrypt for your certificate, pass in the path to the certificate. # Let's Encrypt for your certificate, pass in the path to the certificate.
# (This has no effect `url` does not start with `https://`) # (This has no effect `url` does not start with `https://`)
# tls_cert_path: "/var/lib/headplane/tls.crt" # tls_cert_path: "/var/lib/headplane/tls.crt"
# Optional, public URL if they differ # Optional, public URL if they differ
# This affects certain parts of the web UI # This affects certain parts of the web UI
# public_url: "https://headscale.example.com" # public_url: "https://headscale.example.com"
# Path to the Headscale configuration file # Path to the Headscale configuration file
# This is optional, but HIGHLY recommended for the best experience # This is optional, but HIGHLY recommended for the best experience
# If this is read only, Headplane will show your configuration settings # If this is read only, Headplane will show your configuration settings
# in the Web UI, but they cannot be changed. # in the Web UI, but they cannot be changed.
config_path: "/etc/headscale/config.yaml" config_path: "/etc/headscale/config.yaml"
# Headplane internally validates the Headscale configuration # Headplane internally validates the Headscale configuration
# to ensure that it changes the configuration in a safe way. # to ensure that it changes the configuration in a safe way.
# If you want to disable this validation, set this to false. # If you want to disable this validation, set this to false.
config_strict: true config_strict: true
# Integration configurations for Headplane to interact with Headscale # Integration configurations for Headplane to interact with Headscale
# Only one of these should be enabled at a time or you will get errors # Only one of these should be enabled at a time or you will get errors
integration: integration:
docker: docker:
enabled: false enabled: false
# The name (or ID) of the container running Headscale # The name (or ID) of the container running Headscale
container_name: "headscale" container_name: "headscale"
# The path to the Docker socket (do not change this if you are unsure) # The path to the Docker socket (do not change this if you are unsure)
# Docker socket paths must start with unix:// or tcp:// and at the moment # Docker socket paths must start with unix:// or tcp:// and at the moment
# https connections are not supported. # https connections are not supported.
socket: "unix:///var/run/docker.sock" socket: "unix:///var/run/docker.sock"
# Please refer to docs/integration/Kubernetes.md for more information # Please refer to docs/integration/Kubernetes.md for more information
# on how to configure the Kubernetes integration. There are requirements in # on how to configure the Kubernetes integration. There are requirements in
# order to allow Headscale to be controlled by Headplane in a cluster. # order to allow Headscale to be controlled by Headplane in a cluster.
kubernetes: kubernetes:
enabled: false enabled: false
# Validates the manifest for the Pod to ensure all of the criteria # Validates the manifest for the Pod to ensure all of the criteria
# are set correctly. Turn this off if you are having issues with # are set correctly. Turn this off if you are having issues with
# shareProcessNamespace not being validated correctly. # shareProcessNamespace not being validated correctly.
validate_manifest: true validate_manifest: true
# This should be the name of the Pod running Headscale and Headplane. # This should be the name of the Pod running Headscale and Headplane.
# If this isn't static you should be using the Kubernetes Downward API # If this isn't static you should be using the Kubernetes Downward API
# to set this value (refer to docs/Integrated-Mode.md for more info). # to set this value (refer to docs/Integrated-Mode.md for more info).
pod_name: "headscale" pod_name: "headscale"
# Proc is the "Native" integration that only works when Headscale and # Proc is the "Native" integration that only works when Headscale and
# Headplane are running outside of a container. There is no configuration, # Headplane are running outside of a container. There is no configuration,
# but you need to ensure that the Headplane process can terminate the # but you need to ensure that the Headplane process can terminate the
# Headscale process. # Headscale process.
# #
# (If they are both running under systemd as sudo, this will work). # (If they are both running under systemd as sudo, this will work).
proc: proc:
enabled: false enabled: false
# OIDC Configuration for simpler authentication # OIDC Configuration for simpler authentication
# (This is optional, but recommended for the best experience) # (This is optional, but recommended for the best experience)
oidc: oidc:
issuer: "https://accounts.google.com" issuer: "https://accounts.google.com"
client_id: "your-client-id" client_id: "your-client-id"
# The client secret for the OIDC client # The client secret for the OIDC client
# Either this or `client_secret_path` must be set for OIDC to work # Either this or `client_secret_path` must be set for OIDC to work
client_secret: "<your-client-secret>" client_secret: "<your-client-secret>"
# You can alternatively set `client_secret_path` to read the secret from disk. # You can alternatively set `client_secret_path` to read the secret from disk.
# The path specified can resolve environment variables, making integration # The path specified can resolve environment variables, making integration
# with systemd's `LoadCredential` straightforward: # with systemd's `LoadCredential` straightforward:
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret" # client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
disable_api_key_login: false disable_api_key_login: false
token_endpoint_auth_method: "client_secret_post" token_endpoint_auth_method: "client_secret_post"
# If you are using OIDC, you need to generate an API key # If you are using OIDC, you need to generate an API key
# that can be used to authenticate other sessions when signing in. # that can be used to authenticate other sessions when signing in.
# #
# This can be done with `headscale apikeys create --expiration 999d` # This can be done with `headscale apikeys create --expiration 999d`
headscale_api_key: "<your-headscale-api-key>" headscale_api_key: "<your-headscale-api-key>"
# Optional, but highly recommended otherwise Headplane # Optional, but highly recommended otherwise Headplane
# will attempt to automatically guess this from the issuer # will attempt to automatically guess this from the issuer
# #
# This should point to your publicly accessibly URL # This should point to your publicly accessibly URL
# for your Headplane instance with /admin/oidc/callback # for your Headplane instance with /admin/oidc/callback
redirect_uri: "http://localhost:3000/admin/oidc/callback" redirect_uri: "http://localhost:3000/admin/oidc/callback"
# Stores the users and their permissions for Headplane
# This is a path to a JSON file, default is specified below.
user_storage_file: "/var/lib/headplane/users.json"