Merge branch 'main' into ssh-error-message-clarity

This commit is contained in:
Jacob 2026-06-25 12:20:05 -04:00 committed by GitHub
commit 2e54969a51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 610 additions and 49 deletions

View file

@ -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)

View file

@ -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.

View file

@ -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": ["*"]
}
]
}

View file

@ -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

View file

@ -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) {

View file

@ -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.

View file

@ -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)

View file

@ -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"`

175
hscontrol/api/v2/users.go Normal file
View file

@ -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
}

View file

@ -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)

View file

@ -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.

View file

@ -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"`)
}

View file

@ -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)
}

View file

@ -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

View file

@ -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)
}
})
}
}

View file

@ -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.

View file

@ -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]