mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42ffe69486 | |||
| 0fb02d0d8b | |||
| d189d75d5d | |||
| 8105b8f94d | |||
| 5953740769 | |||
| 7a345d118c | |||
| 0965e16401 | |||
| e57821df14 | |||
| dfdf5d3ff9 | |||
| 4046c6edb1 | |||
| 7817addbc6 | |||
| e727c48406 | |||
| 85a0f932a7 | |||
| 985d7d9dc6 | |||
| 91254902fa | |||
| 55ffd5e841 | |||
| 82cb74b20b | |||
| e373c4a65e | |||
| 7b4966be02 | |||
| 3d07b941b4 | |||
| 862180df91 | |||
| 11a7c335f7 | |||
| 3d71049afe | |||
| 8948d440fc | |||
| d5dffbaa1d | |||
| d1e2773179 | |||
| 295dd43059 | |||
| b3791385b9 | |||
| 70d535fe8d | |||
| 2ff5dd290f | |||
| 1463a8cc42 | |||
| 87485a81f3 | |||
| 3a3e5ca65e | |||
| 3eff763436 | |||
| 6ad0847653 | |||
| 199ef46ee1 | |||
| b938642da7 | |||
| e868f177aa |
@@ -1,31 +1,31 @@
|
||||
name: Bug Report
|
||||
description: Report an issue with Headplane
|
||||
assignees: [tale]
|
||||
labels: [bug, triage]
|
||||
labels: ["Bug", "Needs Triage"]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
A detailed description of the issue and steps to reproduce it.
|
||||
If applicable, include any error messages or screenshots.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
A detailed description of the issue and steps to reproduce it.
|
||||
If applicable, include any error messages or screenshots.
|
||||
|
||||
If this is not an issue with Headplane, but an issue with your
|
||||
environment, please consider opening a discussion instead.
|
||||
placeholder: e.g. "When I try to upload a file, I get an error message."
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Headplane Version
|
||||
description: What version of Headplane are you using?
|
||||
placeholder: e.g. "v0.5.5"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Headscale Version
|
||||
description: What version of Headscale are you using?
|
||||
placeholder: e.g. "v0.25.1"
|
||||
validations:
|
||||
required: true
|
||||
If this is not an issue with Headplane, but an issue with your
|
||||
environment, please consider opening a discussion instead.
|
||||
placeholder: e.g. "When I try to upload a file, I get an error message."
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Headplane Version
|
||||
description: What version of Headplane are you using?
|
||||
placeholder: e.g. "v0.6.2"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Headscale Version
|
||||
description: What version of Headscale are you using?
|
||||
placeholder: e.g. "v0.27.1"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
name: Feature Request
|
||||
description: Request a new feature or enhancement for Headplane
|
||||
assignees: [tale]
|
||||
labels: [enhancement, triage]
|
||||
labels: ["Feature", "Needs Triage"]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
A detailed description of the feature you would like to see added.
|
||||
Please include any relevant context, such as why this feature is
|
||||
important and how it would benefit other users beyond yourself.
|
||||
placeholder: e.g. "I would like to see support for custom themes in Headplane so that I can personalize the interface to my liking."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
A detailed description of the feature you would like to see added.
|
||||
Please include any relevant context, such as why this feature is
|
||||
important and how it would benefit other users beyond yourself.
|
||||
placeholder: e.g. "I would like to see support for custom themes in Headplane so that I can personalize the interface to my liking."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# 0.6.2 (Next)
|
||||
- Added search and sortable columns to the machines list page (closes [#351](https://github.com/tale/headplane/issues/351)).
|
||||
- Added support for Headscale 0.27.0 and 0.27.1
|
||||
- Bundle all `node_modules` aside from native ones to reduce bundle and container size (closes [#331](https://github.com/tale/headplane/issues/331)).
|
||||
- Allow conditionally compiling the SSH WASM integration when building (closes [#337](https://github.com/tale/headplane/issues/337)).
|
||||
@@ -10,10 +11,20 @@
|
||||
- Secret path loading has been reworked from the ground up to be more reliable (closes [#334](https://github.com/tale/headplane/issues/334)).
|
||||
- Added better testing and validation for configuration loading
|
||||
- Re-worked the OIDC integration to adhere to the correct standards and surface more errors to the user.
|
||||
- Deprecated `oidc.redirect_uri` and automated callback URL detection in favor of setting `server.base_url` correctly.
|
||||
- Explicitly added `oidc.use_pkce` to correctly determine PKCE configuration.
|
||||
- `oidc.token_endpoint_auth_method` is now optional and will attempt to be auto-detected, defaulting to `client_secret_basic` if unavailable (closes [#410](https://github.com/tale/headplane/issues/410)).
|
||||
- Removed several unnecessarily verbose or spammy log messages.
|
||||
- Updated the minimum Docker API used to support the latest Docker versions (via [#370](https://github.com/tale/headplane/pull/370)).
|
||||
- Enhanced the node tag dialog to show a dropdown of assignable tags (via [#362](https://github.com/tale/headplane/pull/362)).
|
||||
- Fixed an issue where the website favicon would not load correctly (closes [#323](https://github.com/tale/headplane/issues/323)).
|
||||
- Correctly handle invalid ACL policy inserts on Headscale 0.27+ (closes [#383](https://github.com/tale/headplane/issues/383)).
|
||||
- Prevent a machine from changing its owner to itself (closes [#373](https://github.com/tale/headplane/issues/373)).
|
||||
- Added an `/admin/api/info` route that can expose sensitive information if `server.info_secret` is set in the configuration (closes [#324](https://github.com/tale/headplane/issues/324)).
|
||||
- Correctly apply Gravatar profile pictures on the user page if applicable (closes [#405](https://github.com/tale/headplane/issues/405)).
|
||||
- Machine key registration no longer works if the key isn't 24 characters long (closes [#415](https://github.com/tale/headplane/issues/415)).
|
||||
- Fixed some mobile CSS issues across the application (closes [#401](https://github.com/tale/headplane/issues/401)).
|
||||
- Added a Docker healthcheck to the container (closes [#411](https://github.com/tale/headplane/issues/411)).
|
||||
---
|
||||
|
||||
# 0.6.1 (October 12, 2025)
|
||||
|
||||
+12
-2
@@ -11,14 +11,16 @@ ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG IMAGE_TAG
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 IMAGE_TAG=$IMAGE_TAG \
|
||||
./build.sh --wasm --agent --fake-shell \
|
||||
./build.sh --wasm --agent --fake-shell --healthcheck \
|
||||
--wasm-output /bin/hp_ssh.wasm \
|
||||
--agent-output /bin/hp_agent \
|
||||
--fake-shell-output /bin/fake-sh
|
||||
--fake-shell-output /bin/fake-sh \
|
||||
--healthcheck-output /bin/hp_healthcheck
|
||||
|
||||
RUN chmod +x /bin/hp_ssh.wasm
|
||||
RUN chmod +x /bin/hp_agent
|
||||
RUN chmod +x /bin/fake-sh
|
||||
RUN chmod +x /bin/hp_healthcheck
|
||||
|
||||
# Folder needs to exist for later stages
|
||||
RUN mkdir -p /var/lib/headplane/agent
|
||||
@@ -49,6 +51,10 @@ COPY --from=go-base /var/lib/headplane /var/lib/headplane
|
||||
COPY --from=go-base /bin/fake-sh /bin/sh
|
||||
COPY --from=go-base /bin/fake-sh /bin/bash
|
||||
|
||||
COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD ["/bin/hp_healthcheck"]
|
||||
|
||||
WORKDIR /app
|
||||
CMD [ "/app/build/server/index.js" ]
|
||||
|
||||
@@ -61,6 +67,10 @@ COPY --from=js-base /run/node_modules /app/node_modules
|
||||
|
||||
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
|
||||
COPY --from=go-base /var/lib/headplane /var/lib/headplane
|
||||
COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD ["/bin/hp_healthcheck"]
|
||||
|
||||
WORKDIR /app
|
||||
CMD [ "node", "/app/build/server/index.js" ]
|
||||
|
||||
@@ -107,6 +107,11 @@ function Panel(props: DialogPanelProps) {
|
||||
return (
|
||||
<Form
|
||||
{...dialogProps}
|
||||
className={cn(
|
||||
'outline-hidden rounded-3xl w-full max-w-lg',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
)}
|
||||
method={method ?? 'POST'}
|
||||
onSubmit={(event) => {
|
||||
if (onSubmit) {
|
||||
onSubmit(event);
|
||||
@@ -114,12 +119,7 @@ function Panel(props: DialogPanelProps) {
|
||||
|
||||
close?.();
|
||||
}}
|
||||
method={method ?? 'POST'}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'outline-hidden rounded-3xl w-full max-w-lg',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
)}
|
||||
>
|
||||
<Card className="w-full max-w-lg" variant="flat">
|
||||
{children}
|
||||
@@ -130,9 +130,9 @@ function Panel(props: DialogPanelProps) {
|
||||
<>
|
||||
<Button onPress={close}>Cancel</Button>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
type="submit"
|
||||
variant={variant === 'destructive' ? 'danger' : 'heavy'}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
|
||||
@@ -8,11 +8,18 @@ export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
|
||||
labelHidden?: boolean;
|
||||
isRequired?: boolean;
|
||||
className?: string;
|
||||
isInvalid?: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
// TODO: Custom isInvalid logic for custom error messages
|
||||
export default function Input(props: InputProps) {
|
||||
const { label, labelHidden, className } = props;
|
||||
const {
|
||||
label,
|
||||
labelHidden,
|
||||
className,
|
||||
isInvalid: customIsInvalid,
|
||||
errorMessage,
|
||||
} = props;
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const id = useId(props.id);
|
||||
|
||||
@@ -21,7 +28,7 @@ export default function Input(props: InputProps) {
|
||||
inputProps,
|
||||
descriptionProps,
|
||||
errorMessageProps,
|
||||
isInvalid,
|
||||
isInvalid: ariaIsInvalid,
|
||||
validationErrors,
|
||||
} = useTextField(
|
||||
{
|
||||
@@ -32,16 +39,18 @@ export default function Input(props: InputProps) {
|
||||
ref,
|
||||
);
|
||||
|
||||
const isInvalid = customIsInvalid ?? ariaIsInvalid;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full" aria-label={label}>
|
||||
<div className="flex flex-col w-full">
|
||||
<label
|
||||
{...labelProps}
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
'text-xs font-medium px-3 mb-0.5',
|
||||
'text-headplane-700 dark:text-headplane-100',
|
||||
labelHidden && 'sr-only',
|
||||
)}
|
||||
htmlFor={id}
|
||||
>
|
||||
{label}
|
||||
{props.isRequired && (
|
||||
@@ -50,8 +59,6 @@ export default function Input(props: InputProps) {
|
||||
</label>
|
||||
<input
|
||||
{...inputProps}
|
||||
required={props.isRequired}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl px-3 py-2',
|
||||
'focus:outline-hidden focus:ring-3',
|
||||
@@ -59,6 +66,8 @@ export default function Input(props: InputProps) {
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
required={props.isRequired}
|
||||
/>
|
||||
{props.description && (
|
||||
<div
|
||||
@@ -76,7 +85,7 @@ export default function Input(props: InputProps) {
|
||||
{...errorMessageProps}
|
||||
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
|
||||
>
|
||||
{validationErrors.join(' ')}
|
||||
{errorMessage ?? validationErrors.join(' ')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -23,15 +23,15 @@ function Tabs({ label, className, ...props }: TabsProps) {
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
<div
|
||||
{...tabListProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-center rounded-t-xl w-fit',
|
||||
'flex items-center rounded-t-xl w-fit max-w-full overflow-x-auto',
|
||||
'border-headplane-100 dark:border-headplane-800',
|
||||
'border-t border-x',
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<Tab key={item.key} item={item} state={state} />
|
||||
<Tab item={item} key={item.key} state={state} />
|
||||
))}
|
||||
</div>
|
||||
<TabsPanel key={state.selectedItem?.key} state={state} />
|
||||
@@ -52,7 +52,6 @@ function Tab({ item, state }: TabsTabProps) {
|
||||
return (
|
||||
<div
|
||||
{...tabProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'pl-2 pr-3 py-2.5',
|
||||
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
|
||||
@@ -60,6 +59,7 @@ function Tab({ item, state }: TabsTabProps) {
|
||||
'border-r border-headplane-100 dark:border-headplane-800',
|
||||
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{rendered}
|
||||
</div>
|
||||
@@ -76,11 +76,11 @@ function TabsPanel({ state, ...props }: TabsPanelProps) {
|
||||
return (
|
||||
<div
|
||||
{...tabPanelProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'w-full overflow-clip rounded-b-xl rounded-r-xl',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{state.selectedItem?.props.children}
|
||||
</div>
|
||||
|
||||
@@ -18,15 +18,26 @@ export function getErrorMessage(error: Error | unknown): {
|
||||
const { statusCode, rawData, data, requestUrl } = error.data;
|
||||
if (statusCode >= 500) {
|
||||
return {
|
||||
title: 'Cannot connect to Headscale API',
|
||||
title: 'Headscale API Error',
|
||||
jsxMessage: (
|
||||
<Card.Text>
|
||||
There was an error communicating with the Headscale API.
|
||||
<br />
|
||||
The server responded with a status code of{' '}
|
||||
<strong>{statusCode}</strong>, indicating a server-side issue.
|
||||
Please check the Headscale server status and try again later.
|
||||
</Card.Text>
|
||||
<>
|
||||
<Card.Text>
|
||||
There was an error communicating with the Headscale API.
|
||||
<br />
|
||||
The server responded with a status code of{' '}
|
||||
<strong>{statusCode}</strong>, indicating a server-side issue.
|
||||
Please check the Headscale server status and try again later.
|
||||
</Card.Text>
|
||||
{(error.data.data != null || error.data.rawData != null) && (
|
||||
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
|
||||
{error.data.data != null ? (
|
||||
<code>{JSON.stringify(error.data.data, null, 2)}</code>
|
||||
) : (
|
||||
<code>{error.data.rawData}</code>
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -124,7 +135,7 @@ export function getErrorMessage(error: Error | unknown): {
|
||||
<br />
|
||||
Status Code: <strong>{error.status}</strong>
|
||||
<br />
|
||||
Status Text: <strong>{error.statusText}</strong>
|
||||
Status Text: <strong>{error.data}</strong>
|
||||
</>
|
||||
),
|
||||
};
|
||||
@@ -159,12 +170,10 @@ export function getErrorMessage(error: Error | unknown): {
|
||||
|
||||
// Traverse the error chain to find the root cause
|
||||
let rootError = error;
|
||||
console.log('error', error.cause != null);
|
||||
if (error.cause != null) {
|
||||
rootError = error.cause as Error;
|
||||
while (rootError.cause != null) {
|
||||
rootError = rootError.cause as Error;
|
||||
console.log('setting rootError', rootError.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
|
||||
<Links />
|
||||
<link href="favicon.ico" rel="icon" />
|
||||
</head>
|
||||
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
|
||||
<body className="overscroll-none overflow-x-hidden dark:bg-headplane-900 dark:text-headplane-50">
|
||||
{children}
|
||||
<ToastProvider queue={toastQueue} />
|
||||
<ScrollRestoration />
|
||||
|
||||
@@ -5,6 +5,9 @@ export default [
|
||||
index('routes/util/redirect.ts'),
|
||||
route('/healthz', 'routes/util/healthz.ts'),
|
||||
|
||||
// API Routes
|
||||
...prefix('/api', [route('/info', 'routes/util/info.ts')]),
|
||||
|
||||
// Authentication Routes
|
||||
route('/login', 'routes/auth/login/page.tsx'),
|
||||
route('/logout', 'routes/auth/logout.ts'),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { data } from 'react-router';
|
||||
import ResponseError from '~/server/headscale/api/response-error';
|
||||
import { isDataWithApiError } from '~/server/headscale/api/error-client';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Route } from './+types/overview';
|
||||
|
||||
@@ -37,68 +37,99 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
|
||||
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
|
||||
if (isDataWithApiError(error)) {
|
||||
const rawData = error.data.rawData;
|
||||
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11
|
||||
if (rawData.includes('update is disabled')) {
|
||||
throw data('Policy is not writable', { status: 403 });
|
||||
}
|
||||
|
||||
// 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;
|
||||
const message =
|
||||
error.data.data != null &&
|
||||
'message' in error.data.data &&
|
||||
typeof error.data.data.message === 'string'
|
||||
? error.data.data.message
|
||||
: undefined;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
if (message == null) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
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;
|
||||
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
|
||||
// we need to reference other error messages based on API version.
|
||||
if (context.hsApi.clientHelpers.isAtleast('0.27.0')) {
|
||||
if (message.includes('parsing HuJSON:')) {
|
||||
const cutIndex = message.indexOf('parsing HuJSON:');
|
||||
const trimmed =
|
||||
cutIndex > -1
|
||||
? `Syntax error: ${message.slice(cutIndex + 16).trim()}`
|
||||
: message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('empty policy')) {
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: 'Policy error: Supplied policy was empty',
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
if (message.includes('parsing policy from bytes:')) {
|
||||
const cutIndex = message.indexOf('parsing policy from bytes:');
|
||||
const trimmed =
|
||||
cutIndex > -1
|
||||
? `Syntax error: ${message.slice(cutIndex + 26).trim()}`
|
||||
: message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Pre-0.27.0 error messages
|
||||
if (message.includes('parsing hujson')) {
|
||||
const cutIndex = message.indexOf('err: hujson:');
|
||||
const trimmed =
|
||||
cutIndex > -1
|
||||
? `Syntax error: ${message.slice(cutIndex + 12)}`
|
||||
: message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('unmarshalling policy')) {
|
||||
const cutIndex = message.indexOf('err:');
|
||||
const trimmed =
|
||||
cutIndex > -1
|
||||
? `Syntax error: ${message.slice(cutIndex + 5)}`
|
||||
: message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { data } from 'react-router';
|
||||
import ResponseError from '~/server/headscale/api/response-error';
|
||||
import { isDataWithApiError } from '~/server/headscale/api/error-client';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Route } from './+types/overview';
|
||||
|
||||
@@ -30,30 +30,19 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
try {
|
||||
const { policy, updatedAt } = await api.getPolicy();
|
||||
|
||||
// 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
|
||||
if (isDataWithApiError(error)) {
|
||||
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10
|
||||
if (error.data.rawData.includes('acl policy not found')) {
|
||||
flags.policy = '';
|
||||
flags.writable = true;
|
||||
return flags;
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
// Otherwise, this is a Headscale error that we can just propagate.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
|
||||
import {
|
||||
AlertCircle,
|
||||
Construction,
|
||||
Eye,
|
||||
FlaskConical,
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useFetcher, useRevalidator } from 'react-router';
|
||||
import { isRouteErrorResponse, useFetcher, useRevalidator } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
import Link from '~/components/Link';
|
||||
import Notice from '~/components/Notice';
|
||||
import Tabs from '~/components/Tabs';
|
||||
import { isApiError } from '~/server/headscale/api/error-client';
|
||||
import toast from '~/utils/toast';
|
||||
import type { Route } from './+types/overview';
|
||||
import { aclAction } from './acl-action';
|
||||
@@ -164,3 +172,49 @@ export default function Page({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
if (
|
||||
isRouteErrorResponse(error) &&
|
||||
isApiError(error.data) &&
|
||||
error.data.rawData.includes('reading policy from path') &&
|
||||
error.data.rawData.includes('no such file or directory')
|
||||
) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="max-w-2xl" variant="flat">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title>ACL Policy Unavailable</Card.Title>
|
||||
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
|
||||
</div>
|
||||
<Card.Text>
|
||||
The ACL policy is currently unavailable because the policy file does
|
||||
not exist on the server. This usually indicates that Headscale is
|
||||
running in <Code>file</Code> mode for ACLs, and the specified policy
|
||||
file is missing.
|
||||
</Card.Text>
|
||||
</Card>
|
||||
<Card className="max-w-2xl" variant="flat">
|
||||
<Card.Text>
|
||||
In order to resolve this issue, there are two possible actions you
|
||||
can take:
|
||||
</Card.Text>
|
||||
<ul className="list-disc list-outside mt-2 ml-4 space-y-1 text-sm">
|
||||
<li>
|
||||
Create the ACL policy file at the specified path in your Headscale
|
||||
configuration.
|
||||
</li>
|
||||
<li>
|
||||
Alternatively, you can switch Headscale to use{' '}
|
||||
<Code>database</Code> mode for ACLs by updating your Headscale
|
||||
configuration. This will allow Headplane to manage the ACL policy
|
||||
directly through the web interface.
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { data, redirect } from 'react-router';
|
||||
import ResponseError from '~/server/headscale/api/response-error';
|
||||
import { isDataWithApiError } from '~/server/headscale/api/error-client';
|
||||
import log from '~/utils/log';
|
||||
import type { Route } from './+types/page';
|
||||
|
||||
@@ -74,12 +74,15 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResponseError) {
|
||||
// Check if this is a React Router DataWithResponseInit wrapping a Headscale API error
|
||||
if (isDataWithApiError(error)) {
|
||||
const apiError = error.data;
|
||||
// TODO: What in gods name is wrong with the headscale API?
|
||||
if (
|
||||
error.status === 401 ||
|
||||
error.status === 403 ||
|
||||
(error.status === 500 && error.response.trim() === 'Unauthorized')
|
||||
apiError.statusCode === 401 ||
|
||||
apiError.statusCode === 403 ||
|
||||
(apiError.statusCode === 500 &&
|
||||
apiError.rawData.trim() === 'Unauthorized')
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -24,7 +24,7 @@ export function OidcConfigErrorNotice({
|
||||
</ul>{' '}
|
||||
<Link
|
||||
name="Headplane OIDC Issues"
|
||||
to="https://headplane.net/configuration/sso#help"
|
||||
to="https://headplane.net/configuration/sso#troubleshooting"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
|
||||
@@ -47,7 +47,7 @@ function getErrorMessage(code: string) {
|
||||
case 'error_auth_failed':
|
||||
return (
|
||||
<Card.Text>
|
||||
Authentication with the SSO provider failed. Please tray again later.
|
||||
Authentication with the SSO provider failed. Please try again later.
|
||||
Headplane logs may provide more information.
|
||||
</Card.Text>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
data,
|
||||
Form,
|
||||
Link as RemixLink,
|
||||
redirect,
|
||||
@@ -12,7 +11,6 @@ import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
import Input from '~/components/Input';
|
||||
import Link from '~/components/Link';
|
||||
import { OidcConnectorError } from '~/server/web/oidc-connector';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
import type { Route } from './+types/page';
|
||||
import { loginAction } from './action';
|
||||
@@ -37,7 +35,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
const isOidcConnectorEnabled = context.oidcConnector?.isValid;
|
||||
const oidcErrorCodes = !isOidcConnectorEnabled
|
||||
? context.oidcConnector!.errors
|
||||
? (context.oidcConnector?.errors ?? [])
|
||||
: [];
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { count, eq } from 'drizzle-orm';
|
||||
import * as oidc from 'openid-client';
|
||||
import {
|
||||
createCookie,
|
||||
data,
|
||||
type LoaderFunctionArgs,
|
||||
redirect,
|
||||
} from 'react-router';
|
||||
import { data, redirect } from 'react-router';
|
||||
import { ulid } from 'ulidx';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { users } from '~/server/db/schema';
|
||||
import { Roles } from '~/server/web/roles';
|
||||
import log from '~/utils/log';
|
||||
import type { OidcCookieState } from './oidc-start';
|
||||
import { createOidcStateCookie } from '~/utils/oidc-state';
|
||||
import type { Route } from './+types/oidc-callback';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
if (!context.oidcConnector?.isValid) {
|
||||
throw data('OIDC is not enabled or misconfigured', { status: 501 });
|
||||
}
|
||||
@@ -27,35 +19,34 @@ export async function loader({
|
||||
return redirect('/login?s=error_no_query');
|
||||
}
|
||||
|
||||
const cookie = createCookie('__oidc_auth_flow', {
|
||||
httpOnly: true,
|
||||
maxAge: 300,
|
||||
secure: context.config.server.cookie_secure,
|
||||
domain: context.config.server.cookie_domain,
|
||||
});
|
||||
const cookie = createOidcStateCookie(context.config);
|
||||
const oidcCookieState = await cookie.parse(request.headers.get('Cookie'));
|
||||
|
||||
const oidcCookieState: OidcCookieState | null = await cookie.parse(
|
||||
request.headers.get('Cookie'),
|
||||
);
|
||||
|
||||
if (oidcCookieState == null || typeof oidcCookieState !== 'object') {
|
||||
if (oidcCookieState == null) {
|
||||
log.warn('auth', 'Called OIDC callback without session cookie');
|
||||
return redirect('/login?s=error_no_session');
|
||||
}
|
||||
|
||||
const { state, nonce } = oidcCookieState;
|
||||
if (!state || !nonce) {
|
||||
const { state, nonce, redirect_uri, verifier } = oidcCookieState;
|
||||
if (!state || !nonce || !redirect_uri || !verifier) {
|
||||
log.warn('auth', 'OIDC session cookie is missing required fields');
|
||||
return redirect('/login?s=error_invalid_session');
|
||||
}
|
||||
|
||||
try {
|
||||
const callbackUrl = new URL(redirect_uri);
|
||||
const currentUrl = new URL(request.url);
|
||||
callbackUrl.search = currentUrl.search;
|
||||
|
||||
const tokens = await oidc.authorizationCodeGrant(
|
||||
context.oidcConnector.client,
|
||||
request,
|
||||
callbackUrl,
|
||||
{
|
||||
expectedState: state,
|
||||
expectedNonce: nonce,
|
||||
...(context.oidcConnector.usePKCE
|
||||
? { pkceCodeVerifier: verifier }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -130,20 +121,51 @@ export async function loader({
|
||||
'Got an OIDC response error body: %s',
|
||||
JSON.stringify(error.cause),
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof oidc.AuthorizationResponseError) {
|
||||
// Check for PKCE-related errors
|
||||
if (
|
||||
error.error.toLowerCase().includes('code_verifier') ||
|
||||
error.error.toLowerCase().includes('code verifier') ||
|
||||
error.error.toLowerCase().includes('pkce')
|
||||
) {
|
||||
log.error(
|
||||
'auth',
|
||||
'PKCE error detected. Your OIDC provider may require PKCE to be enabled. Current setting: use_pkce=%s',
|
||||
context.oidcConnector?.usePKCE,
|
||||
);
|
||||
|
||||
if (!context.oidcConnector?.usePKCE) {
|
||||
log.error(
|
||||
'auth',
|
||||
'Consider setting oidc.use_pkce=true in your configuration if your provider requires PKCE',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (error instanceof oidc.AuthorizationResponseError) {
|
||||
log.error(
|
||||
'auth',
|
||||
'Got an OIDC authorization response error: %s',
|
||||
error.error,
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof oidc.WWWAuthenticateChallengeError) {
|
||||
} else if (error instanceof oidc.WWWAuthenticateChallengeError) {
|
||||
log.error('auth', 'Got an OIDC WWW-Authenticate challenge error');
|
||||
} else if (error instanceof oidc.ClientError) {
|
||||
log.error(
|
||||
'auth',
|
||||
'Got an OIDC authorization client error: %s',
|
||||
error.cause instanceof Error
|
||||
? error.cause.message
|
||||
: String(error.cause),
|
||||
);
|
||||
} else {
|
||||
log.error(
|
||||
'auth',
|
||||
'Got an OIDC error: %s',
|
||||
error instanceof Error && error.cause
|
||||
? JSON.stringify(error.cause)
|
||||
: String(error),
|
||||
);
|
||||
}
|
||||
|
||||
return redirect('/login?s=error_auth_failed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import * as oidc from 'openid-client';
|
||||
import {
|
||||
createCookie,
|
||||
data,
|
||||
type LoaderFunctionArgs,
|
||||
redirect,
|
||||
} from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { data, redirect } from 'react-router';
|
||||
import { HeadplaneConfig } from '~/server/config/config-schema';
|
||||
import { createOidcStateCookie } from '~/utils/oidc-state';
|
||||
import type { Route } from './+types/oidc-start';
|
||||
|
||||
export interface OidcCookieState {
|
||||
nonce: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
await context.sessions.auth(request);
|
||||
return redirect('/');
|
||||
@@ -25,25 +14,25 @@ export async function loader({
|
||||
throw data('OIDC is not enabled or misconfigured', { status: 501 });
|
||||
}
|
||||
|
||||
const cookie = createCookie('__oidc_auth_flow', {
|
||||
httpOnly: true,
|
||||
maxAge: 300,
|
||||
secure: context.config.server.cookie_secure,
|
||||
domain: context.config.server.cookie_domain,
|
||||
});
|
||||
|
||||
const redirectUri =
|
||||
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
|
||||
const cookie = createOidcStateCookie(context.config);
|
||||
const redirect_uri = getRedirectUri(context.config, request);
|
||||
|
||||
const nonce = oidc.randomNonce();
|
||||
const verifier = oidc.randomPKCECodeVerifier();
|
||||
const state = oidc.randomState();
|
||||
|
||||
const url = oidc.buildAuthorizationUrl(context.oidcConnector.client, {
|
||||
...(context.oidcConnector.extraParams ?? {}),
|
||||
scope: context.oidcConnector.scope,
|
||||
redirect_uri: redirectUri,
|
||||
redirect_uri,
|
||||
state,
|
||||
nonce,
|
||||
...(context.oidcConnector.usePKCE
|
||||
? {
|
||||
code_challenge_method: 'S256',
|
||||
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
return redirect(url.href, {
|
||||
@@ -52,12 +41,27 @@ export async function loader({
|
||||
'Set-Cookie': await cookie.serialize({
|
||||
state,
|
||||
nonce,
|
||||
} satisfies OidcCookieState),
|
||||
verifier,
|
||||
redirect_uri,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getRedirectUri(req: Request) {
|
||||
function getRedirectUri(config: HeadplaneConfig, req: Request): string {
|
||||
if (config.server.base_url != null) {
|
||||
const url = new URL(`${__PREFIX__}/oidc/callback`, config.server.base_url);
|
||||
return url.href;
|
||||
}
|
||||
|
||||
if (config.oidc?.redirect_uri != null) {
|
||||
const url = new URL(
|
||||
`${__PREFIX__}/oidc/callback`,
|
||||
config.oidc.redirect_uri,
|
||||
);
|
||||
return url.href;
|
||||
}
|
||||
|
||||
const url = new URL(`${__PREFIX__}/oidc/callback`, req.url);
|
||||
let host = req.headers.get('Host');
|
||||
if (!host) {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { DndContext, DragOverlay, closestCorners } from '@dnd-kit/core';
|
||||
import { closestCorners, DndContext, DragOverlay } from '@dnd-kit/core';
|
||||
import {
|
||||
restrictToParentElement,
|
||||
restrictToVerticalAxis,
|
||||
} from '@dnd-kit/modifiers';
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, Lock } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { type FetcherWithComponents, Form, useFetcher } from 'react-router';
|
||||
import { Form } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Input from '~/components/Input';
|
||||
import TableList from '~/components/TableList';
|
||||
@@ -37,18 +37,15 @@ export default function ManageDomains({
|
||||
}, [searchDomains]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<div className="flex flex-col w-full sm:w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
|
||||
<p className="mb-4">
|
||||
Set custom DNS search domains for your Tailnet. When using Magic DNS,
|
||||
your tailnet domain is used as the first search domain.
|
||||
</p>
|
||||
<DndContext
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={(event) => {
|
||||
setActiveId(event.active.id);
|
||||
}}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
onDragEnd={(event) => {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
@@ -70,6 +67,9 @@ export default function ManageDomains({
|
||||
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
|
||||
}
|
||||
}}
|
||||
onDragStart={(event) => {
|
||||
setActiveId(event.active.id);
|
||||
}}
|
||||
>
|
||||
<TableList>
|
||||
{magic ? (
|
||||
@@ -91,19 +91,19 @@ export default function ManageDomains({
|
||||
>
|
||||
{localDomains.map((sd, index) => (
|
||||
<Domain
|
||||
key={sd}
|
||||
domain={sd}
|
||||
id={index + 1}
|
||||
isDisabled={isDisabled}
|
||||
key={sd}
|
||||
/>
|
||||
))}
|
||||
<DragOverlay adjustScale>
|
||||
{activeId ? (
|
||||
<Domain
|
||||
isDragging
|
||||
domain={localDomains[(activeId as number) - 1]}
|
||||
id={(activeId as number) - 1}
|
||||
isDisabled={isDisabled}
|
||||
isDragging
|
||||
/>
|
||||
) : undefined}
|
||||
</DragOverlay>
|
||||
@@ -111,28 +111,28 @@ export default function ManageDomains({
|
||||
{isDisabled ? undefined : (
|
||||
<TableList.Item key="add-sd">
|
||||
<Form
|
||||
method="POST"
|
||||
className="flex items-center justify-between w-full"
|
||||
method="POST"
|
||||
>
|
||||
<input type="hidden" name="action_id" value="add_domain" />
|
||||
<input name="action_id" type="hidden" value="add_domain" />
|
||||
<Input
|
||||
type="text"
|
||||
className={cn(
|
||||
'border-none font-mono p-0 text-sm',
|
||||
'rounded-none focus:ring-0 w-full ml-1',
|
||||
)}
|
||||
placeholder="Search Domain"
|
||||
label="Search Domain"
|
||||
name="domain"
|
||||
labelHidden
|
||||
isRequired
|
||||
label="Search Domain"
|
||||
labelHidden
|
||||
name="domain"
|
||||
placeholder="Search Domain"
|
||||
type="text"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
)}
|
||||
type="submit"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
@@ -164,11 +164,11 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
|
||||
|
||||
return (
|
||||
<TableList.Item
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
isSortableDragging ? 'opacity-50' : '',
|
||||
isDragging ? 'ring-3 bg-white dark:bg-headplane-900' : '',
|
||||
)}
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
@@ -186,15 +186,15 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
|
||||
</p>
|
||||
{isDragging ? undefined : (
|
||||
<Form method="POST">
|
||||
<input type="hidden" name="action_id" value="remove_domain" />
|
||||
<input type="hidden" name="domain" value={domain} />
|
||||
<input name="action_id" type="hidden" value="remove_domain" />
|
||||
<input name="domain" type="hidden" value={domain} />
|
||||
<Button
|
||||
type="submit"
|
||||
isDisabled={isDisabled}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md',
|
||||
'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
type="submit"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
|
||||
@@ -20,14 +20,14 @@ export default function ManageNS({
|
||||
overrideLocalDns,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<div className="flex flex-col w-full sm:w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
||||
<p>
|
||||
Set the nameservers used by devices on the Tailnet to resolve DNS
|
||||
queries.{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1054/dns"
|
||||
name="Tailscale DNS Documentation"
|
||||
to="https://tailscale.com/kb/1054/dns"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
@@ -35,12 +35,12 @@ export default function ManageNS({
|
||||
<div className="mt-4">
|
||||
{Object.keys(nameservers).map((key) => (
|
||||
<NameserverList
|
||||
key={key}
|
||||
isGlobal={key === 'global'}
|
||||
isDisabled={isDisabled}
|
||||
isGlobal={key === 'global'}
|
||||
key={key}
|
||||
name={key}
|
||||
nameservers={nameservers}
|
||||
overrideLocalDns={overrideLocalDns}
|
||||
name={key}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -66,16 +66,17 @@ function NameserverList({
|
||||
name,
|
||||
}: ListProps) {
|
||||
const list = isGlobal ? nameservers.global : nameservers[name];
|
||||
const submit = useSubmit();
|
||||
|
||||
if (list.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const submit = useSubmit();
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{isGlobal ? (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between w-full gap-2">
|
||||
<h2 className="text-md font-medium opacity-80">
|
||||
Global Nameservers
|
||||
</h2>
|
||||
@@ -87,8 +88,8 @@ function NameserverList({
|
||||
names outside the tailnet. When disabled (default), devices
|
||||
will prefer their local DNS configuration.
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1054/dns#global-nameservers"
|
||||
name="Tailscale Global Nameservers Documentation"
|
||||
to="https://tailscale.com/kb/1054/dns#global-nameservers"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
@@ -96,11 +97,10 @@ function NameserverList({
|
||||
</Tooltip>
|
||||
<p>Override DNS servers</p>
|
||||
<Switch
|
||||
label="Override local DNS settings"
|
||||
className="h-[15px] w-[23px] p-[2px]"
|
||||
switchClassName="h-[9px] w-[9px]"
|
||||
name="override_dns"
|
||||
className="h-[15px] w-[23px] p-0.5"
|
||||
defaultSelected={overrideLocalDns}
|
||||
label="Override local DNS settings"
|
||||
name="override_dns"
|
||||
onChange={(v) => {
|
||||
submit(
|
||||
{
|
||||
@@ -112,6 +112,7 @@ function NameserverList({
|
||||
},
|
||||
);
|
||||
}}
|
||||
switchClassName="h-[9px] w-[9px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,20 +126,20 @@ function NameserverList({
|
||||
<TableList.Item key={ns}>
|
||||
<p className="font-mono text-sm">{ns}</p>
|
||||
<Form method="POST">
|
||||
<input type="hidden" name="action_id" value="remove_ns" />
|
||||
<input type="hidden" name="ns" value={ns} />
|
||||
<input name="action_id" type="hidden" value="remove_ns" />
|
||||
<input name="ns" type="hidden" value={ns} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="split_name"
|
||||
type="hidden"
|
||||
value={isGlobal ? 'global' : name}
|
||||
/>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
type="submit"
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md',
|
||||
'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
type="submit"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
|
||||
@@ -13,14 +13,14 @@ interface Props {
|
||||
|
||||
export default function ManageRecords({ records, isDisabled }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<div className="flex flex-col w-full sm:w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
|
||||
<p>
|
||||
Headscale supports adding custom DNS records to your Tailnet. As of now,
|
||||
only <Code>A</Code> and <Code>AAAA</Code> records are supported.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/dns"
|
||||
name="Headscale DNS Records documentation"
|
||||
to="https://headscale.net/stable/ref/dns"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
@@ -32,7 +32,7 @@ export default function ManageRecords({ records, isDisabled }: Props) {
|
||||
<p className="opacity-50 mx-auto">No DNS records found</p>
|
||||
</TableList.Item>
|
||||
) : (
|
||||
records.map((record, index) => (
|
||||
records.map((record) => (
|
||||
<TableList.Item key={`${record.name}-${record.value}`}>
|
||||
<div className="flex gap-2 items-center w-full">
|
||||
<p
|
||||
@@ -43,22 +43,24 @@ export default function ManageRecords({ records, isDisabled }: Props) {
|
||||
>
|
||||
{record.type}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 w-full">
|
||||
<p className="font-mono text-sm">{record.name}</p>
|
||||
<p className="font-mono text-sm">{record.value}</p>
|
||||
<div className="flex flex-col sm:flex-row sm:gap-2 flex-1 min-w-0">
|
||||
<p className="font-mono text-sm truncate">{record.name}</p>
|
||||
<p className="font-mono text-sm truncate opacity-70 sm:opacity-100">
|
||||
{record.value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Form method="POST">
|
||||
<input type="hidden" name="action_id" value="remove_record" />
|
||||
<input type="hidden" name="record_name" value={record.name} />
|
||||
<input type="hidden" name="record_type" value={record.type} />
|
||||
<input name="action_id" type="hidden" value="remove_record" />
|
||||
<input name="record_name" type="hidden" value={record.name} />
|
||||
<input name="record_type" type="hidden" value={record.type} />
|
||||
<Button
|
||||
type="submit"
|
||||
isDisabled={isDisabled}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md',
|
||||
'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
type="submit"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
|
||||
@@ -9,21 +9,21 @@ interface Props {
|
||||
|
||||
export default function RenameTailnet({ name, isDisabled }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col w-2/3 gap-y-4">
|
||||
<div className="flex flex-col w-full sm:w-2/3 gap-y-4">
|
||||
<h1 className="text-2xl font-medium mb-2">Tailnet Name</h1>
|
||||
<p>
|
||||
This is the base domain name of your Tailnet. Devices are accessible at{' '}
|
||||
<Code>[device].{name}</Code> when Magic DNS is enabled.
|
||||
</p>
|
||||
<Input
|
||||
isReadOnly
|
||||
labelHidden
|
||||
className="w-3/5 font-medium text-sm"
|
||||
isReadOnly
|
||||
label="Tailnet name"
|
||||
value={name}
|
||||
labelHidden
|
||||
onFocus={(event) => {
|
||||
event.target.select();
|
||||
}}
|
||||
value={name}
|
||||
/>
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={isDisabled}>Rename Tailnet</Dialog.Button>
|
||||
@@ -33,13 +33,13 @@ export default function RenameTailnet({ name, isDisabled }: Props) {
|
||||
Keep in mind that changing this can lead to all sorts of unexpected
|
||||
behavior and may break existing devices in your tailnet.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="rename_tailnet" />
|
||||
<input name="action_id" type="hidden" value="rename_tailnet" />
|
||||
<Input
|
||||
defaultValue={name}
|
||||
isRequired
|
||||
label="Tailnet name"
|
||||
placeholder="ts.net"
|
||||
defaultValue={name}
|
||||
name="new_name"
|
||||
placeholder="ts.net"
|
||||
/>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
|
||||
@@ -84,20 +84,20 @@ export default function Page() {
|
||||
tailnet.
|
||||
</Notice>
|
||||
)}
|
||||
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
|
||||
<RenameTailnet isDisabled={isDisabled} name={data.baseDomain} />
|
||||
<ManageNS
|
||||
nameservers={allNs}
|
||||
isDisabled={isDisabled}
|
||||
nameservers={allNs}
|
||||
overrideLocalDns={data.overrideDns}
|
||||
/>
|
||||
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
|
||||
<ManageRecords isDisabled={isDisabled} records={data.extraRecords} />
|
||||
<ManageDomains
|
||||
searchDomains={data.searchDomains}
|
||||
isDisabled={isDisabled}
|
||||
magic={data.magicDns ? data.baseDomain : undefined}
|
||||
searchDomains={data.searchDomains}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col w-2/3">
|
||||
<div className="flex flex-col w-full sm:w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
|
||||
<p className="mb-4">
|
||||
Automatically register domain names for each device on the tailnet.
|
||||
@@ -108,7 +108,7 @@ export default function Page() {
|
||||
</Code>{' '}
|
||||
when Magic DNS is enabled.
|
||||
</p>
|
||||
<ToggleMagic isEnabled={data.magicDns} isDisabled={isDisabled} />
|
||||
<ToggleMagic isDisabled={isDisabled} isEnabled={data.magicDns} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import * as hinfo from '~/utils/host-info';
|
||||
import { PopulatedNode } from '~/utils/node-info';
|
||||
import { formatTimeDelta } from '~/utils/time';
|
||||
import toast from '~/utils/toast';
|
||||
import MenuOptions from './menu';
|
||||
|
||||
@@ -33,10 +34,7 @@ export default function MachineRow({
|
||||
isDisabled,
|
||||
existingTags,
|
||||
}: Props) {
|
||||
const uiTags = useMemo(() => {
|
||||
const tags = uiTagsForNode(node, isAgent);
|
||||
return tags;
|
||||
}, [node, isAgent]);
|
||||
const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]);
|
||||
|
||||
const ipOptions = useMemo(() => {
|
||||
if (magic) {
|
||||
@@ -129,22 +127,30 @@ export default function MachineRow({
|
||||
</td>
|
||||
) : undefined}
|
||||
<td className="py-2">
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 text-sm',
|
||||
'text-headplane-600 dark:text-headplane-300',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-x-1">
|
||||
<StatusCircle
|
||||
className="w-4 h-4"
|
||||
className="w-4 h-4 mt-0.5"
|
||||
isOnline={node.online && !node.expired}
|
||||
/>
|
||||
<p suppressHydrationWarning>
|
||||
{node.online && !node.expired
|
||||
? 'Connected'
|
||||
: new Date(node.lastSeen).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
'text-sm',
|
||||
'text-headplane-600 dark:text-headplane-300',
|
||||
)}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{node.online && !node.expired
|
||||
? 'Connected'
|
||||
: new Date(node.lastSeen).toLocaleString()}
|
||||
</p>
|
||||
{!(node.online && !node.expired) && (
|
||||
<p className="text-xs opacity-50" suppressHydrationWarning>
|
||||
{formatTimeDelta(new Date(node.lastSeen))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
<MenuOptions
|
||||
|
||||
@@ -11,11 +11,11 @@ interface MoveProps {
|
||||
}
|
||||
|
||||
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
|
||||
const [userId, setUserId] = useState<Key | null>(null);
|
||||
const [userId, setUserId] = useState<Key | null>(machine.user.id);
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Panel isDisabled={userId === machine.user.id}>
|
||||
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
The owner of the machine is the user associated with it.
|
||||
|
||||
@@ -20,10 +20,12 @@ export default function NewMachine(data: NewMachineProps) {
|
||||
const [mkey, setMkey] = useState('');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isMkeyInvalid = mkey.length > 0 && mkey.length !== 24;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog isOpen={pushDialog} onOpenChange={setPushDialog}>
|
||||
<Dialog.Panel isDisabled={mkey.length < 1}>
|
||||
<Dialog.Panel isDisabled={mkey.length !== 24}>
|
||||
<Dialog.Title>Register Machine Key</Dialog.Title>
|
||||
<Dialog.Text className="mb-4">
|
||||
The machine key is given when you run{' '}
|
||||
@@ -32,6 +34,8 @@ export default function NewMachine(data: NewMachineProps) {
|
||||
</Dialog.Text>
|
||||
<input name="action_id" type="hidden" value="register" />
|
||||
<Input
|
||||
errorMessage="Machine key must be exactly 24 characters"
|
||||
isInvalid={isMkeyInvalid}
|
||||
isRequired
|
||||
label="Machine Key"
|
||||
name="register_key"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Info } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, Info, X } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import Code from '~/components/Code';
|
||||
import Input from '~/components/Input';
|
||||
import Link from '~/components/Link';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
@@ -66,11 +68,97 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
export const action = machineAction;
|
||||
|
||||
type SortField = 'name' | 'ip' | 'version' | 'lastSeen';
|
||||
|
||||
export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortField, setSortField] = useState<SortField>('name');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
const filteredAndSortedNodes = useMemo(() => {
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
|
||||
let nodes = loaderData.populatedNodes.filter((node) => {
|
||||
if (!query) return true;
|
||||
if (node.givenName.toLowerCase().includes(query)) return true;
|
||||
if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query)))
|
||||
return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
nodes = [...nodes].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (sortField) {
|
||||
case 'name':
|
||||
comparison = a.givenName.localeCompare(b.givenName);
|
||||
break;
|
||||
case 'ip': {
|
||||
const getIPv4 = (addresses: string[]) =>
|
||||
addresses.find((ip) => !ip.includes(':')) || addresses[0] || '';
|
||||
const ipA = getIPv4(a.ipAddresses);
|
||||
const ipB = getIPv4(b.ipAddresses);
|
||||
|
||||
if (!ipA.includes(':') && !ipB.includes(':')) {
|
||||
const octetsA = ipA.split('.').map(Number);
|
||||
const octetsB = ipB.split('.').map(Number);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (octetsA[i] !== octetsB[i]) {
|
||||
comparison = octetsA[i] - octetsB[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
comparison = ipA.localeCompare(ipB);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'version': {
|
||||
const versionA = a.hostInfo?.IPNVersion?.split('-')[0] || '0';
|
||||
const versionB = b.hostInfo?.IPNVersion?.split('-')[0] || '0';
|
||||
const partsA = versionA.split('.').map(Number);
|
||||
const partsB = versionB.split('.').map(Number);
|
||||
const maxLen = Math.max(partsA.length, partsB.length);
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const segA = partsA[i] || 0;
|
||||
const segB = partsB[i] || 0;
|
||||
if (segA !== segB) {
|
||||
comparison = segA - segB;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'lastSeen':
|
||||
if (a.online !== b.online) {
|
||||
comparison = a.online ? 1 : -1;
|
||||
break;
|
||||
}
|
||||
comparison =
|
||||
new Date(a.lastSeen).getTime() - new Date(b.lastSeen).getTime();
|
||||
break;
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
return nodes;
|
||||
}, [loaderData.populatedNodes, searchQuery, sortField, sortDirection]);
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex flex-col w-2/3">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-2xl font-medium mb-2">Machines</h1>
|
||||
<p>
|
||||
Manage the devices connected to your Tailnet.{' '}
|
||||
@@ -89,61 +177,215 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
users={loaderData.users}
|
||||
/>
|
||||
</div>
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||
<tr className="text-left px-0.5">
|
||||
<th className="uppercase text-xs font-bold pb-2">Name</th>
|
||||
<th className="pb-2 w-1/4">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<p className="uppercase text-xs font-bold">Addresses</p>
|
||||
{loaderData.magic ? (
|
||||
<Tooltip>
|
||||
<Info className="w-4 h-4" />
|
||||
<Tooltip.Body className="font-normal">
|
||||
Since MagicDNS is enabled, you can access devices based on
|
||||
their name and also at{' '}
|
||||
<Code>
|
||||
[name].
|
||||
{loaderData.magic}
|
||||
</Code>
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
</div>
|
||||
</th>
|
||||
{/* We only want to show the version column if there are agents */}
|
||||
{loaderData.agent !== undefined ? (
|
||||
<th className="uppercase text-xs font-bold pb-2">Version</th>
|
||||
) : undefined}
|
||||
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={cn(
|
||||
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
|
||||
'border-t border-headplane-100 dark:border-headplane-800',
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<div className="relative w-64">
|
||||
<Input
|
||||
label="Search machines"
|
||||
labelHidden
|
||||
maxLength={100}
|
||||
onChange={(value) => setSearchQuery(value.slice(0, 100))}
|
||||
placeholder="Search by name or IP address..."
|
||||
value={searchQuery}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
aria-label="Clear search"
|
||||
className={cn(
|
||||
'absolute right-2 top-1/2 -translate-y-1/2',
|
||||
'p-1 rounded-full',
|
||||
'text-headplane-400 hover:text-headplane-600',
|
||||
'dark:text-headplane-500 dark:hover:text-headplane-300',
|
||||
'hover:bg-headplane-100 dark:hover:bg-headplane-800',
|
||||
)}
|
||||
onClick={() => setSearchQuery('')}
|
||||
type="button"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{loaderData.populatedNodes.map((node) => (
|
||||
<MachineRow
|
||||
existingTags={sortNodeTags(loaderData.nodes)}
|
||||
isAgent={
|
||||
loaderData.agent ? loaderData.agent === node.nodeKey : undefined
|
||||
}
|
||||
isDisabled={
|
||||
loaderData.writable
|
||||
? false // If the user has write permissions, they can edit all machines
|
||||
: node.user.providerId?.split('/').pop() !==
|
||||
loaderData.subject
|
||||
}
|
||||
key={node.id}
|
||||
magic={loaderData.magic}
|
||||
node={node}
|
||||
users={loaderData.users}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<span className="text-sm text-headplane-500 whitespace-nowrap">
|
||||
{searchQuery
|
||||
? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines`
|
||||
: `${loaderData.populatedNodes.length} machines`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table-auto w-full rounded-lg min-w-[640px]">
|
||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||
<tr className="text-left px-0.5">
|
||||
<th
|
||||
aria-sort={
|
||||
sortField === 'name'
|
||||
? sortDirection === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
}
|
||||
className="uppercase text-xs font-bold pb-2"
|
||||
>
|
||||
<button
|
||||
aria-label="Sort by name"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 cursor-pointer',
|
||||
'hover:text-headplane-900 dark:hover:text-headplane-100',
|
||||
)}
|
||||
onClick={() => handleSort('name')}
|
||||
type="button"
|
||||
>
|
||||
Name
|
||||
{sortField === 'name' &&
|
||||
(sortDirection === 'asc' ? (
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
))}
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
aria-sort={
|
||||
sortField === 'ip'
|
||||
? sortDirection === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
}
|
||||
className="pb-2 w-1/4"
|
||||
>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<button
|
||||
aria-label="Sort by IP address"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 cursor-pointer uppercase text-xs font-bold',
|
||||
'hover:text-headplane-900 dark:hover:text-headplane-100',
|
||||
)}
|
||||
onClick={() => handleSort('ip')}
|
||||
type="button"
|
||||
>
|
||||
Addresses
|
||||
{sortField === 'ip' &&
|
||||
(sortDirection === 'asc' ? (
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
))}
|
||||
</button>
|
||||
{loaderData.magic ? (
|
||||
<Tooltip>
|
||||
<Info className="w-4 h-4" />
|
||||
<Tooltip.Body className="font-normal">
|
||||
Since MagicDNS is enabled, you can access devices based
|
||||
on their name and also at{' '}
|
||||
<Code>
|
||||
[name].
|
||||
{loaderData.magic}
|
||||
</Code>
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
</div>
|
||||
</th>
|
||||
{/* We only want to show the version column if there are agents */}
|
||||
{loaderData.agent !== undefined ? (
|
||||
<th
|
||||
aria-sort={
|
||||
sortField === 'version'
|
||||
? sortDirection === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
}
|
||||
className="uppercase text-xs font-bold pb-2"
|
||||
>
|
||||
<button
|
||||
aria-label="Sort by version"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 cursor-pointer',
|
||||
'hover:text-headplane-900 dark:hover:text-headplane-100',
|
||||
)}
|
||||
onClick={() => handleSort('version')}
|
||||
type="button"
|
||||
>
|
||||
Version
|
||||
{sortField === 'version' &&
|
||||
(sortDirection === 'asc' ? (
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
))}
|
||||
</button>
|
||||
</th>
|
||||
) : undefined}
|
||||
<th
|
||||
aria-sort={
|
||||
sortField === 'lastSeen'
|
||||
? sortDirection === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
}
|
||||
className="uppercase text-xs font-bold pb-2"
|
||||
>
|
||||
<button
|
||||
aria-label="Sort by last seen"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 cursor-pointer',
|
||||
'hover:text-headplane-900 dark:hover:text-headplane-100',
|
||||
)}
|
||||
onClick={() => handleSort('lastSeen')}
|
||||
type="button"
|
||||
>
|
||||
Last Seen
|
||||
{sortField === 'lastSeen' &&
|
||||
(sortDirection === 'asc' ? (
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
))}
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={cn(
|
||||
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
|
||||
'border-t border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
{filteredAndSortedNodes.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
className="py-8 text-center text-headplane-500"
|
||||
colSpan={loaderData.agent !== undefined ? 5 : 4}
|
||||
>
|
||||
No machines found matching "{searchQuery}"
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredAndSortedNodes.map((node) => (
|
||||
<MachineRow
|
||||
existingTags={sortNodeTags(loaderData.nodes)}
|
||||
isAgent={
|
||||
loaderData.agent
|
||||
? loaderData.agent === node.nodeKey
|
||||
: undefined
|
||||
}
|
||||
isDisabled={
|
||||
loaderData.writable
|
||||
? false // If the user has write permissions, they can edit all machines
|
||||
: node.user.providerId?.split('/').pop() !==
|
||||
loaderData.subject
|
||||
}
|
||||
key={node.id}
|
||||
magic={loaderData.magic}
|
||||
node={node}
|
||||
users={loaderData.users}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function Page({
|
||||
}: Route.ComponentProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8 max-w-(--breakpoint-lg)">
|
||||
<div className="flex flex-col w-2/3">
|
||||
<div className="flex flex-col w-full sm:w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Settings</h1>
|
||||
<p>
|
||||
The settings page is still under construction. As I'm able to add more
|
||||
@@ -23,7 +23,7 @@ export default function Page({
|
||||
free to open an issue on the GitHub repository.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col w-2/3">
|
||||
<div className="flex flex-col w-full sm:w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Pre-Auth Keys</h1>
|
||||
<p>
|
||||
Headscale fully supports pre-authentication keys in order to easily
|
||||
@@ -45,7 +45,7 @@ export default function Page({
|
||||
</RemixLink>
|
||||
{config && isOidcEnabled ? (
|
||||
<>
|
||||
<div className="flex flex-col w-2/3">
|
||||
<div className="flex flex-col w-full sm:w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">
|
||||
Authentication Restrictions
|
||||
</h1>
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function Page({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 max-w-(--breakpoint-lg)">
|
||||
<div className="flex flex-col w-2/3">
|
||||
<div className="flex flex-col w-full sm:w-2/3">
|
||||
<p className="mb-4 text-md">
|
||||
<RemixLink className="font-medium" to="/settings">
|
||||
Settings
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function RestrictionTable({
|
||||
isDisabled,
|
||||
}: RestrictionProps) {
|
||||
return (
|
||||
<div className="w-2/3">
|
||||
<div className="w-full sm:w-2/3">
|
||||
<h2 className="text-2xl font-medium mt-8">
|
||||
Permitted {type.charAt(0).toUpperCase() + type.slice(1)}s
|
||||
</h2>
|
||||
@@ -61,7 +61,7 @@ export default function RestrictionTable({
|
||||
) : (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
{iconForType(type)}
|
||||
<p className="font-semibold">
|
||||
<p className="font-semibold text-center">
|
||||
All {type}s are permitted to authenticate.
|
||||
</p>
|
||||
</TableList.Item>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Machine, User } from '~/types';
|
||||
@@ -32,6 +33,18 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const users = apiUsers.map((user) => ({
|
||||
...user,
|
||||
machines: nodes.filter((node) => node.user.id === user.id),
|
||||
profilePicUrl:
|
||||
context.config.oidc?.profile_picture_source === 'gravatar'
|
||||
? (() => {
|
||||
if (!user.email) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const emailHash = user.email.trim().toLowerCase();
|
||||
const hash = createHash('sha256').update(emailHash).digest('hex');
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
|
||||
})()
|
||||
: user.profilePicUrl,
|
||||
}));
|
||||
|
||||
const roles = await Promise.all(
|
||||
@@ -101,32 +114,34 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
Manage the users in your network and their permissions.
|
||||
</p>
|
||||
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||
<tr className="text-left px-0.5">
|
||||
<th className="uppercase text-xs font-bold pb-2">User</th>
|
||||
<th className="uppercase text-xs font-bold pb-2">Role</th>
|
||||
<th className="uppercase text-xs font-bold pb-2">Created At</th>
|
||||
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={cn(
|
||||
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
|
||||
'border-t border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
{users
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
role={loaderData.roles[users.indexOf(user)]}
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table-auto w-full rounded-lg min-w-[640px]">
|
||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||
<tr className="text-left px-0.5">
|
||||
<th className="uppercase text-xs font-bold pb-2">User</th>
|
||||
<th className="uppercase text-xs font-bold pb-2">Role</th>
|
||||
<th className="uppercase text-xs font-bold pb-2">Created At</th>
|
||||
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={cn(
|
||||
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
|
||||
'border-t border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
{users
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
role={loaderData.roles[users.indexOf(user)]}
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { versions } from 'node:process';
|
||||
import { data } from 'react-router';
|
||||
import type { Route } from './+types/info';
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
if (context.config.server.info_secret == null) {
|
||||
throw data(
|
||||
{
|
||||
status: 'Forbidden',
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const bearer = request.headers.get('Authorization') ?? '';
|
||||
if (!bearer.startsWith('Bearer ')) {
|
||||
throw data(
|
||||
{
|
||||
status: 'Unauthorized',
|
||||
},
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
const token = bearer.slice('Bearer '.length).trim();
|
||||
if (token !== context.config.server.info_secret) {
|
||||
throw data(
|
||||
{
|
||||
status: 'Forbidden',
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
// Use a fake API key for healthcheck
|
||||
const api = context.hsApi.getRuntimeClient('fake-api-key');
|
||||
const healthy = await api.isHealthy();
|
||||
|
||||
const body = {
|
||||
status: healthy ? 'healthy' : 'unhealthy',
|
||||
headplane_version: __VERSION__,
|
||||
headscale_canonical_version: healthy ? context.hsApi.apiVersion : 'unknown',
|
||||
internal_versions: {
|
||||
node: versions.node,
|
||||
v8: versions.v8,
|
||||
uv: versions.uv,
|
||||
zlib: versions.zlib,
|
||||
openssl: versions.openssl,
|
||||
libc: versions.libc,
|
||||
},
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type } from 'arktype';
|
||||
import log from '~/utils/log';
|
||||
import DockerIntegration from './integration/docker';
|
||||
import KubernetesIntegration from './integration/kubernetes';
|
||||
import ProcIntegration from './integration/proc';
|
||||
@@ -14,7 +15,9 @@ export const pathSupportedKeys = [
|
||||
const serverConfig = type({
|
||||
host: 'string.ip = "0.0.0.0"',
|
||||
port: 'number.integer = 3000',
|
||||
base_url: 'string.url?',
|
||||
data_path: 'string.lower = "/var/lib/headplane/"',
|
||||
info_secret: 'string?',
|
||||
|
||||
cookie_secret: '(32 <= string <= 32)',
|
||||
cookie_secure: 'boolean = true',
|
||||
@@ -25,7 +28,9 @@ const serverConfig = type({
|
||||
const partialServerConfig = type({
|
||||
host: 'string.ip?',
|
||||
port: 'number.integer?',
|
||||
base_url: 'string.url?',
|
||||
data_path: 'string.lower?',
|
||||
info_secret: 'string?',
|
||||
|
||||
cookie_secret: '(32 <= string <= 32)?',
|
||||
cookie_secure: 'boolean?',
|
||||
@@ -62,7 +67,32 @@ const oidcConfig = type({
|
||||
client_id: 'string',
|
||||
client_secret: 'string',
|
||||
headscale_api_key: 'string',
|
||||
redirect_uri: 'string.url?',
|
||||
use_pkce: 'boolean = false',
|
||||
redirect_uri: type('string.url')
|
||||
.pipe((value, ctx) => {
|
||||
log.warn(
|
||||
'config',
|
||||
'%s is deprecated and will be removed in 0.7.0',
|
||||
ctx.propString,
|
||||
);
|
||||
|
||||
const cleanedValue = new URL(value.trim());
|
||||
if (cleanedValue.pathname.endsWith(`${__PREFIX__}/oidc/callback`)) {
|
||||
cleanedValue.pathname = cleanedValue.pathname.replace(
|
||||
`${__PREFIX__}/oidc/callback`,
|
||||
'/',
|
||||
);
|
||||
|
||||
log.warn(
|
||||
'config',
|
||||
'Please migrate to using `server.base_url` with a value of "%s"',
|
||||
cleanedValue.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
return cleanedValue.toString();
|
||||
})
|
||||
.optional(),
|
||||
disable_api_key_login: 'boolean = false',
|
||||
scope: 'string = "openid email profile"',
|
||||
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
|
||||
@@ -71,19 +101,19 @@ const oidcConfig = type({
|
||||
authorization_endpoint: 'string.url?',
|
||||
token_endpoint: 'string.url?',
|
||||
userinfo_endpoint: 'string.url?',
|
||||
token_endpoint_auth_method:
|
||||
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
|
||||
|
||||
// Old/deprecated options
|
||||
user_storage_file: 'string.lower = "/var/lib/headplane/users.json"',
|
||||
strict_validation: type('unknown').narrow(deprecatedField()).optional(),
|
||||
token_endpoint_auth_method: type('unknown')
|
||||
.narrow(deprecatedField())
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const partialOidcConfig = type({
|
||||
issuer: 'string.url?',
|
||||
client_id: 'string?',
|
||||
client_secret: 'string?',
|
||||
use_pkce: 'boolean?',
|
||||
headscale_api_key: 'string?',
|
||||
redirect_uri: 'string.url?',
|
||||
disable_api_key_login: 'boolean?',
|
||||
@@ -94,13 +124,12 @@ const partialOidcConfig = type({
|
||||
authorization_endpoint: 'string.url?',
|
||||
token_endpoint: 'string.url?',
|
||||
userinfo_endpoint: 'string.url?',
|
||||
token_endpoint_auth_method:
|
||||
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
|
||||
|
||||
// Old/deprecated options
|
||||
user_storage_file: 'string.lower?',
|
||||
strict_validation: type('unknown').narrow(deprecatedField()).optional(),
|
||||
token_endpoint_auth_method: type('unknown')
|
||||
.narrow(deprecatedField())
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const agentConfig = type({
|
||||
|
||||
@@ -44,6 +44,13 @@ export function isConnectionError(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error is a DataUnauthorizedError.
|
||||
* This checks if the error has a `data` property with a `statusCode` of 401.
|
||||
*
|
||||
* @param error - The error to check.
|
||||
* @returns True if the error is a DataUnauthorizedError, false otherwise.
|
||||
*/
|
||||
export function isDataUnauthorizedError(error: unknown): boolean {
|
||||
return (
|
||||
error != null &&
|
||||
@@ -55,3 +62,23 @@ export function isDataUnauthorizedError(error: unknown): boolean {
|
||||
error.data.statusCode === 401
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error is a DataWithResponseInit wrapping a
|
||||
* HeadscaleAPIError. This is used in loaders/actions to handle errors thrown by
|
||||
* `data()` before they reach the ErrorBoundary.
|
||||
*
|
||||
* @param error - The error to check.
|
||||
* @returns True if the error is a DataWithResponseInit containing a
|
||||
* HeadscaleAPIError, false otherwise.
|
||||
*/
|
||||
export function isDataWithApiError(
|
||||
error: unknown,
|
||||
): error is { data: HeadscaleAPIError } {
|
||||
return (
|
||||
error != null &&
|
||||
typeof error === 'object' &&
|
||||
'data' in error &&
|
||||
isApiError(error.data)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// Represents an error that occurred during a response
|
||||
// Thrown when status codes are >= 400
|
||||
export default class ResponseError extends Error {
|
||||
status: number;
|
||||
response: string;
|
||||
requestUrl: string;
|
||||
responseObject?: Record<string, unknown>;
|
||||
|
||||
constructor(status: number, response: string, requestUrl: string) {
|
||||
super(`${requestUrl}: status ${status} - ${response}`);
|
||||
this.name = 'ResponseError';
|
||||
this.status = status;
|
||||
this.response = response;
|
||||
this.requestUrl = requestUrl;
|
||||
|
||||
try {
|
||||
// Try to parse the response as JSON to get a response object
|
||||
this.responseObject = JSON.parse(response);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,7 @@ const appLoadContext = {
|
||||
integration: await loadIntegration(config.integration),
|
||||
oidcConnector: config.oidc
|
||||
? await createOidcConnector(
|
||||
config.server.base_url,
|
||||
config.oidc,
|
||||
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ export type OidcConnector =
|
||||
| {
|
||||
isValid: true;
|
||||
isExclusive: boolean;
|
||||
usePKCE: boolean;
|
||||
client: oidc.Configuration;
|
||||
apiKey: string;
|
||||
scope: string;
|
||||
@@ -40,16 +41,23 @@ export type OidcConnector =
|
||||
* Creates an OIDC connector based on the configuration and Headscale API.
|
||||
* This will attempt to validate the configuration and return any errors.
|
||||
*
|
||||
* @param baseUrl The base URL of the Headplane server.
|
||||
* @param config The OIDC configuration.
|
||||
* @param client The Headscale runtime API client.
|
||||
* @returns An OIDC connector with validation status.
|
||||
*/
|
||||
export async function createOidcConnector(
|
||||
baseUrl: string | undefined,
|
||||
config: OidcConfig,
|
||||
client: RuntimeApiClient,
|
||||
): Promise<OidcConnector> {
|
||||
// TODO: MEANINGFUL LOGS NOT JUST DEBUG SPAM LOL
|
||||
//
|
||||
if (baseUrl == null && config.redirect_uri == null) {
|
||||
log.warn(
|
||||
'config',
|
||||
'OIDC is enabled but `server.base_url` is not set in the config. Starting in Headplane 0.7.0 this will be required for OIDC to function properly and will throw errors if not set, see https://headplane.net/features/sso#configuring-oidc for more information.',
|
||||
);
|
||||
}
|
||||
|
||||
const errors: OidcConnectorError[] = [];
|
||||
if (!config.headscale_api_key) {
|
||||
errors.push('INVALID_API_KEY');
|
||||
@@ -89,6 +97,7 @@ export async function createOidcConnector(
|
||||
return {
|
||||
isValid: true,
|
||||
isExclusive: config.disable_api_key_login,
|
||||
usePKCE: config.use_pkce,
|
||||
client: oidcClientOrErrors,
|
||||
apiKey: config.headscale_api_key,
|
||||
scope: config.scope,
|
||||
@@ -113,6 +122,13 @@ async function discoveryCoalesce(
|
||||
config.client_id,
|
||||
);
|
||||
metadata = client.serverMetadata();
|
||||
if (config.use_pkce === true && !client.serverMetadata().supportsPKCE()) {
|
||||
log.warn(
|
||||
'config',
|
||||
'OIDC provider does not support PKCE, but it is enabled in the config',
|
||||
);
|
||||
}
|
||||
|
||||
if (metadata.claims_supported != null) {
|
||||
if (!metadata.claims_supported.includes('sub')) {
|
||||
log.error('config', 'OIDC provider does not support `sub` claim');
|
||||
@@ -184,6 +200,7 @@ async function discoveryCoalesce(
|
||||
|
||||
const oidcClient = new oidc.Configuration(
|
||||
{
|
||||
...metadata,
|
||||
issuer: config.issuer,
|
||||
authorization_endpoint,
|
||||
token_endpoint,
|
||||
@@ -191,7 +208,53 @@ async function discoveryCoalesce(
|
||||
},
|
||||
config.client_id,
|
||||
config.client_secret,
|
||||
negotiateTokenEndpointAuthMethod(config, metadata),
|
||||
);
|
||||
|
||||
return oidcClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the token endpoint authentication method based on config and metadata.
|
||||
*
|
||||
* @param config The OIDC configuration.
|
||||
* @param metadata The OIDC server metadata.
|
||||
* @returns The client authentication method for the token endpoint.
|
||||
*/
|
||||
function negotiateTokenEndpointAuthMethod(
|
||||
config: OidcConfig,
|
||||
metadata: oidc.ServerMetadata,
|
||||
): oidc.ClientAuth {
|
||||
if (config.token_endpoint_auth_method != null) {
|
||||
switch (config.token_endpoint_auth_method) {
|
||||
case 'client_secret_basic':
|
||||
return oidc.ClientSecretBasic(config.client_secret);
|
||||
case 'client_secret_post':
|
||||
return oidc.ClientSecretPost(config.client_secret);
|
||||
case 'client_secret_jwt':
|
||||
return oidc.ClientSecretJwt(config.client_secret);
|
||||
}
|
||||
}
|
||||
|
||||
const supported = metadata.token_endpoint_auth_methods_supported;
|
||||
if (supported != null && supported.length > 0) {
|
||||
// Prefer client_secret_basic (spec default), otherwise use first available
|
||||
if (supported.includes('client_secret_basic')) {
|
||||
return oidc.ClientSecretBasic(config.client_secret);
|
||||
}
|
||||
|
||||
if (supported.includes('client_secret_post')) {
|
||||
return oidc.ClientSecretPost(config.client_secret);
|
||||
}
|
||||
|
||||
if (supported.includes('client_secret_jwt')) {
|
||||
return oidc.ClientSecretJwt(config.client_secret);
|
||||
}
|
||||
}
|
||||
|
||||
log.warn(
|
||||
'config',
|
||||
'Falling back to client_secret_basic for token endpoint authentication',
|
||||
);
|
||||
return oidc.ClientSecretBasic(config.client_secret);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
@utility container {
|
||||
margin-inline: auto;
|
||||
padding-inline: 1rem;
|
||||
max-width: 100%;
|
||||
|
||||
/* biome-ignore lint/correctness/noUnknownFunction: Tailwind CSS */
|
||||
@media (width >= theme(--breakpoint-sm)) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createCookie } from 'react-router';
|
||||
import type { HeadplaneConfig } from '~/server/config/config-schema';
|
||||
|
||||
export interface OidcStateCookie {
|
||||
nonce: string;
|
||||
state: string;
|
||||
verifier: string;
|
||||
redirect_uri: string;
|
||||
}
|
||||
|
||||
export function createOidcStateCookie(config: HeadplaneConfig) {
|
||||
const cookie = createCookie('__oidc_state', {
|
||||
httpOnly: true,
|
||||
maxAge: 1800,
|
||||
secure: config.server.cookie_secure,
|
||||
domain: config.server.cookie_domain,
|
||||
path: `${__PREFIX__}/oidc/callback`,
|
||||
});
|
||||
|
||||
return {
|
||||
...cookie,
|
||||
serialize: async (value: OidcStateCookie): Promise<string> => {
|
||||
return cookie.serialize(value);
|
||||
},
|
||||
|
||||
parse: async (
|
||||
cookieHeader: string | null,
|
||||
): Promise<OidcStateCookie | null> => {
|
||||
const parsed = await cookie.parse(cookieHeader);
|
||||
if (
|
||||
parsed == null ||
|
||||
typeof parsed !== 'object' ||
|
||||
typeof parsed.nonce !== 'string' ||
|
||||
typeof parsed.state !== 'string' ||
|
||||
typeof parsed.verifier !== 'string' ||
|
||||
typeof parsed.redirect_uri !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
nonce: parsed.nonce,
|
||||
state: parsed.state,
|
||||
verifier: parsed.verifier,
|
||||
redirect_uri: parsed.redirect_uri,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Formats the time delta since a given date into a human-readable string.
|
||||
* - Under 1 hour: "X minutes ago"
|
||||
* - Under 1 day: "X hours, Y minutes ago"
|
||||
* - Under 1 month: "X days, Y hours ago"
|
||||
* - Over 1 month: "X months, Y days ago"
|
||||
*/
|
||||
export function formatTimeDelta(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
|
||||
const minutes = Math.floor(diffMs / (1000 * 60));
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const months = Math.floor(days / 30);
|
||||
|
||||
if (minutes < 60) {
|
||||
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
if (hours < 24) {
|
||||
const remainingMinutes = minutes % 60;
|
||||
if (remainingMinutes === 0) {
|
||||
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
return `${hours} hour${hours !== 1 ? 's' : ''}, ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
if (days < 30) {
|
||||
const remainingHours = hours % 24;
|
||||
if (remainingHours === 0) {
|
||||
return `${days} day${days !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
return `${days} day${days !== 1 ? 's' : ''}, ${remainingHours} hour${remainingHours !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
const remainingDays = days % 30;
|
||||
if (remainingDays === 0) {
|
||||
return `${months} month${months !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
return `${months} month${months !== 1 ? 's' : ''}, ${remainingDays} day${remainingDays !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ BUILD_WASM=0
|
||||
BUILD_APP=0
|
||||
BUILD_AGENT=0
|
||||
BUILD_FAKE_SHELL=0
|
||||
BUILD_HEALTHCHECK=0
|
||||
SKIP_PATH_CHECKS=0
|
||||
SKIP_PNPM_PRUNE=0
|
||||
APP_INSTALL_ONLY=0
|
||||
@@ -23,6 +24,7 @@ APP_INSTALL_ONLY=0
|
||||
WASM_OUTPUT="$PUBLIC_DIR/hp_ssh.wasm"
|
||||
AGENT_OUTPUT="$BUILD_DIR/hp_agent"
|
||||
FAKE_SHELL_OUTPUT="$BUILD_DIR/hp_fake_sh"
|
||||
HEALTHCHECK_OUTPUT="$BUILD_DIR/hp_healthcheck"
|
||||
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
run() { echo ">> $*"; "$@"; }
|
||||
@@ -33,6 +35,7 @@ while [ $# -gt 0 ]; do
|
||||
--app) BUILD_APP=1 ;;
|
||||
--agent) BUILD_AGENT=1 ;;
|
||||
--fake-shell) BUILD_FAKE_SHELL=1 ;;
|
||||
--healthcheck) BUILD_HEALTHCHECK=1 ;;
|
||||
--skip-path-checks) SKIP_PATH_CHECKS=1 ;;
|
||||
--skip-pnpm-prune) SKIP_PNPM_PRUNE=1 ;;
|
||||
--app-install-only) APP_INSTALL_ONLY=1 ;;
|
||||
@@ -55,6 +58,12 @@ while [ $# -gt 0 ]; do
|
||||
FAKE_SHELL_OUTPUT=$1
|
||||
;;
|
||||
|
||||
--healthcheck-output)
|
||||
shift
|
||||
[ $# -gt 0 ] || die "--healthcheck-output requires a path"
|
||||
HEALTHCHECK_OUTPUT=$1
|
||||
;;
|
||||
|
||||
--help)
|
||||
cat <<EOF
|
||||
Usage: $0 [flags]
|
||||
@@ -62,12 +71,14 @@ Usage: $0 [flags]
|
||||
--app build react-router app
|
||||
--agent build tailscale agent
|
||||
--fake-shell build fake shell binary (for Docker)
|
||||
--healthcheck build healthcheck binary
|
||||
--skip-path-checks skip safety checks (ie. checking PATH)
|
||||
--skip-pnpm-prune skip pruning devDependencies from node_modules
|
||||
--app-install-only only install app dependencies, skip build
|
||||
--wasm-output <path> override wasm output path
|
||||
--agent-output <path> override agent output path
|
||||
--fake-shell-output <path> override fake shell output path
|
||||
--healthcheck-output <path> override healthcheck output path
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
@@ -84,6 +95,8 @@ if [ "$BUILD_WASM" -eq 0 ] && [ "$BUILD_APP" -eq 0 ] && \
|
||||
BUILD_WASM=1
|
||||
BUILD_APP=1
|
||||
BUILD_AGENT=1
|
||||
BUILD_HEALTHCHECK=1
|
||||
BUILD_FAKE_SHELL=0
|
||||
fi
|
||||
|
||||
if [ "$SKIP_PATH_CHECKS" -eq 0 ]; then
|
||||
@@ -96,6 +109,7 @@ if [ "$SKIP_PATH_CHECKS" -eq 0 ]; then
|
||||
[ "$BUILD_APP" -eq 1 ] && need_pnpm=1
|
||||
[ "$BUILD_AGENT" -eq 1 ] && need_go=1
|
||||
[ "$BUILD_FAKE_SHELL" -eq 1 ] && need_go=1
|
||||
[ "$BUILD_HEALTHCHECK" -eq 1 ] && need_go=1
|
||||
|
||||
if [ $need_go -eq 1 ]; then
|
||||
echo "==> Checking for Go toolchain"
|
||||
@@ -160,11 +174,16 @@ build_fake_shell() {
|
||||
-o "$FAKE_SHELL_OUTPUT" ./cmd/fake_sh
|
||||
}
|
||||
|
||||
[ "$BUILD_FAKE_SHELL" = 1 ] && build_fake_shell
|
||||
build_healthcheck() {
|
||||
echo "==> Building healthcheck binary → $HEALTHCHECK_OUTPUT"
|
||||
mkdir -p "$(dirname "$HEALTHCHECK_OUTPUT")"
|
||||
go build -o "$HEALTHCHECK_OUTPUT" ./cmd/hp_healthcheck
|
||||
}
|
||||
|
||||
[ "$BUILD_WASM" = 1 ] && build_wasm
|
||||
[ "$BUILD_APP" = 1 ] && build_app
|
||||
[ "$BUILD_AGENT" = 1 ] && build_agent
|
||||
[ "$BUILD_FAKE_SHELL" = 1 ] && build_fake_shell
|
||||
[ "$BUILD_HEALTHCHECK" = 1 ] && build_healthcheck
|
||||
|
||||
echo "✅ Build complete."
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get("http://localhost:3000/admin/healthz")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Health check failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Fprintf(os.Stderr, "Health check returned non-OK status: %d\n", resp.StatusCode)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Health check passed.")
|
||||
os.Exit(0)
|
||||
}
|
||||
+29
-10
@@ -4,6 +4,11 @@ server:
|
||||
host: "0.0.0.0"
|
||||
port: 3000
|
||||
|
||||
# The base URL for Headplane. Please keep in mind that this will be required
|
||||
# for Headscale to properly function going forward AND it should not include
|
||||
# the dashboard prefix (/admin) portion.
|
||||
base_url: "http://localhost:3000"
|
||||
|
||||
# The secret used to encode and decode web sessions (must be 32 characters)
|
||||
# You may also provide `cookie_secret_path` instead to read a value from disk.
|
||||
# See https://headplane.net/configuration/#sensitive-values
|
||||
@@ -30,6 +35,16 @@ server:
|
||||
# PLEASE ensure this directory is mounted if running in Docker.
|
||||
data_path: "/var/lib/headplane"
|
||||
|
||||
# The info secret is optional and allows access to certain debug endpoints
|
||||
# that may expose sensitive information about your Headplane instance.
|
||||
#
|
||||
# As of now, this protects the /api/info endpoint which exposes details about
|
||||
# the Headplane and Headscale versions in use. In the future, more endpoints
|
||||
# may be protected by this secret.
|
||||
#
|
||||
# If not set, these endpoints will be disabled.
|
||||
# info_secret: "<change_me_to_something_secure!>"
|
||||
|
||||
# Headscale specific settings to allow Headplane to talk
|
||||
# to Headscale and access deep integration features
|
||||
headscale:
|
||||
@@ -149,9 +164,9 @@ integration:
|
||||
proc:
|
||||
enabled: false
|
||||
|
||||
# OIDC Configuration for simpler authentication
|
||||
# (This is optional, but recommended for the best experience)
|
||||
# oidc:
|
||||
# OIDC Configuration for simpler authentication
|
||||
# (This is optional, but recommended for the best experience)
|
||||
# oidc:
|
||||
# The OIDC issuer URL
|
||||
# issuer: "https://accounts.google.com"
|
||||
|
||||
@@ -170,6 +185,11 @@ integration:
|
||||
# token_endpoint: ""
|
||||
# userinfo_endpoint: ""
|
||||
|
||||
# The authentication method to use when communicating with the token endpoint.
|
||||
# This is fully optional and Headplane will attempt to auto-detect the best
|
||||
# method and fall back to `client_secret_basic` if unsure.
|
||||
# token_endpoint_auth_method: "client_secret_post"
|
||||
|
||||
# The client ID for the OIDC client
|
||||
# For the best experience please ensure this is *identical* to the client_id
|
||||
# you are using for Headscale. because
|
||||
@@ -180,16 +200,15 @@ integration:
|
||||
# See https://headplane.net/configuration/#sensitive-values
|
||||
# client_secret: "<your-client-secret>"
|
||||
|
||||
# Whether to use PKCE when authenticating users. This is recommended as it
|
||||
# adds an extra layer of security to the authentication process. Enabling this
|
||||
# means your OIDC provider must support PKCE and it must be enabled on the
|
||||
# client.
|
||||
# use_pkce: true
|
||||
|
||||
# If you want to disable traditional login via Headscale API keys
|
||||
# disable_api_key_login: false
|
||||
|
||||
# Optional, but highly recommended otherwise Headplane
|
||||
# will attempt to automatically guess this from the issuer
|
||||
#
|
||||
# This should point to your publicly accessibly URL
|
||||
# for your Headplane instance with /admin/oidc/callback
|
||||
# redirect_uri: "http://localhost:3000/admin/oidc/callback"
|
||||
|
||||
# By default profile pictures are pulled from the OIDC provider when
|
||||
# we go to fetch the userinfo endpoint. Optionally, this can be set to
|
||||
# "oidc" or "gravatar" as of 0.6.1.
|
||||
|
||||
@@ -11,6 +11,9 @@ export default defineConfig({
|
||||
{ text: 'Home', link: '/' },
|
||||
{ text: 'Changelog', link: '/CHANGELOG' },
|
||||
],
|
||||
search: {
|
||||
provider: 'local',
|
||||
},
|
||||
sidebar: [
|
||||
{
|
||||
text: 'Getting Started',
|
||||
@@ -30,6 +33,10 @@ export default defineConfig({
|
||||
link: '/configuration',
|
||||
items: [
|
||||
{ text: 'Common Issues', link: '/configuration/common-issues' },
|
||||
{
|
||||
text: 'Sensitive Values',
|
||||
link: '/configuration#sensitive-values',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ text: 'Nix', link: '/Nix' },
|
||||
|
||||
+111
-1
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: Single Sign-On (SSO)
|
||||
description: Configure Single Sign-On (SSO) authentication for Headplane.
|
||||
outline: [2, 3]
|
||||
---
|
||||
|
||||
# Single Sign-On (SSO)
|
||||
@@ -11,4 +12,113 @@ description: Configure Single Sign-On (SSO) authentication for Headplane.
|
||||
<figcaption>SSO Configuration Page</figcaption>
|
||||
</figure>
|
||||
|
||||
TODO
|
||||
Single Sign-On allows users to authenticate with Headplane through an external
|
||||
Identity Provider (IdP). It does this using the OpenID Connect (OIDC) protocol,
|
||||
which is widely supported by many popular IdPs.
|
||||
|
||||
## Getting Started
|
||||
To set up Single Sign-On (SSO) with Headplane, there are several steps involved.
|
||||
As a general recommendation, please read through the entire guide before
|
||||
beginning the process as there are several important factors to consider.
|
||||
|
||||
### Requirements
|
||||
|
||||
::: warning
|
||||
If you are also using OpenID Connect (OIDC) authentication with Headscale, it is
|
||||
**fundamentally important** that both Headscale and Headplane are configured to
|
||||
use the *exact same client* in your Identity Provider (IdP). This means that
|
||||
both services should share the same client ID and secret.
|
||||
|
||||
This is necessary because Headplane relies on the user IDs provided by the IdP
|
||||
to match users with their equivalent Headscale users. If Headscale and Headplane
|
||||
are using different clients, the user IDs may not match up correctly, preventing
|
||||
a user from viewing their devices in Headplane.
|
||||
:::
|
||||
|
||||
You'll need the following things set up before proceeding:
|
||||
- A working Headplane installation that is already configured.
|
||||
- An Identity Provider (IdP) that supports OAuth2 and OpenID Connect (OIDC).
|
||||
- `server.base_url` set to the public URL of your Headplane instance in your
|
||||
configuration file (ie. the domain that's visible in the browser).
|
||||
- A Headscale API key with a relatively long expiration time (eg. 1 year).
|
||||
|
||||
### Configuring the Client
|
||||
You'll need to create a client in your Identity Provider (IdP) that Headplane
|
||||
can use for authentication. A part of that step involves giving an allowed
|
||||
"redirect URL" to your IdP. This URL is where the IdP will send users back to
|
||||
after they have authenticated.
|
||||
|
||||
For Headplane, the redirect URL will be in the following format, where the
|
||||
domain is replaced with the value set for `server.base_url` in your Headplane
|
||||
configuration:
|
||||
|
||||
```
|
||||
https://headplane.example.com/admin/oidc/callback
|
||||
```
|
||||
|
||||
Once you have created the client in your IdP, make note of the following
|
||||
information as you'll need it for the Headplane configuration:
|
||||
- Client ID
|
||||
- Client Secret (if applicable)
|
||||
- Issuer URL
|
||||
|
||||
### OIDC Configuration
|
||||
To enable OIDC authentication in Headplane, you'll need to add the necessary
|
||||
configuration options via the file or environment variables. See below:
|
||||
|
||||
```yaml
|
||||
oidc:
|
||||
headscale_api_key: "<generated-api-key>"
|
||||
issuer: "https://your-idp.com"
|
||||
client_id: "your-client-id"
|
||||
client_secret: "your-client-secret"
|
||||
# You can also provide the client secret via a file:
|
||||
# client_secret_path: "${HOME}/secrets/headplane_oidc_client_secret.txt"
|
||||
|
||||
# Those options should generally be sufficient, but you can also set these:
|
||||
# authorization_endpoint: ""
|
||||
# token_endpoint: ""
|
||||
# userinfo_endpoint: ""
|
||||
# scope: "openid email profile"
|
||||
# extra_params:
|
||||
# foo: "bar"
|
||||
# baz: "qux"
|
||||
```
|
||||
|
||||
Headplane automatically tries to discover the necessary OIDC endpoints but if
|
||||
your IdP does not support discovery, you may need to manually specify them.
|
||||
|
||||
### PKCE
|
||||
|
||||
::: warning
|
||||
Headplane currently only supports the **`S256`** code challenge method for PKCE.
|
||||
You may need to ensure that your Identity Provider is configured to accept this
|
||||
method.
|
||||
:::
|
||||
|
||||
By default, Headplane does not use PKCE (Proof Key for Code Exchange) when
|
||||
communicating with the Identity Provider. PKCE is generally a best practice for
|
||||
OIDC and can enhance security. *Some Identity Providers may even require PKCE
|
||||
to be used.* To enable PKCE you'll need to set `oidc.use_pkce`
|
||||
to `true` in your Headplane configuration file:
|
||||
|
||||
```yaml
|
||||
oidc:
|
||||
use_pkce: true
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
Some of the common issues you may encounter when configuring OIDC with Headplane
|
||||
include:
|
||||
|
||||
- **Invalid API Key**: Ensure that the API key provided to Headplane is valid
|
||||
and has not expired.
|
||||
- **Missing [some]_endpoint**: If your IdP does not provide standard OIDC
|
||||
endpoints, you may need to manually specify them in the Headplane configuration.
|
||||
- **Missing the `sub` claim**: Ensure that your IdP is configured to include the
|
||||
`sub` claim in the ID token, as this is required for Headplane to identify users.
|
||||
- **Redirect URI Mismatch**: Ensure that the redirect URI configured in your IdP
|
||||
and that `server.base_url` in Headplane match exactly.
|
||||
- **Cookie Issues**: The OIDC authentication relies on your cookie configuration
|
||||
for Headplane. If OIDC cannot complete due to a missing session or invalid
|
||||
session then please check your cookie settings.
|
||||
|
||||
@@ -39,6 +39,28 @@ It's important to mount your configuration file and also provide a persistent
|
||||
storage location for Headplane to store its own data. You can also change the
|
||||
port mapping if you want to run it on a different port.
|
||||
|
||||
## Health Checks
|
||||
|
||||
The Docker image includes a built-in healthcheck that verifies the Headplane
|
||||
server is running and responding. Docker will automatically monitor the
|
||||
container and report its health status. No additional configuration is required.
|
||||
|
||||
The healthcheck binary is located at `/bin/hp_healthcheck` inside the container.
|
||||
If you need to override the default healthcheck behavior, you can do so in your
|
||||
`compose.yaml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
headplane:
|
||||
image: ghcr.io/tale/headplane:latest
|
||||
healthcheck:
|
||||
test: ["/bin/hp_healthcheck"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 5s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
## Accessing Headplane
|
||||
|
||||
After starting the container, you can access the Headplane web interface by
|
||||
|
||||
Generated
+6
-6
@@ -7,11 +7,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1762521437,
|
||||
"narHash": "sha256-RXN+lcx4DEn3ZS+LqEJSUu/HH+dwGvy0syN7hTo/Chg=",
|
||||
"lastModified": 1764011051,
|
||||
"narHash": "sha256-M7SZyPZiqZUR/EiiBJnmyUbOi5oE/03tCeFrTiUZchI=",
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"rev": "07bacc9531f5f4df6657c0a02a806443685f384a",
|
||||
"rev": "17ed8d9744ebe70424659b0ef74ad6d41fc87071",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -40,11 +40,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1763191728,
|
||||
"narHash": "sha256-esRhOS0APE6k40Hs/jjReXg+rx+J5LkWw7cuWFKlwYA=",
|
||||
"lastModified": 1768032153,
|
||||
"narHash": "sha256-6kD1MdY9fsE6FgSwdnx29hdH2UcBKs3/+JJleMShuJg=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "1d4c88323ac36805d09657d13a5273aea1b34f0c",
|
||||
"rev": "3146c6aa9995e7351a398e17470e15305e6e18ff",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
Reference in New Issue
Block a user