mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-13 11:29:09 +09:00
487 lines
15 KiB
Go
487 lines
15 KiB
Go
package apiv2
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/netip"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
"github.com/juanfont/headscale/hscontrol/scope"
|
|
"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},
|
|
}, scope.DevicesCoreRead), 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},
|
|
}, scope.DevicesCoreRead), 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},
|
|
}, scope.DevicesCore), 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},
|
|
}, scope.DevicesCore), 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},
|
|
}, scope.DevicesCore), 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},
|
|
}, scope.DevicesCore), 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
|
|
}
|
|
|
|
// An OAuth token may only assign tags within its grant (held directly or
|
|
// owned by a held tag per policy); an admin API key is unrestricted. The
|
|
// devices:core scope alone must not let a token stamp an arbitrary policy
|
|
// tag (e.g. tag:prod) onto any node. SetNodeTags still enforces that each
|
|
// tag exists in policy.
|
|
if tokenTags, isOAuth := principalTags(ctx); isOAuth {
|
|
for _, tag := range in.Body.Tags {
|
|
if !b.State.TagOwnedByTags(tag, tokenTags) {
|
|
return nil, huma.Error403Forbidden("token may not assign tag " + tag)
|
|
}
|
|
}
|
|
}
|
|
|
|
_, 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},
|
|
}, scope.DevicesCore), 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},
|
|
}, scope.DevicesRoutes), 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},
|
|
}, scope.DevicesRoutesRead), 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 ""
|
|
}
|