diff --git a/CHANGELOG.md b/CHANGELOG.md index 084cc895..0081a90c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Next +- Fixed `server.data_path`, `headscale.config_path`, `headscale.dns_records_path` and `headscale.tls_cert_path` being silently lowercased, which pointed Headplane at a different location for any path containing a capital letter (closes [#612](https://github.com/tale/headplane/issues/612)). - Fixed the Headplane agent falling back to an interactive Tailscale login. The agent now starts with a pre-auth-key, preserves its existing state across restarts, and auto-approves itself when Headscale requires manual approval (closes [#582](https://github.com/tale/headplane/issues/582)). - Fixed creating pre-auth keys with an expiry of 1000 days or more. The number input submitted its locale-formatted value (`365,000`, `365 000`, `365.000`), which either failed with a 500 or silently created a key with a truncated expiry. The raw value is now submitted and the server rejects malformed expiries with a 400 (closes [#596](https://github.com/tale/headplane/issues/596)). diff --git a/app/server/config/config-schema.ts b/app/server/config/config-schema.ts index ef9c1c9b..303af2b0 100644 --- a/app/server/config/config-schema.ts +++ b/app/server/config/config-schema.ts @@ -35,7 +35,7 @@ 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/"', + data_path: 'string = "/var/lib/headplane/"', info_secret: "string?", cookie_secret: "(32 <= string <= 32)", @@ -65,7 +65,7 @@ const partialServerConfig = type({ host: "string.ip?", port: "number.integer?", base_url: "string.url?", - data_path: "string.lower?", + data_path: "string?", info_secret: "string?", cookie_secret: "(32 <= string <= 32)?", @@ -94,10 +94,10 @@ const headscaleConfig = type({ .pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v)) .optional(), api_key: "string?", - config_path: "string.lower?", + config_path: "string?", config_strict: "boolean = true", - dns_records_path: "string.lower?", - tls_cert_path: "string.lower?", + dns_records_path: "string?", + tls_cert_path: "string?", }); const partialHeadscaleConfig = type({ @@ -108,10 +108,10 @@ const partialHeadscaleConfig = type({ .pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v)) .optional(), api_key: "string?", - config_path: "string.lower?", + config_path: "string?", config_strict: "boolean?", - dns_records_path: "string.lower?", - tls_cert_path: "string.lower?", + dns_records_path: "string?", + tls_cert_path: "string?", }); const assignableRole = '"admin" | "network_admin" | "it_admin" | "auditor" | "viewer" | "member"'; diff --git a/tests/unit/config/path-casing.test.ts b/tests/unit/config/path-casing.test.ts new file mode 100644 index 00000000..163814ca --- /dev/null +++ b/tests/unit/config/path-casing.test.ts @@ -0,0 +1,67 @@ +import { dump } from "js-yaml"; +import { beforeEach, describe, expect, test } from "vitest"; + +import { loadConfig } from "~/server/config/load"; + +import { clearFakeFiles, createFakeFile } from "../setup/overlay-fs"; + +const CONFIG_PATH = "/etc/headplane/config.yaml"; + +function writeConfig(overrides: { + server?: Record; + headscale?: Record; +}) { + createFakeFile( + CONFIG_PATH, + dump({ + server: { + host: "0.0.0.0", + port: 3000, + cookie_secret: "abcdefghijklmnopqrstuvwxyz123456", + cookie_secure: false, + ...overrides.server, + }, + headscale: { url: "http://localhost:8080", ...overrides.headscale }, + }), + ); +} + +// `string.lower` rewrites the value it validates rather than rejecting it, so +// declaring a filesystem path with it silently points Headplane at a different +// directory on a case-sensitive filesystem. +describe("filesystem paths preserve their casing", () => { + beforeEach(() => { + clearFakeFiles(); + }); + + test("loadConfig_withMixedCaseDataPath_preservesIt", async () => { + writeConfig({ server: { data_path: "/srv/Headplane" } }); + const config = await loadConfig(CONFIG_PATH); + + expect(config.server.data_path).toBe("/srv/Headplane"); + }); + + test("loadConfig_withMixedCaseHeadscalePaths_preservesThem", async () => { + writeConfig({ + headscale: { + config_path: "/opt/Headscale/config.yaml", + dns_records_path: "/opt/Headscale/records.json", + tls_cert_path: "/opt/Headscale/Cert.pem", + }, + }); + const config = await loadConfig(CONFIG_PATH); + + expect(config.headscale.config_path).toBe("/opt/Headscale/config.yaml"); + expect(config.headscale.dns_records_path).toBe("/opt/Headscale/records.json"); + expect(config.headscale.tls_cert_path).toBe("/opt/Headscale/Cert.pem"); + }); + + test("loadConfig_withMixedCaseCookieDomain_stillLowercasesIt", async () => { + // Not a path: DNS is case-insensitive, so normalising here is correct and + // should stay. + writeConfig({ server: { cookie_domain: "Headplane.Example.COM" } }); + const config = await loadConfig(CONFIG_PATH); + + expect(config.server.cookie_domain).toBe("headplane.example.com"); + }); +});