Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d9cbd8e0e | |||
| feb8b8bba5 | |||
| c304effcdf | |||
| 982d811d53 | |||
| d369e36fa4 | |||
| a2e659f36c | |||
| 1490406784 | |||
| e1c87412d4 | |||
| 6e55f442fd | |||
| 09e1b1f261 | |||
| 3e83f8617b | |||
| 1d6472765a | |||
| 2bc85085f5 | |||
| 5c949e2da5 | |||
| 12754bd0aa | |||
| 10bb4eba97 | |||
| 13a734923d | |||
| d5ee8ae0f3 | |||
| 331a9c8dcf | |||
| 808b01c5d7 | |||
| a8abd37e3a | |||
| d1fa76971b | |||
| a9e8394dec | |||
| 9dafd8e8b0 | |||
| 0c1f6969da | |||
| 4214e14ba8 | |||
| 447a31f91e | |||
| 9e21823163 | |||
| aa7e2a3128 | |||
| ecef45c98a | |||
| d165264876 | |||
| dd479d4117 | |||
| 29d91785fb | |||
| 98d1cb1333 | |||
| 2229f547a9 | |||
| 65cc278a59 | |||
| 1555846df2 | |||
| e8c1cadf54 | |||
| d867769025 | |||
| 1d6066d3f0 | |||
| 1d821251a9 | |||
| a0d6905123 | |||
| 4095ed2a68 | |||
| 58e98278d1 | |||
| 90f0bf2555 | |||
| ea2ffdf0c1 | |||
| 9aedd9baad | |||
| 690b52d8c6 | |||
| a72a3d6e5f | |||
| c4c1fd8aab | |||
| 9801ef453d | |||
| d041a62fcd | |||
| 0e6b5ea6d0 | |||
| a2054786f8 | |||
| 5a46fd0a97 | |||
| b8999161a2 | |||
| 224cbbdcaf | |||
| 75ba3a3dc7 | |||
| 4f57fdb43b | |||
| b170e11dd6 | |||
| 6b278309ed | |||
| ea8ecfb28f | |||
| 099bd3bcb8 | |||
| 0aa0406ea6 | |||
| 3cc726320a | |||
| bda151f4e8 | |||
| 6d411853d5 | |||
| dc4d05a2d9 | |||
| 361859f374 | |||
| c60652e39f | |||
| 1db385e716 | |||
| ab0cb7b782 | |||
| fd73832879 | |||
| 7804d83181 | |||
| fe40a5734e | |||
| c0bdf91f6b | |||
| bd46f97121 | |||
| d71e55d7af | |||
| ddd20fe027 | |||
| dca5f9d149 | |||
| be8ce7a2fe | |||
| 6fa0e265fe | |||
| 74e87bd80e | |||
| d01699009d | |||
| 08db192db1 | |||
| 3ea5fed8f6 | |||
| dd9d6cd550 | |||
| f82244f85a | |||
| 896aaad61e | |||
| e5dece5b7b | |||
| 2797525969 | |||
| f0f8a6f01b | |||
| 3bd5cc99aa | |||
| 937fa6f6fa | |||
| 3ffbabd7fc | |||
| c08203cc76 | |||
| 57f045eaf6 | |||
| faa583bb38 | |||
| 98ea2cb06f | |||
| 868d85bbeb | |||
| dbd9d39da9 | |||
| 72cc9d8974 | |||
| ed50c48965 | |||
| e19dbda5ed | |||
| a72cc1bb1c | |||
| f0e4868252 |
@@ -0,0 +1,44 @@
|
||||
name: Publish Nightly Docker Image
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 8 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and Publish Nightly
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
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:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
@@ -0,0 +1,44 @@
|
||||
name: 'Build'
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
@@ -1,4 +1,89 @@
|
||||
### 0.1.5 (May 22, 2024)
|
||||
### 0.3.5 (November 8, 2024)
|
||||
- Quickfix a bug where environment variables are ignored on the server.
|
||||
- Remove a nagging error about missing cookie since that happens when signed out.
|
||||
|
||||
### 0.3.4 (November 7, 2024)
|
||||
- Clicking on the machine name in the users page now takes you to the machine overview page.
|
||||
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
|
||||
- Fixed a bunch of silly issues introduced by me not typechecking the codebase.
|
||||
- Improve documentation and support when running Headplane outside of Docker.
|
||||
- Removing Split DNS records will no longer result in an error (fixes [#40](https://github.com/tale/headplane/issues/40))
|
||||
- Removing the last ACL tag on a machine no longer results in an error (fixes [#41](https://github.com/tale/headplane/issues/41))
|
||||
- Added full support for Exit Nodes in the UI and redesigned the machines page (fixes [#36](https://github.com/tale/headplane/issues/36))
|
||||
- Added a basic check to see if the API keys passed via cookies are invalid.
|
||||
|
||||
### 0.3.3 (October 28, 2024)
|
||||
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
|
||||
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
|
||||
- Improved ACL editor performance by switching back to CodeMirror 6
|
||||
- Fixed an issue where editing the ACL policy would cause it to revert on the UI (fixes [#34](https://github.com/tale/headplane/issues/34))
|
||||
- Updated to the next stable beta of the React 19 Compiler ([See More](https://react.dev/learn/react-compiler))
|
||||
|
||||
### 0.3.2 (October 11, 2024)
|
||||
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
|
||||
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
|
||||
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
|
||||
- Handle `null` values on machine expiry when using a database like PostgreSQL.
|
||||
- Use `X-Forwarded-Proto` and `Host` headers for building the OIDC callback URL.
|
||||
|
||||
### 0.3.1 (October 3, 2024)
|
||||
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
|
||||
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
|
||||
- Added the option for debug logs with the `DEBUG=true` environment variable.
|
||||
|
||||
### 0.3.0 (September 25, 2024)
|
||||
- Bumped the minimum supported version of Headscale to 0.23.
|
||||
- Updated the UI to respect `dns.use_username_in_magic_dns`.
|
||||
|
||||
### 0.2.4 (August 24, 2024)
|
||||
- Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively.
|
||||
- Removed the `ACL_FILE` environment variable since it's no longer needed.
|
||||
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
|
||||
- Fixed a bug where removing Split DNS configurations would crash the UI.
|
||||
|
||||
### 0.2.3 (August 23, 2024)
|
||||
- Change the minimum required version of Headscale to 0.23-beta2
|
||||
- Support the new API policy mode for Headscale 0.23-beta1
|
||||
- Switch to the new DNS configuration in Headscale 0.23-beta2 (fixes [#29](https://github.com/tale/headplane/issues/29))
|
||||
- If OIDC environment variables are defined, don't use configuration file values (fixes [#24](https://github.com/tale/headplane/issues/24))
|
||||
|
||||
### 0.2.2 (August 2, 2024)
|
||||
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
|
||||
- Added a new logger utility that shows categories, levels, and timestamps.
|
||||
- Reimplemented the integration system to be more resilient and log more information.
|
||||
- Fixed an issue where the /proc integration found `undefined` PIDs.
|
||||
|
||||
### 0.2.1 (July 7, 2024)
|
||||
- Added the ability to manage custom DNS records on your Tailnet.
|
||||
- ACL tags for machines are now able to be changed via the machine menu.
|
||||
- Fixed a bug where the ACL editor did not show the diffs correctly.
|
||||
- Fixed an issue that stopped the "Discard changes" button in the ACL editor from working.
|
||||
|
||||
### 0.2.0 (June 23, 2024)
|
||||
- Fix the dropdown options for machines not working on the machines page.
|
||||
- Add an option to change the machine owner in the dropdown (aside from the users page).
|
||||
|
||||
### 0.1.9 (June 2, 2024)
|
||||
- Switch to Monaco editor with proper HuJSON and YAML syntax highlighting.
|
||||
- Utilize magic DNS hostnames for the machine overview page.
|
||||
- Fixed the expiry issue once and for all.
|
||||
- Add a nightly build with the `ghcr.io/tale/headplane:edge` tag
|
||||
|
||||
### 0.1.8 (June 2, 2024)
|
||||
- Built basic functionality for the machine overview page (by machine ID).
|
||||
- Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly.
|
||||
- Prevent users from being deleted if they still have ownership of machines.
|
||||
- Fixed some type issues where `Date` was being used instead of `string` for timestamps.
|
||||
|
||||
### 0.1.7 (May 30, 2024)
|
||||
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
|
||||
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
|
||||
- Made the strict configuration loader more lenient to allow for more flexibility.
|
||||
- Added `HEADSCALE_CONFIG_UNSTRICT`=true to revert back to a weaker configuration loader.
|
||||
- Headplane's context now only loads once at start instead of being lazy-loaded.
|
||||
- Improved logging and error propagation so that it's easier to debug issues.
|
||||
|
||||
### 0.1.6 (May 22, 2024)
|
||||
- Added experimental support for advanced integration without Docker.
|
||||
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
|
||||
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
|
||||
|
||||
@@ -19,4 +19,4 @@ RUN echo '{"type":"module"}' > /app/package.json
|
||||
EXPOSE 3000
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
CMD [ "node_modules/.bin/remix-serve", "./build/server/index.js" ]
|
||||
CMD [ "node", "./build/headplane/server.js" ]
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
# Headplane
|
||||
> An advanced UI for [juanfont/headscale](https://github.com/juanfont/headscale)
|
||||
|
||||

|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="./assets/preview-dark.png"
|
||||
>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="./assets/preview-light.png"
|
||||
>
|
||||
<img
|
||||
alt="Preview"
|
||||
src="./assets/preview-dark.png"
|
||||
>
|
||||
</picture>
|
||||
|
||||
Headscale is a self-hosted version of the Tailscale control server, however, it currently lacks a first-party web UI.
|
||||
Headplane aims to solve this issue by providing a GUI that can deeply integrate with the Headscale server.
|
||||
@@ -13,9 +26,17 @@ It's able to replicate nearly all of the functions of the official Tailscale Saa
|
||||
- DNS and *safe* Headscale configuration management
|
||||
|
||||
## Deployment
|
||||
- If you run Headscale in a Docker container, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide.
|
||||
- If you run Headscale natively, see the [Basic Deployment](/docs/Basic-Integration.md) guide.
|
||||
- For more configuration options, refer to the [Configuration](/docs/Configuration.md) guide.
|
||||
> For more configuration options, refer to the [Configuration](/docs/Configuration.md) guide.
|
||||
|
||||
For fully-featured deployments, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide.
|
||||
This includes automatic management of ACLs, DNS settings, and Headscale configuration.
|
||||
*This is the closest experience to the Tailscale UI that can be achieved with Headscale and Headplane.*
|
||||
*If you aren't sure which one to pick, we recommend this.*
|
||||
|
||||
If your environment is not able to support the advanced deployment, you can still use the basic deployment.
|
||||
For basic deployments, see the [Basic Deployment](/docs/Basic-Integration.md) guide.
|
||||
It does not include automatic management of ACLs, DNS settings, or the Headscale configuration,
|
||||
instead requiring manual editing and reloading when making changes.
|
||||
|
||||
## Contributing
|
||||
If you would like to contribute, please install a relatively modern version of Node.js and PNPM.
|
||||
|
||||
@@ -6,14 +6,19 @@ interface Props {
|
||||
name: string
|
||||
value: string
|
||||
isCopyable?: boolean
|
||||
link?: string
|
||||
}
|
||||
|
||||
export default function Attribute({ name, value, isCopyable }: Props) {
|
||||
export default function Attribute({ name, value, link, isCopyable }: Props) {
|
||||
const canCopy = isCopyable ?? false
|
||||
return (
|
||||
<dl className="flex gap-1 text-sm w-full">
|
||||
<dt className="w-1/2 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1">
|
||||
{name}
|
||||
{link ? (
|
||||
<a className="hover:underline" href={link}>
|
||||
{name}
|
||||
</a>
|
||||
) : name}
|
||||
</dt>
|
||||
|
||||
{canCopy
|
||||
|
||||
@@ -9,7 +9,10 @@ import Menu from './Menu'
|
||||
import TabLink from './TabLink'
|
||||
|
||||
interface Properties {
|
||||
readonly data?: HeadplaneContext & { user?: SessionData['user'] }
|
||||
readonly data?: {
|
||||
config: HeadplaneContext['config']
|
||||
user?: SessionData['user']
|
||||
}
|
||||
}
|
||||
|
||||
interface LinkProperties {
|
||||
@@ -98,11 +101,7 @@ export default function Header({ data }: Properties) {
|
||||
<nav className="container flex items-center gap-x-4 overflow-x-auto">
|
||||
<TabLink to="/machines" name="Machines" icon={<ServerIcon className="w-4 h-4" />} />
|
||||
<TabLink to="/users" name="Users" icon={<PeopleIcon className="w-4 h-4" />} />
|
||||
{data?.acl.read
|
||||
? (
|
||||
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
|
||||
)
|
||||
: undefined}
|
||||
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
|
||||
{data?.config.read
|
||||
? (
|
||||
<>
|
||||
|
||||
@@ -2,30 +2,29 @@ import { LinkExternalIcon } from '@primer/octicons-react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
/* eslint-disable unicorn/no-keyword-prefix */
|
||||
type Properties = {
|
||||
readonly to: string;
|
||||
readonly name: string;
|
||||
readonly children: string;
|
||||
readonly className?: string;
|
||||
interface Props {
|
||||
to: string
|
||||
name: string
|
||||
children: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function Link({ to, name: alt, children, className }: Properties) {
|
||||
export default function Link({ to, name: alt, children, className }: Props) {
|
||||
return (
|
||||
<a
|
||||
href={to}
|
||||
aria-label={alt}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-x-1',
|
||||
'text-blue-500 hover:text-blue-700',
|
||||
'dark:text-blue-400 dark:hover:text-blue-300',
|
||||
className
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<LinkExternalIcon className='h-3 w-3'/>
|
||||
<LinkExternalIcon className="h-3 w-3" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { InfoIcon } from '@primer/octicons-react'
|
||||
import clsx from 'clsx'
|
||||
import { type ReactNode } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export default function Notice({ children }: { readonly children: ReactNode }) {
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function Notice({ children, className }: Props) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
'p-4 rounded-md w-fit flex items-center gap-3',
|
||||
'bg-slate-400 dark:bg-slate-700'
|
||||
<div className={cn(
|
||||
'p-4 rounded-md w-full flex items-center gap-3',
|
||||
'bg-ui-200 dark:bg-ui-800',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<InfoIcon className='h-6 w-6 text-white'/>
|
||||
<InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { PlusIcon, DashIcon } from '@primer/octicons-react'
|
||||
import { Dispatch, SetStateAction } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Input,
|
||||
NumberField as AriaNumberField
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type NumberFieldProps = Parameters<typeof AriaNumberField>[0] & {
|
||||
label: string;
|
||||
state?: [number, Dispatch<SetStateAction<number>>];
|
||||
}
|
||||
|
||||
export default function NumberField(props: NumberFieldProps) {
|
||||
return (
|
||||
<AriaNumberField
|
||||
{...props}
|
||||
aria-label={props.label}
|
||||
className="w-full"
|
||||
value={props.state?.[0]}
|
||||
onChange={value => {
|
||||
props.state?.[1](value)
|
||||
}}
|
||||
>
|
||||
<Group className={cn(
|
||||
'flex px-2.5 py-1.5 w-full rounded-lg my-1',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'dark:bg-ui-800 dark:text-ui-300 gap-2',
|
||||
'focus-within:ring-2 focus-within:ring-blue-600',
|
||||
props.className
|
||||
)}>
|
||||
<Input
|
||||
className="w-full bg-transparent focus:outline-none"
|
||||
/>
|
||||
<Button slot="decrement">
|
||||
<DashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button slot="increment">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Group>
|
||||
</AriaNumberField>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ChevronDownIcon } from '@primer/octicons-react'
|
||||
import { Dispatch, ReactNode, SetStateAction } from 'react'
|
||||
import {
|
||||
Button,
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
Popover,
|
||||
Select as AriaSelect,
|
||||
SelectValue,
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type SelectProps = Parameters<typeof AriaSelect>[0] & {
|
||||
readonly label: string
|
||||
readonly state?: [string, Dispatch<SetStateAction<string>>]
|
||||
readonly children: ReactNode
|
||||
}
|
||||
|
||||
function Select(props: SelectProps) {
|
||||
return (
|
||||
<AriaSelect
|
||||
{...props}
|
||||
aria-label={props.label}
|
||||
selectedKey={props.state?.[0]}
|
||||
onSelectionChange={(key) => {
|
||||
props.state?.[1](key.toString())
|
||||
}}
|
||||
className={cn(
|
||||
'block w-full rounded-lg my-1',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'bg-white dark:bg-ui-800 dark:text-ui-300',
|
||||
'focus-within:outline-6',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<Button className={cn(
|
||||
'w-full flex items-center justify-between',
|
||||
'px-2.5 py-1.5 rounded-lg',
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
<ChevronDownIcon className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Popover
|
||||
className={cn(
|
||||
'mt-2 rounded-md w-[var(--trigger-width)]',
|
||||
'bg-ui-100 dark:bg-ui-800 shadow-sm',
|
||||
'overflow-hidden z-50',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'entering:fade-in entering:zoom-in-95',
|
||||
'exiting:fade-out exiting:zoom-out-95',
|
||||
'fill-mode-forwards origin-left-right',
|
||||
)}
|
||||
>
|
||||
<ListBox>
|
||||
{props.children}
|
||||
</ListBox>
|
||||
</Popover>
|
||||
</AriaSelect>
|
||||
)
|
||||
}
|
||||
|
||||
type ItemProps = Parameters<typeof ListBoxItem>[0]
|
||||
|
||||
function Item(props: ItemProps) {
|
||||
return (
|
||||
<ListBoxItem
|
||||
{...props}
|
||||
className={cn(
|
||||
'px-4 py-2 w-full outline-none w-full',
|
||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</ListBoxItem>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(Select, { Item })
|
||||
@@ -1,7 +1,7 @@
|
||||
import { XIcon } from '@primer/octicons-react'
|
||||
import { type AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
|
||||
import { ToastQueue, type ToastState, useToastQueue } from '@react-stately/toast'
|
||||
import { type ReactNode, useRef } from 'react'
|
||||
import { AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
|
||||
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast'
|
||||
import { ReactNode, useRef } from 'react'
|
||||
import { Button } from 'react-aria-components'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ClientOnly } from 'remix-utils/client-only'
|
||||
@@ -14,6 +14,8 @@ type ToastProperties = AriaToastProps<ReactNode> & {
|
||||
|
||||
function Toast({ state, ...properties }: ToastProperties) {
|
||||
const reference = useRef(null)
|
||||
|
||||
// @ts-expect-error: RefObject doesn't map to FocusableElement?
|
||||
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference)
|
||||
|
||||
return (
|
||||
@@ -52,11 +54,15 @@ export function toast(text: string) {
|
||||
export function Toaster() {
|
||||
const reference = useRef(null)
|
||||
const state = useToastQueue(toasts)
|
||||
|
||||
// @ts-expect-error: React 19 has weird types for Portal vs Node
|
||||
const { regionProps } = useToastRegion({}, state, reference)
|
||||
|
||||
return (
|
||||
<ClientOnly>
|
||||
{() => createPortal(
|
||||
{
|
||||
// @ts-expect-error: Portal doesn't match Node in React 19 yet
|
||||
() => createPortal(
|
||||
state.visibleToasts.length >= 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
|
||||
import type { AppLoadContext, EntryContext } from '@remix-run/node'
|
||||
import { createReadableStreamFromReadable } from '@remix-run/node'
|
||||
import { RemixServer } from '@remix-run/react'
|
||||
import { isbot } from 'isbot'
|
||||
import { renderToPipeableStream } from 'react-dom/server'
|
||||
|
||||
import { loadContext } from './utils/config/headplane'
|
||||
|
||||
await loadContext()
|
||||
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_loadContext: AppLoadContext,
|
||||
) {
|
||||
const ua = request.headers.get('user-agent')
|
||||
const isBot = ua ? isbot(ua) : false
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer
|
||||
context={remixContext}
|
||||
url={request.url}
|
||||
abortDelay={5000}
|
||||
/>,
|
||||
{
|
||||
[isBot ? 'onAllReady' : 'onShellReady']() {
|
||||
shellRendered = true
|
||||
const body = new PassThrough()
|
||||
const stream = createReadableStreamFromReadable(body)
|
||||
responseHeaders.set('Content-Type', 'text/html')
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
}),
|
||||
)
|
||||
|
||||
pipe(body)
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error as Error)
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500
|
||||
if (shellRendered) {
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
setTimeout(abort, 5000)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { access, constants } from 'node:fs/promises'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
|
||||
import { Client } from 'undici'
|
||||
|
||||
import { HeadscaleError, pull } from '~/utils/headscale'
|
||||
import log from '~/utils/log'
|
||||
|
||||
import { createIntegration } from './integration'
|
||||
|
||||
interface Context {
|
||||
client: Client | undefined
|
||||
container: string | undefined
|
||||
maxAttempts: number
|
||||
}
|
||||
|
||||
export default createIntegration<Context>({
|
||||
name: 'Docker',
|
||||
context: {
|
||||
client: undefined,
|
||||
container: undefined,
|
||||
maxAttempts: 10,
|
||||
},
|
||||
isAvailable: async (context) => {
|
||||
// Check for the HEADSCALE_CONTAINER environment variable first
|
||||
// to avoid unnecessary fetching of the Docker socket
|
||||
log.debug('INTG', 'Checking Docker integration availability')
|
||||
context.container = process.env.HEADSCALE_CONTAINER
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
|
||||
if (!context.container || context.container.length === 0) {
|
||||
log.error('INTG', 'Missing HEADSCALE_CONTAINER variable')
|
||||
return false
|
||||
}
|
||||
|
||||
log.info('INTG', 'Using container: %s', context.container)
|
||||
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock'
|
||||
let url: URL | undefined
|
||||
|
||||
try {
|
||||
url = new URL(path)
|
||||
} catch {
|
||||
log.error('INTG', 'Invalid Docker socket path: %s', path)
|
||||
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.debug('INTG', 'Failed to connect to Docker API', error)
|
||||
log.error('INTG', 'Failed to connect to Docker API')
|
||||
return false
|
||||
}
|
||||
|
||||
context.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.debug('INTG', 'Failed to access Docker socket: %s', error)
|
||||
log.error('INTG', 'Failed to access Docker socket: %s',
|
||||
path,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
context.client = new Client('http://localhost', {
|
||||
socketPath: url.pathname,
|
||||
})
|
||||
}
|
||||
|
||||
return context.client !== undefined
|
||||
},
|
||||
|
||||
onConfigChange: async (context) => {
|
||||
if (!context.client || !context.container) {
|
||||
return
|
||||
}
|
||||
|
||||
log.info('INTG', 'Restarting Headscale via Docker')
|
||||
|
||||
let attempts = 0
|
||||
while (attempts <= context.maxAttempts) {
|
||||
log.debug(
|
||||
'INTG', 'Restarting container: %s (attempt %d)',
|
||||
context.container,
|
||||
attempts,
|
||||
)
|
||||
|
||||
const response = await context.client.request({
|
||||
method: 'POST',
|
||||
path: `/v1.30/containers/${context.container}/restart`,
|
||||
})
|
||||
|
||||
if (response.statusCode !== 204) {
|
||||
if (attempts < context.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 <= context.maxAttempts) {
|
||||
try {
|
||||
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts)
|
||||
await pull('v1', '')
|
||||
return
|
||||
} catch (error) {
|
||||
if (error instanceof HeadscaleError && error.status === 401) {
|
||||
break
|
||||
}
|
||||
|
||||
if (error instanceof HeadscaleError && error.status === 404) {
|
||||
break
|
||||
}
|
||||
|
||||
if (attempts < context.maxAttempts) {
|
||||
attempts++
|
||||
await setTimeout(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
throw new Error(`Missed restart deadline for ${context.container}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import log from '~/utils/log'
|
||||
|
||||
import dockerIntegration from './docker'
|
||||
import { IntegrationFactory } from './integration'
|
||||
import kubernetesIntegration from './kubernetes'
|
||||
import procIntegration from './proc'
|
||||
|
||||
export * from './integration'
|
||||
|
||||
export async function loadIntegration() {
|
||||
let integration = process.env.HEADSCALE_INTEGRATION
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
|
||||
// Old HEADSCALE_CONTAINER variable upgrade path
|
||||
// This ensures that when people upgrade from older versions of Headplane
|
||||
// they don't explicitly need to define the new HEADSCALE_INTEGRATION
|
||||
// variable that is needed to configure docker
|
||||
if (!integration && process.env.HEADSCALE_CONTAINER) {
|
||||
integration = 'docker'
|
||||
}
|
||||
|
||||
if (!integration) {
|
||||
log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION')
|
||||
return
|
||||
}
|
||||
|
||||
let integrationFactory: IntegrationFactory | undefined
|
||||
switch (integration.toLowerCase().trim()) {
|
||||
case 'docker': {
|
||||
integrationFactory = dockerIntegration
|
||||
break
|
||||
}
|
||||
|
||||
case 'proc':
|
||||
case 'native':
|
||||
case 'linux': {
|
||||
integrationFactory = procIntegration
|
||||
break
|
||||
}
|
||||
|
||||
case 'kubernetes':
|
||||
case 'k8s': {
|
||||
integrationFactory = kubernetesIntegration
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
log.error('INTG', 'Unknown integration: %s', integration)
|
||||
throw new Error(`Unknown integration: ${integration}`)
|
||||
}
|
||||
}
|
||||
|
||||
log.info('INTG', 'Loading integration: %s', integration)
|
||||
try {
|
||||
const res = await integrationFactory.isAvailable(
|
||||
integrationFactory.context,
|
||||
)
|
||||
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,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
log.info('INTG', 'Loaded integration: %s', integration)
|
||||
return integrationFactory
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export interface IntegrationFactory<T = any> {
|
||||
name: string
|
||||
context: T
|
||||
isAvailable: (context: T) => Promise<boolean> | boolean
|
||||
onConfigChange?: (context: T) => Promise<void> | void
|
||||
}
|
||||
|
||||
export function createIntegration<T>(
|
||||
options: IntegrationFactory<T>,
|
||||
) {
|
||||
return options
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { platform } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { kill } from 'node:process'
|
||||
|
||||
import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node'
|
||||
|
||||
import log from '~/utils/log'
|
||||
|
||||
import { createIntegration } from './integration'
|
||||
|
||||
interface Context {
|
||||
pid: number | undefined
|
||||
}
|
||||
|
||||
export default createIntegration<Context>({
|
||||
name: 'Kubernetes (k8s)',
|
||||
context: {
|
||||
pid: undefined,
|
||||
},
|
||||
isAvailable: async (context) => {
|
||||
if (platform() !== 'linux') {
|
||||
log.error('INTG', 'Kubernetes is only available on Linux')
|
||||
return false
|
||||
}
|
||||
|
||||
const svcRoot = Config.SERVICEACCOUNT_ROOT
|
||||
try {
|
||||
log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot)
|
||||
const files = await readdir(svcRoot)
|
||||
if (files.length === 0) {
|
||||
log.error('INTG', 'Kubernetes service account not found')
|
||||
return false
|
||||
}
|
||||
|
||||
const mappedFiles = new Set(files.map(file => join(svcRoot, file)))
|
||||
const expectedFiles = [
|
||||
Config.SERVICEACCOUNT_CA_PATH,
|
||||
Config.SERVICEACCOUNT_TOKEN_PATH,
|
||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||
]
|
||||
|
||||
log.debug('INTG', 'Looking for %s', expectedFiles.join(', '))
|
||||
if (!expectedFiles.every(file => mappedFiles.has(file))) {
|
||||
log.error('INTG', 'Malformed Kubernetes service account')
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to access %s: %s', svcRoot, error)
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot)
|
||||
const namespace = await readFile(
|
||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||
'utf8',
|
||||
)
|
||||
|
||||
// Some very ugly nesting but it's necessary
|
||||
if (process.env.HEADSCALE_INTEGRATION_UNSTRICT === 'true') {
|
||||
log.warn('INTG', 'Skipping strict Pod status check')
|
||||
} else {
|
||||
const pod = process.env.POD_NAME
|
||||
if (!pod) {
|
||||
log.error('INTG', 'Missing POD_NAME variable')
|
||||
return false
|
||||
}
|
||||
|
||||
if (pod.trim().length === 0) {
|
||||
log.error('INTG', 'Pod name is empty')
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Checking Kubernetes pod %s in namespace %s',
|
||||
pod,
|
||||
namespace,
|
||||
)
|
||||
|
||||
try {
|
||||
log.debug('INTG', 'Attempgin to get cluster KubeConfig')
|
||||
const kc = new KubeConfig()
|
||||
kc.loadFromCluster()
|
||||
|
||||
const cluster = kc.getCurrentCluster()
|
||||
if (!cluster) {
|
||||
log.error('INTG', 'Malformed kubeconfig')
|
||||
return false
|
||||
}
|
||||
|
||||
log.info('INTG', 'Service account connected to %s (%s)',
|
||||
cluster.name,
|
||||
cluster.server,
|
||||
)
|
||||
|
||||
const kCoreV1Api = kc.makeApiClient(CoreV1Api)
|
||||
|
||||
log.info('INTG', 'Checking pod %s in namespace %s (%s)',
|
||||
pod,
|
||||
namespace,
|
||||
kCoreV1Api.basePath,
|
||||
)
|
||||
|
||||
log.debug('INTG', 'Reading pod info for %s', pod)
|
||||
const { response, body } = await kCoreV1Api.readNamespacedPod(
|
||||
pod,
|
||||
namespace,
|
||||
)
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
log.error('INTG', 'Failed to read pod info: http %d',
|
||||
response.statusCode,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Got pod info: %o', body.spec)
|
||||
const shared = body.spec?.shareProcessNamespace
|
||||
if (shared === undefined) {
|
||||
log.error(
|
||||
'INTG',
|
||||
'Pod does not have spec.shareProcessNamespace set',
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!shared) {
|
||||
log.error(
|
||||
'INTG',
|
||||
'Pod has set but disabled spec.shareProcessNamespace',
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
log.info('INTG', 'Pod %s enabled shared processes', pod)
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to read pod info: %s', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Looking for namespaced process in /proc')
|
||||
const dir = resolve('/proc')
|
||||
try {
|
||||
const subdirs = await readdir(dir)
|
||||
const promises = subdirs.map(async (dir) => {
|
||||
const pid = Number.parseInt(dir, 10)
|
||||
|
||||
if (Number.isNaN(pid)) {
|
||||
return
|
||||
}
|
||||
|
||||
const path = join('/proc', dir, 'cmdline')
|
||||
try {
|
||||
log.debug('INTG', '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)
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(promises)
|
||||
const pids = []
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
pids.push(result.value)
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Found Headscale processes: %o', pids)
|
||||
if (pids.length > 1) {
|
||||
log.error('INTG', 'Found %d Headscale processes: %s',
|
||||
pids.length,
|
||||
pids.join(', '),
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
if (pids.length === 0) {
|
||||
log.error('INTG', 'Could not find Headscale process')
|
||||
return false
|
||||
}
|
||||
|
||||
context.pid = pids[0]
|
||||
log.info('INTG', 'Found Headscale process with PID: %d', context.pid)
|
||||
return true
|
||||
} catch {
|
||||
log.error('INTG', 'Failed to read /proc')
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
onConfigChange: (context) => {
|
||||
if (!context.pid) {
|
||||
return
|
||||
}
|
||||
|
||||
log.info('INTG', 'Sending SIGTERM to Headscale')
|
||||
kill(context.pid, 'SIGTERM')
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { platform } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { kill } from 'node:process'
|
||||
|
||||
import log from '~/utils/log'
|
||||
|
||||
import { createIntegration } from './integration'
|
||||
|
||||
interface Context {
|
||||
pid: number | undefined
|
||||
}
|
||||
|
||||
export default createIntegration<Context>({
|
||||
name: 'Native Linux (/proc)',
|
||||
context: {
|
||||
pid: undefined,
|
||||
},
|
||||
isAvailable: async (context) => {
|
||||
if (platform() !== 'linux') {
|
||||
log.error('INTG', '/proc is only available on Linux')
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Checking /proc for Headscale process')
|
||||
const dir = resolve('/proc')
|
||||
try {
|
||||
const subdirs = await readdir(dir)
|
||||
const promises = subdirs.map(async (dir) => {
|
||||
const pid = Number.parseInt(dir, 10)
|
||||
|
||||
if (Number.isNaN(pid)) {
|
||||
return
|
||||
}
|
||||
|
||||
const path = join('/proc', dir, 'cmdline')
|
||||
try {
|
||||
log.debug('INTG', '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)
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(promises)
|
||||
const pids = []
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
pids.push(result.value)
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Found Headscale processes: %o', pids)
|
||||
if (pids.length > 1) {
|
||||
log.error('INTG', 'Found %d Headscale processes: %s',
|
||||
pids.length,
|
||||
pids.join(', '),
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
if (pids.length === 0) {
|
||||
log.error('INTG', 'Could not find Headscale process')
|
||||
return false
|
||||
}
|
||||
|
||||
context.pid = pids[0]
|
||||
log.info('INTG', 'Found Headscale process with PID: %d', context.pid)
|
||||
return true
|
||||
} catch {
|
||||
log.error('INTG', 'Failed to read /proc')
|
||||
return false
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import Merge from 'react-codemirror-merge'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import * as shopify from '@shopify/lang-jsonc'
|
||||
import { ClientOnly } from 'remix-utils/client-only'
|
||||
import { ErrorBoundary } from 'react-error-boundary'
|
||||
import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
|
||||
import { useState } from 'react'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Fallback from './fallback'
|
||||
|
||||
interface EditorProps {
|
||||
isDisabled?: boolean
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function Editor(props: EditorProps) {
|
||||
const [light, setLight] = useState(false)
|
||||
useEffect(() => {
|
||||
const theme = window.matchMedia('(prefers-color-scheme: light)')
|
||||
setLight(theme.matches)
|
||||
theme.addEventListener('change', (theme) => {
|
||||
setLight(theme.matches)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
|
||||
)}>
|
||||
<div className="overflow-y-scroll h-editor text-sm">
|
||||
<ErrorBoundary fallback={
|
||||
<p className={cn(
|
||||
'w-full h-full flex items-center justify-center',
|
||||
'text-gray-400 dark:text-gray-500 text-xl',
|
||||
)}>
|
||||
Failed to load the editor.
|
||||
</p>
|
||||
}>
|
||||
<ClientOnly fallback={<Fallback acl={props.value} />}>
|
||||
{() => (
|
||||
<CodeMirror
|
||||
value={props.value}
|
||||
height="100%"
|
||||
extensions={[shopify.jsonc()]}
|
||||
style={{ height: "100%" }}
|
||||
theme={light ? githubLight : githubDark}
|
||||
onChange={(value) => props.onChange(value)}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DifferProps {
|
||||
left: string
|
||||
right: string
|
||||
}
|
||||
|
||||
export function Differ(props: DifferProps) {
|
||||
const [light, setLight] = useState(false)
|
||||
useEffect(() => {
|
||||
const theme = window.matchMedia('(prefers-color-scheme: light)')
|
||||
setLight(theme.matches)
|
||||
theme.addEventListener('change', (theme) => {
|
||||
setLight(theme.matches)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
|
||||
)}>
|
||||
<div className="overflow-y-scroll h-editor text-sm">
|
||||
{props.left === props.right ? (
|
||||
<p className={cn(
|
||||
'w-full h-full flex items-center justify-center',
|
||||
'text-gray-400 dark:text-gray-500 text-xl',
|
||||
)}>
|
||||
No changes
|
||||
</p>
|
||||
) : (
|
||||
<ErrorBoundary fallback={
|
||||
<p className={cn(
|
||||
'w-full h-full flex items-center justify-center',
|
||||
'text-gray-400 dark:text-gray-500 text-xl',
|
||||
)}>
|
||||
Failed to load the editor.
|
||||
</p>
|
||||
}>
|
||||
<ClientOnly fallback={<Fallback acl={props.right} />}>
|
||||
{() => (
|
||||
<Merge
|
||||
orientation="a-b"
|
||||
theme={light ? githubLight : githubDark}
|
||||
>
|
||||
<Merge.Original
|
||||
readOnly
|
||||
value={props.left}
|
||||
extensions={[shopify.jsonc()]}
|
||||
/>
|
||||
<Merge.Modified
|
||||
readOnly
|
||||
value={props.right}
|
||||
extensions={[shopify.jsonc()]}
|
||||
/>
|
||||
</Merge>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { json } from '@codemirror/lang-json'
|
||||
import { yaml } from '@codemirror/lang-yaml'
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import clsx from 'clsx'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import CodeMirrorMerge from 'react-codemirror-merge'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import { toast } from '~/components/Toaster'
|
||||
|
||||
import Fallback from './fallback'
|
||||
|
||||
interface EditorProperties {
|
||||
readonly acl: string
|
||||
readonly setAcl: (acl: string) => void
|
||||
readonly mode: 'edit' | 'diff'
|
||||
|
||||
readonly data: {
|
||||
hasAclWrite: boolean
|
||||
currentAcl: string
|
||||
aclType: string
|
||||
}
|
||||
}
|
||||
|
||||
export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
|
||||
const [light, setLight] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetcher = useFetcher()
|
||||
const aclType = useMemo(() => data.aclType === 'json' ? json() : yaml(), [data.aclType])
|
||||
|
||||
useEffect(() => {
|
||||
const theme = window.matchMedia('(prefers-color-scheme: light)')
|
||||
setLight(theme.matches)
|
||||
|
||||
theme.addEventListener('change', (theme) => {
|
||||
setLight(theme.matches)
|
||||
})
|
||||
|
||||
// Prevents the FOUC
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={clsx(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
|
||||
)}
|
||||
>
|
||||
<div className="overflow-y-scroll h-editor text-sm">
|
||||
{loading
|
||||
? (
|
||||
<Fallback acl={acl} where="client" />
|
||||
)
|
||||
: (
|
||||
mode === 'edit'
|
||||
? (
|
||||
<CodeMirror
|
||||
value={acl}
|
||||
theme={light ? githubLight : githubDark}
|
||||
extensions={[aclType]}
|
||||
readOnly={!data.hasAclWrite}
|
||||
onChange={(value) => {
|
||||
setAcl(value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<CodeMirrorMerge
|
||||
theme={light ? githubLight : githubDark}
|
||||
orientation="a-b"
|
||||
>
|
||||
<CodeMirrorMerge.Original
|
||||
readOnly
|
||||
value={data.currentAcl}
|
||||
extensions={[aclType]}
|
||||
/>
|
||||
<CodeMirrorMerge.Modified
|
||||
readOnly
|
||||
value={acl}
|
||||
extensions={[aclType]}
|
||||
/>
|
||||
</CodeMirrorMerge>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="heavy"
|
||||
className="mr-2"
|
||||
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
acl,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
|
||||
toast('Updated tailnet ACL policy')
|
||||
}}
|
||||
>
|
||||
{fetcher.state === 'idle'
|
||||
? undefined
|
||||
: (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={fetcher.state === 'loading' || data.currentAcl === acl}
|
||||
onPress={() => {
|
||||
setAcl(data.currentAcl)
|
||||
}}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cn } from '~/utils/cn'
|
||||
import { AlertIcon } from '@primer/octicons-react'
|
||||
|
||||
import Card from '~/components/Card'
|
||||
import Code from '~/components/Code'
|
||||
|
||||
interface Props {
|
||||
message: string
|
||||
}
|
||||
|
||||
export function ErrorView({ message }: Props) {
|
||||
return (
|
||||
<Card variant="flat" className="max-w-full mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Card.Title className="text-xl mb-0">
|
||||
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:
|
||||
<br />
|
||||
<Code>
|
||||
{message}
|
||||
</Code>
|
||||
</Card.Text>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +1,24 @@
|
||||
import clsx from 'clsx'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
|
||||
type FallbackProperties = {
|
||||
readonly acl: string;
|
||||
readonly where: 'client' | 'server';
|
||||
interface Props {
|
||||
readonly acl: string
|
||||
}
|
||||
|
||||
export default function Fallback({ acl, where }: FallbackProperties) {
|
||||
export default function Fallback({ acl }: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className={clsx(
|
||||
where === 'server' ? 'mb-2 overflow-hidden rounded-tr-lg rounded-b-lg' : '',
|
||||
where === 'server' ? 'border border-gray-200 dark:border-gray-700' : ''
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
readOnly
|
||||
className={clsx(
|
||||
'w-full h-editor font-mono resize-none',
|
||||
'text-sm text-gray-600 dark:text-gray-300',
|
||||
'pl-10 pt-1 leading-snug'
|
||||
)}
|
||||
value={acl}
|
||||
/>
|
||||
</div>
|
||||
{where === 'server' ? (
|
||||
<>
|
||||
<Button
|
||||
variant='heavy'
|
||||
className='mr-2'
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</>
|
||||
) : undefined}
|
||||
</>
|
||||
<div className="inline-block relative w-full h-editor">
|
||||
<Spinner className="w-4 h-4 absolute p-2" />
|
||||
<textarea
|
||||
readOnly
|
||||
className={cn(
|
||||
'w-full h-editor font-mono resize-none',
|
||||
'text-sm text-gray-600 dark:text-gray-300',
|
||||
'bg-ui-100 dark:bg-ui-800',
|
||||
'pl-10 pt-1 leading-snug',
|
||||
)}
|
||||
value={acl}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,75 +1,215 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
|
||||
import { type ActionFunctionArgs, json } from '@remix-run/node'
|
||||
import { useLoaderData } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { useLoaderData, useRevalidator } from '@remix-run/react'
|
||||
import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher'
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
|
||||
import { ClientOnly } from 'remix-utils/client-only'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import Code from '~/components/Code'
|
||||
import Link from '~/components/Link'
|
||||
import Notice from '~/components/Notice'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import { toast } from '~/components/Toaster'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane'
|
||||
import { sighupHeadscale } from '~/utils/docker'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig } from '~/utils/config/headscale'
|
||||
import { HeadscaleError, pull, put } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import log from '~/utils/log'
|
||||
|
||||
import Editor from './editor'
|
||||
import Fallback from './fallback'
|
||||
import { Editor, Differ } from './cm.client'
|
||||
import { Unavailable } from './unavailable'
|
||||
import { ErrorView } from './error'
|
||||
|
||||
export async function loader() {
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
|
||||
// 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.
|
||||
const context = await loadContext()
|
||||
if (!context.acl.read) {
|
||||
throw new Error('No ACL configuration is available')
|
||||
let modeGuess = 'database' // Assume database mode
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig()
|
||||
modeGuess = config.policy?.mode ?? 'database'
|
||||
}
|
||||
|
||||
const { data, type } = await loadAcl()
|
||||
return {
|
||||
hasAclWrite: context.acl.write,
|
||||
currentAcl: data,
|
||||
aclType: type,
|
||||
// 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 pull<{ policy: string }>(
|
||||
'v1/policy',
|
||||
session.get('hsApiKey')!,
|
||||
)
|
||||
|
||||
let write = false // On file mode we already know it's readonly
|
||||
if (modeGuess === 'database' && policy.length > 0) {
|
||||
try {
|
||||
await put('v1/policy', session.get('hsApiKey')!, {
|
||||
policy: policy,
|
||||
})
|
||||
|
||||
write = true
|
||||
} catch (error) {
|
||||
write = false
|
||||
log.debug(
|
||||
'APIC',
|
||||
'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 }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!session.has('hsApiKey')) {
|
||||
return json({ success: false }, {
|
||||
return json({ success: false, error: null }, {
|
||||
status: 401,
|
||||
})
|
||||
}
|
||||
|
||||
const context = await loadContext()
|
||||
if (!context.acl.write) {
|
||||
return json({ success: false }, {
|
||||
status: 403,
|
||||
try {
|
||||
const { acl } = await request.json() as { acl: string }
|
||||
const { policy } = await put<{ policy: string }>(
|
||||
'v1/policy',
|
||||
session.get('hsApiKey')!,
|
||||
{
|
||||
policy: acl,
|
||||
}
|
||||
)
|
||||
|
||||
return json({ success: true, policy, error: null })
|
||||
} catch (error) {
|
||||
log.debug('APIC', 'Failed to update ACL policy with error %s', error)
|
||||
|
||||
// @ts-ignore: Shut UP we know it's a string most of the time
|
||||
const text = JSON.parse(error.message)
|
||||
return json({ success: false, error: text.message }, {
|
||||
status: error instanceof HeadscaleError ? error.status : 500,
|
||||
})
|
||||
}
|
||||
|
||||
const data = await request.json() as { acl: string }
|
||||
await patchAcl(data.acl)
|
||||
|
||||
if (context.docker) {
|
||||
await sighupHeadscale()
|
||||
}
|
||||
|
||||
return json({ success: true })
|
||||
return json({ success: true, error: null })
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const [acl, setAcl] = useState(data.currentAcl)
|
||||
const fetcher = useDebounceFetcher<typeof action>()
|
||||
const revalidator = useRevalidator()
|
||||
|
||||
const [acl, setAcl] = useState(data.policy ?? '')
|
||||
const [toasted, setToasted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetcher.data || toasted) {
|
||||
return
|
||||
}
|
||||
|
||||
// @ts-ignore: useDebounceFetcher is not typed correctly
|
||||
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
|
||||
// @ts-ignore: useDebounceFetcher is not typed correctly
|
||||
if (fetcher.data?.success === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
return data.policy === acl
|
||||
}, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data.hasAclWrite
|
||||
? undefined
|
||||
: (
|
||||
{data.read && !data.write
|
||||
? (
|
||||
<div className="mb-4">
|
||||
<Notice>
|
||||
The ACL policy file is readonly to Headplane.
|
||||
You will not be able to make changes here.
|
||||
<Notice className="w-fit">
|
||||
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)
|
||||
@@ -97,80 +237,120 @@ export default function Page() {
|
||||
.
|
||||
</p>
|
||||
|
||||
<Tabs>
|
||||
<TabList className={cn(
|
||||
'flex border-t border-gray-200 dark:border-gray-700',
|
||||
'w-fit rounded-t-lg overflow-hidden',
|
||||
'text-gray-400 dark:text-gray-500',
|
||||
)}
|
||||
>
|
||||
<Tab
|
||||
id="edit"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2 rounded-tl-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
{
|
||||
// @ts-ignore: useDebounceFetcher is not typed correctly
|
||||
fetcher.data?.success === false
|
||||
? (
|
||||
// @ts-ignore: useDebounceFetcher is not typed correctly
|
||||
<ErrorView message={fetcher.data.error} />
|
||||
) : undefined}
|
||||
|
||||
{data.read ? (
|
||||
<>
|
||||
<Tabs>
|
||||
<TabList className={cn(
|
||||
'flex border-t border-gray-200 dark:border-gray-700',
|
||||
'w-fit rounded-t-lg overflow-hidden',
|
||||
'text-gray-400 dark:text-gray-500',
|
||||
)}
|
||||
>
|
||||
<Tab
|
||||
id="edit"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2 rounded-tl-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
>
|
||||
<PencilIcon className="w-5 h-5" />
|
||||
<p>Edit file</p>
|
||||
</Tab>
|
||||
<Tab
|
||||
id="diff"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
>
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
<p>Preview changes</p>
|
||||
</Tab>
|
||||
<Tab
|
||||
id="preview"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2 rounded-tr-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
>
|
||||
<BeakerIcon className="w-5 h-5" />
|
||||
<p>Preview rules</p>
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="edit">
|
||||
<Editor
|
||||
isDisabled={!data.write}
|
||||
value={acl}
|
||||
onChange={setAcl}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="diff">
|
||||
<Differ
|
||||
left={data?.policy ?? ''}
|
||||
right={acl}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="preview">
|
||||
<div
|
||||
className={cn(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
|
||||
'p-16 flex flex-col items-center justify-center',
|
||||
)}
|
||||
>
|
||||
<IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" />
|
||||
<p className="w-1/2 text-center mt-4">
|
||||
The Preview rules is very much still a work in progress.
|
||||
It is a bit complicated to implement right now but hopefully it will be available soon.
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
<Button
|
||||
variant="heavy"
|
||||
className="mr-2"
|
||||
isDisabled={disabled}
|
||||
onPress={() => {
|
||||
setToasted(false)
|
||||
fetcher.submit({
|
||||
acl,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="w-5 h-5" />
|
||||
<p>Edit file</p>
|
||||
</Tab>
|
||||
<Tab
|
||||
id="diff"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
{fetcher.state === 'idle'
|
||||
? undefined
|
||||
: (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={disabled}
|
||||
onPress={() => {
|
||||
setAcl(data?.policy ?? '')
|
||||
}}
|
||||
>
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
<p>Preview changes</p>
|
||||
</Tab>
|
||||
<Tab
|
||||
id="preview"
|
||||
className={({ isSelected }) => cn(
|
||||
'px-4 py-2 rounded-tr-lg',
|
||||
'focus:outline-none flex items-center gap-2',
|
||||
'border-x border-gray-200 dark:border-gray-700',
|
||||
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
|
||||
)}
|
||||
>
|
||||
<BeakerIcon className="w-5 h-5" />
|
||||
<p>Preview rules</p>
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="edit">
|
||||
<ClientOnly fallback={<Fallback acl={acl} where="server" />}>
|
||||
{() => (
|
||||
<Editor data={data} acl={acl} setAcl={setAcl} mode="edit" />
|
||||
)}
|
||||
</ClientOnly>
|
||||
</TabPanel>
|
||||
<TabPanel id="diff">
|
||||
<ClientOnly fallback={<Fallback acl={acl} where="server" />}>
|
||||
{() => (
|
||||
<Editor data={data} acl={acl} setAcl={setAcl} mode="diff" />
|
||||
)}
|
||||
</ClientOnly>
|
||||
</TabPanel>
|
||||
<TabPanel id="preview">
|
||||
<div
|
||||
className={cn(
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
|
||||
'p-16 flex flex-col items-center justify-center',
|
||||
)}
|
||||
>
|
||||
<IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" />
|
||||
<p className="w-1/2 text-center mt-4">
|
||||
The Preview rules is very much still a work in progress.
|
||||
It is a bit complicated to implement right now but hopefully it will be available soon.
|
||||
</p>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</>
|
||||
) : <Unavailable mode={data.mode as "database" | "file"} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { cn } from '~/utils/cn'
|
||||
import { AlertIcon } from '@primer/octicons-react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Card from '~/components/Card'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
records: { name: string, type: 'A', value: string }[]
|
||||
}
|
||||
|
||||
export default function AddDNS({ records }: Props) {
|
||||
const submit = useSubmit()
|
||||
const [name, setName] = useState('')
|
||||
const [ip, setIp] = useState('')
|
||||
|
||||
const isDuplicate = useMemo(() => {
|
||||
if (name.length === 0 || ip.length === 0) return false
|
||||
const lookup = records.find(record => record.name === name)
|
||||
if (!lookup) return false
|
||||
|
||||
return lookup.value === ip
|
||||
}, [records, name, ip])
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>
|
||||
Add DNS record
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Add DNS record
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Enter the domain and IP address for the new DNS record.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!name || !ip) return
|
||||
|
||||
setName('')
|
||||
setIp('')
|
||||
|
||||
submit({
|
||||
'dns.extra_records': [
|
||||
...records,
|
||||
{
|
||||
name,
|
||||
type: 'A',
|
||||
value: ip,
|
||||
},
|
||||
],
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Domain"
|
||||
placeholder="test.example.com"
|
||||
name="domain"
|
||||
state={[name, setName]}
|
||||
className={cn(
|
||||
'mt-2',
|
||||
isDuplicate && 'outline outline-red-500',
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
label="IP Address"
|
||||
placeholder="101.101.101.101"
|
||||
name="ip"
|
||||
state={[ip, setIp]}
|
||||
className={cn(
|
||||
isDuplicate && 'outline outline-red-500',
|
||||
)}
|
||||
/>
|
||||
{isDuplicate
|
||||
? (
|
||||
<p className="text-sm opacity-50">
|
||||
A record with the domain name
|
||||
{' '}
|
||||
<Code>{name}</Code>
|
||||
{' '}
|
||||
and IP address
|
||||
{' '}
|
||||
<Code>{ip}</Code>
|
||||
{' '}
|
||||
already exists.
|
||||
</p>
|
||||
)
|
||||
: undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-8">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
isDisabled={isDuplicate}
|
||||
>
|
||||
Add
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export default function AddNameserver({ nameservers }: Props) {
|
||||
}
|
||||
|
||||
submit({
|
||||
'dns_config.restricted_nameservers': splitNs,
|
||||
'dns.nameservers.split': splitNs,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
@@ -65,7 +65,7 @@ export default function AddNameserver({ nameservers }: Props) {
|
||||
globalNs.push(ns)
|
||||
|
||||
submit({
|
||||
'dns_config.nameservers': globalNs,
|
||||
'dns.nameservers.global': globalNs,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useSubmit } from '@remix-run/react'
|
||||
import { Button } from 'react-aria-components'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Link from '~/components/Link'
|
||||
import TableList from '~/components/TableList'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import AddDNS from './dialogs/dns'
|
||||
|
||||
interface Props {
|
||||
records: { name: string, type: 'A', value: string }[]
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
export default function DNS({ records, isDisabled }: Props) {
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Headscale supports adding custom DNS records to your Tailnet.
|
||||
As of now, only
|
||||
{' '}
|
||||
<Code>A</Code>
|
||||
{' '}
|
||||
records are supported.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://headscale.net/dns-records/"
|
||||
name="Headscale DNS Records documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<TableList className="mb-8">
|
||||
{records.length === 0
|
||||
? (
|
||||
<TableList.Item>
|
||||
<p className="opacity-50 text-sm mx-auto">
|
||||
No DNS records found
|
||||
</p>
|
||||
</TableList.Item>
|
||||
)
|
||||
: records.map((record, index) => (
|
||||
<TableList.Item key={index}>
|
||||
<div className="flex gap-24">
|
||||
<div className="flex gap-2">
|
||||
<p className="font-mono text-sm font-bold">{record.type}</p>
|
||||
<p className="font-mono text-sm">{record.name}</p>
|
||||
</div>
|
||||
<p className="font-mono text-sm">{record.value}</p>
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm',
|
||||
'text-red-600 dark:text-red-400',
|
||||
'hover:text-red-700 dark:hover:text-red-300',
|
||||
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => {
|
||||
submit({
|
||||
'dns.extra_records': records
|
||||
.filter((_, i) => i !== index),
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
))}
|
||||
</TableList>
|
||||
|
||||
{isDisabled
|
||||
? undefined
|
||||
: (
|
||||
<AddDNS records={records} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'dns_config.domains': [...localDomains, newDomain]
|
||||
'dns.search_domains': [...localDomains, newDomain]
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
@@ -212,8 +212,7 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
|
||||
isDisabled={disabled}
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'dns_config.domains': localDomains.filter((_, index) => index !== id - 1)
|
||||
'dns.search_domains': localDomains.filter((_, index) => index !== id - 1)
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function Modal({ isEnabled, disabled }: Properties) {
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'dns_config.magic_dns': !isEnabled
|
||||
'dns.magic_dns': !isEnabled
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
|
||||
@@ -11,11 +11,10 @@ import AddNameserver from './dialogs/nameserver'
|
||||
|
||||
interface Props {
|
||||
nameservers: Record<string, string[]>
|
||||
override: boolean
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
export default function Nameservers({ nameservers, override, isDisabled }: Props) {
|
||||
export default function Nameservers({ nameservers, isDisabled }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
||||
@@ -36,8 +35,7 @@ export default function Nameservers({ nameservers, override, isDisabled }: Props
|
||||
key={key}
|
||||
isGlobal={key === 'global'}
|
||||
isDisabled={isDisabled}
|
||||
nameservers={nameservers[key]}
|
||||
override={override}
|
||||
nameservers={nameservers}
|
||||
name={key}
|
||||
/>
|
||||
))}
|
||||
@@ -57,12 +55,14 @@ interface ListProps {
|
||||
isDisabled: boolean
|
||||
nameservers: string[]
|
||||
name: string
|
||||
override: boolean
|
||||
}
|
||||
|
||||
function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: ListProps) {
|
||||
const [localOverride, setLocalOverride] = useState(override)
|
||||
function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps) {
|
||||
const submit = useSubmit()
|
||||
const list = isGlobal ? nameservers['global'] : nameservers[name]
|
||||
if (list.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
@@ -70,33 +70,9 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: L
|
||||
<h2 className="text-md font-medium opacity-80">
|
||||
{isGlobal ? 'Global Nameservers' : name}
|
||||
</h2>
|
||||
{isGlobal
|
||||
? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="text-sm opacity-50">
|
||||
Override local DNS
|
||||
</span>
|
||||
<Switch
|
||||
label="Override local DNS"
|
||||
defaultSelected={localOverride}
|
||||
isDisabled={isDisabled}
|
||||
onChange={() => {
|
||||
submit({
|
||||
'dns_config.override_local_dns': !localOverride,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
|
||||
setLocalOverride(!localOverride)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: undefined}
|
||||
</div>
|
||||
<TableList>
|
||||
{nameservers.map((ns, index) => (
|
||||
{list.length > 0 ? list.map((ns, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<TableList.Item key={index}>
|
||||
<p className="font-mono text-sm">{ns}</p>
|
||||
@@ -111,17 +87,18 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: L
|
||||
onPress={() => {
|
||||
if (isGlobal) {
|
||||
submit({
|
||||
'dns_config.nameservers': nameservers
|
||||
'dns.nameservers.global': list
|
||||
.filter((_, i) => i !== index),
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
} else {
|
||||
const key = `dns_config.restricted_nameservers."${name}"`
|
||||
submit({
|
||||
[key]: nameservers
|
||||
.filter((_, i) => i !== index),
|
||||
'dns.nameservers.split': {
|
||||
...nameservers,
|
||||
[name]: list.filter((_, i) => i !== index),
|
||||
}
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
@@ -132,7 +109,7 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: L
|
||||
Remove
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
))}
|
||||
)) : undefined}
|
||||
</TableList>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function Modal({ name, disabled }: Properties) {
|
||||
Devices are accessible at
|
||||
{' '}
|
||||
<Code>
|
||||
[device].[user].{name}
|
||||
[device].{name}
|
||||
</Code>
|
||||
{' '}
|
||||
when Magic DNS is enabled.
|
||||
@@ -80,7 +80,7 @@ export default function Modal({ name, disabled }: Properties) {
|
||||
variant='confirm'
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
'dns_config.base_domain': newName
|
||||
'dns.base_domain': newName
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
|
||||
@@ -5,10 +5,10 @@ import Code from '~/components/Code'
|
||||
import Notice from '~/components/Notice'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig, patchConfig } from '~/utils/config/headscale'
|
||||
import { restartHeadscale } from '~/utils/docker'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
|
||||
import DNS from './dns'
|
||||
import Domains from './domains'
|
||||
import MagicModal from './magic'
|
||||
import Nameservers from './nameservers'
|
||||
@@ -24,13 +24,14 @@ export async function loader() {
|
||||
const config = await loadConfig()
|
||||
const dns = {
|
||||
prefixes: config.prefixes,
|
||||
magicDns: config.dns_config.magic_dns,
|
||||
baseDomain: config.dns_config.base_domain,
|
||||
overrideLocal: config.dns_config.override_local_dns,
|
||||
nameservers: config.dns_config.nameservers,
|
||||
splitDns: config.dns_config.restricted_nameservers,
|
||||
searchDomains: config.dns_config.domains,
|
||||
extraRecords: config.dns_config.extra_records,
|
||||
magicDns: config.dns.magic_dns,
|
||||
baseDomain: config.dns.use_username_in_magic_dns
|
||||
? `[user].${config.dns.base_domain}`
|
||||
: config.dns.base_domain,
|
||||
nameservers: config.dns.nameservers.global,
|
||||
splitDns: config.dns.nameservers.split,
|
||||
searchDomains: config.dns.search_domains,
|
||||
extraRecords: config.dns.extra_records,
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -56,7 +57,11 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
|
||||
const data = await request.json() as Record<string, unknown>
|
||||
await patchConfig(data)
|
||||
await restartHeadscale()
|
||||
|
||||
if (context.integration?.onConfigChange) {
|
||||
await context.integration.onConfigChange(context.integration.context)
|
||||
}
|
||||
|
||||
return json({ success: true })
|
||||
}
|
||||
|
||||
@@ -83,7 +88,11 @@ export default function Page() {
|
||||
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
|
||||
<Nameservers
|
||||
nameservers={allNs}
|
||||
override={data.overrideLocal}
|
||||
isDisabled={!data.config.write}
|
||||
/>
|
||||
|
||||
<DNS
|
||||
records={data.extraRecords}
|
||||
isDisabled={!data.config.write}
|
||||
/>
|
||||
|
||||
@@ -100,7 +109,7 @@ export default function Page() {
|
||||
on the tailnet. Devices will be accessible at
|
||||
{' '}
|
||||
<Code>
|
||||
[device].[user].
|
||||
[device].
|
||||
{data.baseDomain}
|
||||
</Code>
|
||||
{' '}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { type LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { Link, useLoaderData } from '@remix-run/react'
|
||||
|
||||
import Attribute from '~/components/Attribute'
|
||||
import Card from '~/components/Card'
|
||||
import StatusCircle from '~/components/StatusCircle'
|
||||
import { type Machine } from '~/types'
|
||||
import { pull } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!params.id) {
|
||||
throw new Error('No machine ID provided')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const data = await pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!)
|
||||
return data.node
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
useLiveData({ interval: 1000 })
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className='mb-4 text-gray-500 dark:text-gray-400 text-sm'>
|
||||
<Link
|
||||
to='/machines'
|
||||
className='font-bold text-gray-700 dark:text-gray-300 hover:underline'
|
||||
>
|
||||
All Machines
|
||||
</Link>
|
||||
{' / '}
|
||||
{data.givenName}
|
||||
</p>
|
||||
<span className='flex items-baseline gap-x-4 text-sm mb-4'>
|
||||
<h1 className='text-2xl font-bold'>
|
||||
{data.givenName}
|
||||
</h1>
|
||||
<StatusCircle isOnline={data.online} className='w-4 h-4'/>
|
||||
</span>
|
||||
<Card variant='flat'>
|
||||
<Attribute name='Creator' value={data.user.name}/>
|
||||
<Attribute name='Node ID' value={data.id}/>
|
||||
<Attribute name='Node Name' value={data.givenName}/>
|
||||
<Attribute name='Hostname' value={data.name}/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name='Node Key'
|
||||
value={data.nodeKey}
|
||||
/>
|
||||
<Attribute
|
||||
name='Created'
|
||||
value={new Date(data.createdAt).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
name='Last Seen'
|
||||
value={new Date(data.lastSeen).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
name='Expiry'
|
||||
value={new Date(data.expiry).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name='Domain'
|
||||
value={`${data.givenName}.${data.user.name}.ts.net`}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { Link as RemixLink, useLoaderData } from '@remix-run/react'
|
||||
import { InfoIcon, GearIcon, CheckCircleIcon, SkipIcon, PersonIcon } from '@primer/octicons-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import Attribute from '~/components/Attribute'
|
||||
import Button from '~/components/Button'
|
||||
import Card from '~/components/Card'
|
||||
import Menu from '~/components/Menu'
|
||||
import Tooltip from '~/components/Tooltip'
|
||||
import StatusCircle from '~/components/StatusCircle'
|
||||
import { Machine, Route, User } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig } from '~/utils/config/headscale'
|
||||
import { pull } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
import Link from '~/components/Link'
|
||||
|
||||
import { menuAction } from '../_data.machines._index/action'
|
||||
import MenuOptions from '../_data.machines._index/menu'
|
||||
import Routes from '../_data.machines._index/dialogs/routes'
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!params.id) {
|
||||
throw new Error('No machine ID provided')
|
||||
}
|
||||
|
||||
const context = await loadContext()
|
||||
let magic: string | undefined
|
||||
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig()
|
||||
if (config.dns.magic_dns) {
|
||||
magic = config.dns.base_domain
|
||||
}
|
||||
}
|
||||
|
||||
const [machine, routes, users] = await Promise.all([
|
||||
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
|
||||
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
|
||||
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
||||
])
|
||||
|
||||
return {
|
||||
machine: machine.node,
|
||||
routes: routes.routes.filter(route => route.node.id === params.id),
|
||||
users: users.users,
|
||||
magic,
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
return menuAction(request)
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { machine, magic, routes, users } = useLoaderData<typeof loader>()
|
||||
const routesState = useState(false)
|
||||
useLiveData({ interval: 1000 })
|
||||
|
||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||
|| machine.expiry === null
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now()
|
||||
|
||||
const tags = [
|
||||
...machine.forcedTags,
|
||||
...machine.validTags,
|
||||
]
|
||||
|
||||
if (expired) {
|
||||
tags.unshift('Expired')
|
||||
}
|
||||
|
||||
// This is much easier with Object.groupBy but it's too new for us
|
||||
const { exit, subnet, subnetApproved } = routes.reduce((acc, route) => {
|
||||
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
|
||||
acc.exit.push(route)
|
||||
return acc
|
||||
}
|
||||
|
||||
if (route.enabled) {
|
||||
acc.subnetApproved.push(route)
|
||||
return acc
|
||||
}
|
||||
|
||||
acc.subnet.push(route)
|
||||
return acc
|
||||
}, { exit: [], subnetApproved: [], subnet: [] })
|
||||
|
||||
const exitEnabled = useMemo(() => {
|
||||
if (exit.length !== 2) return false
|
||||
return exit[0].enabled && exit[1].enabled
|
||||
}, [exit])
|
||||
|
||||
if (exitEnabled) {
|
||||
tags.unshift('Exit Node')
|
||||
}
|
||||
|
||||
if (subnetApproved.length > 0) {
|
||||
tags.unshift('Subnets')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-8 text-md">
|
||||
<RemixLink
|
||||
to="/machines"
|
||||
className="font-medium"
|
||||
>
|
||||
All Machines
|
||||
</RemixLink>
|
||||
<span className="mx-2">
|
||||
/
|
||||
</span>
|
||||
{machine.givenName}
|
||||
</p>
|
||||
<div className={cn(
|
||||
'flex justify-between items-center',
|
||||
'border-b border-ui-100 dark:border-ui-800',
|
||||
)}>
|
||||
<span className="flex items-baseline gap-x-4 text-sm mb-4">
|
||||
<h1 className="text-2xl font-medium">
|
||||
{machine.givenName}
|
||||
</h1>
|
||||
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
|
||||
</span>
|
||||
|
||||
<MenuOptions
|
||||
className={cn(
|
||||
'bg-ui-100 dark:bg-ui-800',
|
||||
)}
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
users={users}
|
||||
magic={magic}
|
||||
buttonChild={
|
||||
<Menu.Button className={cn(
|
||||
'flex items-center justify-center gap-x-2',
|
||||
'bg-main-200 dark:bg-main-700/30',
|
||||
'hover:bg-main-300 dark:hover:bg-main-600/30',
|
||||
'text-ui-700 dark:text-ui-300 mb-2',
|
||||
'w-fit text-sm rounded-lg px-3 py-2'
|
||||
)}>
|
||||
<GearIcon className="w-5" />
|
||||
Machine Settings
|
||||
</Menu.Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 mb-4">
|
||||
<div className="border-r border-ui-100 dark:border-ui-800 p-2 pr-4">
|
||||
<span className="text-sm text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
||||
Managed by
|
||||
<Tooltip>
|
||||
<Tooltip.Button>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</Tooltip.Button>
|
||||
<Tooltip.Body>
|
||||
By default, a machine’s permissions match its creator’s.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="flex items-center gap-x-2.5 mt-1">
|
||||
<div className={cn(
|
||||
'rounded-full h-7 w-7 flex items-center justify-center',
|
||||
'border border-ui-200 dark:border-ui-700',
|
||||
)}>
|
||||
<PersonIcon className="w-4 h-4" />
|
||||
</div>
|
||||
{machine.user.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 pl-4">
|
||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||
Status
|
||||
</p>
|
||||
<div className="flex gap-1 mt-1 mb-8">
|
||||
{tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className={cn(
|
||||
'text-xs rounded-md px-1.5 py-0.5',
|
||||
'bg-ui-200 dark:bg-ui-800',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-medium mb-4 mt-8">
|
||||
Subnets & Routing
|
||||
</h2>
|
||||
<Routes
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
state={routesState}
|
||||
/>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p>
|
||||
Subnets let you expose physical network routes onto Tailscale.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1019/subnets"
|
||||
name="Tailscale Subnets Documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
<Button
|
||||
variant="light"
|
||||
control={routesState}
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
</div>
|
||||
<Card
|
||||
variant="flat"
|
||||
className={cn(
|
||||
'w-full max-w-full grid sm:grid-cols-2',
|
||||
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
||||
Approved
|
||||
<Tooltip>
|
||||
<Tooltip.Button>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</Tooltip.Button>
|
||||
<Tooltip.Body>
|
||||
Traffic to these routes are being
|
||||
routed through this machine.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{subnetApproved.length === 0 ? (
|
||||
<span className="text-ui-400 dark:text-ui-300">
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{subnetApproved.map(route => (
|
||||
<li key={route.id}>
|
||||
{route.prefix}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
'p-0 rounded-sm bg-transparent mt-1',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
'hover:bg-transparent',
|
||||
'hover:text-blue-600 dark:hover:text-blue-500',
|
||||
)}
|
||||
control={routesState}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
||||
Awaiting Approval
|
||||
<Tooltip>
|
||||
<Tooltip.Button>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</Tooltip.Button>
|
||||
<Tooltip.Body>
|
||||
This machine is advertising these routes,
|
||||
but they must be approved before traffic
|
||||
will be routed to them.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{subnet.length === 0 ? (
|
||||
<span className="text-ui-400 dark:text-ui-300">
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{subnet.map(route => (
|
||||
<li key={route.id}>
|
||||
{route.prefix}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
'p-0 rounded-sm bg-transparent mt-1',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
'hover:bg-transparent',
|
||||
'hover:text-blue-600 dark:hover:text-blue-500',
|
||||
)}
|
||||
control={routesState}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
|
||||
Exit Node
|
||||
<Tooltip>
|
||||
<Tooltip.Button>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</Tooltip.Button>
|
||||
<Tooltip.Body>
|
||||
Whether this machine can act as an
|
||||
exit node for your tailnet.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{exit.length === 0 ? (
|
||||
<span className="text-ui-400 dark:text-ui-300">
|
||||
—
|
||||
</span>
|
||||
) : exitEnabled ? (
|
||||
<span className="flex items-center gap-x-1">
|
||||
<CheckCircleIcon className="w-3.5 h-3.5 text-green-700" />
|
||||
Allowed
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-x-1">
|
||||
<SkipIcon className="w-3.5 h-3.5 text-red-700" />
|
||||
Awaiting Approval
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
'p-0 rounded-sm bg-transparent mt-1',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
'hover:bg-transparent',
|
||||
'hover:text-blue-600 dark:hover:text-blue-500',
|
||||
)}
|
||||
control={routesState}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<h2 className="text-xl font-medium mb-4">
|
||||
Machine Details
|
||||
</h2>
|
||||
<Card variant="flat" className="w-full max-w-full">
|
||||
<Attribute name="Creator" value={machine.user.name} />
|
||||
<Attribute name="Node ID" value={machine.id} />
|
||||
<Attribute name="Node Name" value={machine.givenName} />
|
||||
<Attribute name="Hostname" value={machine.name} />
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Node Key"
|
||||
value={machine.nodeKey}
|
||||
/>
|
||||
<Attribute
|
||||
name="Created"
|
||||
value={new Date(machine.createdAt).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
name="Last Seen"
|
||||
value={new Date(machine.lastSeen).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
name="Expiry"
|
||||
value={expired
|
||||
? new Date(machine.expiry).toLocaleString()
|
||||
: 'Never'
|
||||
}
|
||||
/>
|
||||
{magic
|
||||
? (
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Domain"
|
||||
value={`${machine.givenName}.${magic}`}
|
||||
/>
|
||||
)
|
||||
: undefined}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { ActionFunctionArgs, json } from '@remix-run/node'
|
||||
|
||||
import { del, post } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import log from '~/utils/log'
|
||||
|
||||
export async function menuAction(request: ActionFunctionArgs['request']) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!session.has('hsApiKey')) {
|
||||
return json({ message: 'Unauthorized' }, {
|
||||
status: 401,
|
||||
})
|
||||
}
|
||||
|
||||
const data = await request.formData()
|
||||
if (!data.has('_method') || !data.has('id')) {
|
||||
return json({ message: 'No method or ID provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const id = String(data.get('id'))
|
||||
const method = String(data.get('_method'))
|
||||
|
||||
switch (method) {
|
||||
case 'delete': {
|
||||
await del(`v1/node/${id}`, session.get('hsApiKey')!)
|
||||
return json({ message: 'Machine removed' })
|
||||
}
|
||||
|
||||
case 'expire': {
|
||||
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
|
||||
return json({ message: 'Machine expired' })
|
||||
}
|
||||
|
||||
case 'rename': {
|
||||
if (!data.has('name')) {
|
||||
return json({ message: 'No name provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const name = String(data.get('name'))
|
||||
|
||||
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
|
||||
return json({ message: 'Machine renamed' })
|
||||
}
|
||||
|
||||
case 'routes': {
|
||||
if (!data.has('route') || !data.has('enabled')) {
|
||||
return json({ message: 'No route or enabled provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const route = String(data.get('route'))
|
||||
const enabled = data.get('enabled') === 'true'
|
||||
const postfix = enabled ? 'enable' : 'disable'
|
||||
|
||||
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
|
||||
return json({ message: 'Route updated' })
|
||||
}
|
||||
|
||||
case 'exit-node': {
|
||||
if (!data.has('routes') || !data.has('enabled')) {
|
||||
return json({ message: 'No route or enabled provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const routes = data.get('routes')?.toString().split(',') ?? []
|
||||
const enabled = data.get('enabled') === 'true'
|
||||
const postfix = enabled ? 'enable' : 'disable'
|
||||
|
||||
await Promise.all(routes.map(async (route) => {
|
||||
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
|
||||
}))
|
||||
|
||||
return json({ message: 'Exit node updated' })
|
||||
}
|
||||
|
||||
case 'move': {
|
||||
if (!data.has('to')) {
|
||||
return json({ message: 'No destination provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const to = String(data.get('to'))
|
||||
|
||||
try {
|
||||
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
|
||||
return json({ message: `Moved node ${id} to ${to}` })
|
||||
} catch {
|
||||
return json({ message: `Failed to move node ${id} to ${to}` }, {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
case 'tags': {
|
||||
const tags = data.get('tags')?.toString()
|
||||
.split(',')
|
||||
.filter((tag) => tag.trim() !== '')
|
||||
?? []
|
||||
|
||||
try {
|
||||
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
|
||||
tags,
|
||||
})
|
||||
|
||||
return json({ message: 'Tags updated' })
|
||||
} catch (error) {
|
||||
log.debug('APIC', 'Failed to update tags: %s', error)
|
||||
return json({ message: 'Failed to update tags' }, {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
case 'register': {
|
||||
const key = data.get('mkey')?.toString()
|
||||
const user = data.get('user')?.toString()
|
||||
|
||||
if (!key) {
|
||||
return json({ message: 'No machine key provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return json({ message: 'No user provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const qp = new URLSearchParams()
|
||||
qp.append('user', user)
|
||||
qp.append('key', key)
|
||||
|
||||
const url = `v1/node/register?${qp.toString()}`
|
||||
await post(url, session.get('hsApiKey')!, {
|
||||
user, key,
|
||||
})
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
message: 'Machine registered'
|
||||
})
|
||||
} catch {
|
||||
return json({
|
||||
success: false,
|
||||
message: 'Failed to register machine'
|
||||
}, {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
return json({ message: 'Invalid method' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +1,62 @@
|
||||
import { type FetcherWithComponents } from '@remix-run/react'
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { type Dispatch, type SetStateAction } from 'react'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import { type Machine } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type DeleteProperties = {
|
||||
readonly machine: Machine;
|
||||
readonly fetcher: FetcherWithComponents<unknown>;
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
interface DeleteProps {
|
||||
readonly machine: Machine
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
}
|
||||
|
||||
export default function Delete({ machine, fetcher, state }: DeleteProperties) {
|
||||
export default function Delete({ machine, state }: DeleteProps) {
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Remove {machine.givenName}
|
||||
Remove
|
||||
{' '}
|
||||
{machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
This machine will be permanently removed from
|
||||
your network. To re-add it, you will need to
|
||||
reauthenticate to your tailnet from the device.
|
||||
</Dialog.Text>
|
||||
<fetcher.Form method='POST'>
|
||||
<input type='hidden' name='_method' value='delete'/>
|
||||
<input type='hidden' name='id' value={machine.id}/>
|
||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="delete" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant='cancel'
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant='confirm'
|
||||
variant="confirm"
|
||||
className={cn(
|
||||
'bg-red-500 hover:border-red-700',
|
||||
'dark:bg-red-600 dark:hover:border-red-700',
|
||||
'pressed:bg-red-600 hover:bg-red-600',
|
||||
'text-white dark:text-white'
|
||||
'text-white dark:text-white',
|
||||
)}
|
||||
onPress={close}
|
||||
>
|
||||
Remove
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
|
||||
@@ -1,54 +1,62 @@
|
||||
import { type FetcherWithComponents } from '@remix-run/react'
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { type Dispatch, type SetStateAction } from 'react'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import { type Machine } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type DeleteProperties = {
|
||||
readonly machine: Machine;
|
||||
readonly fetcher: FetcherWithComponents<unknown>;
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
interface ExpireProps {
|
||||
readonly machine: Machine
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
}
|
||||
|
||||
export default function Expire({ machine, fetcher, state }: DeleteProperties) {
|
||||
export default function Expire({ machine, state }: ExpireProps) {
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Expire {machine.givenName}
|
||||
Expire
|
||||
{' '}
|
||||
{machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
This will disconnect the machine from your Tailnet.
|
||||
In order to reconnect, you will need to re-authenticate
|
||||
from the device.
|
||||
</Dialog.Text>
|
||||
<fetcher.Form method='POST'>
|
||||
<input type='hidden' name='_method' value='expire'/>
|
||||
<input type='hidden' name='id' value={machine.id}/>
|
||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="expire" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant='cancel'
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant='confirm'
|
||||
variant="confirm"
|
||||
className={cn(
|
||||
'bg-red-500 hover:border-red-700',
|
||||
'dark:bg-red-600 dark:hover:border-red-700',
|
||||
'pressed:bg-red-600 hover:bg-red-600',
|
||||
'text-white dark:text-white'
|
||||
'text-white dark:text-white',
|
||||
)}
|
||||
onPress={close}
|
||||
>
|
||||
Expire
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { type Dispatch, type SetStateAction, useState } from 'react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Select from '~/components/Select'
|
||||
import { type Machine, User } from '~/types'
|
||||
|
||||
interface MoveProps {
|
||||
readonly machine: Machine
|
||||
readonly users: User[]
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
readonly magic?: string
|
||||
}
|
||||
|
||||
export default function Move({ machine, state, magic, users }: MoveProps) {
|
||||
const [owner, setOwner] = useState(machine.user.name)
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Change the owner of
|
||||
{' '}
|
||||
{machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
The owner of the machine is the user associated with it.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="move" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<Select
|
||||
label="Owner"
|
||||
name="to"
|
||||
placeholder="Select a user"
|
||||
state={[owner, setOwner]}
|
||||
>
|
||||
{users.map(user => (
|
||||
<Select.Item key={user.id} id={user.name}>
|
||||
{user.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
{magic
|
||||
? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||
This machine is accessible by the hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{machine.givenName}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
: undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
Change owner
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Form, useFetcher, Link } from '@remix-run/react'
|
||||
import { Dispatch, SetStateAction, useState, useEffect } from 'react'
|
||||
import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
import Select from '~/components/Select'
|
||||
import Menu from '~/components/Menu'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import { toast } from '~/components/Toaster'
|
||||
import { Machine, User } from '~/types'
|
||||
|
||||
export interface NewProps {
|
||||
server: string
|
||||
users: User[]
|
||||
}
|
||||
|
||||
export default function New(data: NewProps) {
|
||||
const fetcher = useFetcher<{ success?: boolean }>()
|
||||
const mkeyState = useState(false)
|
||||
const [mkey, setMkey] = useState('')
|
||||
const [user, setUser] = useState('')
|
||||
const [toasted, setToasted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetcher.data || toasted) {
|
||||
return
|
||||
}
|
||||
|
||||
if (fetcher.data.success) {
|
||||
toast('Registered new machine')
|
||||
} else {
|
||||
toast('Failed to register machine due to an invalid key')
|
||||
}
|
||||
|
||||
setToasted(true)
|
||||
}, [fetcher.data, toasted, mkey])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog>
|
||||
<Dialog.Panel control={mkeyState}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Register Machine Key
|
||||
</Dialog.Title>
|
||||
<Dialog.Text className='mb-4'>
|
||||
The machine key is given when you run
|
||||
{' '}
|
||||
<Code>
|
||||
tailscale up --login-server=
|
||||
</Code>
|
||||
<Code>
|
||||
{data.server}
|
||||
</Code>
|
||||
{' '}
|
||||
on your device.
|
||||
</Dialog.Text>
|
||||
<fetcher.Form method="POST" onSubmit={e => {
|
||||
fetcher.submit(e.currentTarget)
|
||||
close()
|
||||
}}>
|
||||
<input type="hidden" name="_method" value="register" />
|
||||
<input type="hidden" name="id" value="_" />
|
||||
<TextField
|
||||
label='Machine Key'
|
||||
placeholder='mkey:ff.....'
|
||||
name="mkey"
|
||||
state={[mkey, setMkey]}
|
||||
className='my-2 font-mono'
|
||||
/>
|
||||
<Select
|
||||
label="Owner"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
state={[user, setUser]}
|
||||
>
|
||||
{data.users.map(user => (
|
||||
<Select.Item key={user.id} id={user.name}>
|
||||
{user.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
isDisabled={!mkey || !mkey.trim().startsWith('mkey:') || !user}
|
||||
>
|
||||
{fetcher.state === 'idle'
|
||||
? undefined
|
||||
: (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Register
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
<Menu>
|
||||
<Menu.Button
|
||||
className={cn(
|
||||
'w-fit text-sm rounded-lg px-4 py-2',
|
||||
'bg-main-700 dark:bg-main-800 text-white',
|
||||
'hover:bg-main-800 dark:hover:bg-main-700',
|
||||
)}
|
||||
>
|
||||
Add Device
|
||||
</Menu.Button>
|
||||
<Menu.Items>
|
||||
<Menu.ItemButton control={mkeyState}>
|
||||
<ServerIcon className='w-4 h-4 mr-2'/>
|
||||
Register Machine Key
|
||||
</Menu.ItemButton>
|
||||
<Menu.ItemButton>
|
||||
<Link to="/settings/auth-keys">
|
||||
<KeyIcon className='w-4 h-4 mr-2'/>
|
||||
Generate Pre-auth Key
|
||||
</Link>
|
||||
</Menu.ItemButton>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FetcherWithComponents } from '@remix-run/react'
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { type Dispatch, type SetStateAction, useState } from 'react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
@@ -6,15 +6,15 @@ import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
import { type Machine } from '~/types'
|
||||
|
||||
type RenameProperties = {
|
||||
readonly machine: Machine;
|
||||
readonly fetcher: FetcherWithComponents<unknown>;
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
readonly magic?: string;
|
||||
interface RenameProps {
|
||||
readonly machine: Machine
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
readonly magic?: string
|
||||
}
|
||||
|
||||
export default function Rename({ machine, fetcher, state, magic }: RenameProperties) {
|
||||
export default function Rename({ machine, state, magic }: RenameProps) {
|
||||
const [name, setName] = useState(machine.givenName)
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
@@ -22,65 +22,76 @@ export default function Rename({ machine, fetcher, state, magic }: RenamePropert
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Edit machine name for {machine.givenName}
|
||||
Edit machine name for
|
||||
{' '}
|
||||
{machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
This name is shown in the admin panel, in Tailscale clients,
|
||||
and used when generating MagicDNS names.
|
||||
</Dialog.Text>
|
||||
<fetcher.Form method='POST'>
|
||||
<input type='hidden' name='_method' value='rename'/>
|
||||
<input type='hidden' name='id' value={machine.id}/>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="rename" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<TextField
|
||||
label='Machine name'
|
||||
placeholder='Machine name'
|
||||
name='name'
|
||||
label="Machine name"
|
||||
placeholder="Machine name"
|
||||
name="name"
|
||||
state={[name, setName]}
|
||||
className='my-2'
|
||||
className="my-2"
|
||||
/>
|
||||
{magic ? (
|
||||
name.length > 0 && name !== machine.givenName ? (
|
||||
<p className='text-sm text-gray-500 dark:text-gray-300 leading-tight'>
|
||||
This machine will be accessible by the hostname
|
||||
{' '}
|
||||
<Code className='text-sm'>
|
||||
{name.toLowerCase().replaceAll(/\s+/g, '-')}
|
||||
</Code>
|
||||
{'. '}
|
||||
The hostname
|
||||
{' '}
|
||||
<Code className='text-sm'>
|
||||
{machine.givenName}
|
||||
</Code>
|
||||
{' '}
|
||||
will no longer point to this machine.
|
||||
</p>
|
||||
) : (
|
||||
<p className='text-sm text-gray-500 dark:text-gray-300 leading-tight'>
|
||||
This machine is accessible by the hostname
|
||||
{' '}
|
||||
<Code className='text-sm'>
|
||||
{machine.givenName}
|
||||
</Code>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
) : undefined}
|
||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||
{magic
|
||||
? (
|
||||
name.length > 0 && name !== machine.givenName
|
||||
? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||
This machine will be accessible by the hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{name.toLowerCase().replaceAll(/\s+/g, '-')}
|
||||
</Code>
|
||||
{'. '}
|
||||
The hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{machine.givenName}
|
||||
</Code>
|
||||
{' '}
|
||||
will no longer point to this machine.
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||
This machine is accessible by the hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{machine.givenName}
|
||||
</Code>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
)
|
||||
: undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant='cancel'
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant='confirm'
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
Rename
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
|
||||
@@ -1,57 +1,89 @@
|
||||
import { type FetcherWithComponents } from '@remix-run/react'
|
||||
import { type Dispatch, type SetStateAction } from 'react'
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
import { Dispatch, SetStateAction, useMemo } from 'react'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Switch from '~/components/Switch'
|
||||
import { type Machine, type Route } from '~/types'
|
||||
import Link from '~/components/Link'
|
||||
import { Machine, Route } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type RoutesProperties = {
|
||||
readonly machine: Machine;
|
||||
readonly routes: Route[];
|
||||
readonly fetcher: FetcherWithComponents<unknown>;
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
interface RoutesProps {
|
||||
readonly machine: Machine
|
||||
readonly routes: Route[]
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
}
|
||||
|
||||
// TODO: Support deleting routes
|
||||
export default function Routes({ machine, routes, fetcher, state }: RoutesProperties) {
|
||||
export default function Routes({ machine, routes, state }: RoutesProps) {
|
||||
const fetcher = useFetcher()
|
||||
|
||||
// This is much easier with Object.groupBy but it's too new for us
|
||||
const { exit, subnet } = routes.reduce((acc, route) => {
|
||||
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
|
||||
acc.exit.push(route)
|
||||
return acc
|
||||
}
|
||||
|
||||
acc.subnet.push(route)
|
||||
return acc
|
||||
}, { exit: [], subnet: [] })
|
||||
|
||||
const exitEnabled = useMemo(() => {
|
||||
if (exit.length !== 2) return false
|
||||
return exit[0].enabled && exit[1].enabled
|
||||
}, [exit])
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Edit route settings of {machine.givenName}
|
||||
Edit route settings of
|
||||
{' '}
|
||||
{machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text className="font-bold">
|
||||
Subnet routes
|
||||
</Dialog.Text>
|
||||
<Dialog.Text>
|
||||
Connect to devices you can't install Tailscale on
|
||||
by advertising IP ranges as subnet routes.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1019/subnets"
|
||||
name="Tailscale Subnets Documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</Dialog.Text>
|
||||
<div className={cn(
|
||||
'rounded-lg overflow-y-auto my-2',
|
||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
||||
'border border-zinc-200 dark:border-zinc-700'
|
||||
'border border-zinc-200 dark:border-zinc-700',
|
||||
)}
|
||||
>
|
||||
{routes.length === 0 ? (
|
||||
{subnet.length === 0
|
||||
? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-center',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
No routes are advertised on this machine.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: undefined}
|
||||
{subnet.map(route => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-center',
|
||||
'text-ui-600 dark:text-ui-300'
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
No routes are advertised on this machine.
|
||||
</p>
|
||||
</div>
|
||||
) : undefined}
|
||||
{routes.map(route => (
|
||||
<div
|
||||
key={route.node.id}
|
||||
key={route.id}
|
||||
className={cn(
|
||||
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-between'
|
||||
'items-center justify-between',
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
@@ -59,8 +91,8 @@ export default function Routes({ machine, routes, fetcher, state }: RoutesProper
|
||||
</p>
|
||||
<Switch
|
||||
defaultSelected={route.enabled}
|
||||
label='Enabled'
|
||||
onChange={checked => {
|
||||
label="Enabled"
|
||||
onChange={(checked) => {
|
||||
const form = new FormData()
|
||||
form.set('id', machine.id)
|
||||
form.set('_method', 'routes')
|
||||
@@ -68,16 +100,76 @@ export default function Routes({ machine, routes, fetcher, state }: RoutesProper
|
||||
|
||||
form.set('enabled', String(checked))
|
||||
fetcher.submit(form, {
|
||||
method: 'POST'
|
||||
method: 'POST',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||
<Dialog.Text className="font-bold mt-8">
|
||||
Exit nodes
|
||||
</Dialog.Text>
|
||||
<Dialog.Text>
|
||||
Allow your network to route internet traffic through this machine.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1103/exit-nodes"
|
||||
name="Tailscale Exit-node Documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</Dialog.Text>
|
||||
<div className={cn(
|
||||
'rounded-lg overflow-y-auto my-2',
|
||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
||||
'border border-zinc-200 dark:border-zinc-700',
|
||||
)}
|
||||
>
|
||||
{exit.length === 0
|
||||
? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-center',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
This machine is not an exit node.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-between',
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
Use as exit node
|
||||
</p>
|
||||
<Switch
|
||||
defaultSelected={exitEnabled}
|
||||
label="Enabled"
|
||||
onChange={(checked) => {
|
||||
const form = new FormData()
|
||||
form.set('id', machine.id)
|
||||
form.set('_method', 'exit-node')
|
||||
form.set('routes', exit.map(route => route.id).join(','))
|
||||
|
||||
form.set('enabled', String(checked))
|
||||
fetcher.submit(form, {
|
||||
method: 'POST',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant='cancel'
|
||||
variant="cancel"
|
||||
isDisabled={fetcher.state === 'submitting'}
|
||||
onPress={close}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { PlusIcon, XIcon } from '@primer/octicons-react'
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { Dispatch, SetStateAction, useState } from 'react'
|
||||
import { Button, Input } from 'react-aria-components'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Link from '~/components/Link'
|
||||
import { Machine } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface TagsProps {
|
||||
readonly machine: Machine
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
}
|
||||
|
||||
export default function Tags({ machine, state }: TagsProps) {
|
||||
const [tags, setTags] = useState(machine.forcedTags)
|
||||
const [tag, setTag] = useState('')
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Edit ACL tags for
|
||||
{' '}
|
||||
{machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
ACL tags can be used to reference machines in your ACL policies.
|
||||
See the
|
||||
{' '}
|
||||
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1068/acl-tags"
|
||||
name="Tailscale documentation"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>
|
||||
{' '}
|
||||
for more information.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="tags" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<input type="hidden" name="tags" value={tags.join(',')} />
|
||||
<div
|
||||
className={cn(
|
||||
'border border-ui-300 rounded-lg overflow-visible',
|
||||
'dark:border-ui-700 dark:text-ui-300 mt-4',
|
||||
)}
|
||||
>
|
||||
<div className="divide-y divide-ui-200 dark:divide-ui-600">
|
||||
{tags.length === 0
|
||||
? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-center rounded-t-lg',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
No tags are set on this machine.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: tags.map(item => (
|
||||
<div
|
||||
key={item}
|
||||
id={item}
|
||||
className={cn(
|
||||
'px-2.5 py-1.5 flex',
|
||||
'items-center justify-between',
|
||||
'font-mono text-sm',
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
<Button
|
||||
className="rounded-full p-0 w-6 h-6"
|
||||
onPress={() => {
|
||||
setTags(tags.filter(tag => tag !== item))
|
||||
}}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex px-2.5 py-1.5 w-full',
|
||||
'border-t border-ui-300 dark:border-ui-700',
|
||||
'rounded-b-lg justify-between items-center',
|
||||
'dark:bg-ui-800 dark:text-ui-300',
|
||||
'focus-within:ring-2 focus-within:ring-blue-600',
|
||||
tag.length > 0 && !tag.startsWith('tag:')
|
||||
&& 'outline outline-red-500',
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
placeholder="tag:example"
|
||||
className={cn(
|
||||
'bg-transparent w-full',
|
||||
'border-none focus:ring-0',
|
||||
'focus:outline-none font-mono text-sm',
|
||||
'dark:bg-transparent dark:text-ui-300',
|
||||
)}
|
||||
value={tag}
|
||||
onChange={(e) => {
|
||||
setTag(e.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className={cn(
|
||||
'rounded-lg p-0 h-6 w-6',
|
||||
!tag.startsWith('tag:')
|
||||
&& 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={
|
||||
tag.length === 0
|
||||
|| !tag.startsWith('tag:')
|
||||
|| tags.includes(tag)
|
||||
}
|
||||
onPress={() => {
|
||||
setTags([...tags, tag])
|
||||
setTag('')
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
Save
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,71 +1,99 @@
|
||||
/* eslint-disable react/hook-use-state */
|
||||
import { ChevronDownIcon, CopyIcon, KebabHorizontalIcon } from '@primer/octicons-react'
|
||||
import { type FetcherWithComponents, Link } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react'
|
||||
import { Link } from '@remix-run/react'
|
||||
|
||||
import Menu from '~/components/Menu'
|
||||
import StatusCircle from '~/components/StatusCircle'
|
||||
import { toast } from '~/components/Toaster'
|
||||
import { type Machine, type Route } from '~/types'
|
||||
import { Machine, Route, User } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Delete from './dialogs/delete'
|
||||
import Expire from './dialogs/expire'
|
||||
import Rename from './dialogs/rename'
|
||||
import Routes from './dialogs/routes'
|
||||
import MenuOptions from './menu'
|
||||
|
||||
type MachineProperties = {
|
||||
readonly machine: Machine;
|
||||
readonly routes: Route[];
|
||||
readonly fetcher: FetcherWithComponents<unknown>;
|
||||
readonly magic?: string;
|
||||
interface Props {
|
||||
readonly machine: Machine
|
||||
readonly routes: Route[]
|
||||
readonly users: User[]
|
||||
readonly magic?: string
|
||||
}
|
||||
|
||||
export default function MachineRow({ machine, routes, fetcher, magic }: MachineProperties) {
|
||||
const renameState = useState(false)
|
||||
const expireState = useState(false)
|
||||
const removeState = useState(false)
|
||||
const routesState = useState(false)
|
||||
export default function MachineRow({ machine, routes, magic, users }: Props) {
|
||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||
|| machine.expiry === null
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now()
|
||||
|
||||
const expired = new Date(machine.expiry).getTime() < Date.now()
|
||||
const tags = [
|
||||
...machine.forcedTags,
|
||||
...machine.validTags
|
||||
...machine.validTags,
|
||||
]
|
||||
|
||||
if (expired) {
|
||||
tags.unshift('Expired')
|
||||
}
|
||||
|
||||
let prefix = magic?.startsWith('[user]')
|
||||
? magic.replace('[user]', machine.user.name)
|
||||
: magic
|
||||
|
||||
// This is much easier with Object.groupBy but it's too new for us
|
||||
const { exit, subnet, subnetApproved } = routes.reduce((acc, route) => {
|
||||
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
|
||||
acc.exit.push(route)
|
||||
return acc
|
||||
}
|
||||
|
||||
if (route.enabled) {
|
||||
acc.subnetApproved.push(route)
|
||||
return acc
|
||||
}
|
||||
|
||||
acc.subnet.push(route)
|
||||
return acc
|
||||
}, { exit: [], subnetApproved: [], subnet: [] })
|
||||
|
||||
const exitEnabled = useMemo(() => {
|
||||
if (exit.length !== 2) return false
|
||||
return exit[0].enabled && exit[1].enabled
|
||||
}, [exit])
|
||||
|
||||
if (exitEnabled) {
|
||||
tags.unshift('Exit Node')
|
||||
}
|
||||
|
||||
if (subnetApproved.length > 0) {
|
||||
tags.unshift('Subnets')
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={machine.id}
|
||||
className='hover:bg-zinc-100 dark:hover:bg-zinc-800 group'
|
||||
className="hover:bg-zinc-100 dark:hover:bg-zinc-800 group"
|
||||
>
|
||||
<td className='pl-0.5 py-2'>
|
||||
<td className="pl-0.5 py-2">
|
||||
<Link
|
||||
to={`/machines/${machine.id}`}
|
||||
className='group/link h-full'
|
||||
className="group/link h-full"
|
||||
>
|
||||
<p className={cn(
|
||||
'font-semibold leading-snug',
|
||||
'group-hover/link:text-blue-600',
|
||||
'group-hover/link:dark:text-blue-400'
|
||||
'group-hover/link:dark:text-blue-400',
|
||||
)}
|
||||
>
|
||||
{machine.givenName}
|
||||
</p>
|
||||
<p className='text-sm text-gray-500 dark:text-gray-300 font-mono'>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 font-mono">
|
||||
{machine.name}
|
||||
</p>
|
||||
<div className='flex gap-1 mt-1'>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className={cn(
|
||||
'text-xs rounded-sm px-1 py-0.5',
|
||||
'bg-gray-100 dark:bg-zinc-700',
|
||||
'text-gray-600 dark:text-gray-300'
|
||||
'text-xs rounded-md px-1.5 py-0.5',
|
||||
'bg-ui-200 dark:bg-ui-800',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
@@ -74,21 +102,21 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className='py-2'>
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
{machine.ipAddresses[0]}
|
||||
<Menu>
|
||||
<Menu.Button>
|
||||
<ChevronDownIcon className='w-4 h-4'/>
|
||||
<ChevronDownIcon className="w-4 h-4" />
|
||||
</Menu.Button>
|
||||
<Menu.Items>
|
||||
{machine.ipAddresses.map(ip => (
|
||||
<Menu.ItemButton
|
||||
key={ip}
|
||||
type='button'
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1.5 text-sm',
|
||||
'justify-between w-full'
|
||||
'justify-between w-full',
|
||||
)}
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(ip)
|
||||
@@ -96,108 +124,60 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
|
||||
}}
|
||||
>
|
||||
{ip}
|
||||
<CopyIcon className='w-3 h-3'/>
|
||||
<CopyIcon className="w-3 h-3" />
|
||||
</Menu.ItemButton>
|
||||
))}
|
||||
{magic ? (
|
||||
<Menu.ItemButton
|
||||
type='button'
|
||||
className={cn(
|
||||
'flex items-center gap-x-1.5 text-sm',
|
||||
'justify-between w-full break-keep'
|
||||
)}
|
||||
onPress={async () => {
|
||||
const ip = `${machine.givenName}.${machine.user.name}.${magic}`
|
||||
await navigator.clipboard.writeText(ip)
|
||||
toast('Copied hostname to clipboard')
|
||||
}}
|
||||
>
|
||||
{machine.givenName}.{machine.user.name}.{magic}
|
||||
<CopyIcon className='w-3 h-3'/>
|
||||
</Menu.ItemButton>
|
||||
) : undefined}
|
||||
{magic
|
||||
? (
|
||||
<Menu.ItemButton
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1.5 text-sm',
|
||||
'justify-between w-full break-keep',
|
||||
)}
|
||||
onPress={async () => {
|
||||
const ip = `${machine.givenName}.${prefix}`
|
||||
await navigator.clipboard.writeText(ip)
|
||||
toast('Copied hostname to clipboard')
|
||||
}}
|
||||
>
|
||||
{machine.givenName}
|
||||
.
|
||||
{prefix}
|
||||
<CopyIcon className="w-3 h-3" />
|
||||
</Menu.ItemButton>
|
||||
)
|
||||
: undefined}
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</div>
|
||||
</td>
|
||||
<td className='py-2'>
|
||||
<td className="py-2">
|
||||
<span className={cn(
|
||||
'flex items-center gap-x-1 text-sm',
|
||||
'text-gray-500 dark:text-gray-400'
|
||||
'text-gray-500 dark:text-gray-400',
|
||||
)}
|
||||
>
|
||||
<StatusCircle
|
||||
isOnline={machine.online && !expired}
|
||||
className='w-4 h-4'
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<p>
|
||||
{machine.online && !expired
|
||||
? 'Connected'
|
||||
: new Date(
|
||||
machine.lastSeen
|
||||
machine.lastSeen,
|
||||
).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className='py-2 pr-0.5'>
|
||||
<Rename
|
||||
machine={machine}
|
||||
fetcher={fetcher}
|
||||
state={renameState}
|
||||
magic={magic}
|
||||
/>
|
||||
<Delete
|
||||
machine={machine}
|
||||
fetcher={fetcher}
|
||||
state={removeState}
|
||||
/>
|
||||
{expired ? undefined : (
|
||||
<Expire
|
||||
machine={machine}
|
||||
fetcher={fetcher}
|
||||
state={expireState}
|
||||
/>
|
||||
)}
|
||||
<Routes
|
||||
<td className="py-2 pr-0.5">
|
||||
<MenuOptions
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
fetcher={fetcher}
|
||||
state={routesState}
|
||||
users={users}
|
||||
magic={magic}
|
||||
/>
|
||||
|
||||
<Menu>
|
||||
<Menu.Button
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'border border-transparent rounded-lg py-0.5 w-10',
|
||||
'group-hover:border-gray-200 dark:group-hover:border-zinc-700'
|
||||
)}
|
||||
>
|
||||
<KebabHorizontalIcon className='w-5'/>
|
||||
</Menu.Button>
|
||||
<Menu.Items>
|
||||
<Menu.ItemButton control={renameState}>
|
||||
Edit machine name
|
||||
</Menu.ItemButton>
|
||||
<Menu.ItemButton control={routesState}>
|
||||
Edit route settings
|
||||
</Menu.ItemButton>
|
||||
<Menu.Item className='opacity-50 hover:bg-transparent'>
|
||||
Edit ACL tags
|
||||
</Menu.Item>
|
||||
{expired ? undefined : (
|
||||
<Menu.ItemButton control={expireState}>
|
||||
Expire
|
||||
</Menu.ItemButton>
|
||||
)}
|
||||
<Menu.ItemButton
|
||||
className='text-red-500 dark:text-red-400'
|
||||
control={removeState}
|
||||
>
|
||||
Remove
|
||||
</Menu.ItemButton>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { KebabHorizontalIcon } from '@primer/octicons-react'
|
||||
import { ReactNode, useState } from 'react'
|
||||
|
||||
import MenuComponent from '~/components/Menu'
|
||||
import { Machine, Route, User } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Delete from './dialogs/delete'
|
||||
import Expire from './dialogs/expire'
|
||||
import Move from './dialogs/move'
|
||||
import Rename from './dialogs/rename'
|
||||
import Routes from './dialogs/routes'
|
||||
import Tags from './dialogs/tags'
|
||||
|
||||
interface MenuProps {
|
||||
machine: Machine
|
||||
routes: Route[]
|
||||
users: User[]
|
||||
magic?: string
|
||||
buttonChild?: ReactNode
|
||||
}
|
||||
|
||||
export default function Menu({ machine, routes, magic, users, buttonChild }: MenuProps) {
|
||||
const renameState = useState(false)
|
||||
const expireState = useState(false)
|
||||
const removeState = useState(false)
|
||||
const routesState = useState(false)
|
||||
const moveState = useState(false)
|
||||
const tagsState = useState(false)
|
||||
|
||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||
|| machine.expiry === null
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Rename
|
||||
machine={machine}
|
||||
state={renameState}
|
||||
magic={magic}
|
||||
/>
|
||||
<Delete
|
||||
machine={machine}
|
||||
state={removeState}
|
||||
/>
|
||||
{expired
|
||||
? undefined
|
||||
: (
|
||||
<Expire
|
||||
machine={machine}
|
||||
state={expireState}
|
||||
/>
|
||||
)}
|
||||
<Routes
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
state={routesState}
|
||||
/>
|
||||
<Tags
|
||||
machine={machine}
|
||||
state={tagsState}
|
||||
/>
|
||||
<Move
|
||||
machine={machine}
|
||||
state={moveState}
|
||||
users={users}
|
||||
magic={magic}
|
||||
/>
|
||||
|
||||
<MenuComponent>
|
||||
{buttonChild ?? (
|
||||
<MenuComponent.Button
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'border border-transparent rounded-lg py-0.5 w-10',
|
||||
'group-hover:border-gray-200 dark:group-hover:border-zinc-700',
|
||||
)}
|
||||
>
|
||||
<KebabHorizontalIcon className="w-5" />
|
||||
</MenuComponent.Button>
|
||||
)}
|
||||
<MenuComponent.Items>
|
||||
<MenuComponent.ItemButton control={renameState}>
|
||||
Edit machine name
|
||||
</MenuComponent.ItemButton>
|
||||
<MenuComponent.ItemButton control={routesState}>
|
||||
Edit route settings
|
||||
</MenuComponent.ItemButton>
|
||||
<MenuComponent.ItemButton control={tagsState}>
|
||||
Edit ACL tags
|
||||
</MenuComponent.ItemButton>
|
||||
<MenuComponent.ItemButton control={moveState}>
|
||||
Change owner
|
||||
</MenuComponent.ItemButton>
|
||||
{expired
|
||||
? undefined
|
||||
: (
|
||||
<MenuComponent.ItemButton control={expireState}>
|
||||
Expire
|
||||
</MenuComponent.ItemButton>
|
||||
)}
|
||||
<MenuComponent.ItemButton
|
||||
className="text-red-500 dark:text-red-400"
|
||||
control={removeState}
|
||||
>
|
||||
Remove
|
||||
</MenuComponent.ItemButton>
|
||||
</MenuComponent.Items>
|
||||
</MenuComponent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +1,29 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { InfoIcon } from '@primer/octicons-react'
|
||||
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { useFetcher, useLoaderData } from '@remix-run/react'
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { useLoaderData } from '@remix-run/react'
|
||||
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import { type Machine, type Route } from '~/types'
|
||||
import Link from '~/components/Link'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig } from '~/utils/config/headscale'
|
||||
import { del, post, pull } from '~/utils/headscale'
|
||||
import { pull } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
import type { Machine, Route, User } from '~/types'
|
||||
|
||||
import { menuAction } from './action'
|
||||
import MachineRow from './machine'
|
||||
import NewMachine from './dialogs/new'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
const [machines, routes] = await Promise.all([
|
||||
const [machines, routes, users] = await Promise.all([
|
||||
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
|
||||
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
|
||||
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
||||
])
|
||||
|
||||
const context = await loadContext()
|
||||
@@ -27,91 +31,50 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig()
|
||||
if (config.dns_config.magic_dns) {
|
||||
magic = config.dns_config.base_domain
|
||||
if (config.dns.magic_dns) {
|
||||
magic = config.dns.base_domain
|
||||
}
|
||||
|
||||
if (config.dns.use_username_in_magic_dns) {
|
||||
magic = `[user].${magic}`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: machines.nodes,
|
||||
routes: routes.routes,
|
||||
users: users.users,
|
||||
magic,
|
||||
server: context.headscaleUrl,
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!session.has('hsApiKey')) {
|
||||
return json({ message: 'Unauthorized' }, {
|
||||
status: 401,
|
||||
})
|
||||
}
|
||||
|
||||
const data = await request.formData()
|
||||
if (!data.has('_method') || !data.has('id')) {
|
||||
return json({ message: 'No method or ID provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const id = String(data.get('id'))
|
||||
const method = String(data.get('_method'))
|
||||
|
||||
switch (method) {
|
||||
case 'delete': {
|
||||
await del(`v1/node/${id}`, session.get('hsApiKey')!)
|
||||
return json({ message: 'Machine removed' })
|
||||
}
|
||||
|
||||
case 'expire': {
|
||||
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
|
||||
return json({ message: 'Machine expired' })
|
||||
}
|
||||
|
||||
case 'rename': {
|
||||
if (!data.has('name')) {
|
||||
return json({ message: 'No name provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const name = String(data.get('name'))
|
||||
|
||||
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
|
||||
return json({ message: 'Machine renamed' })
|
||||
}
|
||||
|
||||
case 'routes': {
|
||||
if (!data.has('route') || !data.has('enabled')) {
|
||||
return json({ message: 'No route or enabled provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const route = String(data.get('route'))
|
||||
const enabled = data.get('enabled') === 'true'
|
||||
const postfix = enabled ? 'enable' : 'disable'
|
||||
|
||||
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
|
||||
return json({ message: 'Route updated' })
|
||||
}
|
||||
|
||||
default: {
|
||||
return json({ message: 'Invalid method' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
}
|
||||
return menuAction(request)
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
useLiveData({ interval: 3000 })
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const fetcher = useFetcher()
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl font-medium mb-4">Machines</h1>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className='text-2xl font-medium mb-4'>Machines</h1>
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
Manage the devices connected to your Tailnet.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1372/manage-devices"
|
||||
name="Tailscale Manage Devices Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<NewMachine server={data.server} users={data.users} />
|
||||
</div>
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
<thead className="text-gray-500 dark:text-gray-400">
|
||||
<tr className="text-left uppercase text-xs font-bold px-0.5">
|
||||
@@ -135,7 +98,7 @@ export default function Page() {
|
||||
based on their name and also at
|
||||
{' '}
|
||||
<Code>
|
||||
[name].[user].
|
||||
[name].
|
||||
{data.magic}
|
||||
</Code>
|
||||
</Tooltip>
|
||||
@@ -155,10 +118,9 @@ export default function Page() {
|
||||
{data.nodes.map(machine => (
|
||||
<MachineRow
|
||||
key={machine.id}
|
||||
// Typescript isn't smart enough yet
|
||||
machine={machine as unknown as Machine}
|
||||
routes={data.routes.filter(route => route.node.id === machine.id) as unknown as Route[]}
|
||||
fetcher={fetcher}
|
||||
machine={machine}
|
||||
routes={data.routes.filter(route => route.node.id === machine.id)}
|
||||
users={data.users}
|
||||
magic={data.magic}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { IssueDraftIcon } from '@primer/octicons-react'
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className='w-96 mx-auto flex flex-col justify-center items-center text-center my-8'>
|
||||
<IssueDraftIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
|
||||
<p className='text-lg mt-8'>
|
||||
The settings page is currently unavailable.
|
||||
It will be available in a future release.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Link from '~/components/Link'
|
||||
import Button from '~/components/Button'
|
||||
import { Link as RemixLink } from '@remix-run/react'
|
||||
import { ArrowRightIcon } from '@primer/octicons-react'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex flex-col gap-8 max-w-screen-lg">
|
||||
<div className='flex flex-col w-2/3'>
|
||||
<h1 className='text-2xl font-medium mb-4'>Settings</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
The settings page is still under construction.
|
||||
As I'm able to add more features, I'll be adding them here.
|
||||
If you require any features, feel free to open an issue on
|
||||
the GitHub repository.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-col w-2/3'>
|
||||
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Headscale fully supports pre-authentication keys in order to
|
||||
easily add devices to your Tailnet.
|
||||
To learn more about using pre-authentication keys, visit the
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
||||
name="Tailscale Auth Keys documentation"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<RemixLink to="/settings/auth-keys">
|
||||
<span className={cn(
|
||||
'text-lg font-medium',
|
||||
'text-gray-700 dark:text-gray-300',
|
||||
)}>
|
||||
Manage Auth Keys
|
||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
||||
</span>
|
||||
</RemixLink>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
import type { PreAuthKey } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Spinner from '~/components/Spinner'
|
||||
|
||||
interface Props {
|
||||
authKey: PreAuthKey
|
||||
}
|
||||
|
||||
export default function ExpireKey({ authKey }: Props) {
|
||||
const fetcher = useFetcher()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button className="my-4">
|
||||
Expire Key
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Expire auth key?
|
||||
</Dialog.Title>
|
||||
<fetcher.Form method="DELETE" onSubmit={e => {
|
||||
fetcher.submit(e.currentTarget)
|
||||
close()
|
||||
}}>
|
||||
<input type="hidden" name="user" value={authKey.user} />
|
||||
<input type="hidden" name="key" value={authKey.key} />
|
||||
<Dialog.Text>
|
||||
Expiring this authentication key will immediately
|
||||
prevent it from being used to authenticate new devices.
|
||||
{' '}
|
||||
This action cannot be undone.
|
||||
</Dialog.Text>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
className={cn(
|
||||
'bg-red-500 hover:border-red-700',
|
||||
'dark:bg-red-600 dark:hover:border-red-700',
|
||||
'pressed:bg-red-600 hover:bg-red-600',
|
||||
'text-white dark:text-white',
|
||||
)}
|
||||
onPress={close}
|
||||
>
|
||||
{fetcher.state === 'idle'
|
||||
? undefined
|
||||
: (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Expire
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { RepoForkedIcon } from '@primer/octicons-react'
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
import NumberField from '~/components/NumberField'
|
||||
import Tooltip from '~/components/Tooltip'
|
||||
import Select from '~/components/Select'
|
||||
import Switch from '~/components/Switch'
|
||||
import Link from '~/components/Link'
|
||||
import Spinner from '~/components/Spinner'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
import { User } from '~/types'
|
||||
|
||||
interface Props {
|
||||
users: User[]
|
||||
}
|
||||
|
||||
// TODO: Tags
|
||||
export default function AddPreAuthKey(data: Props) {
|
||||
const fetcher = useFetcher()
|
||||
const [user, setUser] = useState('')
|
||||
const [reusable, setReusable] = useState(false)
|
||||
const [ephemeral, setEphemeral] = useState(false)
|
||||
const [aclTags, setAclTags] = useState([])
|
||||
const [expiry, setExpiry] = useState(90)
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button className="my-4">
|
||||
Create pre-auth key
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Generate auth key
|
||||
</Dialog.Title>
|
||||
<fetcher.Form method="POST" onSubmit={e => {
|
||||
fetcher.submit(e.currentTarget)
|
||||
close()
|
||||
}}>
|
||||
<Dialog.Text className="font-semibold">
|
||||
User
|
||||
</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
Attach this key to a user
|
||||
</Dialog.Text>
|
||||
<Select
|
||||
label="Owner"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
state={[user, setUser]}
|
||||
>
|
||||
{data.users.map(user => (
|
||||
<Select.Item key={user.id} id={user.name}>
|
||||
{user.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
<Dialog.Text className="font-semibold mt-4">
|
||||
Key Expiration
|
||||
</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
Set this key to expire between 1 and 90 days.
|
||||
</Dialog.Text>
|
||||
<NumberField
|
||||
label="Expiry"
|
||||
name="expiry"
|
||||
minValue={1}
|
||||
maxValue={90}
|
||||
state={[expiry, setExpiry]}
|
||||
formatOptions={{
|
||||
style: 'unit',
|
||||
unit: 'day',
|
||||
unitDisplay: 'short',
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<div>
|
||||
<Dialog.Text className="font-semibold">
|
||||
Reusable
|
||||
</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
Use this key to authenticate more than one device.
|
||||
</Dialog.Text>
|
||||
</div>
|
||||
<Switch
|
||||
label="Reusable"
|
||||
name="reusable"
|
||||
defaultSelected={reusable}
|
||||
onChange={() => { setReusable(!reusable) }}
|
||||
/>
|
||||
</div>
|
||||
<input type="hidden" name="reusable" value={reusable.toString()} />
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<div>
|
||||
<Dialog.Text className="font-semibold">
|
||||
Ephemeral
|
||||
</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
Devices authenticated with this key will
|
||||
be automatically removed once they go offline.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1111/ephemeral-nodes"
|
||||
name="Tailscale Ephemeral Nodes Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Dialog.Text>
|
||||
</div>
|
||||
<Switch
|
||||
label="Ephemeral"
|
||||
name="ephemeral"
|
||||
defaultSelected={ephemeral}
|
||||
onChange={() => {
|
||||
setEphemeral(!ephemeral)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input type="hidden" name="ephemeral" value={ephemeral.toString()} />
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
isDisabled={!user || !expiry}
|
||||
>
|
||||
{fetcher.state === 'idle'
|
||||
? undefined
|
||||
: (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Generate
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { PreAuthKey } from '~/types'
|
||||
import { toast } from '~/components/Toaster'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Button from '~/components/Button'
|
||||
import Attribute from '~/components/Attribute'
|
||||
import ExpireKey from './dialogs/expire'
|
||||
|
||||
interface Props {
|
||||
authKey: PreAuthKey
|
||||
server: string
|
||||
}
|
||||
|
||||
export default function AuthKeyRow({ authKey, server }: Props) {
|
||||
const createdAt = new Date(authKey.createdAt).toLocaleString()
|
||||
const expiration = new Date(authKey.expiration).toLocaleString()
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Attribute name="Key" value={authKey.key} isCopyable />
|
||||
<Attribute name="User" value={authKey.user} isCopyable />
|
||||
<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} />
|
||||
<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">
|
||||
{authKey.used || new Date(authKey.expiration) < new Date()
|
||||
? undefined
|
||||
: (
|
||||
<ExpireKey authKey={authKey} />
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
className="my-4"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
`tailscale up --login-server ${server} --authkey ${authKey.key}`
|
||||
)
|
||||
|
||||
toast('Copied command to clipboard')
|
||||
}}
|
||||
>
|
||||
Copy Tailscale Command
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { LoaderFunctionArgs, ActionFunctionArgs, json } from '@remix-run/node'
|
||||
import { useLoaderData } from '@remix-run/react'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { Link as RemixLink } from '@remix-run/react'
|
||||
import { PreAuthKey, User } from '~/types'
|
||||
import { pull, post } from '~/utils/headscale'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Link from '~/components/Link'
|
||||
import TableList from '~/components/TableList'
|
||||
import Select from '~/components/Select'
|
||||
import Switch from '~/components/Switch'
|
||||
|
||||
import AddPreAuthKey from './dialogs/new'
|
||||
import AuthKeyRow from './key'
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!session.has('hsApiKey')) {
|
||||
return json({ message: 'Unauthorized' }, {
|
||||
status: 401,
|
||||
})
|
||||
}
|
||||
|
||||
const data = await request.formData()
|
||||
|
||||
// Expiring a pre-auth key
|
||||
if (request.method === 'DELETE') {
|
||||
const key = data.get('key')
|
||||
const user = data.get('user')
|
||||
|
||||
if (!key || !user) {
|
||||
return json({ message: 'Missing parameters' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
await post<{ preAuthKey: PreAuthKey }>(
|
||||
'v1/preauthkey/expire',
|
||||
session.get('hsApiKey')!,
|
||||
{
|
||||
user: user,
|
||||
key: key,
|
||||
}
|
||||
)
|
||||
|
||||
return json({ message: 'Pre-auth key expired' })
|
||||
}
|
||||
|
||||
// Creating a new pre-auth key
|
||||
if (request.method === 'POST') {
|
||||
const user = data.get('user')
|
||||
const expiry = data.get('expiry')
|
||||
const reusable = data.get('reusable')
|
||||
const ephemeral = data.get('ephemeral')
|
||||
|
||||
if (!user || !expiry || !reusable || !ephemeral) {
|
||||
return json({ message: 'Missing parameters' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
// Extract the first "word" from expiry which is the day number
|
||||
// Calculate the date X days from now using the day number
|
||||
const day = Number(expiry.toString().split(' ')[0])
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + day)
|
||||
|
||||
const key = await post<{ preAuthKey: PreAuthKey }>(
|
||||
'v1/preauthkey',
|
||||
session.get('hsApiKey')!,
|
||||
{
|
||||
user: user,
|
||||
ephemeral: ephemeral === 'on',
|
||||
reusable: reusable === 'on',
|
||||
expiration: date.toISOString(),
|
||||
aclTags: [], // TODO
|
||||
}
|
||||
)
|
||||
|
||||
return json({ message: 'Pre-auth key created', key })
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const context = await loadContext()
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
const users = await pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!)
|
||||
|
||||
const preAuthKeys = await Promise.all(users.users.map(user => {
|
||||
const qp = new URLSearchParams()
|
||||
qp.set('user', user.name)
|
||||
|
||||
return pull<{ preAuthKeys: PreAuthKey[] }>(
|
||||
`v1/preauthkey?${qp.toString()}`,
|
||||
session.get('hsApiKey')!
|
||||
)
|
||||
}))
|
||||
|
||||
return {
|
||||
keys: preAuthKeys.flatMap(keys => keys.preAuthKeys),
|
||||
users: users.users,
|
||||
server: context.headscaleUrl,
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { keys, users, server } = useLoaderData<typeof loader>()
|
||||
const [user, setUser] = useState('All')
|
||||
const [status, setStatus] = useState('Active')
|
||||
useLiveData({ interval: 3000 })
|
||||
|
||||
const filteredKeys = keys.filter(key => {
|
||||
if (user !== 'All' && key.user !== user) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (status !== 'All') {
|
||||
const now = new Date()
|
||||
const expiry = new Date(key.expiration)
|
||||
|
||||
if (status === 'Active') {
|
||||
return !(expiry < now) && !key.used
|
||||
}
|
||||
|
||||
if (status === 'Used/Expired') {
|
||||
return key.used || expiry < now
|
||||
}
|
||||
|
||||
if (status === 'Reusable') {
|
||||
return key.reusable
|
||||
}
|
||||
|
||||
if (status === 'Ephemeral') {
|
||||
return key.ephemeral
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='flex flex-col w-2/3'>
|
||||
<p className="mb-8 text-md">
|
||||
<RemixLink
|
||||
to="/settings"
|
||||
className="font-medium"
|
||||
>
|
||||
Settings
|
||||
</RemixLink>
|
||||
<span className="mx-2">
|
||||
/
|
||||
</span>
|
||||
{' '}
|
||||
Pre-Auth Keys
|
||||
</p>
|
||||
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
||||
Headscale fully supports pre-authentication keys in order to
|
||||
easily add devices to your Tailnet.
|
||||
To learn more about using pre-authentication keys, visit the
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
||||
name="Tailscale Auth Keys documentation"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>
|
||||
</p>
|
||||
<AddPreAuthKey users={users} />
|
||||
<div className="flex justify-between gap-4 mt-4">
|
||||
<div className="w-full">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
||||
Filter by user
|
||||
</p>
|
||||
<Select
|
||||
label="Filter by User"
|
||||
placeholder="Select a user"
|
||||
state={[user, setUser]}
|
||||
>
|
||||
<Select.Item id="All">All</Select.Item>
|
||||
{users.map(user => (
|
||||
<Select.Item key={user.id} id={user.name}>
|
||||
{user.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300">
|
||||
Filter by status
|
||||
</p>
|
||||
<Select
|
||||
label="Filter by status"
|
||||
placeholder="Select a status"
|
||||
state={[status, setStatus]}
|
||||
>
|
||||
<Select.Item id="All">All</Select.Item>
|
||||
<Select.Item id="Active">Active</Select.Item>
|
||||
<Select.Item id="Used/Expired">Used/Expired</Select.Item>
|
||||
<Select.Item id="Reusable">Reusable</Select.Item>
|
||||
<Select.Item id="Ephemeral">Ephemeral</Select.Item>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<TableList className="mt-4">
|
||||
{filteredKeys.length === 0 ? (
|
||||
<TableList.Item>
|
||||
<p className="opacity-50 text-sm mx-auto">
|
||||
No pre-auth keys
|
||||
</p>
|
||||
</TableList.Item>
|
||||
) : filteredKeys.map(key => (
|
||||
<TableList.Item key={key.id}>
|
||||
<AuthKeyRow authKey={key} server={server} />
|
||||
</TableList.Item>
|
||||
))}
|
||||
</TableList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
|
||||
const context = await loadContext()
|
||||
return {
|
||||
...context,
|
||||
config: context.config,
|
||||
user: session.get('user'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ export default function Add({ magic }: Props) {
|
||||
{' '}
|
||||
<Code>
|
||||
[machine].
|
||||
{username.length > 0 ? username : '[username]'}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
|
||||
@@ -8,10 +8,9 @@ import Dialog from '~/components/Dialog'
|
||||
|
||||
interface Props {
|
||||
username: string
|
||||
magic?: string
|
||||
}
|
||||
|
||||
export default function Remove({ username, magic }: Props) {
|
||||
export default function Remove({ username }: Props) {
|
||||
const submit = useSubmit()
|
||||
const dialogState = useState(false)
|
||||
|
||||
@@ -41,24 +40,6 @@ export default function Remove({ username, magic }: Props) {
|
||||
?
|
||||
{' '}
|
||||
A deleted user cannot be recovered.
|
||||
{magic
|
||||
? (
|
||||
<p className="text-sm mt-8 text-ui-600 dark:text-ui-300">
|
||||
{' '}
|
||||
Since Magic DNS is enabled, machines
|
||||
currently accessible via
|
||||
{' '}
|
||||
<Code>
|
||||
[machine].
|
||||
{username}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
{' '}
|
||||
will become orphaned and inaccessible.
|
||||
</p>
|
||||
)
|
||||
: undefined}
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Form, useSubmit } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
|
||||
@@ -40,33 +39,6 @@ export default function Rename({ username, magic }: Props) {
|
||||
Enter a new username for
|
||||
{' '}
|
||||
{username}
|
||||
?
|
||||
{magic
|
||||
? (
|
||||
<p className="text-sm mt-8 text-ui-600 dark:text-ui-300">
|
||||
{' '}
|
||||
Since Magic DNS is enabled, machines
|
||||
currently accessible via
|
||||
{' '}
|
||||
<Code>
|
||||
[machine].
|
||||
{username}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
{' '}
|
||||
will now become accessible via
|
||||
{' '}
|
||||
<Code>
|
||||
[machine].
|
||||
{newName.length > 0 ? newName : '[new-username]'}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
: undefined}
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { type DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
|
||||
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
|
||||
import { PersonIcon } from '@primer/octicons-react'
|
||||
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { useActionData, useLoaderData, useSubmit } from '@remix-run/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ClientOnly } from 'remix-utils/client-only'
|
||||
@@ -10,7 +10,7 @@ import Attribute from '~/components/Attribute'
|
||||
import Card from '~/components/Card'
|
||||
import StatusCircle from '~/components/StatusCircle'
|
||||
import { toast } from '~/components/Toaster'
|
||||
import { type Machine, type User } from '~/types'
|
||||
import { Machine, User } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig } from '~/utils/config/headscale'
|
||||
@@ -41,8 +41,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig()
|
||||
if (config.dns_config.magic_dns) {
|
||||
magic = config.dns_config.base_domain
|
||||
if (config.dns.magic_dns) {
|
||||
magic = config.dns.base_domain
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,15 +182,12 @@ export default function Page() {
|
||||
<Auth magic={data.magic} />
|
||||
)}
|
||||
<ClientOnly fallback={
|
||||
// @ts-expect-error: JsonifyObject is weird
|
||||
<Users users={users} />
|
||||
}
|
||||
>
|
||||
{() => (
|
||||
<InteractiveUsers
|
||||
// @ts-expect-error: JsonifyObject is weird
|
||||
users={users}
|
||||
// @ts-expect-error: JsonifyObject is weird
|
||||
setUsers={setUsers}
|
||||
magic={data.magic}
|
||||
/>
|
||||
@@ -211,11 +208,10 @@ interface UserProps {
|
||||
function Users({ users, magic }: UserProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
|
||||
{users.map((user, i) => (
|
||||
{users.map(user => (
|
||||
<UserCard
|
||||
key={user.id}
|
||||
user={user}
|
||||
isFirst={i === 0}
|
||||
magic={magic}
|
||||
/>
|
||||
))}
|
||||
@@ -267,11 +263,10 @@ function InteractiveUsers({ users, setUsers, magic }: UserProps) {
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
|
||||
{users.map((user, i) => (
|
||||
{users.map(user => (
|
||||
<UserCard
|
||||
key={user.id}
|
||||
user={user}
|
||||
isFirst={i === 0}
|
||||
magic={magic}
|
||||
/>
|
||||
))}
|
||||
@@ -304,6 +299,7 @@ function MachineChip({ machine }: { readonly machine: Machine }) {
|
||||
<StatusCircle isOnline={machine.online} className="w-4 h-4 px-1 w-fit" />
|
||||
<Attribute
|
||||
name={machine.givenName}
|
||||
link={`machines/${machine.id}`}
|
||||
value={machine.ipAddresses[0]}
|
||||
/>
|
||||
</div>
|
||||
@@ -312,11 +308,10 @@ function MachineChip({ machine }: { readonly machine: Machine }) {
|
||||
|
||||
interface CardProps {
|
||||
user: UserMachine
|
||||
isFirst: boolean
|
||||
magic?: string
|
||||
}
|
||||
|
||||
function UserCard({ user, isFirst, magic }: CardProps) {
|
||||
function UserCard({ user, magic }: CardProps) {
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: user.name,
|
||||
})
|
||||
@@ -339,11 +334,11 @@ function UserCard({ user, isFirst, magic }: CardProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Rename username={user.name} magic={magic} />
|
||||
{isFirst
|
||||
? undefined
|
||||
: (
|
||||
<Remove username={user.name} magic={magic} />
|
||||
)}
|
||||
{user.machines.length === 0
|
||||
? (
|
||||
<Remove username={user.name} />
|
||||
)
|
||||
: undefined}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
|
||||
@@ -80,8 +80,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
}),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} catch {
|
||||
return json({
|
||||
error: 'Invalid API key',
|
||||
})
|
||||
@@ -123,6 +122,7 @@ export default function Page() {
|
||||
label="API Key"
|
||||
name="api-key"
|
||||
placeholder="API Key"
|
||||
type="password"
|
||||
/>
|
||||
<Button
|
||||
className="w-full mt-2.5"
|
||||
|
||||
@@ -7,3 +7,24 @@
|
||||
scrollbar-gutter: stable
|
||||
}
|
||||
}
|
||||
|
||||
.cm-merge-theme {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.cm-mergeView {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.cm-mergeViewEditors {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cm-mergeViewEditor {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Weirdest class name characters but ok */
|
||||
.cm-mergeView .ͼ1 .cm-scroller, .cm-mergeView .ͼ1 {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import type { User } from './User'
|
||||
|
||||
export type Machine = {
|
||||
id: string;
|
||||
machineKey: string;
|
||||
nodeKey: string;
|
||||
discoKey: string;
|
||||
ipAddresses: string[];
|
||||
name: string;
|
||||
export interface Machine {
|
||||
id: string
|
||||
machineKey: string
|
||||
nodeKey: string
|
||||
discoKey: string
|
||||
ipAddresses: string[]
|
||||
name: string
|
||||
|
||||
user: User;
|
||||
lastSeen: Date;
|
||||
expiry: Date;
|
||||
user: User
|
||||
lastSeen: string
|
||||
expiry: string
|
||||
|
||||
preAuthKey?: unknown; // TODO
|
||||
preAuthKey?: unknown // TODO
|
||||
|
||||
createdAt: Date;
|
||||
createdAt: string
|
||||
registerMethod: 'REGISTER_METHOD_UNSPECIFIED'
|
||||
| 'REGISTER_METHOD_AUTH_KEY'
|
||||
| 'REGISTER_METHOD_CLI'
|
||||
| 'REGISTER_METHOD_OIDC';
|
||||
| 'REGISTER_METHOD_OIDC'
|
||||
|
||||
forcedTags: string[];
|
||||
invalidTags: string[];
|
||||
validTags: string[];
|
||||
givenName: string;
|
||||
online: boolean;
|
||||
forcedTags: string[]
|
||||
invalidTags: string[]
|
||||
validTags: string[]
|
||||
givenName: string
|
||||
online: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface PreAuthKey {
|
||||
id: string
|
||||
key: string
|
||||
user: string
|
||||
reusable: boolean
|
||||
ephemeral: boolean
|
||||
used: boolean
|
||||
expiration: string
|
||||
createdAt: string
|
||||
aclTags: string[]
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Machine } from './Machine'
|
||||
|
||||
export type Route = {
|
||||
id: string;
|
||||
node: Machine;
|
||||
prefix: string;
|
||||
advertised: boolean;
|
||||
enabled: boolean;
|
||||
isPrimary: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date;
|
||||
export interface Route {
|
||||
id: string
|
||||
node: Machine
|
||||
prefix: string
|
||||
advertised: boolean
|
||||
enabled: boolean
|
||||
isPrimary: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deletedAt: string
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
export interface User {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './Key'
|
||||
export * from './Machine'
|
||||
export * from './Route'
|
||||
export * from './User'
|
||||
export * from './PreAuthKey'
|
||||
|
||||
@@ -8,28 +8,21 @@ import { resolve } from 'node:path'
|
||||
|
||||
import { parse } from 'yaml'
|
||||
|
||||
import { HeadscaleConfig, loadConfig } from './headscale'
|
||||
import { IntegrationFactory, loadIntegration } from '~/integration'
|
||||
import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale'
|
||||
import log from '~/utils/log'
|
||||
|
||||
export interface HeadplaneContext {
|
||||
debug: boolean
|
||||
headscaleUrl: string
|
||||
cookieSecret: string
|
||||
integration: IntegrationFactory | undefined
|
||||
|
||||
config: {
|
||||
read: boolean
|
||||
write: boolean
|
||||
}
|
||||
|
||||
acl: {
|
||||
read: boolean
|
||||
write: boolean
|
||||
}
|
||||
|
||||
docker?: {
|
||||
url: string
|
||||
sock: boolean
|
||||
container: string
|
||||
}
|
||||
|
||||
oidc?: {
|
||||
issuer: string
|
||||
client: string
|
||||
@@ -46,12 +39,20 @@ export async function loadContext(): Promise<HeadplaneContext> {
|
||||
return context
|
||||
}
|
||||
|
||||
let config: HeadscaleConfig | undefined
|
||||
try {
|
||||
config = await loadConfig()
|
||||
} catch {}
|
||||
const envFile = process.env.LOAD_ENV_FILE === 'true'
|
||||
if (envFile) {
|
||||
log.info('CTXT', 'Loading environment variables from .env')
|
||||
await import('dotenv/config')
|
||||
}
|
||||
|
||||
const debug = process.env.DEBUG === 'true'
|
||||
if (debug) {
|
||||
log.info('CTXT', 'Debug mode is enabled! Logs will spam a lot.')
|
||||
log.info('CTXT', 'Please disable debug mode in production.')
|
||||
}
|
||||
|
||||
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
||||
const { config, contextData } = await checkConfig(path)
|
||||
|
||||
let headscaleUrl = process.env.HEADSCALE_URL
|
||||
if (!headscaleUrl && !config) {
|
||||
@@ -72,146 +73,68 @@ export async function loadContext(): Promise<HeadplaneContext> {
|
||||
}
|
||||
|
||||
context = {
|
||||
debug,
|
||||
headscaleUrl,
|
||||
cookieSecret,
|
||||
config: await checkConfig(path, config),
|
||||
acl: await checkAcl(config),
|
||||
docker: await checkDocker(),
|
||||
integration: await loadIntegration(),
|
||||
config: contextData,
|
||||
oidc: await checkOidc(config),
|
||||
}
|
||||
|
||||
log.info('CTXT', 'Starting Headplane with Context')
|
||||
log.info('CTXT', 'HEADSCALE_URL: %s', headscaleUrl)
|
||||
log.info('CTXT', 'Integration: %s', context.integration?.name ?? 'None')
|
||||
log.info('CTXT', 'Config: %s', contextData.read
|
||||
? `Found ${contextData.write ? '' : '(Read Only)'}`
|
||||
: 'Unavailable',
|
||||
)
|
||||
|
||||
log.info('CTXT', 'OIDC: %s', context.oidc ? 'Configured' : 'Unavailable')
|
||||
return context
|
||||
}
|
||||
|
||||
export async function loadAcl() {
|
||||
let path = process.env.ACL_FILE
|
||||
if (!path) {
|
||||
try {
|
||||
const config = await loadConfig()
|
||||
path = config.acl_policy_path
|
||||
} catch {}
|
||||
}
|
||||
async function checkConfig(path: string) {
|
||||
log.debug('CTXT', 'Checking config at %s', path)
|
||||
|
||||
if (!path) {
|
||||
return { data: '', type: 'json' }
|
||||
}
|
||||
|
||||
const data = await readFile(path, 'utf8')
|
||||
|
||||
// Naive check for YAML over JSON
|
||||
// This is because JSON.parse doesn't support comments
|
||||
let config: HeadscaleConfig | undefined
|
||||
try {
|
||||
parse(data)
|
||||
return { data, type: 'yaml' }
|
||||
config = await loadConfig(path)
|
||||
} catch {
|
||||
return { data, type: 'json' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function patchAcl(data: string) {
|
||||
let path = process.env.ACL_FILE
|
||||
if (!path) {
|
||||
try {
|
||||
const config = await loadConfig()
|
||||
path = config.acl_policy_path
|
||||
} catch {}
|
||||
log.debug('CTXT', 'Config at %s failed to load', path)
|
||||
return {
|
||||
config: undefined,
|
||||
contextData: {
|
||||
read: false,
|
||||
write: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
throw new Error('No ACL file defined')
|
||||
}
|
||||
|
||||
await writeFile(path, data, 'utf8')
|
||||
}
|
||||
|
||||
async function checkConfig(path: string, config?: HeadscaleConfig) {
|
||||
let write = false
|
||||
try {
|
||||
log.debug('CTXT', 'Checking write access to %s', path)
|
||||
await access(path, constants.W_OK)
|
||||
write = true
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
read: config ? true : false,
|
||||
write,
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAcl(config?: HeadscaleConfig) {
|
||||
let path = process.env.ACL_FILE
|
||||
if (!path && config) {
|
||||
path = config.acl_policy_path
|
||||
}
|
||||
|
||||
let read = false
|
||||
let write = false
|
||||
if (path) {
|
||||
try {
|
||||
await access(path, constants.R_OK)
|
||||
read = true
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await access(path, constants.W_OK)
|
||||
write = true
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
read,
|
||||
write,
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDocker() {
|
||||
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock'
|
||||
|
||||
let url: URL | undefined
|
||||
try {
|
||||
url = new URL(path)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// The API is available as an HTTP endpoint
|
||||
if (url.protocol === 'tcp:') {
|
||||
url.protocol = 'http:'
|
||||
}
|
||||
|
||||
// Check if the socket is accessible
|
||||
if (url.protocol === 'unix:') {
|
||||
try {
|
||||
await access(path, constants.R_OK)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (url.protocol === 'http:') {
|
||||
try {
|
||||
await fetch(new URL('/v1.30/version', url).href)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'unix:') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!process.env.HEADSCALE_CONTAINER) {
|
||||
return
|
||||
log.debug('CTXT', 'No write access to %s', path)
|
||||
}
|
||||
|
||||
return {
|
||||
url: url.href,
|
||||
sock: url.protocol === 'unix:',
|
||||
container: process.env.HEADSCALE_CONTAINER,
|
||||
config,
|
||||
contextData: {
|
||||
read: true,
|
||||
write,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOidc(config?: HeadscaleConfig) {
|
||||
log.debug('CTXT', 'Checking OIDC configuration')
|
||||
|
||||
const disableKeyLogin = process.env.DISABLE_API_KEY_LOGIN === 'true'
|
||||
log.debug('CTXT', 'API Key Login Enabled: %s', !disableKeyLogin)
|
||||
|
||||
log.debug('CTXT', 'Checking ROOT_API_KEY and falling back to API_KEY')
|
||||
const rootKey = process.env.ROOT_API_KEY ?? process.env.API_KEY
|
||||
if (!rootKey) {
|
||||
throw new Error('ROOT_API_KEY or API_KEY not set')
|
||||
@@ -221,6 +144,10 @@ async function checkOidc(config?: HeadscaleConfig) {
|
||||
let client = process.env.OIDC_CLIENT_ID
|
||||
let secret = process.env.OIDC_CLIENT_SECRET
|
||||
|
||||
log.debug('CTXT', 'Checking OIDC environment variables')
|
||||
log.debug('CTXT', 'Issuer: %s', issuer)
|
||||
log.debug('CTXT', 'Client: %s', client)
|
||||
|
||||
if (
|
||||
(issuer ?? client ?? secret)
|
||||
&& !(issuer && client && secret)
|
||||
@@ -229,12 +156,23 @@ async function checkOidc(config?: HeadscaleConfig) {
|
||||
throw new Error('OIDC environment variables are incomplete')
|
||||
}
|
||||
|
||||
if (issuer && client && secret) {
|
||||
return {
|
||||
issuer,
|
||||
client,
|
||||
secret,
|
||||
rootKey,
|
||||
disableKeyLogin,
|
||||
}
|
||||
}
|
||||
|
||||
if ((!issuer || !client || !secret) && config) {
|
||||
issuer = config.oidc?.issuer
|
||||
client = config.oidc?.client_id
|
||||
secret = config.oidc?.client_secret
|
||||
|
||||
if (!secret && config.oidc?.client_secret_path) {
|
||||
log.debug('CTXT', 'Trying to read OIDC client secret from %s', config.oidc.client_secret_path)
|
||||
try {
|
||||
const data = await readFile(
|
||||
config.oidc.client_secret_path,
|
||||
@@ -244,7 +182,9 @@ async function checkOidc(config?: HeadscaleConfig) {
|
||||
if (data && data.length > 0) {
|
||||
secret = data.trim()
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
log.error('CTXT', 'Failed to read OIDC client secret from %s', config.oidc.client_secret_path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,20 @@ import { resolve } from 'node:path'
|
||||
import { type Document, parseDocument } from 'yaml'
|
||||
import { z } from 'zod'
|
||||
|
||||
import log from '~/utils/log'
|
||||
|
||||
const goBool = z
|
||||
.union([z.boolean(), z.literal('true'), z.literal('false')])
|
||||
.transform((value) => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
|
||||
return value === 'true'
|
||||
})
|
||||
|
||||
const goDuration = z.union([z.literal(0), z.string()])
|
||||
|
||||
const HeadscaleConfig = z.object({
|
||||
tls_letsencrypt_cache_dir: z.string().default('/var/www/cache'),
|
||||
tls_letsencrypt_challenge_type: z.enum(['HTTP-01', 'TLS-ALPN-01']).default('HTTP-01'),
|
||||
@@ -26,12 +40,11 @@ const HeadscaleConfig = z.object({
|
||||
listen_addr: z.string(),
|
||||
metrics_listen_addr: z.string().optional(),
|
||||
grpc_listen_addr: z.string().default(':50443'),
|
||||
grpc_allow_insecure: z.boolean().default(false),
|
||||
grpc_allow_insecure: goBool.default(false),
|
||||
|
||||
disable_check_updates: z.boolean().default(false),
|
||||
ephemeral_node_inactivity_timeout: z.string().default('120s'),
|
||||
randomize_client_port: z.boolean().default(false),
|
||||
acl_policy_path: z.string().optional(),
|
||||
disable_check_updates: goBool.default(false),
|
||||
ephemeral_node_inactivity_timeout: goDuration.default('120s'),
|
||||
randomize_client_port: goBool.default(false),
|
||||
|
||||
acme_email: z.string().optional(),
|
||||
acme_url: z.string().optional(),
|
||||
@@ -39,8 +52,13 @@ const HeadscaleConfig = z.object({
|
||||
unix_socket: z.string().default('/var/run/headscale/headscale.sock'),
|
||||
unix_socket_permission: z.string().default('0o770'),
|
||||
|
||||
policy: z.object({
|
||||
mode: z.enum(['file', 'database']).default('file'),
|
||||
path: z.string().optional(),
|
||||
}).optional(),
|
||||
|
||||
tuning: z.object({
|
||||
batch_change_delay: z.string().default('800ms'),
|
||||
batch_change_delay: goDuration.default('800ms'),
|
||||
node_mapsession_buffered_chan_size: z.number().default(30),
|
||||
}).optional(),
|
||||
|
||||
@@ -54,14 +72,14 @@ const HeadscaleConfig = z.object({
|
||||
}).default({ level: 'info', format: 'text' }),
|
||||
|
||||
logtail: z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
enabled: goBool.default(false),
|
||||
}).default({ enabled: false }),
|
||||
|
||||
cli: z.object({
|
||||
address: z.string().optional(),
|
||||
api_key: z.string().optional(),
|
||||
timeout: z.string().default('10s'),
|
||||
insecure: z.boolean().default(false),
|
||||
timeout: goDuration.default('10s'),
|
||||
insecure: goBool.default(false),
|
||||
}).optional(),
|
||||
|
||||
prefixes: z.object({
|
||||
@@ -70,61 +88,63 @@ const HeadscaleConfig = z.object({
|
||||
v6: z.string(),
|
||||
}),
|
||||
|
||||
dns_config: z.object({
|
||||
override_local_dns: z.boolean().default(true),
|
||||
nameservers: z.array(z.string()).default([]),
|
||||
restricted_nameservers: z.record(z.array(z.string())).default({}),
|
||||
domains: z.array(z.string()).default([]),
|
||||
dns: z.object({
|
||||
magic_dns: goBool.default(true),
|
||||
base_domain: z.string().default('headscale.net'),
|
||||
nameservers: z.object({
|
||||
global: z.array(z.string()).default([]),
|
||||
split: z.record(z.array(z.string())).default({}),
|
||||
}).default({ global: [], split: {} }),
|
||||
search_domains: z.array(z.string()).default([]),
|
||||
extra_records: z.array(z.object({
|
||||
name: z.string(),
|
||||
type: z.literal('A'),
|
||||
value: z.string(),
|
||||
})).default([]),
|
||||
magic_dns: z.boolean().default(false),
|
||||
base_domain: z.string().default('headscale.net'),
|
||||
use_username_in_magic_dns: goBool.default(false),
|
||||
}),
|
||||
|
||||
oidc: z.object({
|
||||
only_start_if_oidc_is_available: z.boolean().default(true),
|
||||
only_start_if_oidc_is_available: goBool.default(false),
|
||||
issuer: z.string().optional(),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
client_secret_path: z.string().optional(),
|
||||
scope: z.array(z.string()).default(['openid', 'profile', 'email']),
|
||||
extra_params: z.record(z.string()).default({}),
|
||||
extra_params: z.record(z.unknown()).default({}),
|
||||
allowed_domains: z.array(z.string()).optional(),
|
||||
allowed_users: z.array(z.string()).optional(),
|
||||
allowed_groups: z.array(z.string()).optional(),
|
||||
strip_email_domain: z.boolean().default(true),
|
||||
expiry: z.string().default('180d'),
|
||||
use_expiry_from_token: z.boolean().default(false),
|
||||
strip_email_domain: goBool.default(false),
|
||||
expiry: goDuration.default('180d'),
|
||||
use_expiry_from_token: goBool.default(false),
|
||||
}).optional(),
|
||||
|
||||
database: z.union([
|
||||
z.object({
|
||||
type: z.literal('sqlite'),
|
||||
debug: z.boolean().default(false),
|
||||
debug: goBool.default(false),
|
||||
sqlite: z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('sqlite3'),
|
||||
debug: z.boolean().default(false),
|
||||
debug: goBool.default(false),
|
||||
sqlite: z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('postgres'),
|
||||
debug: z.boolean().default(false),
|
||||
debug: goBool.default(false),
|
||||
postgres: z.object({
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
name: z.string(),
|
||||
user: z.string(),
|
||||
pass: z.string(),
|
||||
ssl: z.boolean().default(false),
|
||||
ssl: goBool.default(true),
|
||||
max_open_conns: z.number().default(10),
|
||||
max_idle_conns: z.number().default(10),
|
||||
conn_max_idle_time_secs: z.number().default(3600),
|
||||
@@ -134,7 +154,7 @@ const HeadscaleConfig = z.object({
|
||||
|
||||
derp: z.object({
|
||||
server: z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
enabled: goBool.default(true),
|
||||
region_id: z.number().optional(),
|
||||
region_code: z.string().optional(),
|
||||
region_name: z.string().optional(),
|
||||
@@ -143,13 +163,13 @@ const HeadscaleConfig = z.object({
|
||||
|
||||
ipv4: z.string().optional(),
|
||||
ipv6: z.string().optional(),
|
||||
automatically_add_embedded_derp_region: z.boolean().default(true),
|
||||
automatically_add_embedded_derp_region: goBool.default(true),
|
||||
}),
|
||||
|
||||
urls: z.array(z.string()).optional(),
|
||||
paths: z.array(z.string()).optional(),
|
||||
auto_update_enabled: z.boolean().default(true),
|
||||
update_frequency: z.string().default('24h'),
|
||||
auto_update_enabled: goBool.default(true),
|
||||
update_frequency: goDuration.default('24h'),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -158,16 +178,102 @@ export type HeadscaleConfig = z.infer<typeof HeadscaleConfig>
|
||||
export let configYaml: Document | undefined
|
||||
export let config: HeadscaleConfig | undefined
|
||||
|
||||
export async function loadConfig() {
|
||||
export async function loadConfig(path?: string) {
|
||||
if (config) {
|
||||
return config
|
||||
}
|
||||
|
||||
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
||||
const data = await readFile(path, 'utf8')
|
||||
if (!path) {
|
||||
throw new Error('Path is required to lazy load config')
|
||||
}
|
||||
|
||||
log.debug('CFGX', 'Loading Headscale configuration from %s', path)
|
||||
const data = await readFile(path, 'utf8')
|
||||
configYaml = parseDocument(data)
|
||||
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
|
||||
|
||||
if (process.env.HEADSCALE_CONFIG_UNSTRICT === 'true') {
|
||||
log.debug('CFGX', 'Loaded Headscale configuration in non-strict mode')
|
||||
const loaded = configYaml.toJSON() as Record<string, unknown>
|
||||
config = {
|
||||
...loaded,
|
||||
tls_letsencrypt_cache_dir: loaded.tls_letsencrypt_cache_dir ?? '/var/www/cache',
|
||||
tls_letsencrypt_challenge_type: loaded.tls_letsencrypt_challenge_type ?? 'HTTP-01',
|
||||
grpc_listen_addr: loaded.grpc_listen_addr ?? ':50443',
|
||||
grpc_allow_insecure: loaded.grpc_allow_insecure ?? false,
|
||||
randomize_client_port: loaded.randomize_client_port ?? false,
|
||||
unix_socket: loaded.unix_socket ?? '/var/run/headscale/headscale.sock',
|
||||
unix_socket_permission: loaded.unix_socket_permission ?? '0o770',
|
||||
tuning: loaded.tuning ?? {
|
||||
batch_change_delay: '800ms',
|
||||
node_mapsession_buffered_chan_size: 30,
|
||||
},
|
||||
|
||||
log: loaded.log ?? {
|
||||
level: 'info',
|
||||
format: 'text',
|
||||
},
|
||||
|
||||
logtail: loaded.logtail ?? {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
cli: loaded.cli ?? {
|
||||
timeout: '10s',
|
||||
insecure: false,
|
||||
},
|
||||
|
||||
prefixes: loaded.prefixes ?? {
|
||||
allocation: 'sequential',
|
||||
v4: '',
|
||||
v6: '',
|
||||
},
|
||||
|
||||
dns: loaded.dns ?? {
|
||||
nameservers: {
|
||||
global: [],
|
||||
split: {},
|
||||
},
|
||||
search_domains: [],
|
||||
extra_records: [],
|
||||
magic_dns: false,
|
||||
base_domain: 'headscale.net',
|
||||
},
|
||||
} as HeadscaleConfig
|
||||
|
||||
log.warn('CFGX', 'Loaded Headscale configuration in non-strict mode')
|
||||
log.warn('CFGX', 'By using this mode you forfeit GitHub issue support')
|
||||
log.warn('CFGX', 'This is very dangerous and comes with a few caveats:')
|
||||
log.warn('CFGX', 'Headplane could very easily crash')
|
||||
log.warn('CFGX', 'Headplane could break your Headscale installation')
|
||||
log.warn('CFGX', 'The UI could throw random errors/show incorrect data')
|
||||
log.warn('CFGX', '')
|
||||
return config
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug('CFGX', 'Attempting to parse Headscale configuration')
|
||||
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
|
||||
} catch (error) {
|
||||
log.debug('CFGX', 'Failed to load Headscale configuration')
|
||||
if (error instanceof z.ZodError) {
|
||||
log.error('CFGX', 'Recieved invalid configuration file')
|
||||
log.error('CFGX', 'The following schema issues were found:')
|
||||
for (const issue of error.issues) {
|
||||
const path = issue.path.map(String).join('.')
|
||||
const message = issue.message
|
||||
|
||||
log.error('CFGX', ` '${path}': ${message}`)
|
||||
}
|
||||
|
||||
log.error('CFGX', '')
|
||||
log.error('CFGX', 'Resolve these issues and try again.')
|
||||
log.error('CFGX', 'Headplane will operate without the config')
|
||||
log.error('CFGX', '')
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -177,7 +283,9 @@ export async function patchConfig(partial: Record<string, unknown>) {
|
||||
throw new Error('Config not loaded')
|
||||
}
|
||||
|
||||
log.debug('CFGX', 'Patching Headscale configuration')
|
||||
for (const [key, value] of Object.entries(partial)) {
|
||||
log.debug('CFGX', 'Patching %s with %s', key, value)
|
||||
// If the key is something like `test.bar."foo.bar"`, then we treat
|
||||
// the foo.bar as a single key, and not as two keys, so that needs
|
||||
// to be split correctly.
|
||||
@@ -206,10 +314,19 @@ export async function patchConfig(partial: Record<string, unknown>) {
|
||||
|
||||
// Push the remaining element
|
||||
path.push(temp.replaceAll('"', ''))
|
||||
if (value === null) {
|
||||
configYaml.deleteIn(path)
|
||||
continue
|
||||
}
|
||||
|
||||
configYaml.setIn(path, value)
|
||||
}
|
||||
|
||||
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
|
||||
config = process.env.HEADSCALE_CONFIG_UNSTRICT === 'true'
|
||||
? configYaml.toJSON() as HeadscaleConfig
|
||||
: (await HeadscaleConfig.parseAsync(configYaml.toJSON()))
|
||||
|
||||
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
||||
log.debug('CFGX', 'Writing patched configuration to %s', path)
|
||||
await writeFile(path, configYaml.toString(), 'utf8')
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
|
||||
import { Client } from 'undici'
|
||||
|
||||
import { loadContext } from './config/headplane'
|
||||
import { HeadscaleError, pull } from './headscale'
|
||||
|
||||
export async function sighupHeadscale() {
|
||||
const context = await loadContext()
|
||||
if (!context.docker) {
|
||||
return
|
||||
}
|
||||
|
||||
// Supports the DOCKER_SOCK environment variable
|
||||
const client = context.docker.sock
|
||||
? new Client('http://localhost', {
|
||||
socketPath: context.docker.url,
|
||||
})
|
||||
: new Client(context.docker.url)
|
||||
|
||||
const response = await client.request({
|
||||
method: 'POST',
|
||||
path: `/v1.30/containers/${context.docker.container}/kill?signal=SIGHUP`,
|
||||
})
|
||||
|
||||
if (!response.statusCode || response.statusCode !== 204) {
|
||||
throw new Error('Failed to send SIGHUP to Headscale')
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartHeadscale() {
|
||||
const context = await loadContext()
|
||||
if (!context.docker) {
|
||||
return
|
||||
}
|
||||
|
||||
// Supports the DOCKER_SOCK environment variable
|
||||
const client = context.docker.sock
|
||||
? new Client('http://localhost', {
|
||||
socketPath: context.docker.url,
|
||||
})
|
||||
: new Client(context.docker.url)
|
||||
|
||||
const response = await client.request({
|
||||
method: 'POST',
|
||||
path: `/v1.30/containers/${context.docker.container}/restart`,
|
||||
})
|
||||
|
||||
if (!response.statusCode || response.statusCode !== 204) {
|
||||
throw new Error('Failed to restart Headscale')
|
||||
}
|
||||
|
||||
// Wait for Headscale to restart before continuing
|
||||
let attempts = 0
|
||||
// eslint-disable-next-line
|
||||
while (true) {
|
||||
try {
|
||||
// Acceptable blank because ROOT_API_KEY is not required
|
||||
await pull('v1/apikey', context.oidc?.rootKey ?? '')
|
||||
return
|
||||
} catch (error) {
|
||||
// This means the server is up but the API key is invalid
|
||||
// This can happen if the user only uses ROOT_API_KEY via cookies
|
||||
if (error instanceof HeadscaleError && error.status === 401) {
|
||||
break
|
||||
}
|
||||
|
||||
if (attempts > 10) {
|
||||
throw new Error('Headscale did not restart in time')
|
||||
}
|
||||
|
||||
attempts++
|
||||
await setTimeout(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { loadContext } from './config/headplane'
|
||||
import log from './log'
|
||||
|
||||
export class HeadscaleError extends Error {
|
||||
status: number
|
||||
@@ -18,8 +19,14 @@ export class FatalError extends Error {
|
||||
}
|
||||
|
||||
export async function pull<T>(url: string, key: string) {
|
||||
if (!key || key === 'undefined' || key.length === 0) {
|
||||
throw new Error('Missing API key, could this be a cookie setting issue?')
|
||||
}
|
||||
|
||||
const context = await loadContext()
|
||||
const prefix = context.headscaleUrl
|
||||
|
||||
log.debug('APIC', 'GET %s', `${prefix}/api/${url}`)
|
||||
const response = await fetch(`${prefix}/api/${url}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
@@ -27,6 +34,7 @@ export async function pull<T>(url: string, key: string) {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
log.debug('APIC', 'GET %s failed with status %d', `${prefix}/api/${url}`, response.status)
|
||||
throw new HeadscaleError(await response.text(), response.status)
|
||||
}
|
||||
|
||||
@@ -34,8 +42,14 @@ export async function pull<T>(url: string, key: string) {
|
||||
}
|
||||
|
||||
export async function post<T>(url: string, key: string, body?: unknown) {
|
||||
if (!key || key === 'undefined' || key.length === 0) {
|
||||
throw new Error('Missing API key, could this be a cookie setting issue?')
|
||||
}
|
||||
|
||||
const context = await loadContext()
|
||||
const prefix = context.headscaleUrl
|
||||
|
||||
log.debug('APIC', 'POST %s', `${prefix}/api/${url}`)
|
||||
const response = await fetch(`${prefix}/api/${url}`, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
@@ -45,6 +59,32 @@ export async function post<T>(url: string, key: string, body?: unknown) {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
log.debug('APIC', 'POST %s failed with status %d', `${prefix}/api/${url}`, response.status)
|
||||
throw new HeadscaleError(await response.text(), response.status)
|
||||
}
|
||||
|
||||
return (response.json() as Promise<T>)
|
||||
}
|
||||
|
||||
export async function put<T>(url: string, key: string, body?: unknown) {
|
||||
if (!key || key === 'undefined' || key.length === 0) {
|
||||
throw new Error('Missing API key, could this be a cookie setting issue?')
|
||||
}
|
||||
|
||||
const context = await loadContext()
|
||||
const prefix = context.headscaleUrl
|
||||
|
||||
log.debug('APIC', 'PUT %s', `${prefix}/api/${url}`)
|
||||
const response = await fetch(`${prefix}/api/${url}`, {
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
log.debug('APIC', 'PUT %s failed with status %d', `${prefix}/api/${url}`, response.status)
|
||||
throw new HeadscaleError(await response.text(), response.status)
|
||||
}
|
||||
|
||||
@@ -52,8 +92,14 @@ export async function post<T>(url: string, key: string, body?: unknown) {
|
||||
}
|
||||
|
||||
export async function del<T>(url: string, key: string) {
|
||||
if (!key || key === 'undefined' || key.length === 0) {
|
||||
throw new Error('Missing API key, could this be a cookie setting issue?')
|
||||
}
|
||||
|
||||
const context = await loadContext()
|
||||
const prefix = context.headscaleUrl
|
||||
|
||||
log.debug('APIC', 'DELETE %s', `${prefix}/api/${url}`)
|
||||
const response = await fetch(`${prefix}/api/${url}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
@@ -62,6 +108,7 @@ export async function del<T>(url: string, key: string) {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
log.debug('APIC', 'DELETE %s failed with status %d', `${prefix}/api/${url}`, response.status)
|
||||
throw new HeadscaleError(await response.text(), response.status)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export default {
|
||||
info: (category: string, message: string, ...args: unknown[]) => {
|
||||
defaultLog('INFO', category, message, ...args)
|
||||
},
|
||||
|
||||
warn: (category: string, message: string, ...args: unknown[]) => {
|
||||
defaultLog('WARN', category, message, ...args)
|
||||
},
|
||||
|
||||
error: (category: string, message: string, ...args: unknown[]) => {
|
||||
defaultLog('ERRO', category, message, ...args)
|
||||
},
|
||||
|
||||
debug: (category: string, message: string, ...args: unknown[]) => {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
defaultLog('DEBG', category, message, ...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function defaultLog(
|
||||
level: string,
|
||||
category: string,
|
||||
message: string,
|
||||
...args: unknown[]
|
||||
) {
|
||||
const date = new Date().toISOString()
|
||||
console.log(`${date} (${level}) [${category}] ${message}`, ...args)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { redirect } from '@remix-run/node'
|
||||
import {
|
||||
authorizationCodeGrantRequest,
|
||||
calculatePKCECodeChallenge,
|
||||
type Client,
|
||||
Client,
|
||||
discoveryRequest,
|
||||
generateRandomCodeVerifier,
|
||||
generateRandomNonce,
|
||||
@@ -51,8 +51,8 @@ export async function startOidc(oidc: OidcConfig, req: Request) {
|
||||
const challenge = await calculatePKCECodeChallenge(verifier)
|
||||
|
||||
const callback = new URL('/admin/oidc/callback', req.url)
|
||||
callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:'
|
||||
callback.hostname = req.headers.get('Host') ?? ''
|
||||
callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:'
|
||||
callback.host = req.headers.get('Host') ?? ''
|
||||
const authUrl = new URL(processed.authorization_endpoint)
|
||||
|
||||
authUrl.searchParams.set('client_id', oidcClient.client_id)
|
||||
@@ -119,8 +119,8 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
|
||||
}
|
||||
|
||||
const callback = new URL('/admin/oidc/callback', req.url)
|
||||
callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:'
|
||||
callback.hostname = req.headers.get('Host') ?? ''
|
||||
callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:'
|
||||
callback.host = req.headers.get('Host') ?? ''
|
||||
|
||||
const tokenResponse = await authorizationCodeGrantRequest(
|
||||
processed,
|
||||
|
||||
@@ -27,9 +27,8 @@ export const {
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
secrets: [process.env.COOKIE_SECRET!],
|
||||
secure: true
|
||||
secure: process.env.COOKIE_SECURE !== 'false',
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 151 KiB |
@@ -2,14 +2,13 @@
|
||||
# IT IS NOT AN EXAMPLE OF SOMETHING YOU DEPLOY
|
||||
# I ONLY USE IT FOR DEVELOPING HEADPLANE
|
||||
|
||||
version: '3.9'
|
||||
networks:
|
||||
headplane-dev:
|
||||
name: 'headplane-dev'
|
||||
driver: 'bridge'
|
||||
services:
|
||||
headscale:
|
||||
image: 'headscale/headscale:0.23.0-alpha5'
|
||||
image: 'headscale/headscale:0.23.0-beta2'
|
||||
container_name: 'headscale'
|
||||
restart: 'unless-stopped'
|
||||
command: 'serve'
|
||||
|
||||
@@ -1,130 +1,71 @@
|
||||
# Advanced Integration
|
||||
|
||||

|
||||
The advanced integration methods unlock the full capabilities of Headplane.
|
||||
This is the closest you can get to the SaaS experience if you were paying for
|
||||
Tailscale.
|
||||
|
||||
With the advanced integration it's possible to control Access Control Lists (ACLs) and the Headscale configuration via the Headplane UI.
|
||||
Every single aspect of this integration is optional, meaning you can only use what you want.
|
||||
If you want to use this integration, you do not need Docker and you can make it work with Headscale and Headplane running natively.
|
||||
### Configuration Management
|
||||
|
||||
### Configuration Editing
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="../assets/integration-dark.png"
|
||||
>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="../assets/integration-light.png"
|
||||
>
|
||||
<img
|
||||
alt="Integration Preview"
|
||||
src="../assets/integration-dark.png"
|
||||
>
|
||||
</picture>
|
||||
|
||||
When the configuration file is available to Headplane, the `DNS` and `Settings` tabs will become functional.
|
||||
Similar to the Tailscale UI, you'll be able to edit the configuration without needing to manually edit the file.
|
||||
Headscale will read the file from the path given in the `CONFIG_FILE` environment variable.
|
||||
By default this is set to `/etc/headscale/config.yaml`.
|
||||
The advanced integration allows you to manage the Headscale configuration via
|
||||
the Headplane UI. When the configuration is available for editing, the `DNS`
|
||||
and `Settings` tabs will become available. When using the Docker or Kubernetes
|
||||
integration, changes to the configuration file will be automatically applied
|
||||
to Headscale.
|
||||
|
||||
> One important think to note is that environment variables always take priority over the configuration file.
|
||||
> The `HEADSCALE_URL`, `OIDC_CLIENT_ID`, `OIDFC_ISSUER`, and `OIDC_CLIENT_SECRET` will be preferred over the configuration file if available.
|
||||
> By default, the configuration file is read from `/etc/headscale/config.yaml`.
|
||||
This can be overridden by setting the `CONFIG_FILE` environment variable. Any
|
||||
variables including `HEADSCALE_URL`, `OIDC_CLIENT_ID`, `OIDC_ISSUER`, and
|
||||
`OIDC_CLIENT_SECRET` will take priority over the configuration file.
|
||||
|
||||
### Access Control Lists (ACLs)
|
||||
|
||||

|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="../assets/acls-dark.png"
|
||||
>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="../assets/acls-light.png"
|
||||
>
|
||||
<img
|
||||
alt="ACL Preview"
|
||||
src="../assets/acls-dark.png"
|
||||
>
|
||||
</picture>
|
||||
|
||||
Headplane will enable the `Access Controls` tab if it is able to read an ACL file from Headscale.<br>
|
||||
The ACL file path is read from the following sources in order of priority:
|
||||
The advanced integration allows you to manage the ACLs via the Headplane UI.
|
||||
When the ACL file is available for editing, the `Access Controls` tab will
|
||||
become available. All of the integrations support automatic reloading of the
|
||||
ACLs when the file is changed.
|
||||
|
||||
- **Environment Variable**: If you set the `ACL_FILE` environment variable, Headplane will read the file from that path.
|
||||
- **Configuration Integration**: If you've set this up, then Headplane will read the `acl_policy_path` key from the configuration file.
|
||||
|
||||
### Docker Integration
|
||||
|
||||
The Docker integration can be used to automatically reload the configuration or ACLs when they are changed.
|
||||
In order for this to work, you'll need to pass in the `HEADSCALE_CONTAINER` environment variable.
|
||||
This should be either the name or ID of the Headscale container (you can retrieve this using `docker ps`).
|
||||
If the other integrations aren't setup, then Headplane will automatically disable the Docker integration.
|
||||
|
||||
By default the integration will check for `/var/run/docker.sock`, however you can override this by
|
||||
setting the `DOCKER_SOCK` environment variable if you use a different configuration than the default.
|
||||
When setting `DOCKER_SOCK`, you'll need to include the protocol (e.g., `unix://` or `tcp://`).
|
||||
Headplane currently does not support the HTTPS protocol for the Docker socket.
|
||||
> By default, the ACL file is read from `/etc/headscale/acl_policy.json`.
|
||||
> If `policy.path` is set and `policy.mode` is set to `file`, the ACL file will
|
||||
> be read from the path specified in the configuration file instead.
|
||||
|
||||
## Deployment
|
||||
|
||||
Requirements:
|
||||
- Headscale 0.23 alpha or later
|
||||
- Headscale 0.23 or newer
|
||||
- Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc)
|
||||
- Headscale needs to be running in a docker container
|
||||
|
||||
Here's a good Docker Compose example:
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
headscale:
|
||||
image: 'headscale/headscale:0.23.0-alpha5'
|
||||
container_name: 'headscale'
|
||||
restart: 'unless-stopped'
|
||||
command: 'serve'
|
||||
volumes:
|
||||
- './data:/var/lib/headscale'
|
||||
- './configs:/etc/headscale'
|
||||
ports:
|
||||
- '8080:8080'
|
||||
environment:
|
||||
TZ: 'America/New_York'
|
||||
headplane:
|
||||
container_name: headplane
|
||||
image: ghcr.io/tale/headplane:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- './data:/var/lib/headscale'
|
||||
- './configs:/etc/headscale'
|
||||
- '/var/run/docker.sock:/var/run/docker.sock:ro'
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
# This is always required for Headplane to work
|
||||
COOKIE_SECRET: 'abcdefghijklmnopqrstuvwxyz'
|
||||
Currently there are 3 integration providers that can do this for you:
|
||||
- [Docker Integration](/docs/integration/Docker.md)
|
||||
- [Kubernetes Integration](/docs/integration/Kubernetes.md)
|
||||
- [Native Linux Integration](/docs/integration/Native.md)
|
||||
|
||||
HEADSCALE_CONTAINER: 'headscale'
|
||||
DISABLE_API_KEY_LOGIN: 'true'
|
||||
HOST: '0.0.0.0'
|
||||
PORT: '3000'
|
||||
|
||||
# Overrides the configuration file values if they are set in config.yaml
|
||||
# If you want to share the same OIDC configuration you do not need this
|
||||
OIDC_CLIENT_ID: 'headscale'
|
||||
OIDC_ISSUER: 'https://sso.example.com'
|
||||
OIDC_CLIENT_SECRET: 'super_secret_client_secret'
|
||||
|
||||
# This NEEDS to be set with OIDC, regardless of what's in the config
|
||||
# This needs to be a very long-lived (999 day) API key used to create
|
||||
# shorter ones for OIDC and allow the OIDC functionality to work
|
||||
ROOT_API_KEY: 'abcdefghijklmnopqrstuvwxyz'
|
||||
```
|
||||
|
||||
> For a breakdown of each configuration variable, please refer to the [Configuration](/docs/Configuration.md) guide.
|
||||
> It explains what each variable does, how to configure them, and what the default values are.
|
||||
|
||||
You may also choose to run it natively with the distributed binaries on the releases page.
|
||||
You'll need to manage running this yourself, and I would recommend making a `systemd` unit.
|
||||
|
||||
## Configuration Scenarios
|
||||
|
||||
Since the configuration is fairly modular you can have a variety of different setups.<br>
|
||||
Here are a few examples to inspire you and show you what can work and what can't:
|
||||
|
||||
#### Full Integration
|
||||
Headscale runs in a container, Headplane can run in either a container or natively.
|
||||
Headplane is able to manage the configuration file and ACLs that Headscale uses.
|
||||
When changes happen, the Docker integration will automatically reload the configuration and ACLs.
|
||||
|
||||
> Note that the full integration currently isn't possible if Headscale isn't running in a container.
|
||||
|
||||
#### Configuration Only
|
||||
Headscale and Headplane can either run in containers or natively.
|
||||
Headplane is able to manage the configuration file and ACLs that Headscale uses.
|
||||
When changes are made, Headscale will need to be manually restarted to apply the changes.
|
||||
|
||||
#### ACL Only
|
||||
Headscale and Headplane can either run in containers or natively.
|
||||
Headplane is able to manage the ACLs that Headscale uses.
|
||||
When changes are made, Headscale will need to be sent a `SIGHUP` to reload the ACLs.
|
||||
In this scenario, Headplane does not have access to the configuration file.
|
||||
|
||||
#### Read-Only Configuration or ACLs
|
||||
If the configuration or ACLs are read-only, Headplane will not be able to manage them.
|
||||
Instead you'll only be able to view the configurations on the UI and need to edit them manually.
|
||||
|
||||
#### No Integration
|
||||
If no integration is setup, Headplane will not be able to manage the configuration or ACLs.
|
||||
This is the simplest setup by far, however it also heavily reduces the capabilities of Headplane.
|
||||
|
||||
@@ -1,28 +1,35 @@
|
||||
# Basic Integration
|
||||
|
||||
The basic integration is not able to offer advanced features such as:
|
||||
- Automatic management of Access Control Lists (ACLs)
|
||||
- Management of DNS settings for your tailnet
|
||||
- Management of the Headscale configuration
|
||||
The basic integration is the simplest way to get started with Headplane.
|
||||
It's more of a preview and is heavily limited in the features it can offer
|
||||
when compared to the [Advanced Integration](/docs/Advanced-Integration.md).
|
||||
|
||||
In order to support these features please refer to the [Advanced Integration](./docs/Advanced-Integration.md) guide.
|
||||
Note that in order to use this deployment strategy you need to run Headscale in a Docker container.
|
||||
> Note that the Advanced integration is the recommend way to run
|
||||
Headplane in a production environment.
|
||||
|
||||
## Limitations
|
||||
- No automatic management of Access Control Lists (ACLs)
|
||||
- No management of DNS settings for your tailnet
|
||||
- No capability to edit the configuration
|
||||
- Limited support for OIDC authentication
|
||||
|
||||
## Deployment
|
||||
|
||||
Requirements:
|
||||
- Headscale 0.23 alpha or later
|
||||
- Headscale 0.23 or newer
|
||||
- Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc)
|
||||
|
||||
Headplane is currently best run in a Docker container due to the easy configuration.
|
||||
Here's a very basic `docker-compose.yaml` file that utilizes each configuration variable.
|
||||
Docker heavily simplifies the deployment process, but this process can be
|
||||
adopted to run natively. Follow the first section of the deployment guide
|
||||
in the [Native Integration](/docs/integration/Native.md#deployment) for a
|
||||
bare-metal or virtual machine deployment.
|
||||
|
||||
Here is a simple Docker Compose deployment:
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
headplane:
|
||||
container_name: headplane
|
||||
image: ghcr.io/tale/headplane:latest
|
||||
image: ghcr.io/tale/headplane:0.3.5
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3000:3000'
|
||||
@@ -36,18 +43,14 @@ services:
|
||||
OIDC_ISSUER: 'https://sso.example.com'
|
||||
OIDC_CLIENT_SECRET: 'super_secret_client_secret'
|
||||
DISABLE_API_KEY_LOGIN: 'true'
|
||||
COOKIE_SECURE: 'false'
|
||||
|
||||
# These are the default values
|
||||
HOST: '0.0.0.0'
|
||||
PORT: '3000'
|
||||
```
|
||||
|
||||
> For a breakdown of each configuration variable, please refer to the [Configuration](/docs/Configuration.md) guide.
|
||||
> It explains what each variable does, how to configure them, and what the default values are.
|
||||
|
||||
You may also choose to run it natively with the distributed binaries on the releases page.
|
||||
You'll need to manage running this yourself, and I would recommend making a `systemd` unit.
|
||||
|
||||
## ACL Configuration
|
||||
If you would like to get the web ACL configuration working, you'll need to pass the `ACL_FILE` environment variable.
|
||||
This should point to the path of the ACL file on the Headscale server (ie. `ACL_FILE=/etc/headscale/acl_policy.json`).
|
||||
> For a breakdown of each configuration variable, please refer to the
|
||||
[Configuration](/docs/Configuration.md) guide.
|
||||
> It explains what each variable does, how to configure them, and what the
|
||||
default values are.
|
||||
|
||||
@@ -9,10 +9,13 @@ You can configure Headplane using environment variables.
|
||||
|
||||
#### Optional Variables
|
||||
|
||||
- **`DEBUG`**: Enable debug logging (default: `false`).
|
||||
- **`HOST`**: The host to bind the server to (default: `0.0.0.0`).
|
||||
- **`PORT`**: The port to bind the server to (default: `3000`).
|
||||
- **`CONFIG_FILE`**: The path to the Headscale `config.yaml` (default: `/etc/headscale/config.yaml`).
|
||||
- **`ACL_FILE`**: The path to the ACL file (default: `/etc/headscale/acl_policy.json`, not needed if you have `acl_policy_path` in your config).
|
||||
- **`HEADSCALE_CONFIG_UNSTRICT`**: This will disable the strict configuration loader (default: `false`).
|
||||
- **`COOKIE_SECURE`**: This option enables the `Secure` flag for cookies, ensuring they are sent only over HTTPS, which helps prevent interception and enhances data security. It should be disabled when using HTTP instead of HTTPS (default: `true`).
|
||||
- **`LOAD_ENV_FILE`**: Tell Headplane to read the `.env` file and load it into the environment (default: `false`).
|
||||
|
||||
#### Docker Integration
|
||||
The Docker integration allows Headplane to manage the Headscale docker container.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
## Docker Integration
|
||||
|
||||
The Docker integration allows you to run Headplane and Headscale separately
|
||||
in a dockerized environment. It allows you to unlock full functionality such as
|
||||
automatic reloading of ACLs, DNS management, and Headscale configuration
|
||||
management.
|
||||
|
||||
### Deployment
|
||||
|
||||
> When running with the Docker integration, it's assumed that both Headscale and
|
||||
Headplane will run as containers. If you are running Headscale natively, then
|
||||
refer to the [Native Integration](/docs/integration/Native.md) guide.
|
||||
|
||||
To enable the Docker integration, set the `HEADSCALE_INTEGRATION` environment
|
||||
variable to `docker`. You'll also need to supply `HEADSCALE_CONTAINER` with the
|
||||
name or ID of the Headscale container.
|
||||
|
||||
By default Headplane uses `unix:///var/run/docker.sock` to connect to Docker.
|
||||
This can be overridden by setting the `DOCKER_SOCK` environment variable. For
|
||||
example, a remote socket would be `tcp://<my-remote-host>:2375`. When setting
|
||||
the variable, you'll need to specify the protocol (`unix://` or `tcp://`).
|
||||
|
||||
> The `DOCKER_SOCK` variable does not support the HTTPS protocol.
|
||||
|
||||
To enable the Docker integration, set `HEADSCALE_INTEGRATION=docker` in the environment variables.
|
||||
Additionally, you'll need to pass in the `HEADSCALE_CONTAINER` environment variable.
|
||||
This should be either the name or ID of the Headscale container (you can retrieve this using `docker ps`).
|
||||
If the other integrations aren't setup, then Headplane will automatically disable the Docker integration.
|
||||
|
||||
By default the integration will check for `/var/run/docker.sock`, however you can override this by
|
||||
setting the `DOCKER_SOCK` environment variable if you use a different configuration than the default.
|
||||
When setting `DOCKER_SOCK`, you'll need to include the protocol (e.g., `unix://` or `tcp://`).
|
||||
Headplane currently does not support the HTTPS protocol for the Docker socket.
|
||||
|
||||
Here's an example deployment using Docker Compose (recommended). Keep in mind
|
||||
that you'll NEED to setup a reverse proxy and this is incomplete:
|
||||
```yaml
|
||||
services:
|
||||
headscale:
|
||||
image: 'headscale/headscale:0.23.0'
|
||||
container_name: 'headscale'
|
||||
restart: 'unless-stopped'
|
||||
command: 'serve'
|
||||
volumes:
|
||||
- './data:/var/lib/headscale'
|
||||
- './configs:/etc/headscale'
|
||||
ports:
|
||||
- '8080:8080'
|
||||
environment:
|
||||
TZ: 'America/New_York'
|
||||
headplane:
|
||||
container_name: headplane
|
||||
image: ghcr.io/tale/headplane:0.3.5
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- './data:/var/lib/headscale'
|
||||
- './configs:/etc/headscale'
|
||||
- '/var/run/docker.sock:/var/run/docker.sock:ro'
|
||||
ports:
|
||||
- '3000:3000'
|
||||
environment:
|
||||
# This is always required for Headplane to work
|
||||
COOKIE_SECRET: 'abcdefghijklmnopqrstuvwxyz'
|
||||
|
||||
HEADSCALE_INTEGRATION: 'docker'
|
||||
HEADSCALE_CONTAINER: 'headscale'
|
||||
DISABLE_API_KEY_LOGIN: 'true'
|
||||
HOST: '0.0.0.0'
|
||||
PORT: '3000'
|
||||
|
||||
# Only set this to false if you aren't behind a reverse proxy
|
||||
COOKIE_SECURE: 'false'
|
||||
|
||||
# Overrides the configuration file values if they are set in config.yaml
|
||||
# If you want to share the same OIDC configuration you do not need this
|
||||
OIDC_CLIENT_ID: 'headscale'
|
||||
OIDC_ISSUER: 'https://sso.example.com'
|
||||
OIDC_CLIENT_SECRET: 'super_secret_client_secret'
|
||||
|
||||
# This NEEDS to be set with OIDC, regardless of what's in the config
|
||||
# This needs to be a very long-lived (999 day) API key used to create
|
||||
# shorter ones for OIDC and allow the OIDC functionality to work
|
||||
ROOT_API_KEY: 'abcdefghijklmnopqrstuvwxyz'
|
||||
```
|
||||
|
||||
> For a breakdown of each configuration variable, please refer to the
|
||||
[Configuration](/docs/Configuration.md) guide.
|
||||
> It explains what each variable does, how to configure them, and what the
|
||||
default values are.
|
||||
@@ -0,0 +1,133 @@
|
||||
## Kubernetes Integration
|
||||
|
||||
The Kubernetes integration allows you to run Headplane and Headscale together
|
||||
in a cluster. It allows you to unlock full functionality such as automatic
|
||||
reloading of ACLs, DNS management, and Headscale configuration management.
|
||||
|
||||
Currently there are a few limitations to the Kubernetes integration:
|
||||
- Headplane and Headscale need to run in the same Pod and share the same
|
||||
process space for the integration to work correctly due to a limitation in
|
||||
the Kubernetes API.
|
||||
|
||||
- The only supported methods of deploying the integration are through a
|
||||
`Deployment` or `Pod` (more coming soon). You can still get around this with
|
||||
the `HEADSCALE_INTEGRATION_UNSTRICT` variable, but it's not recommended.
|
||||
|
||||
- The integration will assume that the Headscale container will always restart
|
||||
because the integration relies on a system call that will exit the container.
|
||||
|
||||
### Deployment
|
||||
|
||||
In order to ensure Headplane can read Kubernetes resources, you'll need to
|
||||
grant additional RBAC permissions to the default `ServiceAccount` in the
|
||||
namespace. This can be done with the following:
|
||||
```yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: headplane-agent
|
||||
namespace: default # Adjust namespace as needed
|
||||
rules:
|
||||
- apiGroups: ['']
|
||||
resources: ['pods']
|
||||
verbs: ['get', 'list']
|
||||
- apiGroups: ['apps']
|
||||
resources: ['deployments']
|
||||
verbs: ['get', 'list']
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: headplane-agent
|
||||
namespace: default # Adjust namespace as needed
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: headplane-agent
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: default # If you use a different service account, change this
|
||||
namespace: default # Adjust namespace as needed
|
||||
```
|
||||
|
||||
Keep in mind you'll need to make `PersistentVolumeClaim`s for the data and that
|
||||
they need to be either `ReadWriteOnce` or `ReadWriteMany` depending on your
|
||||
topology. Additionally, you can abstract environment variables and configuration
|
||||
away into a `ConfigMap` or `Secret` for easier management.
|
||||
|
||||
The important parts of this deployment are the `HEADSCALE_INTEGRATION` and
|
||||
`DEPLOYMENT_NAME` environment variables. The `HEADSCALE_INTEGRATION` variable
|
||||
should be set to `kubernetes` and the `POST_NAME` variable should be set
|
||||
to the name of the pod (done using the Downward API below).
|
||||
|
||||
> If you are having issues with validating `shareProcessNamespace`, you can
|
||||
set `HEADSCALE_INTEGRATION_UNSTRICT` to `true` to disable the strict checks.
|
||||
|
||||
A basic deployment of the integration would look like this. Keep in mind that
|
||||
you are responsible for setting up a reverse-proxy via an `Ingress` or `Service`
|
||||
otherwise Headplane will not work:
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: headplane
|
||||
namespace: default # Adjust namespace as needed
|
||||
labels:
|
||||
app: headplane
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: headplane
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: headplane
|
||||
spec:
|
||||
shareProcessNamespace: true
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- name: headplane
|
||||
image: ghcr.io/tale/headplane:0.3.5
|
||||
env:
|
||||
- name: COOKIE_SECRET
|
||||
value: 'abcdefghijklmnopqrstuvwxyz'
|
||||
- name: HEADSCALE_INTEGRATION
|
||||
value: 'kubernetes'
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
|
||||
# Only set this to false if you aren't behind a reverse proxy
|
||||
- name: COOKIE_SECURE
|
||||
value: 'false'
|
||||
volumeMounts:
|
||||
- name: headscale-config
|
||||
mountPath: /etc/headscale
|
||||
|
||||
- name: headscale
|
||||
image: headscale/headscale:0.23.0
|
||||
command: ['serve']
|
||||
env:
|
||||
- name: TZ
|
||||
value: 'America/New_York'
|
||||
volumeMounts:
|
||||
- name: headscale-data
|
||||
mountPath: /var/lib/headscale
|
||||
- name: headscale-config
|
||||
mountPath: /etc/headscale
|
||||
|
||||
volumes:
|
||||
- name: headscale-data
|
||||
persistentVolumeClaim:
|
||||
claimName: headscale-data
|
||||
- name: headscale-config
|
||||
persistentVolumeClaim:
|
||||
claimName: headscale-config
|
||||
```
|
||||
|
||||
> For a breakdown of each configuration variable, please refer to the
|
||||
[Configuration](/docs/Configuration.md) guide.
|
||||
> It explains what each variable does, how to configure them, and what the
|
||||
default values are.
|
||||
@@ -0,0 +1,69 @@
|
||||
## Native Integration
|
||||
|
||||
The Native integration allows you to run both Headplane and Headscale on
|
||||
bare-metal servers or virtual machines. This integration is best suited for
|
||||
environments where Docker or Kubernetes are not available or not desired.
|
||||
|
||||
Currently the Native integration only supports automatic reloading of ACLs. It
|
||||
cannot handle configuration changes as killing the `headscale` process can lead
|
||||
to undefined behavior or the service not restarting.
|
||||
|
||||
### Deployment
|
||||
|
||||
1. Follow the instructions to install Headscale from the
|
||||
[Linux Installation Guide](https://headscale.net/setup/install/official).
|
||||
|
||||
2. Install [Node.js](https://nodejs.org/en/download/package-manager)
|
||||
version 20 or higher (your package manager most likely already has this).
|
||||
|
||||
3. Install [PNPM](https://pnpm.io/installation). This is required
|
||||
as Headplane has issues running correctly when installed and built via NPM or Yarn.
|
||||
|
||||
4. Clone the Headplane repository, install dependencies, and build the project:
|
||||
```sh
|
||||
git clone https://github.com/tale/headplane # (or clone via SSH)
|
||||
cd headplane
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Running Headplane
|
||||
Start Headplane with `node build/headplane/server.js`.
|
||||
|
||||
Headplane does need various environment variables to run correctly. The required
|
||||
variables can be found in the [Configuration](/docs/Configuration.md) guide.
|
||||
If you choose to do this with a `.env` file, you can use the `LOAD_ENV_FILE`
|
||||
variable to tell Headplane to load the file.
|
||||
|
||||
Finally, make sure to set `HEADSCALE_INTEGRATION=proc` to take advantage
|
||||
of controlling Headscale natively on Linux.
|
||||
|
||||
```sh
|
||||
LOAD_ENV_FILE=true node ./build/headplane/server.js
|
||||
```
|
||||
|
||||
> If you'd like, you can turn this into a `systemd` unit to manage the service.
|
||||
> I plan to provide packages and unit files to make this easier in the future.
|
||||
|
||||
### Cannot find ./build directory?
|
||||
Headplane expects the `build` directory to be present when running the server.
|
||||
The structure of this folder is very important and should not be tampered with.
|
||||
If you would like to keep the build directory in a different location, you can
|
||||
set the `BUILD_PATH` environment variable to the path of the build directory
|
||||
at runtime.
|
||||
|
||||
```sh
|
||||
LOAD_ENV_FILE=true BUILD_PATH=/path/to/build node ./build/headplane/server.js
|
||||
```
|
||||
|
||||
### Changing Headplane's Path from `/admin`
|
||||
Additionally, because you are building Headplane from source, you're able to
|
||||
change the default path that Headplane is served from. This can be done by
|
||||
specifying the `__INTERNAL_PREFIX` environment variable, when building.
|
||||
|
||||
```sh
|
||||
__INTERNAL_PREFIX=/my-admin-path pnpm build
|
||||
```
|
||||
|
||||
> Keep in mind that this is very much an experimental feature. Things can easily
|
||||
> break and until it's more stable, it's not recommended to use in production.
|
||||
@@ -1,5 +0,0 @@
|
||||
import tale from 'eslint-config-tale'
|
||||
|
||||
export default [
|
||||
...tale
|
||||
]
|
||||
@@ -4,57 +4,55 @@
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "remix vite:build",
|
||||
"build": "remix vite:build && vite build",
|
||||
"dev": "remix vite:dev",
|
||||
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
|
||||
"start": "remix-serve ./build/server/index.js",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-yaml": "^6.1.1",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@primer/octicons-react": "^19.9.0",
|
||||
"@react-aria/toast": "3.0.0-beta.11",
|
||||
"@react-stately/toast": "3.0.0-beta.3",
|
||||
"@remix-run/node": "^2.9.2",
|
||||
"@remix-run/react": "^2.9.2",
|
||||
"@remix-run/serve": "^2.9.2",
|
||||
"@uiw/codemirror-theme-github": "^4.22.0",
|
||||
"@uiw/react-codemirror": "^4.22.0",
|
||||
"@kubernetes/client-node": "^0.22.2",
|
||||
"@primer/octicons-react": "^19.12.0",
|
||||
"@react-aria/toast": "3.0.0-beta.12",
|
||||
"@react-stately/toast": "3.0.0-beta.4",
|
||||
"@remix-run/node": "^2.13.1",
|
||||
"@remix-run/react": "^2.13.1",
|
||||
"@shopify/lang-jsonc": "^1.0.0",
|
||||
"@uiw/codemirror-theme-github": "^4.23.6",
|
||||
"@uiw/react-codemirror": "^4.23.6",
|
||||
"clsx": "^2.1.1",
|
||||
"isbot": "^5.1.6",
|
||||
"oauth4webapi": "^2.10.4",
|
||||
"dotenv": "^16.4.5",
|
||||
"isbot": "^5.1.17",
|
||||
"mime": "^4.0.4",
|
||||
"oauth4webapi": "^2.17.0",
|
||||
"react": "19.0.0-beta-26f2496093-20240514",
|
||||
"react-aria-components": "^1.2.0",
|
||||
"react-codemirror-merge": "^4.22.0",
|
||||
"react-aria-components": "^1.2.1",
|
||||
"react-codemirror-merge": "^4.23.6",
|
||||
"react-dom": "19.0.0-beta-26f2496093-20240514",
|
||||
"remix-utils": "^7.6.0",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss-react-aria-components": "^1.1.2",
|
||||
"undici": "^6.16.1",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"remix-utils": "^7.7.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwindcss-react-aria-components": "^1.1.6",
|
||||
"undici": "^6.20.1",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"yaml": "^2.4.2",
|
||||
"yaml": "^2.6.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@remix-run/dev": "^2.9.2",
|
||||
"@remix-run/dev": "^2.13.1",
|
||||
"@types/react": "npm:types-react@beta",
|
||||
"@types/react-dom": "npm:types-react-dom@beta",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"babel-plugin-react-compiler": "0.0.0-experimental-c23de8d-20240515",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-tale": "^2.0.4",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"babel-plugin-react-compiler": "19.0.0-beta-8a03594-20241020",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-babel": "^1.2.0",
|
||||
"vite-tsconfig-paths": "^4.2.1"
|
||||
"vite-tsconfig-paths": "^5.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "npm:types-react@beta",
|
||||
@@ -66,7 +64,14 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@react-aria/overlays@3.22.0": "patches/@react-aria__overlays@3.22.0.patch"
|
||||
"@react-aria/overlays@3.22.1": "patches/@react-aria__overlays@3.22.1.patch",
|
||||
"@shopify/lang-jsonc@1.0.0": "patches/@shopify__lang-jsonc@1.0.0.patch"
|
||||
},
|
||||
"peerDependencyRules": {
|
||||
"allowAny": [
|
||||
"react",
|
||||
"react-dom"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
diff --git a/dist/usePreventScroll.mjs b/dist/usePreventScroll.mjs
|
||||
index 532a72f9e9ba985d036b7eba0f950ff6552898d9..7c9263667c2463c8e84fd07064076132fe53284c 100644
|
||||
index 69b84ce2aec5b637a9f0ba8158b6a1ba4173c266..5e546a6e4fc3b7a0468c1c89d887a4392c010624 100644
|
||||
--- a/dist/usePreventScroll.mjs
|
||||
+++ b/dist/usePreventScroll.mjs
|
||||
@@ -48,7 +48,7 @@ function $49c51c25361d4cd2$export$ee0f7cc6afcd1c18(options = {}) {
|
||||
// For most browsers, all we need to do is set `overflow: hidden` on the root element, and
|
||||
// add some padding to prevent the page from shifting when the scrollbar is hidden.
|
||||
function $49c51c25361d4cd2$var$preventScrollStandard() {
|
||||
- return (0, $7mMvr$chain)($49c51c25361d4cd2$var$setStyle(document.documentElement, "paddingRight", `${window.innerWidth - document.documentElement.clientWidth}px`), $49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"));
|
||||
+ return (0, $7mMvr$chain)($49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"));
|
||||
- return (0, $7mMvr$chain)($49c51c25361d4cd2$var$setStyle(document.documentElement, 'paddingRight', `${window.innerWidth - document.documentElement.clientWidth}px`), $49c51c25361d4cd2$var$setStyle(document.documentElement, 'overflow', 'hidden'));
|
||||
+ return (0, $7mMvr$chain)($49c51c25361d4cd2$var$setStyle(document.documentElement, 'overflow', 'hidden'));
|
||||
}
|
||||
// Mobile Safari is a whole different beast. Even with overflow: hidden,
|
||||
// it still scrolls the page in many situations:
|
||||
@@ -15,8 +15,8 @@ index 532a72f9e9ba985d036b7eba0f950ff6552898d9..7c9263667c2463c8e84fd07064076132
|
||||
// enable us to scroll the window to the top, which is required for the rest of this to work.
|
||||
let scrollX = window.pageXOffset;
|
||||
let scrollY = window.pageYOffset;
|
||||
- restoreStyles = (0, $7mMvr$chain)($49c51c25361d4cd2$var$addEvent(window, "scroll", onWindowScroll), $49c51c25361d4cd2$var$setStyle(document.documentElement, "paddingRight", `${window.innerWidth - document.documentElement.clientWidth}px`), $49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"), $49c51c25361d4cd2$var$setStyle(document.body, "marginTop", `-${scrollY}px`), ()=>{
|
||||
+ restoreStyles = (0, $7mMvr$chain)($49c51c25361d4cd2$var$addEvent(window, "scroll", onWindowScroll), $49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"), $49c51c25361d4cd2$var$setStyle(document.body, "marginTop", `-${scrollY}px`), ()=>{
|
||||
- restoreStyles = (0, $7mMvr$chain)($49c51c25361d4cd2$var$addEvent(window, 'scroll', onWindowScroll), $49c51c25361d4cd2$var$setStyle(document.documentElement, 'paddingRight', `${window.innerWidth - document.documentElement.clientWidth}px`), $49c51c25361d4cd2$var$setStyle(document.documentElement, 'overflow', 'hidden'), $49c51c25361d4cd2$var$setStyle(document.body, 'marginTop', `-${scrollY}px`), ()=>{
|
||||
+ restoreStyles = (0, $7mMvr$chain)($49c51c25361d4cd2$var$addEvent(window, 'scroll', onWindowScroll), $49c51c25361d4cd2$var$setStyle(document.documentElement, 'overflow', 'hidden'), $49c51c25361d4cd2$var$setStyle(document.body, 'marginTop', `-${scrollY}px`), ()=>{
|
||||
window.scrollTo(scrollX, scrollY);
|
||||
});
|
||||
// Scroll to the top. The negative margin on the body will make this appear the same.
|
||||
@@ -0,0 +1,44 @@
|
||||
diff --git a/dist/esm/index.js b/dist/esm/index.js
|
||||
index 8b5b71f8eee6606ca8de47c15eef0ce73c01d93f..2bfa4e7c7ff650b35b1bdee75c28bd6fbf6c6704 100644
|
||||
--- a/dist/esm/index.js
|
||||
+++ b/dist/esm/index.js
|
||||
@@ -1,3 +1,3 @@
|
||||
-export { jsonc, jsoncLanguage } from './jsonc';
|
||||
-export { parser } from './parser';
|
||||
+export { jsonc, jsoncLanguage } from './jsonc.js';
|
||||
+export { parser } from './parser.js';
|
||||
//# sourceMappingURL=index.js.map
|
||||
\ No newline at end of file
|
||||
diff --git a/dist/esm/jsonc.js b/dist/esm/jsonc.js
|
||||
index 5f968887d47696122c6bbfc4b98f94af6477f37a..b144021c4b5d4504c73f6c2172e8d1c2ddbfe3ac 100644
|
||||
--- a/dist/esm/jsonc.js
|
||||
+++ b/dist/esm/jsonc.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { parser } from './parser';
|
||||
+import { parser } from './parser.js';
|
||||
import { continuedIndent, indentNodeProp, foldNodeProp, foldInside, LRLanguage, LanguageSupport, } from '@codemirror/language';
|
||||
/// A language provider that provides JSON parsing.
|
||||
export const jsoncLanguage = LRLanguage.define({
|
||||
diff --git a/dist/esm/parser.js b/dist/esm/parser.js
|
||||
index 3d966fcf7c55003b1ba6c2b3f531b7c9b8045cb8..f6e8f56c3506f76f31571a49af8cd44364a864a3 100644
|
||||
--- a/dist/esm/parser.js
|
||||
+++ b/dist/esm/parser.js
|
||||
@@ -1,6 +1,6 @@
|
||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||
import {LRParser} from "@lezer/lr"
|
||||
-import {jsonHighlighting} from "./highlight"
|
||||
+import {jsonHighlighting} from "./highlight.js"
|
||||
export const parser = LRParser.deserialize({
|
||||
version: 14,
|
||||
states: "$zO]QPOOOOQO'#Cd'#CdOtQPO'#CgO|QPO'#ClOOQO'#Cr'#CrQOQPOOOOQO'#Ci'#CiO!TQPO'#ChO!YQPO'#CtOOQO,59R,59RO!bQPO,59RO!gQPO'#CwOOQO,59W,59WO!oQPO,59WO]QPO,59SO!tQPO,59`O!|QPO,59`OOQO1G.m1G.mO#UQPO,59cO#]QPO,59cOOQO1G.r1G.rOOQO1G.n1G.nOOQO,59X,59XO#eQPO1G.zOOQO-E6k-E6kOOQO,59Y,59YO#mQPO1G.}OOQO-E6l-E6lPwQPO'#CmP]QPO'#Cn",
|
||||
diff --git a/package.json b/package.json
|
||||
index 9c3a56db25535b7eb0b1f951abe486d530a2714f..5e858500f155c16ecb068854552a71e1da5f04fc 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "@shopify/lang-jsonc",
|
||||
+ "type": "module",
|
||||
"version": "1.0.0",
|
||||
"description": "JSONC language support for CodeMirror",
|
||||
"publishConfig": {
|
||||
@@ -0,0 +1,159 @@
|
||||
// This is a polyglot entrypoint for Headplane when running in production
|
||||
// It doesn't use any dependencies aside from @remix-run/node and mime
|
||||
// During build we bundle the used dependencies into the file so that
|
||||
// we can only need this file and a Node.js installation to run the server.
|
||||
// PREFIX is defined globally, see vite.config.ts
|
||||
|
||||
import { access, constants } from 'node:fs/promises'
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs'
|
||||
import { createServer } from 'node:http'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { env } from 'node:process'
|
||||
|
||||
function log(level, message) {
|
||||
const date = new Date().toISOString()
|
||||
console.log(`${date} (${level}) [SRVX] ${message}`)
|
||||
}
|
||||
|
||||
log('INFO', `Running with Node.js ${process.versions.node}`)
|
||||
|
||||
try {
|
||||
await access('./node_modules/@remix-run', constants.F_OK | constants.R_OK)
|
||||
log('INFO', 'Found node_modules dependencies')
|
||||
} catch (error) {
|
||||
log('ERROR', 'No node_modules found. Please run `pnpm install` first')
|
||||
log('ERROR', error)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
await access('./build/server', constants.F_OK | constants.R_OK)
|
||||
log('INFO', 'Found build directory')
|
||||
} catch (error) {
|
||||
const date = new Date().toISOString()
|
||||
log('ERROR', 'No build directory found. Please run `pnpm build` first')
|
||||
log('ERROR', error)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const {
|
||||
createRequestHandler: remixRequestHandler,
|
||||
createReadableStreamFromReadable,
|
||||
writeReadableStreamToWritable
|
||||
} = await import('@remix-run/node')
|
||||
const { default: mime } = await import('mime')
|
||||
|
||||
const port = env.PORT || 3000
|
||||
const host = env.HOST || '0.0.0.0'
|
||||
const buildPath = env.BUILD_PATH || './build'
|
||||
|
||||
// Because this is a dynamic import without an easily discernable path
|
||||
// we gain the "deoptimization" we want so that Vite doesn't bundle this
|
||||
const build = await import(resolve(join(buildPath, 'server', 'index.js')))
|
||||
const baseDir = resolve(join(buildPath, 'client'))
|
||||
|
||||
const handler = remixRequestHandler(build, 'production')
|
||||
const http = createServer(async (req, res) => {
|
||||
const url = new URL(`http://${req.headers.host}${req.url}`)
|
||||
|
||||
if (!url.pathname.startsWith(PREFIX)) {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
// We need to handle an issue where say we are navigating to $PREFIX
|
||||
// but Remix does not handle it without the trailing slash. This is
|
||||
// because Remix uses the URL constructor to parse the URL and it
|
||||
// will remove the trailing slash. We need to redirect to the correct
|
||||
// URL so that Remix can handle it correctly.
|
||||
if (url.pathname === PREFIX) {
|
||||
res.writeHead(302, {
|
||||
Location: `${PREFIX}/`
|
||||
})
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
// Before we pass any requests to our Remix handler we need to check
|
||||
// if we can handle a raw file request. This is important for the
|
||||
// Remix loader to work correctly.
|
||||
//
|
||||
// To optimize this, we send them as readable streams in the node
|
||||
// response and we also set headers for aggressive caching.
|
||||
if (url.pathname.startsWith(`${PREFIX}/assets/`)) {
|
||||
const filePath = join(baseDir, url.pathname.replace(PREFIX, ''))
|
||||
const exists = existsSync(filePath)
|
||||
const stats = statSync(filePath)
|
||||
|
||||
if (exists && stats.isFile()) {
|
||||
// Build assets are cache-bust friendly so we can cache them heavily
|
||||
if (req.url.startsWith('/build')) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
}
|
||||
|
||||
// Send the file as a readable stream
|
||||
const fileStream = createReadStream(filePath)
|
||||
const type = mime.getType(filePath)
|
||||
|
||||
res.setHeader('Content-Length', stats.size)
|
||||
res.setHeader('Content-Type', type)
|
||||
fileStream.pipe(res)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handling the request
|
||||
const controller = new AbortController()
|
||||
res.on('close', () => controller.abort())
|
||||
|
||||
const headers = new Headers()
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (!value) continue
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
headers.append(key, v)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
headers.append(key, value)
|
||||
}
|
||||
|
||||
const remixReq = new Request(url.href, {
|
||||
headers,
|
||||
method: req.method,
|
||||
signal: controller.signal,
|
||||
|
||||
// If we have a body we set a duplex and we load the body
|
||||
...(req.method !== 'GET' && req.method !== 'HEAD' ? {
|
||||
body: createReadableStreamFromReadable(req),
|
||||
duplex: 'half'
|
||||
} : {}
|
||||
)
|
||||
})
|
||||
|
||||
// Pass our request to the Remix handler and get a response
|
||||
const response = await handler(remixReq, {}) // No context
|
||||
|
||||
// Handle our response and reply
|
||||
res.statusCode = response.status
|
||||
res.statusMessage = response.statusText
|
||||
|
||||
for (const [key, value] of response.headers.entries()) {
|
||||
res.appendHeader(key, value)
|
||||
}
|
||||
|
||||
if (response.body) {
|
||||
await writeReadableStreamToWritable(response.body, res)
|
||||
return
|
||||
}
|
||||
|
||||
res.end()
|
||||
})
|
||||
|
||||
http.listen(port, host, () => {
|
||||
log('INFO', `Running on ${host}:${port}`)
|
||||
})
|
||||
@@ -186,7 +186,8 @@ log:
|
||||
# Path to a file containg ACL policies.
|
||||
# ACLs can be defined as YAML or HUJSON.
|
||||
# https://tailscale.com/kb/1018/acls/
|
||||
acl_policy_path: /etc/headscale/acl.json
|
||||
policy:
|
||||
mode: 'database'
|
||||
|
||||
## DNS
|
||||
#
|
||||
@@ -197,7 +198,7 @@ acl_policy_path: /etc/headscale/acl.json
|
||||
# - https://tailscale.com/kb/1081/magicdns/
|
||||
# - https://tailscale.com/blog/2021-09-private-dns-with-magicdns/
|
||||
#
|
||||
dns_config:
|
||||
dns_config2:
|
||||
# Whether to prefer using Headscale provided DNS or use local.
|
||||
override_local_dns: true
|
||||
|
||||
@@ -253,6 +254,68 @@ dns_config:
|
||||
# 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" }
|
||||
|
||||
# DEPRECATED
|
||||
# Use the username as part of the DNS name for nodes, with this option enabled:
|
||||
# node1.username.example.com
|
||||
# while when this is disabled:
|
||||
# node1.example.com
|
||||
# This is a legacy option as Headscale has have this wrongly implemented
|
||||
# while in upstream Tailscale, the username is not included.
|
||||
use_username_in_magic_dns: false
|
||||
|
||||
# Unix socket used for the CLI to connect without authentication
|
||||
# Note: for production you will want to set this to something like:
|
||||
@@ -268,46 +331,46 @@ oidc:
|
||||
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.
|
||||
# # 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.
|
||||
#
|
||||
#
|
||||
# # 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`
|
||||
#
|
||||
# # 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
|
||||
|
||||
@@ -6,23 +6,59 @@ import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
|
||||
installGlobals()
|
||||
|
||||
const ReactCompilerConfig = {}
|
||||
const prefix = process.env.__INTERNAL_PREFIX || '/admin'
|
||||
if (prefix.endsWith('/')) {
|
||||
throw new Error('Prefix must not end with a slash')
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
base: '/admin/',
|
||||
plugins: [
|
||||
remix({
|
||||
basename: '/admin/',
|
||||
}),
|
||||
tsconfigPaths(),
|
||||
babel({
|
||||
filter: /\.[jt]sx?$/,
|
||||
babelConfig: {
|
||||
presets: ['@babel/preset-typescript'], // if you use TypeScript
|
||||
plugins: [
|
||||
['babel-plugin-react-compiler', ReactCompilerConfig],
|
||||
],
|
||||
export default defineConfig(({ isSsrBuild }) => {
|
||||
// If we have the Headplane entry we build it as a single
|
||||
// server.mjs file that is built for production server bundle
|
||||
// We know the remix invoked command is vite:build
|
||||
if (!process.argv.includes('vite:build') && !process.argv.includes('vite:dev')) {
|
||||
return {
|
||||
build: {
|
||||
minify: false,
|
||||
target: 'esnext',
|
||||
rollupOptions: {
|
||||
input: './server.mjs',
|
||||
output: {
|
||||
entryFileNames: 'server.js',
|
||||
dir: 'build/headplane',
|
||||
banner: '#!/usr/bin/env node\n',
|
||||
},
|
||||
external: (id) => id.startsWith('node:'),
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
define: {
|
||||
PREFIX: JSON.stringify(prefix),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
stream: 'node:stream',
|
||||
crypto: 'node:crypto',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ({
|
||||
base: `${prefix}/`,
|
||||
build: isSsrBuild ? { target: 'ES2022' } : {},
|
||||
plugins: [
|
||||
remix({
|
||||
basename: `${prefix}/`,
|
||||
}),
|
||||
tsconfigPaths(),
|
||||
babel({
|
||||
filter: /\.[jt]sx?$/,
|
||||
babelConfig: {
|
||||
presets: ['@babel/preset-typescript'],
|
||||
plugins: [
|
||||
['babel-plugin-react-compiler', {}],
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||