Compare commits

..

14 Commits

Author SHA1 Message Date
Aarnav Tale 42ffe69486 feat: add docker health check 2026-01-13 22:54:11 -05:00
Aarnav Tale 0fb02d0d8b feat: implement better token auth endpoint method heuristic for oidc 2026-01-13 22:17:17 -05:00
Aarnav Tale d189d75d5d fix: resolve random css issues with mobile layouts 2026-01-11 15:55:14 -05:00
Aarnav Tale 8105b8f94d fix: enforce machine key length being 24 on registration 2026-01-11 15:20:00 -05:00
Aarnav Tale 5953740769 chore: update issue templates for new labels 2026-01-11 15:03:23 -05:00
Aarnav Tale 7a345d118c feat: check and warn for pkce errors if possible 2026-01-11 14:55:23 -05:00
Aarnav Tale 0965e16401 fix: apply gravatar images to the users page 2026-01-11 14:55:22 -05:00
Aarnav Tale e57821df14 Merge pull request #419 from tale/update_flake_lock_action 2026-01-11 14:41:10 -05:00
github-actions[bot] dfdf5d3ff9 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/3edc4a3' (2025-12-27)
  → 'github:nixos/nixpkgs/3146c6a' (2026-01-10)
2026-01-11 08:28:57 +00:00
Aarnav Tale 4046c6edb1 Merge pull request #409 from 0nano/0nano-doc-sso-patch 2025-12-31 15:11:44 -05:00
Aarnav Tale 7817addbc6 Merge pull request #408 from tale/update_flake_lock_action 2025-12-31 15:06:10 -05:00
github-actions[bot] e727c48406 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/f997fa0' (2025-12-11)
  → 'github:nixos/nixpkgs/3edc4a3' (2025-12-27)
2025-12-28 08:28:47 +00:00
_nano 85a0f932a7 Update OIDC callback URL in SSO documentation 2025-12-21 19:07:39 +01:00
Aarnav Tale 985d7d9dc6 chore: oops forgot changelog 2025-12-14 15:09:57 -05:00
29 changed files with 516 additions and 323 deletions
+26 -26
View File
@@ -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
+11 -11
View File
@@ -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
+6
View File
@@ -13,12 +13,18 @@
- 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
View File
@@ -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" ]
+6 -6
View File
@@ -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>
+17 -8
View File
@@ -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>
+5 -5
View File
@@ -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>
+1 -1
View File
@@ -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 />
+27 -3
View File
@@ -121,6 +121,26 @@ export async function loader({ request, context }: Route.LoaderArgs) {
'Got an OIDC response error body: %s',
JSON.stringify(error.cause),
);
// 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',
@@ -133,13 +153,17 @@ export async function loader({ request, context }: Route.LoaderArgs) {
log.error(
'auth',
'Got an OIDC authorization client error: %s',
error.cause.message,
error.cause instanceof Error
? error.cause.message
: String(error.cause),
);
} else {
log.error(
log.error(
'auth',
'Got an OIDC error: %s',
JSON.stringify(error.cause),
error instanceof Error && error.cause
? JSON.stringify(error.cause)
: String(error),
);
}
return redirect('/login?s=error_auth_failed');
+23 -23
View File
@@ -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>
+18 -17
View File
@@ -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 -11
View File
@@ -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>
+7 -7
View File
@@ -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>
+6 -6
View File
@@ -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>
);
+5 -1
View File
@@ -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"
+152 -150
View File
@@ -157,8 +157,8 @@ export default function Page({ loaderData }: Route.ComponentProps) {
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.{' '}
@@ -210,85 +210,13 @@ export default function Page({ loaderData }: Route.ComponentProps) {
: `${loaderData.populatedNodes.length} machines`}
</span>
</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
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 ? (
<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 === 'version'
sortField === 'name'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
@@ -297,16 +225,16 @@ export default function Page({ loaderData }: Route.ComponentProps) {
className="uppercase text-xs font-bold pb-2"
>
<button
aria-label="Sort by version"
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('version')}
onClick={() => handleSort('name')}
type="button"
>
Version
{sortField === 'version' &&
Name
{sortField === 'name' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
@@ -314,76 +242,150 @@ export default function Page({ loaderData }: Route.ComponentProps) {
))}
</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"
<th
aria-sort={
sortField === 'ip'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="pb-2 w-1/4"
>
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}
<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"
>
No machines found matching "{searchQuery}"
</td>
<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>
) : (
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>
</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>
</>
);
}
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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>
+41 -26
View File
@@ -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>
</>
);
}
+4 -6
View File
@@ -101,13 +101,12 @@ 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({
@@ -125,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({
+46
View File
@@ -208,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);
}
+1
View File
@@ -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)) {
+20 -1
View File
@@ -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."
+29
View File
@@ -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)
}
+8 -3
View File
@@ -164,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"
@@ -185,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
+1 -1
View File
@@ -53,7 +53,7 @@ domain is replaced with the value set for `server.base_url` in your Headplane
configuration:
```
https://headplane.example.com/admin/auth/callback
https://headplane.example.com/admin/oidc/callback
```
Once you have created the client in your IdP, make note of the following
+22
View File
@@ -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
+3 -3
View File
@@ -40,11 +40,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1765457389,
"narHash": "sha256-ddhDtNYvleZeYF7g7TRFSmuQuZh7HCgqstg5YBGwo5s=",
"lastModified": 1768032153,
"narHash": "sha256-6kD1MdY9fsE6FgSwdnx29hdH2UcBKs3/+JJleMShuJg=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "f997fa0f94fb1ce55bccb97f60d41412ae8fde4c",
"rev": "3146c6aa9995e7351a398e17470e15305e6e18ff",
"type": "github"
},
"original": {