Compare commits

...

49 Commits

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