mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-20 18:09:29 +00:00
Merge branch 'main' into ssh-error-message-clarity
This commit is contained in:
commit
2e54969a51
17 changed files with 610 additions and 49 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
175
hscontrol/api/v2/users.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
188
hscontrol/apiv2_users_test.go
Normal file
188
hscontrol/apiv2_users_test.go
Normal 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"`)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue