package policy import ( "errors" "math/rand/v2" "net/netip" "slices" "sort" "testing" "github.com/google/go-cmp/cmp" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" "github.com/rs/zerolog/log" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "go4.org/netipx" "gopkg.in/check.v1" "gorm.io/gorm" "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" ) var iap = func(ipStr string) *netip.Addr { ip := netip.MustParseAddr(ipStr) return &ip } func Test(t *testing.T) { check.TestingT(t) } var _ = check.Suite(&Suite{}) type Suite struct{} func (s *Suite) TestWrongPath(c *check.C) { _, err := LoadACLPolicyFromPath("asdfg") c.Assert(err, check.NotNil) } func TestParsing(t *testing.T) { tests := []struct { name string format string acl string want []tailcfg.FilterRule wantErr bool }{ { name: "invalid-hujson", format: "hujson", acl: ` { `, want: []tailcfg.FilterRule{}, wantErr: true, }, { name: "valid-hujson-invalid-content", format: "hujson", acl: ` { "valid_json": true, "but_a_policy_though": false } `, want: []tailcfg.FilterRule{}, wantErr: true, }, { name: "invalid-cidr", format: "hujson", acl: ` {"example-host-1": "100.100.100.100/42"} `, want: []tailcfg.FilterRule{}, wantErr: true, }, { name: "basic-rule", format: "hujson", acl: ` { "hosts": { "host-1": "100.100.100.100", "subnet-1": "100.100.101.100/24", }, "acls": [ { "action": "accept", "src": [ "subnet-1", "192.168.1.0/24" ], "dst": [ "*:22,3389", "host-1:*", ], }, ], } `, want: []tailcfg.FilterRule{ { SrcIPs: []string{"100.100.101.0/24", "192.168.1.0/24"}, DstPorts: []tailcfg.NetPortRange{ {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{First: 3389, Last: 3389}}, {IP: "::/0", Ports: tailcfg.PortRange{First: 22, Last: 22}}, {IP: "::/0", Ports: tailcfg.PortRange{First: 3389, Last: 3389}}, {IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny}, }, }, }, wantErr: false, }, { name: "parse-protocol", format: "hujson", acl: ` { "hosts": { "host-1": "100.100.100.100", "subnet-1": "100.100.101.100/24", }, "acls": [ { "Action": "accept", "src": [ "*", ], "proto": "tcp", "dst": [ "host-1:*", ], }, { "Action": "accept", "src": [ "*", ], "proto": "udp", "dst": [ "host-1:53", ], }, { "Action": "accept", "src": [ "*", ], "proto": "icmp", "dst": [ "host-1:*", ], }, ], }`, want: []tailcfg.FilterRule{ { SrcIPs: []string{"0.0.0.0/0", "::/0"}, DstPorts: []tailcfg.NetPortRange{ {IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{protocolTCP}, }, { SrcIPs: []string{"0.0.0.0/0", "::/0"}, DstPorts: []tailcfg.NetPortRange{ {IP: "100.100.100.100/32", Ports: tailcfg.PortRange{First: 53, Last: 53}}, }, IPProto: []int{protocolUDP}, }, { SrcIPs: []string{"0.0.0.0/0", "::/0"}, DstPorts: []tailcfg.NetPortRange{ {IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny}, }, IPProto: []int{protocolICMP, protocolIPv6ICMP}, }, }, wantErr: false, }, { name: "port-wildcard", format: "hujson", acl: ` { "hosts": { "host-1": "100.100.100.100", "subnet-1": "100.100.101.100/24", }, "acls": [ { "Action": "accept", "src": [ "*", ], "dst": [ "host-1:*", ], }, ], } `, want: []tailcfg.FilterRule{ { SrcIPs: []string{"0.0.0.0/0", "::/0"}, DstPorts: []tailcfg.NetPortRange{ {IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny}, }, }, }, wantErr: false, }, { name: "port-range", format: "hujson", acl: ` { "hosts": { "host-1": "100.100.100.100", "subnet-1": "100.100.101.100/24", }, "acls": [ { "action": "accept", "src": [ "subnet-1", ], "dst": [ "host-1:5400-5500", ], }, ], } `, want: []tailcfg.FilterRule{ { SrcIPs: []string{"100.100.101.0/24"}, DstPorts: []tailcfg.NetPortRange{ { IP: "100.100.100.100/32", Ports: tailcfg.PortRange{First: 5400, Last: 5500}, }, }, }, }, wantErr: false, }, { name: "port-group", format: "hujson", acl: ` { "groups": { "group:example": [ "testuser", ], }, "hosts": { "host-1": "100.100.100.100", "subnet-1": "100.100.101.100/24", }, "acls": [ { "action": "accept", "src": [ "group:example", ], "dst": [ "host-1:*", ], }, ], } `, want: []tailcfg.FilterRule{ { SrcIPs: []string{"200.200.200.200/32"}, DstPorts: []tailcfg.NetPortRange{ {IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny}, }, }, }, wantErr: false, }, { name: "port-user", format: "hujson", acl: ` { "hosts": { "host-1": "100.100.100.100", "subnet-1": "100.100.101.100/24", }, "acls": [ { "action": "accept", "src": [ "testuser", ], "dst": [ "host-1:*", ], }, ], } `, want: []tailcfg.FilterRule{ { SrcIPs: []string{"200.200.200.200/32"}, DstPorts: []tailcfg.NetPortRange{ {IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny}, }, }, }, wantErr: false, }, { name: "ipv6", format: "hujson", acl: ` { "hosts": { "host-1": "100.100.100.100/32", "subnet-1": "100.100.101.100/24", }, "acls": [ { "action": "accept", "src": [ "*", ], "dst": [ "host-1:*", ], }, ], } `, want: []tailcfg.FilterRule{ { SrcIPs: []string{"0.0.0.0/0", "::/0"}, DstPorts: []tailcfg.NetPortRange{ {IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny}, }, }, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { pol, err := LoadACLPolicyFromBytes([]byte(tt.acl)) if tt.wantErr && err == nil { t.Errorf("parsing() error = %v, wantErr %v", err, tt.wantErr) return } else if !tt.wantErr && err != nil { t.Errorf("parsing() error = %v, wantErr %v", err, tt.wantErr) return } if err != nil { return } user := types.User{ Model: gorm.Model{ID: 1}, Name: "testuser", } rules, err := pol.CompileFilterRules( []types.User{ user, }, types.Nodes{ &types.Node{ IPv4: iap("100.100.100.100"), }, &types.Node{ IPv4: iap("200.200.200.200"), User: user, Hostinfo: &tailcfg.Hostinfo{}, }, }) if (err != nil) != tt.wantErr { t.Errorf("parsing() error = %v, wantErr %v", err, tt.wantErr) return } if diff := cmp.Diff(tt.want, rules); diff != "" { t.Errorf("parsing() unexpected result (-want +got):\n%s", diff) } }) } } func (s *Suite) TestRuleInvalidGeneration(c *check.C) { acl := []byte(` { // Declare static groups of users beyond those in the identity service. "groups": { "group:example": [ "user1@example.com", "user2@example.com", ], }, // Declare hostname aliases to use in place of IP addresses or subnets. "hosts": { "example-host-1": "100.100.100.100", "example-host-2": "100.100.101.100/24", }, // Define who is allowed to use which tags. "tagOwners": { // Everyone in the montreal-admins or global-admins group are // allowed to tag servers as montreal-webserver. "tag:montreal-webserver": [ "group:montreal-admins", "group:global-admins", ], // Only a few admins are allowed to create API servers. "tag:api-server": [ "group:global-admins", "example-host-1", ], }, // Access control lists. "acls": [ // Engineering users, plus the president, can access port 22 (ssh) // and port 3389 (remote desktop protocol) on all servers, and all // ports on git-server or ci-server. { "action": "accept", "src": [ "group:engineering", "president@example.com" ], "dst": [ "*:22,3389", "git-server:*", "ci-server:*" ], }, // Allow engineer users to access any port on a device tagged with // tag:production. { "action": "accept", "src": [ "group:engineers" ], "dst": [ "tag:production:*" ], }, // Allow servers in the my-subnet host and 192.168.1.0/24 to access hosts // on both networks. { "action": "accept", "src": [ "my-subnet", "192.168.1.0/24" ], "dst": [ "my-subnet:*", "192.168.1.0/24:*" ], }, // Allow every user of your network to access anything on the network. // Comment out this section if you want to define specific ACL // restrictions above. { "action": "accept", "src": [ "*" ], "dst": [ "*:*" ], }, // All users in Montreal are allowed to access the Montreal web // servers. { "action": "accept", "src": [ "group:montreal-users" ], "dst": [ "tag:montreal-webserver:80,443" ], }, // Montreal web servers are allowed to make outgoing connections to // the API servers, but only on https port 443. // In contrast, this doesn't grant API servers the right to initiate // any connections. { "action": "accept", "src": [ "tag:montreal-webserver" ], "dst": [ "tag:api-server:443" ], }, ], // Declare tests to check functionality of ACL rules "tests": [ { "src": "user1@example.com", "accept": [ "example-host-1:22", "example-host-2:80" ], "deny": [ "example-host-2:100" ], }, { "src": "user2@example.com", "accept": [ "100.60.3.4:22" ], }, ], } `) pol, err := LoadACLPolicyFromBytes(acl) c.Assert(pol.ACLs, check.HasLen, 6) c.Assert(err, check.IsNil) rules, err := pol.CompileFilterRules([]types.User{}, types.Nodes{}) c.Assert(err, check.NotNil) c.Assert(rules, check.IsNil) } // TODO(kradalby): Make tests values safe, independent and descriptive. func (s *Suite) TestInvalidAction(c *check.C) { pol := &ACLPolicy{ ACLs: []ACL{ { Action: "invalidAction", Sources: []string{"*"}, Destinations: []string{"*:*"}, }, }, } _, _, err := GenerateFilterAndSSHRulesForTests(pol, &types.Node{}, types.Nodes{}, []types.User{}) c.Assert(errors.Is(err, ErrInvalidAction), check.Equals, true) } func (s *Suite) TestInvalidGroupInGroup(c *check.C) { // this ACL is wrong because the group in Sources sections doesn't exist pol := &ACLPolicy{ Groups: Groups{ "group:test": []string{"foo"}, "group:error": []string{"foo", "group:test"}, }, ACLs: []ACL{ { Action: "accept", Sources: []string{"group:error"}, Destinations: []string{"*:*"}, }, }, } _, _, err := GenerateFilterAndSSHRulesForTests(pol, &types.Node{}, types.Nodes{}, []types.User{}) c.Assert(errors.Is(err, ErrInvalidGroup), check.Equals, true) } func (s *Suite) TestInvalidTagOwners(c *check.C) { // this ACL is wrong because no tagOwners own the requested tag for the server pol := &ACLPolicy{ ACLs: []ACL{ { Action: "accept", Sources: []string{"tag:foo"}, Destinations: []string{"*:*"}, }, }, } _, _, err := GenerateFilterAndSSHRulesForTests(pol, &types.Node{}, types.Nodes{}, []types.User{}) c.Assert(errors.Is(err, ErrInvalidTag), check.Equals, true) } func Test_expandGroup(t *testing.T) { type field struct { pol ACLPolicy } type args struct { group string stripEmail bool } tests := []struct { name string field field args args want []string wantErr bool }{ { name: "simple test", field: field{ pol: ACLPolicy{ Groups: Groups{ "group:test": []string{"user1", "user2", "user3"}, "group:foo": []string{"user2", "user3"}, }, }, }, args: args{ group: "group:test", }, want: []string{"user1", "user2", "user3"}, wantErr: false, }, { name: "InexistentGroup", field: field{ pol: ACLPolicy{ Groups: Groups{ "group:test": []string{"user1", "user2", "user3"}, "group:foo": []string{"user2", "user3"}, }, }, }, args: args{ group: "group:undefined", }, want: []string{}, wantErr: true, }, { name: "Expand emails in group", field: field{ pol: ACLPolicy{ Groups: Groups{ "group:admin": []string{ "joe.bar@gmail.com", "john.doe@yahoo.fr", }, }, }, }, args: args{ group: "group:admin", }, want: []string{"joe.bar@gmail.com", "john.doe@yahoo.fr"}, wantErr: false, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { viper.Set("oidc.strip_email_domain", test.args.stripEmail) got, err := test.field.pol.expandUsersFromGroup( test.args.group, ) if (err != nil) != test.wantErr { t.Errorf("expandGroup() error = %v, wantErr %v", err, test.wantErr) return } if diff := cmp.Diff(test.want, got); diff != "" { t.Errorf("expandGroup() unexpected result (-want +got):\n%s", diff) } }) } } func Test_expandTagOwners(t *testing.T) { type args struct { aclPolicy *ACLPolicy tag string } tests := []struct { name string args args want []string wantErr bool }{ { name: "simple tag expansion", args: args{ aclPolicy: &ACLPolicy{ TagOwners: TagOwners{"tag:test": []string{"user1"}}, }, tag: "tag:test", }, want: []string{"user1"}, wantErr: false, }, { name: "expand with tag and group", args: args{ aclPolicy: &ACLPolicy{ Groups: Groups{"group:foo": []string{"user1", "user2"}}, TagOwners: TagOwners{"tag:test": []string{"group:foo"}}, }, tag: "tag:test", }, want: []string{"user1", "user2"}, wantErr: false, }, { name: "expand with user and group", args: args{ aclPolicy: &ACLPolicy{ Groups: Groups{"group:foo": []string{"user1", "user2"}}, TagOwners: TagOwners{"tag:test": []string{"group:foo", "user3"}}, }, tag: "tag:test", }, want: []string{"user1", "user2", "user3"}, wantErr: false, }, { name: "invalid tag", args: args{ aclPolicy: &ACLPolicy{ TagOwners: TagOwners{"tag:foo": []string{"group:foo", "user1"}}, }, tag: "tag:test", }, want: []string{}, wantErr: true, }, { name: "invalid group", args: args{ aclPolicy: &ACLPolicy{ Groups: Groups{"group:bar": []string{"user1", "user2"}}, TagOwners: TagOwners{"tag:test": []string{"group:foo", "user2"}}, }, tag: "tag:test", }, want: []string{}, wantErr: true, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { got, err := expandOwnersFromTag( test.args.aclPolicy, test.args.tag, ) if (err != nil) != test.wantErr { t.Errorf("expandTagOwners() error = %v, wantErr %v", err, test.wantErr) return } if diff := cmp.Diff(test.want, got); diff != "" { t.Errorf("expandTagOwners() = (-want +got):\n%s", diff) } }) } } func Test_expandPorts(t *testing.T) { type args struct { portsStr string needsWildcard bool } tests := []struct { name string args args want *[]tailcfg.PortRange wantErr bool }{ { name: "wildcard", args: args{portsStr: "*", needsWildcard: true}, want: &[]tailcfg.PortRange{ {First: portRangeBegin, Last: portRangeEnd}, }, wantErr: false, }, { name: "needs wildcard but does not require it", args: args{portsStr: "*", needsWildcard: false}, want: &[]tailcfg.PortRange{ {First: portRangeBegin, Last: portRangeEnd}, }, wantErr: false, }, { name: "needs wildcard but gets port", args: args{portsStr: "80,443", needsWildcard: true}, want: nil, wantErr: true, }, { name: "two Destinations", args: args{portsStr: "80,443", needsWildcard: false}, want: &[]tailcfg.PortRange{ {First: 80, Last: 80}, {First: 443, Last: 443}, }, wantErr: false, }, { name: "a range and a port", args: args{portsStr: "80-1024,443", needsWildcard: false}, want: &[]tailcfg.PortRange{ {First: 80, Last: 1024}, {First: 443, Last: 443}, }, wantErr: false, }, { name: "out of bounds", args: args{portsStr: "854038", needsWildcard: false}, want: nil, wantErr: true, }, { name: "wrong port", args: args{portsStr: "85a38", needsWildcard: false}, want: nil, wantErr: true, }, { name: "wrong port in first", args: args{portsStr: "a-80", needsWildcard: false}, want: nil, wantErr: true, }, { name: "wrong port in last", args: args{portsStr: "80-85a38", needsWildcard: false}, want: nil, wantErr: true, }, { name: "wrong port format", args: args{portsStr: "80-85a38-3", needsWildcard: false}, want: nil, wantErr: true, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { got, err := expandPorts(test.args.portsStr, test.args.needsWildcard) if (err != nil) != test.wantErr { t.Errorf("expandPorts() error = %v, wantErr %v", err, test.wantErr) return } if diff := cmp.Diff(test.want, got); diff != "" { t.Errorf("expandPorts() = (-want +got):\n%s", diff) } }) } } func Test_filterNodesByUser(t *testing.T) { users := []types.User{ {Model: gorm.Model{ID: 1}, Name: "marc"}, {Model: gorm.Model{ID: 2}, Name: "joe", Email: "joe@headscale.net"}, {Model: gorm.Model{ID: 3}, Name: "mikael", Email: "mikael@headscale.net", ProviderIdentifier: "http://oidc.org/1234"}, {Model: gorm.Model{ID: 4}, Name: "mikael2", Email: "mikael@headscale.net"}, {Model: gorm.Model{ID: 5}, Name: "mikael", Email: "mikael2@headscale.net"}, {Model: gorm.Model{ID: 6}, Name: "http://oidc.org/1234", Email: "mikael@headscale.net"}, {Model: gorm.Model{ID: 7}, Name: "1"}, {Model: gorm.Model{ID: 8}, Name: "alex", Email: "alex@headscale.net"}, {Model: gorm.Model{ID: 9}, Name: "alex@headscale.net"}, {Model: gorm.Model{ID: 10}, Email: "http://oidc.org/1234"}, } type args struct { nodes types.Nodes user string } tests := []struct { name string args args want types.Nodes }{ { name: "1 node in user", args: args{ nodes: types.Nodes{ &types.Node{User: users[1]}, }, user: "joe", }, want: types.Nodes{ &types.Node{User: users[1]}, }, }, { name: "3 nodes, 2 in user", args: args{ nodes: types.Nodes{ &types.Node{ID: 1, User: users[1]}, &types.Node{ID: 2, User: users[0]}, &types.Node{ID: 3, User: users[0]}, }, user: "marc", }, want: types.Nodes{ &types.Node{ID: 2, User: users[0]}, &types.Node{ID: 3, User: users[0]}, }, }, { name: "5 nodes, 0 in user", args: args{ nodes: types.Nodes{ &types.Node{ID: 1, User: users[1]}, &types.Node{ID: 2, User: users[0]}, &types.Node{ID: 3, User: users[0]}, &types.Node{ID: 4, User: users[0]}, &types.Node{ID: 5, User: users[0]}, }, user: "mickael", }, want: nil, }, { name: "match-by-provider-ident", args: args{ nodes: types.Nodes{ &types.Node{ID: 1, User: users[1]}, &types.Node{ID: 2, User: users[2]}, }, user: "http://oidc.org/1234", }, want: types.Nodes{ &types.Node{ID: 2, User: users[2]}, }, }, { name: "match-by-email", args: args{ nodes: types.Nodes{ &types.Node{ID: 1, User: users[1]}, &types.Node{ID: 2, User: users[2]}, &types.Node{ID: 8, User: users[7]}, }, user: "joe@headscale.net", }, want: types.Nodes{ &types.Node{ID: 1, User: users[1]}, }, }, { name: "multi-match-is-zero", args: args{ nodes: types.Nodes{ &types.Node{ID: 1, User: users[1]}, &types.Node{ID: 2, User: users[2]}, &types.Node{ID: 3, User: users[3]}, }, user: "mikael@headscale.net", }, want: nil, }, { name: "multi-email-first-match-is-zero", args: args{ nodes: types.Nodes{ // First match email, then provider id &types.Node{ID: 3, User: users[3]}, &types.Node{ID: 2, User: users[2]}, }, user: "mikael@headscale.net", }, want: nil, }, { name: "multi-username-first-match-is-zero", args: args{ nodes: types.Nodes{ // First match username, then provider id &types.Node{ID: 4, User: users[3]}, &types.Node{ID: 2, User: users[2]}, }, user: "mikael", }, want: nil, }, { name: "all-users-duplicate-username-random-order", args: args{ nodes: types.Nodes{ &types.Node{ID: 1, User: users[0]}, &types.Node{ID: 2, User: users[1]}, &types.Node{ID: 3, User: users[2]}, &types.Node{ID: 4, User: users[3]}, &types.Node{ID: 5, User: users[4]}, }, user: "mikael", }, want: nil, }, { name: "all-users-unique-username-random-order", args: args{ nodes: types.Nodes{ &types.Node{ID: 1, User: users[0]}, &types.Node{ID: 2, User: users[1]}, &types.Node{ID: 3, User: users[2]}, &types.Node{ID: 4, User: users[3]}, &types.Node{ID: 5, User: users[4]}, }, user: "marc", }, want: types.Nodes{ &types.Node{ID: 1, User: users[0]}, }, }, { name: "all-users-no-username-random-order", args: args{ nodes: types.Nodes{ &types.Node{ID: 1, User: users[0]}, &types.Node{ID: 2, User: users[1]}, &types.Node{ID: 3, User: users[2]}, &types.Node{ID: 4, User: users[3]}, &types.Node{ID: 5, User: users[4]}, }, user: "not-working", }, want: nil, }, { name: "all-users-duplicate-email-random-order", args: args{ nodes: types.Nodes{ &types.Node{ID: 1, User: users[0]}, &types.Node{ID: 2, User: users[1]}, &types.Node{ID: 3, User: users[2]}, &types.Node{ID: 4, User: users[3]}, &types.Node{ID: 5, User: users[4]}, }, user: "mikael@headscale.net", }, want: nil, }, { name: "all-users-duplicate-email-random-order", args: args{ nodes: types.Nodes{ &types.Node{ID: 1, User: users[0]}, &types.Node{ID: 2, User: users[1]}, &types.Node{ID: 3, User: users[2]}, &types.Node{ID: 4, User: users[3]}, &types.Node{ID: 5, User: users[4]}, &types.Node{ID: 8, User: users[7]}, }, user: "joe@headscale.net", }, want: types.Nodes{ &types.Node{ID: 2, User: users[1]}, }, }, { name: "email-as-username-duplicate", args: args{ nodes: types.Nodes{ &types.Node{ID: 1, User: users[7]}, &types.Node{ID: 2, User: users[8]}, }, user: "alex@headscale.net", }, want: nil, }, { name: "all-users-no-email-random-order", args: args{ nodes: types.Nodes{ &types.Node{ID: 1, User: users[0]}, &types.Node{ID: 2, User: users[1]}, &types.Node{ID: 3, User: users[2]}, &types.Node{ID: 4, User: users[3]}, &types.Node{ID: 5, User: users[4]}, }, user: "not-working@headscale.net", }, want: nil, }, { name: "all-users-provider-id-random-order", args: args{ nodes: types.Nodes{ &types.Node{ID: 1, User: users[0]}, &types.Node{ID: 2, User: users[1]}, &types.Node{ID: 3, User: users[2]}, &types.Node{ID: 4, User: users[3]}, &types.Node{ID: 5, User: users[4]}, &types.Node{ID: 6, User: users[5]}, }, user: "http://oidc.org/1234", }, want: types.Nodes{ &types.Node{ID: 3, User: users[2]}, }, }, { name: "all-users-no-provider-id-random-order", args: args{ nodes: types.Nodes{ &types.Node{ID: 1, User: users[0]}, &types.Node{ID: 2, User: users[1]}, &types.Node{ID: 3, User: users[2]}, &types.Node{ID: 4, User: users[3]}, &types.Node{ID: 5, User: users[4]}, &types.Node{ID: 6, User: users[5]}, }, user: "http://oidc.org/4321", }, want: nil, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { for range 1000 { ns := test.args.nodes rand.Shuffle(len(ns), func(i, j int) { ns[i], ns[j] = ns[j], ns[i] }) us := users rand.Shuffle(len(us), func(i, j int) { us[i], us[j] = us[j], us[i] }) got := filterNodesByUser(ns, us, test.args.user) sort.Slice(got, func(i, j int) bool { return got[i].ID < got[j].ID }) if diff := cmp.Diff(test.want, got, util.Comparers...); diff != "" { t.Errorf("filterNodesByUser() = (-want +got):\n%s", diff) } } }) } } func Test_expandAlias(t *testing.T) { set := func(ips []string, prefixes []string) *netipx.IPSet { var builder netipx.IPSetBuilder for _, ip := range ips { builder.Add(netip.MustParseAddr(ip)) } for _, pre := range prefixes { builder.AddPrefix(netip.MustParsePrefix(pre)) } s, _ := builder.IPSet() return s } users := []types.User{ {Model: gorm.Model{ID: 1}, Name: "joe"}, {Model: gorm.Model{ID: 2}, Name: "marc"}, {Model: gorm.Model{ID: 3}, Name: "mickael"}, } type field struct { pol ACLPolicy } type args struct { nodes types.Nodes aclPolicy ACLPolicy alias string } tests := []struct { name string field field args args want *netipx.IPSet wantErr bool }{ { name: "wildcard", field: field{ pol: ACLPolicy{}, }, args: args{ alias: "*", nodes: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.1"), }, &types.Node{ IPv4: iap("100.78.84.227"), }, }, }, want: set([]string{}, []string{ "0.0.0.0/0", "::/0", }), wantErr: false, }, { name: "simple group", field: field{ pol: ACLPolicy{ Groups: Groups{"group:accountant": []string{"joe", "marc"}}, }, }, args: args{ alias: "group:accountant", nodes: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.1"), User: users[0], }, &types.Node{ IPv4: iap("100.64.0.2"), User: users[0], }, &types.Node{ IPv4: iap("100.64.0.3"), User: users[1], }, &types.Node{ IPv4: iap("100.64.0.4"), User: users[2], }, }, }, want: set([]string{ "100.64.0.1", "100.64.0.2", "100.64.0.3", }, []string{}), wantErr: false, }, { name: "wrong group", field: field{ pol: ACLPolicy{ Groups: Groups{"group:accountant": []string{"joe", "marc"}}, }, }, args: args{ alias: "group:hr", nodes: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.1"), User: users[0], }, &types.Node{ IPv4: iap("100.64.0.2"), User: users[0], }, &types.Node{ IPv4: iap("100.64.0.3"), User: users[1], }, &types.Node{ IPv4: iap("100.64.0.4"), User: users[2], }, }, }, want: set([]string{}, []string{}), wantErr: true, }, { name: "simple ipaddress", field: field{ pol: ACLPolicy{}, }, args: args{ alias: "10.0.0.3", nodes: types.Nodes{}, }, want: set([]string{ "10.0.0.3", }, []string{}), wantErr: false, }, { name: "simple host by ip passed through", field: field{ pol: ACLPolicy{}, }, args: args{ alias: "10.0.0.1", nodes: types.Nodes{}, }, want: set([]string{ "10.0.0.1", }, []string{}), wantErr: false, }, { name: "simple host by ipv4 single ipv4", field: field{ pol: ACLPolicy{}, }, args: args{ alias: "10.0.0.1", nodes: types.Nodes{ &types.Node{ IPv4: iap("10.0.0.1"), User: types.User{Name: "mickael"}, }, }, }, want: set([]string{ "10.0.0.1", }, []string{}), wantErr: false, }, { name: "simple host by ipv4 single dual stack", field: field{ pol: ACLPolicy{}, }, args: args{ alias: "10.0.0.1", nodes: types.Nodes{ &types.Node{ IPv4: iap("10.0.0.1"), IPv6: iap("fd7a:115c:a1e0:ab12:4843:2222:6273:2222"), User: types.User{Name: "mickael"}, }, }, }, want: set([]string{ "10.0.0.1", "fd7a:115c:a1e0:ab12:4843:2222:6273:2222", }, []string{}), wantErr: false, }, { name: "simple host by ipv6 single dual stack", field: field{ pol: ACLPolicy{}, }, args: args{ alias: "fd7a:115c:a1e0:ab12:4843:2222:6273:2222", nodes: types.Nodes{ &types.Node{ IPv4: iap("10.0.0.1"), IPv6: iap("fd7a:115c:a1e0:ab12:4843:2222:6273:2222"), User: types.User{Name: "mickael"}, }, }, }, want: set([]string{ "fd7a:115c:a1e0:ab12:4843:2222:6273:2222", "10.0.0.1", }, []string{}), wantErr: false, }, { name: "simple host by hostname alias", field: field{ pol: ACLPolicy{ Hosts: Hosts{ "testy": netip.MustParsePrefix("10.0.0.132/32"), }, }, }, args: args{ alias: "testy", nodes: types.Nodes{}, }, want: set([]string{}, []string{"10.0.0.132/32"}), wantErr: false, }, { name: "private network", field: field{ pol: ACLPolicy{ Hosts: Hosts{ "homeNetwork": netip.MustParsePrefix("192.168.1.0/24"), }, }, }, args: args{ alias: "homeNetwork", nodes: types.Nodes{}, }, want: set([]string{}, []string{"192.168.1.0/24"}), wantErr: false, }, { name: "simple CIDR", field: field{ pol: ACLPolicy{}, }, args: args{ alias: "10.0.0.0/16", nodes: types.Nodes{}, aclPolicy: ACLPolicy{}, }, want: set([]string{}, []string{"10.0.0.0/16"}), wantErr: false, }, { name: "simple tag", field: field{ pol: ACLPolicy{ TagOwners: TagOwners{"tag:hr-webserver": []string{"joe"}}, }, }, args: args{ alias: "tag:hr-webserver", nodes: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.1"), User: users[0], Hostinfo: &tailcfg.Hostinfo{ OS: "centos", Hostname: "foo", RequestTags: []string{"tag:hr-webserver"}, }, }, &types.Node{ IPv4: iap("100.64.0.2"), User: users[0], Hostinfo: &tailcfg.Hostinfo{ OS: "centos", Hostname: "foo", RequestTags: []string{"tag:hr-webserver"}, }, }, &types.Node{ IPv4: iap("100.64.0.3"), User: users[1], }, &types.Node{ IPv4: iap("100.64.0.4"), User: users[0], }, }, }, want: set([]string{ "100.64.0.1", "100.64.0.2", }, []string{}), wantErr: false, }, { name: "No tag defined", field: field{ pol: ACLPolicy{ Groups: Groups{"group:accountant": []string{"joe", "marc"}}, TagOwners: TagOwners{ "tag:accountant-webserver": []string{"group:accountant"}, }, }, }, args: args{ alias: "tag:hr-webserver", nodes: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.1"), User: types.User{Name: "joe"}, }, &types.Node{ IPv4: iap("100.64.0.2"), User: types.User{Name: "joe"}, }, &types.Node{ IPv4: iap("100.64.0.3"), User: types.User{Name: "marc"}, }, &types.Node{ IPv4: iap("100.64.0.4"), User: types.User{Name: "mickael"}, }, }, }, want: set([]string{}, []string{}), wantErr: true, }, { name: "Forced tag defined", field: field{ pol: ACLPolicy{}, }, args: args{ alias: "tag:hr-webserver", nodes: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.1"), User: users[0], ForcedTags: []string{"tag:hr-webserver"}, }, &types.Node{ IPv4: iap("100.64.0.2"), User: users[0], ForcedTags: []string{"tag:hr-webserver"}, }, &types.Node{ IPv4: iap("100.64.0.3"), User: users[1], }, &types.Node{ IPv4: iap("100.64.0.4"), User: users[2], }, }, }, want: set([]string{"100.64.0.1", "100.64.0.2"}, []string{}), wantErr: false, }, { name: "Forced tag with legitimate tagOwner", field: field{ pol: ACLPolicy{ TagOwners: TagOwners{ "tag:hr-webserver": []string{"joe"}, }, }, }, args: args{ alias: "tag:hr-webserver", nodes: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.1"), User: users[0], ForcedTags: []string{"tag:hr-webserver"}, }, &types.Node{ IPv4: iap("100.64.0.2"), User: users[0], Hostinfo: &tailcfg.Hostinfo{ OS: "centos", Hostname: "foo", RequestTags: []string{"tag:hr-webserver"}, }, }, &types.Node{ IPv4: iap("100.64.0.3"), User: users[1], }, &types.Node{ IPv4: iap("100.64.0.4"), User: users[2], }, }, }, want: set([]string{"100.64.0.1", "100.64.0.2"}, []string{}), wantErr: false, }, { name: "list host in user without correctly tagged servers", field: field{ pol: ACLPolicy{ TagOwners: TagOwners{"tag:accountant-webserver": []string{"joe"}}, }, }, args: args{ alias: "joe", nodes: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.1"), User: types.User{Name: "joe"}, Hostinfo: &tailcfg.Hostinfo{ OS: "centos", Hostname: "foo", RequestTags: []string{"tag:accountant-webserver"}, }, }, &types.Node{ IPv4: iap("100.64.0.2"), User: types.User{Name: "joe"}, Hostinfo: &tailcfg.Hostinfo{ OS: "centos", Hostname: "foo", RequestTags: []string{"tag:accountant-webserver"}, }, }, &types.Node{ IPv4: iap("100.64.0.3"), User: users[1], Hostinfo: &tailcfg.Hostinfo{}, }, &types.Node{ IPv4: iap("100.64.0.4"), User: users[0], Hostinfo: &tailcfg.Hostinfo{}, }, }, }, want: set([]string{"100.64.0.4"}, []string{}), wantErr: false, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { got, err := test.field.pol.ExpandAlias( test.args.nodes, users, test.args.alias, ) if (err != nil) != test.wantErr { t.Errorf("expandAlias() error = %v, wantErr %v", err, test.wantErr) return } if diff := cmp.Diff(test.want, got); diff != "" { t.Errorf("expandAlias() unexpected result (-want +got):\n%s", diff) } }) } } func Test_excludeCorrectlyTaggedNodes(t *testing.T) { type args struct { aclPolicy *ACLPolicy nodes types.Nodes user string } tests := []struct { name string args args want types.Nodes wantErr bool }{ { name: "exclude nodes with valid tags", args: args{ aclPolicy: &ACLPolicy{ TagOwners: TagOwners{"tag:accountant-webserver": []string{"joe"}}, }, nodes: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.1"), User: types.User{Name: "joe"}, Hostinfo: &tailcfg.Hostinfo{ OS: "centos", Hostname: "foo", RequestTags: []string{"tag:accountant-webserver"}, }, }, &types.Node{ IPv4: iap("100.64.0.2"), User: types.User{Name: "joe"}, Hostinfo: &tailcfg.Hostinfo{ OS: "centos", Hostname: "foo", RequestTags: []string{"tag:accountant-webserver"}, }, }, &types.Node{ IPv4: iap("100.64.0.4"), User: types.User{Name: "joe"}, Hostinfo: &tailcfg.Hostinfo{}, }, }, user: "joe", }, want: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.4"), User: types.User{Name: "joe"}, Hostinfo: &tailcfg.Hostinfo{}, }, }, }, { name: "exclude nodes with valid tags, and owner is in a group", args: args{ aclPolicy: &ACLPolicy{ Groups: Groups{ "group:accountant": []string{"joe", "bar"}, }, TagOwners: TagOwners{ "tag:accountant-webserver": []string{"group:accountant"}, }, }, nodes: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.1"), User: types.User{Name: "joe"}, Hostinfo: &tailcfg.Hostinfo{ OS: "centos", Hostname: "foo", RequestTags: []string{"tag:accountant-webserver"}, }, }, &types.Node{ IPv4: iap("100.64.0.2"), User: types.User{Name: "joe"}, Hostinfo: &tailcfg.Hostinfo{ OS: "centos", Hostname: "foo", RequestTags: []string{"tag:accountant-webserver"}, }, }, &types.Node{ IPv4: iap("100.64.0.4"), User: types.User{Name: "joe"}, Hostinfo: &tailcfg.Hostinfo{}, }, }, user: "joe", }, want: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.4"), User: types.User{Name: "joe"}, Hostinfo: &tailcfg.Hostinfo{}, }, }, }, { name: "exclude nodes with valid tags and with forced tags", args: args{ aclPolicy: &ACLPolicy{ TagOwners: TagOwners{"tag:accountant-webserver": []string{"joe"}}, }, nodes: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.1"), User: types.User{Name: "joe"}, Hostinfo: &tailcfg.Hostinfo{ OS: "centos", Hostname: "foo", RequestTags: []string{"tag:accountant-webserver"}, }, }, &types.Node{ IPv4: iap("100.64.0.2"), User: types.User{Name: "joe"}, ForcedTags: []string{"tag:accountant-webserver"}, Hostinfo: &tailcfg.Hostinfo{}, }, &types.Node{ IPv4: iap("100.64.0.4"), User: types.User{Name: "joe"}, Hostinfo: &tailcfg.Hostinfo{}, }, }, user: "joe", }, want: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.4"), User: types.User{Name: "joe"}, Hostinfo: &tailcfg.Hostinfo{}, }, }, }, { name: "all nodes have invalid tags, don't exclude them", args: args{ aclPolicy: &ACLPolicy{ TagOwners: TagOwners{"tag:accountant-webserver": []string{"joe"}}, }, nodes: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.1"), User: types.User{Name: "joe"}, Hostinfo: &tailcfg.Hostinfo{ OS: "centos", Hostname: "hr-web1", RequestTags: []string{"tag:hr-webserver"}, }, }, &types.Node{ IPv4: iap("100.64.0.2"), User: types.User{Name: "joe"}, Hostinfo: &tailcfg.Hostinfo{ OS: "centos", Hostname: "hr-web2", RequestTags: []string{"tag:hr-webserver"}, }, }, &types.Node{ IPv4: iap("100.64.0.4"), User: types.User{Name: "joe"}, Hostinfo: &tailcfg.Hostinfo{}, }, }, user: "joe", }, want: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.1"), User: types.User{Name: "joe"}, Hostinfo: &tailcfg.Hostinfo{ OS: "centos", Hostname: "hr-web1", RequestTags: []string{"tag:hr-webserver"}, }, }, &types.Node{ IPv4: iap("100.64.0.2"), User: types.User{Name: "joe"}, Hostinfo: &tailcfg.Hostinfo{ OS: "centos", Hostname: "hr-web2", RequestTags: []string{"tag:hr-webserver"}, }, }, &types.Node{ IPv4: iap("100.64.0.4"), User: types.User{Name: "joe"}, Hostinfo: &tailcfg.Hostinfo{}, }, }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { got := excludeCorrectlyTaggedNodes( test.args.aclPolicy, test.args.nodes, test.args.user, ) if diff := cmp.Diff(test.want, got, util.Comparers...); diff != "" { t.Errorf("excludeCorrectlyTaggedNodes() (-want +got):\n%s", diff) } }) } } func TestACLPolicy_generateFilterRules(t *testing.T) { type field struct { pol ACLPolicy } type args struct { nodes types.Nodes } tests := []struct { name string field field args args want []tailcfg.FilterRule wantErr bool }{ { name: "no-policy", field: field{}, args: args{}, want: nil, wantErr: false, }, { name: "allow-all", field: field{ pol: ACLPolicy{ ACLs: []ACL{ { Action: "accept", Sources: []string{"*"}, Destinations: []string{"*:*"}, }, }, }, }, args: args{ nodes: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.1"), IPv6: iap("fd7a:115c:a1e0:ab12:4843:2222:6273:2221"), }, }, }, want: []tailcfg.FilterRule{ { SrcIPs: []string{"0.0.0.0/0", "::/0"}, DstPorts: []tailcfg.NetPortRange{ { IP: "0.0.0.0/0", Ports: tailcfg.PortRange{ First: 0, Last: 65535, }, }, { IP: "::/0", Ports: tailcfg.PortRange{ First: 0, Last: 65535, }, }, }, }, }, wantErr: false, }, { name: "host1-can-reach-host2-full", field: field{ pol: ACLPolicy{ ACLs: []ACL{ { Action: "accept", Sources: []string{"100.64.0.2"}, Destinations: []string{"100.64.0.1:*"}, }, }, }, }, args: args{ nodes: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.1"), IPv6: iap("fd7a:115c:a1e0:ab12:4843:2222:6273:2221"), User: types.User{Name: "mickael"}, }, &types.Node{ IPv4: iap("100.64.0.2"), IPv6: iap("fd7a:115c:a1e0:ab12:4843:2222:6273:2222"), User: types.User{Name: "mickael"}, }, }, }, want: []tailcfg.FilterRule{ { SrcIPs: []string{ "100.64.0.2/32", "fd7a:115c:a1e0:ab12:4843:2222:6273:2222/128", }, DstPorts: []tailcfg.NetPortRange{ { IP: "100.64.0.1/32", Ports: tailcfg.PortRange{ First: 0, Last: 65535, }, }, { IP: "fd7a:115c:a1e0:ab12:4843:2222:6273:2221/128", Ports: tailcfg.PortRange{ First: 0, Last: 65535, }, }, }, }, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := tt.field.pol.CompileFilterRules( []types.User{}, tt.args.nodes, ) if (err != nil) != tt.wantErr { t.Errorf("ACLgenerateFilterRules() error = %v, wantErr %v", err, tt.wantErr) return } if diff := cmp.Diff(tt.want, got); diff != "" { log.Trace().Interface("got", got).Msg("result") t.Errorf("ACLgenerateFilterRules() unexpected result (-want +got):\n%s", diff) } }) } } // tsExitNodeDest is the list of destination IP ranges that are allowed when // you dump the filter list from a Tailscale node connected to Tailscale SaaS. var tsExitNodeDest = []tailcfg.NetPortRange{ { IP: "0.0.0.0-9.255.255.255", Ports: tailcfg.PortRangeAny, }, { IP: "11.0.0.0-100.63.255.255", Ports: tailcfg.PortRangeAny, }, { IP: "100.128.0.0-169.253.255.255", Ports: tailcfg.PortRangeAny, }, { IP: "169.255.0.0-172.15.255.255", Ports: tailcfg.PortRangeAny, }, { IP: "172.32.0.0-192.167.255.255", Ports: tailcfg.PortRangeAny, }, { IP: "192.169.0.0-255.255.255.255", Ports: tailcfg.PortRangeAny, }, { IP: "2000::-3fff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", Ports: tailcfg.PortRangeAny, }, } // hsExitNodeDest is the list of destination IP ranges that are allowed when // we use headscale "autogroup:internet". var hsExitNodeDest = []tailcfg.NetPortRange{ {IP: "0.0.0.0/5", Ports: tailcfg.PortRangeAny}, {IP: "8.0.0.0/7", Ports: tailcfg.PortRangeAny}, {IP: "11.0.0.0/8", Ports: tailcfg.PortRangeAny}, {IP: "12.0.0.0/6", Ports: tailcfg.PortRangeAny}, {IP: "16.0.0.0/4", Ports: tailcfg.PortRangeAny}, {IP: "32.0.0.0/3", Ports: tailcfg.PortRangeAny}, {IP: "64.0.0.0/3", Ports: tailcfg.PortRangeAny}, {IP: "96.0.0.0/6", Ports: tailcfg.PortRangeAny}, {IP: "100.0.0.0/10", Ports: tailcfg.PortRangeAny}, {IP: "100.128.0.0/9", Ports: tailcfg.PortRangeAny}, {IP: "101.0.0.0/8", Ports: tailcfg.PortRangeAny}, {IP: "102.0.0.0/7", Ports: tailcfg.PortRangeAny}, {IP: "104.0.0.0/5", Ports: tailcfg.PortRangeAny}, {IP: "112.0.0.0/4", Ports: tailcfg.PortRangeAny}, {IP: "128.0.0.0/3", Ports: tailcfg.PortRangeAny}, {IP: "160.0.0.0/5", Ports: tailcfg.PortRangeAny}, {IP: "168.0.0.0/8", Ports: tailcfg.PortRangeAny}, {IP: "169.0.0.0/9", Ports: tailcfg.PortRangeAny}, {IP: "169.128.0.0/10", Ports: tailcfg.PortRangeAny}, {IP: "169.192.0.0/11", Ports: tailcfg.PortRangeAny}, {IP: "169.224.0.0/12", Ports: tailcfg.PortRangeAny}, {IP: "169.240.0.0/13", Ports: tailcfg.PortRangeAny}, {IP: "169.248.0.0/14", Ports: tailcfg.PortRangeAny}, {IP: "169.252.0.0/15", Ports: tailcfg.PortRangeAny}, {IP: "169.255.0.0/16", Ports: tailcfg.PortRangeAny}, {IP: "170.0.0.0/7", Ports: tailcfg.PortRangeAny}, {IP: "172.0.0.0/12", Ports: tailcfg.PortRangeAny}, {IP: "172.32.0.0/11", Ports: tailcfg.PortRangeAny}, {IP: "172.64.0.0/10", Ports: tailcfg.PortRangeAny}, {IP: "172.128.0.0/9", Ports: tailcfg.PortRangeAny}, {IP: "173.0.0.0/8", Ports: tailcfg.PortRangeAny}, {IP: "174.0.0.0/7", Ports: tailcfg.PortRangeAny}, {IP: "176.0.0.0/4", Ports: tailcfg.PortRangeAny}, {IP: "192.0.0.0/9", Ports: tailcfg.PortRangeAny}, {IP: "192.128.0.0/11", Ports: tailcfg.PortRangeAny}, {IP: "192.160.0.0/13", Ports: tailcfg.PortRangeAny}, {IP: "192.169.0.0/16", Ports: tailcfg.PortRangeAny}, {IP: "192.170.0.0/15", Ports: tailcfg.PortRangeAny}, {IP: "192.172.0.0/14", Ports: tailcfg.PortRangeAny}, {IP: "192.176.0.0/12", Ports: tailcfg.PortRangeAny}, {IP: "192.192.0.0/10", Ports: tailcfg.PortRangeAny}, {IP: "193.0.0.0/8", Ports: tailcfg.PortRangeAny}, {IP: "194.0.0.0/7", Ports: tailcfg.PortRangeAny}, {IP: "196.0.0.0/6", Ports: tailcfg.PortRangeAny}, {IP: "200.0.0.0/5", Ports: tailcfg.PortRangeAny}, {IP: "208.0.0.0/4", Ports: tailcfg.PortRangeAny}, {IP: "224.0.0.0/3", Ports: tailcfg.PortRangeAny}, {IP: "2000::/3", Ports: tailcfg.PortRangeAny}, } func TestTheInternet(t *testing.T) { internetSet := theInternet() internetPrefs := internetSet.Prefixes() for i := range internetPrefs { if internetPrefs[i].String() != hsExitNodeDest[i].IP { t.Errorf("prefix from internet set %q != hsExit list %q", internetPrefs[i].String(), hsExitNodeDest[i].IP) } } if len(internetPrefs) != len(hsExitNodeDest) { t.Fatalf("expected same length of prefixes, internet: %d, hsExit: %d", len(internetPrefs), len(hsExitNodeDest)) } } func TestReduceFilterRules(t *testing.T) { users := []types.User{ {Model: gorm.Model{ID: 1}, Name: "mickael"}, {Model: gorm.Model{ID: 2}, Name: "user1"}, {Model: gorm.Model{ID: 3}, Name: "user2"}, {Model: gorm.Model{ID: 4}, Name: "user100"}, } tests := []struct { name string node *types.Node peers types.Nodes pol ACLPolicy want []tailcfg.FilterRule }{ { name: "host1-can-reach-host2-no-rules", pol: ACLPolicy{ ACLs: []ACL{ { Action: "accept", Sources: []string{"100.64.0.1"}, Destinations: []string{"100.64.0.2:*"}, }, }, }, node: &types.Node{ IPv4: iap("100.64.0.1"), IPv6: iap("fd7a:115c:a1e0:ab12:4843:2222:6273:2221"), User: users[0], }, peers: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.2"), IPv6: iap("fd7a:115c:a1e0:ab12:4843:2222:6273:2222"), User: users[0], }, }, want: []tailcfg.FilterRule{}, }, { name: "1604-subnet-routers-are-preserved", pol: ACLPolicy{ Groups: Groups{ "group:admins": {"user1"}, }, ACLs: []ACL{ { Action: "accept", Sources: []string{"group:admins"}, Destinations: []string{"group:admins:*"}, }, { Action: "accept", Sources: []string{"group:admins"}, Destinations: []string{"10.33.0.0/16:*"}, }, }, }, node: &types.Node{ IPv4: iap("100.64.0.1"), IPv6: iap("fd7a:115c:a1e0::1"), User: users[1], Hostinfo: &tailcfg.Hostinfo{ RoutableIPs: []netip.Prefix{ netip.MustParsePrefix("10.33.0.0/16"), }, }, }, peers: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.2"), IPv6: iap("fd7a:115c:a1e0::2"), User: users[1], }, }, want: []tailcfg.FilterRule{ { SrcIPs: []string{ "100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128", }, DstPorts: []tailcfg.NetPortRange{ { IP: "100.64.0.1/32", Ports: tailcfg.PortRangeAny, }, { IP: "fd7a:115c:a1e0::1/128", Ports: tailcfg.PortRangeAny, }, }, }, { SrcIPs: []string{ "100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128", }, DstPorts: []tailcfg.NetPortRange{ { IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny, }, }, }, }, }, { name: "1786-reducing-breaks-exit-nodes-the-client", pol: ACLPolicy{ Hosts: Hosts{ // Exit node "internal": netip.MustParsePrefix("100.64.0.100/32"), }, Groups: Groups{ "group:team": {"user3", "user2", "user1"}, }, ACLs: []ACL{ { Action: "accept", Sources: []string{"group:team"}, Destinations: []string{ "internal:*", }, }, { Action: "accept", Sources: []string{"group:team"}, Destinations: []string{ "autogroup:internet:*", }, }, }, }, node: &types.Node{ IPv4: iap("100.64.0.1"), IPv6: iap("fd7a:115c:a1e0::1"), User: users[1], }, peers: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.2"), IPv6: iap("fd7a:115c:a1e0::2"), User: users[2], }, // "internal" exit node &types.Node{ IPv4: iap("100.64.0.100"), IPv6: iap("fd7a:115c:a1e0::100"), User: users[3], Hostinfo: &tailcfg.Hostinfo{ RoutableIPs: tsaddr.ExitRoutes(), }, }, }, want: []tailcfg.FilterRule{}, }, { name: "1786-reducing-breaks-exit-nodes-the-exit", pol: ACLPolicy{ Hosts: Hosts{ // Exit node "internal": netip.MustParsePrefix("100.64.0.100/32"), }, Groups: Groups{ "group:team": {"user3", "user2", "user1"}, }, ACLs: []ACL{ { Action: "accept", Sources: []string{"group:team"}, Destinations: []string{ "internal:*", }, }, { Action: "accept", Sources: []string{"group:team"}, Destinations: []string{ "autogroup:internet:*", }, }, }, }, node: &types.Node{ IPv4: iap("100.64.0.100"), IPv6: iap("fd7a:115c:a1e0::100"), User: types.User{Name: "user100"}, Hostinfo: &tailcfg.Hostinfo{ RoutableIPs: tsaddr.ExitRoutes(), }, }, peers: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.2"), IPv6: iap("fd7a:115c:a1e0::2"), User: users[2], }, &types.Node{ IPv4: iap("100.64.0.1"), IPv6: iap("fd7a:115c:a1e0::1"), User: users[1], }, }, want: []tailcfg.FilterRule{ { SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"}, DstPorts: []tailcfg.NetPortRange{ { IP: "100.64.0.100/32", Ports: tailcfg.PortRangeAny, }, { IP: "fd7a:115c:a1e0::100/128", Ports: tailcfg.PortRangeAny, }, }, }, { SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"}, DstPorts: hsExitNodeDest, }, }, }, { name: "1786-reducing-breaks-exit-nodes-the-example-from-issue", pol: ACLPolicy{ Hosts: Hosts{ // Exit node "internal": netip.MustParsePrefix("100.64.0.100/32"), }, Groups: Groups{ "group:team": {"user3", "user2", "user1"}, }, ACLs: []ACL{ { Action: "accept", Sources: []string{"group:team"}, Destinations: []string{ "internal:*", }, }, { Action: "accept", Sources: []string{"group:team"}, Destinations: []string{ "0.0.0.0/5:*", "8.0.0.0/7:*", "11.0.0.0/8:*", "12.0.0.0/6:*", "16.0.0.0/4:*", "32.0.0.0/3:*", "64.0.0.0/2:*", "128.0.0.0/3:*", "160.0.0.0/5:*", "168.0.0.0/6:*", "172.0.0.0/12:*", "172.32.0.0/11:*", "172.64.0.0/10:*", "172.128.0.0/9:*", "173.0.0.0/8:*", "174.0.0.0/7:*", "176.0.0.0/4:*", "192.0.0.0/9:*", "192.128.0.0/11:*", "192.160.0.0/13:*", "192.169.0.0/16:*", "192.170.0.0/15:*", "192.172.0.0/14:*", "192.176.0.0/12:*", "192.192.0.0/10:*", "193.0.0.0/8:*", "194.0.0.0/7:*", "196.0.0.0/6:*", "200.0.0.0/5:*", "208.0.0.0/4:*", }, }, }, }, node: &types.Node{ IPv4: iap("100.64.0.100"), IPv6: iap("fd7a:115c:a1e0::100"), User: users[3], Hostinfo: &tailcfg.Hostinfo{ RoutableIPs: tsaddr.ExitRoutes(), }, }, peers: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.2"), IPv6: iap("fd7a:115c:a1e0::2"), User: users[2], }, &types.Node{ IPv4: iap("100.64.0.1"), IPv6: iap("fd7a:115c:a1e0::1"), User: users[1], }, }, want: []tailcfg.FilterRule{ { SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"}, DstPorts: []tailcfg.NetPortRange{ { IP: "100.64.0.100/32", Ports: tailcfg.PortRangeAny, }, { IP: "fd7a:115c:a1e0::100/128", Ports: tailcfg.PortRangeAny, }, }, }, { SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"}, DstPorts: []tailcfg.NetPortRange{ {IP: "0.0.0.0/5", Ports: tailcfg.PortRangeAny}, {IP: "8.0.0.0/7", Ports: tailcfg.PortRangeAny}, {IP: "11.0.0.0/8", Ports: tailcfg.PortRangeAny}, {IP: "12.0.0.0/6", Ports: tailcfg.PortRangeAny}, {IP: "16.0.0.0/4", Ports: tailcfg.PortRangeAny}, {IP: "32.0.0.0/3", Ports: tailcfg.PortRangeAny}, {IP: "64.0.0.0/2", Ports: tailcfg.PortRangeAny}, {IP: "fd7a:115c:a1e0::1/128", Ports: tailcfg.PortRangeAny}, {IP: "fd7a:115c:a1e0::2/128", Ports: tailcfg.PortRangeAny}, {IP: "fd7a:115c:a1e0::100/128", Ports: tailcfg.PortRangeAny}, {IP: "128.0.0.0/3", Ports: tailcfg.PortRangeAny}, {IP: "160.0.0.0/5", Ports: tailcfg.PortRangeAny}, {IP: "168.0.0.0/6", Ports: tailcfg.PortRangeAny}, {IP: "172.0.0.0/12", Ports: tailcfg.PortRangeAny}, {IP: "172.32.0.0/11", Ports: tailcfg.PortRangeAny}, {IP: "172.64.0.0/10", Ports: tailcfg.PortRangeAny}, {IP: "172.128.0.0/9", Ports: tailcfg.PortRangeAny}, {IP: "173.0.0.0/8", Ports: tailcfg.PortRangeAny}, {IP: "174.0.0.0/7", Ports: tailcfg.PortRangeAny}, {IP: "176.0.0.0/4", Ports: tailcfg.PortRangeAny}, {IP: "192.0.0.0/9", Ports: tailcfg.PortRangeAny}, {IP: "192.128.0.0/11", Ports: tailcfg.PortRangeAny}, {IP: "192.160.0.0/13", Ports: tailcfg.PortRangeAny}, {IP: "192.169.0.0/16", Ports: tailcfg.PortRangeAny}, {IP: "192.170.0.0/15", Ports: tailcfg.PortRangeAny}, {IP: "192.172.0.0/14", Ports: tailcfg.PortRangeAny}, {IP: "192.176.0.0/12", Ports: tailcfg.PortRangeAny}, {IP: "192.192.0.0/10", Ports: tailcfg.PortRangeAny}, {IP: "193.0.0.0/8", Ports: tailcfg.PortRangeAny}, {IP: "194.0.0.0/7", Ports: tailcfg.PortRangeAny}, {IP: "196.0.0.0/6", Ports: tailcfg.PortRangeAny}, {IP: "200.0.0.0/5", Ports: tailcfg.PortRangeAny}, {IP: "208.0.0.0/4", Ports: tailcfg.PortRangeAny}, }, }, }, }, { name: "1786-reducing-breaks-exit-nodes-app-connector-like", pol: ACLPolicy{ Hosts: Hosts{ // Exit node "internal": netip.MustParsePrefix("100.64.0.100/32"), }, Groups: Groups{ "group:team": {"user3", "user2", "user1"}, }, ACLs: []ACL{ { Action: "accept", Sources: []string{"group:team"}, Destinations: []string{ "internal:*", }, }, { Action: "accept", Sources: []string{"group:team"}, Destinations: []string{ "8.0.0.0/8:*", "16.0.0.0/8:*", }, }, }, }, node: &types.Node{ IPv4: iap("100.64.0.100"), IPv6: iap("fd7a:115c:a1e0::100"), User: users[3], Hostinfo: &tailcfg.Hostinfo{ RoutableIPs: []netip.Prefix{netip.MustParsePrefix("8.0.0.0/16"), netip.MustParsePrefix("16.0.0.0/16")}, }, }, peers: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.2"), IPv6: iap("fd7a:115c:a1e0::2"), User: users[2], }, &types.Node{ IPv4: iap("100.64.0.1"), IPv6: iap("fd7a:115c:a1e0::1"), User: users[1], }, }, want: []tailcfg.FilterRule{ { SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"}, DstPorts: []tailcfg.NetPortRange{ { IP: "100.64.0.100/32", Ports: tailcfg.PortRangeAny, }, { IP: "fd7a:115c:a1e0::100/128", Ports: tailcfg.PortRangeAny, }, }, }, { SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"}, DstPorts: []tailcfg.NetPortRange{ { IP: "8.0.0.0/8", Ports: tailcfg.PortRangeAny, }, { IP: "16.0.0.0/8", Ports: tailcfg.PortRangeAny, }, }, }, }, }, { name: "1786-reducing-breaks-exit-nodes-app-connector-like2", pol: ACLPolicy{ Hosts: Hosts{ // Exit node "internal": netip.MustParsePrefix("100.64.0.100/32"), }, Groups: Groups{ "group:team": {"user3", "user2", "user1"}, }, ACLs: []ACL{ { Action: "accept", Sources: []string{"group:team"}, Destinations: []string{ "internal:*", }, }, { Action: "accept", Sources: []string{"group:team"}, Destinations: []string{ "8.0.0.0/16:*", "16.0.0.0/16:*", }, }, }, }, node: &types.Node{ IPv4: iap("100.64.0.100"), IPv6: iap("fd7a:115c:a1e0::100"), User: users[3], Hostinfo: &tailcfg.Hostinfo{ RoutableIPs: []netip.Prefix{netip.MustParsePrefix("8.0.0.0/8"), netip.MustParsePrefix("16.0.0.0/8")}, }, }, peers: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.2"), IPv6: iap("fd7a:115c:a1e0::2"), User: users[2], }, &types.Node{ IPv4: iap("100.64.0.1"), IPv6: iap("fd7a:115c:a1e0::1"), User: users[1], }, }, want: []tailcfg.FilterRule{ { SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"}, DstPorts: []tailcfg.NetPortRange{ { IP: "100.64.0.100/32", Ports: tailcfg.PortRangeAny, }, { IP: "fd7a:115c:a1e0::100/128", Ports: tailcfg.PortRangeAny, }, }, }, { SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"}, DstPorts: []tailcfg.NetPortRange{ { IP: "8.0.0.0/16", Ports: tailcfg.PortRangeAny, }, { IP: "16.0.0.0/16", Ports: tailcfg.PortRangeAny, }, }, }, }, }, { name: "1817-reduce-breaks-32-mask", pol: ACLPolicy{ Hosts: Hosts{ "vlan1": netip.MustParsePrefix("172.16.0.0/24"), "dns1": netip.MustParsePrefix("172.16.0.21/32"), }, Groups: Groups{ "group:access": {"user1"}, }, ACLs: []ACL{ { Action: "accept", Sources: []string{"group:access"}, Destinations: []string{ "tag:access-servers:*", "dns1:*", }, }, }, }, node: &types.Node{ IPv4: iap("100.64.0.100"), IPv6: iap("fd7a:115c:a1e0::100"), User: users[3], Hostinfo: &tailcfg.Hostinfo{ RoutableIPs: []netip.Prefix{netip.MustParsePrefix("172.16.0.0/24")}, }, ForcedTags: []string{"tag:access-servers"}, }, peers: types.Nodes{ &types.Node{ IPv4: iap("100.64.0.1"), IPv6: iap("fd7a:115c:a1e0::1"), User: users[1], }, }, want: []tailcfg.FilterRule{ { SrcIPs: []string{"100.64.0.1/32", "fd7a:115c:a1e0::1/128"}, DstPorts: []tailcfg.NetPortRange{ { IP: "100.64.0.100/32", Ports: tailcfg.PortRangeAny, }, { IP: "fd7a:115c:a1e0::100/128", Ports: tailcfg.PortRangeAny, }, { IP: "172.16.0.21/32", Ports: tailcfg.PortRangeAny, }, }, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, _ := tt.pol.CompileFilterRules( users, append(tt.peers, tt.node), ) got = ReduceFilterRules(tt.node, got) if diff := cmp.Diff(tt.want, got); diff != "" { log.Trace().Interface("got", got).Msg("result") t.Errorf("TestReduceFilterRules() unexpected result (-want +got):\n%s", diff) } }) } } func Test_getTags(t *testing.T) { type args struct { aclPolicy *ACLPolicy node *types.Node } tests := []struct { name string args args wantInvalid []string wantValid []string }{ { name: "valid tag one nodes", args: args{ aclPolicy: &ACLPolicy{ TagOwners: TagOwners{ "tag:valid": []string{"joe"}, }, }, node: &types.Node{ User: types.User{ Name: "joe", }, Hostinfo: &tailcfg.Hostinfo{ RequestTags: []string{"tag:valid"}, }, }, }, wantValid: []string{"tag:valid"}, wantInvalid: nil, }, { name: "invalid tag and valid tag one nodes", args: args{ aclPolicy: &ACLPolicy{ TagOwners: TagOwners{ "tag:valid": []string{"joe"}, }, }, node: &types.Node{ User: types.User{ Name: "joe", }, Hostinfo: &tailcfg.Hostinfo{ RequestTags: []string{"tag:valid", "tag:invalid"}, }, }, }, wantValid: []string{"tag:valid"}, wantInvalid: []string{"tag:invalid"}, }, { name: "multiple invalid and identical tags, should return only one invalid tag", args: args{ aclPolicy: &ACLPolicy{ TagOwners: TagOwners{ "tag:valid": []string{"joe"}, }, }, node: &types.Node{ User: types.User{ Name: "joe", }, Hostinfo: &tailcfg.Hostinfo{ RequestTags: []string{ "tag:invalid", "tag:valid", "tag:invalid", }, }, }, }, wantValid: []string{"tag:valid"}, wantInvalid: []string{"tag:invalid"}, }, { name: "only invalid tags", args: args{ aclPolicy: &ACLPolicy{ TagOwners: TagOwners{ "tag:valid": []string{"joe"}, }, }, node: &types.Node{ User: types.User{ Name: "joe", }, Hostinfo: &tailcfg.Hostinfo{ RequestTags: []string{"tag:invalid", "very-invalid"}, }, }, }, wantValid: nil, wantInvalid: []string{"tag:invalid", "very-invalid"}, }, { name: "empty ACLPolicy should return empty tags and should not panic", args: args{ aclPolicy: &ACLPolicy{}, node: &types.Node{ User: types.User{ Name: "joe", }, Hostinfo: &tailcfg.Hostinfo{ RequestTags: []string{"tag:invalid", "very-invalid"}, }, }, }, wantValid: nil, wantInvalid: []string{"tag:invalid", "very-invalid"}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { gotValid, gotInvalid := test.args.aclPolicy.TagsOfNode( test.args.node, ) for _, valid := range gotValid { if !slices.Contains(test.wantValid, valid) { t.Errorf( "valids: getTags() = %v, want %v", gotValid, test.wantValid, ) break } } for _, invalid := range gotInvalid { if !slices.Contains(test.wantInvalid, invalid) { t.Errorf( "invalids: getTags() = %v, want %v", gotInvalid, test.wantInvalid, ) break } } }) } } func Test_getFilteredByACLPeers(t *testing.T) { type args struct { nodes types.Nodes rules []tailcfg.FilterRule node *types.Node } tests := []struct { name string args args want types.Nodes }{ { name: "all hosts can talk to each other", args: args{ nodes: types.Nodes{ // list of all nodes in the database &types.Node{ ID: 1, IPv4: iap("100.64.0.1"), User: types.User{Name: "joe"}, }, &types.Node{ ID: 2, IPv4: iap("100.64.0.2"), User: types.User{Name: "marc"}, }, &types.Node{ ID: 3, IPv4: iap("100.64.0.3"), User: types.User{Name: "mickael"}, }, }, rules: []tailcfg.FilterRule{ // list of all ACLRules registered { SrcIPs: []string{"100.64.0.1", "100.64.0.2", "100.64.0.3"}, DstPorts: []tailcfg.NetPortRange{ {IP: "*"}, }, }, }, node: &types.Node{ // current nodes ID: 1, IPv4: iap("100.64.0.1"), User: types.User{Name: "joe"}, }, }, want: types.Nodes{ &types.Node{ ID: 2, IPv4: iap("100.64.0.2"), User: types.User{Name: "marc"}, }, &types.Node{ ID: 3, IPv4: iap("100.64.0.3"), User: types.User{Name: "mickael"}, }, }, }, { name: "One host can talk to another, but not all hosts", args: args{ nodes: types.Nodes{ // list of all nodes in the database &types.Node{ ID: 1, IPv4: iap("100.64.0.1"), User: types.User{Name: "joe"}, }, &types.Node{ ID: 2, IPv4: iap("100.64.0.2"), User: types.User{Name: "marc"}, }, &types.Node{ ID: 3, IPv4: iap("100.64.0.3"), User: types.User{Name: "mickael"}, }, }, rules: []tailcfg.FilterRule{ // list of all ACLRules registered { SrcIPs: []string{"100.64.0.1", "100.64.0.2", "100.64.0.3"}, DstPorts: []tailcfg.NetPortRange{ {IP: "100.64.0.2"}, }, }, }, node: &types.Node{ // current nodes ID: 1, IPv4: iap("100.64.0.1"), User: types.User{Name: "joe"}, }, }, want: types.Nodes{ &types.Node{ ID: 2, IPv4: iap("100.64.0.2"), User: types.User{Name: "marc"}, }, }, }, { name: "host cannot directly talk to destination, but return path is authorized", args: args{ nodes: types.Nodes{ // list of all nodes in the database &types.Node{ ID: 1, IPv4: iap("100.64.0.1"), User: types.User{Name: "joe"}, }, &types.Node{ ID: 2, IPv4: iap("100.64.0.2"), User: types.User{Name: "marc"}, }, &types.Node{ ID: 3, IPv4: iap("100.64.0.3"), User: types.User{Name: "mickael"}, }, }, rules: []tailcfg.FilterRule{ // list of all ACLRules registered { SrcIPs: []string{"100.64.0.3"}, DstPorts: []tailcfg.NetPortRange{ {IP: "100.64.0.2"}, }, }, }, node: &types.Node{ // current nodes ID: 2, IPv4: iap("100.64.0.2"), User: types.User{Name: "marc"}, }, }, want: types.Nodes{ &types.Node{ ID: 3, IPv4: iap("100.64.0.3"), User: types.User{Name: "mickael"}, }, }, }, { name: "rules allows all hosts to reach one destination", args: args{ nodes: types.Nodes{ // list of all nodes in the database &types.Node{ ID: 1, IPv4: iap("100.64.0.1"), User: types.User{Name: "joe"}, }, &types.Node{ ID: 2, IPv4: iap("100.64.0.2"), User: types.User{Name: "marc"}, }, &types.Node{ ID: 3, IPv4: iap("100.64.0.3"), User: types.User{Name: "mickael"}, }, }, rules: []tailcfg.FilterRule{ // list of all ACLRules registered { SrcIPs: []string{"*"}, DstPorts: []tailcfg.NetPortRange{ {IP: "100.64.0.2"}, }, }, }, node: &types.Node{ // current nodes ID: 1, IPv4: iap("100.64.0.1"), User: types.User{Name: "joe"}, }, }, want: types.Nodes{ &types.Node{ ID: 2, IPv4: iap("100.64.0.2"), User: types.User{Name: "marc"}, }, }, }, { name: "rules allows all hosts to reach one destination, destination can reach all hosts", args: args{ nodes: types.Nodes{ // list of all nodes in the database &types.Node{ ID: 1, IPv4: iap("100.64.0.1"), User: types.User{Name: "joe"}, }, &types.Node{ ID: 2, IPv4: iap("100.64.0.2"), User: types.User{Name: "marc"}, }, &types.Node{ ID: 3, IPv4: iap("100.64.0.3"), User: types.User{Name: "mickael"}, }, }, rules: []tailcfg.FilterRule{ // list of all ACLRules registered { SrcIPs: []string{"*"}, DstPorts: []tailcfg.NetPortRange{ {IP: "100.64.0.2"}, }, }, }, node: &types.Node{ // current nodes ID: 2, IPv4: iap("100.64.0.2"), User: types.User{Name: "marc"}, }, }, want: types.Nodes{ &types.Node{ ID: 1, IPv4: iap("100.64.0.1"), User: types.User{Name: "joe"}, }, &types.Node{ ID: 3, IPv4: iap("100.64.0.3"), User: types.User{Name: "mickael"}, }, }, }, { name: "rule allows all hosts to reach all destinations", args: args{ nodes: types.Nodes{ // list of all nodes in the database &types.Node{ ID: 1, IPv4: iap("100.64.0.1"), User: types.User{Name: "joe"}, }, &types.Node{ ID: 2, IPv4: iap("100.64.0.2"), User: types.User{Name: "marc"}, }, &types.Node{ ID: 3, IPv4: iap("100.64.0.3"), User: types.User{Name: "mickael"}, }, }, rules: []tailcfg.FilterRule{ // list of all ACLRules registered { SrcIPs: []string{"*"}, DstPorts: []tailcfg.NetPortRange{ {IP: "*"}, }, }, }, node: &types.Node{ // current nodes ID: 2, IPv4: iap("100.64.0.2"), User: types.User{Name: "marc"}, }, }, want: types.Nodes{ &types.Node{ ID: 1, IPv4: iap("100.64.0.1"), User: types.User{Name: "joe"}, }, &types.Node{ ID: 3, IPv4: iap("100.64.0.3"), User: types.User{Name: "mickael"}, }, }, }, { name: "without rule all communications are forbidden", args: args{ nodes: types.Nodes{ // list of all nodes in the database &types.Node{ ID: 1, IPv4: iap("100.64.0.1"), User: types.User{Name: "joe"}, }, &types.Node{ ID: 2, IPv4: iap("100.64.0.2"), User: types.User{Name: "marc"}, }, &types.Node{ ID: 3, IPv4: iap("100.64.0.3"), User: types.User{Name: "mickael"}, }, }, rules: []tailcfg.FilterRule{ // list of all ACLRules registered }, node: &types.Node{ // current nodes ID: 2, IPv4: iap("100.64.0.2"), User: types.User{Name: "marc"}, }, }, want: nil, }, { // Investigating 699 // Found some nodes: [ts-head-8w6paa ts-unstable-lys2ib ts-head-upcrmb ts-unstable-rlwpvr] nodes=ts-head-8w6paa // ACL rules generated ACL=[{"DstPorts":[{"Bits":null,"IP":"*","Ports":{"First":0,"Last":65535}}],"SrcIPs":["fd7a:115c:a1e0::3","100.64.0.3","fd7a:115c:a1e0::4","100.64.0.4"]}] // ACL Cache Map={"100.64.0.3":{"*":{}},"100.64.0.4":{"*":{}},"fd7a:115c:a1e0::3":{"*":{}},"fd7a:115c:a1e0::4":{"*":{}}} name: "issue-699-broken-star", args: args{ nodes: types.Nodes{ // &types.Node{ ID: 1, Hostname: "ts-head-upcrmb", IPv4: iap("100.64.0.3"), IPv6: iap("fd7a:115c:a1e0::3"), User: types.User{Name: "user1"}, }, &types.Node{ ID: 2, Hostname: "ts-unstable-rlwpvr", IPv4: iap("100.64.0.4"), IPv6: iap("fd7a:115c:a1e0::4"), User: types.User{Name: "user1"}, }, &types.Node{ ID: 3, Hostname: "ts-head-8w6paa", IPv4: iap("100.64.0.1"), IPv6: iap("fd7a:115c:a1e0::1"), User: types.User{Name: "user2"}, }, &types.Node{ ID: 4, Hostname: "ts-unstable-lys2ib", IPv4: iap("100.64.0.2"), IPv6: iap("fd7a:115c:a1e0::2"), User: types.User{Name: "user2"}, }, }, rules: []tailcfg.FilterRule{ // list of all ACLRules registered { DstPorts: []tailcfg.NetPortRange{ { IP: "*", Ports: tailcfg.PortRange{First: 0, Last: 65535}, }, }, SrcIPs: []string{ "fd7a:115c:a1e0::3", "100.64.0.3", "fd7a:115c:a1e0::4", "100.64.0.4", }, }, }, node: &types.Node{ // current nodes ID: 3, Hostname: "ts-head-8w6paa", IPv4: iap("100.64.0.1"), IPv6: iap("fd7a:115c:a1e0::1"), User: types.User{Name: "user2"}, }, }, want: types.Nodes{ &types.Node{ ID: 1, Hostname: "ts-head-upcrmb", IPv4: iap("100.64.0.3"), IPv6: iap("fd7a:115c:a1e0::3"), User: types.User{Name: "user1"}, }, &types.Node{ ID: 2, Hostname: "ts-unstable-rlwpvr", IPv4: iap("100.64.0.4"), IPv6: iap("fd7a:115c:a1e0::4"), User: types.User{Name: "user1"}, }, }, }, { name: "failing-edge-case-during-p3-refactor", args: args{ nodes: []*types.Node{ { ID: 1, IPv4: iap("100.64.0.2"), Hostname: "peer1", User: types.User{Name: "mini"}, }, { ID: 2, IPv4: iap("100.64.0.3"), Hostname: "peer2", User: types.User{Name: "peer2"}, }, }, rules: []tailcfg.FilterRule{ { SrcIPs: []string{"100.64.0.1/32"}, DstPorts: []tailcfg.NetPortRange{ {IP: "100.64.0.3/32", Ports: tailcfg.PortRangeAny}, {IP: "::/0", Ports: tailcfg.PortRangeAny}, }, }, }, node: &types.Node{ ID: 0, IPv4: iap("100.64.0.1"), Hostname: "mini", User: types.User{Name: "mini"}, }, }, want: []*types.Node{ { ID: 2, IPv4: iap("100.64.0.3"), Hostname: "peer2", User: types.User{Name: "peer2"}, }, }, }, { name: "p4-host-in-netmap-user2-dest-bug", args: args{ nodes: []*types.Node{ { ID: 1, IPv4: iap("100.64.0.2"), Hostname: "user1-2", User: types.User{Name: "user1"}, }, { ID: 0, IPv4: iap("100.64.0.1"), Hostname: "user1-1", User: types.User{Name: "user1"}, }, { ID: 3, IPv4: iap("100.64.0.4"), Hostname: "user2-2", User: types.User{Name: "user2"}, }, }, rules: []tailcfg.FilterRule{ { SrcIPs: []string{ "100.64.0.3/32", "100.64.0.4/32", "fd7a:115c:a1e0::3/128", "fd7a:115c:a1e0::4/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.64.0.3/32", Ports: tailcfg.PortRangeAny}, {IP: "100.64.0.4/32", Ports: tailcfg.PortRangeAny}, {IP: "fd7a:115c:a1e0::3/128", Ports: tailcfg.PortRangeAny}, {IP: "fd7a:115c:a1e0::4/128", Ports: tailcfg.PortRangeAny}, }, }, { SrcIPs: []string{ "100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.64.0.3/32", Ports: tailcfg.PortRangeAny}, {IP: "100.64.0.4/32", Ports: tailcfg.PortRangeAny}, {IP: "fd7a:115c:a1e0::3/128", Ports: tailcfg.PortRangeAny}, {IP: "fd7a:115c:a1e0::4/128", Ports: tailcfg.PortRangeAny}, }, }, }, node: &types.Node{ ID: 2, IPv4: iap("100.64.0.3"), Hostname: "user-2-1", User: types.User{Name: "user2"}, }, }, want: []*types.Node{ { ID: 1, IPv4: iap("100.64.0.2"), Hostname: "user1-2", User: types.User{Name: "user1"}, }, { ID: 0, IPv4: iap("100.64.0.1"), Hostname: "user1-1", User: types.User{Name: "user1"}, }, { ID: 3, IPv4: iap("100.64.0.4"), Hostname: "user2-2", User: types.User{Name: "user2"}, }, }, }, { name: "p4-host-in-netmap-user1-dest-bug", args: args{ nodes: []*types.Node{ { ID: 1, IPv4: iap("100.64.0.2"), Hostname: "user1-2", User: types.User{Name: "user1"}, }, { ID: 2, IPv4: iap("100.64.0.3"), Hostname: "user-2-1", User: types.User{Name: "user2"}, }, { ID: 3, IPv4: iap("100.64.0.4"), Hostname: "user2-2", User: types.User{Name: "user2"}, }, }, rules: []tailcfg.FilterRule{ { SrcIPs: []string{ "100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.64.0.1/32", Ports: tailcfg.PortRangeAny}, {IP: "100.64.0.2/32", Ports: tailcfg.PortRangeAny}, {IP: "fd7a:115c:a1e0::1/128", Ports: tailcfg.PortRangeAny}, {IP: "fd7a:115c:a1e0::2/128", Ports: tailcfg.PortRangeAny}, }, }, { SrcIPs: []string{ "100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128", }, DstPorts: []tailcfg.NetPortRange{ {IP: "100.64.0.3/32", Ports: tailcfg.PortRangeAny}, {IP: "100.64.0.4/32", Ports: tailcfg.PortRangeAny}, {IP: "fd7a:115c:a1e0::3/128", Ports: tailcfg.PortRangeAny}, {IP: "fd7a:115c:a1e0::4/128", Ports: tailcfg.PortRangeAny}, }, }, }, node: &types.Node{ ID: 0, IPv4: iap("100.64.0.1"), Hostname: "user1-1", User: types.User{Name: "user1"}, }, }, want: []*types.Node{ { ID: 1, IPv4: iap("100.64.0.2"), Hostname: "user1-2", User: types.User{Name: "user1"}, }, { ID: 2, IPv4: iap("100.64.0.3"), Hostname: "user-2-1", User: types.User{Name: "user2"}, }, { ID: 3, IPv4: iap("100.64.0.4"), Hostname: "user2-2", User: types.User{Name: "user2"}, }, }, }, { name: "subnet-router-with-only-route", args: args{ nodes: []*types.Node{ { ID: 1, IPv4: iap("100.64.0.1"), Hostname: "user1", User: types.User{Name: "user1"}, }, { ID: 2, IPv4: iap("100.64.0.2"), Hostname: "router", User: types.User{Name: "router"}, Routes: types.Routes{ types.Route{ NodeID: 2, Prefix: netip.MustParsePrefix("10.33.0.0/16"), IsPrimary: true, Enabled: true, }, }, }, }, rules: []tailcfg.FilterRule{ { SrcIPs: []string{ "100.64.0.1/32", }, DstPorts: []tailcfg.NetPortRange{ {IP: "10.33.0.0/16", Ports: tailcfg.PortRangeAny}, }, }, }, node: &types.Node{ ID: 1, IPv4: iap("100.64.0.1"), Hostname: "user1", User: types.User{Name: "user1"}, }, }, want: []*types.Node{ { ID: 2, IPv4: iap("100.64.0.2"), Hostname: "router", User: types.User{Name: "router"}, Routes: types.Routes{ types.Route{ NodeID: 2, Prefix: netip.MustParsePrefix("10.33.0.0/16"), IsPrimary: true, Enabled: true, }, }, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := FilterNodesByACL( tt.args.node, tt.args.nodes, tt.args.rules, ) if diff := cmp.Diff(tt.want, got, util.Comparers...); diff != "" { t.Errorf("FilterNodesByACL() unexpected result (-want +got):\n%s", diff) } }) } } func TestSSHRules(t *testing.T) { tests := []struct { name string node types.Node peers types.Nodes pol ACLPolicy want *tailcfg.SSHPolicy }{ { name: "peers-can-connect", node: types.Node{ Hostname: "testnodes", IPv4: iap("100.64.99.42"), UserID: 0, User: types.User{ Name: "user1", }, }, peers: types.Nodes{ &types.Node{ Hostname: "testnodes2", IPv4: iap("100.64.0.1"), UserID: 0, User: types.User{ Name: "user1", }, }, }, pol: ACLPolicy{ Groups: Groups{ "group:test": []string{"user1"}, }, Hosts: Hosts{ "client": netip.PrefixFrom(netip.MustParseAddr("100.64.99.42"), 32), }, ACLs: []ACL{ { Action: "accept", Sources: []string{"*"}, Destinations: []string{"*:*"}, }, }, SSHs: []SSH{ { Action: "accept", Sources: []string{"group:test"}, Destinations: []string{"client"}, Users: []string{"autogroup:nonroot"}, }, { Action: "accept", Sources: []string{"*"}, Destinations: []string{"client"}, Users: []string{"autogroup:nonroot"}, }, { Action: "accept", Sources: []string{"group:test"}, Destinations: []string{"100.64.99.42"}, Users: []string{"autogroup:nonroot"}, }, { Action: "accept", Sources: []string{"*"}, Destinations: []string{"100.64.99.42"}, Users: []string{"autogroup:nonroot"}, }, }, }, want: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{ { Principals: []*tailcfg.SSHPrincipal{ { UserLogin: "user1", }, }, SSHUsers: map[string]string{ "autogroup:nonroot": "=", }, Action: &tailcfg.SSHAction{Accept: true, AllowAgentForwarding: true, AllowLocalPortForwarding: true}, }, { SSHUsers: map[string]string{ "autogroup:nonroot": "=", }, Principals: []*tailcfg.SSHPrincipal{ { Any: true, }, }, Action: &tailcfg.SSHAction{Accept: true, AllowAgentForwarding: true, AllowLocalPortForwarding: true}, }, { Principals: []*tailcfg.SSHPrincipal{ { UserLogin: "user1", }, }, SSHUsers: map[string]string{ "autogroup:nonroot": "=", }, Action: &tailcfg.SSHAction{Accept: true, AllowAgentForwarding: true, AllowLocalPortForwarding: true}, }, { SSHUsers: map[string]string{ "autogroup:nonroot": "=", }, Principals: []*tailcfg.SSHPrincipal{ { Any: true, }, }, Action: &tailcfg.SSHAction{Accept: true, AllowAgentForwarding: true, AllowLocalPortForwarding: true}, }, }}, }, { name: "peers-cannot-connect", node: types.Node{ Hostname: "testnodes", IPv4: iap("100.64.0.1"), UserID: 0, User: types.User{ Name: "user1", }, }, peers: types.Nodes{ &types.Node{ Hostname: "testnodes2", IPv4: iap("100.64.99.42"), UserID: 0, User: types.User{ Name: "user1", }, }, }, pol: ACLPolicy{ Groups: Groups{ "group:test": []string{"user1"}, }, Hosts: Hosts{ "client": netip.PrefixFrom(netip.MustParseAddr("100.64.99.42"), 32), }, ACLs: []ACL{ { Action: "accept", Sources: []string{"*"}, Destinations: []string{"*:*"}, }, }, SSHs: []SSH{ { Action: "accept", Sources: []string{"group:test"}, Destinations: []string{"100.64.99.42"}, Users: []string{"autogroup:nonroot"}, }, { Action: "accept", Sources: []string{"*"}, Destinations: []string{"100.64.99.42"}, Users: []string{"autogroup:nonroot"}, }, }, }, want: &tailcfg.SSHPolicy{Rules: nil}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := tt.pol.CompileSSHPolicy(&tt.node, []types.User{}, tt.peers) assert.NoError(t, err) if diff := cmp.Diff(tt.want, got); diff != "" { t.Errorf("TestSSHRules() unexpected result (-want +got):\n%s", diff) } }) } } func TestParseDestination(t *testing.T) { tests := []struct { dest string wantAlias string wantPort string }{ { dest: "git-server:*", wantAlias: "git-server", wantPort: "*", }, { dest: "192.168.1.0/24:22", wantAlias: "192.168.1.0/24", wantPort: "22", }, { dest: "192.168.1.1:22", wantAlias: "192.168.1.1", wantPort: "22", }, { dest: "fd7a:115c:a1e0::2:22", wantAlias: "fd7a:115c:a1e0::2", wantPort: "22", }, { dest: "fd7a:115c:a1e0::2/128:22", wantAlias: "fd7a:115c:a1e0::2/128", wantPort: "22", }, { dest: "tag:montreal-webserver:80,443", wantAlias: "tag:montreal-webserver", wantPort: "80,443", }, { dest: "tag:api-server:443", wantAlias: "tag:api-server", wantPort: "443", }, { dest: "example-host-1:*", wantAlias: "example-host-1", wantPort: "*", }, } for _, tt := range tests { t.Run(tt.dest, func(t *testing.T) { alias, port, _ := parseDestination(tt.dest) if alias != tt.wantAlias { t.Errorf("unexpected alias: want(%s) != got(%s)", tt.wantAlias, alias) } if port != tt.wantPort { t.Errorf("unexpected port: want(%s) != got(%s)", tt.wantPort, port) } }) } } // this test should validate that we can expand a group in a TagOWner section and // match properly the IP's of the related hosts. The owner is valid and the tag is also valid. // the tag is matched in the Sources section. func TestValidExpandTagOwnersInSources(t *testing.T) { hostInfo := tailcfg.Hostinfo{ OS: "centos", Hostname: "testnodes", RequestTags: []string{"tag:test"}, } user := types.User{ Model: gorm.Model{ID: 1}, Name: "user1", } node := &types.Node{ ID: 0, Hostname: "testnodes", IPv4: iap("100.64.0.1"), UserID: 0, User: user, RegisterMethod: util.RegisterMethodAuthKey, Hostinfo: &hostInfo, } pol := &ACLPolicy{ Groups: Groups{"group:test": []string{"user1", "user2"}}, TagOwners: TagOwners{"tag:test": []string{"user3", "group:test"}}, ACLs: []ACL{ { Action: "accept", Sources: []string{"tag:test"}, Destinations: []string{"*:*"}, }, }, } got, _, err := GenerateFilterAndSSHRulesForTests(pol, node, types.Nodes{}, []types.User{user}) assert.NoError(t, err) want := []tailcfg.FilterRule{ { SrcIPs: []string{"100.64.0.1/32"}, DstPorts: []tailcfg.NetPortRange{ {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{Last: 65535}}, {IP: "::/0", Ports: tailcfg.PortRange{Last: 65535}}, }, }, } if diff := cmp.Diff(want, got); diff != "" { t.Errorf("TestValidExpandTagOwnersInSources() unexpected result (-want +got):\n%s", diff) } } // need a test with: // tag on a host that isn't owned by a tag owners. So the user // of the host should be valid. func TestInvalidTagValidUser(t *testing.T) { hostInfo := tailcfg.Hostinfo{ OS: "centos", Hostname: "testnodes", RequestTags: []string{"tag:foo"}, } node := &types.Node{ ID: 1, Hostname: "testnodes", IPv4: iap("100.64.0.1"), UserID: 1, User: types.User{ Model: gorm.Model{ID: 1}, Name: "user1", }, RegisterMethod: util.RegisterMethodAuthKey, Hostinfo: &hostInfo, } pol := &ACLPolicy{ TagOwners: TagOwners{"tag:test": []string{"user1"}}, ACLs: []ACL{ { Action: "accept", Sources: []string{"user1"}, Destinations: []string{"*:*"}, }, }, } got, _, err := GenerateFilterAndSSHRulesForTests(pol, node, types.Nodes{}, []types.User{node.User}) assert.NoError(t, err) want := []tailcfg.FilterRule{ { SrcIPs: []string{"100.64.0.1/32"}, DstPorts: []tailcfg.NetPortRange{ {IP: "0.0.0.0/0", Ports: tailcfg.PortRange{Last: 65535}}, {IP: "::/0", Ports: tailcfg.PortRange{Last: 65535}}, }, }, } if diff := cmp.Diff(want, got); diff != "" { t.Errorf("TestInvalidTagValidUser() unexpected result (-want +got):\n%s", diff) } } // this test should validate that we can expand a group in a TagOWner section and // match properly the IP's of the related hosts. The owner is valid and the tag is also valid. // the tag is matched in the Destinations section. func TestValidExpandTagOwnersInDestinations(t *testing.T) { hostInfo := tailcfg.Hostinfo{ OS: "centos", Hostname: "testnodes", RequestTags: []string{"tag:test"}, } node := &types.Node{ ID: 1, Hostname: "testnodes", IPv4: iap("100.64.0.1"), UserID: 1, User: types.User{ Model: gorm.Model{ID: 1}, Name: "user1", }, RegisterMethod: util.RegisterMethodAuthKey, Hostinfo: &hostInfo, } pol := &ACLPolicy{ Groups: Groups{"group:test": []string{"user1", "user2"}}, TagOwners: TagOwners{"tag:test": []string{"user3", "group:test"}}, ACLs: []ACL{ { Action: "accept", Sources: []string{"*"}, Destinations: []string{"tag:test:*"}, }, }, } // rules, _, err := GenerateFilterRules(pol, &node, peers, false) // c.Assert(err, check.IsNil) // // c.Assert(rules, check.HasLen, 1) // c.Assert(rules[0].DstPorts, check.HasLen, 1) // c.Assert(rules[0].DstPorts[0].IP, check.Equals, "100.64.0.1/32") got, _, err := GenerateFilterAndSSHRulesForTests(pol, node, types.Nodes{}, []types.User{node.User}) assert.NoError(t, err) want := []tailcfg.FilterRule{ { SrcIPs: []string{"0.0.0.0/0", "::/0"}, DstPorts: []tailcfg.NetPortRange{ {IP: "100.64.0.1/32", Ports: tailcfg.PortRange{Last: 65535}}, }, }, } if diff := cmp.Diff(want, got); diff != "" { t.Errorf( "TestValidExpandTagOwnersInDestinations() unexpected result (-want +got):\n%s", diff, ) } } // tag on a host is owned by a tag owner, the tag is valid. // an ACL rule is matching the tag to a user. It should not be valid since the // host should be tied to the tag now. func TestValidTagInvalidUser(t *testing.T) { hostInfo := tailcfg.Hostinfo{ OS: "centos", Hostname: "webserver", RequestTags: []string{"tag:webapp"}, } user := types.User{ Model: gorm.Model{ID: 1}, Name: "user1", } node := &types.Node{ ID: 1, Hostname: "webserver", IPv4: iap("100.64.0.1"), UserID: 1, User: user, RegisterMethod: util.RegisterMethodAuthKey, Hostinfo: &hostInfo, } hostInfo2 := tailcfg.Hostinfo{ OS: "debian", Hostname: "Hostname", } nodes2 := &types.Node{ ID: 2, Hostname: "user", IPv4: iap("100.64.0.2"), UserID: 1, User: user, RegisterMethod: util.RegisterMethodAuthKey, Hostinfo: &hostInfo2, } pol := &ACLPolicy{ TagOwners: TagOwners{"tag:webapp": []string{"user1"}}, ACLs: []ACL{ { Action: "accept", Sources: []string{"user1"}, Destinations: []string{"tag:webapp:80,443"}, }, }, } got, _, err := GenerateFilterAndSSHRulesForTests(pol, node, types.Nodes{nodes2}, []types.User{user}) assert.NoError(t, err) want := []tailcfg.FilterRule{ { SrcIPs: []string{"100.64.0.2/32"}, DstPorts: []tailcfg.NetPortRange{ {IP: "100.64.0.1/32", Ports: tailcfg.PortRange{First: 80, Last: 80}}, {IP: "100.64.0.1/32", Ports: tailcfg.PortRange{First: 443, Last: 443}}, }, }, } if diff := cmp.Diff(want, got); diff != "" { t.Errorf("TestValidTagInvalidUser() unexpected result (-want +got):\n%s", diff) } }