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'
|
import TabLink from './TabLink'
|
||||||
|
|
||||||
interface Properties {
|
interface Properties {
|
||||||
readonly data?: HeadplaneContext & { user?: SessionData['user'] }
|
readonly data?: {
|
||||||
|
acl: HeadplaneContext['acl']
|
||||||
|
config: HeadplaneContext['config']
|
||||||
|
user?: SessionData['user']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LinkProperties {
|
interface LinkProperties {
|
||||||
|
|||||||
@ -2,30 +2,29 @@ import { LinkExternalIcon } from '@primer/octicons-react'
|
|||||||
|
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
/* eslint-disable unicorn/no-keyword-prefix */
|
interface Props {
|
||||||
type Properties = {
|
to: string
|
||||||
readonly to: string;
|
name: string
|
||||||
readonly name: string;
|
children: string
|
||||||
readonly children: string;
|
className?: string
|
||||||
readonly className?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Link({ to, name: alt, children, className }: Properties) {
|
export default function Link({ to, name: alt, children, className }: Props) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={to}
|
href={to}
|
||||||
aria-label={alt}
|
aria-label={alt}
|
||||||
target='_blank'
|
target="_blank"
|
||||||
rel='noreferrer'
|
rel="noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-x-1',
|
'inline-flex items-center gap-x-1',
|
||||||
'text-blue-500 hover:text-blue-700',
|
'text-blue-500 hover:text-blue-700',
|
||||||
'dark:text-blue-400 dark:hover:text-blue-300',
|
'dark:text-blue-400 dark:hover:text-blue-300',
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<LinkExternalIcon className='h-3 w-3'/>
|
<LinkExternalIcon className="h-3 w-3" />
|
||||||
</a>
|
</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 Notice from '~/components/Notice'
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
import { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane'
|
import { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane'
|
||||||
import { sighupHeadscale } from '~/utils/docker'
|
|
||||||
import { getSession } from '~/utils/sessions'
|
import { getSession } from '~/utils/sessions'
|
||||||
|
|
||||||
import Editor from './editor'
|
import Editor from './editor'
|
||||||
@ -47,8 +46,8 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
const data = await request.json() as { acl: string }
|
const data = await request.json() as { acl: string }
|
||||||
await patchAcl(data.acl)
|
await patchAcl(data.acl)
|
||||||
|
|
||||||
if (context.docker) {
|
if (context.integration?.sighup) {
|
||||||
await sighupHeadscale()
|
await context.integration.sighup()
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ success: true })
|
return json({ success: true })
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import Code from '~/components/Code'
|
|||||||
import Notice from '~/components/Notice'
|
import Notice from '~/components/Notice'
|
||||||
import { loadContext } from '~/utils/config/headplane'
|
import { loadContext } from '~/utils/config/headplane'
|
||||||
import { loadConfig, patchConfig } from '~/utils/config/headscale'
|
import { loadConfig, patchConfig } from '~/utils/config/headscale'
|
||||||
import { restartHeadscale } from '~/utils/docker'
|
|
||||||
import { getSession } from '~/utils/sessions'
|
import { getSession } from '~/utils/sessions'
|
||||||
import { useLiveData } from '~/utils/useLiveData'
|
import { useLiveData } from '~/utils/useLiveData'
|
||||||
|
|
||||||
@ -56,7 +55,11 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
|
|
||||||
const data = await request.json() as Record<string, unknown>
|
const data = await request.json() as Record<string, unknown>
|
||||||
await patchConfig(data)
|
await patchConfig(data)
|
||||||
await restartHeadscale()
|
|
||||||
|
if (context.integration?.restart) {
|
||||||
|
await context.integration.restart()
|
||||||
|
}
|
||||||
|
|
||||||
return json({ success: true })
|
return json({ success: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,7 +35,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
|
|
||||||
const context = await loadContext()
|
const context = await loadContext()
|
||||||
return {
|
return {
|
||||||
...context,
|
acl: context.acl,
|
||||||
|
config: context.config,
|
||||||
user: session.get('user'),
|
user: session.get('user'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,11 +8,14 @@ import { resolve } from 'node:path'
|
|||||||
|
|
||||||
import { parse } from 'yaml'
|
import { parse } from 'yaml'
|
||||||
|
|
||||||
|
import { checkIntegration, Integration } from '~/integration'
|
||||||
|
|
||||||
import { HeadscaleConfig, loadConfig } from './headscale'
|
import { HeadscaleConfig, loadConfig } from './headscale'
|
||||||
|
|
||||||
export interface HeadplaneContext {
|
export interface HeadplaneContext {
|
||||||
headscaleUrl: string
|
headscaleUrl: string
|
||||||
cookieSecret: string
|
cookieSecret: string
|
||||||
|
integration: Integration | undefined
|
||||||
|
|
||||||
config: {
|
config: {
|
||||||
read: boolean
|
read: boolean
|
||||||
@ -24,12 +27,6 @@ export interface HeadplaneContext {
|
|||||||
write: boolean
|
write: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
docker?: {
|
|
||||||
url: string
|
|
||||||
sock: boolean
|
|
||||||
container: string
|
|
||||||
}
|
|
||||||
|
|
||||||
oidc?: {
|
oidc?: {
|
||||||
issuer: string
|
issuer: string
|
||||||
client: string
|
client: string
|
||||||
@ -74,9 +71,9 @@ export async function loadContext(): Promise<HeadplaneContext> {
|
|||||||
context = {
|
context = {
|
||||||
headscaleUrl,
|
headscaleUrl,
|
||||||
cookieSecret,
|
cookieSecret,
|
||||||
|
integration: await checkIntegration(),
|
||||||
config: await checkConfig(path, config),
|
config: await checkConfig(path, config),
|
||||||
acl: await checkAcl(config),
|
acl: await checkAcl(config),
|
||||||
docker: await checkDocker(),
|
|
||||||
oidc: await checkOidc(config),
|
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) {
|
async function checkOidc(config?: HeadscaleConfig) {
|
||||||
const disableKeyLogin = process.env.DISABLE_API_KEY_LOGIN === 'true'
|
const disableKeyLogin = process.env.DISABLE_API_KEY_LOGIN === 'true'
|
||||||
const rootKey = process.env.ROOT_API_KEY ?? process.env.API_KEY
|
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