Compare commits

...

20 Commits

Author SHA1 Message Date
Aarnav Tale 69c6fc4847 chore: v0.5.8 2025-04-03 13:11:33 -04:00
Aarnav Tale 9b09b13b5f feat: do not login loop if disable_api_key_login is true 2025-04-03 12:57:55 -04:00
Aarnav Tale cf90f3dd32 chore: auto-create /var/lib/headscale in docker 2025-04-03 12:57:55 -04:00
Aarnav Tale 72c1174cb3 fix: make the hidden URL link aria compatible 2025-04-03 12:57:06 -04:00
Aarnav Tale 63bfad77ce fix: add api-error file 2025-04-03 12:57:06 -04:00
Aarnav Tale 41223b89b3 chore: update to headscale 0.25.1 2025-04-03 12:57:06 -04:00
Aarnav Tale 93d5c2ed29 chore: update actions to include next branch 2025-04-03 12:57:06 -04:00
Aarnav Tale 6a94e815f2 feat: improve error returning and parsing logic 2025-04-03 12:57:06 -04:00
Aarnav Tale 234020eec5 feat: support acl capabilities check 2025-04-03 12:57:06 -04:00
Aarnav Tale 58cc7b742c feat: make machine actions permission locked 2025-04-03 12:57:06 -04:00
Aarnav Tale 259d150fc4 feat: add capabilities enforcement on users 2025-04-03 12:57:06 -04:00
Aarnav Tale 5d3fada266 feat: add permissions check on dns page 2025-04-03 12:57:06 -04:00
Aarnav Tale 9d046a0cf6 fix: use new logger on oidc utils 2025-04-03 12:57:06 -04:00
Aarnav Tale 1fb084451d fix: fix integrations not loading 2025-04-03 12:57:06 -04:00
Aarnav Tale d5fb8a2966 feat: support skipping onboarding 2025-04-03 12:57:06 -04:00
Aarnav Tale 7b1340c93e fix: make reassign dialog unactionable if editing owner 2025-04-03 12:57:06 -04:00
Aarnav Tale 16a8122f85 fix: only use basename in ssr build 2025-04-03 12:57:06 -04:00
Aarnav Tale 7d61ad50c4 feat: oops commit the user role change page 2025-04-03 12:57:06 -04:00
Aarnav Tale 103a826178 chore: remove unused code in the user overview 2025-04-03 12:57:06 -04:00
Aarnav Tale 090dec1ca6 chore: update docs 2025-04-02 17:03:58 -04:00
65 changed files with 1384 additions and 1354 deletions
-7
View File
@@ -1,7 +0,0 @@
ROOT_API_KEY=abcdefghijklmnopqrstuvwxyz
COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz
DISABLE_API_KEY_LOGIN=true
HEADSCALE_CONTAINER=headscale
HOST=0.0.0.0
PORT=3000
CONFIG_FILE=/etc/headscale/config.yaml
+1 -1
View File
@@ -10,7 +10,7 @@ concurrency:
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
contents: write # Read/write access to the repository
pull-requests: write # Allow creating pull requests
jobs:
+1
View File
@@ -3,6 +3,7 @@ on:
push:
branches:
- "main"
- "next"
pull_request:
concurrency:
+1
View File
@@ -2,4 +2,5 @@ node_modules
/.react-router
/.cache
/build
/test
.env
+8
View File
@@ -1,3 +1,11 @@
### 0.5.8 (April 3, 2025)
- You can now skip the onboarding page if desired.
- Added the UI to change user roles in the dashboard.
- Fixed an issue where integrations would throw instead of loading properly.
- Loading the ACL page no longer spams blank updates to the Headscale database (fixes [#151](https://github.com/tale/headplane/issues/151))
- Automatically create `/var/lib/headplane` in the Docker container (fixes [#166](https://github.com/tale/headplane/issues/166))
- OIDC logout with `disable_api_key_login` set to true will not automatically login again (fixes [#149](https://github.com/tale/headplane/issues/149))
### 0.5.7 (April 2, 2025)
- Hotfix an issue where assets aren't served under `/admin` or the prefix.
+2
View File
@@ -11,6 +11,8 @@ COPY . .
RUN pnpm run build
FROM node:22-alpine
RUN mkdir -p /var/lib/headplane
WORKDIR /app
COPY --from=build /app/build /app/build
CMD [ "node", "./build/server/index.js" ]
+37 -9
View File
@@ -1,16 +1,36 @@
import { AlertIcon } from '@primer/octicons-react';
import { isRouteErrorResponse, useRouteError } from 'react-router';
import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn';
import Card from './Card';
import Code from './Code';
interface Props {
type?: 'full' | 'embedded';
}
function getMessage(error: Error | unknown) {
function getMessage(error: Error | unknown): {
title: string;
message: string;
} {
if (error instanceof ResponseError) {
if (error.responseObject?.message) {
return {
title: 'Headscale Error',
message: String(error.responseObject.message),
};
}
return {
title: 'Headscale Error',
message: error.response,
};
}
if (!(error instanceof Error)) {
return 'An unknown error occurred';
return {
title: 'Unknown Error',
message: String(error),
};
}
let rootError = error;
@@ -25,16 +45,22 @@ function getMessage(error: Error | unknown) {
// If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
return rootError.errors.map((error) => error.message).join('\n');
return {
title: 'Errors',
message: rootError.errors.map((error) => error.message).join('\n'),
};
}
return rootError.message;
return {
title: 'Error',
message: rootError.message,
};
}
export function ErrorPopup({ type = 'full' }: Props) {
const error = useRouteError();
const routing = isRouteErrorResponse(error);
const message = getMessage(error);
const { title, message } = getMessage(error);
return (
<div
@@ -48,12 +74,14 @@ export function ErrorPopup({ type = 'full' }: Props) {
<Card>
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">
{routing ? error.status : 'Error'}
{routing ? error.status : title}
</Card.Title>
<AlertIcon className="w-12 h-12 text-red-500" />
</div>
<Card.Text className="mt-4 text-lg">
{routing ? error.statusText : <Code>{message}</Code>}
<Card.Text
className={cn('mt-4 text-lg', routing ? 'font-normal' : 'font-mono')}
>
{routing ? error.data.message : message}
</Card.Text>
</Card>
</div>
+12 -1
View File
@@ -30,7 +30,18 @@ export default function Footer({ url, debug }: FooterProps) {
<p className="container text-xs opacity-75">
Version: {__VERSION__}
{' — '}
Connecting to <strong className="blur-xs hover:blur-none">{url}</strong>
Connecting to{' '}
<button
type="button"
tabIndex={0} // Allows keyboard focus
className={cn(
'blur-sm hover:blur-none focus:blur-none transition',
'focus:outline-none focus:ring-2 rounded-sm',
)}
>
{url}
</button>
{/* Connecting to <strong className="blur-xs hover:blur-none">{url}</strong> */}
{debug && ' (Debug mode enabled)'}
</p>
</footer>
+44 -19
View File
@@ -15,9 +15,16 @@ import cn from '~/utils/cn';
interface Props {
configAvailable: boolean;
uiAccess: boolean;
onboarding: boolean;
user?: AuthSession['user'];
access: {
ui: boolean;
machines: boolean;
dns: boolean;
users: boolean;
policy: boolean;
settings: boolean;
};
}
interface LinkProps {
@@ -137,27 +144,45 @@ export default function Header(data: Props) {
) : undefined}
</div>
</div>
{data.uiAccess && !data.onboarding ? (
{data.access.ui && !data.onboarding ? (
<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.access.machines ? (
<TabLink
to="/machines"
name="Machines"
icon={<Server className="w-5" />}
/>
) : undefined}
{data.access.users ? (
<TabLink
to="/users"
name="Users"
icon={<Users className="w-5" />}
/>
) : undefined}
{data.access.policy ? (
<TabLink
to="/acls"
name="Access Control"
icon={<Lock className="w-5" />}
/>
) : undefined}
{data.configAvailable ? (
<>
<TabLink to="/dns" name="DNS" icon={<Globe2 className="w-5" />} />
<TabLink
to="/settings"
name="Settings"
icon={<Settings className="w-5" />}
/>
{data.access.dns ? (
<TabLink
to="/dns"
name="DNS"
icon={<Globe2 className="w-5" />}
/>
) : undefined}
{data.access.settings ? (
<TabLink
to="/settings"
name="Settings"
icon={<Settings className="w-5" />}
/>
) : undefined}
</>
) : undefined}
</nav>
+4 -1
View File
@@ -16,6 +16,7 @@ import cn from '~/utils/cn';
interface MenuProps extends MenuTriggerProps {
placement?: Placement;
isDisabled?: boolean;
children: [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<MenuPanelProps>,
@@ -23,8 +24,9 @@ interface MenuProps extends MenuTriggerProps {
}
// TODO: onAction is called twice for some reason?
// TODO: isDisabled per-prop
function Menu(props: MenuProps) {
const { placement = 'bottom' } = props;
const { placement = 'bottom', isDisabled } = props;
const state = useMenuTriggerState(props);
const ref = useRef<HTMLButtonElement | null>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
@@ -40,6 +42,7 @@ function Menu(props: MenuProps) {
<div>
{cloneElement(button, {
...menuTriggerProps,
isDisabled: isDisabled,
ref,
})}
{state.isOpen && (
+67
View File
@@ -0,0 +1,67 @@
import React, { createContext, useContext, useRef } from 'react';
import {
AriaRadioGroupProps,
AriaRadioProps,
VisuallyHidden,
useFocusRing,
} from 'react-aria';
import { RadioGroupState } from 'react-stately';
import cn from '~/utils/cn';
import { useRadio, useRadioGroup } from 'react-aria';
import { useRadioGroupState } from 'react-stately';
interface RadioGroupProps extends AriaRadioGroupProps {
children: React.ReactElement<RadioProps>[];
className?: string;
}
const RadioContext = createContext<RadioGroupState | null>(null);
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
const state = useRadioGroupState(props);
const { radioGroupProps, labelProps } = useRadioGroup(props, state);
return (
<div {...radioGroupProps} className={cn('flex flex-col gap-2', className)}>
<VisuallyHidden>
<span {...labelProps}>{label}</span>
</VisuallyHidden>
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
</div>
);
}
interface RadioProps extends AriaRadioProps {
className?: string;
}
function Radio({ children, className, ...props }: RadioProps) {
const state = useContext(RadioContext);
const ref = useRef(null);
const { inputProps, isSelected, isDisabled } = useRadio(props, state!, ref);
const { isFocusVisible, focusProps } = useFocusRing();
return (
<label className="flex items-center gap-2 text-sm">
<VisuallyHidden>
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
</VisuallyHidden>
<div
className={cn(
'w-5 h-5 aspect-square rounded-full p-1 border-2',
'border border-headplane-600 dark:border-headplane-300',
isFocusVisible ? 'ring-4' : '',
isDisabled ? 'opacity-50 cursor-not-allowed' : '',
isSelected
? 'border-[6px] border-headplane-900 dark:border-headplane-100'
: '',
className,
)}
/>
{children}
</label>
);
}
export default Object.assign(RadioGroup, { Radio });
+6 -1
View File
@@ -1,8 +1,9 @@
import { XCircleFillIcon } from '@primer/octicons-react';
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { Outlet, useLoaderData } from 'react-router';
import { ErrorPopup } from '~/components/Error';
import type { LoadContext } from '~/server';
import { ResponseError } from '~/server/headscale/api-client';
import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn';
import log from '~/utils/log';
@@ -65,3 +66,7 @@ export default function Layout() {
</>
);
}
export function ErrorBoundary() {
return <ErrorPopup type="embedded" />;
}
+22 -2
View File
@@ -1,4 +1,4 @@
import { BanIcon, CircleCheckIcon } from 'lucide-react';
import { CircleCheckIcon } from 'lucide-react';
import {
LoaderFunctionArgs,
Outlet,
@@ -63,6 +63,11 @@ export async function loader({
return false;
}
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
return true;
}
return subject === sessionUser.subject;
});
@@ -88,6 +93,20 @@ export async function loader({
debug: context.config.debug,
user: session.get('user'),
uiAccess: check,
access: {
ui: await context.sessions.check(request, Capabilities.ui_access),
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,
),
},
onboarding: request.url.endsWith('/onboarding'),
};
} catch {
@@ -102,7 +121,8 @@ export default function Shell() {
return (
<>
<Header {...data} />
{data.uiAccess ? (
{/* Always show the outlet if we are onboarding */}
{(data.onboarding ? true : data.uiAccess) ? (
<Outlet />
) : (
<Card className="mx-auto w-fit mt-24">
+1 -7
View File
@@ -1,8 +1,4 @@
import type {
LinksFunction,
LoaderFunctionArgs,
MetaFunction,
} from 'react-router';
import type { LinksFunction, MetaFunction } from 'react-router';
import {
Links,
Meta,
@@ -18,8 +14,6 @@ import ToastProvider from '~/components/ToastProvider';
import stylesheet from '~/tailwind.css?url';
import { LiveDataProvider } from '~/utils/live-data';
import { useToastQueue } from '~/utils/toast';
import { LoadContext } from './server';
import log from './utils/log';
export const meta: MetaFunction = () => [
{ title: 'Headplane' },
+2 -1
View File
@@ -15,6 +15,7 @@ export default [
// Double nested to separate error propagations
layout('layouts/shell.tsx', [
route('/onboarding', 'routes/users/onboarding.tsx'),
route('/onboarding/skip', 'routes/users/onboarding-skip.tsx'),
layout('layouts/dashboard.tsx', [
...prefix('/machines', [
index('routes/machines/overview.tsx'),
@@ -22,7 +23,7 @@ export default [
]),
route('/users', 'routes/users/overview.tsx'),
route('/acls', 'routes/acls/editor.tsx'),
route('/acls', 'routes/acls/overview.tsx'),
route('/dns', 'routes/dns/overview.tsx'),
...prefix('/settings', [
+113
View File
@@ -0,0 +1,113 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import { Capabilities } from '~/server/web/roles';
import { data400, data403 } from '~/utils/res';
// 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,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
Capabilities.write_policy,
);
if (!check) {
throw data403('You do not have permission to write to the ACL policy');
}
// 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 data400('Missing `policy` in the form data.');
}
try {
const { policy, updatedAt } = await context.client.put<{
policy: string;
updatedAt: string;
}>('v1/policy', session.get('api_key')!, {
policy: policyData,
});
return data({
success: true,
error: undefined,
policy,
updatedAt,
});
} catch (error) {
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('update is disabled')) {
// This means the policy is not writable
throw data403('Policy is not writable');
}
// https://github.com/juanfont/headscale/blob/main/hscontrol/policy/v1/acls.go#L81
if (message.includes('parsing hujson')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
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,
);
}
if (message.includes('unmarshalling policy')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
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,
);
}
if (message.includes('empty policy')) {
return data(
{
success: false,
error: 'Policy error: Supplied policy was empty',
policy: undefined,
updatedAt: undefined,
},
400,
);
}
}
// Otherwise, this is a Headscale error that we can just propagate.
throw error;
}
}
+64
View File
@@ -0,0 +1,64 @@
import { LoaderFunctionArgs } from 'react-router';
import { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import { Capabilities } from '~/server/web/roles';
import { data403 } from '~/utils/res';
// The logic for deciding policy factors is very complicated because
// there are so many factors that need to be accounted for:
// 1. Does the user have permission to read the policy?
// 2. Does the user have permission to write to the policy?
// 3. Is the Headscale policy in file or database mode?
// If database, we can read/write easily via the API.
// If in file mode, we can only write if context.config is available.
// TODO: Consider adding back file editing mode instead of database
export async function aclLoader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_policy);
if (!check) {
throw data403('You do not have permission to read the ACL policy.');
}
const flags = {
// Can the user write to the ACL policy
access: await context.sessions.check(request, Capabilities.write_policy),
writable: false,
policy: '',
};
// Try to load the ACL policy from the API.
try {
const { policy, updatedAt } = await context.client.get<{
policy: string;
updatedAt: string | null;
}>('v1/policy', session.get('api_key')!);
// Successfully loaded the policy, mark it as readable
// If `updatedAt` is null, it means the policy is in file mode.
flags.writable = updatedAt !== null;
flags.policy = policy;
return flags;
} catch (error) {
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('acl policy not found')) {
// This means the policy has never been initiated, and we can
// write to it to get it started or ignore it.
flags.policy = ''; // Start with an empty policy
flags.writable = true;
}
return flags;
}
// Otherwise, this is a Headscale error that we can just propagate.
throw error;
}
}
+3
View File
@@ -14,6 +14,7 @@ interface EditorProps {
onChange: (value: string) => void;
}
// TODO: Remove ClientOnly
export function Editor(props: EditorProps) {
const [light, setLight] = useState(false);
useEffect(() => {
@@ -38,6 +39,8 @@ export function Editor(props: EditorProps) {
{() => (
<CodeMirror
value={props.value}
editable={!props.isDisabled} // Allow editing unless disabled
readOnly={props.isDisabled} // Use readOnly if disabled
height="100%"
extensions={[shopify.jsonc()]}
style={{ height: '100%' }}
+30 -10
View File
@@ -1,24 +1,44 @@
import { AlertIcon } from '@primer/octicons-react';
import cn from '~/utils/cn';
import React from 'react';
import Card from '~/components/Card';
import Code from '~/components/Code';
interface Props {
message: string;
interface NoticeViewProps {
title: string;
children: React.ReactNode;
}
export function ErrorView({ message }: Props) {
export function NoticeView({ children, title }: NoticeViewProps) {
return (
<Card variant="flat" className="max-w-full mb-4">
<Card variant="flat" className="max-w-2xl my-8">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">Error</Card.Title>
<Card.Title className="text-xl mb-0">{title}</Card.Title>
<AlertIcon className="w-8 h-8 text-yellow-500" />
</div>
<Card.Text className="mt-4">{children}</Card.Text>
</Card>
);
}
interface ErrorViewProps {
children: string;
}
export function ErrorView({ children }: ErrorViewProps) {
const [title, ...rest] = children.split(':');
const formattedMessage = rest.length > 0 ? rest.join(':').trim() : children;
return (
<Card variant="flat" className="max-w-2xl mb-4">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">
{title.trim() ?? 'Error'}
</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500" />
</div>
<Card.Text className="mt-4">
Could not apply changes to your ACL policy due to the following error:
Could not apply changes to the ACL policy:
<br />
<Code>{message}</Code>
<span className="font-mono">{formattedMessage}</span>
</Card.Text>
</Card>
);
-1
View File
@@ -1,4 +1,3 @@
import Spinner from '~/components/Spinner';
import cn from '~/utils/cn';
interface Props {
@@ -1,39 +0,0 @@
import { AlertIcon } from '@primer/octicons-react';
import cn from '~/utils/cn';
import Card from '~/components/Card';
import Code from '~/components/Code';
interface Props {
mode: 'file' | 'database';
}
export function Unavailable({ mode }: Props) {
return (
<Card variant="flat" className="max-w-prose mt-12">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">ACL Policy Unavailable</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500" />
</div>
<Card.Text className="mt-4">
Unable to load a valid ACL policy configuration. This is most likely due
to a misconfiguration in your Headscale configuration file.
</Card.Text>
{mode !== 'file' ? (
<p className="mt-4 text-sm">
According to your configuration, the ACL policy mode is set to{' '}
<Code>file</Code> but the ACL file is not available. Ensure that the{' '}
<Code>policy.path</Code> is set to a valid path in your Headscale
configuration.
</p>
) : (
<p className="mt-4 text-sm">
In order to fully utilize the ACL management features of Headplane,
please set <Code>policy.mode</Code> to either <Code>file</Code> or{' '}
<Code>database</Code> in your Headscale configuration.
</p>
)}
</Card>
);
}
-305
View File
@@ -1,305 +0,0 @@
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import {
redirect,
useFetcher,
useLoaderData,
useRevalidator,
} from 'react-router';
import Button from '~/components/Button';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Spinner from '~/components/Spinner';
import Tabs from '~/components/Tabs';
import type { LoadContext } from '~/server';
import { ResponseError } from '~/server/headscale/api-client';
import log from '~/utils/log';
import { send } from '~/utils/res';
import toast from '~/utils/toast';
import { Differ, Editor } from './components/cm.client';
import { ErrorView } from './components/error';
import { Unavailable } from './components/unavailable';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
// The way policy is handled in 0.23 of Headscale and later is verbose.
// The 2 ACL policy modes are either the database one or file one
//
// File: The ACL policy is readonly to the API and manually edited
// Database: The ACL policy is read/write to the API
//
// To determine if we first have an ACL policy available we need to check
// if fetching the v1/policy route gives us a 500 status code or a 200.
//
// 500 can mean many different things here unfortunately:
// - In file based that means the file is not accessible
// - In database mode this can mean that we have never set an ACL policy
// - In database mode this can mean that the ACL policy is not available
// - A general server error may have occurred
//
// Unfortunately the server errors are not very descriptive so we have to
// do some silly guesswork here. If we are running in an integration mode
// and have the Headscale configuration available to us, our assumptions
// can be more accurate, otherwise we just HAVE to assume that the ACL
// policy has never been set.
//
// We can do damage control by checking for write access and if we are not
// able to PUT an ACL policy on the v1/policy route, we can already know
// that the policy is at the very-least readonly or not available.
let modeGuess = 'database'; // Assume database mode
if (!context.hs.readable()) {
modeGuess = context.hs.c!.policy?.mode ?? 'database';
}
// Attempt to load the policy, for both the frontend and for checking
// if we are able to write to the policy for write access
try {
const { policy } = await context.client.get<{ policy: string }>(
'v1/policy',
session.get('api_key')!,
);
let write = false; // On file mode we already know it's readonly
if (modeGuess === 'database' && policy.length > 0) {
try {
await context.client.put('v1/policy', session.get('api_key')!, {
policy: policy,
});
write = true;
} catch (error) {
write = false;
log.debug('api', 'Failed to write to ACL policy with error %s', error);
}
}
return {
read: true,
write,
mode: modeGuess,
policy,
};
} catch {
// If we are explicit on file mode then this is the end of the road
if (modeGuess === 'file') {
return {
read: false,
write: false,
mode: modeGuess,
policy: null,
};
}
// Assume that we have write access otherwise?
// This is sort of a brittle assumption to make but we don't want
// to create a default policy if we don't have to.
return {
read: true,
write: true,
mode: modeGuess,
policy: null,
};
}
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
try {
const { acl } = (await request.json()) as { acl: string };
const { policy } = await context.client.put<{ policy: string }>(
'v1/policy',
session.get('api_key')!,
{
policy: acl,
},
);
return { success: true, policy, error: null };
} catch (error) {
log.debug('api', 'Failed to update ACL policy with error %s', error);
// @ts-ignore: TODO: Shut UP we know it's a string most of the time
const text = JSON.parse(error.message);
return send(
{ success: false, error: text.message },
{
status: error instanceof ResponseError ? error.status : 500,
},
);
}
}
export default function Page() {
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher<typeof action>();
const revalidator = useRevalidator();
const [acl, setAcl] = useState(data.policy ?? '');
const [toasted, setToasted] = useState(false);
useEffect(() => {
if (!fetcher.data || toasted) {
return;
}
if (fetcher.data.success) {
toast('Updated tailnet ACL policy');
} else {
toast('Failed to update tailnet ACL policy');
}
setToasted(true);
if (revalidator.state === 'idle') {
revalidator.revalidate();
}
}, [fetcher.data, toasted, data.policy]);
// The state for if the save and discard buttons should be disabled
// is pretty complicated to calculate and varies on different states.
const disabled = useMemo(() => {
if (!data.read || !data.write) {
return true;
}
// First check our fetcher states
if (fetcher.state === 'loading') {
return true;
}
if (revalidator.state === 'loading') {
return true;
}
// If we have a failed fetcher state allow the user to try again
if (fetcher.data?.success === false) {
return false;
}
return data.policy === acl;
}, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl]);
return (
<div>
{data.read && !data.write ? (
<div className="mb-4">
<Notice>
The ACL policy is read-only. You can view the current policy but you
cannot make changes to it.
<br />
To resolve this, you need to set the ACL policy mode to database in
your Headscale configuration.
</Notice>
</div>
) : undefined}
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>{' '}
and the{' '}
<Link
to="https://headscale.net/stable/ref/acls/"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
{fetcher.data?.success === false ? (
<ErrorView message={fetcher.data.error} />
) : undefined}
{data.read ? (
<>
<Tabs label="ACL Editor" className="mb-4">
<Tabs.Item
key="edit"
title={
<div className="flex items-center gap-2">
<Pencil className="p-1" />
<span>Edit file</span>
</div>
}
>
<Editor isDisabled={!data.write} value={acl} onChange={setAcl} />
</Tabs.Item>
<Tabs.Item
key="diff"
title={
<div className="flex items-center gap-2">
<Eye className="p-1" />
<span>Preview changes</span>
</div>
}
>
<Differ left={data?.policy ?? ''} right={acl} />
</Tabs.Item>
<Tabs.Item
key="preview"
title={
<div className="flex items-center gap-2">
<FlaskConical className="p-1" />
<span>Preview rules</span>
</div>
}
>
<div className="flex flex-col items-center py-8">
<Construction />
<p className="w-1/2 text-center mt-4">
Previewing rules is not available yet. This feature is still
in development and is pretty complicated to implement.
Hopefully I will be able to get to it soon.
</p>
</div>
</Tabs.Item>
</Tabs>
<Button
variant="heavy"
className="mr-2"
isDisabled={disabled}
onPress={() => {
setToasted(false);
fetcher.submit(
{
acl,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className="w-3 h-3" />
)}
Save
</Button>
<Button
isDisabled={disabled}
onPress={() => {
setAcl(data?.policy ?? '');
}}
>
Discard Changes
</Button>
</>
) : (
<Unavailable mode={data.mode as 'database' | 'file'} />
)}
</div>
);
}
+173
View File
@@ -0,0 +1,173 @@
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
ActionFunctionArgs,
LoaderFunctionArgs,
useFetcher,
useLoaderData,
useRevalidator,
} from 'react-router';
import Button from '~/components/Button';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Tabs from '~/components/Tabs';
import type { LoadContext } from '~/server';
import toast from '~/utils/toast';
import { aclAction } from './acl-action';
import { aclLoader } from './acl-loader';
import { Differ, Editor } from './components/cm.client';
import { ErrorView, NoticeView } from './components/error';
export async function loader(request: LoaderFunctionArgs<LoadContext>) {
return aclLoader(request);
}
export async function action(request: ActionFunctionArgs<LoadContext>) {
return aclAction(request);
}
export default function Page() {
// Access is a write check here, we already check read in aclLoader
const { access, writable, policy } = useLoaderData<typeof loader>();
const [codePolicy, setCodePolicy] = useState(policy);
const fetcher = useFetcher<typeof action>();
const { revalidate } = useRevalidator();
const disabled = !access || !writable; // Disable if no permission or not writable
useEffect(() => {
// Update the codePolicy when the loader data changes
if (policy !== codePolicy) {
setCodePolicy(policy);
}
}, [policy]);
useEffect(() => {
if (!fetcher.data) {
// No data yet, return
return;
}
if (fetcher.data.success === true) {
toast('Updated policy');
revalidate();
}
}, [fetcher.data]);
return (
<div>
{!access ? (
<NoticeView title="ACL Policy restricted">
You do not have the necessary permissions to edit the Access Control
List policy. Please contact your administrator to request access or to
make changes to the ACL policy.
</NoticeView>
) : !writable ? (
<NoticeView title="Read-only ACL Policy">
The ACL policy mode is most likely set to <Code>file</Code> in your
Headscale configuration. This means that the ACL file cannot be edited
through the web interface. In order to resolve this, you'll need to
set <Code>acl.mode</Code> to <Code>database</Code> in your Headscale
configuration.
</NoticeView>
) : undefined}
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>{' '}
and the{' '}
<Link
to="https://headscale.net/stable/ref/acls/"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
{fetcher.data?.error !== undefined ? (
<ErrorView>{fetcher.data.error}</ErrorView>
) : undefined}
<Tabs label="ACL Editor" className="mb-4">
<Tabs.Item
key="edit"
title={
<div className="flex items-center gap-2">
<Pencil className="p-1" />
<span>Edit file</span>
</div>
}
>
<Editor
isDisabled={disabled}
value={codePolicy}
onChange={setCodePolicy}
/>
</Tabs.Item>
<Tabs.Item
key="diff"
title={
<div className="flex items-center gap-2">
<Eye className="p-1" />
<span>Preview changes</span>
</div>
}
>
<Differ left={policy} right={codePolicy} />
</Tabs.Item>
<Tabs.Item
key="preview"
title={
<div className="flex items-center gap-2">
<FlaskConical className="p-1" />
<span>Preview rules</span>
</div>
}
>
<div className="flex flex-col items-center py-8">
<Construction />
<p className="w-1/2 text-center mt-4">
Previewing rules is not available yet. This feature is still in
development and is pretty complicated to implement. Hopefully I
will be able to get to it soon.
</p>
</div>
</Tabs.Item>
</Tabs>
<Button
variant="heavy"
className="mr-2"
isDisabled={
disabled ||
fetcher.state !== 'idle' ||
codePolicy.length === 0 ||
codePolicy === policy
}
onPress={() => {
const formData = new FormData();
console.log(codePolicy);
formData.append('policy', codePolicy);
fetcher.submit(formData, { method: 'PATCH' });
}}
>
Save
</Button>
<Button
isDisabled={
disabled || fetcher.state !== 'idle' || codePolicy === policy
}
onPress={() => {
// Reset the editor to the original policy
setCodePolicy(policy);
}}
>
Discard Changes
</Button>
</div>
);
}
+49 -12
View File
@@ -1,7 +1,9 @@
import { useEffect } from 'react';
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
redirect,
useSearchParams,
} from 'react-router';
import { Form, useActionData, useLoaderData } from 'react-router';
import Button from '~/components/Button';
@@ -15,6 +17,9 @@ export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const qp = new URL(request.url).searchParams;
const state = qp.get('s');
try {
const session = await context.sessions.auth(request);
if (session.has('api_key')) {
@@ -24,12 +29,18 @@ export async function loader({
const disableApiKeyLogin = context.config.oidc?.disable_api_key_login;
if (context.oidc && disableApiKeyLogin) {
return redirect('/oidc/start');
// Prevents automatic redirect loop if OIDC is enabled and API key login is disabled
// Since logging out would just log back in based on the redirects
if (state !== 'logout') {
return redirect('/oidc/start');
}
}
return {
oidc: context.oidc,
disableApiKeyLogin,
state,
};
}
@@ -92,14 +103,47 @@ export async function action({
}
export default function Page() {
const data = useLoaderData<typeof loader>();
const { state, disableApiKeyLogin, oidc } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const [params] = useSearchParams();
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
if (state !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete('s');
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
window.history.replaceState(null, '', newUrl);
}
}, [state, params]);
if (state === 'logout') {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>You have been logged out</Card.Title>
<Card.Text>
You can now close this window. If you would like to log in again,
please refresh the page.
</Card.Text>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>Welcome to Headplane</Card.Title>
{!data.disableApiKeyLogin ? (
{!disableApiKeyLogin ? (
<Form method="post">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate
@@ -124,19 +168,12 @@ export default function Page() {
</Button>
</Form>
) : undefined}
{data.oidc ? (
{oidc ? (
<Form method="POST">
{data.disableApiKeyLogin ? (
<Card.Text className="mb-6">
Sign in with your authentication provider to continue. Your
administrator has disabled API key login.
</Card.Text>
) : undefined}
<input type="hidden" name="oidc-start" value="true" />
<Button
className="w-full mt-2"
variant={data.disableApiKeyLogin ? 'heavy' : 'light'}
variant={disableApiKeyLogin ? 'heavy' : 'light'}
type="submit"
>
Single Sign-On
+7 -1
View File
@@ -14,7 +14,13 @@ export async function action({
return redirect('/login');
}
return 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';
return redirect(url, {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
},
+18 -9
View File
@@ -1,11 +1,20 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import { hp_getIntegration } from '~/utils/integration/loader';
import { Capabilities } from '~/server/web/roles';
export async function dnsAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const check = await context.sessions.check(
request,
Capabilities.write_network,
);
if (!check) {
return data({ success: false }, 403);
}
if (!context.hs.writable()) {
return data({ success: false }, 403);
}
@@ -51,7 +60,7 @@ async function renameTailnet(formData: FormData, context: LoadContext) {
},
]);
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function toggleMagic(formData: FormData, context: LoadContext) {
@@ -67,7 +76,7 @@ async function toggleMagic(formData: FormData, context: LoadContext) {
},
]);
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function removeNs(formData: FormData, context: LoadContext) {
@@ -100,7 +109,7 @@ async function removeNs(formData: FormData, context: LoadContext) {
]);
}
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function addNs(formData: FormData, context: LoadContext) {
@@ -135,7 +144,7 @@ async function addNs(formData: FormData, context: LoadContext) {
]);
}
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function removeDomain(formData: FormData, context: LoadContext) {
@@ -153,7 +162,7 @@ async function removeDomain(formData: FormData, context: LoadContext) {
},
]);
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function addDomain(formData: FormData, context: LoadContext) {
@@ -173,7 +182,7 @@ async function addDomain(formData: FormData, context: LoadContext) {
},
]);
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function removeRecord(formData: FormData, context: LoadContext) {
@@ -196,7 +205,7 @@ async function removeRecord(formData: FormData, context: LoadContext) {
},
]);
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function addRecord(formData: FormData, context: LoadContext) {
@@ -219,5 +228,5 @@ async function addRecord(formData: FormData, context: LoadContext) {
},
]);
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
+29 -2
View File
@@ -3,6 +3,7 @@ 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';
@@ -11,11 +12,30 @@ import ToggleMagic from './components/toggle-magic';
import { dnsAction } from './dns-actions';
// We do not want to expose every config value
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
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 writablePermission = await context.sessions.check(
request,
Capabilities.write_network,
);
const config = context.hs.c!;
const dns = {
prefixes: config.prefixes,
@@ -29,6 +49,7 @@ export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
return {
...dns,
access: writablePermission,
writable: context.hs.writable(),
};
}
@@ -46,7 +67,7 @@ export default function Page() {
}
allNs.global = data.nameservers;
const isDisabled = data.writable === false;
const isDisabled = data.access === false || data.writable === false;
return (
<div className="flex flex-col gap-16 max-w-screen-lg">
@@ -56,6 +77,12 @@ export default function Page() {
the configuration
</Notice>
)}
{data.access ? undefined : (
<Notice>
Your permissions do not allow you to modify the DNS settings for this
tailnet.
</Notice>
)}
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
<ManageNS nameservers={allNs} isDisabled={isDisabled} />
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
@@ -18,6 +18,7 @@ interface Props {
isAgent?: boolean;
magic?: string;
stats?: HostInfo;
isDisabled?: boolean;
}
export default function MachineRow({
@@ -27,6 +28,7 @@ export default function MachineRow({
isAgent,
magic,
stats,
isDisabled,
}: Props) {
const expired =
machine.expiry === '0001-01-01 00:00:00' ||
@@ -191,6 +193,7 @@ export default function MachineRow({
routes={routes}
users={users}
magic={magic}
isDisabled={isDisabled}
/>
</td>
</tr>
+3 -1
View File
@@ -16,6 +16,7 @@ interface MenuProps {
users: User[];
magic?: string;
isFullButton?: boolean;
isDisabled?: boolean;
}
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
@@ -26,6 +27,7 @@ export default function MachineMenu({
magic,
users,
isFullButton,
isDisabled,
}: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
@@ -96,7 +98,7 @@ export default function MachineMenu({
/>
)}
<Menu>
<Menu isDisabled={isDisabled}>
{isFullButton ? (
<Menu.Button className="flex items-center gap-x-2">
<Cog className="h-5" />
+6 -8
View File
@@ -8,12 +8,13 @@ import Menu from '~/components/Menu';
import Select from '~/components/Select';
import type { User } from '~/types';
export interface NewProps {
export interface NewMachineProps {
server: string;
users: User[];
isDisabled?: boolean;
}
export default function New(data: NewProps) {
export default function NewMachine(data: NewMachineProps) {
const [pushDialog, setPushDialog] = useState(false);
const [mkey, setMkey] = useState('');
const navigate = useNavigate();
@@ -25,11 +26,8 @@ export default function New(data: NewProps) {
<Dialog.Title>Register Machine Key</Dialog.Title>
<Dialog.Text className="mb-4">
The machine key is given when you run{' '}
<Code isCopyable>
tailscale up --login-server=
{data.server}
</Code>{' '}
on your device.
<Code isCopyable>tailscale up --login-server={data.server}</Code> on
your device.
</Dialog.Text>
<input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" />
@@ -53,7 +51,7 @@ export default function New(data: NewProps) {
</Select>
</Dialog.Panel>
</Dialog>
<Menu>
<Menu isDisabled={data.isDisabled}>
<Menu.Button variant="heavy">Add Device</Menu.Button>
<Menu.Panel
onAction={(key) => {
@@ -1,43 +1,65 @@
import type { ActionFunctionArgs } from 'react-router';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { Machine } from '~/types';
import log from '~/utils/log';
import { send } from '~/utils/res';
import { data400, data403, data404, send } from '~/utils/res';
// TODO: Turn this into the same thing as dns-actions like machine-actions!!!
export async function menuAction({
// TODO: Clean this up like dns-actions and user-actions
export async function machineAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const data = await request.formData();
if (!data.has('_method') || !data.has('id')) {
return send(
{ message: 'No method or ID provided' },
{
status: 400,
},
);
const check = await context.sessions.check(
request,
Capabilities.write_machines,
);
const apiKey = session.get('api_key')!;
const formData = await request.formData();
// TODO: Rename this to 'action_id' and 'node_id'
const action = formData.get('_method')?.toString();
const nodeId = formData.get('id')?.toString();
if (!action || !nodeId) {
return data400('Missing required parameters: _method and id');
}
const id = String(data.get('id'));
const method = String(data.get('_method'));
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
apiKey,
);
switch (method) {
const node = nodes.find((node) => node.id === nodeId);
if (!node) {
return data404(`Node with ID ${nodeId} not found`);
}
const subject = session.get('user')!.subject;
if (node.user.providerId?.split('/').pop() !== subject) {
if (!check) {
return data403('You do not have permission to act on this machine');
}
}
// TODO: Split up into methods
switch (action) {
case 'delete': {
await context.client.delete(`v1/node/${id}`, session.get('api_key')!);
await context.client.delete(`v1/node/${nodeId}`, session.get('api_key')!);
return { message: 'Machine removed' };
}
case 'expire': {
await context.client.post(
`v1/node/${id}/expire`,
`v1/node/${nodeId}/expire`,
session.get('api_key')!,
);
return { message: 'Machine expired' };
}
case 'rename': {
if (!data.has('name')) {
if (!formData.has('name')) {
return send(
{ message: 'No name provided' },
{
@@ -46,16 +68,16 @@ export async function menuAction({
);
}
const name = String(data.get('name'));
const name = String(formData.get('name'));
await context.client.post(
`v1/node/${id}/rename/${name}`,
`v1/node/${nodeId}/rename/${name}`,
session.get('api_key')!,
);
return { message: 'Machine renamed' };
}
case 'routes': {
if (!data.has('route') || !data.has('enabled')) {
if (!formData.has('route') || !formData.has('enabled')) {
return send(
{ message: 'No route or enabled provided' },
{
@@ -64,8 +86,8 @@ export async function menuAction({
);
}
const route = String(data.get('route'));
const enabled = data.get('enabled') === 'true';
const route = String(formData.get('route'));
const enabled = formData.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await context.client.post(
@@ -76,7 +98,7 @@ export async function menuAction({
}
case 'exit-node': {
if (!data.has('routes') || !data.has('enabled')) {
if (!formData.has('routes') || !formData.has('enabled')) {
return send(
{ message: 'No route or enabled provided' },
{
@@ -85,8 +107,8 @@ export async function menuAction({
);
}
const routes = data.get('routes')?.toString().split(',') ?? [];
const enabled = data.get('enabled') === 'true';
const routes = formData.get('routes')?.toString().split(',') ?? [];
const enabled = formData.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await Promise.all(
@@ -102,7 +124,7 @@ export async function menuAction({
}
case 'move': {
if (!data.has('to')) {
if (!formData.has('to')) {
return send(
{ message: 'No destination provided' },
{
@@ -111,22 +133,22 @@ export async function menuAction({
);
}
const to = String(data.get('to'));
const to = String(formData.get('to'));
try {
await context.client.post(
`v1/node/${id}/user`,
`v1/node/${nodeId}/user`,
session.get('api_key')!,
{
user: to,
},
);
return { message: `Moved node ${id} to ${to}` };
return { message: `Moved node ${nodeId} to ${to}` };
} catch (error) {
console.error(error);
return send(
{ message: `Failed to move node ${id} to ${to}` },
{ message: `Failed to move node ${nodeId} to ${to}` },
{
status: 500,
},
@@ -136,7 +158,7 @@ export async function menuAction({
case 'tags': {
const tags =
data
formData
.get('tags')
?.toString()
.split(',')
@@ -144,7 +166,7 @@ export async function menuAction({
try {
await context.client.post(
`v1/node/${id}/tags`,
`v1/node/${nodeId}/tags`,
session.get('api_key')!,
{
tags,
@@ -164,8 +186,8 @@ export async function menuAction({
}
case 'register': {
const key = data.get('mkey')?.toString();
const user = data.get('user')?.toString();
const key = formData.get('mkey')?.toString();
const user = formData.get('user')?.toString();
if (!key) {
return send(
+2 -2
View File
@@ -12,9 +12,9 @@ import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server';
import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
import { menuAction } from './action';
import MenuOptions from './components/menu';
import Routes from './dialogs/routes';
import { machineAction } from './machine-actions';
export async function loader({
request,
@@ -59,7 +59,7 @@ export async function loader({
}
export async function action(request: ActionFunctionArgs) {
return menuAction(request);
return machineAction(request);
}
export default function Page() {
+34 -3
View File
@@ -6,17 +6,40 @@ import { ErrorPopup } from '~/components/Error';
import Link from '~/components/Link';
import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
import { menuAction } from './action';
import MachineRow from './components/machine';
import MachineRow from './components/machine-row';
import NewMachine from './dialogs/new';
import { machineAction } from './machine-actions';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const user = session.get('user');
if (!user) {
throw new Error('Missing user session. Please log in again.');
}
const check = await context.sessions.check(
request,
Capabilities.read_machines,
);
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_machines,
);
const [machines, routes, users] = await Promise.all([
context.client.get<{ nodes: Machine[] }>(
'v1/node',
@@ -45,11 +68,13 @@ export async function loader({
publicServer: context.config.headscale.public_url,
agents: context.agents?.tailnetIDs(),
stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)),
writable: writablePermission,
subject: user.subject,
};
}
export async function action(request: ActionFunctionArgs) {
return menuAction(request);
return machineAction(request);
}
export default function Page() {
@@ -73,6 +98,7 @@ export default function Page() {
<NewMachine
server={data.publicServer ?? data.server}
users={data.users}
isDisabled={!data.writable}
/>
</div>
<table className="table-auto w-full rounded-lg">
@@ -123,6 +149,11 @@ export default function Page() {
// This is useful for when there are no agents configured
isAgent={data.agents?.includes(machine.id)}
stats={data.stats?.[machine.nodeKey]}
isDisabled={
data.writable
? false // If the user has write permissions, they can edit all machines
: machine.user.providerId?.split('/').pop() !== data.subject
}
/>
))}
</tbody>
@@ -4,11 +4,12 @@ import Link from '~/components/Link';
import type { HeadplaneConfig } from '~/server/config/schema';
import CreateUser from '../dialogs/create-user';
interface Props {
interface ManageBannerProps {
oidc?: NonNullable<HeadplaneConfig['oidc']>;
isDisabled?: boolean;
}
export default function ManageBanner({ oidc }: Props) {
export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
return (
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
<div className="flex flex-col md:flex-row">
@@ -60,7 +61,7 @@ export default function ManageBanner({ oidc }: Props) {
: 'You can add, remove, and rename users here.'}
</p>
<div className="flex items-center gap-2 mt-4">
<CreateUser />
<CreateUser isDisabled={isDisabled} />
</div>
</div>
</div>
+13 -1
View File
@@ -4,15 +4,17 @@ 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';
interface MenuProps {
user: User & {
headplaneRole: string;
machines: Machine[];
};
}
type Modal = 'rename' | 'delete' | null;
type Modal = 'rename' | 'delete' | 'reassign' | null;
export default function UserMenu({ user }: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
@@ -36,6 +38,15 @@ export default function UserMenu({ user }: MenuProps) {
}}
/>
)}
{modal === 'reassign' && (
<Reassign
user={user}
isOpen={modal === 'reassign'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
<Menu>
<Menu.IconButton
@@ -51,6 +62,7 @@ export default function UserMenu({ user }: MenuProps) {
<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>
+1 -1
View File
@@ -58,7 +58,7 @@ export default function UserRow({ user, role }: UserRowProps) {
</span>
</td>
<td className="py-2 pr-0.5">
<MenuOptions user={user} />
<MenuOptions user={{ ...user, headplaneRole: role }} />
</td>
</tr>
);
+6 -2
View File
@@ -1,11 +1,15 @@
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
interface CreateUserProps {
isDisabled?: boolean;
}
// TODO: Support image upload for user avatars
export default function CreateUser() {
export default function CreateUser({ isDisabled }: CreateUserProps) {
return (
<Dialog>
<Dialog.Button>Add a new user</Dialog.Button>
<Dialog.Button isDisabled={isDisabled}>Add a new user</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Add a new user</Dialog.Title>
<Dialog.Text className="mb-6">
+103
View File
@@ -0,0 +1,103 @@
import Dialog from '~/components/Dialog';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import RadioGroup from '~/components/RadioGroup';
import { Roles } from '~/server/web/roles';
import { User } from '~/types';
interface ReassignProps {
user: User & { headplaneRole: string };
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function ReassignUser({
user,
isOpen,
setIsOpen,
}: ReassignProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel
variant={user.headplaneRole === 'owner' ? 'unactionable' : 'normal'}
>
<Dialog.Title>Change role for {user.name}?</Dialog.Title>
<Dialog.Text className="mb-6">
Most roles are carried straight from Tailscale. However, keep in mind
that I have not fully implemented permissions yet and some things may
be accessible to everyone. The only fully completed role is Member.{' '}
<Link
to="https://tailscale.com/kb/1138/user-roles"
name="Tailscale User Roles documentation"
>
Learn More
</Link>
</Dialog.Text>
{user.headplaneRole === 'owner' ? (
<Notice>The Tailnet owner cannot be reassigned.</Notice>
) : (
<>
<input type="hidden" name="action_id" value="reassign_user" />
<input type="hidden" name="user_id" value={user.id} />
<RadioGroup
isRequired
name="new_role"
label="Role"
className="gap-4"
defaultValue={user.headplaneRole}
>
{Object.keys(Roles)
.filter((role) => role !== 'owner')
.map((role) => {
const { name, desc } = mapRoleToName(role);
return (
<RadioGroup.Radio key={role} value={role}>
<div className="block">
<p className="font-bold">{name}</p>
<p className="opacity-70">{desc}</p>
</div>
</RadioGroup.Radio>
);
})}
</RadioGroup>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
function mapRoleToName(role: string) {
switch (role) {
case 'admin':
return {
name: 'Admin',
desc: 'Can view the admin console, manage network, machine, and user settings.',
};
case 'network_admin':
return {
name: 'Network Admin',
desc: 'Can view the admin console and manage ACLs and network settings. Cannot manage machines or users.',
};
case 'it_admin':
return {
name: 'IT Admin',
desc: 'Can view the admin console and manage machines and users. Cannot manage ACLs or network settings.',
};
case 'auditor':
return {
name: 'Auditor',
desc: 'Can view the admin console.',
};
case 'member':
return {
name: 'Member',
desc: 'Cannot view the admin console.',
};
default:
return {
name: 'Unknown',
desc: 'Unknown',
};
}
}
+16
View File
@@ -0,0 +1,16 @@
import { LoaderFunctionArgs, redirect } from 'react-router';
import { LoadContext } from '~/server';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const user = session.get('user');
if (!user) {
return redirect('/login');
}
context.sessions.overrideOnboarding(user.subject, true);
return redirect('/machines');
}
+7
View File
@@ -1,3 +1,4 @@
import { ArrowRight } from 'lucide-react';
import { useEffect } from 'react';
import { GrApple } from 'react-icons/gr';
import { ImFinder } from 'react-icons/im';
@@ -335,6 +336,12 @@ export default function Page() {
</div>
)}
</Card>
<NavLink to="/onboarding/skip" className="col-span-2 w-max mx-auto">
<Button className="flex items-center gap-1">
I already know what I'm doing
<ArrowRight className="p-1" />
</Button>
</NavLink>
</div>
</div>
);
+21 -168
View File
@@ -1,26 +1,36 @@
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core';
import { PersonIcon } from '@primer/octicons-react';
import { useEffect, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData, useSubmit } from 'react-router';
import Attribute from '~/components/Attribute';
import Card from '~/components/Card';
import { ErrorPopup } from '~/components/Error';
import StatusCircle from '~/components/StatusCircle';
import type { LoadContext } from '~/server';
import type { Machine, User } from '~/types';
import { Capabilities } from '~/server/web/roles';
import { 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';
interface UserMachine extends User {
machines: Machine[];
}
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_users);
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_users,
);
const [machines, apiUsers] = await Promise.all([
context.client.get<{ nodes: Machine[] }>(
'v1/node',
@@ -67,6 +77,7 @@ export async function loader({
}
return {
writable: writablePermission, // whether the user can write to the API
oidc: context.config.oidc,
roles,
magic,
@@ -97,7 +108,7 @@ export default function Page() {
Manage the users in your network and their permissions. Tip: You can
drag machines between users to change ownership.
</p>
<ManageBanner oidc={data.oidc} />
<ManageBanner oidc={data.oidc} isDisabled={!data.writable} />
<table className="table-auto w-full rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
@@ -124,164 +135,6 @@ export default function Page() {
))}
</tbody>
</table>
{/* <Users users={users} /> */}
{/* <ClientOnly fallback={<Users users={users} />}>
{() => (
<InteractiveUsers
users={users}
setUsers={setUsers}
magic={data.magic}
/>
)}
</ClientOnly> */}
</>
);
}
type UserMachine = User & { machines: Machine[] };
interface UserProps {
users: UserMachine[];
setUsers?: (users: UserMachine[]) => void;
magic?: string;
}
function Users({ users, magic }: UserProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map((user) => (
<UserCard key={user.id} user={user} magic={magic} />
))}
</div>
);
}
function InteractiveUsers({ users, setUsers, magic }: UserProps) {
const submit = useSubmit();
return (
<DndContext
onDragEnd={(event) => {
const { over, active } = event;
if (!over) {
return;
}
// Update the UI optimistically
const newUsers = new Array<UserMachine>();
const reference = active.data as DataRef<Machine>;
if (!reference.current) {
return;
}
// Ignore if the user is unchanged
if (reference.current.user.name === over.id) {
return;
}
for (const user of users) {
newUsers.push({
...user,
machines:
over.id === user.name
? [...user.machines, reference.current]
: user.machines.filter((m) => m.id !== active.id),
});
}
setUsers?.(newUsers);
const data = new FormData();
data.append('action_id', 'change_owner');
data.append('user_id', over.id.toString());
data.append('node_id', reference.current.id);
submit(data, {
method: 'POST',
});
}}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map((user) => (
<UserCard key={user.id} user={user} magic={magic} />
))}
</div>
</DndContext>
);
}
function MachineChip({ machine }: { readonly machine: Machine }) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: machine.id,
data: machine,
});
return (
<div
ref={setNodeRef}
className={cn(
'flex items-center w-full gap-2 py-1',
'hover:bg-headplane-50 dark:hover:bg-headplane-950 rounded-xl',
)}
style={{
transform: transform
? `translate3d(${transform.x.toString()}px, ${transform.y.toString()}px, 0)`
: undefined,
}}
{...listeners}
{...attributes}
>
<StatusCircle isOnline={machine.online} className="px-1 h-4 w-fit" />
<Attribute
name={machine.givenName}
link={`machines/${machine.id}`}
value={machine.ipAddresses[0]}
/>
</div>
);
}
interface CardProps {
user: UserMachine;
magic?: string;
}
function UserCard({ user, magic }: CardProps) {
const { isOver, setNodeRef } = useDroppable({
id: user.name,
});
return (
<div ref={setNodeRef}>
<Card
variant="flat"
className={cn(
'max-w-full w-full overflow-visible h-full',
isOver ? 'bg-headplane-100 dark:bg-headplane-800' : '',
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<PersonIcon className="w-6 h-6" />
<span className="text-lg font-mono">{user.name}</span>
</div>
<div className="flex items-center gap-2">
<RenameUser user={user} />
{user.machines.length === 0 ? (
<DeleteUser user={user} />
) : undefined}
</div>
</div>
<div className="mt-4">
{user.machines.map((machine) => (
<MachineChip key={machine.id} machine={machine} />
))}
</div>
</Card>
</div>
);
}
export function ErrorBoundary() {
return <ErrorPopup type="embedded" />;
}
+42 -10
View File
@@ -1,13 +1,20 @@
import { ActionFunctionArgs, data } from 'react-router';
import { ActionFunctionArgs, Session, data } from 'react-router';
import type { LoadContext } from '~/server';
import { Capabilities, Roles } from '~/server/web/roles';
import { AuthSession } from '~/server/web/sessions';
import { User } from '~/types';
export async function userAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const apiKey = session.get('api_key')!;
const check = await context.sessions.check(request, Capabilities.write_users);
if (!check) {
return data({ success: false }, 403);
}
const apiKey = session.get('api_key')!;
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
@@ -21,8 +28,8 @@ export async function userAction({
return deleteUser(formData, apiKey, context);
case 'rename_user':
return renameUser(formData, apiKey, context);
case 'change_owner':
return changeOwner(formData, apiKey, context);
case 'reassign_user':
return reassignUser(formData, apiKey, context, session);
default:
return data({ success: false }, 400);
}
@@ -75,18 +82,43 @@ async function renameUser(
await context.client.post(`v1/user/${userId}/rename/${newName}`, apiKey);
}
async function changeOwner(
async function reassignUser(
formData: FormData,
apiKey: string,
context: LoadContext,
session: Session<AuthSession, unknown>,
) {
const userId = formData.get('user_id')?.toString();
const nodeId = formData.get('node_id')?.toString();
if (!userId || !nodeId) {
const newRole = formData.get('new_role')?.toString();
if (!userId || !newRole) {
return data({ success: false }, 400);
}
await context.client.post(`v1/node/${nodeId}/user`, apiKey, {
user: userId,
});
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
apiKey,
);
const user = users.find((user) => user.id === userId);
if (!user?.providerId) {
return data({ success: false }, 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) {
return data({ success: false }, 400);
}
const result = await context.sessions.reassignSubject(
subject,
newRole as keyof typeof Roles,
);
if (!result) {
return data({ success: false }, 403);
}
return data({ success: true });
}
+7
View File
@@ -8,11 +8,18 @@ many side-effects (in this case, importing a module may run code).
server
├── index.ts: Loads everything and starts the web server.
├── config/
│ ├── integration/
│ │ ├── abstract.ts: Defines the abstract class for integrations.
│ │ ├── docker.ts: Contains the Docker integration.
│ │ ├── index.ts: Determines the correct integration to use (if any).
│ │ ├── kubernetes.ts: Contains the Kubernetes integration.
│ │ ├── proc.ts: Contains the Proc integration.
│ ├── env.ts: Checks the environment variables for custom overrides.
│ ├── loader.ts: Checks the configuration file and coalesces with ENV.
│ ├── schema.ts: Defines the schema for the Headplane configuration.
├── headscale/
│ ├── api-client.ts: Creates the HTTP client that talks to the Headscale API.
│ ├── api-error.ts: Contains the ResponseError definition.
│ ├── config-loader.ts: Loads the Headscale configuration (if available).
│ ├── config-schema.ts: Defines the schema for the Headscale configuration.
├── web/
@@ -1,3 +1,5 @@
import type { ApiClient } from '~/server/headscale/api-client';
export abstract class Integration<T> {
protected context: NonNullable<T>;
constructor(context: T) {
@@ -9,6 +11,6 @@ export abstract class Integration<T> {
}
abstract isAvailable(): Promise<boolean> | boolean;
abstract onConfigChange(): Promise<void> | void;
abstract onConfigChange(client: ApiClient): Promise<void> | void;
abstract get name(): string;
}
@@ -1,9 +1,9 @@
import { constants, access } from 'node:fs/promises';
import { setTimeout } from 'node:timers/promises';
import { Client } from 'undici';
import { HeadscaleError, healthcheck, pull } from '~/utils/headscale';
import { HeadplaneConfig } from '~server/context/parser';
import log from '~server/utils/log';
import { ApiClient } from '~/server/headscale/api-client';
import log from '~/utils/log';
import type { HeadplaneConfig } from '../schema';
import { Integration } from './abstract';
type T = NonNullable<HeadplaneConfig['integration']>['docker'];
@@ -17,21 +17,25 @@ export default class DockerIntegration extends Integration<T> {
async isAvailable() {
if (this.context.container_name.length === 0) {
log.error('INTG', 'Docker container name is empty');
log.error('config', 'Docker container name is empty');
return false;
}
log.info('INTG', 'Using container: %s', this.context.container_name);
log.info('config', 'Using container: %s', this.context.container_name);
let url: URL | undefined;
try {
url = new URL(this.context.socket);
} catch {
log.error('INTG', 'Invalid Docker socket path: %s', this.context.socket);
log.error(
'config',
'Invalid Docker socket path: %s',
this.context.socket,
);
return false;
}
if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') {
log.error('INTG', 'Invalid Docker socket protocol: %s', url.protocol);
log.error('config', 'Invalid Docker socket protocol: %s', url.protocol);
return false;
}
@@ -42,11 +46,11 @@ export default class DockerIntegration extends Integration<T> {
const fetchU = url.href.replace(url.protocol, 'http:');
try {
log.info('INTG', 'Checking API: %s', fetchU);
log.info('config', 'Checking API: %s', fetchU);
await fetch(new URL('/v1.30/version', fetchU).href);
} catch (error) {
log.error('INTG', 'Failed to connect to Docker API: %s', error);
log.debug('INTG', 'Connection error: %o', error);
log.error('config', 'Failed to connect to Docker API: %s', error);
log.debug('config', 'Connection error: %o', error);
return false;
}
@@ -56,11 +60,11 @@ export default class DockerIntegration extends Integration<T> {
// Check if the socket is accessible
if (url.protocol === 'unix:') {
try {
log.info('INTG', 'Checking socket: %s', url.pathname);
log.info('config', 'Checking socket: %s', url.pathname);
await access(url.pathname, constants.R_OK);
} catch (error) {
log.error('INTG', 'Failed to access Docker socket: %s', url.pathname);
log.debug('INTG', 'Access error: %o', error);
log.error('config', 'Failed to access Docker socket: %s', url.pathname);
log.debug('config', 'Access error: %o', error);
return false;
}
@@ -72,17 +76,17 @@ export default class DockerIntegration extends Integration<T> {
return this.client !== undefined;
}
async onConfigChange() {
async onConfigChange(client: ApiClient) {
if (!this.client) {
return;
}
log.info('INTG', 'Restarting Headscale via Docker');
log.info('config', 'Restarting Headscale via Docker');
let attempts = 0;
while (attempts <= this.maxAttempts) {
log.debug(
'INTG',
'config',
'Restarting container: %s (attempt %d)',
this.context.container_name,
attempts,
@@ -111,19 +115,15 @@ export default class DockerIntegration extends Integration<T> {
attempts = 0;
while (attempts <= this.maxAttempts) {
try {
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts);
await healthcheck();
log.info('INTG', 'Headscale is up and running');
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
const status = await client.healthcheck();
if (status === false) {
throw new Error('Headscale is not running');
}
log.info('config', 'Headscale is up and running');
return;
} catch (error) {
if (error instanceof HeadscaleError && error.status === 401) {
break;
}
if (error instanceof HeadscaleError && error.status === 404) {
break;
}
if (attempts < this.maxAttempts) {
attempts++;
await setTimeout(1000);
@@ -131,7 +131,7 @@ export default class DockerIntegration extends Integration<T> {
}
log.error(
'INTG',
'config',
'Missed restart deadline for %s',
this.context.container_name,
);
+62
View File
@@ -0,0 +1,62 @@
import { HeadplaneConfig } from '~/server/config/schema';
import log from '~/utils/log';
import dockerIntegration from './docker';
import kubernetesIntegration from './kubernetes';
import procIntegration from './proc';
export async function loadIntegration(context: HeadplaneConfig['integration']) {
const integration = getIntegration(context);
if (!integration) {
return;
}
try {
const res = await integration.isAvailable();
if (!res) {
log.error('config', 'Integration %s is not available', integration);
return;
}
} catch (error) {
log.error(
'config',
'Failed to load integration %s: %s',
integration,
error,
);
log.debug('config', 'Loading error: %o', error);
return;
}
return integration;
}
function getIntegration(integration: HeadplaneConfig['integration']) {
const docker = integration?.docker;
const k8s = integration?.kubernetes;
const proc = integration?.proc;
if (!docker?.enabled && !k8s?.enabled && !proc?.enabled) {
log.debug('config', 'No integrations enabled');
return;
}
if (docker?.enabled && k8s?.enabled && proc?.enabled) {
log.error('config', 'Multiple integrations enabled, please pick one only');
return;
}
if (docker?.enabled) {
log.info('config', 'Using Docker integration');
return new dockerIntegration(integration?.docker);
}
if (k8s?.enabled) {
log.info('config', 'Using Kubernetes integration');
return new kubernetesIntegration(integration?.kubernetes);
}
if (proc?.enabled) {
log.info('config', 'Using Proc integration');
return new procIntegration(integration?.proc);
}
}
@@ -4,9 +4,9 @@ import { join, resolve } from 'node:path';
import { kill } from 'node:process';
import { setTimeout } from 'node:timers/promises';
import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node';
import { HeadscaleError, healthcheck } from '~/utils/headscale';
import { HeadplaneConfig } from '~server/context/parser';
import log from '~server/utils/log';
import { ApiClient } from '~/server/headscale/api-client';
import log from '~/utils/log';
import { HeadplaneConfig } from '../schema';
import { Integration } from './abstract';
// TODO: Upgrade to the new CoreV1Api from @kubernetes/client-node
@@ -21,16 +21,16 @@ export default class KubernetesIntegration extends Integration<T> {
async isAvailable() {
if (platform() !== 'linux') {
log.error('INTG', 'Kubernetes is only available on Linux');
log.error('config', 'Kubernetes is only available on Linux');
return false;
}
const svcRoot = Config.SERVICEACCOUNT_ROOT;
try {
log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot);
log.debug('config', 'Checking Kubernetes service account at %s', svcRoot);
const files = await readdir(svcRoot);
if (files.length === 0) {
log.error('INTG', 'Kubernetes service account not found');
log.error('config', 'Kubernetes service account not found');
return false;
}
@@ -41,17 +41,17 @@ export default class KubernetesIntegration extends Integration<T> {
Config.SERVICEACCOUNT_NAMESPACE_PATH,
];
log.debug('INTG', 'Looking for %s', expectedFiles.join(', '));
log.debug('config', 'Looking for %s', expectedFiles.join(', '));
if (!expectedFiles.every((file) => mappedFiles.has(file))) {
log.error('INTG', 'Malformed Kubernetes service account');
log.error('config', 'Malformed Kubernetes service account');
return false;
}
} catch (error) {
log.error('INTG', 'Failed to access %s: %s', svcRoot, error);
log.error('config', 'Failed to access %s: %s', svcRoot, error);
return false;
}
log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot);
log.debug('config', 'Reading Kubernetes service account at %s', svcRoot);
const namespace = await readFile(
Config.SERVICEACCOUNT_NAMESPACE_PATH,
'utf8',
@@ -59,39 +59,39 @@ export default class KubernetesIntegration extends Integration<T> {
// Some very ugly nesting but it's necessary
if (this.context.validate_manifest === false) {
log.warn('INTG', 'Skipping strict Pod status check');
log.warn('config', 'Skipping strict Pod status check');
} else {
const pod = this.context.pod_name;
if (!pod) {
log.error('INTG', 'Missing POD_NAME variable');
log.error('config', 'Missing POD_NAME variable');
return false;
}
if (pod.trim().length === 0) {
log.error('INTG', 'Pod name is empty');
log.error('config', 'Pod name is empty');
return false;
}
log.debug(
'INTG',
'config',
'Checking Kubernetes pod %s in namespace %s',
pod,
namespace,
);
try {
log.debug('INTG', 'Attempgin to get cluster KubeConfig');
log.debug('config', 'Attempgin to get cluster KubeConfig');
const kc = new KubeConfig();
kc.loadFromCluster();
const cluster = kc.getCurrentCluster();
if (!cluster) {
log.error('INTG', 'Malformed kubeconfig');
log.error('config', 'Malformed kubeconfig');
return false;
}
log.info(
'INTG',
'config',
'Service account connected to %s (%s)',
cluster.name,
cluster.server,
@@ -100,14 +100,14 @@ export default class KubernetesIntegration extends Integration<T> {
const kCoreV1Api = kc.makeApiClient(CoreV1Api);
log.info(
'INTG',
'config',
'Checking pod %s in namespace %s (%s)',
pod,
namespace,
kCoreV1Api.basePath,
);
log.debug('INTG', 'Reading pod info for %s', pod);
log.debug('config', 'Reading pod info for %s', pod);
const { response, body } = await kCoreV1Api.readNamespacedPod(
pod,
namespace,
@@ -115,36 +115,39 @@ export default class KubernetesIntegration extends Integration<T> {
if (response.statusCode !== 200) {
log.error(
'INTG',
'config',
'Failed to read pod info: http %d',
response.statusCode,
);
return false;
}
log.debug('INTG', 'Got pod info: %o', body.spec);
log.debug('config', 'Got pod info: %o', body.spec);
const shared = body.spec?.shareProcessNamespace;
if (shared === undefined) {
log.error('INTG', 'Pod does not have spec.shareProcessNamespace set');
log.error(
'config',
'Pod does not have spec.shareProcessNamespace set',
);
return false;
}
if (!shared) {
log.error(
'INTG',
'config',
'Pod has set but disabled spec.shareProcessNamespace',
);
return false;
}
log.info('INTG', 'Pod %s enabled shared processes', pod);
log.info('config', 'Pod %s enabled shared processes', pod);
} catch (error) {
log.error('INTG', 'Failed to read pod info: %s', error);
log.error('config', 'Failed to read pod info: %s', error);
return false;
}
}
log.debug('INTG', 'Looking for namespaced process in /proc');
log.debug('config', 'Looking for namespaced process in /proc');
const dir = resolve('/proc');
try {
const subdirs = await readdir(dir);
@@ -157,13 +160,13 @@ export default class KubernetesIntegration extends Integration<T> {
const path = join('/proc', dir, 'cmdline');
try {
log.debug('INTG', 'Reading %s', path);
log.debug('config', 'Reading %s', path);
const data = await readFile(path, 'utf8');
if (data.includes('headscale')) {
return pid;
}
} catch (error) {
log.debug('INTG', 'Failed to read %s: %s', path, error);
log.debug('config', 'Failed to read %s: %s', path, error);
}
});
@@ -176,10 +179,10 @@ export default class KubernetesIntegration extends Integration<T> {
}
}
log.debug('INTG', 'Found Headscale processes: %o', pids);
log.debug('config', 'Found Headscale processes: %o', pids);
if (pids.length > 1) {
log.error(
'INTG',
'config',
'Found %d Headscale processes: %s',
pids.length,
pids.join(', '),
@@ -188,49 +191,45 @@ export default class KubernetesIntegration extends Integration<T> {
}
if (pids.length === 0) {
log.error('INTG', 'Could not find Headscale process');
log.error('config', 'Could not find Headscale process');
return false;
}
this.pid = pids[0];
log.info('INTG', 'Found Headscale process with PID: %d', this.pid);
log.info('config', 'Found Headscale process with PID: %d', this.pid);
return true;
} catch {
log.error('INTG', 'Failed to read /proc');
log.error('config', 'Failed to read /proc');
return false;
}
}
async onConfigChange() {
async onConfigChange(client: ApiClient) {
if (!this.pid) {
return;
}
try {
log.info('INTG', 'Sending SIGTERM to Headscale');
log.info('config', 'Sending SIGTERM to Headscale');
kill(this.pid, 'SIGTERM');
} catch (error) {
log.error('INTG', 'Failed to send SIGTERM to Headscale: %s', error);
log.debug('INTG', 'kill(1) error: %o', error);
log.error('config', 'Failed to send SIGTERM to Headscale: %s', error);
log.debug('config', 'kill(1) error: %o', error);
}
await setTimeout(1000);
let attempts = 0;
while (attempts <= this.maxAttempts) {
try {
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts);
await healthcheck();
log.info('INTG', 'Headscale is up and running');
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
const status = await client.healthcheck();
if (status === false) {
throw new Error('Headscale is not running');
}
log.info('config', 'Headscale is up and running');
return;
} catch (error) {
if (error instanceof HeadscaleError && error.status === 401) {
break;
}
if (error instanceof HeadscaleError && error.status === 404) {
break;
}
if (attempts < this.maxAttempts) {
attempts++;
await setTimeout(1000);
@@ -238,7 +237,7 @@ export default class KubernetesIntegration extends Integration<T> {
}
log.error(
'INTG',
'config',
'Missed restart deadline for Headscale (pid %d)',
this.pid,
);
@@ -3,9 +3,9 @@ import { platform } from 'node:os';
import { join, resolve } from 'node:path';
import { kill } from 'node:process';
import { setTimeout } from 'node:timers/promises';
import { HeadscaleError, healthcheck } from '~/utils/headscale';
import { HeadplaneConfig } from '~server/context/parser';
import log from '~server/utils/log';
import { ApiClient } from '~/server/headscale/api-client';
import log from '~/utils/log';
import { HeadplaneConfig } from '../schema';
import { Integration } from './abstract';
type T = NonNullable<HeadplaneConfig['integration']>['proc'];
@@ -19,11 +19,11 @@ export default class ProcIntegration extends Integration<T> {
async isAvailable() {
if (platform() !== 'linux') {
log.error('INTG', '/proc is only available on Linux');
log.error('config', '/proc is only available on Linux');
return false;
}
log.debug('INTG', 'Checking /proc for Headscale process');
log.debug('config', 'Checking /proc for Headscale process');
const dir = resolve('/proc');
try {
const subdirs = await readdir(dir);
@@ -36,13 +36,13 @@ export default class ProcIntegration extends Integration<T> {
const path = join('/proc', dir, 'cmdline');
try {
log.debug('INTG', 'Reading %s', path);
log.debug('config', 'Reading %s', path);
const data = await readFile(path, 'utf8');
if (data.includes('headscale')) {
return pid;
}
} catch (error) {
log.error('INTG', 'Failed to read %s: %s', path, error);
log.error('config', 'Failed to read %s: %s', path, error);
}
});
@@ -55,10 +55,10 @@ export default class ProcIntegration extends Integration<T> {
}
}
log.debug('INTG', 'Found Headscale processes: %o', pids);
log.debug('config', 'Found Headscale processes: %o', pids);
if (pids.length > 1) {
log.error(
'INTG',
'config',
'Found %d Headscale processes: %s',
pids.length,
pids.join(', '),
@@ -67,49 +67,46 @@ export default class ProcIntegration extends Integration<T> {
}
if (pids.length === 0) {
log.error('INTG', 'Could not find Headscale process');
log.error('config', 'Could not find Headscale process');
return false;
}
this.pid = pids[0];
log.info('INTG', 'Found Headscale process with PID: %d', this.pid);
log.info('config', 'Found Headscale process with PID: %d', this.pid);
return true;
} catch {
log.error('INTG', 'Failed to read /proc');
log.error('config', 'Failed to read /proc');
return false;
}
}
async onConfigChange() {
async onConfigChange(client: ApiClient) {
if (!this.pid) {
return;
}
try {
log.info('INTG', 'Sending SIGTERM to Headscale');
log.info('config', 'Sending SIGTERM to Headscale');
kill(this.pid, 'SIGTERM');
} catch (error) {
log.error('INTG', 'Failed to send SIGTERM to Headscale: %s', error);
log.debug('INTG', 'kill(1) error: %o', error);
log.error('config', 'Failed to send SIGTERM to Headscale: %s', error);
log.debug('config', 'kill(1) error: %o', error);
}
await setTimeout(1000);
let attempts = 0;
while (attempts <= this.maxAttempts) {
try {
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts);
await healthcheck();
log.info('INTG', 'Headscale is up and running');
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
const status = await client.healthcheck();
if (status === false) {
log.error('config', 'Headscale is not running');
return;
}
log.info('config', 'Headscale is up and running');
return;
} catch (error) {
if (error instanceof HeadscaleError && error.status === 401) {
break;
}
if (error instanceof HeadscaleError && error.status === 404) {
break;
}
if (attempts < this.maxAttempts) {
attempts++;
await setTimeout(1000);
@@ -117,7 +114,7 @@ export default class ProcIntegration extends Integration<T> {
}
log.error(
'INTG',
'config',
'Missed restart deadline for Headscale (pid %d)',
this.pid,
);
+2 -18
View File
@@ -1,6 +1,7 @@
import { readFile } from 'node:fs/promises';
import { Agent, Dispatcher, request } from 'undici';
import log from '~/utils/log';
import ResponseError from './api-error';
export async function createApiClient(base: string, certPath?: string) {
if (!certPath) {
@@ -20,24 +21,7 @@ export async function createApiClient(base: string, certPath?: string) {
}
}
// Represents an error that occurred during a response
// Thrown when status codes are >= 400
export class ResponseError extends Error {
status: number;
response: string;
constructor(status: number, response: string) {
super(`Response Error (${status}): ${response}`);
this.name = 'ResponseError';
this.status = status;
this.response = response;
}
}
// Represents an error that occurred during a request
// class RequestError extends Error {
class ApiClient {
export class ApiClient {
private agent: Agent;
private base: string;
+19
View File
@@ -0,0 +1,19 @@
// Represents an error that occurred during a response
// Thrown when status codes are >= 400
export default class ResponseError extends Error {
status: number;
response: string;
responseObject?: Record<string, unknown>;
constructor(status: number, response: string) {
super(`Response Error (${status}): ${response}`);
this.name = 'ResponseError';
this.status = status;
this.response = response;
try {
// Try to parse the response as JSON to get a response object
this.responseObject = JSON.parse(response);
} catch {}
}
}
+2
View File
@@ -4,6 +4,7 @@ import { createHonoServer } from 'react-router-hono-server/node';
import type { WebSocket } from 'ws';
import log from '~/utils/log';
import { configureConfig, configureLogger, envVariables } from './config/env';
import { loadIntegration } from './config/integration';
import { loadConfig } from './config/loader';
import { createApiClient } from './headscale/api-client';
import { loadHeadscaleConfig } from './headscale/config-loader';
@@ -61,6 +62,7 @@ const appLoadContext = {
config.server.agent.ttl,
),
integration: await loadIntegration(config.integration),
oidc: config.oidc ? await createOidcClient(config.oidc) : undefined,
};
+8 -8
View File
@@ -3,10 +3,10 @@ export const Capabilities = {
// Can access the admin console
ui_access: 1 << 0,
// Read tailnet policy file
// Read tailnet policy file (unimplemented)
read_policy: 1 << 1,
// Write tailnet policy file
// Write tailnet policy file (unimplemented)
write_policy: 1 << 2,
// Read network configurations
@@ -16,13 +16,13 @@ export const Capabilities = {
// make subnet, or allow a node to be an exit node, enable HTTPS
write_network: 1 << 4,
// Read feature configuration
// Read feature configuration (unimplemented)
read_feature: 1 << 5,
// Write feature configuration, for example, enable Taildrop
// Write feature configuration, for example, enable Taildrop (unimplemented)
write_feature: 1 << 6,
// Configure user & group provisioning
// Configure user & group provisioning (unimplemented)
configure_iam: 1 << 7,
// Read machines, for example, see machine names and status
@@ -38,13 +38,13 @@ export const Capabilities = {
// approve users, make Admin
write_users: 1 << 11,
// Can generate authkeys
// Can generate authkeys (unimplemented)
generate_authkeys: 1 << 12,
// Can use any tag (without being tag owner)
// Can use any tag (without being tag owner) (unimplemented)
use_tags: 1 << 13,
// Write tailnet name
// Write tailnet name (unimplemented)
write_tailnet: 1 << 14,
// Owner flag
+87 -13
View File
@@ -47,12 +47,12 @@ interface CookieOptions {
class Sessionizer {
private storage: SessionStorage<JoinedSession, Error>;
private caps: Record<string, Capabilities>;
private caps: Record<string, { c: Capabilities; oo?: boolean }>;
private capsPath?: string;
constructor(
options: CookieOptions,
caps: Record<string, Capabilities>,
caps: Record<string, { c: Capabilities; oo?: boolean }>,
capsPath?: string,
) {
this.caps = caps;
@@ -85,16 +85,24 @@ class Sessionizer {
return session as Session<AuthSession, Error>;
}
roleForSubject(subject: string) {
const role = this.caps[subject];
roleForSubject(subject: string): keyof typeof Roles | undefined {
const role = this.caps[subject]?.c;
if (!role) {
return;
}
// 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;
return key as keyof typeof Roles;
}
}
}
onboardForSubject(subject: string) {
return this.caps[subject]?.oo ?? false;
}
// 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) {
@@ -120,10 +128,33 @@ class Sessionizer {
const role = this.caps[subject];
if (!role) {
const memberRole = await this.registerSubject(subject);
return (capabilities & memberRole) === capabilities;
return (capabilities & memberRole.c) === capabilities;
}
return (capabilities & role) === capabilities;
return (capabilities & role.c) === capabilities;
}
async checkSubject(subject: string, capabilities: Capabilities) {
// 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.c) === capabilities;
}
return (capabilities & role.c) === capabilities;
}
// This code is very simple, if the user does not exist in the database
@@ -137,13 +168,13 @@ class Sessionizer {
if (Object.keys(this.caps).length === 0) {
log.debug('auth', 'First user registered as owner: %s', subject);
this.caps[subject] = Roles.owner;
this.caps[subject] = { c: Roles.owner };
await this.flushUserDatabase();
return this.caps[subject];
}
log.debug('auth', 'New user registered as member: %s', subject);
this.caps[subject] = Roles.member;
this.caps[subject] = { c: Roles.member };
await this.flushUserDatabase();
return this.caps[subject];
}
@@ -153,7 +184,11 @@ class Sessionizer {
return;
}
const data = Object.entries(this.caps).map(([u, c]) => ({ u, c }));
const data = Object.entries(this.caps).map(([u, { c, oo }]) => ({
u,
c,
oo,
}));
try {
const handle = await open(this.capsPath, 'w');
await handle.write(JSON.stringify(data));
@@ -163,6 +198,31 @@ class Sessionizer {
}
}
// Updates the capabilities and roles of a subject
async reassignSubject(subject: string, role: keyof typeof Roles) {
// Check if we are owner
if (this.roleForSubject(subject) === 'owner') {
return false;
}
this.caps[subject] = {
...this.caps[subject], // Preserve the existing capabilities if any
c: Roles[role],
};
await this.flushUserDatabase();
return true;
}
// Overrides the onboarding status for a subject
async overrideOnboarding(subject: string, onboarding: boolean) {
this.caps[subject] = {
...this.caps[subject], // Preserve the existing capabilities if any
oo: onboarding,
};
await this.flushUserDatabase();
}
getOrCreate<T extends JoinedSession = AuthSession>(request: Request) {
return this.storage.getSession(request.headers.get('cookie')) as Promise<
Session<T, Error>
@@ -182,7 +242,13 @@ export async function createSessionStorage(
options: CookieOptions,
usersPath?: string,
) {
const map: Record<string, Capabilities> = {};
const map: Record<
string,
{
c: number;
oo?: boolean;
}
> = {};
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
@@ -191,7 +257,10 @@ export async function createSessionStorage(
log.debug('config', 'Loaded %d users from database', data.length);
for (const user of data) {
map[user.u] = user.c;
map[user.u] = {
c: user.c,
oo: user.oo,
};
}
}
@@ -213,7 +282,11 @@ async function loadUserFile(path: string) {
try {
const data = await readFile(realPath, 'utf8');
const users = JSON.parse(data.trim()) as { u?: string; c?: number }[];
const users = JSON.parse(data.trim()) as {
u?: string;
c?: number;
oo?: boolean;
}[];
// Never trust user input
return users.filter(
@@ -221,6 +294,7 @@ async function loadUserFile(path: string) {
) as {
u: string;
c: number;
oo?: boolean;
}[];
} catch (error) {
log.debug('config', 'Error reading user database file: %s', error);
-69
View File
@@ -1,69 +0,0 @@
import { HeadplaneConfig } from '~/server/config/schema';
import log from '~/utils/log';
import { Integration } from './abstract';
// import dockerIntegration from './docker';
// import kubernetesIntegration from './kubernetes';
// import procIntegration from './proc';
const runtimeIntegration: Integration<unknown> | undefined = undefined;
export function hp_getIntegration() {
return runtimeIntegration;
}
export async function hp_loadIntegration(
context: HeadplaneConfig['integration'],
) {
// const integration = getIntegration(context);
// if (!integration) {
// return;
// }
// try {
// const res = await integration.isAvailable();
// if (!res) {
// log.error('INTG', 'Integration %s is not available', integration);
// return;
// }
// } catch (error) {
// log.error('INTG', 'Failed to load integration %s: %s', integration, error);
// log.debug('INTG', 'Loading error: %o', error);
// return;
// }
// runtimeIntegration = integration;
}
function getIntegration(integration: HeadplaneConfig['integration']) {
const docker = integration?.docker;
const k8s = integration?.kubernetes;
const proc = integration?.proc;
if (!docker?.enabled && !k8s?.enabled && !proc?.enabled) {
log.debug('INTG', 'No integrations enabled');
return;
}
if (docker?.enabled && k8s?.enabled && proc?.enabled) {
log.error('INTG', 'Multiple integrations enabled, please pick one only');
return;
}
// if (docker?.enabled) {
// log.info('INTG', 'Using Docker integration');
// return new dockerIntegration(integration?.docker);
// }
// if (k8s?.enabled) {
// log.info('INTG', 'Using Kubernetes integration');
// return new kubernetesIntegration(integration?.kubernetes);
// }
// if (proc?.enabled) {
// log.info('INTG', 'Using Proc integration');
// return new procIntegration(integration?.proc);
// }
}
// IMPORTANT THIS IS A SIDE EFFECT ON INIT
// TODO: Switch this to the new singleton system
// const context = hp_getConfig();
// hp_loadIntegration(context.integration);
+5 -5
View File
@@ -14,15 +14,15 @@ export function getRedirectUri(req: Request) {
}
if (!host) {
log.error('OIDC', 'Unable to find a host header');
log.error('OIDC', 'Ensure either Host or X-Forwarded-Host is set');
log.error('auth', 'Unable to find a host header');
log.error('auth', 'Ensure either Host or X-Forwarded-Host is set');
throw new Error('Could not determine reverse proxy host');
}
const proto = req.headers.get('X-Forwarded-Proto');
if (!proto) {
log.warn('OIDC', 'No X-Forwarded-Proto header found');
log.warn('OIDC', 'Assuming your Headplane instance runs behind HTTP');
log.warn('auth', 'No X-Forwarded-Proto header found');
log.warn('auth', 'Assuming your Headplane instance runs behind HTTP');
}
url.protocol = proto ?? 'http:';
@@ -186,7 +186,7 @@ export function formatError(error: unknown) {
};
}
log.error('OIDC', 'Unknown error: %s', error);
log.error('auth', 'Unknown error: %s', error);
return {
code: 500,
error: {
+30
View File
@@ -7,3 +7,33 @@ export function send<T>(payload: T, init?: number | ResponseInit) {
export function send401<T>(payload: T) {
return data(payload, { status: 401 });
}
export function data400(message: string) {
return data(
{
success: false,
message,
},
{ status: 400 },
);
}
export function data403(message: string) {
return data(
{
success: false,
message,
},
{ status: 403 },
);
}
export function data404(message: string) {
return data(
{
success: false,
message,
},
{ status: 404 },
);
}
+17 -17
View File
@@ -3,21 +3,21 @@
# I ONLY USE IT FOR DEVELOPING HEADPLANE
networks:
headplane-dev:
name: "headplane-dev"
driver: "bridge"
headplane-dev:
name: "headplane-dev"
driver: "bridge"
services:
headscale:
image: "headscale/headscale:0.25.0"
container_name: "headscale"
restart: "unless-stopped"
command: "serve"
networks:
- "headplane-dev"
volumes:
- "./.cache/headscale:/var/lib/headscale"
- "./test:/etc/headscale"
ports:
- "8080:8080"
environment:
TZ: "America/New_York"
headscale:
image: "headscale/headscale:0.25.1"
container_name: "headscale"
restart: "unless-stopped"
command: "serve"
networks:
- "headplane-dev"
volumes:
- "./.cache/headscale:/var/lib/headscale"
- "./test:/etc/headscale"
ports:
- "8080:8080"
environment:
TZ: "America/New_York"
+10
View File
@@ -16,6 +16,16 @@ Requirements:
- [PNPM](https://pnpm.io/installation) 10.x
- A finished configuration file (config.yaml)
Before installing Headplane, ensure that `/var/lib/headplane` exists and is
writable by the user that will run the Headplane service. You can create this
directory with the following command:
```sh
sudo mkdir -p /var/lib/headplane
# Replace headplane:headplane with the appropriate user and group if not root.
sudo chown -R headplane:headplane /var/lib/headplane
```
Clone the Headplane repository, install dependencies, and build the project:
```sh
git clone https://github.com/tale/headplane
+4
View File
@@ -7,6 +7,10 @@ Headplane uses a configuration file to manage its settings
for a the file at `/etc/headplane/config.yaml`. This can be changed using the
**`HEADPLANE_CONFIG_PATH`** environment variable to point to a different location.
Headplane also stores stuff in the `/var/lib/headplane` directory by default.
This can be configured on a per-section basis in the configuration file, but
it is very important this directory is persistent and writable by Headplane.
## Environment Variables
It is also possible to override the configuration file using environment variables.
These changes get merged *after* the configuration file is loaded, so they will take precedence.
+12 -4
View File
@@ -34,7 +34,7 @@ Here is what a sample Docker Compose deployment would look like:
services:
headplane:
# I recommend you pin the version to a specific release
image: ghcr.io/tale/headplane:0.5.1
image: ghcr.io/tale/headplane:0.5.8
container_name: headplane
restart: unless-stopped
ports:
@@ -44,10 +44,13 @@ services:
# This should match headscale.config_path in your config.yaml
- './headscale-config/config.yaml:/etc/headscale/config.yaml'
# Headplane stores its data in this directory
- './headplane-data:/var/lib/headplane'
# If you are using the Docker integration, mount the Docker socket
- '/var/run/docker.sock:/var/run/docker.sock:ro'
headscale:
image: headscale/headscale:0.25.0
image: headscale/headscale:0.25.1
container_name: headscale
restart: unless-stopped
command: serve
@@ -148,7 +151,7 @@ spec:
serviceAccountName: default
containers:
- name: headplane
image: ghcr.io/tale/headplane:0.5.1
image: ghcr.io/tale/headplane:0.5.8
env:
# Set these if the pod name for Headscale is not static
# We will use the downward API to get the pod name instead
@@ -161,9 +164,11 @@ spec:
volumeMounts:
- name: headscale-config
mountPath: /etc/headscale
- name: headplane-data
mountPath: /var/lib/headplane
- name: headscale
image: headscale/headscale:0.25.0
image: headscale/headscale:0.25.1
command: ['serve']
volumeMounts:
- name: headscale-data
@@ -175,6 +180,9 @@ spec:
- name: headscale-data
persistentVolumeClaim:
claimName: headscale-data
- name: headplane-data
persistentVolumeClaim:
claimName: headplane-data
- name: headscale-config
persistentVolumeClaim:
claimName: headscale-config
+2 -1
View File
@@ -19,13 +19,14 @@ Here is what a sample Docker Compose deployment would look like:
services:
headplane:
# I recommend you pin the version to a specific release
image: ghcr.io/tale/headplane:0.5.1
image: ghcr.io/tale/headplane:0.5.8
container_name: headplane
restart: unless-stopped
ports:
- '3000:3000'
volumes:
- './config.yaml:/etc/headplane/config.yaml'
- './headplane-data:/var/lib/headplane'
```
This will result in the Headplane UI being available at the `/admin` path of the
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "headplane",
"private": true,
"sideEffects": false,
"version": "0.5.7",
"version": "0.5.8",
"type": "module",
"scripts": {
"build": "react-router build",
-71
View File
@@ -1,71 +0,0 @@
{
// Declare static groups of users. Use autogroups for all users or users with a specific role.
"groups": {
"group:admin": ["tale"],
"group:user": ["tale", "arjun"],
"group:proxmox": ["tale", "arjun"]
},
// Define the tags which can be applied to devices and by which users.
"tagOwners": {
"tag:infra": ["group:admin"],
"tag:hyperv": ["group:admin"],
"tag:vm": ["group:admin", "group:proxmox"]
},
"acls": [
{
"action": "accept",
"src": ["tag:vm", "tag:infra"],
"dst": ["tag:vm:*", "tag:infra:*"]
},
//{
// "action": "accept",
// "src": ["autogroup:member"],
// "dst": ["autogroup:internet:*"],
//},
// Proxmox members have access to all traffic from VMs (including SSH)
{
"action": "accept",
"src": ["group:proxmox"],
"dst": ["tag:vm:*"]
},
// Anyone can access VM traffic
{"action": "accept", "src": ["group:user"], "dst": ["tag:vm:*"]},
// Admins get to override any destination restrictions
{
"action": "accept",
"src": ["group:admin"],
"dst": ["*:*"]
},
],
"ssh": [
{
// Any Proxmox members can SSH into VMs
"action": "accept",
"src": ["group:proxmox"],
"dst": ["tag:vm"],
"users": ["*"]
},
{
// Any Admin can SSH into infrastructure
"action": "accept",
"src": ["group:admin"],
"dst": ["tag:infra"],
"users": ["*"]
}
],
// Test access rules every time they're saved.
// "tests": [
// {
// "src": "alice@example.com",
// "accept": ["tag:example"],
// "deny": ["100.101.102.103:443"],
// },
// ],
}
-379
View File
@@ -1,379 +0,0 @@
---
# headscale will look for a configuration file named `config.yaml` (or `config.json`) in the following order:
#
# - `/etc/headscale`
# - `~/.headscale`
# - current working directory
# The url clients will connect to.
# Typically this will be a domain like:
#
# https://myheadscale.example.com:443
#
server_url: http://localhost:8080
# Address to listen to / bind to on the server
#
# For production:
listen_addr: 0.0.0.0:8080
# Address to listen to /metrics, you may want
# to keep this endpoint private to your internal
# network
#
metrics_listen_addr: 127.0.0.1:9090
# Address to listen for gRPC.
# gRPC is used for controlling a headscale server
# remotely with the CLI
# Note: Remote access _only_ works if you have
# valid certificates.
#
# For production:
grpc_listen_addr: 0.0.0.0:50443
# Allow the gRPC admin interface to run in INSECURE
# mode. This is not recommended as the traffic will
# be unencrypted. Only enable if you know what you
# are doing.
grpc_allow_insecure: false
# Private key used to encrypt the traffic between headscale
# and Tailscale clients.
# The private key file will be autogenerated if it's missing.
#
private_key_path: /var/lib/headscale/private.key
# The Noise section includes specific configuration for the
# TS2021 Noise protocol
noise:
# The Noise private key is used to encrypt the
# traffic between headscale and Tailscale clients when
# using the new Noise-based protocol. It must be different
# from the legacy private key.
private_key_path: /var/lib/headscale/noise_private.key
# List of IP prefixes to allocate tailaddresses from.
# Each prefix consists of either an IPv4 or IPv6 address,
# and the associated prefix length, delimited by a slash.
# While this looks like it can take arbitrary values, it
# needs to be within IP ranges supported by the Tailscale
# client.
# IPv6: https://github.com/tailscale/tailscale/blob/22ebb25e833264f58d7c3f534a8b166894a89536/net/tsaddr/tsaddr.go#LL81C52-L81C71
# IPv4: https://github.com/tailscale/tailscale/blob/22ebb25e833264f58d7c3f534a8b166894a89536/net/tsaddr/tsaddr.go#L33
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
# DERP is a relay system that Tailscale uses when a direct
# connection cannot be established.
# https://tailscale.com/blog/how-tailscale-works/#encrypted-tcp-relays-derp
#
# headscale needs a list of DERP servers that can be presented
# to the clients.
derp:
server:
# If enabled, runs the embedded DERP server and merges it into the rest of the DERP config
# The Headscale server_url defined above MUST be using https, DERP requires TLS to be in place
enabled: false
# Region ID to use for the embedded DERP server.
# The local DERP prevails if the region ID collides with other region ID coming from
# the regular DERP config.
region_id: 999
# Region code and name are displayed in the Tailscale UI to identify a DERP region
region_code: "headscale"
region_name: "Headscale Embedded DERP"
# Listens over UDP at the configured address for STUN connections - to help with NAT traversal.
# When the embedded DERP server is enabled stun_listen_addr MUST be defined.
#
# For more details on how this works, check this great article: https://tailscale.com/blog/how-tailscale-works/
stun_listen_addr: "0.0.0.0:3478"
# List of externally available DERP maps encoded in JSON
urls:
- https://controlplane.tailscale.com/derpmap/default
# Locally available DERP map files encoded in YAML
#
# This option is mostly interesting for people hosting
# their own DERP servers:
# https://tailscale.com/kb/1118/custom-derp-servers/
#
# paths:
# - /etc/headscale/derp-example.yaml
paths: []
# If enabled, a worker will be set up to periodically
# refresh the given sources and update the derpmap
# will be set up.
auto_update_enabled: true
# How often should we check for DERP updates?
update_frequency: 24h
# Disables the automatic check for headscale updates on startup
disable_check_updates: false
# Time before an inactive ephemeral node is deleted?
ephemeral_node_inactivity_timeout: 30m
# Period to check for node updates within the tailnet. A value too low will severely affect
# CPU consumption of Headscale. A value too high (over 60s) will cause problems
# for the nodes, as they won't get updates or keep alive messages frequently enough.
# In case of doubts, do not touch the default 10s.
node_update_check_interval: 10s
database:
type: sqlite3
sqlite:
path: '/var/lib/headscale/db.sqlite'
# # Postgres config
# If using a Unix socket to connect to Postgres, set the socket path in the 'host' field and leave 'port' blank.
# db_type: postgres
# db_host: localhost
# db_port: 5432
# db_name: headscale
# db_user: foo
# db_pass: bar
# If other 'sslmode' is required instead of 'require(true)' and 'disabled(false)', set the 'sslmode' you need
# in the 'db_ssl' field. Refers to https://www.postgresql.org/docs/current/libpq-ssl.html Table 34.1.
# db_ssl: false
### TLS configuration
#
## Let's encrypt / ACME
#
# headscale supports automatically requesting and setting up
# TLS for a domain with Let's Encrypt.
#
# URL to ACME directory
acme_url: https://acme-v02.api.letsencrypt.org/directory
# Email to register with ACME provider
acme_email: ""
# Domain name to request a TLS certificate for:
tls_letsencrypt_hostname: ""
# Path to store certificates and metadata needed by
# letsencrypt
# For production:
tls_letsencrypt_cache_dir: /var/lib/headscale/cache
# Type of ACME challenge to use, currently supported types:
# HTTP-01 or TLS-ALPN-01
# See [docs/tls.md](docs/tls.md) for more information
tls_letsencrypt_challenge_type: HTTP-01
# When HTTP-01 challenge is chosen, letsencrypt must set up a
# verification endpoint, and it will be listening on:
# :http = port 80
tls_letsencrypt_listen: ":http"
## Use already defined certificates:
tls_cert_path: ""
tls_key_path: ""
log:
# Output formatting for logs: text or json
format: text
level: info
# Path to a file containg ACL policies.
# ACLs can be defined as YAML or HUJSON.
# https://tailscale.com/kb/1018/acls/
policy:
mode: 'database'
## DNS
#
# headscale supports Tailscale's DNS configuration and MagicDNS.
# Please have a look to their KB to better understand the concepts:
#
# - https://tailscale.com/kb/1054/dns/
# - https://tailscale.com/kb/1081/magicdns/
# - https://tailscale.com/blog/2021-09-private-dns-with-magicdns/
#
dns_config2:
# Whether to prefer using Headscale provided DNS or use local.
override_local_dns: true
# List of DNS servers to expose to clients.
nameservers:
- 1.1.1.1
- 1.0.0.1
- 2606:4700:4700::1111
- 2606:4700:4700::1001
# NextDNS (see https://tailscale.com/kb/1218/nextdns/).
# "abc123" is example NextDNS ID, replace with yours.
#
# With metadata sharing:
# nameservers:
# - https://dns.nextdns.io/abc123
#
# Without metadata sharing:
# nameservers:
# - 2a07:a8c0::ab:c123
# - 2a07:a8c1::ab:c123
# Split DNS (see https://tailscale.com/kb/1054/dns/),
# list of search domains and the DNS to query for each one.
#
# restricted_nameservers:
# foo.bar.com:
# - 1.1.1.1
# darp.headscale.net:
# - 1.1.1.1
# - 8.8.8.8
# Search domains to inject.
domains: []
# Extra DNS records
# so far only A-records are supported (on the tailscale side)
# See https://github.com/juanfont/headscale/blob/main/docs/dns-records.md#Limitations
# extra_records:
# - name: "grafana.myvpn.example.com"
# type: "A"
# value: "100.64.0.3"
#
# # you can also put it in one line
# - { name: "prometheus.myvpn.example.com", type: "A", value: "100.64.0.3" }
# Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/).
# Only works if there is at least a nameserver defined.
magic_dns: true
# Defines the base domain to create the hostnames for MagicDNS.
# `base_domain` must be a FQDNs, without the trailing dot.
# The FQDN of the hosts will be
# `hostname.user.base_domain` (e.g., _myhost.myuser.example.com_).
base_domain: ts.net
extra_records:
- name: test.example.com
type: A
value: 1.1.1.1
dns:
# Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/).
# Only works if there is at least a nameserver defined.
magic_dns: true
# Defines the base domain to create the hostnames for MagicDNS.
# This domain _must_ be different from the server_url domain.
# `base_domain` must be a FQDN, without the trailing dot.
# The FQDN of the hosts will be
# `hostname.base_domain` (e.g., _myhost.example.com_).
base_domain: example.com
# List of DNS servers to expose to clients.
nameservers:
global:
- 1.1.1.1
- 1.0.0.1
- 2606:4700:4700::1111
- 2606:4700:4700::1001
# NextDNS (see https://tailscale.com/kb/1218/nextdns/).
# "abc123" is example NextDNS ID, replace with yours.
# - https://dns.nextdns.io/abc123
# Split DNS (see https://tailscale.com/kb/1054/dns/),
# a map of domains and which DNS server to use for each.
split:
{}
# foo.bar.com:
# - 1.1.1.1
# darp.headscale.net:
# - 1.1.1.1
# - 8.8.8.8
# Set custom DNS search domains. With MagicDNS enabled,
# your tailnet base_domain is always the first search domain.
search_domains: []
# Extra DNS records
# so far only A-records are supported (on the tailscale side)
# See https://github.com/juanfont/headscale/blob/main/docs/dns-records.md#Limitations
extra_records: []
# - name: "grafana.myvpn.example.com"
# type: "A"
# value: "100.64.0.3"
#
# # you can also put it in one line
# - { name: "prometheus.myvpn.example.com", type: "A", value: "100.64.0.3" }
# Unix socket used for the CLI to connect without authentication
# Note: for production you will want to set this to something like:
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0777"
#
# headscale supports experimental OpenID connect support,
# it is still being tested and might have some bugs, please
# help us test it.
# OpenID Connect
oidc:
only_start_if_oidc_is_available: false
issuer: "https://sso.example.com"
client_id: "headscale"
client_secret: "super_secret_client_secret"
# # Alternatively, set `client_secret_path` to read the secret from the file.
# # It resolves environment variables, making integration to systemd's
# # `LoadCredential` straightforward:
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
# # client_secret and client_secret_path are mutually exclusive.
#
# # The amount of time from a node is authenticated with OpenID until it
# # expires and needs to reauthenticate.
# # Setting the value to "0" will mean no expiry.
expiry: 180d
#
# # Use the expiry from the token received from OpenID when the user logged
# # in, this will typically lead to frequent need to reauthenticate and should
# # only been enabled if you know what you are doing.
# # Note: enabling this will cause `oidc.expiry` to be ignored.
# use_expiry_from_token: false
#
# # Customize the scopes used in the OIDC flow, defaults to "openid", "profile" and "email" and add custom query
# # parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email".
#
# scope: ["openid", "profile", "email", "custom"]
# extra_params:
# domain_hint: example.com
#
# # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the
# # authentication request will be rejected.
#
allowed_domains:
- example.com
# # Note: Groups from keycloak have a leading '/'
# allowed_groups:
# - /headscale
# allowed_users:
# - alice@example.com
#
# # If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed.
# # This will transform `first-name.last-name@example.com` to the user `first-name.last-name`
# # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following
# user: `first-name.last-name.example.com`
#
strip_email_domain: true
# Logtail configuration
# Logtail is Tailscales logging and auditing infrastructure, it allows the control panel
# to instruct tailscale nodes to log their activity to a remote server.
logtail:
# Enable logtail for this headscales clients.
# As there is currently no support for overriding the log server in headscale, this is
# disabled by default. Enabling this will make your clients send logs to Tailscale Inc.
enabled: true
# Enabling this option makes devices prefer a random port for WireGuard traffic over the
# default static port 41641. This option is intended as a workaround for some buggy
# firewall devices. See https://tailscale.com/kb/1181/firewalls/ for more information.
randomize_client_port: false
+1 -1
View File
@@ -19,7 +19,7 @@ if (!version) {
}
export default defineConfig(({ isSsrBuild }) => ({
base: `${prefix}/`,
base: isSsrBuild ? `${prefix}/` : undefined,
plugins: [reactRouterHonoServer(), reactRouter(), tsconfigPaths()],
css: {
postcss: {