diff --git a/hscontrol/api/v1/auth.go b/hscontrol/api/v1/auth.go index da0c0402..6476ab04 100644 --- a/hscontrol/api/v1/auth.go +++ b/hscontrol/api/v1/auth.go @@ -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 +} diff --git a/hscontrol/api/v1/policy.go b/hscontrol/api/v1/policy.go new file mode 100644 index 00000000..a5ec9521 --- /dev/null +++ b/hscontrol/api/v1/policy.go @@ -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 +} diff --git a/hscontrol/servertest/apiv1_auth_test.go b/hscontrol/servertest/apiv1_auth_test.go new file mode 100644 index 00000000..98233f68 --- /dev/null +++ b/hscontrol/servertest/apiv1_auth_test.go @@ -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) +} diff --git a/hscontrol/servertest/apiv1_policy_test.go b/hscontrol/servertest/apiv1_policy_test.go new file mode 100644 index 00000000..aeb9329a --- /dev/null +++ b/hscontrol/servertest/apiv1_policy_test.go @@ -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) +}