From 4409eafa8061214b5da75f4b864ac660cca5fe64 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 21 May 2025 11:08:33 +0200 Subject: [PATCH 1/2] db: add sqlite "source of truth" schema Signed-off-by: Kristoffer Dalby --- hscontrol/db/schema.sql | 109 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 hscontrol/db/schema.sql diff --git a/hscontrol/db/schema.sql b/hscontrol/db/schema.sql new file mode 100644 index 00000000..e54dc028 --- /dev/null +++ b/hscontrol/db/schema.sql @@ -0,0 +1,109 @@ +-- This file is the representation of the SQLite schema of Headscale. +-- It is the "source of truth" and is used to validate any migrations +-- that are run against the database to ensure it ends in the expected state. + +CREATE TABLE migrations(id text,PRIMARY KEY(id)); + +CREATE TABLE users( + id integer PRIMARY KEY AUTOINCREMENT, + name text, + display_name text, + email text, + provider_identifier text, + provider text, + profile_pic_url text, + + created_at datetime, + updated_at datetime, + deleted_at datetime +); +CREATE INDEX idx_users_deleted_at ON users(deleted_at); + + +-- The following three UNIQUE indexes work together to enforce the user identity model: +-- +-- 1. Users can be either local (provider_identifier is NULL) or from external providers (provider_identifier set) +-- 2. Each external provider identifier must be unique across the system +-- 3. Local usernames must be unique among local users +-- 4. The same username can exist across different providers with different identifiers +-- +-- Examples: +-- - Can create local user "alice" (provider_identifier=NULL) +-- - Can create external user "alice" with GitHub (name="alice", provider_identifier="alice_github") +-- - Can create external user "alice" with Google (name="alice", provider_identifier="alice_google") +-- - Cannot create another local user "alice" (blocked by idx_name_no_provider_identifier) +-- - Cannot create another user with provider_identifier="alice_github" (blocked by idx_provider_identifier) +-- - Cannot create user "bob" with provider_identifier="alice_github" (blocked by idx_name_provider_identifier) +CREATE UNIQUE INDEX idx_provider_identifier ON users( + provider_identifier +) WHERE provider_identifier IS NOT NULL; +CREATE UNIQUE INDEX idx_name_provider_identifier ON users( + name, + provider_identifier +); +CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users( + name +) WHERE provider_identifier IS NULL; + +CREATE TABLE pre_auth_keys( + id integer PRIMARY KEY AUTOINCREMENT, + key text, + user_id integer, + reusable numeric, + ephemeral numeric DEFAULT false, + used numeric DEFAULT false, + tags text, + expiration datetime, + + created_at datetime, + + CONSTRAINT fk_pre_auth_keys_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE TABLE api_keys( + id integer PRIMARY KEY AUTOINCREMENT, + prefix text, + hash blob, + expiration datetime, + last_seen datetime, + + created_at datetime +); +CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(prefix); + +CREATE TABLE IF NOT EXISTS "nodes"( + id integer PRIMARY KEY AUTOINCREMENT, + machine_key text, + node_key text, + disco_key text, + + endpoints text, + host_info text, + ipv4 text, + ipv6 text, + hostname text, + given_name varchar(63), + user_id integer, + register_method text, + forced_tags text, + auth_key_id integer, + last_seen datetime, + expiry datetime, + + created_at datetime, + updated_at datetime, + deleted_at datetime, + + CONSTRAINT fk_nodes_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_nodes_auth_key FOREIGN KEY(auth_key_id) REFERENCES pre_auth_keys(id) +); + +CREATE TABLE policies( + id integer PRIMARY KEY AUTOINCREMENT, + data text, + + created_at datetime, + updated_at datetime, + deleted_at datetime +); +CREATE INDEX idx_policies_deleted_at ON policies(deleted_at); From d821c9040affa992e5692f519c15cfe412a1ae8a Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Wed, 21 May 2025 11:08:55 +0200 Subject: [PATCH 2/2] db: compare schema after migration against truth Signed-off-by: Kristoffer Dalby --- flake.nix | 2 +- go.mod | 2 +- go.sum | 2 ++ hscontrol/db/db.go | 29 +++++++++++++++++++++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/flake.nix b/flake.nix index 21304ab9..4f2d4c41 100644 --- a/flake.nix +++ b/flake.nix @@ -30,7 +30,7 @@ # When updating go.mod or go.sum, a new sha will need to be calculated, # update this if you have a mismatch after doing a change to those files. - vendorHash = "sha256-dR8xmUIDMIy08lhm7r95GNNMAbXv4qSH3v9HR40HlNk="; + vendorHash = "sha256-3OKZxOIY5f06Uk9TlYXS16Dtwbnli1KeZfK9UGtjjSc="; subPackages = ["cmd/headscale"]; diff --git a/go.mod b/go.mod index 260f3950..c879d1e9 100644 --- a/go.mod +++ b/go.mod @@ -111,7 +111,7 @@ require ( github.com/containerd/console v1.0.4 // indirect github.com/containerd/continuity v0.4.5 // indirect github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect - github.com/creachadair/mds v0.24.1 // indirect + github.com/creachadair/mds v0.24.3 // indirect github.com/dblohm7/wingoes v0.0.0-20240123200102-b75a8a7d7eb0 // indirect github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect github.com/docker/cli v28.1.1+incompatible // indirect diff --git a/go.sum b/go.sum index 2759bbb1..8ca3f56b 100644 --- a/go.sum +++ b/go.sum @@ -120,6 +120,8 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creachadair/mds v0.24.1 h1:bzL4ItCtAUxxO9KkotP0PVzlw4tnJicAcjPu82v2mGs= github.com/creachadair/mds v0.24.1/go.mod h1:ArfS0vPHoLV/SzuIzoqTEZfoYmac7n9Cj8XPANHocvw= +github.com/creachadair/mds v0.24.3 h1:X7cM2ymZSyl4IVWnfyXLxRXMJ6awhbcWvtLPhfnTaqI= +github.com/creachadair/mds v0.24.3/go.mod h1:0oeHt9QWu8VfnmskOL4zi2CumjEvB29ScmtOmdrhFeU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0= diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index bab0061e..a47760f2 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -3,6 +3,7 @@ package db import ( "context" "database/sql" + _ "embed" "encoding/json" "errors" "fmt" @@ -18,6 +19,7 @@ import ( "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" "github.com/rs/zerolog/log" + "github.com/tailscale/squibble" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" @@ -27,6 +29,9 @@ import ( "zgo.at/zcache/v2" ) +//go:embed schema.sql +var dbSchema string + func init() { schema.RegisterSerializer("text", TextSerialiser{}) } @@ -725,6 +730,30 @@ AND auth_key_id NOT IN ( log.Fatal().Err(err).Msgf("Migration failed: %v", err) } + // Validate that the schema ends up in the expected state. + // This is currently only done on sqlite as squibble does not + // support Postgres and we use our sqlite schema as our source of + // truth. + if cfg.Type == types.DatabaseSqlite { + sqlConn, err := dbConn.DB() + if err != nil { + return nil, fmt.Errorf("getting DB from gorm: %w", err) + } + + // or else it blocks... + sqlConn.SetMaxIdleConns(100) + sqlConn.SetMaxOpenConns(100) + defer sqlConn.SetMaxIdleConns(1) + defer sqlConn.SetMaxOpenConns(1) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := squibble.Validate(ctx, sqlConn, dbSchema); err != nil { + return nil, fmt.Errorf("validating schema: %w", err) + } + } + db := HSDatabase{ DB: dbConn, cfg: &cfg,