diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d690642..78c5c133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Fix subnet routers with Primary Routes [#811](https://github.com/juanfont/headscale/pull/811) - Added support for JSON logs [#653](https://github.com/juanfont/headscale/issues/653) - Add support for generating pre-auth keys with tags [#767](https://github.com/juanfont/headscale/pull/767) +- Add support for evaluating `autoApprovers` ACL entries when a machine is registered [#763](https://github.com/juanfont/headscale/pull/763) ## 0.16.4 (2022-08-21) diff --git a/acls_types.go b/acls_types.go index 04c66307..903f848b 100644 --- a/acls_types.go +++ b/acls_types.go @@ -11,11 +11,12 @@ import ( // ACLPolicy represents a Tailscale ACL Policy. type ACLPolicy struct { - Groups Groups `json:"groups" yaml:"groups"` - Hosts Hosts `json:"hosts" yaml:"hosts"` - TagOwners TagOwners `json:"tagOwners" yaml:"tagOwners"` - ACLs []ACL `json:"acls" yaml:"acls"` - Tests []ACLTest `json:"tests" yaml:"tests"` + Groups Groups `json:"groups" yaml:"groups"` + Hosts Hosts `json:"hosts" yaml:"hosts"` + TagOwners TagOwners `json:"tagOwners" yaml:"tagOwners"` + ACLs []ACL `json:"acls" yaml:"acls"` + Tests []ACLTest `json:"tests" yaml:"tests"` + AutoApprovers AutoApprovers `json:"autoApprovers" yaml:"autoApprovers"` } // ACL is a basic rule for the ACL Policy. @@ -42,6 +43,13 @@ type ACLTest struct { Deny []string `json:"deny,omitempty" yaml:"deny,omitempty"` } +// AutoApprovers specify which users (namespaces?), groups or tags have their advertised routes +// or exit node status automatically enabled. +type AutoApprovers struct { + Routes map[string][]string `json:"routes" yaml:"routes"` + ExitNode []string `json:"exitNode" yaml:"exitNode"` +} + // UnmarshalJSON allows to parse the Hosts directly into netip objects. func (hosts *Hosts) UnmarshalJSON(data []byte) error { newHosts := Hosts{} @@ -100,3 +108,28 @@ func (policy ACLPolicy) IsZero() bool { return false } + +// Returns the list of autoApproving namespaces, groups or tags for a given IPPrefix. +func (autoApprovers *AutoApprovers) GetRouteApprovers( + prefix netip.Prefix, +) ([]string, error) { + if prefix.Bits() == 0 { + return autoApprovers.ExitNode, nil // 0.0.0.0/0, ::/0 or equivalent + } + + approverAliases := []string{} + + for autoApprovedPrefix, autoApproverAliases := range autoApprovers.Routes { + autoApprovedPrefix, err := netip.ParsePrefix(autoApprovedPrefix) + if err != nil { + return nil, err + } + + if autoApprovedPrefix.Bits() >= prefix.Bits() && + autoApprovedPrefix.Contains(prefix.Masked().Addr()) { + approverAliases = append(approverAliases, autoApproverAliases...) + } + } + + return approverAliases, nil +} diff --git a/machine.go b/machine.go index da980530..92fca36a 100644 --- a/machine.go +++ b/machine.go @@ -949,6 +949,64 @@ func (h *Headscale) EnableRoutes(machine *Machine, routeStrs ...string) error { return nil } +// Enabled any routes advertised by a machine that match the ACL autoApprovers policy. +func (h *Headscale) EnableAutoApprovedRoutes(machine *Machine) { + if len(machine.IPAddresses) == 0 { + return // This machine has no IPAddresses, so can't possibly match any autoApprovers ACLs + } + + approvedRoutes := make([]netip.Prefix, 0, len(machine.HostInfo.RoutableIPs)) + thisMachine := []Machine{*machine} + + for _, advertisedRoute := range machine.HostInfo.RoutableIPs { + if contains(machine.EnabledRoutes, advertisedRoute) { + continue // Skip routes that are already enabled for the node + } + + routeApprovers, err := h.aclPolicy.AutoApprovers.GetRouteApprovers( + advertisedRoute, + ) + if err != nil { + log.Err(err). + Str("advertisedRoute", advertisedRoute.String()). + Uint64("machineId", machine.ID). + Msg("Failed to resolve autoApprovers for advertised route") + + return + } + + for _, approvedAlias := range routeApprovers { + if approvedAlias == machine.Namespace.Name { + approvedRoutes = append(approvedRoutes, advertisedRoute) + } else { + approvedIps, err := expandAlias(thisMachine, *h.aclPolicy, approvedAlias, h.cfg.OIDC.StripEmaildomain) + if err != nil { + log.Err(err). + Str("alias", approvedAlias). + Msg("Failed to expand alias when processing autoApprovers policy") + + return + } + + // approvedIPs should contain all of machine's IPs if it matches the rule, so check for first + if contains(approvedIps, machine.IPAddresses[0].String()) { + approvedRoutes = append(approvedRoutes, advertisedRoute) + } + } + } + } + + for _, approvedRoute := range approvedRoutes { + if !contains(machine.EnabledRoutes, approvedRoute) { + log.Info(). + Str("route", approvedRoute.String()). + Uint64("client", machine.ID). + Msg("Enabling autoApproved route for client") + machine.EnabledRoutes = append(machine.EnabledRoutes, approvedRoute) + } + } +} + func (machine *Machine) RoutesToProto() *v1.Routes { availableRoutes := machine.GetAdvertisedRoutes() diff --git a/machine_test.go b/machine_test.go index 302f159a..275ab14e 100644 --- a/machine_test.go +++ b/machine_test.go @@ -1050,3 +1050,44 @@ func TestHeadscale_GenerateGivenName(t *testing.T) { }) } } + +func (s *Suite) TestAutoApproveRoutes(c *check.C) { + err := app.LoadACLPolicy("./tests/acls/acl_policy_autoapprovers.hujson") + c.Assert(err, check.IsNil) + + namespace, err := app.CreateNamespace("test") + c.Assert(err, check.IsNil) + + pak, err := app.CreatePreAuthKey(namespace.Name, false, false, nil, nil) + c.Assert(err, check.IsNil) + + nodeKey := key.NewNode() + + defaultRoute := netip.MustParsePrefix("0.0.0.0/0") + route1 := netip.MustParsePrefix("10.10.0.0/16") + route2 := netip.MustParsePrefix("10.11.0.0/16") + + machine := Machine{ + ID: 0, + MachineKey: "foo", + NodeKey: NodePublicKeyStripPrefix(nodeKey.Public()), + DiscoKey: "faa", + Hostname: "test", + NamespaceID: namespace.ID, + RegisterMethod: RegisterMethodAuthKey, + AuthKeyID: uint(pak.ID), + HostInfo: HostInfo{ + RequestTags: []string{"tag:exit"}, + RoutableIPs: []netip.Prefix{defaultRoute, route1, route2}, + }, + IPAddresses: []netip.Addr{netip.MustParseAddr("100.64.0.1")}, + } + + app.db.Save(&machine) + + machine0ByID, err := app.GetMachineByID(0) + c.Assert(err, check.IsNil) + + app.EnableAutoApprovedRoutes(machine0ByID) + c.Assert(machine0ByID.GetEnabledRoutes(), check.HasLen, 3) +} diff --git a/protocol_common_poll.go b/protocol_common_poll.go index 6dedfd0b..e697286b 100644 --- a/protocol_common_poll.go +++ b/protocol_common_poll.go @@ -42,7 +42,11 @@ func (h *Headscale) handlePollCommon( Str("machine", machine.Hostname). Err(err) } + + // update routes with peer information + h.EnableAutoApprovedRoutes(machine) } + // From Tailscale client: // // ReadOnly is whether the client just wants to fetch the MapResponse, diff --git a/tests/acls/acl_policy_autoapprovers.hujson b/tests/acls/acl_policy_autoapprovers.hujson new file mode 100644 index 00000000..bf564d88 --- /dev/null +++ b/tests/acls/acl_policy_autoapprovers.hujson @@ -0,0 +1,24 @@ +// This ACL validates autoApprovers support for +// exit nodes and advertised routes + +{ + "tagOwners": { + "tag:exit": ["test"], + }, + + "groups": { + "group:test": ["test"] + }, + + "acls": [ + {"action": "accept", "users": ["*"], "ports": ["*:*"]}, + ], + + "autoApprovers": { + "exitNode": ["tag:exit"], + "routes": { + "10.10.0.0/16": ["group:test"], + "10.11.0.0/16": ["test"], + } + } +} \ No newline at end of file