mirror of
https://git.vectorsigma.ru/public/headscale.git
synced 2026-08-06 19:48:18 +00:00
hscontrol/api/v1: implement node endpoints
Register, Get, List, Delete, Rename, Expire, SetTags, SetApprovedRoutes (with exit-route expansion), BackfillNodeIPs, DebugCreateNode over the state layer. Tagged nodes present as TaggedDevices; client errors map to 4xx.
This commit is contained in:
@@ -43,17 +43,19 @@ status code itself is unchanged for equivalent conditions (e.g. unknown user →
|
||||
|
||||
## Behaviour
|
||||
|
||||
### Unknown resources return 404 consistently
|
||||
### Client errors return 4xx consistently
|
||||
|
||||
**What:** operations that look up a resource by id return `404` when it does not
|
||||
exist. Several gRPC handlers (e.g. `RenameUser`, `DeleteUser`) returned a plain
|
||||
Go error, which grpc-gateway rendered as `500`; only a few (e.g. `GetNode`) used
|
||||
an explicit not-found status.
|
||||
**What:** client mistakes now map to the appropriate 4xx status instead of 500.
|
||||
Missing resources are `404` (e.g. `RenameUser`, `DeleteUser`, `GetNode`,
|
||||
`DeleteNode`); invalid input is `400` (e.g. an unparseable route in
|
||||
`SetApprovedRoutes`, a malformed registration key in `RegisterNode`, an invalid
|
||||
tag in `SetTags`, an unconfirmed `BackfillNodeIPs`). Many of these gRPC handlers
|
||||
returned a plain Go error, which grpc-gateway rendered as `500`.
|
||||
|
||||
**Why:** a missing resource is a client error, not a server error; 404 is the
|
||||
correct, consistent status.
|
||||
**Why:** a missing resource or bad input is a client error, not a server error;
|
||||
4xx is the correct, consistent status.
|
||||
|
||||
**Client impact:** clients that treated these as 500 should treat them as 404.
|
||||
**Client impact:** clients that treated these as 500 should treat them as 400/404.
|
||||
|
||||
### Health on database failure
|
||||
|
||||
|
||||
@@ -84,6 +84,45 @@ func oasPreAuthKey(k *v1.PreAuthKey) oas.PreAuthKey {
|
||||
}
|
||||
}
|
||||
|
||||
func optPreAuthKey(k *v1.PreAuthKey) oas.OptPreAuthKey {
|
||||
if k == nil {
|
||||
return oas.OptPreAuthKey{}
|
||||
}
|
||||
|
||||
return oas.NewOptPreAuthKey(oasPreAuthKey(k))
|
||||
}
|
||||
|
||||
func optRegisterMethod(rm v1.RegisterMethod) oas.OptRegisterMethod {
|
||||
if rm == v1.RegisterMethod_REGISTER_METHOD_UNSPECIFIED {
|
||||
return oas.OptRegisterMethod{}
|
||||
}
|
||||
|
||||
return oas.NewOptRegisterMethod(oas.RegisterMethod(rm.String()))
|
||||
}
|
||||
|
||||
func oasNode(n *v1.Node) oas.Node {
|
||||
return oas.Node{
|
||||
ID: optUint64(n.GetId()),
|
||||
MachineKey: optString(n.GetMachineKey()),
|
||||
NodeKey: optString(n.GetNodeKey()),
|
||||
DiscoKey: optString(n.GetDiscoKey()),
|
||||
IpAddresses: strs(n.GetIpAddresses()),
|
||||
Name: optString(n.GetName()),
|
||||
User: optUser(n.GetUser()),
|
||||
LastSeen: optTime(n.GetLastSeen()),
|
||||
Expiry: optTime(n.GetExpiry()),
|
||||
PreAuthKey: optPreAuthKey(n.GetPreAuthKey()),
|
||||
CreatedAt: optTime(n.GetCreatedAt()),
|
||||
RegisterMethod: optRegisterMethod(n.GetRegisterMethod()),
|
||||
GivenName: optString(n.GetGivenName()),
|
||||
Online: optBool(n.GetOnline()),
|
||||
ApprovedRoutes: strs(n.GetApprovedRoutes()),
|
||||
AvailableRoutes: strs(n.GetAvailableRoutes()),
|
||||
SubnetRoutes: strs(n.GetSubnetRoutes()),
|
||||
Tags: strs(n.GetTags()),
|
||||
}
|
||||
}
|
||||
|
||||
func oasAPIKey(k *v1.ApiKey) oas.ApiKey {
|
||||
return oas.ApiKey{
|
||||
ID: optUint64(k.GetId()),
|
||||
|
||||
311
hscontrol/api/v1/nodes.go
Normal file
311
hscontrol/api/v1/nodes.go
Normal file
@@ -0,0 +1,311 @@
|
||||
package apiv1
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
oas "github.com/juanfont/headscale/gen/api/v1"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/util"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
// RegisterNode registers a node to a user using a registration id, then
|
||||
// auto-approves its routes.
|
||||
func (s *Server) RegisterNode(
|
||||
_ context.Context,
|
||||
params oas.RegisterNodeParams,
|
||||
) (*oas.RegisterNodeOK, error) {
|
||||
registrationID, err := types.AuthIDFromString(params.Key.Or(""))
|
||||
if err != nil {
|
||||
return nil, badRequest(err.Error())
|
||||
}
|
||||
|
||||
user, err := s.state.GetUserByName(params.User.Or(""))
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
node, nodeChange, err := s.state.HandleNodeFromAuthPath(
|
||||
registrationID,
|
||||
types.UserID(user.ID),
|
||||
nil,
|
||||
util.RegisterMethodCLI,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
routeChange, err := s.state.AutoApproveRoutes(node)
|
||||
if err != nil {
|
||||
return nil, internalError("auto approving routes: " + err.Error())
|
||||
}
|
||||
|
||||
s.change(nodeChange, routeChange)
|
||||
|
||||
return &oas.RegisterNodeOK{Node: oas.NewOptNode(oasNode(node.Proto()))}, nil
|
||||
}
|
||||
|
||||
// GetNode returns a node by id.
|
||||
func (s *Server) GetNode(_ context.Context, params oas.GetNodeParams) (*oas.GetNodeOK, error) {
|
||||
node, ok := s.state.GetNodeByID(types.NodeID(params.NodeID))
|
||||
if !ok {
|
||||
return nil, notFound("node not found")
|
||||
}
|
||||
|
||||
return &oas.GetNodeOK{Node: oas.NewOptNode(oasNode(node.Proto()))}, nil
|
||||
}
|
||||
|
||||
// SetTags sets the ACL tags of a node, converting it to a tagged node.
|
||||
func (s *Server) SetTags(
|
||||
_ context.Context,
|
||||
req *oas.SetTagsReq,
|
||||
params oas.SetTagsParams,
|
||||
) (*oas.SetTagsOK, error) {
|
||||
if len(req.Tags) == 0 {
|
||||
return nil, badRequest(
|
||||
"cannot remove all tags from a node - tagged nodes must have at least one tag",
|
||||
)
|
||||
}
|
||||
|
||||
for _, tag := range req.Tags {
|
||||
err := validateTag(tag)
|
||||
if err != nil {
|
||||
return nil, badRequest(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
_, found := s.state.GetNodeByID(types.NodeID(params.NodeID))
|
||||
if !found {
|
||||
return nil, notFound("node not found")
|
||||
}
|
||||
|
||||
node, nodeChange, err := s.state.SetNodeTags(types.NodeID(params.NodeID), req.Tags)
|
||||
if err != nil {
|
||||
return nil, badRequest(err.Error())
|
||||
}
|
||||
|
||||
s.change(nodeChange)
|
||||
|
||||
return &oas.SetTagsOK{Node: oas.NewOptNode(oasNode(node.Proto()))}, nil
|
||||
}
|
||||
|
||||
// SetApprovedRoutes sets the approved subnet routes of a node, expanding exit
|
||||
// routes to cover both address families.
|
||||
func (s *Server) SetApprovedRoutes(
|
||||
_ context.Context,
|
||||
req *oas.SetApprovedRoutesReq,
|
||||
params oas.SetApprovedRoutesParams,
|
||||
) (*oas.SetApprovedRoutesOK, error) {
|
||||
var newApproved []netip.Prefix
|
||||
|
||||
for _, route := range req.Routes {
|
||||
prefix, err := netip.ParsePrefix(route)
|
||||
if err != nil {
|
||||
return nil, badRequest("parsing route: " + err.Error())
|
||||
}
|
||||
|
||||
// An exit route is annotated by both v4 and v6 default routes.
|
||||
if prefix == tsaddr.AllIPv4() || prefix == tsaddr.AllIPv6() {
|
||||
newApproved = append(newApproved, tsaddr.AllIPv4(), tsaddr.AllIPv6())
|
||||
} else {
|
||||
newApproved = append(newApproved, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortFunc(newApproved, netip.Prefix.Compare)
|
||||
newApproved = slices.Compact(newApproved)
|
||||
|
||||
node, nodeChange, err := s.state.SetApprovedRoutes(types.NodeID(params.NodeID), newApproved)
|
||||
if err != nil {
|
||||
return nil, badRequest(err.Error())
|
||||
}
|
||||
|
||||
s.change(nodeChange)
|
||||
|
||||
proto := node.Proto()
|
||||
// SubnetRoutes carries only the routes actively served from the node.
|
||||
proto.SubnetRoutes = util.PrefixesToString(s.state.GetNodePrimaryRoutes(node.ID()))
|
||||
|
||||
return &oas.SetApprovedRoutesOK{Node: oas.NewOptNode(oasNode(proto))}, nil
|
||||
}
|
||||
|
||||
// DeleteNode deletes a node.
|
||||
func (s *Server) DeleteNode(_ context.Context, params oas.DeleteNodeParams) error {
|
||||
node, ok := s.state.GetNodeByID(types.NodeID(params.NodeID))
|
||||
if !ok {
|
||||
return notFound("node not found")
|
||||
}
|
||||
|
||||
nodeChange, err := s.state.DeleteNode(node)
|
||||
if err != nil {
|
||||
return mapStateError(err)
|
||||
}
|
||||
|
||||
s.change(nodeChange)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpireNode expires a node, or disables its expiry.
|
||||
func (s *Server) ExpireNode(
|
||||
_ context.Context,
|
||||
params oas.ExpireNodeParams,
|
||||
) (*oas.ExpireNodeOK, error) {
|
||||
_, hasExpiry := params.Expiry.Get()
|
||||
if params.DisableExpiry.Or(false) && hasExpiry {
|
||||
return nil, badRequest("cannot set both disable_expiry and expiry")
|
||||
}
|
||||
|
||||
var expiry *time.Time
|
||||
|
||||
if !params.DisableExpiry.Or(false) {
|
||||
t := time.Now()
|
||||
if v, ok := params.Expiry.Get(); ok {
|
||||
t = v
|
||||
}
|
||||
|
||||
expiry = &t
|
||||
}
|
||||
|
||||
node, nodeChange, err := s.state.SetNodeExpiry(types.NodeID(params.NodeID), expiry)
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
s.change(nodeChange)
|
||||
|
||||
return &oas.ExpireNodeOK{Node: oas.NewOptNode(oasNode(node.Proto()))}, nil
|
||||
}
|
||||
|
||||
// RenameNode renames a node.
|
||||
func (s *Server) RenameNode(
|
||||
_ context.Context,
|
||||
params oas.RenameNodeParams,
|
||||
) (*oas.RenameNodeOK, error) {
|
||||
node, nodeChange, err := s.state.RenameNode(types.NodeID(params.NodeID), params.NewName)
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
s.change(nodeChange)
|
||||
|
||||
return &oas.RenameNodeOK{Node: oas.NewOptNode(oasNode(node.Proto()))}, nil
|
||||
}
|
||||
|
||||
// ListNodes lists nodes, optionally filtered by user, sorted by id.
|
||||
func (s *Server) ListNodes(
|
||||
_ context.Context,
|
||||
params oas.ListNodesParams,
|
||||
) (*oas.ListNodesOK, error) {
|
||||
var nodes views.Slice[types.NodeView]
|
||||
|
||||
if params.User.Or("") != "" {
|
||||
user, err := s.state.GetUserByName(params.User.Or(""))
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
nodes = s.state.ListNodesByUser(types.UserID(user.ID))
|
||||
} else {
|
||||
nodes = s.state.ListNodes()
|
||||
}
|
||||
|
||||
return &oas.ListNodesOK{Nodes: s.nodesToOAS(nodes)}, nil
|
||||
}
|
||||
|
||||
// nodesToOAS converts a slice of node views to API nodes, presenting tagged
|
||||
// nodes as the TaggedDevices user and populating SubnetRoutes with the routes
|
||||
// actively served from each node.
|
||||
func (s *Server) nodesToOAS(nodes views.Slice[types.NodeView]) []oas.Node {
|
||||
out := make([]oas.Node, nodes.Len())
|
||||
|
||||
for index, node := range nodes.All() {
|
||||
proto := node.Proto()
|
||||
|
||||
if node.IsTagged() {
|
||||
proto.User = types.TaggedDevices.Proto()
|
||||
}
|
||||
|
||||
proto.SubnetRoutes = util.PrefixesToString(
|
||||
append(s.state.GetNodePrimaryRoutes(node.ID()), node.ExitRoutes()...),
|
||||
)
|
||||
|
||||
out[index] = oasNode(proto)
|
||||
}
|
||||
|
||||
slices.SortFunc(out, func(a, b oas.Node) int { return cmp.Compare(a.ID.Or(0), b.ID.Or(0)) })
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// BackfillNodeIPs backfills missing IP addresses for all nodes. It must be
|
||||
// explicitly confirmed.
|
||||
func (s *Server) BackfillNodeIPs(
|
||||
_ context.Context,
|
||||
params oas.BackfillNodeIPsParams,
|
||||
) (*oas.BackfillNodeIPsOK, error) {
|
||||
if !params.Confirmed.Or(false) {
|
||||
return nil, badRequest("not confirmed, aborting")
|
||||
}
|
||||
|
||||
changes, err := s.state.BackfillNodeIPs()
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
return &oas.BackfillNodeIPsOK{Changes: changes}, nil
|
||||
}
|
||||
|
||||
// DebugCreateNode caches a synthetic node registration for testing and echoes
|
||||
// back a node describing it. The real node is created later via AuthApprove.
|
||||
func (s *Server) DebugCreateNode(
|
||||
_ context.Context,
|
||||
req *oas.DebugCreateNodeReq,
|
||||
) (*oas.DebugCreateNodeOK, error) {
|
||||
user, err := s.state.GetUserByName(req.User.Or(""))
|
||||
if err != nil {
|
||||
return nil, mapStateError(err)
|
||||
}
|
||||
|
||||
routes, err := util.StringToIPPrefix(req.Routes)
|
||||
if err != nil {
|
||||
return nil, badRequest(err.Error())
|
||||
}
|
||||
|
||||
registrationID, err := types.AuthIDFromString(req.Key.Or(""))
|
||||
if err != nil {
|
||||
return nil, badRequest(err.Error())
|
||||
}
|
||||
|
||||
regData := &types.RegistrationData{
|
||||
NodeKey: key.NewNode().Public(),
|
||||
MachineKey: key.NewMachine().Public(),
|
||||
Hostname: req.Name.Or(""),
|
||||
Expiry: &time.Time{},
|
||||
}
|
||||
|
||||
s.state.SetAuthCacheEntry(registrationID, types.NewRegisterAuthRequest(regData))
|
||||
|
||||
echoNode := types.Node{
|
||||
NodeKey: regData.NodeKey,
|
||||
MachineKey: regData.MachineKey,
|
||||
Hostname: regData.Hostname,
|
||||
User: user,
|
||||
Expiry: &time.Time{},
|
||||
LastSeen: &time.Time{},
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
Hostname: req.Name.Or(""),
|
||||
OS: "TestOS",
|
||||
RoutableIPs: routes,
|
||||
},
|
||||
}
|
||||
|
||||
return &oas.DebugCreateNodeOK{Node: oas.NewOptNode(oasNode(echoNode.Proto()))}, nil
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
apiv1 "github.com/juanfont/headscale/gen/api/v1"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
)
|
||||
|
||||
// APIClient returns an ogen-generated v1 API client wired to this server's
|
||||
@@ -38,6 +39,24 @@ func (s *TestServer) APIClient(tb testing.TB, apiKey string) *apiv1.Client {
|
||||
return client
|
||||
}
|
||||
|
||||
// CreateNode creates a registered test node present in both the database and
|
||||
// the in-memory NodeStore, so it can be read and mutated through the API.
|
||||
func (s *TestServer) CreateNode(
|
||||
tb testing.TB,
|
||||
user *types.User,
|
||||
hostname string,
|
||||
) *types.Node {
|
||||
tb.Helper()
|
||||
|
||||
node := s.st.CreateRegisteredNodeForTest(user, hostname)
|
||||
// Ensure the User association is present in the NodeStore snapshot; the
|
||||
// database read path preloads it, but the test helper does not.
|
||||
node.User = user
|
||||
s.st.PutNodeInStoreForTest(*node)
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
// CreateAPIKey mints a non-expiring API key and returns the secret token.
|
||||
func (s *TestServer) CreateAPIKey(tb testing.TB) string {
|
||||
tb.Helper()
|
||||
|
||||
207
hscontrol/servertest/apiv1_nodes_test.go
Normal file
207
hscontrol/servertest/apiv1_nodes_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package servertest_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apiv1 "github.com/juanfont/headscale/gen/api/v1"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAPIv1_GetNode(t *testing.T) {
|
||||
srv, client := apiClient(t)
|
||||
ctx := context.Background()
|
||||
user := srv.CreateUser(t, "alice")
|
||||
node := srv.CreateNode(t, user, "node1")
|
||||
|
||||
resp, err := client.GetNode(ctx, apiv1.GetNodeParams{NodeID: uint64(node.ID)})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint64(node.ID), resp.Node.Value.ID.Value)
|
||||
assert.Equal(t, "alice", resp.Node.Value.User.Value.Name.Value)
|
||||
|
||||
_, err = client.GetNode(ctx, apiv1.GetNodeParams{NodeID: 99999})
|
||||
requireProblem(t, err, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestAPIv1_ListNodes(t *testing.T) {
|
||||
srv, client := apiClient(t)
|
||||
ctx := context.Background()
|
||||
alice := srv.CreateUser(t, "alice")
|
||||
bob := srv.CreateUser(t, "bob")
|
||||
srv.CreateNode(t, alice, "alice1")
|
||||
srv.CreateNode(t, alice, "alice2")
|
||||
srv.CreateNode(t, bob, "bob1")
|
||||
|
||||
all, err := client.ListNodes(ctx, apiv1.ListNodesParams{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, all.Nodes, 3)
|
||||
|
||||
for i := 1; i < len(all.Nodes); i++ {
|
||||
assert.Less(t, all.Nodes[i-1].ID.Value, all.Nodes[i].ID.Value)
|
||||
}
|
||||
|
||||
byUser, err := client.ListNodes(ctx, apiv1.ListNodesParams{User: apiv1.NewOptString("alice")})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, byUser.Nodes, 2)
|
||||
}
|
||||
|
||||
func TestAPIv1_DeleteNode(t *testing.T) {
|
||||
srv, client := apiClient(t)
|
||||
ctx := context.Background()
|
||||
user := srv.CreateUser(t, "alice")
|
||||
node := srv.CreateNode(t, user, "node1")
|
||||
|
||||
require.NoError(t, client.DeleteNode(ctx, apiv1.DeleteNodeParams{NodeID: uint64(node.ID)}))
|
||||
|
||||
_, err := client.GetNode(ctx, apiv1.GetNodeParams{NodeID: uint64(node.ID)})
|
||||
requireProblem(t, err, http.StatusNotFound)
|
||||
|
||||
err = client.DeleteNode(ctx, apiv1.DeleteNodeParams{NodeID: 99999})
|
||||
requireProblem(t, err, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestAPIv1_RenameNode(t *testing.T) {
|
||||
srv, client := apiClient(t)
|
||||
ctx := context.Background()
|
||||
user := srv.CreateUser(t, "alice")
|
||||
node := srv.CreateNode(t, user, "node1")
|
||||
|
||||
resp, err := client.RenameNode(ctx, apiv1.RenameNodeParams{
|
||||
NodeID: uint64(node.ID),
|
||||
NewName: "renamed",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "renamed", resp.Node.Value.GivenName.Value)
|
||||
}
|
||||
|
||||
func TestAPIv1_ExpireNode(t *testing.T) {
|
||||
srv, client := apiClient(t)
|
||||
ctx := context.Background()
|
||||
user := srv.CreateUser(t, "alice")
|
||||
node := srv.CreateNode(t, user, "node1")
|
||||
|
||||
resp, err := client.ExpireNode(ctx, apiv1.ExpireNodeParams{NodeID: uint64(node.ID)})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Node.Value.Expiry.Set, "expiry should be set")
|
||||
|
||||
// Disabling expiry clears it.
|
||||
resp, err = client.ExpireNode(ctx, apiv1.ExpireNodeParams{
|
||||
NodeID: uint64(node.ID),
|
||||
DisableExpiry: apiv1.NewOptBool(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, resp.Node.Value.Expiry.Set, "expiry should be cleared")
|
||||
|
||||
// Setting both expiry and disable_expiry is a 400.
|
||||
_, err = client.ExpireNode(ctx, apiv1.ExpireNodeParams{
|
||||
NodeID: uint64(node.ID),
|
||||
Expiry: apiv1.NewOptDateTime(time.Now()),
|
||||
DisableExpiry: apiv1.NewOptBool(true),
|
||||
})
|
||||
requireProblem(t, err, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestAPIv1_SetApprovedRoutes_ExitNodeExpansion(t *testing.T) {
|
||||
srv, client := apiClient(t)
|
||||
ctx := context.Background()
|
||||
user := srv.CreateUser(t, "alice")
|
||||
node := srv.CreateNode(t, user, "node1")
|
||||
|
||||
resp, err := client.SetApprovedRoutes(
|
||||
ctx,
|
||||
&apiv1.SetApprovedRoutesReq{Routes: []string{"0.0.0.0/0"}},
|
||||
apiv1.SetApprovedRoutesParams{NodeID: uint64(node.ID)},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
// An exit route is expanded to both default routes.
|
||||
assert.Contains(t, resp.Node.Value.ApprovedRoutes, "0.0.0.0/0")
|
||||
assert.Contains(t, resp.Node.Value.ApprovedRoutes, "::/0")
|
||||
}
|
||||
|
||||
func TestAPIv1_SetTags_Validation(t *testing.T) {
|
||||
srv, client := apiClient(t)
|
||||
ctx := context.Background()
|
||||
user := srv.CreateUser(t, "alice")
|
||||
node := srv.CreateNode(t, user, "node1")
|
||||
|
||||
// Empty tag list is a 400.
|
||||
_, err := client.SetTags(
|
||||
ctx,
|
||||
&apiv1.SetTagsReq{Tags: []string{}},
|
||||
apiv1.SetTagsParams{NodeID: uint64(node.ID)},
|
||||
)
|
||||
requireProblem(t, err, http.StatusBadRequest)
|
||||
|
||||
// Malformed tag is a 400.
|
||||
_, err = client.SetTags(
|
||||
ctx,
|
||||
&apiv1.SetTagsReq{Tags: []string{"notatag"}},
|
||||
apiv1.SetTagsParams{NodeID: uint64(node.ID)},
|
||||
)
|
||||
requireProblem(t, err, http.StatusBadRequest)
|
||||
|
||||
// Unknown node is a 404.
|
||||
_, err = client.SetTags(
|
||||
ctx,
|
||||
&apiv1.SetTagsReq{Tags: []string{"tag:test"}},
|
||||
apiv1.SetTagsParams{NodeID: 99999},
|
||||
)
|
||||
requireProblem(t, err, http.StatusNotFound)
|
||||
|
||||
// Unauthorized tag (no tagOwners policy) is a 400.
|
||||
_, err = client.SetTags(
|
||||
ctx,
|
||||
&apiv1.SetTagsReq{Tags: []string{"tag:test"}},
|
||||
apiv1.SetTagsParams{NodeID: uint64(node.ID)},
|
||||
)
|
||||
requireProblem(t, err, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestAPIv1_BackfillNodeIPs(t *testing.T) {
|
||||
_, client := apiClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Without confirmation it is a 400.
|
||||
_, err := client.BackfillNodeIPs(ctx, apiv1.BackfillNodeIPsParams{})
|
||||
requireProblem(t, err, http.StatusBadRequest)
|
||||
|
||||
// Confirmed succeeds.
|
||||
_, err = client.BackfillNodeIPs(ctx, apiv1.BackfillNodeIPsParams{
|
||||
Confirmed: apiv1.NewOptBool(true),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAPIv1_RegisterNode_Errors(t *testing.T) {
|
||||
srv, client := apiClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
srv.CreateUser(t, "alice")
|
||||
|
||||
// Malformed registration key is a 400.
|
||||
_, err := client.RegisterNode(ctx, apiv1.RegisterNodeParams{
|
||||
User: apiv1.NewOptString("alice"),
|
||||
Key: apiv1.NewOptString("not-a-valid-key"),
|
||||
})
|
||||
requireProblem(t, err, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func TestAPIv1_DebugCreateNode(t *testing.T) {
|
||||
srv, client := apiClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
srv.CreateUser(t, "alice")
|
||||
|
||||
resp, err := client.DebugCreateNode(ctx, &apiv1.DebugCreateNodeReq{
|
||||
User: apiv1.NewOptString("alice"),
|
||||
Key: apiv1.NewOptString(types.MustAuthID().String()),
|
||||
Name: apiv1.NewOptString("debug-node"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "debug-node", resp.Node.Value.Name.Value)
|
||||
assert.Equal(t, "alice", resp.Node.Value.User.Value.Name.Value)
|
||||
}
|
||||
Reference in New Issue
Block a user