diff --git a/CHANGELOG.md b/CHANGELOG.md index e035041e3..399121dea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ HTTP API directly. - Expiring or deleting a non-existent pre-auth key now returns an error instead of silently succeeding [#3324](https://github.com/juanfont/headscale/pull/3324) - SSH policy validation errors now prefix their messages with `ssh:` so failures like `ssh: users must be specified` make it clear that the problem comes from an SSH rule violation [#3343](https://github.com/juanfont/headscale/pull/3343) +- Improve systemd service file hardening [#3341](https://github.com/juanfont/headscale/pull/3341) ## 0.29.1 (2026-06-18) diff --git a/config-example.yaml b/config-example.yaml index 28fc617d3..09f0356b8 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -230,12 +230,12 @@ database: # ssl: false ### TLS configuration -# -## Let's encrypt / ACME -# -# headscale supports automatically requesting and setting up +# See: https://headscale.net/stable/ref/tls/ + +## Let's Encrypt / ACME +# Headscale supports automatically requesting and setting up # TLS for a domain with Let's Encrypt. -# + # URL to ACME directory acme_url: https://acme-v02.api.letsencrypt.org/directory @@ -245,15 +245,13 @@ acme_email: "" # Domain name to request a TLS certificate for: tls_letsencrypt_hostname: "" -# Path to store certificates and metadata needed by -# letsencrypt -# For production: +# Path to store certificates and metadata needed by letsencrypt tls_letsencrypt_cache_dir: /var/lib/headscale/cache # Type of ACME challenge to use, currently supported types: # HTTP-01 or TLS-ALPN-01 -# See: https://headscale.net/stable/ref/tls/ tls_letsencrypt_challenge_type: HTTP-01 + # When HTTP-01 challenge is chosen, letsencrypt must set up a # verification endpoint, and it will be listening on: # :http = port 80 @@ -279,6 +277,7 @@ policy: # The mode can be "file" or "database" that defines # where the policies are stored and read from. mode: file + # If the mode is set to "file", the path to a HuJSON file containing policies. path: "" @@ -460,6 +459,7 @@ taildrop: # choice. auto_update: enabled: false + # Advanced performance tuning parameters. # The defaults are carefully chosen and should rarely need adjustment. # Only modify these if you have identified a specific performance issue. diff --git a/docs/ref/policy.md b/docs/ref/policy.md index 3529a81ee..f326d7ecb 100644 --- a/docs/ref/policy.md +++ b/docs/ref/policy.md @@ -158,17 +158,17 @@ devices. Can only be used in policy destinations. { "src": ["boss@"], "dst": ["boss@"], - "ip": "*" + "ip": ["*"] }, { "src": ["dev1@"], "dst": ["dev1@"], - "ip": "*" + "ip": ["*"] }, { "src": ["intern1@"], "dst": ["intern1@"], - "ip": "*" + "ip": ["*"] } ] } diff --git a/docs/setup/upgrade.md b/docs/setup/upgrade.md index e12359cf3..8176e933a 100644 --- a/docs/setup/upgrade.md +++ b/docs/setup/upgrade.md @@ -2,7 +2,7 @@ !!! tip "Required update path" - Its required to update from one stable version to the next (e.g. 0.26.0 → 0.27.1 → 0.28.0) without skipping minor + It's required to update from one stable version to the next (e.g. 0.26.0 → 0.27.1 → 0.28.0) without skipping minor versions in between. You should always pick the latest available patch release. Update an existing Headscale installation to a new version: @@ -23,7 +23,7 @@ upgrading. A full backup of Headscale depends on your individual setup, but belo === "Standard installation" - A installation that follows our [official releases](install/official.md) setup guide uses the following paths: + An installation that follows our [official releases](install/official.md) setup guide uses the following paths: - [Configuration file](../ref/configuration.md): `/etc/headscale/config.yaml` - Data directory: `/var/lib/headscale` @@ -37,7 +37,7 @@ upgrading. A full backup of Headscale depends on your individual setup, but belo === "Container" - A installation that follows our [container](install/container.md) setup guide uses a single source volume directory + An installation that follows our [container](install/container.md) setup guide uses a single source volume directory that contains the configuration file, data directory and the SQLite database. ```console diff --git a/hscontrol/api/v2/acl.go b/hscontrol/api/v2/acl.go index 8129e7670..51bd25ce6 100644 --- a/hscontrol/api/v2/acl.go +++ b/hscontrol/api/v2/acl.go @@ -44,9 +44,8 @@ type setACLInput struct { Tailnet string `path:"tailnet"` IfMatch string `header:"If-Match"` Accept string `header:"Accept"` - // RawBody captures the HuJSON or JSON policy body verbatim; huma feeds the - // raw request bytes here regardless of Content-Type. The declared type only - // shapes the OpenAPI schema. + // RawBody captures the raw HuJSON or JSON policy bytes; huma feeds them here + // regardless of Content-Type. The declared type only shapes the OpenAPI schema. RawBody []byte `contentType:"application/json"` } @@ -183,7 +182,7 @@ func currentPolicy(b Backend) ([]byte, error) { return nil, huma.Error500InternalServerError("unsupported policy mode", nil) } -// streamPolicy writes the policy bytes verbatim with the chosen content type and +// streamPolicy writes the policy bytes as-is with the chosen content type and // a content-addressed ETag, bypassing huma's JSON marshaler so HuJSON survives. func streamPolicy(data []byte, contentType string) *huma.StreamResponse { return &huma.StreamResponse{Body: func(ctx huma.Context) { diff --git a/hscontrol/api/v2/api.go b/hscontrol/api/v2/api.go index 02d9a5c6c..a2a8b887e 100644 --- a/hscontrol/api/v2/api.go +++ b/hscontrol/api/v2/api.go @@ -244,6 +244,9 @@ const ( ScopeFeatureSettings Scope = "feature_settings" ScopeFeatureSettingsRead Scope = "feature_settings:read" + + ScopeUsers Scope = "users" + ScopeUsersRead Scope = "users:read" ) // scopeMetaKey keys the per-operation required Scope in huma.Operation.Metadata. diff --git a/hscontrol/api/v2/errors.go b/hscontrol/api/v2/errors.go index 78241b3d4..7bd25c4f5 100644 --- a/hscontrol/api/v2/errors.go +++ b/hscontrol/api/v2/errors.go @@ -66,6 +66,7 @@ func mapError(msg string, err error) error { switch { case errors.Is(err, gorm.ErrRecordNotFound), errors.Is(err, db.ErrPreAuthKeyNotFound), + errors.Is(err, db.ErrUserNotFound), errors.Is(err, state.ErrNodeNotFound): return huma.Error404NotFound(msg, err) diff --git a/hscontrol/api/v2/settings.go b/hscontrol/api/v2/settings.go index cdd2bea03..58628370a 100644 --- a/hscontrol/api/v2/settings.go +++ b/hscontrol/api/v2/settings.go @@ -16,7 +16,7 @@ func init() { // TailnetSettings is the Tailscale tailnet-settings response. Headscale's config // is file-based and mostly not runtime-mutable, so only a few fields carry a -// real value; the rest report the honest "off"/default. +// real value; the rest report the default "off". type TailnetSettings struct { ACLsExternallyManagedOn bool `json:"aclsExternallyManagedOn"` ACLsExternalLink string `json:"aclsExternalLink"` diff --git a/hscontrol/api/v2/users.go b/hscontrol/api/v2/users.go new file mode 100644 index 000000000..c3d94526d --- /dev/null +++ b/hscontrol/api/v2/users.go @@ -0,0 +1,175 @@ +package apiv2 + +import ( + "context" + "net/http" + "strconv" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/types" +) + +func init() { + registrations = append(registrations, registerUsers) +} + +// Headscale models none of type/role/status and has a single tailnet, so these +// fields are fixed strings. Every account is an active member. +const ( + userTypeMember = "member" + userRoleMember = "member" + userStatusActive = "active" + singleTailnetID = "1" + + // tagTailscaleCompat marks operations ported from the Tailscale API. + tagTailscaleCompat = "Tailscale compat" +) + +// User is the Tailscale user response. Identity fields map from the Headscale +// user; type/role/status/tailnetId are constants (see above); the device fields +// are aggregated from the user's nodes. +type User struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + LoginName string `json:"loginName"` + ProfilePicURL string `json:"profilePicUrl"` + TailnetID string `json:"tailnetId"` + Created time.Time `json:"created"` + Type string `json:"type"` + Role string `json:"role"` + Status string `json:"status"` + DeviceCount int `json:"deviceCount"` + LastSeen time.Time `json:"lastSeen"` + CurrentlyConnected bool `json:"currentlyConnected"` +} + +type ( + userByIDInput struct { + UserID string `doc:"User id (the decimal user id)." path:"id"` + } + listUsersInput struct { + Tailnet string `doc:"Tailnet; must be \"-\" (the single Headscale tailnet)." path:"tailnet"` + Type string `doc:"Filter by user type; Headscale users are all \"member\"." query:"type"` + Role string `doc:"Filter by user role; Headscale users are all \"member\"." query:"role"` + } + + userOutput struct{ Body User } + listUsersOutput struct { + Body struct { + Users []User `json:"users" nullable:"false"` + } + } +) + +func registerUsers(api huma.API, b Backend) { + usersTags := []string{"Users", tagTailscaleCompat} + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "getUser", + Method: http.MethodGet, + Path: "/api/v2/users/{id}", + Summary: "Get a user", + Tags: usersTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeUsersRead), func(ctx context.Context, in *userByIDInput) (*userOutput, error) { + view, err := lookupUser(b, in.UserID) + if err != nil { + return nil, err + } + + return &userOutput{Body: userFromView(b, view)}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "listUsers", + Method: http.MethodGet, + Path: "/api/v2/tailnet/{tailnet}/users", + Summary: "List users", + Tags: usersTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeUsersRead), func(ctx context.Context, in *listUsersInput) (*listUsersOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + out := &listUsersOutput{} + out.Body.Users = []User{} + + // Headscale has only "member" users. A filter for any other type/role + // matches nothing, so return the empty envelope. + if !matchesMember(in.Type) || !matchesMember(in.Role) { + return out, nil + } + + users, err := b.State.ListAllUsers() + if err != nil { + return nil, huma.Error500InternalServerError("listing users", err) + } + + out.Body.Users = make([]User, 0, len(users)) + for i := range users { + out.Body.Users = append(out.Body.Users, userFromView(b, users[i].View())) + } + + return out, nil + }) +} + +// lookupUser resolves a user id to its UserView, mapping a malformed or unknown +// id to 404 (the Tailscale SDK keys IsNotFound off the status code), exactly as +// lookupNode does for devices. +func lookupUser(b Backend, rawID string) (types.UserView, error) { + id, err := parseID(rawID, "user") + if err != nil { + return types.UserView{}, err + } + + user, err := b.State.GetUserByID(types.UserID(id)) + if err != nil { + return types.UserView{}, mapError("looking up user", err) + } + + return user.View(), nil +} + +// matchesMember reports whether an optional type/role filter selects Headscale's +// only user kind. An empty value means "no filter". +func matchesMember(filter string) bool { + return filter == "" || filter == userTypeMember +} + +// userFromView maps a Headscale user onto the Tailscale User through the +// UserView accessors. deviceCount, lastSeen, and currentlyConnected are +// aggregated from the user's nodes in the NodeStore. +func userFromView(b Backend, view types.UserView) User { + u := User{ + ID: strconv.FormatUint(uint64(view.ID()), 10), + DisplayName: view.Display(), + LoginName: view.Username(), + ProfilePicURL: view.ProfilePicURL(), + TailnetID: singleTailnetID, + Created: view.CreatedAt(), + Type: userTypeMember, + Role: userRoleMember, + Status: userStatusActive, + } + + nodes := b.State.ListNodesByUser(types.UserID(view.ID())) + u.DeviceCount = nodes.Len() + + for _, node := range nodes.All() { + if node.IsOnline().Valid() && node.IsOnline().Get() { + u.CurrentlyConnected = true + } + + if ls := node.LastSeen(); ls.Valid() && ls.Get().After(u.LastSeen) { + u.LastSeen = ls.Get() + } + } + + return u +} diff --git a/hscontrol/apiv2_keys_test.go b/hscontrol/apiv2_keys_test.go index 679eb40fe..c71b18cfe 100644 --- a/hscontrol/apiv2_keys_test.go +++ b/hscontrol/apiv2_keys_test.go @@ -8,6 +8,8 @@ import ( "time" "github.com/danielgtaylor/huma/v2/humatest" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" "github.com/juanfont/headscale/hscontrol/types" "github.com/stretchr/testify/assert" @@ -131,17 +133,21 @@ func TestAPIv2Key_Create_Tagged(t *testing.T) { }) // (a) create response — Tailscale Key shape, exact seconds (int64 wire). - assert.Equal(t, "auth", created.KeyType) + // ID/Key/Created/Expires are server-assigned; assert their presence apart. + want := apiv2.Key{ + KeyType: "auth", + Description: "dev access", + ExpirySeconds: 86400, + Capabilities: taggedCaps("tag:test"), + Tags: []string{"tag:test"}, + } + if diff := cmp.Diff(want, created, cmpopts.IgnoreFields(apiv2.Key{}, "ID", "Key", "Created", "Expires")); diff != "" { + t.Errorf("created key mismatch (-want +got):\n%s", diff) + } + assert.NotEmpty(t, created.ID) assert.NotEmpty(t, created.Key, "secret returned on create") - assert.Equal(t, "dev access", created.Description) - assert.Equal(t, int64(86400), created.ExpirySeconds, "seconds, not nanoseconds") - assert.Empty(t, created.UserID, "tagged key presents no owner") - assert.Equal(t, []string{"tag:test"}, created.Capabilities.Devices.Create.Tags) - assert.True(t, created.Capabilities.Devices.Create.Reusable) - assert.True(t, created.Capabilities.Devices.Create.Preauthorized, "echoed on create") assert.NotNil(t, created.Expires) - assert.False(t, created.Invalid) // (c) server-side — the stored key. pak := srvKey(t, app, created.ID) @@ -234,14 +240,18 @@ func TestAPIv2Key_Get(t *testing.T) { }) got := getKey(t, api, created.ID) - assert.Equal(t, created.ID, got.ID) - assert.Empty(t, got.Key, "secret omitted on get") - assert.Equal(t, "dev access", got.Description) - assert.Equal(t, int64(86400), got.ExpirySeconds, "stable across get") - assert.True(t, got.Capabilities.Devices.Create.Reusable) - assert.True(t, got.Capabilities.Devices.Create.Preauthorized, "Headscale always preauthorizes") - assert.Equal(t, []string{"tag:test"}, got.Capabilities.Devices.Create.Tags) - assert.False(t, got.Invalid) + // Get omits the secret (empty Key) and is stable across the round-trip. + want := apiv2.Key{ + ID: created.ID, + KeyType: "auth", + Description: "dev access", + ExpirySeconds: 86400, + Capabilities: taggedCaps("tag:test"), + Tags: []string{"tag:test"}, + } + if diff := cmp.Diff(want, got, cmpopts.IgnoreFields(apiv2.Key{}, "Created", "Expires")); diff != "" { + t.Errorf("got key mismatch (-want +got):\n%s", diff) + } // Unknown id and bad tailnet both 404 with the Tailscale error body. assert.Equal(t, http.StatusNotFound, api.Get("/api/v2/tailnet/-/keys/999999").Code) diff --git a/hscontrol/apiv2_settings_test.go b/hscontrol/apiv2_settings_test.go index a46e83d78..62b88bd75 100644 --- a/hscontrol/apiv2_settings_test.go +++ b/hscontrol/apiv2_settings_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/danielgtaylor/huma/v2/humatest" + "github.com/google/go-cmp/cmp" apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" "github.com/juanfont/headscale/hscontrol/types" "github.com/stretchr/testify/assert" @@ -102,7 +103,7 @@ func TestAPIv2SettingsComputedFields(t *testing.T) { } } -// TestAPIv2SettingsConstantOffFields pins the honestly-hardcoded-off fields: +// TestAPIv2SettingsConstantOffFields pins the hardcoded-off fields: // even with every config knob set, they must not pick up signal. func TestAPIv2SettingsConstantOffFields(t *testing.T) { cfg := types.Config{ @@ -113,13 +114,16 @@ func TestAPIv2SettingsConstantOffFields(t *testing.T) { s := getSettings(t, settingsAPIWithConfig(t, &cfg)) - assert.False(t, s.DevicesApprovalOn) - assert.False(t, s.DevicesAutoUpdatesOn) - assert.False(t, s.UsersApprovalOn) - assert.False(t, s.NetworkFlowLoggingOn) - assert.False(t, s.RegionalRoutingOn) - assert.False(t, s.PostureIdentityCollectionOn) - assert.Empty(t, s.ACLsExternalLink) + // Only the four computed fields reflect cfg; everything else stays off. + want := apiv2.TailnetSettings{ + ACLsExternallyManagedOn: true, + DevicesKeyDurationDays: 90, + HTTPSEnabled: true, + UsersRoleAllowedToJoinExternalTailnets: "none", + } + if diff := cmp.Diff(want, s); diff != "" { + t.Errorf("settings mismatch (-want +got):\n%s", diff) + } } // TestAPIv2SettingsPatchUnsupported confirms writes are rejected and inert. diff --git a/hscontrol/apiv2_users_test.go b/hscontrol/apiv2_users_test.go new file mode 100644 index 000000000..38efd9a68 --- /dev/null +++ b/hscontrol/apiv2_users_test.go @@ -0,0 +1,188 @@ +package hscontrol + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + + "github.com/danielgtaylor/huma/v2/humatest" + "github.com/google/go-cmp/cmp" + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// userID stringifies a test user's id the way the API emits it. +func userID(u *types.User) string { + return strconv.FormatUint(uint64(u.ID), 10) +} + +// getUserByID GETs a user by id and decodes it. +func getUserByID(t *testing.T, api humatest.TestAPI, id string) apiv2.User { + t.Helper() + + resp := api.Get("/api/v2/users/" + id) + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var user apiv2.User + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &user)) + + return user +} + +// listUsers GETs the user list (with an optional raw query string) and returns +// the decoded slice plus the raw body, so tests can pin the envelope shape. +func listUsers(t *testing.T, api humatest.TestAPI, query string) ([]apiv2.User, string) { + t.Helper() + + resp := api.Get("/api/v2/tailnet/-/users" + query) + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + var env struct { + Users []apiv2.User `json:"users"` + } + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &env)) + + return env.Users, resp.Body.String() +} + +func containsUserID(users []apiv2.User, id string) bool { + for _, u := range users { + if u.ID == id { + return true + } + } + + return false +} + +func TestAPIv2User_Get(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + user := app.state.CreateUserForTest("golden") + + got := getUserByID(t, api, userID(user)) + + // Shape for a user with no devices: identity mapped, unmodelled fields fixed. + want := apiv2.User{ + ID: userID(user), + DisplayName: "golden", + LoginName: "golden", + TailnetID: "1", + Created: user.CreatedAt, + Type: "member", + Role: "member", + Status: "active", + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("user mismatch (-want +got):\n%s", diff) + } +} + +func TestAPIv2User_Get_DeviceCount(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + user := app.state.CreateUserForTest("owner") + + const wantDevices = 3 + for i := range wantDevices { + node := app.state.CreateRegisteredNodeForTest(user, "dut-"+strconv.Itoa(i)) + app.state.PutNodeInStoreForTest(*node) + } + + got := getUserByID(t, api, userID(user)) + assert.Equal(t, wantDevices, got.DeviceCount, "deviceCount aggregates the user's nodes") +} + +func TestAPIv2User_Get_NotFound(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + tests := []struct { + name string + id string + }{ + {name: "unknown numeric id", id: "999999"}, + {name: "non-numeric id", id: "not-a-number"}, + // The tagged-devices pseudo-user is never a real DB row. + {name: "tagged-devices pseudo-user", id: strconv.Itoa(types.TaggedDevicesUserID)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := api.Get("/api/v2/users/" + tt.id) + assert.Equal(t, http.StatusNotFound, resp.Code) + assert.Contains(t, resp.Body.String(), `"message"`, "Tailscale error shape") + }) + } +} + +func TestAPIv2User_List(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + u1 := app.state.CreateUserForTest("alice") + u2 := app.state.CreateUserForTest("bob") + + users, body := listUsers(t, api, "") + assert.Contains(t, body, `"users"`, "list is enveloped under users") + assert.True(t, containsUserID(users, userID(u1))) + assert.True(t, containsUserID(users, userID(u2))) + assert.False(t, containsUserID(users, strconv.Itoa(types.TaggedDevicesUserID)), + "tagged-devices pseudo-user is not listed") + + for _, u := range users { + assert.NotEmpty(t, u.ID, "every user has an id") + assert.NotEmpty(t, u.LoginName, "every user has a login name") + assert.Equal(t, "member", u.Type) + assert.Equal(t, "active", u.Status) + } +} + +func TestAPIv2User_List_Filters(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + app.state.CreateUserForTest("alice") + app.state.CreateUserForTest("bob") + + tests := []struct { + name string + query string + wantEmpty bool + }{ + {name: "no filter", query: "", wantEmpty: false}, + {name: "type member", query: "?type=member", wantEmpty: false}, + {name: "role member", query: "?role=member", wantEmpty: false}, + {name: "member and member", query: "?type=member&role=member", wantEmpty: false}, + {name: "type shared", query: "?type=shared", wantEmpty: true}, + {name: "role admin", query: "?role=admin", wantEmpty: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + users, body := listUsers(t, api, tt.query) + assert.Contains(t, body, `"users"`) + assert.NotContains(t, body, "null", "empty list marshals as [] not null") + + if tt.wantEmpty { + assert.Empty(t, users) + } else { + assert.NotEmpty(t, users) + } + }) + } +} + +func TestAPIv2User_List_BadTailnet(t *testing.T) { + app := createTestApp(t) + api := registerAPIV2(t, app) + + resp := api.Get("/api/v2/tailnet/example.com/users") + assert.Equal(t, http.StatusNotFound, resp.Code) + assert.Contains(t, resp.Body.String(), `"message"`) +} diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go index fa2c38097..d432a052b 100644 --- a/hscontrol/oidc.go +++ b/hscontrol/oidc.go @@ -87,7 +87,7 @@ func NewAuthProviderOIDC( ) (*AuthProviderOIDC, error) { var err error // grab oidc config if it hasn't been already - oidcProvider, err := oidc.NewProvider(context.Background(), cfg.Issuer) //nolint:contextcheck + oidcProvider, err := oidc.NewProvider(ctx, cfg.Issuer) if err != nil { return nil, fmt.Errorf("creating OIDC provider from issuer config: %w", err) } diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index 14c8f0598..a5e9be759 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -2271,6 +2271,10 @@ var tailscaleCapAllowlist = map[tailcfg.PeerCapability]bool{ tailcfg.PeerCapabilityWebUI: true, // tailscale.com/cap/webui tailcfg.PeerCapabilityKubernetes: true, // tailscale.com/cap/kubernetes tailcfg.PeerCapabilityTsIDP: true, // tailscale.com/cap/tsidp + + // tailscale.com/cap/secrets is the capability used by setec + // (github.com/tailscale/setec); allow it so it can be granted via policy. + tailcfg.PeerCapability("tailscale.com/cap/secrets"): true, } // validateGrantSrcDstCombination validates [Grant]-specific source/destination diff --git a/hscontrol/policy/v2/types_test.go b/hscontrol/policy/v2/types_test.go index 849fd7d0a..c2d7a888b 100644 --- a/hscontrol/policy/v2/types_test.go +++ b/hscontrol/policy/v2/types_test.go @@ -6244,3 +6244,27 @@ func TestUnmarshalPolicySSHTests(t *testing.T) { }) } } + +func TestValidateCapabilityName(t *testing.T) { + tests := []struct { + name string + cap string + wantErr error + }{ + {name: "custom domain allowed", cap: "example.com/cap/foo", wantErr: nil}, + {name: "allowlisted tailscale cap", cap: "tailscale.com/cap/drive", wantErr: nil}, + {name: "setec secrets cap allowed", cap: "tailscale.com/cap/secrets", wantErr: nil}, + {name: "non-allowlisted tailscale cap rejected", cap: "tailscale.com/cap/nope", wantErr: ErrCapNameTailscaleDomain}, + {name: "url scheme rejected", cap: "https://tailscale.com/cap/drive", wantErr: ErrCapNameInvalidForm}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateCapabilityName(tt.cap) + if tt.wantErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, tt.wantErr) + } + }) + } +} diff --git a/hscontrol/servertest/apiv2_test.go b/hscontrol/servertest/apiv2_test.go index eadc6b3ef..4fb98d10c 100644 --- a/hscontrol/servertest/apiv2_test.go +++ b/hscontrol/servertest/apiv2_test.go @@ -2,6 +2,7 @@ package servertest_test import ( "encoding/json" + "fmt" "os" "os/exec" "path/filepath" @@ -40,6 +41,7 @@ func TestAPIv2(t *testing.T) { t.Run("GoClient", func(t *testing.T) { apiv2GoClient(t, srv, srv.URL, apiKey, owner) + apiv2UsersGoClient(t, srv, srv.URL, apiKey, owner) node := srv.CreateRegisteredNode(t, owner, "dut-go") apiv2DevicesGoClient(t, srv, srv.URL, apiKey, node.ID()) @@ -49,6 +51,7 @@ func TestAPIv2(t *testing.T) { t.Run("TSCLI", func(t *testing.T) { apiv2TSCLI(t, srv, srv.URL, apiKey) + apiv2UsersTSCLI(t, srv.URL, apiKey, owner) node := srv.CreateRegisteredNode(t, owner, "dut-tscli") apiv2DevicesTSCLI(t, srv, srv.URL, apiKey, node.ID()) @@ -58,6 +61,7 @@ func TestAPIv2(t *testing.T) { t.Run("Terraform", func(t *testing.T) { apiv2Terraform(t, srv, srv.URL, apiKey, owner) + apiv2UsersTerraform(t, srv, srv.URL, apiKey, owner) node := srv.CreateRegisteredNode(t, owner, "dut-tf") apiv2DevicesACLTerraform(t, srv, srv.URL, apiKey, node.Hostname(), node.ID()) @@ -150,6 +154,85 @@ func containsKeyID(keys []tsclient.Key, id string) bool { return false } +func containsUserID(users []tsclient.User, id string) bool { + for _, u := range users { + if u.ID == id { + return true + } + } + + return false +} + +// srvUserCount is the server-side ground truth for the number of users. +func srvUserCount(t *testing.T, srv *servertest.TestServer) int { + t.Helper() + + users, err := srv.State().ListAllUsers() + require.NoError(t, err) + + return len(users) +} + +// apiv2UsersGoClient exercises the Users data sources through the official SDK: +// get-by-id, list, the type/role filters (member matches all, anything else +// matches nothing), and a typed 404 — each cross-checked against server truth. +func apiv2UsersGoClient(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) { + t.Helper() + + ctx := t.Context() + ur := goClient(t, baseURL, apiKey).Users() + ownerID := strconv.FormatUint(uint64(owner.ID), 10) + + got, err := ur.Get(ctx, ownerID) + require.NoError(t, err) + assert.Equal(t, ownerID, got.ID) + assert.Equal(t, owner.Username(), got.LoginName) + assert.Equal(t, tsclient.UserTypeMember, got.Type) + assert.Equal(t, tsclient.UserStatusActive, got.Status) + assert.Equal(t, srv.State().ListNodesByUser(types.UserID(owner.ID)).Len(), got.DeviceCount, + "deviceCount matches the server's node count for the user") + + all, err := ur.List(ctx, nil, nil) + require.NoError(t, err) + assert.True(t, containsUserID(all, ownerID), "owner present in user list") + assert.Len(t, all, srvUserCount(t, srv)) + + // member matches every Headscale user; shared/admin match nothing. + members, err := ur.List(ctx, new(tsclient.UserTypeMember), nil) + require.NoError(t, err) + assert.Len(t, members, len(all)) + + shared, err := ur.List(ctx, new(tsclient.UserTypeShared), nil) + require.NoError(t, err) + assert.Empty(t, shared, "Headscale has no shared users") + + admins, err := ur.List(ctx, nil, new(tsclient.UserRoleAdmin)) + require.NoError(t, err) + assert.Empty(t, admins, "Headscale has no admin-role users") + + _, err = ur.Get(ctx, "999999") + require.Error(t, err) + assert.True(t, tsclient.IsNotFound(err), "unknown user id is a typed 404") +} + +// apiv2UsersTSCLI exercises the user verbs through tscli, asserting the owner is +// present in the list and retrievable by id. +func apiv2UsersTSCLI(t *testing.T, baseURL, apiKey string, owner *types.User) { + t.Helper() + + run, _ := tscliRunner(t, baseURL, apiKey) + ownerID := strconv.FormatUint(uint64(owner.ID), 10) + + listOut := run("list", "users", "-o", "json") + assert.Contains(t, listOut, ownerID) + assert.Contains(t, listOut, `"member"`) + + getOut := run("get", "user", "--user", ownerID, "-o", "json") + assert.Contains(t, getOut, ownerID) + assert.Contains(t, getOut, owner.Username()) +} + // srvPreAuthKey is the server-side ground truth for a key id; it fails the test // if the key is absent. func srvPreAuthKey(t *testing.T, srv *servertest.TestServer, id string) types.PreAuthKey { @@ -297,6 +380,74 @@ func apiv2Terraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey st require.NotNil(t, srvPreAuthKey(t, srv, keyID).Revoked, "key revoked after destroy") } +// usersTFConfig drives the tailscale_user (by login name) and tailscale_users +// data sources, plus tailscale_4via6 (provider-local compute, no server call) to +// prove that data source resolves against Headscale unchanged. %s is the owner's +// login name. Data sources create nothing, so the assertions are value +// correctness plus no drift on re-read. +const usersTFConfig = ` +terraform { + required_providers { + tailscale = { + source = "tailscale/tailscale" + version = "~> 0.21" + } + } +} + +provider "tailscale" {} + +data "tailscale_user" "owner" { + login_name = "%s" +} + +data "tailscale_users" "all" {} + +data "tailscale_4via6" "site" { + site = 7 + cidr = "10.1.1.0/24" +} + +output "user_id" { value = data.tailscale_user.owner.id } +output "user_login_name" { value = data.tailscale_user.owner.login_name } +output "user_type" { value = data.tailscale_user.owner.type } +output "user_device_count" { value = data.tailscale_user.owner.device_count } +output "users_count" { value = length(data.tailscale_users.all.users) } +output "via6" { value = data.tailscale_4via6.site.ipv6 } +` + +// apiv2UsersTerraform runs a tofu init/apply/(no-drift)/destroy over the +// tailscale_user + tailscale_users data sources (and the provider-local +// tailscale_4via6), cross-checking the data-source outputs against the server's +// stored users. +func apiv2UsersTerraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) { + t.Helper() + + tf := newTofu(t, baseURL, apiKey, fmt.Sprintf(usersTFConfig, owner.Username())) + + tf.run("init", "-no-color", "-input=false") + tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + + outputs := tf.outputs() + assert.Equal(t, strconv.FormatUint(uint64(owner.ID), 10), outputs.str(t, "user_id")) + assert.Equal(t, owner.Username(), outputs.str(t, "user_login_name")) + assert.Equal(t, "member", outputs.str(t, "user_type")) + assert.Equal(t, srv.State().ListNodesByUser(types.UserID(owner.ID)).Len(), + int(outputs.num(t, "user_device_count"))) + assert.Equal(t, srvUserCount(t, srv), int(outputs.num(t, "users_count"))) + + // tailscale_4via6 is computed by the provider with no server call; assert it + // resolved to a Tailscale 4via6 address. + assert.Contains(t, outputs.str(t, "via6"), "fd7a:115c:a1e0", "4via6 mapped address") + + // A converged data-source read must produce an empty plan — drift is a read bug. + tf.assertNoDrift() + + // destroy removes only TF state; the users persist (they are data sources). + tf.run("destroy", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + assert.GreaterOrEqual(t, srvUserCount(t, srv), 1, "users persist across data-source destroy") +} + // tofu binds a tofu binary, working dir, and env for a single workspace. cmd is // a closure capturing the looked-up binary so subprocess construction stays in // one place. diff --git a/packaging/systemd/headscale.service b/packaging/systemd/headscale.service index 7d20444ff..9d3b69712 100644 --- a/packaging/systemd/headscale.service +++ b/packaging/systemd/headscale.service @@ -1,7 +1,6 @@ [Unit] After=network.target Description=headscale coordination server for Tailscale -X-Restart-Triggers=/etc/headscale/config.yaml [Service] Type=simple @@ -15,9 +14,11 @@ RestartSec=5 WorkingDirectory=/var/lib/headscale ReadWritePaths=/var/lib/headscale -AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_CHOWN -CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_CHOWN +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +DevicePolicy=closed LockPersonality=true +MemoryDenyWriteExecute=true NoNewPrivileges=true PrivateDevices=true PrivateMounts=true @@ -42,9 +43,9 @@ RuntimeDirectoryMode=0750 StateDirectory=headscale StateDirectoryMode=0750 SystemCallArchitectures=native -SystemCallFilter=@chown SystemCallFilter=@system-service SystemCallFilter=~@privileged +SystemCallFilter=~@resources UMask=0077 [Install]