diff --git a/app/server/config/config-schema.ts b/app/server/config/config-schema.ts index 6e0875b..2b64c83 100644 --- a/app/server/config/config-schema.ts +++ b/app/server/config/config-schema.ts @@ -100,7 +100,6 @@ const oidcConfig = type({ 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(), }); @@ -123,7 +122,6 @@ const partialOidcConfig = type({ 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(), }); diff --git a/app/server/web/auth.ts b/app/server/web/auth.ts index e447971..e3f1f80 100644 --- a/app/server/web/auth.ts +++ b/app/server/web/auth.ts @@ -1,6 +1,6 @@ -import { createHash } from "node:crypto"; +import { createHash, createHmac } from "node:crypto"; -import { eq, lt } from "drizzle-orm"; +import { eq, lt, sql } from "drizzle-orm"; import { LibSQLDatabase } from "drizzle-orm/libsql/driver"; import { createCookie } from "react-router"; import { ulid } from "ulidx"; @@ -212,8 +212,9 @@ export class AuthService { } /** - * Create a new API key session. The API key is stored server-side - * as a SHA-256 hash — it never appears in the cookie. + * Create a new API key session. A SHA-256 hash of the key is stored + * server-side for auditing. The plaintext key is carried in the + * HMAC-signed cookie so it can be used for Headscale API calls. * Returns the Set-Cookie header value. */ async createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise { @@ -272,9 +273,10 @@ export class AuthService { /** * Find or create a Headplane user by OIDC subject. Returns the - * user ID. Used during OIDC callback to establish identity. + * user ID. The first user ever created is automatically granted + * the owner role (bootstrap). Uses upsert to avoid race conditions. */ - async findOrCreateUser(subject: string, defaultRole: Role): Promise { + async findOrCreateUser(subject: string): Promise { const [existing] = await this.opts.db .select() .from(users) @@ -293,20 +295,25 @@ export class AuthService { await this.opts.db.insert(users).values({ id, sub: subject, - role: defaultRole, - caps: capsForRole(defaultRole), + role: "member", + caps: capsForRole("member"), onboarded: false, }); - return id; - } + // If this is the only user in the table, promote to owner. + // The unique constraint on `sub` prevents two concurrent inserts + // for the same subject; for different subjects, COUNT atomically + // reflects all committed rows so at most one will see count === 1. + const [{ count }] = await this.opts.db.select({ count: sql`count(*)` }).from(users); - /** - * Check if there are any users in the database (for bootstrap). - */ - async hasAnyUsers(): Promise { - const [row] = await this.opts.db.select({ id: users.id }).from(users).limit(1); - return row !== undefined; + if (count === 1) { + await this.opts.db + .update(users) + .set({ role: "owner", caps: capsForRole("owner") }) + .where(eq(users.id, id)); + } + + return id; } /** @@ -377,9 +384,7 @@ export class AuthService { }); const signed = Buffer.from(JSON.stringify(payload)).toString("base64url"); - const hmac = createHash("sha256") - .update(this.opts.secret + signed) - .digest("base64url"); + const hmac = createHmac("sha256", this.opts.secret).update(signed).digest("base64url"); return cookie.serialize(`${signed}.${hmac}`); } @@ -408,9 +413,7 @@ export class AuthService { const signed = raw.slice(0, dotIndex); const hmac = raw.slice(dotIndex + 1); - const expected = createHash("sha256") - .update(this.opts.secret + signed) - .digest("base64url"); + const expected = createHmac("sha256", this.opts.secret).update(signed).digest("base64url"); if (hmac !== expected) { throw new Error("Invalid session cookie signature"); diff --git a/docs/NixOS-options.md b/docs/NixOS-options.md index 275b8ba..fabbfb5 100644 --- a/docs/NixOS-options.md +++ b/docs/NixOS-options.md @@ -5,374 +5,346 @@ All options must be under `services.headplane`. For example: `settings.headscale.config_path` becomes `services.headplane.settings.headscale.config_path`. ## debug -*Description:* Enable debug logging -*Type:* boolean +_Description:_ Enable debug logging -*Default:* `false` +_Type:_ boolean +_Default:_ `false` ## enable -*Description:* Whether to enable headplane. -*Type:* boolean +_Description:_ Whether to enable headplane. -*Default:* `false` +_Type:_ boolean -*Example:* `true` +_Default:_ `false` +_Example:_ `true` ## package -*Description:* The headplane package to use. -*Type:* package +_Description:_ The headplane package to use. -*Default:* `pkgs.headplane` +_Type:_ package +_Default:_ `pkgs.headplane` ## settings -*Description:* Headplane configuration options. Generates a YAML config file. + +_Description:_ Headplane configuration options. Generates a YAML config file. See: https://github.com/tale/headplane/blob/main/config.example.yaml +_Type:_ submodule -*Type:* submodule - -*Default:* `{ }` - +_Default:_ `{ }` ## settings.headscale -*Description:* Headscale specific settings for Headplane integration. -*Type:* submodule +_Description:_ Headscale specific settings for Headplane integration. -*Default:* `{ }` +_Type:_ submodule +_Default:_ `{ }` ## settings.headscale.config_path -*Description:* Path to the Headscale configuration file. + +_Description:_ Path to the Headscale configuration file. This is optional, but HIGHLY recommended for the best experience. If this is read only, Headplane will show your configuration settings in the Web UI, but they cannot be changed. +_Type:_ null or absolute path -*Type:* null or absolute path - -*Default:* `null` - -*Example:* `"/etc/headscale/config.yaml"` +_Default:_ `null` +_Example:_ `"/etc/headscale/config.yaml"` ## settings.headscale.config_strict -*Description:* Headplane internally validates the Headscale configuration + +_Description:_ Headplane internally validates the Headscale configuration to ensure that it changes the configuration in a safe way. If you want to disable this validation, set this to false. +_Type:_ boolean -*Type:* boolean - -*Default:* `true` - +_Default:_ `true` ## settings.headscale.dns_records_path -*Description:* If you are using `dns.extra_records_path` in your Headscale configuration, you need to set this to the path for Headplane to be able to read the DNS records. + +_Description:_ If you are using `dns.extra_records_path` in your Headscale configuration, you need to set this to the path for Headplane to be able to read the DNS records. Ensure that the file is both readable and writable by the Headplane process. When using this, Headplane will no longer need to automatically restart Headscale for DNS record changes. +_Type:_ null or absolute path -*Type:* null or absolute path - -*Default:* `null` - -*Example:* `"/var/lib/headplane/extra_records.json"` +_Default:_ `null` +_Example:_ `"/var/lib/headplane/extra_records.json"` ## settings.headscale.public_url -*Description:* Public URL if differrent. This affects certain parts of the web UI. -*Type:* null or string +_Description:_ Public URL if differrent. This affects certain parts of the web UI. -*Default:* `null` +_Type:_ null or string -*Example:* `"https://headscale.example.com"` +_Default:_ `null` +_Example:_ `"https://headscale.example.com"` ## settings.headscale.tls_cert_path -*Description:* Path to a file containing the TLS certificate. +_Description:_ Path to a file containing the TLS certificate. -*Type:* null or absolute path +_Type:_ null or absolute path -*Default:* `null` - -*Example:* `"config.sops.secrets.tls_cert.path"` +_Default:_ `null` +_Example:_ `"config.sops.secrets.tls_cert.path"` ## settings.headscale.url -*Description:* The URL to your Headscale instance. + +_Description:_ The URL to your Headscale instance. All API requests are routed through this URL. THIS IS NOT the gRPC endpoint, but the HTTP endpoint. IMPORTANT: If you are using TLS this MUST be set to `https://`. +_Type:_ string -*Type:* string - -*Default:* `"http://127.0.0.1:8080"` - -*Example:* `"https://headscale.example.com"` +_Default:_ `"http://127.0.0.1:8080"` +_Example:_ `"https://headscale.example.com"` ## settings.integration -*Description:* Integration configurations for Headplane to interact with Headscale. -*Type:* submodule +_Description:_ Integration configurations for Headplane to interact with Headscale. -*Default:* `{ }` +_Type:_ submodule +_Default:_ `{ }` ## settings.integration.agent -*Description:* Agent configuration for the Headplane agent. -*Type:* submodule +_Description:_ Agent configuration for the Headplane agent. -*Default:* `{ }` +_Type:_ submodule +_Default:_ `{ }` ## settings.integration.agent.cache_path -*Description:* Where to store the agent cache. -*Type:* absolute path +_Description:_ Where to store the agent cache. -*Default:* `"/var/lib/headplane/agent_cache.json"` +_Type:_ absolute path +_Default:_ `"/var/lib/headplane/agent_cache.json"` ## settings.integration.agent.cache_ttl -*Description:* How long to cache agent information (in milliseconds). + +_Description:_ How long to cache agent information (in milliseconds). If you want data to update faster, reduce the TTL, but this will increase the frequency of requests to Headscale. +_Type:_ signed integer -*Type:* signed integer - -*Default:* `180000` - +_Default:_ `180000` ## settings.integration.agent.enabled -*Description:* The Headplane agent allows retrieving information about nodes. + +_Description:_ The Headplane agent allows retrieving information about nodes. This allows the UI to display version, OS, and connectivity data. You will see the Headplane agent in your Tailnet as a node when it connects. +_Type:_ boolean -*Type:* boolean - -*Default:* `false` - +_Default:_ `false` ## settings.integration.agent.host_name -*Description:* Optionally change the name of the agent in the Tailnet -*Type:* string +_Description:_ Optionally change the name of the agent in the Tailnet -*Default:* `"headplane-agent"` +_Type:_ string +_Default:_ `"headplane-agent"` ## settings.integration.agent.package -*Description:* The headplane-agent package to use. -*Type:* package +_Description:_ The headplane-agent package to use. -*Default:* `pkgs.headplane-agent` +_Type:_ package +_Default:_ `pkgs.headplane-agent` ## settings.integration.agent.pre_authkey_path -*Description:* Path to a file containing the agent preauth key. + +_Description:_ Path to a file containing the agent preauth key. To connect to your Tailnet, you need to generate a pre-auth key. This can be done via the web UI or through the `headscale` CLI. +_Type:_ null or absolute path -*Type:* null or absolute path - -*Default:* `null` - -*Example:* `"config.sops.secrets.agent_pre_authkey.path"` +_Default:_ `null` +_Example:_ `"config.sops.secrets.agent_pre_authkey.path"` ## settings.integration.agent.work_dir -*Description:* Do not change this unless you are running a custom deployment. + +_Description:_ Do not change this unless you are running a custom deployment. The work_dir represents where the agent will store its data to be able to automatically reauthenticate with your Tailnet. It needs to be writable by the user running the Headplane process. +_Type:_ absolute path -*Type:* absolute path - -*Default:* `"/var/lib/headplane/agent"` - +_Default:_ `"/var/lib/headplane/agent"` ## settings.integration.proc -*Description:* Native process integration settings. -*Type:* submodule +_Description:_ Native process integration settings. -*Default:* `{ }` +_Type:_ submodule +_Default:_ `{ }` ## settings.integration.proc.enabled -*Description:* Enable "Native" integration that works when Headscale and + +_Description:_ Enable "Native" integration that works when Headscale and Headplane are running outside of a container. There is no additional configuration, but you need to ensure that the Headplane process can terminate the Headscale process. +_Type:_ boolean -*Type:* boolean - -*Default:* `true` - +_Default:_ `true` ## settings.oidc -*Description:* OIDC Configuration for authentication. -*Type:* submodule +_Description:_ OIDC Configuration for authentication. -*Default:* `{ }` +_Type:_ submodule +_Default:_ `{ }` ## settings.oidc.client_id -*Description:* The client ID for the OIDC client. -*Type:* string +_Description:_ The client ID for the OIDC client. -*Default:* `""` +_Type:_ string -*Example:* `"your-client-id"` +_Default:_ `""` +_Example:_ `"your-client-id"` ## settings.oidc.client_secret_path -*Description:* Path to a file containing the OIDC client secret. +_Description:_ Path to a file containing the OIDC client secret. -*Type:* null or absolute path +_Type:_ null or absolute path -*Default:* `null` - -*Example:* `"config.sops.secrets.oidc_client_secret.path"` +_Default:_ `null` +_Example:_ `"config.sops.secrets.oidc_client_secret.path"` ## settings.oidc.disable_api_key_login -*Description:* Whether to disable API key login. -*Type:* boolean +_Description:_ Whether to disable API key login. -*Default:* `false` +_Type:_ boolean +_Default:_ `false` ## settings.oidc.headscale_api_key_path -*Description:* Path to a file containing the Headscale API key. +_Description:_ Path to a file containing the Headscale API key. -*Type:* null or absolute path +_Type:_ null or absolute path -*Default:* `null` - -*Example:* `"config.sops.secrets.headscale_api_key.path"` +_Default:_ `null` +_Example:_ `"config.sops.secrets.headscale_api_key.path"` ## settings.oidc.issuer -*Description:* URL to OpenID issuer. -*Type:* string +_Description:_ URL to OpenID issuer. -*Default:* `""` +_Type:_ string -*Example:* `"https://provider.example.com/issuer-url"` +_Default:_ `""` +_Example:_ `"https://provider.example.com/issuer-url"` ## settings.oidc.redirect_uri -*Description:* This should point to your publicly accessible URL + +_Description:_ This should point to your publicly accessible URL for your Headplane instance with /admin/oidc/callback. +_Type:_ string -*Type:* string - -*Default:* `""` - -*Example:* `"https://headscale.example.com/admin/oidc/callback"` +_Default:_ `""` +_Example:_ `"https://headscale.example.com/admin/oidc/callback"` ## settings.oidc.token_endpoint_auth_method -*Description:* The token endpoint authentication method. -*Type:* one of "client_secret_post", "client_secret_basic", "client_secret_jwt" +_Description:_ The token endpoint authentication method. -*Default:* `"client_secret_post"` - - -## settings.oidc.user_storage_file -*Description:* Path to a file containing the users and their permissions for Headplane. - - -*Type:* absolute path - -*Default:* `"/var/lib/headplane/users.json"` - -*Example:* `"/var/lib/headplane/users.json"` +_Type:_ one of "client_secret_post", "client_secret_basic", "client_secret_jwt" +_Default:_ `"client_secret_post"` ## settings.server -*Description:* Server configuration for Headplane web application. -*Type:* submodule +_Description:_ Server configuration for Headplane web application. -*Default:* `{ }` +_Type:_ submodule +_Default:_ `{ }` ## settings.server.cookie_secret_path -*Description:* Path to a file containing the cookie secret. + +_Description:_ Path to a file containing the cookie secret. The secret must be exactly 32 characters long. +_Type:_ null or absolute path -*Type:* null or absolute path - -*Default:* `null` - -*Example:* `"config.sops.secrets.headplane_cookie.path"` +_Default:_ `null` +_Example:_ `"config.sops.secrets.headplane_cookie.path"` ## settings.server.cookie_secure -*Description:* Should the cookies only work over HTTPS? + +_Description:_ Should the cookies only work over HTTPS? Set to false if running via HTTP without a proxy. Recommended to be true in production. +_Type:_ boolean -*Type:* boolean - -*Default:* `true` - +_Default:_ `true` ## settings.server.data_path -*Description:* The path to persist Headplane specific data. + +_Description:_ The path to persist Headplane specific data. All data going forward is stored in this directory, including the internal database and any cache related files. Data formats prior to 0.6.1 will automatically be migrated. +_Type:_ absolute path -*Type:* absolute path - -*Default:* `"/var/lib/headplane"` - -*Example:* `"/var/lib/headplane"` +_Default:_ `"/var/lib/headplane"` +_Example:_ `"/var/lib/headplane"` ## settings.server.host -*Description:* The host address to bind to. -*Type:* string +_Description:_ The host address to bind to. -*Default:* `"127.0.0.1"` +_Type:_ string -*Example:* `"0.0.0.0"` +_Default:_ `"127.0.0.1"` +_Example:_ `"0.0.0.0"` ## settings.server.port -*Description:* The port to listen on. -*Type:* 16 bit unsigned integer; between 0 and 65535 (both inclusive) +_Description:_ The port to listen on. -*Default:* `3000` +_Type:_ 16 bit unsigned integer; between 0 and 65535 (both inclusive) +_Default:_ `3000` diff --git a/nix/options.nix b/nix/options.nix index 20ee37c..b53dd9e 100644 --- a/nix/options.nix +++ b/nix/options.nix @@ -343,15 +343,6 @@ in { example = "config.sops.secrets.headscale_api_key.path"; }; - user_storage_file = mkOption { - type = types.path; - default = "/var/lib/headplane/users.json"; - description = '' - Path to a file containing the users and their permissions for Headplane. - ''; - example = "/var/lib/headplane/users.json"; - }; - use_pkce = mkOption { type = types.bool; default = false;