Compare commits

..

10 Commits

Author SHA1 Message Date
Aarnav Tale 57f045eaf6 fix: support top level await on the server 2024-05-30 11:41:36 -04:00
Aarnav Tale faa583bb38 chore: v0.1.7 2024-05-30 10:41:55 -04:00
Aarnav Tale 98ea2cb06f feat: add a way to escape hatch from strict config checking 2024-05-30 10:41:40 -04:00
Aarnav Tale 868d85bbeb chore: log about context loading 2024-05-30 10:28:37 -04:00
Aarnav Tale dbd9d39da9 style: cleanup vite.config.ts 2024-05-30 10:20:53 -04:00
Aarnav Tale 72cc9d8974 fix: flexible duration and go boolean handling 2024-05-30 10:20:41 -04:00
Aarnav Tale ed50c48965 fix: handle configs better and propagate errors 2024-05-30 10:15:02 -04:00
Aarnav Tale e19dbda5ed fix: headscale expiry can be a literal 0 2024-05-26 19:41:56 -04:00
Aarnav Tale a72cc1bb1c chore: document the new integration providers 2024-05-26 19:40:46 -04:00
Aarnav Tale f0e4868252 feat: configure support for generic integrations
Co-authored-by: Gage Orsburn <gageorsburn@live.com>
2024-05-26 19:23:24 -04:00
17 changed files with 513 additions and 202 deletions
+9 -1
View File
@@ -1,4 +1,12 @@
### 0.1.5 (May 22, 2024)
### 0.1.7 (May 30, 2024)
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
- Made the strict configuration loader more lenient to allow for more flexibility.
- Added `HEADSCALE_CONFIG_UNSTRICT`=true to revert back to a weaker configuration loader.
- Headplane's context now only loads once at start instead of being lazy-loaded.
- Improved logging and error propagation so that it's easier to debug issues.
### 0.1.6 (May 22, 2024)
- Added experimental support for advanced integration without Docker.
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
+11 -3
View File
@@ -13,9 +13,17 @@ It's able to replicate nearly all of the functions of the official Tailscale Saa
- DNS and *safe* Headscale configuration management
## Deployment
- If you run Headscale in a Docker container, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide.
- If you run Headscale natively, see the [Basic Deployment](/docs/Basic-Integration.md) guide.
- For more configuration options, refer to the [Configuration](/docs/Configuration.md) guide.
> For more configuration options, refer to the [Configuration](/docs/Configuration.md) guide.
For fully-featured deployments, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide.
This includes automatic management of ACLs, DNS settings, and Headscale configuration.
*This is the closest experience to the Tailscale UI that can be achieved with Headscale and Headplane.*
*If you aren't sure which one to pick, we recommend this.*
If your environment is not able to support the advanced deployment, you can still use the basic deployment.
For basic deployments, see the [Basic Deployment](/docs/Basic-Integration.md) guide.
It does not include automatic management of ACLs, DNS settings, or the Headscale configuration,
instead requiring manual editing and reloading when making changes.
## Contributing
If you would like to contribute, please install a relatively modern version of Node.js and PNPM.
+5 -1
View File
@@ -9,7 +9,11 @@ import Menu from './Menu'
import TabLink from './TabLink'
interface Properties {
readonly data?: HeadplaneContext & { user?: SessionData['user'] }
readonly data?: {
acl: HeadplaneContext['acl']
config: HeadplaneContext['config']
user?: SessionData['user']
}
}
interface LinkProperties {
+10 -11
View File
@@ -2,30 +2,29 @@ import { LinkExternalIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
/* eslint-disable unicorn/no-keyword-prefix */
type Properties = {
readonly to: string;
readonly name: string;
readonly children: string;
readonly className?: string;
interface Props {
to: string
name: string
children: string
className?: string
}
export default function Link({ to, name: alt, children, className }: Properties) {
export default function Link({ to, name: alt, children, className }: Props) {
return (
<a
href={to}
aria-label={alt}
target='_blank'
rel='noreferrer'
target="_blank"
rel="noreferrer"
className={cn(
'inline-flex items-center gap-x-1',
'text-blue-500 hover:text-blue-700',
'dark:text-blue-400 dark:hover:text-blue-300',
className
className,
)}
>
{children}
<LinkExternalIcon className='h-3 w-3'/>
<LinkExternalIcon className="h-3 w-3" />
</a>
)
}
+62
View File
@@ -0,0 +1,62 @@
import { PassThrough } from 'node:stream'
import type { AppLoadContext, EntryContext } from '@remix-run/node'
import { createReadableStreamFromReadable } from '@remix-run/node'
import { RemixServer } from '@remix-run/react'
import { isbot } from 'isbot'
import { renderToPipeableStream } from 'react-dom/server'
import { loadContext } from './utils/config/headplane'
await loadContext()
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_loadContext: AppLoadContext,
) {
const ua = request.headers.get('user-agent')
const isBot = ua ? isbot(ua) : false
return new Promise((resolve, reject) => {
let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={5000}
/>,
{
[isBot ? 'onAllReady' : 'onShellReady']() {
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set('Content-Type', 'text/html')
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
)
pipe(body)
},
onShellError(error: unknown) {
reject(error as Error)
},
onError(error: unknown) {
responseStatusCode = 500
if (shellRendered) {
console.error(error)
}
},
},
)
setTimeout(abort, 5000)
})
}
+127
View File
@@ -0,0 +1,127 @@
import { access, constants } from 'node:fs/promises'
import { setTimeout } from 'node:timers/promises'
import { Client } from 'undici'
import { HeadscaleError, pull } from '~/utils/headscale'
import type { Integration } from '.'
// 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
}
async function sighup() {
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}/kill?signal=SIGHUP`,
})
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', '')
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
+58
View File
@@ -0,0 +1,58 @@
import docker from './docker'
import proc from './proc'
export interface Integration {
name: string
preflight: () => Promise<boolean>
sighup?: () => Promise<void>
restart?: () => Promise<void>
}
// 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() {
let integration = process.env.HEADSCALE_INTEGRATION
?.trim()
.toLowerCase()
// Old HEADSCALE_CONTAINER variable upgrade path
if (!integration && process.env.HEADSCALE_CONTAINER) {
integration = 'docker'
}
if (!integration) {
console.log('Running Headplane without any integrations')
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) {
case 'docker': {
return docker
}
case 'proc': {
return proc
}
default: {
throw new Error(`Unknown integration: ${name}`)
}
}
}
+83
View File
@@ -0,0 +1,83 @@
import { access, constants, 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 '.'
// Integration name
const name = 'Native Linux (/proc)'
// 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
}
}
async function findPid() {
const dirs = await readdir('/proc')
const promises = dirs.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') {
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
+2 -3
View File
@@ -9,7 +9,6 @@ import Link from '~/components/Link'
import Notice from '~/components/Notice'
import { cn } from '~/utils/cn'
import { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane'
import { sighupHeadscale } from '~/utils/docker'
import { getSession } from '~/utils/sessions'
import Editor from './editor'
@@ -47,8 +46,8 @@ export async function action({ request }: ActionFunctionArgs) {
const data = await request.json() as { acl: string }
await patchAcl(data.acl)
if (context.docker) {
await sighupHeadscale()
if (context.integration?.sighup) {
await context.integration.sighup()
}
return json({ success: true })
+5 -2
View File
@@ -5,7 +5,6 @@ import Code from '~/components/Code'
import Notice from '~/components/Notice'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig, patchConfig } from '~/utils/config/headscale'
import { restartHeadscale } from '~/utils/docker'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
@@ -56,7 +55,11 @@ export async function action({ request }: ActionFunctionArgs) {
const data = await request.json() as Record<string, unknown>
await patchConfig(data)
await restartHeadscale()
if (context.integration?.restart) {
await context.integration.restart()
}
return json({ success: true })
}
+2 -1
View File
@@ -35,7 +35,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
const context = await loadContext()
return {
...context,
acl: context.acl,
config: context.config,
user: session.get('user'),
}
}
+32 -63
View File
@@ -8,11 +8,14 @@ import { resolve } from 'node:path'
import { parse } from 'yaml'
import { checkIntegration, Integration } from '~/integration'
import { HeadscaleConfig, loadConfig } from './headscale'
export interface HeadplaneContext {
headscaleUrl: string
cookieSecret: string
integration: Integration | undefined
config: {
read: boolean
@@ -24,12 +27,6 @@ export interface HeadplaneContext {
write: boolean
}
docker?: {
url: string
sock: boolean
container: string
}
oidc?: {
issuer: string
client: string
@@ -46,12 +43,8 @@ export async function loadContext(): Promise<HeadplaneContext> {
return context
}
let config: HeadscaleConfig | undefined
try {
config = await loadConfig()
} catch {}
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
const { config, contextData } = await checkConfig(path)
let headscaleUrl = process.env.HEADSCALE_URL
if (!headscaleUrl && !config) {
@@ -74,12 +67,19 @@ export async function loadContext(): Promise<HeadplaneContext> {
context = {
headscaleUrl,
cookieSecret,
config: await checkConfig(path, config),
integration: await checkIntegration(),
config: contextData,
acl: await checkAcl(config),
docker: await checkDocker(),
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')
return context
}
@@ -124,7 +124,20 @@ export async function patchAcl(data: string) {
await writeFile(path, data, 'utf8')
}
async function checkConfig(path: string, config?: HeadscaleConfig) {
async function checkConfig(path: string) {
let config: HeadscaleConfig | undefined
try {
config = await loadConfig(path)
} catch {
return {
config: undefined,
contextData: {
read: false,
write: false,
},
}
}
let write = false
try {
await access(path, constants.W_OK)
@@ -132,8 +145,11 @@ async function checkConfig(path: string, config?: HeadscaleConfig) {
} catch {}
return {
read: config ? true : false,
write,
config,
contextData: {
read: true,
write,
},
}
}
@@ -163,53 +179,6 @@ async function checkAcl(config?: HeadscaleConfig) {
}
}
async function checkDocker() {
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock'
let url: URL | undefined
try {
url = new URL(path)
} catch {
return
}
// The API is available as an HTTP endpoint
if (url.protocol === 'tcp:') {
url.protocol = 'http:'
}
// Check if the socket is accessible
if (url.protocol === 'unix:') {
try {
await access(path, constants.R_OK)
} catch {
return
}
}
if (url.protocol === 'http:') {
try {
await fetch(new URL('/v1.30/version', url).href)
} catch {
return
}
}
if (url.protocol !== 'http:' && url.protocol !== 'unix:') {
return
}
if (!process.env.HEADSCALE_CONTAINER) {
return
}
return {
url: url.href,
sock: url.protocol === 'unix:',
container: process.env.HEADSCALE_CONTAINER,
}
}
async function checkOidc(config?: HeadscaleConfig) {
const disableKeyLogin = process.env.DISABLE_API_KEY_LOGIN === 'true'
const rootKey = process.env.ROOT_API_KEY ?? process.env.API_KEY
+79 -28
View File
@@ -12,6 +12,18 @@ import { resolve } from 'node:path'
import { type Document, parseDocument } from 'yaml'
import { z } from 'zod'
const goBool = z
.union([z.boolean(), z.literal('true'), z.literal('false')])
.transform((value) => {
if (typeof value === 'boolean') {
return value
}
return value === 'true'
})
const goDuration = z.union([z.literal(0), z.string()])
const HeadscaleConfig = z.object({
tls_letsencrypt_cache_dir: z.string().default('/var/www/cache'),
tls_letsencrypt_challenge_type: z.enum(['HTTP-01', 'TLS-ALPN-01']).default('HTTP-01'),
@@ -26,11 +38,11 @@ const HeadscaleConfig = z.object({
listen_addr: z.string(),
metrics_listen_addr: z.string().optional(),
grpc_listen_addr: z.string().default(':50443'),
grpc_allow_insecure: z.boolean().default(false),
grpc_allow_insecure: goBool.default(false),
disable_check_updates: z.boolean().default(false),
ephemeral_node_inactivity_timeout: z.string().default('120s'),
randomize_client_port: z.boolean().default(false),
disable_check_updates: goBool.default(false),
ephemeral_node_inactivity_timeout: goDuration.default('120s'),
randomize_client_port: goBool.default(false),
acl_policy_path: z.string().optional(),
acme_email: z.string().optional(),
@@ -40,7 +52,7 @@ const HeadscaleConfig = z.object({
unix_socket_permission: z.string().default('0o770'),
tuning: z.object({
batch_change_delay: z.string().default('800ms'),
batch_change_delay: goDuration.default('800ms'),
node_mapsession_buffered_chan_size: z.number().default(30),
}).optional(),
@@ -54,14 +66,14 @@ const HeadscaleConfig = z.object({
}).default({ level: 'info', format: 'text' }),
logtail: z.object({
enabled: z.boolean().default(false),
enabled: goBool.default(false),
}).default({ enabled: false }),
cli: z.object({
address: z.string().optional(),
api_key: z.string().optional(),
timeout: z.string().default('10s'),
insecure: z.boolean().default(false),
timeout: goDuration.default('10s'),
insecure: goBool.default(false),
}).optional(),
prefixes: z.object({
@@ -71,7 +83,7 @@ const HeadscaleConfig = z.object({
}),
dns_config: z.object({
override_local_dns: z.boolean().default(true),
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([]),
@@ -80,51 +92,51 @@ const HeadscaleConfig = z.object({
type: z.literal('A'),
value: z.string(),
})).default([]),
magic_dns: z.boolean().default(false),
magic_dns: goBool.default(false),
base_domain: z.string().default('headscale.net'),
}),
oidc: z.object({
only_start_if_oidc_is_available: z.boolean().default(true),
only_start_if_oidc_is_available: goBool.default(false),
issuer: z.string().optional(),
client_id: z.string().optional(),
client_secret: z.string().optional(),
client_secret_path: z.string().optional(),
scope: z.array(z.string()).default(['openid', 'profile', 'email']),
extra_params: z.record(z.string()).default({}),
extra_params: z.record(z.unknown()).default({}),
allowed_domains: z.array(z.string()).optional(),
allowed_users: z.array(z.string()).optional(),
allowed_groups: z.array(z.string()).optional(),
strip_email_domain: z.boolean().default(true),
expiry: z.string().default('180d'),
use_expiry_from_token: z.boolean().default(false),
strip_email_domain: goBool.default(false),
expiry: goDuration.default('180d'),
use_expiry_from_token: goBool.default(false),
}).optional(),
database: z.union([
z.object({
type: z.literal('sqlite'),
debug: z.boolean().default(false),
debug: goBool.default(false),
sqlite: z.object({
path: z.string(),
}),
}),
z.object({
type: z.literal('sqlite3'),
debug: z.boolean().default(false),
debug: goBool.default(false),
sqlite: z.object({
path: z.string(),
}),
}),
z.object({
type: z.literal('postgres'),
debug: z.boolean().default(false),
debug: goBool.default(false),
postgres: z.object({
host: z.string(),
port: z.number(),
name: z.string(),
user: z.string(),
pass: z.string(),
ssl: z.boolean().default(false),
ssl: goBool.default(true),
max_open_conns: z.number().default(10),
max_idle_conns: z.number().default(10),
conn_max_idle_time_secs: z.number().default(3600),
@@ -134,7 +146,7 @@ const HeadscaleConfig = z.object({
derp: z.object({
server: z.object({
enabled: z.boolean().default(false),
enabled: goBool.default(true),
region_id: z.number().optional(),
region_code: z.string().optional(),
region_name: z.string().optional(),
@@ -143,13 +155,13 @@ const HeadscaleConfig = z.object({
ipv4: z.string().optional(),
ipv6: z.string().optional(),
automatically_add_embedded_derp_region: z.boolean().default(true),
automatically_add_embedded_derp_region: goBool.default(true),
}),
urls: z.array(z.string()).optional(),
paths: z.array(z.string()).optional(),
auto_update_enabled: z.boolean().default(true),
update_frequency: z.string().default('24h'),
auto_update_enabled: goBool.default(true),
update_frequency: goDuration.default('24h'),
}),
})
@@ -158,16 +170,52 @@ export type HeadscaleConfig = z.infer<typeof HeadscaleConfig>
export let configYaml: Document | undefined
export let config: HeadscaleConfig | undefined
export async function loadConfig() {
export async function loadConfig(path?: string) {
if (config) {
return config
}
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
const data = await readFile(path, 'utf8')
if (!path) {
throw new Error('Path is required to lazy load config')
}
const data = await readFile(path, 'utf8')
configYaml = parseDocument(data)
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
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('')
return config
}
try {
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:')
for (const issue of error.issues) {
const path = issue.path.map(String).join('.')
const message = issue.message
console.log(`- '${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('')
}
throw error
}
return config
}
@@ -209,7 +257,10 @@ export async function patchConfig(partial: Record<string, unknown>) {
configYaml.setIn(path, value)
}
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
config = process.env.HEADSCALE_CONFIG_UNSTRICT === 'true'
? configYaml.toJSON() as HeadscaleConfig
: (await HeadscaleConfig.parseAsync(configYaml.toJSON()))
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
await writeFile(path, configYaml.toString(), 'utf8')
}
-76
View File
@@ -1,76 +0,0 @@
import { setTimeout } from 'node:timers/promises'
import { Client } from 'undici'
import { loadContext } from './config/headplane'
import { HeadscaleError, pull } from './headscale'
export async function sighupHeadscale() {
const context = await loadContext()
if (!context.docker) {
return
}
// Supports the DOCKER_SOCK environment variable
const client = context.docker.sock
? new Client('http://localhost', {
socketPath: context.docker.url,
})
: new Client(context.docker.url)
const response = await client.request({
method: 'POST',
path: `/v1.30/containers/${context.docker.container}/kill?signal=SIGHUP`,
})
if (!response.statusCode || response.statusCode !== 204) {
throw new Error('Failed to send SIGHUP to Headscale')
}
}
export async function restartHeadscale() {
const context = await loadContext()
if (!context.docker) {
return
}
// Supports the DOCKER_SOCK environment variable
const client = context.docker.sock
? new Client('http://localhost', {
socketPath: context.docker.url,
})
: new Client(context.docker.url)
const response = await client.request({
method: 'POST',
path: `/v1.30/containers/${context.docker.container}/restart`,
})
if (!response.statusCode || response.statusCode !== 204) {
throw new Error('Failed to restart Headscale')
}
// Wait for Headscale to restart before continuing
let attempts = 0
// eslint-disable-next-line
while (true) {
try {
// Acceptable blank because ROOT_API_KEY is not required
await pull('v1/apikey', context.oidc?.rootKey ?? '')
return
} catch (error) {
// This means the server is up but the API key is invalid
// This can happen if the user only uses ROOT_API_KEY via cookies
if (error instanceof HeadscaleError && error.status === 401) {
break
}
if (attempts > 10) {
throw new Error('Headscale did not restart in time')
}
attempts++
await setTimeout(1000)
}
}
}
+22 -7
View File
@@ -4,9 +4,9 @@
With the advanced integration it's possible to control Access Control Lists (ACLs) and the Headscale configuration via the Headplane UI.
Every single aspect of this integration is optional, meaning you can only use what you want.
If you want to use this integration, you do not need Docker and you can make it work with Headscale and Headplane running natively.
Additionally, with an integration provider, you can automatically reload the configuration or ACLs when they are changed.
### Configuration Editing
## Configuration Editing
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.
@@ -16,7 +16,7 @@ By default this is set to `/etc/headscale/config.yaml`.
> 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.
### Access Control Lists (ACLs)
## Access Control Lists (ACLs)
![ACL Preview](/assets/acl-preview.png)
@@ -26,10 +26,16 @@ The ACL file path is read from the following sources in order of priority:
- **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.
## 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
The Docker integration can be used to automatically reload the configuration or ACLs when they are changed.
In order for this to work, you'll need to pass in the `HEADSCALE_CONTAINER` environment variable.
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.
@@ -38,7 +44,7 @@ setting the `DOCKER_SOCK` environment variable if you use a different configurat
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.
## Deployment
#### Example Docker Deployment
Requirements:
- Headscale 0.23 alpha or later
@@ -75,6 +81,7 @@ services:
# 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'
@@ -98,9 +105,17 @@ services:
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.<br>
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
+1
View File
@@ -13,6 +13,7 @@ You can configure Headplane using environment variables.
- **`PORT`**: The port to bind the server to (default: `3000`).
- **`CONFIG_FILE`**: The path to the Headscale `config.yaml` (default: `/etc/headscale/config.yaml`).
- **`ACL_FILE`**: The path to the ACL file (default: `/etc/headscale/acl_policy.json`, not needed if you have `acl_policy_path` in your config).
- **`HEADSCALE_CONFIG_UNSTRICT`**: This will disable the strict configuration loader (default: `false`).
#### Docker Integration
The Docker integration allows Headplane to manage the Headscale docker container.
+5 -6
View File
@@ -6,10 +6,9 @@ import tsconfigPaths from 'vite-tsconfig-paths'
installGlobals()
const ReactCompilerConfig = {}
export default defineConfig({
export default defineConfig(({ isSsrBuild }) => ({
base: '/admin/',
build: isSsrBuild ? { target: 'ES2022' } : {},
plugins: [
remix({
basename: '/admin/',
@@ -18,11 +17,11 @@ export default defineConfig({
babel({
filter: /\.[jt]sx?$/,
babelConfig: {
presets: ['@babel/preset-typescript'], // if you use TypeScript
presets: ['@babel/preset-typescript'],
plugins: [
['babel-plugin-react-compiler', ReactCompilerConfig],
['babel-plugin-react-compiler', {}],
],
},
}),
],
})
}))