diff --git a/app/components/Header.tsx b/app/components/Header.tsx
index 4132c23..628e712 100644
--- a/app/components/Header.tsx
+++ b/app/components/Header.tsx
@@ -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 {
diff --git a/app/components/Link.tsx b/app/components/Link.tsx
index c9f37ee..0f78a95 100644
--- a/app/components/Link.tsx
+++ b/app/components/Link.tsx
@@ -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 (
{children}
-
+
)
}
diff --git a/app/integration/docker.ts b/app/integration/docker.ts
new file mode 100644
index 0000000..729094c
--- /dev/null
+++ b/app/integration/docker.ts
@@ -0,0 +1,124 @@
+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 '.'
+
+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 { preflight, sighup, restart } satisfies Integration
diff --git a/app/integration/index.ts b/app/integration/index.ts
new file mode 100644
index 0000000..4b9e69f
--- /dev/null
+++ b/app/integration/index.ts
@@ -0,0 +1,57 @@
+import docker from './docker'
+import proc from './proc'
+
+export interface Integration {
+ preflight: () => Promise
+ sighup?: () => Promise
+ restart?: () => Promise
+}
+
+// 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}`)
+ }
+ }
+}
diff --git a/app/integration/proc.ts b/app/integration/proc.ts
new file mode 100644
index 0000000..ecc0ec0
--- /dev/null
+++ b/app/integration/proc.ts
@@ -0,0 +1,80 @@
+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 '.'
+
+// 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 { preflight, sighup } satisfies Integration
diff --git a/app/routes/_data.acls._index/route.tsx b/app/routes/_data.acls._index/route.tsx
index f8c3757..c6f926b 100644
--- a/app/routes/_data.acls._index/route.tsx
+++ b/app/routes/_data.acls._index/route.tsx
@@ -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 })
diff --git a/app/routes/_data.dns._index/route.tsx b/app/routes/_data.dns._index/route.tsx
index a91d878..9ca30fe 100644
--- a/app/routes/_data.dns._index/route.tsx
+++ b/app/routes/_data.dns._index/route.tsx
@@ -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
await patchConfig(data)
- await restartHeadscale()
+
+ if (context.integration?.restart) {
+ await context.integration.restart()
+ }
+
return json({ success: true })
}
diff --git a/app/routes/_data.tsx b/app/routes/_data.tsx
index a7222a5..8f51763 100644
--- a/app/routes/_data.tsx
+++ b/app/routes/_data.tsx
@@ -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'),
}
}
diff --git a/app/utils/config/headplane.ts b/app/utils/config/headplane.ts
index 4e3f061..83646fe 100644
--- a/app/utils/config/headplane.ts
+++ b/app/utils/config/headplane.ts
@@ -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
@@ -74,9 +71,9 @@ export async function loadContext(): Promise {
context = {
headscaleUrl,
cookieSecret,
+ integration: await checkIntegration(),
config: await checkConfig(path, config),
acl: await checkAcl(config),
- docker: await checkDocker(),
oidc: await checkOidc(config),
}
@@ -163,53 +160,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
diff --git a/app/utils/docker.ts b/app/utils/docker.ts
deleted file mode 100644
index f9a8e1e..0000000
--- a/app/utils/docker.ts
+++ /dev/null
@@ -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)
- }
- }
-}