feat: begin working on user auth
This commit is contained in:
parent
8429b19c4a
commit
bf02015dc7
@ -15,6 +15,7 @@ import cn from '~/utils/cn';
|
||||
|
||||
interface Props {
|
||||
configAvailable: boolean;
|
||||
uiAccess: boolean;
|
||||
user?: AuthSession['user'];
|
||||
}
|
||||
|
||||
@ -135,29 +136,31 @@ export default function Header(data: Props) {
|
||||
) : undefined}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
|
||||
<TabLink
|
||||
to="/machines"
|
||||
name="Machines"
|
||||
icon={<Server className="w-5" />}
|
||||
/>
|
||||
<TabLink to="/users" name="Users" icon={<Users className="w-5" />} />
|
||||
<TabLink
|
||||
to="/acls"
|
||||
name="Access Control"
|
||||
icon={<Lock className="w-5" />}
|
||||
/>
|
||||
{data.configAvailable ? (
|
||||
<>
|
||||
<TabLink to="/dns" name="DNS" icon={<Globe2 className="w-5" />} />
|
||||
<TabLink
|
||||
to="/settings"
|
||||
name="Settings"
|
||||
icon={<Settings className="w-5" />}
|
||||
/>
|
||||
</>
|
||||
) : undefined}
|
||||
</nav>
|
||||
{data.uiAccess ? (
|
||||
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
|
||||
<TabLink
|
||||
to="/machines"
|
||||
name="Machines"
|
||||
icon={<Server className="w-5" />}
|
||||
/>
|
||||
<TabLink to="/users" name="Users" icon={<Users className="w-5" />} />
|
||||
<TabLink
|
||||
to="/acls"
|
||||
name="Access Control"
|
||||
icon={<Lock className="w-5" />}
|
||||
/>
|
||||
{data.configAvailable ? (
|
||||
<>
|
||||
<TabLink to="/dns" name="DNS" icon={<Globe2 className="w-5" />} />
|
||||
<TabLink
|
||||
to="/settings"
|
||||
name="Settings"
|
||||
icon={<Settings className="w-5" />}
|
||||
/>
|
||||
</>
|
||||
) : undefined}
|
||||
</nav>
|
||||
) : undefined}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import { BanIcon } from 'lucide-react';
|
||||
import {
|
||||
LoaderFunctionArgs,
|
||||
Outlet,
|
||||
redirect,
|
||||
useLoaderData,
|
||||
} from 'react-router';
|
||||
import Card from '~/components/Card';
|
||||
import Footer from '~/components/Footer';
|
||||
import Header from '~/components/Header';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
|
||||
// This loads the bare minimum for the application to function
|
||||
// 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 {
|
||||
config: context.hs.c,
|
||||
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
configAvailable: context.hs.readable(),
|
||||
debug: context.config.debug,
|
||||
user: session.get('user'),
|
||||
uiAccess: check,
|
||||
};
|
||||
} catch {
|
||||
// No session, so we can just return
|
||||
@ -40,10 +45,24 @@ export async function loader({
|
||||
|
||||
export default function Shell() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<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} />
|
||||
</>
|
||||
);
|
||||
|
||||
83
app/routes/users/components/user-row.tsx
Normal file
83
app/routes/users/components/user-row.tsx
Normal 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';
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ import type { LoadContext } from '~/server';
|
||||
import type { Machine, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import ManageBanner from './components/manage-banner';
|
||||
import UserRow from './components/user-row';
|
||||
import DeleteUser from './dialogs/delete-user';
|
||||
import RenameUser from './dialogs/rename-user';
|
||||
import { userAction } from './user-actions';
|
||||
@ -34,6 +35,28 @@ export async function loader({
|
||||
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;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
@ -43,6 +66,7 @@ export async function loader({
|
||||
|
||||
return {
|
||||
oidc: context.config.oidc,
|
||||
roles,
|
||||
magic,
|
||||
users,
|
||||
};
|
||||
@ -72,7 +96,35 @@ export default function Page() {
|
||||
drag machines between users to change ownership.
|
||||
</p>
|
||||
<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
|
||||
users={users}
|
||||
@ -80,7 +132,7 @@ export default function Page() {
|
||||
magic={data.magic}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</ClientOnly> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -18,4 +18,5 @@ server
|
||||
├── web/
|
||||
│ ├── agent.ts: Handles setting up the agent WebSocket if needed.
|
||||
│ ├── 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.
|
||||
|
||||
@ -27,6 +27,7 @@ const oidcConfig = type({
|
||||
token_endpoint_auth_method:
|
||||
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"',
|
||||
redirect_uri: 'string.url?',
|
||||
user_storage_file: 'string = "/var/lib/headplane/users.json"',
|
||||
disable_api_key_login: stringToBool,
|
||||
headscale_api_key: 'string',
|
||||
strict_validation: stringToBool.default(true),
|
||||
|
||||
@ -40,12 +40,15 @@ const appLoadContext = {
|
||||
),
|
||||
|
||||
// TODO: Better cookie options in config
|
||||
sessions: createSessionStorage({
|
||||
name: '_hp_session',
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
secure: config.server.cookie_secure,
|
||||
secrets: [config.server.cookie_secret],
|
||||
}),
|
||||
sessions: await createSessionStorage(
|
||||
{
|
||||
name: '_hp_session',
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
secure: config.server.cookie_secure,
|
||||
secrets: [config.server.cookie_secret],
|
||||
},
|
||||
config.oidc?.user_storage_file,
|
||||
),
|
||||
|
||||
client: await createApiClient(
|
||||
config.headscale.url,
|
||||
|
||||
144
app/server/web/roles.ts
Normal file
144
app/server/web/roles.ts
Normal 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';
|
||||
}
|
||||
@ -1,9 +1,13 @@
|
||||
import { open, readFile } from 'node:fs/promises';
|
||||
import { exit } from 'node:process';
|
||||
import {
|
||||
CookieSerializeOptions,
|
||||
Session,
|
||||
SessionStorage,
|
||||
createCookieSessionStorage,
|
||||
} from 'react-router';
|
||||
import log from '~/utils/log';
|
||||
import { Capabilities, Roles } from './roles';
|
||||
|
||||
export interface AuthSession {
|
||||
state: 'auth';
|
||||
@ -42,7 +46,16 @@ interface CookieOptions {
|
||||
|
||||
class Sessionizer {
|
||||
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({
|
||||
cookie: {
|
||||
...options,
|
||||
@ -71,6 +84,84 @@ class Sessionizer {
|
||||
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) {
|
||||
return this.storage.getSession(request.headers.get('cookie')) as Promise<
|
||||
Session<T, Error>
|
||||
@ -86,6 +177,49 @@ class Sessionizer {
|
||||
}
|
||||
}
|
||||
|
||||
export function createSessionStorage(options: CookieOptions) {
|
||||
return new Sessionizer(options);
|
||||
export async function createSessionStorage(
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
// disable debug logging if the `HEADPLANE_DEBUG_LOG` specifies as such.
|
||||
|
||||
const levels = ['info', 'warn', 'error', 'debug'] as const;
|
||||
type Category = 'server' | 'config' | 'agent' | 'api';
|
||||
type Category = 'server' | 'config' | 'agent' | 'api' | 'auth';
|
||||
|
||||
export interface Logger
|
||||
extends Record<
|
||||
|
||||
@ -1,107 +1,111 @@
|
||||
# Configuration for the Headplane server and web application
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 3000
|
||||
host: "0.0.0.0"
|
||||
port: 3000
|
||||
|
||||
# The secret used to encode and decode web sessions
|
||||
# Ensure that this is exactly 32 characters long
|
||||
cookie_secret: "<change_me_to_something_secure!>"
|
||||
# The secret used to encode and decode web sessions
|
||||
# Ensure that this is exactly 32 characters long
|
||||
cookie_secret: "<change_me_to_something_secure!>"
|
||||
|
||||
# Should the cookies only work over HTTPS?
|
||||
# Set to false if running via HTTP without a proxy
|
||||
# (I recommend this is true in production)
|
||||
cookie_secure: true
|
||||
# Should the cookies only work over HTTPS?
|
||||
# Set to false if running via HTTP without a proxy
|
||||
# (I recommend this is true in production)
|
||||
cookie_secure: true
|
||||
|
||||
# Headscale specific settings to allow Headplane to talk
|
||||
# to Headscale and access deep integration features
|
||||
headscale:
|
||||
# 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://`
|
||||
url: "http://headscale:5000"
|
||||
# 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://`
|
||||
url: "http://headscale:5000"
|
||||
|
||||
# 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.
|
||||
# (This has no effect `url` does not start with `https://`)
|
||||
# tls_cert_path: "/var/lib/headplane/tls.crt"
|
||||
# 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.
|
||||
# (This has no effect `url` does not start with `https://`)
|
||||
# tls_cert_path: "/var/lib/headplane/tls.crt"
|
||||
|
||||
# Optional, public URL if they differ
|
||||
# This affects certain parts of the web UI
|
||||
# public_url: "https://headscale.example.com"
|
||||
# Optional, public URL if they differ
|
||||
# This affects certain parts of the web UI
|
||||
# public_url: "https://headscale.example.com"
|
||||
|
||||
# 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.
|
||||
config_path: "/etc/headscale/config.yaml"
|
||||
# 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.
|
||||
config_path: "/etc/headscale/config.yaml"
|
||||
|
||||
# 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.
|
||||
config_strict: true
|
||||
# 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.
|
||||
config_strict: true
|
||||
|
||||
# Integration configurations for Headplane to interact with Headscale
|
||||
# Only one of these should be enabled at a time or you will get errors
|
||||
integration:
|
||||
docker:
|
||||
enabled: false
|
||||
# The name (or ID) of the container running Headscale
|
||||
container_name: "headscale"
|
||||
# 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
|
||||
# https connections are not supported.
|
||||
socket: "unix:///var/run/docker.sock"
|
||||
# Please refer to docs/integration/Kubernetes.md for more information
|
||||
# on how to configure the Kubernetes integration. There are requirements in
|
||||
# order to allow Headscale to be controlled by Headplane in a cluster.
|
||||
kubernetes:
|
||||
enabled: false
|
||||
# Validates the manifest for the Pod to ensure all of the criteria
|
||||
# are set correctly. Turn this off if you are having issues with
|
||||
# shareProcessNamespace not being validated correctly.
|
||||
validate_manifest: true
|
||||
# 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
|
||||
# to set this value (refer to docs/Integrated-Mode.md for more info).
|
||||
pod_name: "headscale"
|
||||
docker:
|
||||
enabled: false
|
||||
# The name (or ID) of the container running Headscale
|
||||
container_name: "headscale"
|
||||
# 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
|
||||
# https connections are not supported.
|
||||
socket: "unix:///var/run/docker.sock"
|
||||
# Please refer to docs/integration/Kubernetes.md for more information
|
||||
# on how to configure the Kubernetes integration. There are requirements in
|
||||
# order to allow Headscale to be controlled by Headplane in a cluster.
|
||||
kubernetes:
|
||||
enabled: false
|
||||
# Validates the manifest for the Pod to ensure all of the criteria
|
||||
# are set correctly. Turn this off if you are having issues with
|
||||
# shareProcessNamespace not being validated correctly.
|
||||
validate_manifest: true
|
||||
# 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
|
||||
# to set this value (refer to docs/Integrated-Mode.md for more info).
|
||||
pod_name: "headscale"
|
||||
|
||||
# Proc is the "Native" integration that only works when Headscale and
|
||||
# Headplane are running outside of a container. There is no configuration,
|
||||
# but you need to ensure that the Headplane process can terminate the
|
||||
# Headscale process.
|
||||
#
|
||||
# (If they are both running under systemd as sudo, this will work).
|
||||
proc:
|
||||
enabled: false
|
||||
# Proc is the "Native" integration that only works when Headscale and
|
||||
# Headplane are running outside of a container. There is no configuration,
|
||||
# but you need to ensure that the Headplane process can terminate the
|
||||
# Headscale process.
|
||||
#
|
||||
# (If they are both running under systemd as sudo, this will work).
|
||||
proc:
|
||||
enabled: false
|
||||
|
||||
# OIDC Configuration for simpler authentication
|
||||
# (This is optional, but recommended for the best experience)
|
||||
oidc:
|
||||
issuer: "https://accounts.google.com"
|
||||
client_id: "your-client-id"
|
||||
issuer: "https://accounts.google.com"
|
||||
client_id: "your-client-id"
|
||||
|
||||
# The client secret for the OIDC client
|
||||
# Either this or `client_secret_path` must be set for OIDC to work
|
||||
client_secret: "<your-client-secret>"
|
||||
# You can alternatively set `client_secret_path` to read the secret from disk.
|
||||
# The path specified can resolve environment variables, making integration
|
||||
# with systemd's `LoadCredential` straightforward:
|
||||
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
|
||||
# The client secret for the OIDC client
|
||||
# Either this or `client_secret_path` must be set for OIDC to work
|
||||
client_secret: "<your-client-secret>"
|
||||
# You can alternatively set `client_secret_path` to read the secret from disk.
|
||||
# The path specified can resolve environment variables, making integration
|
||||
# with systemd's `LoadCredential` straightforward:
|
||||
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
|
||||
|
||||
disable_api_key_login: false
|
||||
token_endpoint_auth_method: "client_secret_post"
|
||||
disable_api_key_login: false
|
||||
token_endpoint_auth_method: "client_secret_post"
|
||||
|
||||
# If you are using OIDC, you need to generate an API key
|
||||
# that can be used to authenticate other sessions when signing in.
|
||||
#
|
||||
# This can be done with `headscale apikeys create --expiration 999d`
|
||||
headscale_api_key: "<your-headscale-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.
|
||||
#
|
||||
# This can be done with `headscale apikeys create --expiration 999d`
|
||||
headscale_api_key: "<your-headscale-api-key>"
|
||||
|
||||
# Optional, but highly recommended otherwise Headplane
|
||||
# will attempt to automatically guess this from the issuer
|
||||
#
|
||||
# This should point to your publicly accessibly URL
|
||||
# for your Headplane instance with /admin/oidc/callback
|
||||
redirect_uri: "http://localhost:3000/admin/oidc/callback"
|
||||
# Optional, but highly recommended otherwise Headplane
|
||||
# will attempt to automatically guess this from the issuer
|
||||
#
|
||||
# This should point to your publicly accessibly URL
|
||||
# for your Headplane instance with /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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user