diff --git a/app/integration/docker.ts b/app/integration/docker.ts index d2e8d68..473373a 100644 --- a/app/integration/docker.ts +++ b/app/integration/docker.ts @@ -4,6 +4,7 @@ import { setTimeout } from 'node:timers/promises' import { Client } from 'undici' import { HeadscaleError, pull } from '~/utils/headscale' +import log from '~/utils/log' import { createIntegration } from './integration' @@ -28,19 +29,25 @@ export default createIntegration({ .toLowerCase() if (!container || container.length === 0) { + log.error('INTG', 'Missing HEADSCALE_CONTAINER variable') return false } + log.info('INTG', 'Using container: %s', container) const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock' let url: URL | undefined try { 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 } @@ -49,8 +56,10 @@ export default createIntegration({ 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 } @@ -60,8 +69,14 @@ export default createIntegration({ // Check if the socket is accessible if (url.protocol === 'unix:') { try { - await access(path, constants.R_OK) + 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 } @@ -70,7 +85,7 @@ export default createIntegration({ }) } - return client === undefined + return client !== undefined }, onAclChange: async ({ client, container, maxAttempts }) => { @@ -78,6 +93,8 @@ export default createIntegration({ return } + log.info('INTG', 'Sending SIGHUP to Headscale via Docker') + let attempts = 0 while (attempts <= maxAttempts) { const response = await client.request({ @@ -104,6 +121,8 @@ export default createIntegration({ return } + log.info('INTG', 'Restarting Headscale via Docker') + let attempts = 0 while (attempts <= maxAttempts) { const response = await client.request({ diff --git a/app/integration/index.ts b/app/integration/index.ts index 801eee0..24b7dd0 100644 --- a/app/integration/index.ts +++ b/app/integration/index.ts @@ -1,10 +1,13 @@ +import log from '~/utils/log' + import dockerIntegration from './docker' +import { IntegrationFactory } from './integration' import kubernetesIntegration from './kubernetes' import procIntegration from './proc' export * from './integration' -export function loadIntegration() { +export async function loadIntegration() { let integration = process.env.HEADSCALE_INTEGRATION ?.trim() .toLowerCase() @@ -18,26 +21,55 @@ export function loadIntegration() { } if (!integration) { - console.log('Running Headplane without any integrations') + log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION') return } + let integrationFactory: IntegrationFactory | undefined switch (integration.toLowerCase().trim()) { case 'docker': { - return dockerIntegration + integrationFactory = dockerIntegration + break } case 'proc': case 'native': case 'linux': { - return procIntegration + integrationFactory = procIntegration + break } case 'kubernetes': case 'k8s': { - return kubernetesIntegration + integrationFactory = kubernetesIntegration + break + } + + default: { + log.error('INTG', 'Unknown integration: %s', integration) + throw new Error(`Unknown integration: ${integration}`) } } - console.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 } diff --git a/app/integration/kubernetes.ts b/app/integration/kubernetes.ts index 904c4b3..b2cc881 100644 --- a/app/integration/kubernetes.ts +++ b/app/integration/kubernetes.ts @@ -5,6 +5,8 @@ import { kill } from 'node:process' import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node' +import log from '~/utils/log' + import { createIntegration } from './integration' interface Context { @@ -18,6 +20,7 @@ export default createIntegration({ }, isAvailable: async ({ pid }) => { if (platform() !== 'linux') { + log.error('INTG', 'Kubernetes is only available on Linux') return false } @@ -25,7 +28,7 @@ export default createIntegration({ try { const files = await readdir(svcRoot) if (files.length === 0) { - console.error('No Kubernetes service account found') + log.error('INTG', 'Kubernetes service account not found') return false } @@ -37,11 +40,11 @@ export default createIntegration({ ] if (!expectedFiles.every(file => mappedFiles.has(file))) { - console.error('Kubernetes service account is incomplete') + log.error('INTG', 'Malformed Kubernetes service account') return false } } catch (error) { - console.error('Failed to access Kubernetes service account', error) + log.error('INTG', 'Failed to access %s: %s', svcRoot, error) return false } @@ -52,16 +55,16 @@ export default createIntegration({ // Some very ugly nesting but it's necessary if (process.env.HEADSCALE_INTEGRATION_UNSTRICT === 'true') { - console.warn('Skipping strict Kubernetes integration check') + log.warn('INTG', 'Skipping strict Pod status check') } else { const pod = process.env.POD_NAME if (!pod) { - console.error('No pod name found (POD_NAME)') + log.error('INTG', 'Missing POD_NAME variable') return false } if (pod.trim().length === 0) { - console.error('Pod name is empty') + log.error('INTG', 'Pod name is empty') return false } @@ -69,29 +72,57 @@ export default createIntegration({ 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) { - console.error('Failed to read pod', response.statusCode) + log.error('INTG', 'Failed to read pod info: http %d', + response.statusCode, + ) return false } const shared = body.spec?.shareProcessNamespace if (shared === undefined) { - console.error('Pod does not have shareProcessNamespace set') + log.error( + 'INTG', + 'Pod does not have spec.shareProcessNamespace set', + ) return false } if (!shared) { - console.error('Pod has disabled shareProcessNamespace') + log.error( + 'INTG', + 'Pod has set but disabled spec.shareProcessNamespace', + ) return false } + + log.info('INTG', 'Pod %s enabled shared processes', pod) } catch (error) { - console.error('Failed to check pod', error) + log.error('INTG', 'Failed to read pod info: %s', error) return false } } @@ -125,21 +156,23 @@ export default createIntegration({ } if (pids.length > 1) { - console.warn('Found multiple Headscale processes', pids) - console.log('Disabling the /proc integration') + log.error('INTG', 'Found %d Headscale processes: %s', + pids.length, + pids.join(', '), + ) return false } if (pids.length === 0) { - console.warn('Could not find Headscale process') - console.log('Disabling the /proc integration') + log.error('INTG', 'Could not find Headscale process') return false } pid = pids[0] - console.log('Found Headscale process', pid) + log.info('INTG', 'Found Headscale process with PID: %d', pid) return true } catch { + log.error('INTG', 'Failed to read /proc') return false } }, @@ -149,6 +182,7 @@ export default createIntegration({ return } + log.info('INTG', 'Sending SIGHUP to Headscale') kill(pid, 'SIGHUP') }, @@ -157,6 +191,7 @@ export default createIntegration({ return } + log.info('INTG', 'Sending SIGTERM to Headscale') kill(pid, 'SIGTERM') }, }) diff --git a/app/integration/proc.ts b/app/integration/proc.ts index 73250d2..02c556e 100644 --- a/app/integration/proc.ts +++ b/app/integration/proc.ts @@ -3,6 +3,8 @@ import { platform } from 'node:os' import { join, resolve } from 'node:path' import { kill } from 'node:process' +import log from '~/utils/log' + import { createIntegration } from './integration' interface Context { @@ -16,6 +18,7 @@ export default createIntegration({ }, isAvailable: async ({ pid }) => { if (platform() !== 'linux') { + log.error('INTG', '/proc is only available on Linux') return false } @@ -48,21 +51,23 @@ export default createIntegration({ } if (pids.length > 1) { - console.warn('Found multiple Headscale processes', pids) - console.log('Disabling the /proc integration') + log.error('INTG', 'Found %d Headscale processes: %s', + pids.length, + pids.join(', '), + ) return false } if (pids.length === 0) { - console.warn('Could not find Headscale process') - console.log('Disabling the /proc integration') + log.error('INTG', 'Could not find Headscale process') return false } pid = pids[0] - console.log('Found Headscale process', pid) + log.info('INTG', 'Found Headscale process with PID: %d', pid) return true } catch { + log.error('INTG', 'Failed to read /proc') return false } }, @@ -72,6 +77,7 @@ export default createIntegration({ return } + log.info('INTG', 'Sending SIGHUP to Headscale') kill(pid, 'SIGHUP') }, }) diff --git a/app/utils/log.ts b/app/utils/log.ts new file mode 100644 index 0000000..b9c4775 --- /dev/null +++ b/app/utils/log.ts @@ -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) +}