From f0e4868252cea6d81370e4b4b6a08307b29665a4 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sun, 26 May 2024 19:23:24 -0400 Subject: [PATCH] feat: configure support for generic integrations Co-authored-by: Gage Orsburn --- app/components/Header.tsx | 6 +- app/components/Link.tsx | 21 ++--- app/integration/docker.ts | 124 +++++++++++++++++++++++++ app/integration/index.ts | 57 ++++++++++++ app/integration/proc.ts | 80 ++++++++++++++++ app/routes/_data.acls._index/route.tsx | 5 +- app/routes/_data.dns._index/route.tsx | 7 +- app/routes/_data.tsx | 3 +- app/utils/config/headplane.ts | 58 +----------- app/utils/docker.ts | 76 --------------- 10 files changed, 289 insertions(+), 148 deletions(-) create mode 100644 app/integration/docker.ts create mode 100644 app/integration/index.ts create mode 100644 app/integration/proc.ts delete mode 100644 app/utils/docker.ts 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) - } - } -}