mirror of
https://github.com/juanfont/headscale.git
synced 2026-08-06 23:38:45 +09:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user