import { type } from 'arktype'; import log from '~/utils/log'; import DockerIntegration from './integration/docker'; import KubernetesIntegration from './integration/kubernetes'; import ProcIntegration from './integration/proc'; import { deprecatedField } from './utils'; export const pathSupportedKeys = [ 'server.cookie_secret', 'oidc.client_secret', 'oidc.headscale_api_key', 'integration.agent.pre_authkey', ] as const; const serverConfig = type({ host: 'string.ip = "0.0.0.0"', 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', cookie_domain: 'string.lower?', cookie_max_age: 'number.integer = 86400', }); const partialServerConfig = type({ host: 'string.ip?', port: 'number.integer?', base_url: 'string.url?', data_path: 'string.lower?', info_secret: 'string?', cookie_secret: '(32 <= string <= 32)?', cookie_secure: 'boolean?', cookie_domain: 'string.lower?', cookie_max_age: 'number.integer?', }); const headscaleConfig = type({ url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)), public_url: type('string.url') .pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)) .optional(), config_path: 'string.lower?', config_strict: 'boolean = true', dns_records_path: 'string.lower?', tls_cert_path: 'string.lower?', }); const partialHeadscaleConfig = type({ url: type('string.url') .pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)) .optional(), public_url: type('string.url') .pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)) .optional(), config_path: 'string.lower?', config_strict: 'boolean?', dns_records_path: 'string.lower?', tls_cert_path: 'string.lower?', }); const oidcConfig = type({ issuer: 'string.url', client_id: 'string', client_secret: 'string', headscale_api_key: 'string', use_pkce: 'boolean = false', redirect_uri: type('string.url') .pipe((value, ctx) => { log.warn( 'config', '%s is deprecated and will be removed in 0.7.0', ctx.propString, ); const cleanedValue = new URL(value.trim()); if (cleanedValue.pathname.endsWith(`${__PREFIX__}/oidc/callback`)) { cleanedValue.pathname = cleanedValue.pathname.replace( `${__PREFIX__}/oidc/callback`, '/', ); log.warn( 'config', 'Please migrate to using `server.base_url` with a value of "%s"', cleanedValue.toString(), ); } return cleanedValue.toString(); }) .optional(), disable_api_key_login: 'boolean = false', scope: 'string = "openid email profile"', profile_picture_source: '"oidc" | "gravatar" = "oidc"', extra_params: 'Record?', authorization_endpoint: 'string.url?', token_endpoint: 'string.url?', userinfo_endpoint: 'string.url?', token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?', // Old/deprecated options user_storage_file: 'string.lower = "/var/lib/headplane/users.json"', strict_validation: type('unknown').narrow(deprecatedField()).optional(), }); const partialOidcConfig = type({ issuer: 'string.url?', client_id: 'string?', client_secret: 'string?', use_pkce: 'boolean?', headscale_api_key: 'string?', redirect_uri: 'string.url?', disable_api_key_login: 'boolean?', scope: 'string?', extra_params: 'Record?', profile_picture_source: '"oidc" | "gravatar"?', authorization_endpoint: 'string.url?', token_endpoint: 'string.url?', userinfo_endpoint: 'string.url?', token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?', // Old/deprecated options user_storage_file: 'string.lower?', strict_validation: type('unknown').narrow(deprecatedField()).optional(), }); const agentConfig = type({ enabled: 'boolean', host_name: 'string = "headplane-agent"', pre_authkey: 'string', cache_ttl: 'number.integer = 180000', cache_path: 'string = "/var/lib/headplane/agent_cache.json"', executable_path: 'string = "/usr/libexec/headplane/agent"', work_dir: 'string = "/var/lib/headplane/agent"', }); const partialAgentConfig = type({ enabled: 'boolean?', host_name: 'string?', pre_authkey: 'string?', cache_ttl: 'number.integer?', cache_path: 'string?', executable_path: 'string?', work_dir: 'string?', }); const integrationConfig = type({ docker: DockerIntegration.configSchema.full, kubernetes: KubernetesIntegration.configSchema.full, proc: ProcIntegration.configSchema.full, agent: agentConfig.optional(), }).partial(); export const partialIntegrationConfig = type({ docker: DockerIntegration.configSchema.partial, kubernetes: KubernetesIntegration.configSchema.partial, proc: ProcIntegration.configSchema.partial, agent: partialAgentConfig.optional(), }).partial(); export const headplaneConfig = type({ debug: 'boolean = false', server: serverConfig, headscale: headscaleConfig, oidc: oidcConfig.optional(), integration: integrationConfig.optional(), }).onDeepUndeclaredKey('delete'); export const partialHeadplaneConfig = type({ debug: 'boolean?', server: partialServerConfig.optional(), headscale: partialHeadscaleConfig.optional(), oidc: partialOidcConfig.optional(), integration: partialIntegrationConfig.optional(), }); export type HeadplaneConfig = typeof headplaneConfig.infer; export type PartialHeadplaneConfig = typeof partialHeadplaneConfig.infer; type DotNotationToObjects< T extends string, V, > = T extends `${infer K}.${infer Rest}` ? { [P in K]?: DotNotationToObjects } : { [P in `${T}_path`]?: V }; type ObjectDeepMerge = T extends object ? { [K in keyof T]: T[K] extends object ? ObjectDeepMerge : T[K]; } : T; type ConfigWithPathKeys = ObjectDeepMerge< DotNotationToObjects<(typeof pathSupportedKeys)[number], string | undefined> >; export type PartialHeadplaneConfigWithPaths = PartialHeadplaneConfig & ConfigWithPathKeys;