feat: configure support for generic integrations
Co-authored-by: Gage Orsburn <gageorsburn@live.com>
This commit is contained in:
parent
1a30185047
commit
f0e4868252
@ -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 {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
124
app/integration/docker.ts
Normal file
124
app/integration/docker.ts
Normal file
@ -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
|
||||
57
app/integration/index.ts
Normal file
57
app/integration/index.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import docker from './docker'
|
||||
import proc from './proc'
|
||||
|
||||
export interface Integration {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
80
app/integration/proc.ts
Normal file
80
app/integration/proc.ts
Normal file
@ -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
|
||||
@ -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,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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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'),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<HeadplaneContext> {
|
||||
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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user