From 295dd430599aeec9dcab206590f4b32d76247208 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sat, 6 Dec 2025 14:58:38 -0500 Subject: [PATCH] feat: add info route --- app/routes.ts | 3 ++ app/routes/util/info.ts | 59 ++++++++++++++++++++++++++++++ app/server/config/config-schema.ts | 2 + config.example.yaml | 10 +++++ 4 files changed, 74 insertions(+) create mode 100644 app/routes/util/info.ts diff --git a/app/routes.ts b/app/routes.ts index e2abb9d..d27e9b6 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -5,6 +5,9 @@ export default [ index('routes/util/redirect.ts'), route('/healthz', 'routes/util/healthz.ts'), + // API Routes + ...prefix('/api', [route('/info', 'routes/util/info.ts')]), + // Authentication Routes route('/login', 'routes/auth/login/page.tsx'), route('/logout', 'routes/auth/logout.ts'), diff --git a/app/routes/util/info.ts b/app/routes/util/info.ts new file mode 100644 index 0000000..20b2ec9 --- /dev/null +++ b/app/routes/util/info.ts @@ -0,0 +1,59 @@ +import { versions } from 'node:process'; +import { data } from 'react-router'; +import type { Route } from './+types/info'; + +export async function loader({ request, context }: Route.LoaderArgs) { + if (context.config.server.info_secret == null) { + throw data( + { + status: 'Forbidden', + }, + 403, + ); + } + + const bearer = request.headers.get('Authorization') ?? ''; + if (!bearer.startsWith('Bearer ')) { + throw data( + { + status: 'Unauthorized', + }, + 401, + ); + } + + const token = bearer.slice('Bearer '.length).trim(); + if (token !== context.config.server.info_secret) { + throw data( + { + status: 'Forbidden', + }, + 403, + ); + } + + // Use a fake API key for healthcheck + const api = context.hsApi.getRuntimeClient('fake-api-key'); + const healthy = await api.isHealthy(); + + const body = { + status: healthy ? 'OK' : 'ERROR', + headplane_version: __VERSION__, + headscale_canonical_version: healthy ? context.hsApi.apiVersion : 'unknown', + internal_versions: { + node: versions.node, + v8: versions.v8, + uv: versions.uv, + zlib: versions.zlib, + openssl: versions.openssl, + libc: versions.libc, + }, + }; + + return new Response(JSON.stringify(body), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); +} diff --git a/app/server/config/config-schema.ts b/app/server/config/config-schema.ts index 7e03b1f..8a6e71b 100644 --- a/app/server/config/config-schema.ts +++ b/app/server/config/config-schema.ts @@ -17,6 +17,7 @@ const serverConfig = type({ port: 'number.integer = 3000', base_url: 'string.url?', data_path: 'string.lower = "/var/lib/headplane/"', + info_secret: 'string?', cookie_secret: '(32 <= string <= 32)', cookie_secure: 'boolean = true', @@ -29,6 +30,7 @@ const partialServerConfig = type({ port: 'number.integer?', base_url: 'string.url?', data_path: 'string.lower?', + info_secret: 'string?', cookie_secret: '(32 <= string <= 32)?', cookie_secure: 'boolean?', diff --git a/config.example.yaml b/config.example.yaml index 541fa27..ae96b79 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -35,6 +35,16 @@ server: # PLEASE ensure this directory is mounted if running in Docker. data_path: "/var/lib/headplane" + # The info secret is optional and allows access to certain debug endpoints + # that may expose sensitive information about your Headplane instance. + # + # As of now, this protects the /api/info endpoint which exposes details about + # the Headplane and Headscale versions in use. In the future, more endpoints + # may be protected by this secret. + # + # If not set, these endpoints will be disabled. + # info_secret: "" + # Headscale specific settings to allow Headplane to talk # to Headscale and access deep integration features headscale: