Compare commits

..

21 Commits
0.5.8 ... main

Author SHA1 Message Date
David Gillespie
dbdb759a7e update tailwind
Some checks failed
Build / native (push) Has been cancelled
Build / nix (push) Has been cancelled
Release / Docker Release (push) Has been cancelled
Automated / flake-inputs (push) Has been cancelled
2025-05-09 23:44:57 -06:00
github-actions[bot]
6716e5f0b0
chore: update flake.lock (#202)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-04 15:25:02 -04:00
github-actions[bot]
346b44ec69
chore: update flake.lock (#196)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-03 23:46:31 -04:00
George Ntoutsos
faa61b0f1d
feat: add filtering by container label for Docker integration (#194) 2025-04-24 19:03:33 -04:00
github-actions[bot]
6b63fe209f
chore: update flake.lock (#190)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-20 15:38:03 -04:00
github-actions[bot]
c8507cff7c
chore: update flake.lock (#180)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-15 11:00:11 -04:00
Federico Cerutti
b86f4461c0
feat: restart with headscale, update server.js path in systemd example (#184) 2025-04-10 09:08:43 -04:00
Aarnav Tale
77b510c927
docs: oops 2025-04-05 13:14:00 -04:00
Aarnav Tale
5adcb8c582
ci: skip ci build on certain paths 2025-04-05 12:07:31 -04:00
Aarnav Tale
524c5eb639
docs: docker version tags had an extra char 2025-04-05 11:58:32 -04:00
Aarnav Tale
2894c664d3
docs: fix tag typo 2025-04-05 11:56:59 -04:00
Aarnav Tale
5c2d08decd
chore: update dep tooling 2025-04-04 16:09:10 -04:00
Aarnav Tale
f2e8c6ae4c
chore: update dep tooling 2025-04-04 16:06:22 -04:00
Aarnav Tale
c3e727842a
docs: mention versioning policy 2025-04-04 15:44:16 -04:00
Aarnav Tale
96345ab0a6
chore: switch from nightly to pr based incubration 2025-04-04 10:19:37 -04:00
Aarnav Tale
fe2d7cb57a
chore: v0.5.10 2025-04-03 23:44:19 -04:00
Aarnav Tale
66c7d9a327
chore: v0.5.9 2025-04-03 23:24:13 -04:00
Aarnav Tale
f2747ada94
fix: suppress date and button hydration warnings 2025-04-03 16:54:12 -04:00
Aarnav Tale
0ad578e651
fix: prevent user rename if they are an oidc user 2025-04-03 16:25:30 -04:00
Aarnav Tale
8d1132606a
fix: add aria-label to radio groups 2025-04-03 16:22:52 -04:00
Aarnav Tale
5e332c4a5c
fix: filter out empty users in auth-keys, potential headscale bug 2025-04-03 16:10:39 -04:00
30 changed files with 368 additions and 145 deletions

View File

@ -1,6 +1,12 @@
name: Build
on:
push:
paths-ignore:
- ".zed/**"
- "assets/**"
- "docs/**"
- "CHANGELOG.md"
- "README.md"
branches:
- "main"
- "next"
@ -30,7 +36,6 @@ jobs:
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10
run_install: false
- name: Get pnpm store directory

View File

@ -1,11 +1,11 @@
name: Nightly
name: Pre-release (next)
on:
schedule:
- cron: "0 8 * * *"
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: publish-nightly-${{ github.ref }}
group: pre-release-${{ github.ref }}
cancel-in-progress: true
permissions:
@ -15,12 +15,22 @@ permissions:
jobs:
publish:
name: docker-publish
# Ensure the action only runs if manually dispatched or a PR on the `next` branch in the *main* repository is opened or synchronized.
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'next') }}
name: Docker Pre-release
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=next
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@ -34,14 +44,6 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=edge,branch=main
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:

View File

@ -5,7 +5,7 @@ on:
- "*"
concurrency:
group: publish-${{ github.ref }}
group: release-${{ github.ref }}
cancel-in-progress: true
permissions:
@ -14,13 +14,21 @@ permissions:
packages: write # Write access to the container registry
jobs:
publish:
name: docker-publish
docker:
name: Docker Release
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@ -34,16 +42,6 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=false
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:

2
.tool-versions Normal file
View File

@ -0,0 +1,2 @@
pnpm 10.4.0
node 22

View File

@ -1,3 +1,11 @@
### 0.5.10 (April 4, 2025)
- Fix an issue where other prefernences to skip onboarding affected every user.
### 0.5.9 (April 3, 2025)
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users.
- OIDC users cannot be renamed, so that functionality has been disabled in the menu options.
- Suppress hydration errors for any fields with a date in it.
### 0.5.8 (April 3, 2025)
- You can now skip the onboarding page if desired.
- Added the UI to change user roles in the dashboard.

View File

@ -44,11 +44,18 @@ There are 2 ways to deploy Headplane:
Simple mode does not include the automatic management of DNS and Headplane
settings, requiring manual editing and reloading when making changes.
## Contributing
### Versioning
Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0).
Pre-release builds are available under the `next` tag and get updated when a new release
PR is opened and actively in testing.
### Contributing
Headplane is an open-source project and contributions are welcome! If you have
any suggestions, bug reports, or feature requests, please open an issue. Also
refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
---
<picture>
<source
media="(prefers-color-scheme: dark)"

View File

@ -7,6 +7,7 @@ export interface AttributeProps {
value: string;
isCopyable?: boolean;
link?: string;
suppressHydrationWarning?: boolean;
}
export default function Attribute({
@ -14,6 +15,7 @@ export default function Attribute({
value,
link,
isCopyable,
suppressHydrationWarning,
}: AttributeProps) {
return (
<dl className="flex items-center w-full gap-x-1">
@ -27,6 +29,7 @@ export default function Attribute({
)}
</dt>
<dd
suppressHydrationWarning={suppressHydrationWarning}
className={cn(
'rounded-lg truncate w-full px-2.5 py-1 text-sm',
'flex items-center gap-x-1',
@ -54,7 +57,12 @@ export default function Attribute({
}, 1000);
}}
>
<p className="truncate">{value}</p>
<p
suppressHydrationWarning={suppressHydrationWarning}
className="truncate"
>
{value}
</p>
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
</button>

View File

@ -17,6 +17,7 @@ import cn from '~/utils/cn';
interface MenuProps extends MenuTriggerProps {
placement?: Placement;
isDisabled?: boolean;
disabledKeys?: Key[];
children: [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<MenuPanelProps>,
@ -26,7 +27,7 @@ interface MenuProps extends MenuTriggerProps {
// TODO: onAction is called twice for some reason?
// TODO: isDisabled per-prop
function Menu(props: MenuProps) {
const { placement = 'bottom', isDisabled } = props;
const { placement = 'bottom', isDisabled, disabledKeys = [] } = props;
const state = useMenuTriggerState(props);
const ref = useRef<HTMLButtonElement | null>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
@ -51,6 +52,7 @@ function Menu(props: MenuProps) {
...menuProps,
autoFocus: state.focusStrategy ?? true,
onClose: () => state.close(),
disabledKeys,
})}
</Popover>
)}
@ -60,6 +62,7 @@ function Menu(props: MenuProps) {
interface MenuPanelProps extends AriaMenuProps<object> {
onClose?: () => void;
disabledKeys?: Key[];
}
function Panel(props: MenuPanelProps) {
@ -74,7 +77,12 @@ function Panel(props: MenuPanelProps) {
className="pt-1 pb-1 shadow-xs rounded-md min-w-[200px] focus:outline-none"
>
{[...state.collection].map((item) => (
<MenuSection key={item.key} section={item} state={state} />
<MenuSection
key={item.key}
section={item}
state={state}
disabledKeys={props.disabledKeys}
/>
))}
</ul>
);
@ -83,9 +91,10 @@ function Panel(props: MenuPanelProps) {
interface MenuSectionProps<T> {
section: Node<T>;
state: TreeState<T>;
disabledKeys?: Key[];
}
function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
function MenuSection<T>({ section, state, disabledKeys }: MenuSectionProps<T>) {
const { itemProps, groupProps } = useMenuSection({
heading: section.rendered,
'aria-label': section['aria-label'],
@ -109,7 +118,12 @@ function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
<li {...itemProps}>
<ul {...groupProps}>
{[...section.childNodes].map((item) => (
<MenuItem key={item.key} item={item} state={state} />
<MenuItem
key={item.key}
item={item}
state={state}
isDisabled={disabledKeys?.includes(item.key)}
/>
))}
</ul>
</li>
@ -120,14 +134,14 @@ function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
interface MenuItemProps<T> {
item: Node<T>;
state: TreeState<T>;
isDisabled?: boolean;
}
function MenuItem<T>({ item, state }: MenuItemProps<T>) {
function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
const ref = useRef<HTMLLIElement | null>(null);
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
const isFocused = state.selectionManager.focusedKey === item.key;
const isDisabled = state.selectionManager.isDisabled(item.key);
return (
<li

View File

@ -13,6 +13,7 @@ import { useRadioGroupState } from 'react-stately';
interface RadioGroupProps extends AriaRadioGroupProps {
children: React.ReactElement<RadioProps>[];
label: string;
className?: string;
}
@ -20,7 +21,13 @@ const RadioContext = createContext<RadioGroupState | null>(null);
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
const state = useRadioGroupState(props);
const { radioGroupProps, labelProps } = useRadioGroup(props, state);
const { radioGroupProps, labelProps } = useRadioGroup(
{
...props,
'aria-label': label,
},
state,
);
return (
<div {...radioGroupProps} className={cn('flex flex-col gap-2', className)}>
@ -33,13 +40,21 @@ function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
}
interface RadioProps extends AriaRadioProps {
label: string;
className?: string;
}
function Radio({ children, className, ...props }: RadioProps) {
function Radio({ children, label, className, ...props }: RadioProps) {
const state = useContext(RadioContext);
const ref = useRef(null);
const { inputProps, isSelected, isDisabled } = useRadio(props, state!, ref);
const { inputProps, isSelected, isDisabled } = useRadio(
{
...props,
'aria-label': label,
},
state!,
ref,
);
const { isFocusVisible, focusProps } = useFocusRing();
return (
@ -48,6 +63,7 @@ function Radio({ children, className, ...props }: RadioProps) {
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
</VisuallyHidden>
<div
aria-hidden="true"
className={cn(
'w-5 h-5 aspect-square rounded-full p-1 border-2',
'border border-headplane-600 dark:border-headplane-300',

View File

@ -7,13 +7,13 @@ import {
} from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Footer from '~/components/Footer';
import Header from '~/components/Header';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { User } from '~/types';
import log from '~/utils/log';
import toast from '~/utils/toast';
// This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops?
@ -36,48 +36,56 @@ export async function loader({
if (context.oidc && !request.url.endsWith('/onboarding')) {
let onboarded = false;
try {
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
);
if (users.length === 0) {
onboarded = false;
}
const user = users.find((u) => {
if (u.provider !== 'oidc') {
return false;
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = u.providerId?.split('/').pop();
if (!subject) {
return false;
}
const sessionUser = session.get('user');
if (!sessionUser) {
return false;
}
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
return true;
}
return subject === sessionUser.subject;
});
if (user) {
const sessionUser = session.get('user');
if (sessionUser) {
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
onboarded = true;
} else {
try {
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
);
if (users.length === 0) {
onboarded = false;
}
const user = users.find((u) => {
if (u.provider !== 'oidc') {
return false;
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = u.providerId?.split('/').pop();
if (!subject) {
return false;
}
const sessionUser = session.get('user');
if (!sessionUser) {
return false;
}
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
return true;
}
return subject === sessionUser.subject;
});
if (user) {
onboarded = true;
}
} catch (e) {
// If we cannot lookup users, just assume our user is onboarded
log.debug('api', 'Failed to lookup users %o', e);
onboarded = true;
}
}
} catch (e) {
// If we cannot lookup users, just assume our user is onboarded
log.debug('api', 'Failed to lookup users %o', e);
onboarded = true;
}
if (!onboarded) {
@ -134,11 +142,21 @@ export default function Shell() {
Connect to Tailscale with your devices to access this Tailnet. Use
this command to help you get started:
</Card.Text>
<Button className="pointer-events-none text-md hover:bg-initial focus:ring-0">
<Code className="pointer-events-auto bg-transparent" isCopyable>
tailscale up --login-server={data.url}
</Code>
<Button
className="flex text-md font-mono"
onPress={async () => {
await navigator.clipboard.writeText(
`tailscale up --login-server=${data.url}`,
);
toast('Copied to clipboard');
}}
>
tailscale up --login-server={data.url}
</Button>
<p className="text-xs mt-1 opacity-50 text-center">
Click this button to copy the command.
</p>
<p className="mt-4 text-sm opacity-50">
Your account does not have access to the UI. Please contact your
administrator if you believe this is a mistake.

View File

@ -180,7 +180,7 @@ export default function MachineRow({
isOnline={machine.online && !expired}
className="w-4 h-4"
/>
<p>
<p suppressHydrationWarning>
{machine.online && !expired
? 'Connected'
: new Date(machine.lastSeen).toLocaleString()}

View File

@ -310,14 +310,17 @@ export default function Page() {
<Attribute name="Hostname" value={machine.name} />
<Attribute isCopyable name="Node Key" value={machine.nodeKey} />
<Attribute
suppressHydrationWarning
name="Created"
value={new Date(machine.createdAt).toLocaleString()}
/>
<Attribute
suppressHydrationWarning
name="Last Seen"
value={new Date(machine.lastSeen).toLocaleString()}
/>
<Attribute
suppressHydrationWarning
name="Expiry"
value={expired ? new Date(machine.expiry).toLocaleString() : 'Never'}
/>

View File

@ -22,15 +22,17 @@ export async function loader({
);
const preAuthKeys = await Promise.all(
users.users.map((user) => {
const qp = new URLSearchParams();
qp.set('user', user.name);
users.users
.filter((user) => user.name?.length > 0) // Filter out any invalid users
.map((user) => {
const qp = new URLSearchParams();
qp.set('user', user.name);
return context.client.get<{ preAuthKeys: PreAuthKey[] }>(
`v1/preauthkey?${qp.toString()}`,
session.get('api_key')!,
);
}),
return context.client.get<{ preAuthKeys: PreAuthKey[] }>(
`v1/preauthkey?${qp.toString()}`,
session.get('api_key')!,
);
}),
);
return {

View File

@ -22,15 +22,19 @@ export default function AuthKeyRow({ authKey, server }: Props) {
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
<Attribute name="Created" value={createdAt} />
<Attribute name="Expiration" value={expiration} />
<Attribute suppressHydrationWarning name="Created" value={createdAt} />
<Attribute
suppressHydrationWarning
name="Expiration"
value={expiration}
/>
<p className="mb-1 mt-4">
To use this key, run the following command on your device:
</p>
<Code className="text-sm">
tailscale up --login-server {server} --authkey {authKey.key}
</Code>
<div className="flex gap-4 items-center">
<div suppressHydrationWarning className="flex gap-4 items-center">
{(authKey.used && !authKey.reusable) ||
new Date(authKey.expiration) < new Date() ? undefined : (
<ExpireKey authKey={authKey} />

View File

@ -48,7 +48,7 @@ export default function UserMenu({ user }: MenuProps) {
/>
)}
<Menu>
<Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : []}>
<Menu.IconButton
label="Machine Options"
className={cn(

View File

@ -42,7 +42,10 @@ export default function UserRow({ user, role }: UserRowProps) {
<p>{mapRoleToName(role)}</p>
</td>
<td className="pl-0.5 py-2">
<p className="text-sm text-headplane-600 dark:text-headplane-300">
<p
suppressHydrationWarning
className="text-sm text-headplane-600 dark:text-headplane-300"
>
{new Date(user.createdAt).toLocaleDateString()}
</p>
</td>
@ -54,7 +57,9 @@ export default function UserRow({ user, role }: UserRowProps) {
)}
>
<StatusCircle isOnline={isOnline} className="w-4 h-4" />
<p>{isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()}</p>
<p suppressHydrationWarning>
{isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()}
</p>
</span>
</td>
<td className="py-2 pr-0.5">

View File

@ -51,7 +51,7 @@ export default function ReassignUser({
.map((role) => {
const { name, desc } = mapRoleToName(role);
return (
<RadioGroup.Radio key={role} value={role}>
<RadioGroup.Radio key={role} value={role} label={name}>
<div className="block">
<p className="font-bold">{name}</p>
<p className="opacity-70">{desc}</p>

View File

@ -12,7 +12,6 @@ import {
} from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Options from '~/components/Options';
import StatusCircle from '~/components/StatusCircle';
@ -21,6 +20,7 @@ import { Machine } from '~/types';
import cn from '~/utils/cn';
import { useLiveData } from '~/utils/live-data';
import log from '~/utils/log';
import toast from '~/utils/toast';
export async function loader({
request,
@ -152,20 +152,19 @@ export default function Page() {
}
>
<Button
variant="heavy"
className={cn(
'my-4 px-0 w-full pointer-events-none',
'hover:bg-initial focus:ring-0',
)}
className="flex text-md font-mono"
onPress={async () => {
await navigator.clipboard.writeText(
'curl -fsSL https://tailscale.com/install.sh | sh',
);
toast('Copied to clipboard');
}}
>
<Code
isCopyable
className="bg-transparent pointer-events-auto mx-0"
>
curl -fsSL https://tailscale.com/install.sh | sh
</Code>
curl -fsSL https://tailscale.com/install.sh | sh
</Button>
<p className="text-end text-sm">
<p className="text-xs mt-1 text-headplane-600 dark:text-headplane-300 text-center">
Click this button to copy the command.{' '}
<Link
name="Linux installation script"
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"

View File

@ -3,6 +3,7 @@ import type { LoadContext } from '~/server';
import { Capabilities, Roles } from '~/server/web/roles';
import { AuthSession } from '~/server/web/sessions';
import { User } from '~/types';
import { data400, data403 } from '~/utils/res';
export async function userAction({
request,
@ -11,14 +12,14 @@ export async function userAction({
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.write_users);
if (!check) {
return data({ success: false }, 403);
throw data403('You do not have permission to update users');
}
const apiKey = session.get('api_key')!;
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
return data({ success: false }, 400);
throw data400('Missing `action_id` in the form data.');
}
switch (action) {
@ -31,7 +32,7 @@ export async function userAction({
case 'reassign_user':
return reassignUser(formData, apiKey, context, session);
default:
return data({ success: false }, 400);
throw data400('Invalid `action_id` provided.');
}
}
@ -45,7 +46,7 @@ async function createUser(
const email = formData.get('email')?.toString();
if (!name) {
return data({ success: false }, 400);
throw data400('Missing `username` in the form data.');
}
await context.client.post('v1/user', apiKey, {
@ -62,7 +63,7 @@ async function deleteUser(
) {
const userId = formData.get('user_id')?.toString();
if (!userId) {
return data({ success: false }, 400);
throw data400('Missing `user_id` in the form data.');
}
await context.client.delete(`v1/user/${userId}`, apiKey);
@ -79,6 +80,21 @@ async function renameUser(
return data({ success: false }, 400);
}
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
apiKey,
);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data400(`No user found with id: ${userId}`);
}
if (user.provider === 'oidc') {
// OIDC users cannot be renamed via this endpoint, return an error
throw data403('Users managed by OIDC cannot be renamed');
}
await context.client.post(`v1/user/${userId}/rename/${newName}`, apiKey);
}
@ -86,12 +102,11 @@ async function reassignUser(
formData: FormData,
apiKey: string,
context: LoadContext,
session: Session<AuthSession, unknown>,
) {
const userId = formData.get('user_id')?.toString();
const newRole = formData.get('new_role')?.toString();
if (!userId || !newRole) {
return data({ success: false }, 400);
throw data400('Missing `user_id` or `new_role` in the form data.');
}
const { users } = await context.client.get<{ users: User[] }>(
@ -101,14 +116,16 @@ async function reassignUser(
const user = users.find((user) => user.id === userId);
if (!user?.providerId) {
return data({ success: false }, 400);
throw data400('Specified user is not an OIDC user');
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = user.providerId?.split('/').pop();
if (!subject) {
return data({ success: false }, 400);
throw data400(
'Malformed `providerId` for the specified user. Cannot find subject.',
);
}
const result = await context.sessions.reassignSubject(

View File

@ -6,6 +6,11 @@ import log from '~/utils/log';
import type { HeadplaneConfig } from '../schema';
import { Integration } from './abstract';
interface DockerContainer {
Id: string;
Names: string[];
}
type T = NonNullable<HeadplaneConfig['integration']>['docker'];
export default class DockerIntegration extends Integration<T> {
private maxAttempts = 10;
@ -15,13 +20,63 @@ export default class DockerIntegration extends Integration<T> {
return 'Docker';
}
async getContainerName(label: string, value: string): Promise<string> {
if (!this.client) {
throw new Error('Docker client is not initialized');
}
const filters = encodeURIComponent(
JSON.stringify({
label: [`${label}=${value}`],
}),
);
const { body } = await this.client.request({
method: 'GET',
path: `/containers/json?filters=${filters}`,
});
const containers: DockerContainer[] =
(await body.json()) as DockerContainer[];
if (containers.length > 1) {
throw new Error(
`Found multiple Docker containers matching label ${label}=${value}. Please specify a container name.`,
);
}
if (containers.length === 0) {
throw new Error(
`No Docker containers found matching label: ${label}=${value}`,
);
}
log.info(
'config',
'Found Docker container matching label: %s=%s',
label,
value,
);
return containers[0].Id;
}
async isAvailable() {
if (this.context.container_name.length === 0) {
log.error('config', 'Docker container name is empty');
// Perform a basic check to see if any of the required properties are set
if (
this.context.container_name.length === 0 &&
!this.context.container_label
) {
log.error('config', 'Docker container name and label are both empty');
return false;
}
log.info('config', 'Using container: %s', this.context.container_name);
if (
this.context.container_name.length > 0 &&
!this.context.container_label
) {
log.error(
'config',
'Docker container name and label are mutually exclusive',
);
return false;
}
// Verify that Docker socket is reachable
let url: URL | undefined;
try {
url = new URL(this.context.socket);
@ -72,6 +127,38 @@ export default class DockerIntegration extends Integration<T> {
socketPath: url.pathname,
});
}
if (this.client === undefined) {
log.error('config', 'Failed to create Docker client');
return false;
}
if (this.context.container_name.length === 0) {
try {
if (this.context.container_label === undefined) {
log.error('config', 'Docker container label is not defined');
return false;
}
const containerName = await this.getContainerName(
this.context.container_label.name,
this.context.container_label.value,
);
if (containerName.length === 0) {
log.error(
'config',
'No Docker containers found matching label: %s=%s',
this.context.container_label.name,
this.context.container_label.value,
);
return false;
}
this.context.container_name = containerName;
} catch (error) {
log.error('config', 'Failed to get Docker container name: %s', error);
return false;
}
}
log.info('config', 'Using container: %s', this.context.container_name);
return this.client !== undefined;
}

View File

@ -41,10 +41,16 @@ const headscaleConfig = type({
config_strict: stringToBool,
}).onDeepUndeclaredKey('reject');
const containerLabel = type({
name: 'string',
value: 'string',
}).optional();
const dockerConfig = type({
enabled: stringToBool,
container_name: 'string',
socket: 'string = "unix:///var/run/docker.sock"',
container_label: containerLabel,
});
const kubernetesConfig = type({

View File

@ -3,7 +3,7 @@
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,

View File

@ -10,6 +10,8 @@ services:
headscale:
image: "headscale/headscale:0.25.1"
container_name: "headscale"
labels:
- com.headplane.selector=headscale
restart: "unless-stopped"
command: "serve"
networks:

View File

@ -47,8 +47,14 @@ headscale:
integration:
docker:
enabled: false
# The name (or ID) of the container running Headscale
container_name: "headscale"
# Preferred method: use container_label to dynamically discover the Headscale container.
container_label:
name: "com.headplane.selector"
value: "headscale"
# Optional fallback: directly specify the container name (or ID)
# of the container running Headscale
# container_name: "headscale"
# The path to the Docker socket (do not change this if you are unsure)
# Docker socket paths must start with unix:// or tcp:// and at the moment
# https connections are not supported.

View File

@ -30,7 +30,7 @@ Clone the Headplane repository, install dependencies, and build the project:
```sh
git clone https://github.com/tale/headplane
cd headplane
git checkout v0.5.5 # Or whatever tag you want to use
git checkout 0.5.10 # Or whatever tag you want to use
pnpm install
pnpm build
```
@ -68,14 +68,15 @@ Headplane service:
```ini
[Unit]
Description=Headplane
After=network.target
# Decomment the following line if running on bare metal with integrated mode (/proc integration)
# PartOf=headscale.service
[Service]
Type=simple
User=headplane
Group=headplane
WorkingDirectory=/path/to/headplane
ExecStart=/usr/bin/node /path/to/headplane/build/headplane/server.js
ExecStart=/usr/bin/node /path/to/headplane/build/server/index.js
Restart=always
[Install]

View File

@ -34,7 +34,7 @@ Here is what a sample Docker Compose deployment would look like:
services:
headplane:
# I recommend you pin the version to a specific release
image: ghcr.io/tale/headplane:0.5.8
image: ghcr.io/tale/headplane:0.5.10
container_name: headplane
restart: unless-stopped
ports:
@ -70,11 +70,14 @@ you build the container yourself or run Headplane in Bare-Metal mode.
> setting up your `config.yaml` file to the appropriate values.
## Docker Integration
The Docker integration is the easiest to setup, as it only requires the Docker socket
to be mounted into the container along with some configuration. As long as Headplane
has access to the Docker socket and the name of the Headscale container, it will
automatically propagate config and DNS changes to Headscale without any additional
configuration.
The Docker integration is the easiest to set up, as it only requires mounting the
Docker socket into the container along with some basic configuration. Headplane
uses Docker labels to discover the Headscale container. As long as Headplane has
access to the Docker socket and can identify the Headscale container—either by
label or name—it will automatically propagate configuration and DNS changes to
Headscale without any additional setup. Alternatively, instead of using a label
to dynamically determine the container name, it is possible to directly specify
the container name.
## Native Linux (/proc) Integration
The `proc` integration is used when you are running Headscale and Headplane on
@ -151,7 +154,7 @@ spec:
serviceAccountName: default
containers:
- name: headplane
image: ghcr.io/tale/headplane:0.5.8
image: ghcr.io/tale/headplane:0.5.10
env:
# Set these if the pod name for Headscale is not static
# We will use the downward API to get the pod name instead

View File

@ -19,7 +19,7 @@ Here is what a sample Docker Compose deployment would look like:
services:
headplane:
# I recommend you pin the version to a specific release
image: ghcr.io/tale/headplane:0.5.8
image: ghcr.io/tale/headplane:0.5.10
container_name: headplane
restart: unless-stopped
ports:

View File

@ -40,11 +40,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1741646908,
"narHash": "sha256-55a1x5k+oFY2QCFjj7Mn5nPa8Do0shVl0m280mOAW/Q=",
"lastModified": 1746300365,
"narHash": "sha256-thYTdWqCRipwPRxWiTiH1vusLuAy0okjOyzRx4hLWh4=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "ab0c5b18dab5e4b5d06ed679f8fd7cdc9970c4be",
"rev": "f21e4546e3ede7ae34d12a84602a22246b31f7e0",
"type": "github"
},
"original": {

View File

@ -2,9 +2,10 @@
"name": "headplane",
"private": true,
"sideEffects": false,
"version": "0.5.8",
"version": "0.5.10",
"type": "module",
"scripts": {
"preinstall": "npx only-allow pnpm",
"build": "react-router build",
"dev": "HEADPLANE_LOAD_ENV_OVERRIDES=true HEADPLANE_CONFIG_PATH=./config.example.yaml react-router dev",
"start": "node build/server/index.js",
@ -64,7 +65,8 @@
"postcss": "^8.5.3",
"react-router-dom": "^7.4.0",
"react-scan": "^0.1.0",
"tailwindcss": "^3.4.17",
"tailwindcss": "^4.0.0",
"@tailwindcss/postcss": "^4.1.0",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-react-aria-components": "^2.0.0",
"typescript": "^5.8.2",
@ -73,14 +75,16 @@
"vite-plugin-babel": "^1.3.0",
"vite-tsconfig-paths": "^5.1.4"
},
"packageManager": "pnpm@10.4.0",
"engines": {
"node": ">=22",
"pnpm": ">=10 <11"
"pnpm": ">=10.4 <11"
},
"pnpm": {
"patchedDependencies": {
"@shopify/lang-jsonc@1.0.0": "patches/@shopify__lang-jsonc@1.0.0.patch",
"react-router-hono-server": "patches/react-router-hono-server.patch"
}
},
"onlyBuiltDependencies": ["@biomejs/biome", "esbuild", "lefthook"]
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
},
};