hscontrol/api/v1: implement auth and policy endpoints

AuthRegister/Approve/Reject and GetPolicy/SetPolicy/CheckPolicy over the state
layer, completing all v1 operations. HTTP-parity tests included.
This commit is contained in:
Kristoffer Dalby
2026-06-17 15:40:55 +00:00
parent 83fb0a4548
commit de85965bd8
4 changed files with 308 additions and 0 deletions
+60
View File
@@ -2,11 +2,15 @@ package apiv1
import (
"context"
"errors"
"net/http"
oas "github.com/juanfont/headscale/gen/api/v1"
"github.com/juanfont/headscale/hscontrol/types"
)
var errAuthRejected = errors.New("auth request rejected")
// HandleBearerAuth validates the API key bearer token against the state layer.
// A missing or malformed Authorization header is reported by ogen before this
// is reached. Any validation failure — a malformed/unknown key (which
@@ -25,3 +29,59 @@ func (s *Server) HandleBearerAuth(
return ctx, nil
}
// AuthRegister registers a node via an auth id; it is an alias of RegisterNode.
func (s *Server) AuthRegister(
ctx context.Context,
req *oas.AuthRegisterReq,
) (*oas.AuthRegisterOK, error) {
resp, err := s.RegisterNode(ctx, oas.RegisterNodeParams{
Key: oas.NewOptString(req.AuthId.Or("")),
User: oas.NewOptString(req.User.Or("")),
})
if err != nil {
return nil, err
}
return &oas.AuthRegisterOK{Node: resp.Node}, nil
}
// AuthApprove approves a pending auth session.
func (s *Server) AuthApprove(_ context.Context, req *oas.AuthApproveReq) error {
authReq, apiErr := s.pendingAuth(req.AuthId.Or(""))
if apiErr != nil {
return apiErr
}
authReq.FinishAuth(types.AuthVerdict{})
return nil
}
// AuthReject rejects a pending auth session.
func (s *Server) AuthReject(_ context.Context, req *oas.AuthRejectReq) error {
authReq, apiErr := s.pendingAuth(req.AuthId.Or(""))
if apiErr != nil {
return apiErr
}
authReq.FinishAuth(types.AuthVerdict{Err: errAuthRejected})
return nil
}
// pendingAuth resolves an auth id to its cached, in-progress auth request.
// An unparseable id is a 400; an unknown one is a 404.
func (s *Server) pendingAuth(authID string) (*types.AuthRequest, *oas.ErrorStatusCode) {
id, err := types.AuthIDFromString(authID)
if err != nil {
return nil, badRequest("invalid auth_id: " + err.Error())
}
authReq, ok := s.state.GetAuthCacheEntry(id)
if !ok {
return nil, notFound("no pending auth session for auth_id " + id.String())
}
return authReq, nil
}
+112
View File
@@ -0,0 +1,112 @@
package apiv1
import (
"context"
"os"
oas "github.com/juanfont/headscale/gen/api/v1"
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
)
// GetPolicy returns the current ACL policy, from the database or the policy
// file depending on the configured policy mode.
func (s *Server) GetPolicy(_ context.Context) (*oas.GetPolicyOK, error) {
switch s.cfg.Policy.Mode {
case types.PolicyModeDB:
p, err := s.state.GetPolicy()
if err != nil {
return nil, internalError("loading ACL from database: " + err.Error())
}
return &oas.GetPolicyOK{
Policy: oas.NewOptString(p.Data),
UpdatedAt: oas.NewOptDateTime(p.UpdatedAt),
}, nil
case types.PolicyModeFile:
absPath := util.AbsolutePathFromConfigPath(s.cfg.Policy.Path)
b, err := os.ReadFile(absPath)
if err != nil {
return nil, internalError("reading policy from path " + absPath + ": " + err.Error())
}
return &oas.GetPolicyOK{Policy: oas.NewOptString(string(b))}, nil
}
return nil, internalError(
"no supported policy mode found in configuration, policy.mode: " +
string(s.cfg.Policy.Mode),
)
}
// SetPolicy stores a new ACL policy (database policy mode only), validating it
// against the live nodes and distributing the resulting changes.
func (s *Server) SetPolicy(_ context.Context, req *oas.SetPolicyReq) (*oas.SetPolicyOK, error) {
if s.cfg.Policy.Mode != types.PolicyModeDB {
return nil, badRequest(types.ErrPolicyUpdateIsDisabled.Error())
}
p := req.Policy.Or("")
// Validate against live nodes, where they exist, before storing.
nodes := s.state.ListNodes()
_, err := s.state.SetPolicy([]byte(p))
if err != nil {
return nil, badRequest("setting policy: " + err.Error())
}
if nodes.Len() > 0 {
_, err = s.state.SSHPolicy(nodes.At(0))
if err != nil {
return nil, badRequest("verifying SSH rules: " + err.Error())
}
}
updated, err := s.state.SetPolicyInDB(p)
if err != nil {
return nil, mapStateError(err)
}
// Always reload so routes are re-evaluated even when the content is unchanged.
cs, err := s.state.ReloadPolicy()
if err != nil {
return nil, internalError("reloading policy: " + err.Error())
}
if len(cs) > 0 {
s.change(cs...)
}
return &oas.SetPolicyOK{
Policy: oas.NewOptString(updated.Data),
UpdatedAt: oas.NewOptDateTime(updated.UpdatedAt),
}, nil
}
// CheckPolicy validates a policy against the live users and nodes without
// storing it. Works regardless of policy mode.
func (s *Server) CheckPolicy(_ context.Context, req *oas.CheckPolicyReq) error {
polB := []byte(req.Policy.Or(""))
users, err := s.state.ListAllUsers()
if err != nil {
return internalError("loading users: " + err.Error())
}
nodes := s.state.ListNodes()
pm, err := policyv2.NewPolicyManager(polB, users, nodes)
if err != nil {
return badRequest(err.Error())
}
_, err = pm.SetPolicy(polB)
if err != nil {
return badRequest(err.Error())
}
return nil
}
+94
View File
@@ -0,0 +1,94 @@
package servertest_test
import (
"context"
"net/http"
"testing"
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_AuthRegister_Errors(t *testing.T) {
srv, client := apiClient(t)
ctx := context.Background()
srv.CreateUser(t, "alice")
// Malformed auth id is a 400.
_, err := client.AuthRegister(ctx, &apiv1.AuthRegisterReq{
User: apiv1.NewOptString("alice"),
AuthId: apiv1.NewOptString("not-valid"),
})
requireProblem(t, err, http.StatusBadRequest)
}
func TestAPIv1_AuthApprove(t *testing.T) {
srv, client := apiClient(t)
ctx := context.Background()
srv.CreateUser(t, "alice")
authID := types.MustAuthID()
_, err := client.DebugCreateNode(ctx, &apiv1.DebugCreateNodeReq{
User: apiv1.NewOptString("alice"),
Key: apiv1.NewOptString(authID.String()),
Name: apiv1.NewOptString("pending-node"),
})
require.NoError(t, err)
authReq, ok := srv.State().GetAuthCacheEntry(authID)
require.True(t, ok)
// FinishAuth sends on an unbuffered channel; drain it so AuthApprove returns.
verdict := make(chan types.AuthVerdict, 1)
go func() { verdict <- <-authReq.WaitForAuth() }()
require.NoError(t, client.AuthApprove(ctx, &apiv1.AuthApproveReq{
AuthId: apiv1.NewOptString(authID.String()),
}))
assert.NoError(t, (<-verdict).Err, "approval verdict should carry no error")
}
func TestAPIv1_AuthReject(t *testing.T) {
srv, client := apiClient(t)
ctx := context.Background()
srv.CreateUser(t, "alice")
authID := types.MustAuthID()
_, err := client.DebugCreateNode(ctx, &apiv1.DebugCreateNodeReq{
User: apiv1.NewOptString("alice"),
Key: apiv1.NewOptString(authID.String()),
Name: apiv1.NewOptString("pending-node"),
})
require.NoError(t, err)
authReq, ok := srv.State().GetAuthCacheEntry(authID)
require.True(t, ok)
verdict := make(chan types.AuthVerdict, 1)
go func() { verdict <- <-authReq.WaitForAuth() }()
require.NoError(t, client.AuthReject(ctx, &apiv1.AuthRejectReq{
AuthId: apiv1.NewOptString(authID.String()),
}))
assert.Error(t, (<-verdict).Err, "rejection verdict should carry an error")
}
func TestAPIv1_AuthApprove_Errors(t *testing.T) {
_, client := apiClient(t)
ctx := context.Background()
// Malformed auth id is a 400.
requireProblem(t, client.AuthApprove(ctx, &apiv1.AuthApproveReq{
AuthId: apiv1.NewOptString("not-valid"),
}), http.StatusBadRequest)
// Unknown (but well-formed) auth id is a 404.
requireProblem(t, client.AuthApprove(ctx, &apiv1.AuthApproveReq{
AuthId: apiv1.NewOptString(types.MustAuthID().String()),
}), http.StatusNotFound)
}
+42
View File
@@ -0,0 +1,42 @@
package servertest_test
import (
"context"
"net/http"
"testing"
apiv1 "github.com/juanfont/headscale/gen/api/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const validPolicy = `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`
func TestAPIv1_SetAndGetPolicy(t *testing.T) {
_, client := apiClient(t)
ctx := context.Background()
setResp, err := client.SetPolicy(ctx, &apiv1.SetPolicyReq{
Policy: apiv1.NewOptString(validPolicy),
})
require.NoError(t, err)
require.NotEmpty(t, setResp.Policy.Value)
getResp, err := client.GetPolicy(ctx)
require.NoError(t, err)
assert.Equal(t, setResp.Policy.Value, getResp.Policy.Value)
}
func TestAPIv1_CheckPolicy(t *testing.T) {
_, client := apiClient(t)
ctx := context.Background()
require.NoError(t, client.CheckPolicy(ctx, &apiv1.CheckPolicyReq{
Policy: apiv1.NewOptString(validPolicy),
}))
// Invalid policy is a 400.
requireProblem(t, client.CheckPolicy(ctx, &apiv1.CheckPolicyReq{
Policy: apiv1.NewOptString("{not valid"),
}), http.StatusBadRequest)
}