Compare commits

..

40 Commits

Author SHA1 Message Date
Aarnav Tale d041a62fcd chore: 0.2.3 2024-08-23 15:20:27 -04:00
Aarnav Tale 0e6b5ea6d0 chore: update docs for minimum beta requirement 2024-08-23 15:18:32 -04:00
Aarnav Tale a2054786f8 fix(TALE-31): use oidc variables first over config 2024-08-23 15:12:53 -04:00
Aarnav Tale 5a46fd0a97 feat(TALE-30): add support for new DNS configs
This is a breaking change to support 0.23-beta2
2024-08-22 16:55:05 -04:00
Aarnav Tale b8999161a2 feat(TALE-29): handle no ACL configurations being available 2024-08-04 17:30:41 -04:00
Aarnav Tale 224cbbdcaf chore: update to headscale beta for compose dev 2024-08-04 11:33:11 -04:00
Aarnav Tale 75ba3a3dc7 feat(TALE-29): support the headscale policy api changes 2024-08-04 11:32:29 -04:00
Aarnav Tale 4f57fdb43b chore: v0.2.2 2024-08-02 16:15:08 -04:00
Aarnav Tale b170e11dd6 fix(TALE-7): don't destructure context otherwise it won't set properly 2024-08-02 16:14:05 -04:00
Aarnav Tale 6b278309ed fix(TALE-7): use the new integration methods in the code 2024-07-10 19:38:44 -04:00
Aarnav Tale ea8ecfb28f chore: use new logger 2024-07-10 19:36:13 -04:00
Aarnav Tale 099bd3bcb8 feat(TALE-7): add proper integration logging 2024-07-10 19:26:59 -04:00
Aarnav Tale 0aa0406ea6 feat(TALE-7): reimplement integration system 2024-07-09 22:53:00 -04:00
Aarnav Tale 3cc726320a fix(TALE-5): remove deployment check and only use pod spec 2024-07-08 14:40:38 -04:00
Aarnav Tale bda151f4e8 chore: simplify documentation 2024-07-08 14:31:53 -04:00
Aarnav Tale 6d411853d5 feat(TALE-5): implement k8s integration 2024-07-08 13:23:53 -04:00
Aarnav Tale dc4d05a2d9 fix: filter out undefined proc pids 2024-07-08 01:17:28 -04:00
Aarnav Tale 361859f374 chore: v0.2.1 2024-07-07 14:57:33 -04:00
Aarnav Tale c60652e39f fix(TALE-4): prevent duplicate ACL tags from being added 2024-07-07 14:57:16 -04:00
Aarnav Tale 1db385e716 chore(TALE-11): remove unnecessary component in dns 2024-07-07 14:53:16 -04:00
Aarnav Tale ab0cb7b782 feat(TALE-11): support custom DNS records 2024-07-07 14:52:47 -04:00
Aarnav Tale fd73832879 fix: use acl diff editor and make discard button work 2024-07-07 14:28:28 -04:00
Aarnav Tale 7804d83181 feat(TALE-4): make acl tags editable from the menu 2024-07-07 14:20:39 -04:00
Aarnav Tale fe40a5734e chore: update deps 2024-07-07 14:19:53 -04:00
Aarnav Tale c0bdf91f6b chore: v0.2.0 2024-06-23 18:21:37 -04:00
Aarnav Tale bd46f97121 chore: update deps 2024-06-23 18:18:01 -04:00
Aarnav Tale d71e55d7af feat(TALE-10): edit machine owner via dropdown 2024-06-23 17:25:33 -04:00
Aarnav Tale ddd20fe027 feat: create select component 2024-06-23 17:25:22 -04:00
Aarnav Tale dca5f9d149 fix(TALE-6): make menu dropdown actions available on machines page 2024-06-23 01:05:03 -04:00
Aarnav Tale be8ce7a2fe fix: set defaults with HEADSCALE_UNSTRICT 2024-06-18 23:59:06 -04:00
Aarnav Tale 6fa0e265fe feat: update images 2024-06-03 23:33:38 -04:00
Aarnav Tale 74e87bd80e chore: v0.1.9 2024-06-03 22:52:26 -04:00
Aarnav Tale d01699009d chore: update packages 2024-06-03 22:51:45 -04:00
Aarnav Tale 08db192db1 fix: use magic domain for machine domain attribute 2024-06-03 22:43:38 -04:00
Aarnav Tale 3ea5fed8f6 fix: support jsonc comments and switch to monaco for acls 2024-06-02 22:40:23 -04:00
Aarnav Tale dd9d6cd550 fix: set edge tag to main 2024-06-02 16:52:29 -04:00
Aarnav Tale f82244f85a fix: github actions work this time? 2024-06-02 14:33:43 -04:00
Aarnav Tale 896aaad61e fix: push action had syntax error 2024-06-02 14:29:32 -04:00
Aarnav Tale e5dece5b7b feat: add build and nightly 2024-06-02 14:28:42 -04:00
Aarnav Tale 2797525969 fix: expired check 2.0? 2024-06-02 14:03:25 -04:00
55 changed files with 4799 additions and 2879 deletions
+44
View File
@@ -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
+44
View File
@@ -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
+28
View File
@@ -1,3 +1,31 @@
### 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.
+14 -1
View File
@@ -1,7 +1,20 @@
# Headplane
> An advanced UI for [juanfont/headscale](https://github.com/juanfont/headscale)
![Preview](/assets/main-preview.png)
<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.
+1 -5
View File
@@ -102,11 +102,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
? (
<>
+14 -7
View File
@@ -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>
)
+82
View File
@@ -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 })
+157 -110
View File
@@ -4,124 +4,171 @@ import { setTimeout } from 'node:timers/promises'
import { Client } from 'undici'
import { HeadscaleError, pull } from '~/utils/headscale'
import log from '~/utils/log'
import type { Integration } from '.'
import { createIntegration } from './integration'
// Integration name
const name = 'Docker'
let url: URL | undefined
let container: string | undefined
async function preflight() {
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock'
try {
url = new URL(path)
} catch {
return false
}
// 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 false
}
}
if (url.protocol === 'http:') {
try {
await fetch(new URL('/v1.30/version', url).href)
} catch {
return false
}
}
if (url.protocol !== 'http:' && url.protocol !== 'unix:') {
return false
}
container = process.env.HEADSCALE_CONTAINER
?.trim()
.toLowerCase()
if (!container || container.length === 0) {
return false
}
return true
interface Context {
client: Client | undefined
container: string | undefined
maxAttempts: number
}
async function sighup() {
if (!url || !container) {
return
}
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
context.container = process.env.HEADSCALE_CONTAINER
?.trim()
.toLowerCase()
// Supports the DOCKER_SOCK environment variable
const client = url.protocol === 'unix:'
? new Client('http://localhost', {
socketPath: url.href,
})
: new Client(url.href)
if (!context.container || context.container.length === 0) {
log.error('INTG', 'Missing HEADSCALE_CONTAINER variable')
return false
}
const response = await client.request({
method: 'POST',
path: `/v1.30/containers/${container}/kill?signal=SIGHUP`,
})
log.info('INTG', 'Using container: %s', context.container)
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock'
let url: URL | undefined
if (!response.statusCode || response.statusCode !== 204) {
throw new Error('Failed to send SIGHUP to Headscale')
}
}
async function restart() {
if (!url || !container) {
return
}
// Supports the DOCKER_SOCK environment variable
const client = url.protocol === 'unix:'
? new Client('http://localhost', {
socketPath: url.href,
})
: new Client(url.href)
const response = await client.request({
method: 'POST',
path: `/v1.30/containers/${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 @typescript-eslint/no-unnecessary-condition, no-constant-condition
while (true) {
try {
await pull('v1', '')
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:') {
url.protocol = 'http:'
try {
log.info('INTG', 'Checking API: %s', url.href)
await fetch(new URL('/v1.30/version', url).href)
} catch {
log.error('INTG', 'Failed to connect to Docker API')
return false
}
context.client = new Client(url.href)
}
// 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 {
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
},
onAclChange: async (context) => {
if (!context.client || !context.container) {
return
} catch (error) {
if (error instanceof HeadscaleError && error.status === 401) {
break
}
if (attempts > 10) {
throw new Error('Headscale did not restart in time')
}
attempts++
await setTimeout(1000)
}
}
}
export default { name, preflight, sighup, restart } satisfies Integration
log.info('INTG', 'Sending SIGHUP to Headscale via Docker')
let attempts = 0
while (attempts <= context.maxAttempts) {
const response = await context.client.request({
method: 'POST',
path: `/v1.30/containers/${context.container}/kill?signal=SIGHUP`,
})
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
}
},
onConfigChange: async (context) => {
if (!context.client || !context.container) {
return
}
log.info('INTG', 'Restarting Headscale via Docker')
let attempts = 0
while (attempts <= context.maxAttempts) {
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 {
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}`)
}
}
},
})
+53 -36
View File
@@ -1,58 +1,75 @@
import docker from './docker'
import proc from './proc'
import log from '~/utils/log'
export interface Integration {
name: string
preflight: () => Promise<boolean>
sighup?: () => Promise<void>
restart?: () => Promise<void>
}
import dockerIntegration from './docker'
import { IntegrationFactory } from './integration'
import kubernetesIntegration from './kubernetes'
import procIntegration from './proc'
// Because we previously supported the Docker integration by
// checking for the HEADSCALE_CONTAINER variable, we need to
// check for it here as well.
//
// 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
// an integration.
export async function checkIntegration() {
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) {
console.log('Running Headplane without any integrations')
log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION')
return
}
let module: Integration | undefined
try {
module = getIntegration(integration)
await module.preflight()
} catch (error) {
console.error('Failed to load integration', error)
return
}
return module
}
function getIntegration(name: string) {
switch (name) {
let integrationFactory: IntegrationFactory | undefined
switch (integration.toLowerCase().trim()) {
case 'docker': {
return docker
integrationFactory = dockerIntegration
break
}
case 'proc': {
return proc
case 'proc':
case 'native':
case 'linux': {
integrationFactory = procIntegration
break
}
case 'kubernetes':
case 'k8s': {
integrationFactory = kubernetesIntegration
break
}
default: {
throw new Error(`Unknown integration: ${name}`)
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
}
+14
View File
@@ -0,0 +1,14 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface IntegrationFactory<T = any> {
name: string
context: T
isAvailable: (context: T) => Promise<boolean> | boolean
onAclChange?: (context: T) => Promise<void> | void
onConfigChange?: (context: T) => Promise<void> | void
}
export function createIntegration<T>(
options: IntegrationFactory<T>,
) {
return options
}
+197
View File
@@ -0,0 +1,197 @@
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 {
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,
]
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
}
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
}
try {
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,
)
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
}
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
}
}
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 {
const data = await readFile(path, 'utf8')
if (data.includes('headscale')) {
return pid
}
} catch {}
})
const results = await Promise.allSettled(promises)
const pids = []
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
pids.push(result.value)
}
}
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
}
},
onAclChange: (context) => {
if (!context.pid) {
return
}
log.info('INTG', 'Sending SIGHUP to Headscale')
kill(context.pid, 'SIGHUP')
},
onConfigChange: (context) => {
if (!context.pid) {
return
}
log.info('INTG', 'Sending SIGTERM to Headscale')
kill(context.pid, 'SIGTERM')
},
})
+70 -70
View File
@@ -1,83 +1,83 @@
import { access, constants, readdir, readFile } from 'node:fs/promises'
import { readdir, readFile } from 'node:fs/promises'
import { platform } from 'node:os'
import { join, resolve } from 'node:path'
import { kill } from 'node:process'
import type { Integration } from '.'
import log from '~/utils/log'
// Integration name
const name = 'Native Linux (/proc)'
import { createIntegration } from './integration'
// Check if we have a /proc and if it's readable
async function preflight() {
if (platform() !== 'linux') {
return false
}
const dir = resolve('/proc')
try {
await access(dir, constants.R_OK)
return true
} catch (error) {
console.error('Failed to access /proc', error)
return false
}
interface Context {
pid: number | undefined
}
async function findPid() {
const dirs = await readdir('/proc')
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
}
const promises = dirs.map(async (dir) => {
const pid = Number.parseInt(dir, 10)
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)) {
if (Number.isNaN(pid)) {
return
}
const path = join('/proc', dir, 'cmdline')
try {
const data = await readFile(path, 'utf8')
if (data.includes('headscale')) {
return pid
}
} catch {}
})
const results = await Promise.allSettled(promises)
const pids = []
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
pids.push(result.value)
}
}
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
}
},
onAclChange: (context) => {
if (!context.pid) {
return
}
const path = join('/proc', dir, 'cmdline')
try {
const data = await readFile(path, 'utf8')
if (data.includes('headscale')) {
return pid
}
} catch {}
})
const results = await Promise.allSettled(promises)
const pids = []
for (const result of results) {
if (result.status === 'fulfilled') {
pids.push(result.value)
}
}
if (pids.length > 1) {
console.warn('Found multiple Headscale processes', pids)
console.log('Disabling the /proc integration')
return
}
if (pids.length === 0) {
console.warn('Could not find Headscale process')
console.log('Disabling the /proc integration')
return
}
return pids[0]
}
async function sighup() {
const pid = await findPid()
if (!pid) {
return
}
try {
kill(pid, 'SIGHUP')
} catch (error) {
console.error('Failed to send SIGHUP to Headscale', error)
}
}
export default { name, preflight, sighup } satisfies Integration
log.info('INTG', 'Sending SIGHUP to Headscale')
kill(context.pid, 'SIGHUP')
},
})
+71 -97
View File
@@ -1,36 +1,33 @@
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 Editor, { DiffEditor, Monaco } from '@monaco-editor/react'
import { useEffect, useState } from 'react'
import { ClientOnly } from 'remix-utils/client-only'
import Button from '~/components/Button'
import Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster'
import Fallback from '~/routes/_data.acls._index/fallback'
import { cn } from '~/utils/cn'
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
}
interface Props {
variant: 'edit' | 'diff'
language: 'json' | 'yaml'
state: [string, (value: string) => void]
policy?: string
isDisabled?: boolean
}
export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
const [light, setLight] = useState(false)
const [loading, setLoading] = useState(true)
function monacoCallback(monaco: Monaco) {
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
allowComments: true,
schemas: [],
enableSchemaRequest: true,
trailingCommas: 'ignore',
})
const fetcher = useFetcher()
const aclType = useMemo(() => data.aclType === 'json' ? json() : yaml(), [data.aclType])
monaco.languages.register({ id: 'json' })
monaco.languages.register({ id: 'yaml' })
}
export default function MonacoEditor({ variant, language, state, policy, isDisabled }: Props) {
const [light, setLight] = useState(false)
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)')
@@ -39,87 +36,64 @@ export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
theme.addEventListener('change', (theme) => {
setLight(theme.matches)
})
// Prevents the FOUC
setLoading(false)
}, [])
return (
<>
<div className={clsx(
<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">
{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>
)
)}
<ClientOnly fallback={<Fallback acl={state[0]} />}>
{() => variant === 'edit'
? (
<Editor
height="100%"
language={language}
theme={light ? 'light' : 'vs-dark'}
value={state[0]}
onChange={(updated) => {
if (!updated) {
return
}
if (updated !== state[0]) {
state[1](updated)
}
}}
loading={<Fallback acl={state[0]} />}
beforeMount={monacoCallback}
options={{
wordWrap: 'on',
minimap: { enabled: false },
fontSize: 14,
readOnly: isDisabled,
}}
/>
)
: (
<DiffEditor
height="100%"
language={language}
theme={light ? 'light' : 'vs-dark'}
original={policy}
modified={state[0]}
loading={<Fallback acl={state[0]} />}
beforeMount={monacoCallback}
options={{
wordWrap: 'on',
minimap: { enabled: false },
fontSize: 13,
readOnly: isDisabled,
}}
/>
)}
</ClientOnly>
</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>
</>
)
}
+18 -37
View File
@@ -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 FallbackProps {
readonly acl: string
}
export default function Fallback({ acl, where }: FallbackProperties) {
export default function Fallback({ acl }: FallbackProps) {
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-16 pr-8 pt-0.5 leading-snug',
)}
value={acl}
/>
</div>
)
}
+239 -32
View File
@@ -1,31 +1,86 @@
/* 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 { type ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
import { useFetcher, useLoaderData } from '@remix-run/react'
import { useEffect, useState } from 'react'
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
import { ClientOnly } from 'remix-utils/client-only'
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 { HeadscaleError, pull, put } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import Editor from './editor'
import Fallback from './fallback'
import Monaco from './editor'
export async function loader() {
const context = await loadContext()
if (!context.acl.read) {
throw new Error('No ACL configuration is available')
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
try {
const { policy } = await pull<{ policy: string }>(
'v1/policy',
session.get('hsApiKey')!,
)
console.log(policy)
try {
// We have read access, now do we have write access?
// Attempt to set the policy to what we just got
await put('v1/policy', session.get('hsApiKey')!, {
policy,
})
return {
hasAclWrite: true,
isPolicyApi: true,
currentAcl: policy,
aclType: 'json',
} as const
} catch (error) {
if (!(error instanceof HeadscaleError)) {
throw error
}
if (error.status === 500) {
return {
hasAclWrite: false,
isPolicyApi: true,
currentAcl: policy,
aclType: 'json',
} as const
}
}
} catch (error) {
// Propagate our errors through normal error handling
if (!(error instanceof HeadscaleError)) {
throw error
}
// Not on 0.23-beta1 or later
if (error.status === 404) {
const { data, type, read, write } = await loadAcl()
return {
hasAclWrite: write,
isPolicyApi: false,
currentAcl: read ? data : '',
aclType: type,
}
}
throw error
}
const { data, type } = await loadAcl()
return {
hasAclWrite: context.acl.write,
currentAcl: data,
aclType: type,
}
hasAclWrite: true,
isPolicyApi: true,
currentAcl: '',
aclType: 'json',
} as const
}
export async function action({ request }: ActionFunctionArgs) {
@@ -36,6 +91,21 @@ export async function action({ request }: ActionFunctionArgs) {
})
}
const data = await request.json() as { acl: string, api: boolean }
if (data.api) {
try {
await put('v1/policy', session.get('hsApiKey')!, {
policy: data.acl,
})
return json({ success: true })
} catch (error) {
return json({ success: false }, {
status: error instanceof HeadscaleError ? error.status : 500,
})
}
}
const context = await loadContext()
if (!context.acl.write) {
return json({ success: false }, {
@@ -43,19 +113,111 @@ export async function action({ request }: ActionFunctionArgs) {
})
}
const data = await request.json() as { acl: string }
await patchAcl(data.acl)
if (context.integration?.sighup) {
await context.integration.sighup()
if (context.integration?.onAclChange) {
await context.integration.onAclChange(context.integration.context)
}
return json({ success: true })
}
export function ErrorBoundary() {
return (
<div>
<Notice className="mb-4">
An ACL policy is not available or an error occurred while trying to fetch it.
</Notice>
<h1 className="text-2xl font-medium mb-4">
Access Control List (ACL)
</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your network.
You can find more information about the ACL file in the
{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>
{' '}
and the
{' '}
<Link
to="https://headscale.net/acls"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
<div>
<div className="max-w-prose">
<p className="mb-4 text-md">
If you are running Headscale 0.23-beta1 or later, the
ACL configuration is most likely set to
{' '}
<Code>file</Code>
{' '}
mode but the ACL file is not available. In order to
resolve this you will either need to correctly set
{' '}
<Code>policy.path</Code>
{' '}
in your Headscale configuration or set the
{' '}
<Code>policy.mode</Code>
{' '}
to
{' '}
<Code>database</Code>
.
</p>
<p className="mb-2 text-md">
If you are running an older version of Headscale, the
{' '}
<Code>ACL_FILE</Code>
{' '}
environment variable is not set. Refer to the
{' '}
<Link
to="https://github.com/tale/headplane/blob/main/docs/Configuration.md"
name="Headplane Configuration"
>
Headplane Configuration
</Link>
{' '}
documentation for more information on how to set the
ACL file and integrate it with Headscale.
</p>
</div>
</div>
</div>
)
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const fetcher = useFetcher<typeof action>()
const [acl, setAcl] = useState(data.currentAcl)
const [toasted, setToasted] = useState(false)
useEffect(() => {
if (!fetcher.data || toasted) {
return
}
if (fetcher.data.success) {
toast('Updated tailnet ACL policy')
} else {
toast('Failed to update tailnet ACL policy')
}
setToasted(true)
setAcl(data.currentAcl)
}, [fetcher.data, toasted, data.currentAcl])
return (
<div>
@@ -63,10 +225,25 @@ export default function Page() {
? undefined
: (
<div className="mb-4">
<Notice>
The ACL policy file is readonly to Headplane.
You will not be able to make changes here.
</Notice>
{data.isPolicyApi
? (
<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>
)
: (
<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 configure a Headplane integration
or make the ACL_FILE environment variable available.
</Notice>
)}
</div>
)}
@@ -141,18 +318,20 @@ export default function Page() {
</Tab>
</TabList>
<TabPanel id="edit">
<ClientOnly fallback={<Fallback acl={acl} where="server" />}>
{() => (
<Editor data={data} acl={acl} setAcl={setAcl} mode="edit" />
)}
</ClientOnly>
<Monaco
isDisabled={!data.hasAclWrite}
variant="edit"
language={data.aclType}
state={[acl, setAcl]}
/>
</TabPanel>
<TabPanel id="diff">
<ClientOnly fallback={<Fallback acl={acl} where="server" />}>
{() => (
<Editor data={data} acl={acl} setAcl={setAcl} mode="diff" />
)}
</ClientOnly>
<Monaco
variant="diff"
language={data.aclType}
state={[acl, setAcl]}
policy={data.currentAcl}
/>
</TabPanel>
<TabPanel id="preview">
<div
@@ -170,6 +349,34 @@ export default function Page() {
</div>
</TabPanel>
</Tabs>
<Button
variant="heavy"
className="mr-2"
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
onPress={() => {
setToasted(false)
fetcher.submit({
acl,
api: data.isPolicyApi,
}, {
method: 'PATCH',
encType: 'application/json',
})
}}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
Save
</Button>
<Button
isDisabled={fetcher.state === 'loading' || data.currentAcl === acl || !data.hasAclWrite}
onPress={() => { setAcl(data.currentAcl) }}
>
Discard Changes
</Button>
</div>
)
}
+121
View File
@@ -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',
+88
View File
@@ -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>
)
}
+2 -3
View File
@@ -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'
+1 -1
View File
@@ -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'
+4 -32
View File
@@ -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>
@@ -37,7 +36,6 @@ export default function Nameservers({ nameservers, override, isDisabled }: Props
isGlobal={key === 'global'}
isDisabled={isDisabled}
nameservers={nameservers[key]}
override={override}
name={key}
/>
))}
@@ -57,11 +55,9 @@ 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()
return (
@@ -70,30 +66,6 @@ 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) => (
@@ -111,14 +83,14 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: L
onPress={() => {
if (isGlobal) {
submit({
'dns_config.nameservers': nameservers
'dns.nameservers.global': nameservers
.filter((_, i) => i !== index),
}, {
method: 'PATCH',
encType: 'application/json',
})
} else {
const key = `dns_config.restricted_nameservers."${name}"`
const key = `dns.nameservers.split."${name}"`
submit({
[key]: nameservers
.filter((_, i) => i !== index),
+1 -1
View File
@@ -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'
+14 -10
View File
@@ -8,6 +8,7 @@ import { loadConfig, patchConfig } from '~/utils/config/headscale'
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'
@@ -23,13 +24,12 @@ 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.base_domain,
nameservers: config.dns.nameservers.global,
splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains,
extraRecords: config.dns.extra_records,
}
return {
@@ -56,8 +56,8 @@ export async function action({ request }: ActionFunctionArgs) {
const data = await request.json() as Record<string, unknown>
await patchConfig(data)
if (context.integration?.restart) {
await context.integration.restart()
if (context.integration?.onConfigChange) {
await context.integration.onConfigChange(context.integration.context)
}
return json({ success: true })
@@ -86,7 +86,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}
/>
+25 -13
View File
@@ -1,10 +1,11 @@
import { type LoaderFunctionArgs } from '@remix-run/node'
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { ActionFunctionArgs, 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, Route } from '~/types'
import { type Machine, Route, User } from '~/types'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
@@ -12,6 +13,7 @@ import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import { menuAction } from './_data.machines._index/action'
import MenuOptions from './_data.machines._index/menu'
export async function loader({ request, params }: LoaderFunctionArgs) {
@@ -25,30 +27,35 @@ export async function loader({ request, params }: 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
}
}
const [machine, routes] = await Promise.all([
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const [machine, routes, users] = await Promise.all([
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
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 } = useLoaderData<typeof loader>()
const { machine, magic, routes, users } = useLoaderData<typeof loader>()
useLiveData({ interval: 1000 })
const expired = machine.expiry === '0001-01-01 00:00:00'
|| machine.expiry === '0001-01-01T00:00:00Z'
? false
: new Date(machine.expiry).getTime() < Date.now()
@@ -86,6 +93,7 @@ export default function Page() {
<MenuOptions
machine={machine}
routes={routes}
users={users}
magic={magic}
/>
</div>
@@ -128,11 +136,15 @@ export default function Page() {
name="Expiry"
value={new Date(machine.expiry).toLocaleString()}
/>
<Attribute
isCopyable
name="Domain"
value={`${machine.givenName}.${machine.user.name}.ts.net`}
/>
{magic
? (
<Attribute
isCopyable
name="Domain"
value={`${machine.givenName}.${machine.user.name}.${magic}`}
/>
)
: undefined}
</Card>
<h2 className="text-xl font-medium mb-4 mt-8">
Machine Routes
+106
View File
@@ -0,0 +1,106 @@
/* 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'
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 '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(',') ?? []
try {
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
tags,
})
return json({ message: 'Tags updated' })
} catch {
return json({ message: 'Failed to update tags' }, {
status: 500,
})
}
}
default: {
return json({ message: 'Invalid method' }, {
status: 400,
})
}
}
}
@@ -0,0 +1,119 @@
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.
When MagicDNS is enabled, the username of the owner
will control the hostname of the machine.
</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
? (
owner === machine.user.name
? (
<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}
.
{owner}
.
{magic}
</Code>
.
</p>
)
: (
<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">
{machine.givenName}
.
{owner}
.
{magic}
</Code>
{'. '}
The hostname
{' '}
<Code className="text-sm">
{machine.givenName}
.
{machine.user.name}
.
{magic}
</Code>
{' '}
will no longer point to this machine.
</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,161 @@
import { PlusIcon, XIcon } from '@primer/octicons-react'
import { Form, useSubmit } from '@remix-run/react'
import { type Dispatch, type SetStateAction, useState } from 'react'
import { Button, Input } from 'react-aria-components'
import Dialog from '~/components/Dialog'
import Link from '~/components/Link'
import { type 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>
)
}
+5 -2
View File
@@ -4,7 +4,7 @@ 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 { type Machine, type Route, User } from '~/types'
import { cn } from '~/utils/cn'
import MenuOptions from './menu'
@@ -12,11 +12,13 @@ import MenuOptions from './menu'
interface Props {
readonly machine: Machine
readonly routes: Route[]
readonly users: User[]
readonly magic?: string
}
export default function MachineRow({ machine, routes, magic }: Props) {
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'
? false
: new Date(machine.expiry).getTime() < Date.now()
@@ -141,6 +143,7 @@ export default function MachineRow({ machine, routes, magic }: Props) {
<MenuOptions
machine={machine}
routes={routes}
users={users}
magic={magic}
/>
</td>
+23 -4
View File
@@ -2,27 +2,33 @@ import { KebabHorizontalIcon } from '@primer/octicons-react'
import { useState } from 'react'
import MenuComponent from '~/components/Menu'
import { Machine, 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 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
}
export default function Menu({ machine, routes, magic }: MenuProps) {
export default function Menu({ machine, routes, magic, users }: 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'
? false
: new Date(machine.expiry).getTime() < Date.now()
@@ -50,6 +56,16 @@ export default function Menu({ machine, routes, magic }: MenuProps) {
routes={routes}
state={routesState}
/>
<Tags
machine={machine}
state={tagsState}
/>
<Move
machine={machine}
state={moveState}
users={users}
magic={magic}
/>
<MenuComponent>
<MenuComponent.Button
@@ -68,9 +84,12 @@ export default function Menu({ machine, routes, magic }: MenuProps) {
<MenuComponent.ItemButton control={routesState}>
Edit route settings
</MenuComponent.ItemButton>
<MenuComponent.Item className="opacity-50 hover:bg-transparent">
<MenuComponent.ItemButton control={tagsState}>
Edit ACL tags
</MenuComponent.Item>
</MenuComponent.ItemButton>
<MenuComponent.ItemButton control={moveState}>
Change owner
</MenuComponent.ItemButton>
{expired
? undefined
: (
+11 -68
View File
@@ -1,25 +1,27 @@
/* 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 { type ActionFunctionArgs, type 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 { type Machine, type Route, User } from '~/types'
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 { menuAction } from './action'
import MachineRow from './machine'
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,81 +29,21 @@ 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
}
}
return {
nodes: machines.nodes,
routes: routes.routes,
users: users.users,
magic,
}
}
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() {
@@ -156,6 +98,7 @@ export default function Page() {
key={machine.id}
machine={machine}
routes={data.routes.filter(route => route.node.id === machine.id)}
users={data.users}
magic={data.magic}
/>
))}
+2 -2
View File
@@ -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
}
}
+1 -2
View File
@@ -80,8 +80,7 @@ export async function action({ request }: ActionFunctionArgs) {
}),
},
})
} catch (error) {
console.error(error)
} catch {
return json({
error: 'Invalid API key',
})
+51 -15
View File
@@ -8,14 +8,14 @@ import { resolve } from 'node:path'
import { parse } from 'yaml'
import { checkIntegration, Integration } from '~/integration'
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 {
headscaleUrl: string
cookieSecret: string
integration: Integration | undefined
integration: IntegrationFactory | undefined
config: {
read: boolean
@@ -67,23 +67,35 @@ export async function loadContext(): Promise<HeadplaneContext> {
context = {
headscaleUrl,
cookieSecret,
integration: await checkIntegration(),
integration: await loadIntegration(),
config: contextData,
acl: await checkAcl(config),
oidc: await checkOidc(config),
}
console.log('Completed loading the Headplane Context')
console.log('Headscale URL:', headscaleUrl)
console.log('Integration:', context.integration?.name ?? 'None')
console.log('Config:', contextData.read ? `Found ${contextData.write ? '' : '(Read Only)'}` : 'Unavailable')
console.log('ACL:', context.acl.read ? `Found ${context.acl.write ? '' : '(Read Only)'}` : 'Unavailable')
console.log('OIDC:', context.oidc ? 'Configured' : 'Unavailable')
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', 'ACL: %s', context.acl.read
? `Found ${context.acl.write ? '' : '(Read Only)'}`
: 'Unavailable',
)
log.info('CTXT', 'OIDC: %s', context.oidc ? 'Configured' : 'Unavailable')
return context
}
export async function loadAcl() {
export async function loadAcl(): Promise<{
data: string
type: 'json' | 'yaml'
read: boolean
write: boolean
}> {
let path = process.env.ACL_FILE
if (!path) {
try {
@@ -93,18 +105,32 @@ export async function loadAcl() {
}
if (!path) {
return { data: '', type: 'json' }
throw new Error('No ACL file defined')
}
// Check for attributes
let read = false
let write = false
try {
await access(path, constants.R_OK)
read = true
} catch {}
try {
await access(path, constants.W_OK)
write = true
} catch {}
const data = await readFile(path, 'utf8')
// Naive check for YAML over JSON
// This is because JSON.parse doesn't support comments
try {
parse(data)
return { data, type: 'yaml' }
return { data, type: 'yaml', read, write }
} catch {
return { data, type: 'json' }
return { data, type: 'json', read, write }
}
}
@@ -198,6 +224,16 @@ 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
+77 -22
View File
@@ -12,6 +12,8 @@ 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) => {
@@ -51,6 +53,11 @@ 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: goDuration.default('800ms'),
node_mapsession_buffered_chan_size: z.number().default(30),
@@ -82,18 +89,20 @@ const HeadscaleConfig = z.object({
v6: z.string(),
}),
dns_config: z.object({
override_local_dns: goBool.default(false),
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: goBool.default(false),
base_domain: z.string().default('headscale.net'),
use_username_in_magic_dns: goBool.default(false),
}),
oidc: z.object({
@@ -183,14 +192,60 @@ export async function loadConfig(path?: string) {
configYaml = parseDocument(data)
if (process.env.HEADSCALE_CONFIG_UNSTRICT === 'true') {
config = configYaml.toJSON() as HeadscaleConfig
console.log('Loaded Headscale configuration in non-strict mode')
console.log('By using this mode you forfeit GitHub issue support')
console.log('This is very dangerous and comes with a few caveats:')
console.log('- Headplane could very easily crash')
console.log('- Headplane could break your Headscale installation')
console.log('- The UI could throw random errors/show incorrect data')
console.log('')
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
}
@@ -198,19 +253,19 @@ export async function loadConfig(path?: string) {
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
} catch (error) {
if (error instanceof z.ZodError) {
console.log('Failed to parse the Headscale configuration file!')
console.log('The following schema issues were found:')
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
console.log(`- '${path}': ${message}`)
log.error('CFGX', ` '${path}': ${message}`)
}
console.log('')
console.log('Please fix the configuration file and try again.')
console.log('Headplane will operate as if no config is present.')
console.log('')
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
+18
View File
@@ -51,6 +51,24 @@ export async function post<T>(url: string, key: string, body?: unknown) {
return (response.json() as Promise<T>)
}
export async function put<T>(url: string, key: string, body?: unknown) {
const context = await loadContext()
const prefix = context.headscaleUrl
const response = await fetch(`${prefix}/api/${url}`, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`,
},
})
if (!response.ok) {
throw new HeadscaleError(await response.text(), response.status)
}
return (response.json() as Promise<T>)
}
export async function del<T>(url: string, key: string) {
const context = await loadContext()
const prefix = context.headscaleUrl
+23
View File
@@ -0,0 +1,23 @@
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)
},
}
function defaultLog(
level: string,
category: string,
message: string,
...args: unknown[]
) {
const date = new Date().toISOString()
console.log(`${date} (${level}) [${category}] ${message}`, ...args)
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

+1 -2
View File
@@ -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-beta1'
container_name: 'headscale'
restart: 'unless-stopped'
command: 'serve'
+55 -129
View File
@@ -1,145 +1,71 @@
# Advanced Integration
![Integration Preview](/assets/advanced-preview.png)
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.
Additionally, with an integration provider, you can automatically reload the configuration or ACLs when they are changed.
### 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)
### Access Control Lists (ACLs)
![ACL Preview](/assets/acl-preview.png)
<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.
> By default, the ACL file is read from `/etc/headscale/acl_policy.json`. This
can be overridden by setting the `ACL_FILE` environment variable and is also
overriden by the `acl_policy_path` key in the configuration file if set.
## Automatic Configuration Reload
When the configuration file is changed, Headscale will need to be restarted to apply the changes.
Similarly, when the ACL file is changed, Headscale will need to be sent a `SIGHUP` signal to reload the ACLs.
Currently there are 2 integration providers that can do this for you:
### Docker Integration
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.
#### Example Docker Deployment
## Deployment
Requirements:
- Headscale 0.23 alpha or later
- Headscale 0.23 beta-2 or later
- 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_INTEGRATION: 'docker'
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.
### Native Linux Integration (Beta)
The native integration for Linux relies on the `/proc` directory to locate the Headscale process.
To enable it, set the `HEADSCALE_INTEGRATION=proc` value in the environment variables.
Because of the way this integration works, it only supports automatically reloading ACLs.
It's still very experimental and may not work in all environments.
## Configuration Scenarios
Since the configuration is fairly modular you can have a variety of different setups.
This mostly applies to the Docker integration since the native integration isn't fully featured yet.
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.
+21 -19
View File
@@ -1,24 +1,31 @@
# 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 beta-2 or later
- 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
@@ -42,12 +49,7 @@ services:
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.
+86
View File
@@ -0,0 +1,86 @@
## 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-beta2'
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'
HEADSCALE_INTEGRATION: 'docker'
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.
+129
View File
@@ -0,0 +1,129 @@
## 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:latest
env:
- name: COOKIE_SECRET
value: 'abcdefghijklmnopqrstuvwxyz'
- name: HEADSCALE_INTEGRATION
value: 'kubernetes'
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
volumeMounts:
- name: headscale-config
mountPath: /etc/headscale
- name: headscale
image: headscale/headscale:0.23.0-beta2
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.
+28
View File
@@ -0,0 +1,28 @@
## 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
Follow the instructions to install Headscale from the
[Linux Installation Guide](https://headscale.net/running-headscale-linux/). As
of now, Headplane requires Node.js 20 to be installed on the system. Once you
are ready, clone the repository (`git clone https://github.com/tale/headplane`),
install dependencies (`npm install`), build the project (`npm run build`), and
start the server (`npm start`).
> 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.
When running Headplane, you'll need to set environment variables to configure
the application. The `HEADSCALE_INTEGRATION` variable should be set to `proc`.
> 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.
+22 -25
View File
@@ -11,48 +11,45 @@
"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.21.0",
"@monaco-editor/react": "^4.6.0",
"@primer/octicons-react": "^19.10.0",
"@react-aria/toast": "3.0.0-beta.12",
"@react-stately/toast": "3.0.0-beta.4",
"@remix-run/node": "^2.10.2",
"@remix-run/react": "^2.10.2",
"@remix-run/serve": "^2.10.2",
"clsx": "^2.1.1",
"isbot": "^5.1.6",
"oauth4webapi": "^2.10.4",
"react": "19.0.0-beta-26f2496093-20240514",
"react-aria-components": "^1.2.0",
"react-codemirror-merge": "^4.22.0",
"react-dom": "19.0.0-beta-26f2496093-20240514",
"isbot": "^5.1.11",
"oauth4webapi": "^2.11.1",
"react": "19.0.0-rc-f38c22b244-20240704",
"react-aria-components": "^1.2.1",
"react-dom": "19.0.0-rc-f38c22b244-20240704",
"remix-utils": "^7.6.0",
"tailwind-merge": "^2.3.0",
"tailwindcss-react-aria-components": "^1.1.2",
"undici": "^6.16.1",
"tailwindcss-react-aria-components": "^1.1.3",
"undici": "^6.19.2",
"usehooks-ts": "^3.1.0",
"yaml": "^2.4.2",
"yaml": "^2.4.5",
"zod": "^3.23.8"
},
"devDependencies": {
"@remix-run/dev": "^2.9.2",
"@remix-run/dev": "^2.10.2",
"@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",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.4",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"typescript": "^5.5.3",
"vite": "^5.3.3",
"vite-plugin-babel": "^1.2.0",
"vite-tsconfig-paths": "^4.2.1"
},
@@ -66,7 +63,7 @@
},
"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"
}
}
}
@@ -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.
+2449 -2088
View File
File diff suppressed because it is too large Load Diff
+102 -39
View File
@@ -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