diff --git a/hscontrol/api/v2/devices.go b/hscontrol/api/v2/devices.go new file mode 100644 index 00000000..23eb7cac --- /dev/null +++ b/hscontrol/api/v2/devices.go @@ -0,0 +1,472 @@ +package apiv2 + +import ( + "context" + "net/http" + "net/netip" + "slices" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" + "tailscale.com/net/tsaddr" +) + +func init() { + registrations = append(registrations, registerDevices) +} + +// Device is the Tailscale device response. Headscale nodes map onto it; fields +// Headscale does not track are emitted as their zero value. The route slices +// are only populated for the fields=all variant, matching Tailscale. +type Device struct { + Addresses []string `json:"addresses" nullable:"false"` + Name string `json:"name"` + ID string `json:"id"` + NodeID string `json:"nodeId"` + Authorized bool `json:"authorized"` + User string `json:"user"` + Tags []string `json:"tags" nullable:"false"` + KeyExpiryDisabled bool `json:"keyExpiryDisabled"` + Created time.Time `json:"created"` + Expires *time.Time `json:"expires,omitempty"` + Hostname string `json:"hostname"` + IsEphemeral bool `json:"isEphemeral"` + LastSeen *time.Time `json:"lastSeen,omitempty"` + MachineKey string `json:"machineKey"` + NodeKey string `json:"nodeKey"` + OS string `json:"os"` + ClientVersion string `json:"clientVersion"` + UpdateAvailable bool `json:"updateAvailable"` + + // Populated only when fields=all is requested. + AdvertisedRoutes []string `json:"advertisedRoutes,omitempty"` + EnabledRoutes []string `json:"enabledRoutes,omitempty"` +} + +// DeviceRoutes is the GET /device/{id}/routes response: what the node announces +// vs which of those are approved (Tailscale calls approved routes "enabled"). +type DeviceRoutes struct { + Advertised []string `json:"advertisedRoutes" nullable:"false"` + Enabled []string `json:"enabledRoutes" nullable:"false"` +} + +// Request bodies match the Tailscale SDK wire shapes. +type ( + setAuthorizedRequest struct { + Authorized bool `json:"authorized"` + } + setNameRequest struct { + Name string `json:"name"` + } + // setTagsRequest.Tags is intentionally not nullable:"false": the SDK sends + // "tags":null for "make untagged", which the handler accepts as a no-op + // rather than failing to decode (Headscale cannot untag a node). + setTagsRequest struct { + Tags []string `json:"tags"` + } + setKeyRequest struct { + KeyExpiryDisabled bool `json:"keyExpiryDisabled"` + } + setSubnetRoutesRequest struct { + Routes []string `json:"routes" nullable:"false"` + } +) + +type ( + deviceByIDInput struct { + DeviceID string `doc:"Device id (the decimal node id)." path:"id"` + Fields string `doc:"Set to \"all\" for route fields." query:"fields"` + } + listDevicesInput struct { + Tailnet string `doc:"Tailnet; must be \"-\"." path:"tailnet"` + Fields string `query:"fields"` + } + setAuthorizedInput struct { + DeviceID string `path:"id"` + Body setAuthorizedRequest + } + setNameInput struct { + DeviceID string `path:"id"` + Body setNameRequest + } + setTagsInput struct { + DeviceID string `path:"id"` + Body setTagsRequest + } + setKeyInput struct { + DeviceID string `path:"id"` + Body setKeyRequest + } + setSubnetRoutesInput struct { + DeviceID string `path:"id"` + Body setSubnetRoutesRequest + } + + deviceOutput struct{ Body Device } + deviceRoutesOutput struct{ Body DeviceRoutes } + listDevicesOutput struct { + Body struct { + Devices []Device `json:"devices" nullable:"false"` + } + } + emptyOutput struct{ Body struct{} } +) + +func registerDevices(api huma.API, b Backend) { + deviceTags := []string{"Devices", "Tailscale compat"} + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "getDevice", + Method: http.MethodGet, + Path: "/api/v2/device/{id}", + Summary: "Get a device", + Tags: deviceTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeDevicesCoreRead), func(ctx context.Context, in *deviceByIDInput) (*deviceOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + return &deviceOutput{Body: deviceFromView(node, in.Fields == "all")}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "listDevices", + Method: http.MethodGet, + Path: "/api/v2/tailnet/{tailnet}/devices", + Summary: "List devices", + Tags: deviceTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeDevicesCoreRead), func(ctx context.Context, in *listDevicesInput) (*listDevicesOutput, error) { + err := requireDefaultTailnet(in.Tailnet) + if err != nil { + return nil, err + } + + nodes := b.State.ListNodes() + allFields := in.Fields == "all" + + out := &listDevicesOutput{} + out.Body.Devices = make([]Device, 0, nodes.Len()) + + for _, node := range nodes.All() { + out.Body.Devices = append(out.Body.Devices, deviceFromView(node, allFields)) + } + + return out, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "deleteDevice", + Method: http.MethodDelete, + Path: "/api/v2/device/{id}", + Summary: "Delete a device", + Tags: deviceTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeDevicesCore), func(ctx context.Context, in *deviceByIDInput) (*emptyOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + nodeChange, err := b.State.DeleteNode(node) + if err != nil { + return nil, mapError("deleting device", err) + } + + b.Change(nodeChange) + + return &emptyOutput{}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "authorizeDevice", + Method: http.MethodPost, + Path: "/api/v2/device/{id}/authorized", + Summary: "Authorize a device", + Tags: deviceTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeDevicesCore), func(ctx context.Context, in *setAuthorizedInput) (*emptyOutput, error) { + _, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + // Headscale nodes are authorized the moment they register; there is no + // de-authorize state. Accept authorized=true as a no-op; reject false so + // callers are not misled into thinking the device is fenced off. + if !in.Body.Authorized { + return nil, huma.Error400BadRequest( + "Headscale does not support de-authorizing a device; delete or expire it instead", + ) + } + + return &emptyOutput{}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "setDeviceName", + Method: http.MethodPost, + Path: "/api/v2/device/{id}/name", + Summary: "Set a device's name", + Tags: deviceTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeDevicesCore), func(ctx context.Context, in *setNameInput) (*emptyOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + _, nodeChange, err := b.State.RenameNode(node.ID(), in.Body.Name) + if err != nil { + return nil, mapError("renaming device", err) + } + + b.Change(nodeChange) + + return &emptyOutput{}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "setDeviceTags", + Method: http.MethodPost, + Path: "/api/v2/device/{id}/tags", + Summary: "Set a device's tags", + Tags: deviceTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeDevicesCore), func(ctx context.Context, in *setTagsInput) (*emptyOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + // Headscale cannot make a node untagged (tags-as-identity is one-way), so + // an empty/null tag set is accepted as a no-op rather than rejected. This + // keeps tooling lifecycles such as Terraform destroy working; the SDK + // sends "tags":null for "make untagged". + if len(in.Body.Tags) == 0 { + return &emptyOutput{}, nil + } + + _, nodeChange, err := b.State.SetNodeTags(node.ID(), in.Body.Tags) + if err != nil { + return nil, mapError("setting device tags", err) + } + + b.Change(nodeChange) + + return &emptyOutput{}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "setDeviceKey", + Method: http.MethodPost, + Path: "/api/v2/device/{id}/key", + Summary: "Set a device's key settings", + Tags: deviceTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeDevicesCore), func(ctx context.Context, in *setKeyInput) (*emptyOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + // Only disabling expiry maps cleanly (expiry=nil never expires). + // Re-enabling has no target expiry in the Tailscale request and Headscale + // stores no original, so it is accepted as a no-op (keeps Terraform + // destroy working) rather than guessing a lifetime. + if !in.Body.KeyExpiryDisabled { + return &emptyOutput{}, nil + } + + _, nodeChange, err := b.State.SetNodeExpiry(node.ID(), nil) + if err != nil { + return nil, mapError("setting device key expiry", err) + } + + b.Change(nodeChange) + + return &emptyOutput{}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "setDeviceRoutes", + Method: http.MethodPost, + Path: "/api/v2/device/{id}/routes", + Summary: "Set a device's enabled subnet routes", + Tags: deviceTags, + Security: security, + DefaultStatus: http.StatusOK, + Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeDevicesRoutes), func(ctx context.Context, in *setSubnetRoutesInput) (*deviceRoutesOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + approved, err := parseRoutes(in.Body.Routes) + if err != nil { + return nil, err + } + + updated, nodeChange, err := b.State.SetApprovedRoutes(node.ID(), approved) + if err != nil { + return nil, mapError("setting device routes", err) + } + + b.Change(nodeChange) + + return &deviceRoutesOutput{Body: routesFromView(updated)}, nil + }) + + huma.Register(api, requireScope(huma.Operation{ + OperationID: "getDeviceRoutes", + Method: http.MethodGet, + Path: "/api/v2/device/{id}/routes", + Summary: "Get a device's subnet routes", + Tags: deviceTags, + Security: security, + Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, + }, ScopeDevicesRoutesRead), func(ctx context.Context, in *deviceByIDInput) (*deviceRoutesOutput, error) { + node, err := lookupNode(b, in.DeviceID) + if err != nil { + return nil, err + } + + return &deviceRoutesOutput{Body: routesFromView(node)}, nil + }) +} + +// lookupNode resolves a device id to its NodeView, mapping a malformed or +// unknown id to 404 (the Tailscale SDK keys IsNotFound off the status code). +func lookupNode(b Backend, rawID string) (types.NodeView, error) { + nodeID, err := parseNodeID(rawID) + if err != nil { + return types.NodeView{}, err + } + + node, ok := b.State.GetNodeByID(nodeID) + if !ok { + return types.NodeView{}, huma.Error404NotFound("device not found") + } + + return node, nil +} + +// parseRoutes parses the enabled-route strings, expanding an exit route to both +// families (else the node is not annotated as an exit node), then sorts/dedups. +func parseRoutes(routes []string) ([]netip.Prefix, error) { + var approved []netip.Prefix + + for _, route := range routes { + prefix, err := netip.ParsePrefix(route) + if err != nil { + return nil, huma.Error400BadRequest("parsing route", err) + } + + if prefix == tsaddr.AllIPv4() || prefix == tsaddr.AllIPv6() { + approved = append(approved, tsaddr.AllIPv4(), tsaddr.AllIPv6()) + } else { + approved = append(approved, prefix) + } + } + + slices.SortFunc(approved, netip.Prefix.Compare) + + return slices.Compact(approved), nil +} + +// deviceFromView maps a Headscale node onto the Tailscale Device, reading +// through the [types.NodeView] accessors. allFields gates the route slices, +// which Tailscale only returns for fields=all. +func deviceFromView(view types.NodeView, allFields bool) Device { + id := view.StringID() + + d := Device{ + Addresses: emptyIfNil(view.IPsAsString()), + Name: view.GivenName(), + ID: id, + NodeID: id, + Authorized: true, // Headscale has no post-registration de-auth state. + User: deviceUser(view), + Tags: emptyIfNil(view.Tags().AsSlice()), + KeyExpiryDisabled: !view.Expiry().Valid(), + Created: view.CreatedAt(), + Hostname: view.Hostname(), + IsEphemeral: view.IsEphemeral(), + MachineKey: view.MachineKey().String(), + NodeKey: view.NodeKey().String(), + OS: hostinfoOS(view), + ClientVersion: hostinfoVersion(view), + } + + if view.Expiry().Valid() { + exp := view.Expiry().Get() + d.Expires = &exp + } + + // LastSeen is reported only when the device is offline, matching tailcfg. + if view.LastSeen().Valid() && view.IsOnline().Valid() && !view.IsOnline().Get() { + ls := view.LastSeen().Get() + d.LastSeen = &ls + } + + if allFields { + d.AdvertisedRoutes = emptyIfNil(util.PrefixesToString(view.AnnouncedRoutes())) + d.EnabledRoutes = emptyIfNil(util.PrefixesToString(view.ApprovedRoutes().AsSlice())) + } + + return d +} + +func routesFromView(view types.NodeView) DeviceRoutes { + return DeviceRoutes{ + Advertised: emptyIfNil(util.PrefixesToString(view.AnnouncedRoutes())), + Enabled: emptyIfNil(util.PrefixesToString(view.ApprovedRoutes().AsSlice())), + } +} + +// deviceUser is the owning login. [types.NodeView.Owner] already resolves the +// tags-as-identity rule: tagged nodes present the special TaggedDevices user, +// user-owned nodes present their login, orphaned nodes present nothing. +func deviceUser(view types.NodeView) string { + owner := view.Owner() + if owner.Valid() { + return owner.Username() + } + + return "" +} + +// hostinfoOS / hostinfoVersion read Hostinfo fields, returning "" when the node +// has not reported Hostinfo yet. +func hostinfoOS(view types.NodeView) string { + if hi := view.Hostinfo(); hi.Valid() { + return hi.OS() + } + + return "" +} + +func hostinfoVersion(view types.NodeView) string { + if hi := view.Hostinfo(); hi.Valid() { + return hi.IPNVersion() + } + + return "" +}