Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbdb759a7e | ||
|
|
6716e5f0b0 | ||
|
|
346b44ec69 | ||
|
|
faa61b0f1d | ||
|
|
6b63fe209f | ||
|
|
c8507cff7c | ||
|
|
b86f4461c0 | ||
|
|
77b510c927 | ||
|
|
5adcb8c582 | ||
|
|
524c5eb639 | ||
|
|
2894c664d3 | ||
|
|
5c2d08decd | ||
|
|
f2e8c6ae4c | ||
|
|
c3e727842a | ||
|
|
96345ab0a6 | ||
|
|
fe2d7cb57a | ||
|
|
66c7d9a327 | ||
|
|
f2747ada94 | ||
|
|
0ad578e651 | ||
|
|
8d1132606a | ||
|
|
5e332c4a5c |
7
.github/workflows/build.yaml
vendored
7
.github/workflows/build.yaml
vendored
@ -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
|
||||
|
||||
@ -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:
|
||||
24
.github/workflows/release.yaml
vendored
24
.github/workflows/release.yaml
vendored
@ -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
2
.tool-versions
Normal file
@ -0,0 +1,2 @@
|
||||
pnpm 10.4.0
|
||||
node 22
|
||||
@ -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.
|
||||
|
||||
@ -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)"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -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'}
|
||||
/>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -48,7 +48,7 @@ export default function UserMenu({ user }: MenuProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Menu>
|
||||
<Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : []}>
|
||||
<Menu.IconButton
|
||||
label="Machine Options"
|
||||
className={cn(
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": false
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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": {
|
||||
|
||||
12
package.json
12
package.json
@ -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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user