+
+ {/* */}
+ {/* }>
{() => (
)}
-
+ */}
>
);
}
diff --git a/app/server/README.md b/app/server/README.md
index 3c30f4f..a14d0b7 100644
--- a/app/server/README.md
+++ b/app/server/README.md
@@ -18,4 +18,5 @@ server
├── web/
│ ├── agent.ts: Handles setting up the agent WebSocket if needed.
│ ├── oidc.ts: Loads and validates an OIDC configuration (if available).
+│ ├── roles.ts: Contains information about authentication permissions.
│ ├── sessions.ts: Initializes the session store and methods to manage it.
diff --git a/app/server/config/schema.ts b/app/server/config/schema.ts
index c4e3c6c..5b9be8a 100644
--- a/app/server/config/schema.ts
+++ b/app/server/config/schema.ts
@@ -27,6 +27,7 @@ const oidcConfig = type({
token_endpoint_auth_method:
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"',
redirect_uri: 'string.url?',
+ user_storage_file: 'string = "/var/lib/headplane/users.json"',
disable_api_key_login: stringToBool,
headscale_api_key: 'string',
strict_validation: stringToBool.default(true),
diff --git a/app/server/index.ts b/app/server/index.ts
index 78c14b8..e3535ab 100644
--- a/app/server/index.ts
+++ b/app/server/index.ts
@@ -40,12 +40,15 @@ const appLoadContext = {
),
// TODO: Better cookie options in config
- sessions: createSessionStorage({
- name: '_hp_session',
- maxAge: 60 * 60 * 24, // 24 hours
- secure: config.server.cookie_secure,
- secrets: [config.server.cookie_secret],
- }),
+ sessions: await createSessionStorage(
+ {
+ name: '_hp_session',
+ maxAge: 60 * 60 * 24, // 24 hours
+ secure: config.server.cookie_secure,
+ secrets: [config.server.cookie_secret],
+ },
+ config.oidc?.user_storage_file,
+ ),
client: await createApiClient(
config.headscale.url,
diff --git a/app/server/web/roles.ts b/app/server/web/roles.ts
new file mode 100644
index 0000000..abe99b9
--- /dev/null
+++ b/app/server/web/roles.ts
@@ -0,0 +1,144 @@
+export type Capabilities = (typeof Capabilities)[keyof typeof Capabilities];
+export const Capabilities = {
+ // Can access the admin console
+ ui_access: 1 << 0,
+
+ // Read tailnet policy file
+ read_policy: 1 << 1,
+
+ // Write tailnet policy file
+ write_policy: 1 << 2,
+
+ // Read network configurations
+ read_network: 1 << 3,
+
+ // Write network configurations, for example, enable MagicDNS, split DNS,
+ // make subnet, or allow a node to be an exit node, enable HTTPS
+ write_network: 1 << 4,
+
+ // Read feature configuration
+ read_feature: 1 << 5,
+
+ // Write feature configuration, for example, enable Taildrop
+ write_feature: 1 << 6,
+
+ // Configure user & group provisioning
+ configure_iam: 1 << 7,
+
+ // Read machines, for example, see machine names and status
+ read_machines: 1 << 8,
+
+ // Write machines, for example, approve, rename, and remove machines
+ write_machines: 1 << 9,
+
+ // Read users and user roles
+ read_users: 1 << 10,
+
+ // Write users and user roles, for example, remove users,
+ // approve users, make Admin
+ write_users: 1 << 11,
+
+ // Can generate authkeys
+ generate_authkeys: 1 << 12,
+
+ // Can use any tag (without being tag owner)
+ use_tags: 1 << 13,
+
+ // Write tailnet name
+ write_tailnet: 1 << 14,
+
+ // Owner flag
+ owner: 1 << 15,
+} as const;
+
+export type Roles = [keyof typeof Roles];
+export const Roles = {
+ owner:
+ Capabilities.ui_access |
+ Capabilities.read_policy |
+ Capabilities.write_policy |
+ Capabilities.read_network |
+ Capabilities.write_network |
+ Capabilities.read_feature |
+ Capabilities.write_feature |
+ Capabilities.configure_iam |
+ Capabilities.read_machines |
+ Capabilities.write_machines |
+ Capabilities.read_users |
+ Capabilities.write_users |
+ Capabilities.generate_authkeys |
+ Capabilities.use_tags |
+ Capabilities.write_tailnet |
+ Capabilities.owner,
+
+ admin:
+ Capabilities.ui_access |
+ Capabilities.read_policy |
+ Capabilities.write_policy |
+ Capabilities.read_network |
+ Capabilities.write_network |
+ Capabilities.read_feature |
+ Capabilities.write_feature |
+ Capabilities.configure_iam |
+ Capabilities.read_machines |
+ Capabilities.write_machines |
+ Capabilities.read_users |
+ Capabilities.write_users |
+ Capabilities.generate_authkeys |
+ Capabilities.use_tags |
+ Capabilities.write_tailnet,
+
+ network_admin:
+ Capabilities.ui_access |
+ Capabilities.read_policy |
+ Capabilities.write_policy |
+ Capabilities.read_network |
+ Capabilities.write_network |
+ Capabilities.read_feature |
+ Capabilities.read_machines |
+ Capabilities.read_users |
+ Capabilities.generate_authkeys |
+ Capabilities.use_tags |
+ Capabilities.write_tailnet,
+
+ it_admin:
+ Capabilities.ui_access |
+ Capabilities.read_policy |
+ Capabilities.read_network |
+ Capabilities.read_feature |
+ Capabilities.write_feature |
+ Capabilities.configure_iam |
+ Capabilities.read_machines |
+ Capabilities.write_machines |
+ Capabilities.read_users |
+ Capabilities.write_users |
+ Capabilities.generate_authkeys,
+
+ auditor:
+ Capabilities.ui_access |
+ Capabilities.read_policy |
+ Capabilities.read_network |
+ Capabilities.read_feature |
+ Capabilities.read_machines |
+ Capabilities.read_users,
+
+ // Default role for new users with 0 capabilities on the UI side of things
+ member: 0,
+} as const;
+
+export type Role = keyof typeof Roles;
+export type Capability = keyof typeof Capabilities;
+export function hasCapability(role: Role, capability: Capability): boolean {
+ return (Roles[role] & Capabilities[capability]) !== 0;
+}
+
+export function getRoleFromCapabilities(capabilities: Capabilities): Role {
+ const iterable = Roles as Record;
+ for (const role in iterable) {
+ if (iterable[role] === capabilities) {
+ return role as Role;
+ }
+ }
+
+ return 'member';
+}
diff --git a/app/server/web/sessions.ts b/app/server/web/sessions.ts
index d03a43d..b39110d 100644
--- a/app/server/web/sessions.ts
+++ b/app/server/web/sessions.ts
@@ -1,9 +1,13 @@
+import { open, readFile } from 'node:fs/promises';
+import { exit } from 'node:process';
import {
CookieSerializeOptions,
Session,
SessionStorage,
createCookieSessionStorage,
} from 'react-router';
+import log from '~/utils/log';
+import { Capabilities, Roles } from './roles';
export interface AuthSession {
state: 'auth';
@@ -42,7 +46,16 @@ interface CookieOptions {
class Sessionizer {
private storage: SessionStorage;
- constructor(options: CookieOptions) {
+ private caps: Record;
+ private capsPath?: string;
+
+ constructor(
+ options: CookieOptions,
+ caps: Record,
+ capsPath?: string,
+ ) {
+ this.caps = caps;
+ this.capsPath = capsPath;
this.storage = createCookieSessionStorage({
cookie: {
...options,
@@ -71,6 +84,84 @@ class Sessionizer {
return session as Session;
}
+ roleForSubject(subject: string) {
+ const role = this.caps[subject];
+ // We need this in string form based on Object.keys of the roles
+ for (const [key, value] of Object.entries(Roles)) {
+ if (value === role) {
+ return key;
+ }
+ }
+ }
+
+ // Given an OR of capabilities, check if the session has the required
+ // capabilities. If not, return false. Can throw since it calls auth()
+ async check(request: Request, capabilities: Capabilities) {
+ const session = await this.auth(request);
+ const { subject } = session.get('user') ?? {};
+ if (!subject) {
+ return false;
+ }
+
+ // This is the subject we set on API key based sessions. API keys
+ // inherently imply admin access so we return true for all checks.
+ if (subject === 'unknown-non-oauth') {
+ return true;
+ }
+
+ // If the role does not exist, then this is a new subject that we have
+ // not seen before. Since this is new, we set access to the lowest
+ // level by default which is the member role.
+ //
+ // This also allows us to avoid configuring preventing sign ups with
+ // OIDC, since the default sign up logic gives member which does not
+ // have access to the UI whatsoever.
+ const role = this.caps[subject];
+ if (!role) {
+ const memberRole = await this.registerSubject(subject);
+ return (capabilities & memberRole) === capabilities;
+ }
+
+ return (capabilities & role) === capabilities;
+ }
+
+ // This code is very simple, if the user does not exist in the database
+ // file then we register it with the lowest level of access. If the user
+ // database is empty, the first user to sign in will be given the owner
+ // role.
+ private async registerSubject(subject: string) {
+ if (this.caps[subject]) {
+ return this.caps[subject];
+ }
+
+ if (Object.keys(this.caps).length === 0) {
+ log.debug('auth', 'First user registered as owner: %s', subject);
+ this.caps[subject] = Roles.owner;
+ await this.flushUserDatabase();
+ return this.caps[subject];
+ }
+
+ log.debug('auth', 'New user registered as member: %s', subject);
+ this.caps[subject] = Roles.member;
+ await this.flushUserDatabase();
+ return this.caps[subject];
+ }
+
+ private async flushUserDatabase() {
+ if (!this.capsPath) {
+ return;
+ }
+
+ const data = Object.entries(this.caps).map(([u, c]) => ({ u, c }));
+ try {
+ const handle = await open(this.capsPath, 'w');
+ await handle.write(JSON.stringify(data));
+ await handle.close();
+ } catch (error) {
+ log.error('config', 'Error writing user database file: %s', error);
+ }
+ }
+
getOrCreate(request: Request) {
return this.storage.getSession(request.headers.get('cookie')) as Promise<
Session
@@ -86,6 +177,49 @@ class Sessionizer {
}
}
-export function createSessionStorage(options: CookieOptions) {
- return new Sessionizer(options);
+export async function createSessionStorage(
+ options: CookieOptions,
+ usersPath?: string,
+) {
+ const map: Record = {};
+ if (usersPath) {
+ // We need to load our users from the file (default to empty map)
+ // We then translate each user into a capability object using the helper
+ // method defined in the roles.ts file
+ const data = await loadUserFile(usersPath);
+ log.debug('config', 'Loaded %d users from database', data.length);
+
+ for (const user of data) {
+ map[user.u] = user.c;
+ }
+ }
+
+ return new Sessionizer(options, map, usersPath);
+}
+
+async function loadUserFile(path: string) {
+ try {
+ const handle = await open(path, 'w');
+ log.info('config', 'Using user database file at %s', path);
+ await handle.close();
+ } catch (error) {
+ log.info('config', 'User database file not accessible at %s', path);
+ log.debug('config', 'Error details: %s', error);
+ exit(1);
+ }
+
+ try {
+ const data = await readFile(path, 'utf8');
+ const users = JSON.parse(data) as { u?: string; c?: number }[];
+
+ // Never trust user input
+ return users.filter((user) => user.u && user.c) as {
+ u: string;
+ c: number;
+ }[];
+ } catch (error) {
+ log.debug('config', 'Error reading user database file: %s', error);
+ log.debug('config', 'Using empty user database');
+ return [];
+ }
}
diff --git a/app/utils/log.ts b/app/utils/log.ts
index ec733cb..8e99c83 100644
--- a/app/utils/log.ts
+++ b/app/utils/log.ts
@@ -4,7 +4,7 @@
// disable debug logging if the `HEADPLANE_DEBUG_LOG` specifies as such.
const levels = ['info', 'warn', 'error', 'debug'] as const;
-type Category = 'server' | 'config' | 'agent' | 'api';
+type Category = 'server' | 'config' | 'agent' | 'api' | 'auth';
export interface Logger
extends Record<
diff --git a/config.example.yaml b/config.example.yaml
index 39709f3..02ad0d6 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -1,107 +1,111 @@
# Configuration for the Headplane server and web application
server:
- host: "0.0.0.0"
- port: 3000
+ host: "0.0.0.0"
+ port: 3000
- # The secret used to encode and decode web sessions
- # Ensure that this is exactly 32 characters long
- cookie_secret: ""
+ # The secret used to encode and decode web sessions
+ # Ensure that this is exactly 32 characters long
+ cookie_secret: ""
- # Should the cookies only work over HTTPS?
- # Set to false if running via HTTP without a proxy
- # (I recommend this is true in production)
- cookie_secure: true
+ # Should the cookies only work over HTTPS?
+ # Set to false if running via HTTP without a proxy
+ # (I recommend this is true in production)
+ cookie_secure: true
# Headscale specific settings to allow Headplane to talk
# to Headscale and access deep integration features
headscale:
- # 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://`
- url: "http://headscale:5000"
+ # 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://`
+ url: "http://headscale:5000"
- # If you use the TLS configuration in Headscale, and you are not using
- # Let's Encrypt for your certificate, pass in the path to the certificate.
- # (This has no effect `url` does not start with `https://`)
- # tls_cert_path: "/var/lib/headplane/tls.crt"
+ # If you use the TLS configuration in Headscale, and you are not using
+ # Let's Encrypt for your certificate, pass in the path to the certificate.
+ # (This has no effect `url` does not start with `https://`)
+ # tls_cert_path: "/var/lib/headplane/tls.crt"
- # Optional, public URL if they differ
- # This affects certain parts of the web UI
- # public_url: "https://headscale.example.com"
+ # Optional, public URL if they differ
+ # This affects certain parts of the web UI
+ # public_url: "https://headscale.example.com"
- # 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.
- config_path: "/etc/headscale/config.yaml"
+ # 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.
+ config_path: "/etc/headscale/config.yaml"
- # 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.
- config_strict: true
+ # 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.
+ config_strict: true
# Integration configurations for Headplane to interact with Headscale
# Only one of these should be enabled at a time or you will get errors
integration:
- docker:
- enabled: false
- # The name (or ID) of the container running Headscale
- container_name: "headscale"
- # The path to the Docker socket (do not change this if you are unsure)
- # Docker socket paths must start with unix:// or tcp:// and at the moment
- # https connections are not supported.
- socket: "unix:///var/run/docker.sock"
- # Please refer to docs/integration/Kubernetes.md for more information
- # on how to configure the Kubernetes integration. There are requirements in
- # order to allow Headscale to be controlled by Headplane in a cluster.
- kubernetes:
- enabled: false
- # Validates the manifest for the Pod to ensure all of the criteria
- # are set correctly. Turn this off if you are having issues with
- # shareProcessNamespace not being validated correctly.
- validate_manifest: true
- # This should be the name of the Pod running Headscale and Headplane.
- # If this isn't static you should be using the Kubernetes Downward API
- # to set this value (refer to docs/Integrated-Mode.md for more info).
- pod_name: "headscale"
+ docker:
+ enabled: false
+ # The name (or ID) of the container running Headscale
+ container_name: "headscale"
+ # The path to the Docker socket (do not change this if you are unsure)
+ # Docker socket paths must start with unix:// or tcp:// and at the moment
+ # https connections are not supported.
+ socket: "unix:///var/run/docker.sock"
+ # Please refer to docs/integration/Kubernetes.md for more information
+ # on how to configure the Kubernetes integration. There are requirements in
+ # order to allow Headscale to be controlled by Headplane in a cluster.
+ kubernetes:
+ enabled: false
+ # Validates the manifest for the Pod to ensure all of the criteria
+ # are set correctly. Turn this off if you are having issues with
+ # shareProcessNamespace not being validated correctly.
+ validate_manifest: true
+ # This should be the name of the Pod running Headscale and Headplane.
+ # If this isn't static you should be using the Kubernetes Downward API
+ # to set this value (refer to docs/Integrated-Mode.md for more info).
+ pod_name: "headscale"
- # Proc is the "Native" integration that only works when Headscale and
- # Headplane are running outside of a container. There is no configuration,
- # but you need to ensure that the Headplane process can terminate the
- # Headscale process.
- #
- # (If they are both running under systemd as sudo, this will work).
- proc:
- enabled: false
+ # Proc is the "Native" integration that only works when Headscale and
+ # Headplane are running outside of a container. There is no configuration,
+ # but you need to ensure that the Headplane process can terminate the
+ # Headscale process.
+ #
+ # (If they are both running under systemd as sudo, this will work).
+ proc:
+ enabled: false
# OIDC Configuration for simpler authentication
# (This is optional, but recommended for the best experience)
oidc:
- issuer: "https://accounts.google.com"
- client_id: "your-client-id"
+ issuer: "https://accounts.google.com"
+ client_id: "your-client-id"
- # The client secret for the OIDC client
- # Either this or `client_secret_path` must be set for OIDC to work
- client_secret: ""
- # You can alternatively set `client_secret_path` to read the secret from disk.
- # The path specified can resolve environment variables, making integration
- # with systemd's `LoadCredential` straightforward:
- # client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
+ # The client secret for the OIDC client
+ # Either this or `client_secret_path` must be set for OIDC to work
+ client_secret: ""
+ # You can alternatively set `client_secret_path` to read the secret from disk.
+ # The path specified can resolve environment variables, making integration
+ # with systemd's `LoadCredential` straightforward:
+ # client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
- disable_api_key_login: false
- token_endpoint_auth_method: "client_secret_post"
+ disable_api_key_login: false
+ token_endpoint_auth_method: "client_secret_post"
- # If you are using OIDC, you need to generate an API key
- # that can be used to authenticate other sessions when signing in.
- #
- # This can be done with `headscale apikeys create --expiration 999d`
- headscale_api_key: ""
+ # If you are using OIDC, you need to generate an API key
+ # that can be used to authenticate other sessions when signing in.
+ #
+ # This can be done with `headscale apikeys create --expiration 999d`
+ headscale_api_key: ""
- # Optional, but highly recommended otherwise Headplane
- # will attempt to automatically guess this from the issuer
- #
- # This should point to your publicly accessibly URL
- # for your Headplane instance with /admin/oidc/callback
- redirect_uri: "http://localhost:3000/admin/oidc/callback"
+ # Optional, but highly recommended otherwise Headplane
+ # will attempt to automatically guess this from the issuer
+ #
+ # This should point to your publicly accessibly URL
+ # for your Headplane instance with /admin/oidc/callback
+ redirect_uri: "http://localhost:3000/admin/oidc/callback"
+
+ # Stores the users and their permissions for Headplane
+ # This is a path to a JSON file, default is specified below.
+ user_storage_file: "/var/lib/headplane/users.json"