mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f57fdb43b | |||
| b170e11dd6 | |||
| 6b278309ed | |||
| ea8ecfb28f | |||
| 099bd3bcb8 | |||
| 0aa0406ea6 | |||
| 3cc726320a | |||
| bda151f4e8 | |||
| 6d411853d5 | |||
| dc4d05a2d9 |
@@ -1,3 +1,9 @@
|
||||
### 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.
|
||||
|
||||
+157
-110
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
},
|
||||
})
|
||||
|
||||
@@ -47,8 +47,8 @@ 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 })
|
||||
|
||||
@@ -57,8 +57,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 })
|
||||
|
||||
@@ -80,8 +80,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
}),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} catch {
|
||||
return json({
|
||||
error: 'Invalid API key',
|
||||
})
|
||||
|
||||
@@ -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,19 +67,26 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
@@ -229,13 +231,13 @@ export async function loadConfig(path?: string) {
|
||||
},
|
||||
} 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('')
|
||||
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
|
||||
}
|
||||
|
||||
@@ -243,19 +245,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+28
-128
@@ -1,5 +1,11 @@
|
||||
# Advanced Integration
|
||||
|
||||
The advanced integration methods unlock the full capabilities of Headplane.
|
||||
This is the closest you can get to the SaaS experience if you were paying for
|
||||
Tailscale.
|
||||
|
||||
### Configuration Management
|
||||
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
@@ -15,21 +21,18 @@
|
||||
>
|
||||
</picture>
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Configuration Editing
|
||||
> 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.
|
||||
|
||||
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`.
|
||||
|
||||
> 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)
|
||||
|
||||
<picture>
|
||||
<source
|
||||
@@ -46,126 +49,23 @@ By default this is set to `/etc/headscale/config.yaml`.
|
||||
>
|
||||
</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 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.
|
||||
|
||||
+20
-18
@@ -1,12 +1,17 @@
|
||||
# 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
|
||||
|
||||
@@ -14,11 +19,13 @@ Requirements:
|
||||
- Headscale 0.23 alpha 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.
|
||||
|
||||
@@ -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-alpha12'
|
||||
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.
|
||||
@@ -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-alpha12
|
||||
command: ['serve']
|
||||
env:
|
||||
- name: TZ
|
||||
value: 'America/New_York'
|
||||
volumeMounts:
|
||||
- name: headscale-data
|
||||
mountPath: /var/lib/headscale
|
||||
- name: headscale-config
|
||||
mountPath: /etc/headscale
|
||||
|
||||
volumes:
|
||||
- name: headscale-data
|
||||
persistentVolumeClaim:
|
||||
claimName: headscale-data
|
||||
- name: headscale-config
|
||||
persistentVolumeClaim:
|
||||
claimName: headscale-config
|
||||
```
|
||||
|
||||
> For a breakdown of each configuration variable, please refer to the
|
||||
[Configuration](/docs/Configuration.md) guide.
|
||||
> It explains what each variable does, how to configure them, and what the
|
||||
default values are.
|
||||
@@ -0,0 +1,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.
|
||||
@@ -15,6 +15,7 @@
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@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",
|
||||
|
||||
Generated
+484
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user