mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
239 lines
7.3 KiB
TypeScript
239 lines
7.3 KiB
TypeScript
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",
|
|
"headscale.api_key",
|
|
"oidc.client_secret",
|
|
"oidc.headscale_api_key",
|
|
] as const;
|
|
|
|
function normalizeStringArray(values: string[]): string[] {
|
|
const seen = new Set<string>();
|
|
const normalized: string[] = [];
|
|
|
|
for (const value of values) {
|
|
const trimmed = value.trim();
|
|
if (trimmed.length === 0 || seen.has(trimmed)) {
|
|
continue;
|
|
}
|
|
|
|
seen.add(trimmed);
|
|
normalized.push(trimmed);
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
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",
|
|
|
|
// TLS termination. When both `tls_cert_path` and `tls_key_path`
|
|
// are provided, Headplane serves HTTPS on `server.port`. When
|
|
// either is set, `cookie_secure` is forced to `true`.
|
|
tls_cert_path: "string?",
|
|
tls_key_path: "string?",
|
|
});
|
|
|
|
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?",
|
|
|
|
tls_cert_path: "string?",
|
|
tls_key_path: "string?",
|
|
});
|
|
|
|
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(),
|
|
api_key: "string?",
|
|
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(),
|
|
api_key: "string?",
|
|
config_path: "string.lower?",
|
|
config_strict: "boolean?",
|
|
dns_records_path: "string.lower?",
|
|
tls_cert_path: "string.lower?",
|
|
});
|
|
|
|
const oidcConfig = type({
|
|
enabled: "boolean = true",
|
|
issuer: "string.url",
|
|
client_id: "string",
|
|
client_secret: "string",
|
|
headscale_api_key: type("string")
|
|
.pipe((value, ctx) => {
|
|
log.warn("config", "%s is deprecated, use headscale.api_key instead", ctx.propString);
|
|
return value;
|
|
})
|
|
.optional(),
|
|
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"',
|
|
subject_claims: type("string[]").pipe(normalizeStringArray).optional(),
|
|
allow_weak_rsa_keys: "boolean = false",
|
|
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
|
|
extra_params: "Record<string, string>?",
|
|
|
|
authorization_endpoint: "string.url?",
|
|
token_endpoint: "string.url?",
|
|
userinfo_endpoint: "string.url?",
|
|
end_session_endpoint: "string.url?",
|
|
post_logout_redirect_uri: "string.url?",
|
|
use_end_session: "boolean = false",
|
|
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
|
|
|
|
// Old/deprecated options
|
|
strict_validation: type("unknown").narrow(deprecatedField()).optional(),
|
|
});
|
|
|
|
const partialOidcConfig = type({
|
|
enabled: "boolean?",
|
|
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?",
|
|
subject_claims: type("string[]").pipe(normalizeStringArray).optional(),
|
|
allow_weak_rsa_keys: "boolean?",
|
|
extra_params: "Record<string, string>?",
|
|
profile_picture_source: '"oidc" | "gravatar"?',
|
|
|
|
authorization_endpoint: "string.url?",
|
|
token_endpoint: "string.url?",
|
|
userinfo_endpoint: "string.url?",
|
|
end_session_endpoint: "string.url?",
|
|
post_logout_redirect_uri: "string.url?",
|
|
use_end_session: "boolean?",
|
|
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
|
|
|
|
// Old/deprecated options
|
|
strict_validation: type("unknown").narrow(deprecatedField()).optional(),
|
|
});
|
|
|
|
const agentConfig = type({
|
|
enabled: "boolean",
|
|
host_name: 'string = "headplane-agent"',
|
|
cache_ttl: "number.integer = 180000",
|
|
executable_path: 'string = "/usr/libexec/headplane/agent"',
|
|
work_dir: 'string = "/var/lib/headplane/agent"',
|
|
pre_authkey: type("unknown").narrow(deprecatedField()).optional(),
|
|
cache_path: type("unknown").narrow(deprecatedField()).optional(),
|
|
});
|
|
|
|
const partialAgentConfig = type({
|
|
enabled: "boolean?",
|
|
host_name: "string?",
|
|
cache_ttl: "number.integer?",
|
|
executable_path: "string?",
|
|
work_dir: "string?",
|
|
pre_authkey: type("unknown").narrow(deprecatedField()).optional(),
|
|
cache_path: type("unknown").narrow(deprecatedField()).optional(),
|
|
});
|
|
|
|
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<Rest, V> }
|
|
: { [P in `${T}_path`]?: V };
|
|
|
|
type ObjectDeepMerge<T> = T extends object
|
|
? {
|
|
[K in keyof T]: T[K] extends object ? ObjectDeepMerge<T[K]> : T[K];
|
|
}
|
|
: T;
|
|
|
|
type ConfigWithPathKeys = ObjectDeepMerge<
|
|
DotNotationToObjects<(typeof pathSupportedKeys)[number], string | undefined>
|
|
>;
|
|
|
|
export type PartialHeadplaneConfigWithPaths = PartialHeadplaneConfig & ConfigWithPathKeys;
|