mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-29 11:36:23 +09:00
9fbf7b9b60
Fixes #3408
0.29's BuildPeerMap returns node views, not node IDs, so the peer map
assertion compares IDs read off the views.
(cherry picked from commit e48bc46cc6)
1620 lines
40 KiB
Go
1620 lines
40 KiB
Go
package policy
|
|
|
|
import (
|
|
"fmt"
|
|
"net/netip"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/juanfont/headscale/hscontrol/policy/matcher"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/juanfont/headscale/hscontrol/util"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
"tailscale.com/tailcfg"
|
|
)
|
|
|
|
var ap = func(ipStr string) *netip.Addr {
|
|
ip := netip.MustParseAddr(ipStr)
|
|
return &ip
|
|
}
|
|
|
|
var p = func(prefStr string) netip.Prefix {
|
|
ip := netip.MustParsePrefix(prefStr)
|
|
return ip
|
|
}
|
|
|
|
func TestBuildPeerMapFromPolicy(t *testing.T) {
|
|
n := func(id types.NodeID, ip, hostname, username string, routess ...string) *types.Node {
|
|
routes := make([]netip.Prefix, 0, len(routess))
|
|
for _, route := range routess {
|
|
routes = append(routes, netip.MustParsePrefix(route))
|
|
}
|
|
|
|
return &types.Node{
|
|
ID: id,
|
|
IPv4: ap(ip),
|
|
Hostname: hostname,
|
|
User: &types.User{Name: username},
|
|
Hostinfo: &tailcfg.Hostinfo{
|
|
RoutableIPs: routes,
|
|
},
|
|
ApprovedRoutes: routes,
|
|
}
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
nodes types.Nodes
|
|
policy string
|
|
node *types.Node
|
|
want types.Nodes
|
|
wantMatchers int
|
|
}{
|
|
{
|
|
name: "2788-exit-node-too-visible",
|
|
nodes: types.Nodes{
|
|
n(1, "100.64.0.1", "mobile", "mobile"),
|
|
n(2, "100.64.0.2", "server", "server"),
|
|
n(3, "100.64.0.3", "exit", "server", "0.0.0.0/0", "::/0"),
|
|
},
|
|
policy: `
|
|
{
|
|
"hosts": {
|
|
"mobile": "100.64.0.1/32",
|
|
"server": "100.64.0.2/32",
|
|
"exit": "100.64.0.3/32"
|
|
},
|
|
|
|
"acls": [
|
|
{
|
|
"action": "accept",
|
|
"src": [
|
|
"mobile"
|
|
],
|
|
"dst": [
|
|
"server:80"
|
|
]
|
|
}
|
|
]
|
|
}`,
|
|
node: n(1, "100.64.0.1", "mobile", "mobile"),
|
|
want: types.Nodes{
|
|
n(2, "100.64.0.2", "server", "server"),
|
|
},
|
|
wantMatchers: 1,
|
|
},
|
|
{
|
|
name: "2788-exit-node-autogroup:internet",
|
|
nodes: types.Nodes{
|
|
n(1, "100.64.0.1", "mobile", "mobile"),
|
|
n(2, "100.64.0.2", "server", "server"),
|
|
n(3, "100.64.0.3", "exit", "server", "0.0.0.0/0", "::/0"),
|
|
},
|
|
policy: `
|
|
{
|
|
"hosts": {
|
|
"mobile": "100.64.0.1/32",
|
|
"server": "100.64.0.2/32",
|
|
"exit": "100.64.0.3/32"
|
|
},
|
|
|
|
"acls": [
|
|
{
|
|
"action": "accept",
|
|
"src": [
|
|
"mobile"
|
|
],
|
|
"dst": [
|
|
"server:80"
|
|
]
|
|
},
|
|
{
|
|
"action": "accept",
|
|
"src": [
|
|
"mobile"
|
|
],
|
|
"dst": [
|
|
"autogroup:internet:*"
|
|
]
|
|
}
|
|
]
|
|
}`,
|
|
node: n(1, "100.64.0.1", "mobile", "mobile"),
|
|
// autogroup:internet emits no client packet filter, but it
|
|
// must still produce a matcher: Node.CanAccess uses
|
|
// matcher.DestsIsTheInternet() + IsExitNode() to surface
|
|
// exit-node peers (juanfont/headscale#3212).
|
|
want: types.Nodes{
|
|
n(2, "100.64.0.2", "server", "server"),
|
|
n(3, "100.64.0.3", "exit", "server", "0.0.0.0/0", "::/0"),
|
|
},
|
|
wantMatchers: 1,
|
|
},
|
|
{
|
|
name: "2788-exit-node-0000-route",
|
|
nodes: types.Nodes{
|
|
n(1, "100.64.0.1", "mobile", "mobile"),
|
|
n(2, "100.64.0.2", "server", "server"),
|
|
n(3, "100.64.0.3", "exit", "server", "0.0.0.0/0", "::/0"),
|
|
},
|
|
policy: `
|
|
{
|
|
"hosts": {
|
|
"mobile": "100.64.0.1/32",
|
|
"server": "100.64.0.2/32",
|
|
"exit": "100.64.0.3/32"
|
|
},
|
|
|
|
"acls": [
|
|
{
|
|
"action": "accept",
|
|
"src": [
|
|
"mobile"
|
|
],
|
|
"dst": [
|
|
"server:80"
|
|
]
|
|
},
|
|
{
|
|
"action": "accept",
|
|
"src": [
|
|
"mobile"
|
|
],
|
|
"dst": [
|
|
"0.0.0.0/0:*"
|
|
]
|
|
}
|
|
]
|
|
}`,
|
|
node: n(1, "100.64.0.1", "mobile", "mobile"),
|
|
want: types.Nodes{
|
|
n(2, "100.64.0.2", "server", "server"),
|
|
n(3, "100.64.0.3", "exit", "server", "0.0.0.0/0", "::/0"),
|
|
},
|
|
wantMatchers: 1,
|
|
},
|
|
{
|
|
name: "2788-exit-node-::0-route",
|
|
nodes: types.Nodes{
|
|
n(1, "100.64.0.1", "mobile", "mobile"),
|
|
n(2, "100.64.0.2", "server", "server"),
|
|
n(3, "100.64.0.3", "exit", "server", "0.0.0.0/0", "::/0"),
|
|
},
|
|
policy: `
|
|
{
|
|
"hosts": {
|
|
"mobile": "100.64.0.1/32",
|
|
"server": "100.64.0.2/32",
|
|
"exit": "100.64.0.3/32"
|
|
},
|
|
|
|
"acls": [
|
|
{
|
|
"action": "accept",
|
|
"src": [
|
|
"mobile"
|
|
],
|
|
"dst": [
|
|
"server:80"
|
|
]
|
|
},
|
|
{
|
|
"action": "accept",
|
|
"src": [
|
|
"mobile"
|
|
],
|
|
"dst": [
|
|
"::0/0:*"
|
|
]
|
|
}
|
|
]
|
|
}`,
|
|
node: n(1, "100.64.0.1", "mobile", "mobile"),
|
|
want: types.Nodes{
|
|
n(2, "100.64.0.2", "server", "server"),
|
|
n(3, "100.64.0.3", "exit", "server", "0.0.0.0/0", "::/0"),
|
|
},
|
|
wantMatchers: 1,
|
|
},
|
|
{
|
|
name: "2784-split-exit-node-access",
|
|
nodes: types.Nodes{
|
|
n(1, "100.64.0.1", "user", "user"),
|
|
n(2, "100.64.0.2", "exit1", "exit", "0.0.0.0/0", "::/0"),
|
|
n(3, "100.64.0.3", "exit2", "exit", "0.0.0.0/0", "::/0"),
|
|
n(4, "100.64.0.4", "otheruser", "otheruser"),
|
|
},
|
|
policy: `
|
|
{
|
|
"hosts": {
|
|
"user": "100.64.0.1/32",
|
|
"exit1": "100.64.0.2/32",
|
|
"exit2": "100.64.0.3/32",
|
|
"otheruser": "100.64.0.4/32",
|
|
},
|
|
|
|
"acls": [
|
|
{
|
|
"action": "accept",
|
|
"src": [
|
|
"user"
|
|
],
|
|
"dst": [
|
|
"exit1:*"
|
|
]
|
|
},
|
|
{
|
|
"action": "accept",
|
|
"src": [
|
|
"otheruser"
|
|
],
|
|
"dst": [
|
|
"exit2:*"
|
|
]
|
|
}
|
|
]
|
|
}`,
|
|
node: n(1, "100.64.0.1", "user", "user"),
|
|
want: types.Nodes{
|
|
n(2, "100.64.0.2", "exit1", "exit", "0.0.0.0/0", "::/0"),
|
|
},
|
|
wantMatchers: 2,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
for idx, pmf := range PolicyManagerFuncsForTest([]byte(tt.policy)) {
|
|
t.Run(fmt.Sprintf("%s-index%d", tt.name, idx), func(t *testing.T) {
|
|
var (
|
|
pm PolicyManager
|
|
err error
|
|
)
|
|
|
|
pm, err = pmf(nil, tt.nodes.ViewSlice())
|
|
require.NoError(t, err)
|
|
|
|
matchers, err := pm.MatchersForNode(tt.node.View())
|
|
require.NoError(t, err)
|
|
assert.Len(t, matchers, tt.wantMatchers)
|
|
|
|
var want []types.NodeID
|
|
for _, n := range tt.want {
|
|
want = append(want, n.ID)
|
|
}
|
|
|
|
var got []types.NodeID
|
|
for _, n := range pm.BuildPeerMap(tt.nodes.ViewSlice())[tt.node.ID] {
|
|
got = append(got, n.ID())
|
|
}
|
|
|
|
if !assert.ElementsMatch(t, want, got) {
|
|
t.Log("Matchers: ")
|
|
|
|
for _, m := range matchers {
|
|
t.Log("\t+", m.DebugString())
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSSHPolicyRules(t *testing.T) {
|
|
users := []types.User{
|
|
{Name: "user1", Model: gorm.Model{ID: 1}},
|
|
{Name: "user2", Model: gorm.Model{ID: 2}},
|
|
{Name: "user3", Model: gorm.Model{ID: 3}},
|
|
{Name: "alice", Email: "alice@example.com", Model: gorm.Model{ID: 4}},
|
|
{Name: "bob", Email: "bob@example.com", Model: gorm.Model{ID: 5}},
|
|
}
|
|
|
|
// Create standard node setups used across tests
|
|
nodeUser1 := types.Node{
|
|
Hostname: "user1-device",
|
|
IPv4: ap("100.64.0.1"),
|
|
UserID: new(uint(1)),
|
|
User: new(users[0]),
|
|
}
|
|
nodeUser2 := types.Node{
|
|
Hostname: "user2-device",
|
|
IPv4: ap("100.64.0.2"),
|
|
UserID: new(uint(2)),
|
|
User: new(users[1]),
|
|
}
|
|
|
|
taggedClient := types.Node{
|
|
Hostname: "tagged-client",
|
|
IPv4: ap("100.64.0.4"),
|
|
UserID: new(uint(2)),
|
|
User: new(users[1]),
|
|
Tags: []string{"tag:client"},
|
|
}
|
|
|
|
// Create a tagged server node for valid SSH patterns
|
|
nodeTaggedServer := types.Node{
|
|
Hostname: "tagged-server",
|
|
IPv4: ap("100.64.0.5"),
|
|
UserID: new(uint(1)),
|
|
User: new(users[0]),
|
|
Tags: []string{"tag:server"},
|
|
}
|
|
|
|
// Nodes for localpart tests (users with email addresses)
|
|
nodeAlice := types.Node{
|
|
Hostname: "alice-device",
|
|
IPv4: ap("100.64.0.6"),
|
|
UserID: new(uint(4)),
|
|
User: new(users[3]),
|
|
}
|
|
nodeBob := types.Node{
|
|
Hostname: "bob-device",
|
|
IPv4: ap("100.64.0.7"),
|
|
UserID: new(uint(5)),
|
|
User: new(users[4]),
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
targetNode types.Node
|
|
peers types.Nodes
|
|
policy string
|
|
wantSSH *tailcfg.SSHPolicy
|
|
expectErr bool
|
|
errorMessage string
|
|
}{
|
|
{
|
|
name: "group-to-tag",
|
|
targetNode: nodeTaggedServer,
|
|
peers: types.Nodes{&nodeUser2},
|
|
policy: `{
|
|
"tagOwners": {
|
|
"tag:server": ["user1@"]
|
|
},
|
|
"groups": {
|
|
"group:admins": ["user2@"]
|
|
},
|
|
"ssh": [
|
|
{
|
|
"action": "accept",
|
|
"src": ["group:admins"],
|
|
"dst": ["tag:server"],
|
|
"users": ["autogroup:nonroot"]
|
|
}
|
|
]
|
|
}`,
|
|
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
|
|
{
|
|
Principals: []*tailcfg.SSHPrincipal{
|
|
{NodeIP: "100.64.0.2"},
|
|
},
|
|
SSHUsers: map[string]string{
|
|
"*": "=",
|
|
"root": "",
|
|
},
|
|
Action: &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowAgentForwarding: true,
|
|
AllowLocalPortForwarding: true,
|
|
AllowRemotePortForwarding: true,
|
|
},
|
|
},
|
|
}},
|
|
},
|
|
{
|
|
name: "check-period-specified",
|
|
targetNode: taggedClient,
|
|
peers: types.Nodes{&nodeUser2},
|
|
policy: `{
|
|
"tagOwners": {
|
|
"tag:client": ["user1@"]
|
|
},
|
|
"groups": {
|
|
"group:admins": ["user2@"]
|
|
},
|
|
"ssh": [
|
|
{
|
|
"action": "check",
|
|
"checkPeriod": "24h",
|
|
"src": ["group:admins"],
|
|
"dst": ["tag:client"],
|
|
"users": ["autogroup:nonroot"]
|
|
}
|
|
]
|
|
}`,
|
|
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
|
|
{
|
|
Principals: []*tailcfg.SSHPrincipal{
|
|
{NodeIP: "100.64.0.2"},
|
|
},
|
|
SSHUsers: map[string]string{
|
|
"*": "=",
|
|
"root": "",
|
|
},
|
|
Action: &tailcfg.SSHAction{
|
|
Accept: false,
|
|
SessionDuration: 0,
|
|
HoldAndDelegate: "unused-url/machine/ssh/action/$SRC_NODE_ID/to/$DST_NODE_ID?local_user=$LOCAL_USER",
|
|
AllowAgentForwarding: false,
|
|
AllowLocalPortForwarding: false,
|
|
AllowRemotePortForwarding: false,
|
|
},
|
|
},
|
|
}},
|
|
},
|
|
{
|
|
name: "no-matching-rules",
|
|
targetNode: nodeUser2,
|
|
peers: types.Nodes{&nodeUser1, &nodeTaggedServer},
|
|
policy: `{
|
|
"tagOwners": {
|
|
"tag:server": ["user1@"]
|
|
},
|
|
"groups": {
|
|
"group:admins": ["user1@"]
|
|
},
|
|
"ssh": [
|
|
{
|
|
"action": "accept",
|
|
"src": ["group:admins"],
|
|
"dst": ["tag:server"],
|
|
"users": ["autogroup:nonroot"]
|
|
}
|
|
]
|
|
}`,
|
|
wantSSH: &tailcfg.SSHPolicy{Rules: nil},
|
|
},
|
|
{
|
|
name: "invalid-action",
|
|
targetNode: nodeTaggedServer,
|
|
peers: types.Nodes{&nodeUser2},
|
|
policy: `{
|
|
"tagOwners": {
|
|
"tag:server": ["user1@"]
|
|
},
|
|
"groups": {
|
|
"group:admins": ["user2@"]
|
|
},
|
|
"ssh": [
|
|
{
|
|
"action": "invalid",
|
|
"src": ["group:admins"],
|
|
"dst": ["tag:server"],
|
|
"users": ["autogroup:nonroot"]
|
|
}
|
|
]
|
|
}`,
|
|
expectErr: true,
|
|
errorMessage: `"invalid" is not a valid action`,
|
|
},
|
|
{
|
|
name: "invalid-check-period",
|
|
targetNode: nodeTaggedServer,
|
|
peers: types.Nodes{&nodeUser2},
|
|
policy: `{
|
|
"tagOwners": {
|
|
"tag:server": ["user1@"]
|
|
},
|
|
"groups": {
|
|
"group:admins": ["user2@"]
|
|
},
|
|
"ssh": [
|
|
{
|
|
"action": "check",
|
|
"checkPeriod": "invalid",
|
|
"src": ["group:admins"],
|
|
"dst": ["tag:server"],
|
|
"users": ["autogroup:nonroot"]
|
|
}
|
|
]
|
|
}`,
|
|
expectErr: true,
|
|
errorMessage: `time: invalid duration "invalid"`,
|
|
},
|
|
// `autogroup:invalid` as an SSH user is no longer rejected:
|
|
// SaaS treats every `autogroup:*` user-string as a literal
|
|
// label and compiles it into the SSHUsers map. The compat
|
|
// suite covers this via ssh-malformed-user-autogroup-* — no
|
|
// dedicated case is needed here.
|
|
{
|
|
name: "ssh-user-unknown-autogroup-as-literal",
|
|
targetNode: taggedClient,
|
|
peers: types.Nodes{&nodeUser2},
|
|
policy: `{
|
|
"tagOwners": {
|
|
"tag:client": ["user1@"]
|
|
},
|
|
"groups": {
|
|
"group:admins": ["user2@"]
|
|
},
|
|
"ssh": [
|
|
{
|
|
"action": "accept",
|
|
"src": ["group:admins"],
|
|
"dst": ["tag:client"],
|
|
"users": ["autogroup:invalid"]
|
|
}
|
|
]
|
|
}`,
|
|
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
|
|
{
|
|
Principals: []*tailcfg.SSHPrincipal{
|
|
{NodeIP: "100.64.0.2"},
|
|
},
|
|
SSHUsers: map[string]string{
|
|
"autogroup:invalid": "autogroup:invalid",
|
|
"root": "",
|
|
},
|
|
Action: &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowAgentForwarding: true,
|
|
AllowLocalPortForwarding: true,
|
|
AllowRemotePortForwarding: true,
|
|
},
|
|
},
|
|
}},
|
|
},
|
|
{
|
|
name: "autogroup-nonroot-should-use-wildcard-with-root-excluded",
|
|
targetNode: nodeTaggedServer,
|
|
peers: types.Nodes{&nodeUser2},
|
|
policy: `{
|
|
"tagOwners": {
|
|
"tag:server": ["user1@"]
|
|
},
|
|
"groups": {
|
|
"group:admins": ["user2@"]
|
|
},
|
|
"ssh": [
|
|
{
|
|
"action": "accept",
|
|
"src": ["group:admins"],
|
|
"dst": ["tag:server"],
|
|
"users": ["autogroup:nonroot"]
|
|
}
|
|
]
|
|
}`,
|
|
// autogroup:nonroot should map to wildcard "*" with root excluded
|
|
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
|
|
{
|
|
Principals: []*tailcfg.SSHPrincipal{
|
|
{NodeIP: "100.64.0.2"},
|
|
},
|
|
SSHUsers: map[string]string{
|
|
"*": "=",
|
|
"root": "",
|
|
},
|
|
Action: &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowAgentForwarding: true,
|
|
AllowLocalPortForwarding: true,
|
|
AllowRemotePortForwarding: true,
|
|
},
|
|
},
|
|
}},
|
|
},
|
|
{
|
|
name: "autogroup-nonroot-plus-root-should-use-wildcard-with-root-mapped",
|
|
targetNode: nodeTaggedServer,
|
|
peers: types.Nodes{&nodeUser2},
|
|
policy: `{
|
|
"tagOwners": {
|
|
"tag:server": ["user1@"]
|
|
},
|
|
"groups": {
|
|
"group:admins": ["user2@"]
|
|
},
|
|
"ssh": [
|
|
{
|
|
"action": "accept",
|
|
"src": ["group:admins"],
|
|
"dst": ["tag:server"],
|
|
"users": ["autogroup:nonroot", "root"]
|
|
}
|
|
]
|
|
}`,
|
|
// autogroup:nonroot + root should map to wildcard "*" with root mapped to itself
|
|
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
|
|
{
|
|
Principals: []*tailcfg.SSHPrincipal{
|
|
{NodeIP: "100.64.0.2"},
|
|
},
|
|
SSHUsers: map[string]string{
|
|
"*": "=",
|
|
"root": "root",
|
|
},
|
|
Action: &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowAgentForwarding: true,
|
|
AllowLocalPortForwarding: true,
|
|
AllowRemotePortForwarding: true,
|
|
},
|
|
},
|
|
}},
|
|
},
|
|
{
|
|
name: "specific-users-should-map-to-themselves-not-equals",
|
|
targetNode: nodeTaggedServer,
|
|
peers: types.Nodes{&nodeUser2},
|
|
policy: `{
|
|
"tagOwners": {
|
|
"tag:server": ["user1@"]
|
|
},
|
|
"groups": {
|
|
"group:admins": ["user2@"]
|
|
},
|
|
"ssh": [
|
|
{
|
|
"action": "accept",
|
|
"src": ["group:admins"],
|
|
"dst": ["tag:server"],
|
|
"users": ["ubuntu", "root"]
|
|
}
|
|
]
|
|
}`,
|
|
// specific usernames should map to themselves, not "="
|
|
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
|
|
{
|
|
Principals: []*tailcfg.SSHPrincipal{
|
|
{NodeIP: "100.64.0.2"},
|
|
},
|
|
SSHUsers: map[string]string{
|
|
"root": "root",
|
|
"ubuntu": "ubuntu",
|
|
},
|
|
Action: &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowAgentForwarding: true,
|
|
AllowLocalPortForwarding: true,
|
|
AllowRemotePortForwarding: true,
|
|
},
|
|
},
|
|
}},
|
|
},
|
|
{
|
|
name: "2863-allow-predefined-missing-users",
|
|
targetNode: taggedClient,
|
|
peers: types.Nodes{&nodeUser2},
|
|
policy: `{
|
|
"groups": {
|
|
"group:example-infra": [
|
|
"user2@",
|
|
"not-created-yet@",
|
|
],
|
|
},
|
|
"tagOwners": {
|
|
"tag:client": [
|
|
"user2@"
|
|
],
|
|
},
|
|
"ssh": [
|
|
// Allow infra to ssh to tag:example-infra server as debian
|
|
{
|
|
"action": "accept",
|
|
"src": [
|
|
"group:example-infra"
|
|
],
|
|
"dst": [
|
|
"tag:client",
|
|
],
|
|
"users": [
|
|
"debian",
|
|
],
|
|
},
|
|
],
|
|
}`,
|
|
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
|
|
{
|
|
Principals: []*tailcfg.SSHPrincipal{
|
|
{NodeIP: "100.64.0.2"},
|
|
},
|
|
SSHUsers: map[string]string{
|
|
"debian": "debian",
|
|
"root": "",
|
|
},
|
|
Action: &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowAgentForwarding: true,
|
|
AllowLocalPortForwarding: true,
|
|
AllowRemotePortForwarding: true,
|
|
},
|
|
},
|
|
}},
|
|
},
|
|
{
|
|
name: "localpart-maps-email-to-os-user",
|
|
targetNode: nodeTaggedServer,
|
|
peers: types.Nodes{&nodeAlice, &nodeBob},
|
|
policy: `{
|
|
"tagOwners": {
|
|
"tag:server": ["alice@example.com"]
|
|
},
|
|
"ssh": [
|
|
{
|
|
"action": "accept",
|
|
"src": ["autogroup:member"],
|
|
"dst": ["tag:server"],
|
|
"users": ["localpart:*@example.com"]
|
|
}
|
|
]
|
|
}`,
|
|
// Per-user common+localpart interleaved: each user gets root deny then localpart.
|
|
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
|
|
{
|
|
Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.6"}},
|
|
SSHUsers: map[string]string{"root": ""},
|
|
Action: &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowAgentForwarding: true,
|
|
AllowLocalPortForwarding: true,
|
|
AllowRemotePortForwarding: true,
|
|
},
|
|
},
|
|
{
|
|
Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.6"}},
|
|
SSHUsers: map[string]string{"alice": "alice"},
|
|
Action: &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowAgentForwarding: true,
|
|
AllowLocalPortForwarding: true,
|
|
AllowRemotePortForwarding: true,
|
|
},
|
|
},
|
|
{
|
|
Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.7"}},
|
|
SSHUsers: map[string]string{"root": ""},
|
|
Action: &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowAgentForwarding: true,
|
|
AllowLocalPortForwarding: true,
|
|
AllowRemotePortForwarding: true,
|
|
},
|
|
},
|
|
{
|
|
Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.7"}},
|
|
SSHUsers: map[string]string{"bob": "bob"},
|
|
Action: &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowAgentForwarding: true,
|
|
AllowLocalPortForwarding: true,
|
|
AllowRemotePortForwarding: true,
|
|
},
|
|
},
|
|
}},
|
|
},
|
|
{
|
|
name: "localpart-combined-with-root",
|
|
targetNode: nodeTaggedServer,
|
|
peers: types.Nodes{&nodeAlice},
|
|
policy: `{
|
|
"tagOwners": {
|
|
"tag:server": ["alice@example.com"]
|
|
},
|
|
"ssh": [
|
|
{
|
|
"action": "accept",
|
|
"src": ["autogroup:member"],
|
|
"dst": ["tag:server"],
|
|
"users": ["localpart:*@example.com", "root"]
|
|
}
|
|
]
|
|
}`,
|
|
// Common root rule followed by alice's per-user localpart rule (interleaved).
|
|
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
|
|
{
|
|
Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.6"}},
|
|
SSHUsers: map[string]string{"root": "root"},
|
|
Action: &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowAgentForwarding: true,
|
|
AllowLocalPortForwarding: true,
|
|
AllowRemotePortForwarding: true,
|
|
},
|
|
},
|
|
{
|
|
Principals: []*tailcfg.SSHPrincipal{{NodeIP: "100.64.0.6"}},
|
|
SSHUsers: map[string]string{"alice": "alice"},
|
|
Action: &tailcfg.SSHAction{
|
|
Accept: true,
|
|
AllowAgentForwarding: true,
|
|
AllowLocalPortForwarding: true,
|
|
AllowRemotePortForwarding: true,
|
|
},
|
|
},
|
|
}},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
for idx, pmf := range PolicyManagerFuncsForTest([]byte(tt.policy)) {
|
|
t.Run(fmt.Sprintf("%s-index%d", tt.name, idx), func(t *testing.T) {
|
|
var (
|
|
pm PolicyManager
|
|
err error
|
|
)
|
|
|
|
pm, err = pmf(users, append(tt.peers, &tt.targetNode).ViewSlice())
|
|
|
|
if tt.expectErr {
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), tt.errorMessage)
|
|
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
|
|
got, err := pm.SSHPolicy("unused-url", tt.targetNode.View())
|
|
require.NoError(t, err)
|
|
|
|
if diff := cmp.Diff(tt.wantSSH, got); diff != "" {
|
|
t.Errorf("SSHPolicy() unexpected result (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReduceRoutes(t *testing.T) {
|
|
type args struct {
|
|
node *types.Node
|
|
routes []netip.Prefix
|
|
rules []tailcfg.FilterRule
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
want []netip.Prefix
|
|
}{
|
|
{
|
|
name: "node-can-access-all-routes",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 1,
|
|
IPv4: ap("100.64.0.1"),
|
|
User: &types.User{Name: "user1"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("192.168.1.0/24"),
|
|
netip.MustParsePrefix("172.16.0.0/16"),
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
SrcIPs: []string{"100.64.0.1"},
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "*"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
want: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("192.168.1.0/24"),
|
|
netip.MustParsePrefix("172.16.0.0/16"),
|
|
},
|
|
},
|
|
{
|
|
name: "node-can-access-specific-route",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 1,
|
|
IPv4: ap("100.64.0.1"),
|
|
User: &types.User{Name: "user1"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("192.168.1.0/24"),
|
|
netip.MustParsePrefix("172.16.0.0/16"),
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
SrcIPs: []string{"100.64.0.1"},
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "10.0.0.0/24"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
want: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
},
|
|
},
|
|
{
|
|
name: "node-can-access-multiple-specific-routes",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 1,
|
|
IPv4: ap("100.64.0.1"),
|
|
User: &types.User{Name: "user1"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("192.168.1.0/24"),
|
|
netip.MustParsePrefix("172.16.0.0/16"),
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
SrcIPs: []string{"100.64.0.1"},
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "10.0.0.0/24"},
|
|
{IP: "192.168.1.0/24"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
want: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("192.168.1.0/24"),
|
|
},
|
|
},
|
|
{
|
|
name: "node-can-access-overlapping-routes",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 1,
|
|
IPv4: ap("100.64.0.1"),
|
|
User: &types.User{Name: "user1"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("10.0.0.0/16"), // Overlaps with the first one
|
|
netip.MustParsePrefix("192.168.1.0/24"),
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
SrcIPs: []string{"100.64.0.1"},
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "10.0.0.0/16"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
want: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("10.0.0.0/16"),
|
|
},
|
|
},
|
|
{
|
|
name: "node-with-no-matching-rules",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 1,
|
|
IPv4: ap("100.64.0.1"),
|
|
User: &types.User{Name: "user1"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("192.168.1.0/24"),
|
|
netip.MustParsePrefix("172.16.0.0/16"),
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
SrcIPs: []string{"100.64.0.2"}, // Different source IP
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "*"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "node-with-both-ipv4-and-ipv6",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 1,
|
|
IPv4: ap("100.64.0.1"),
|
|
IPv6: ap("fd7a:115c:a1e0::1"),
|
|
User: &types.User{Name: "user1"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("2001:db8::/64"),
|
|
netip.MustParsePrefix("192.168.1.0/24"),
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
SrcIPs: []string{"fd7a:115c:a1e0::1"}, // IPv6 source
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "2001:db8::/64"}, // IPv6 destination
|
|
},
|
|
},
|
|
{
|
|
SrcIPs: []string{"100.64.0.1"}, // IPv4 source
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "10.0.0.0/24"}, // IPv4 destination
|
|
},
|
|
},
|
|
},
|
|
},
|
|
want: []netip.Prefix{
|
|
netip.MustParsePrefix("10.0.0.0/24"),
|
|
netip.MustParsePrefix("2001:db8::/64"),
|
|
},
|
|
},
|
|
{
|
|
name: "router-with-multiple-routes-and-node-with-specific-access",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 2,
|
|
IPv4: ap("100.64.0.2"), // Node IP
|
|
User: &types.User{Name: "node"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.10.10.0/24"),
|
|
netip.MustParsePrefix("10.10.11.0/24"),
|
|
netip.MustParsePrefix("10.10.12.0/24"),
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
SrcIPs: []string{"*"}, // Any source
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "100.64.0.1"}, // Router node
|
|
},
|
|
},
|
|
{
|
|
SrcIPs: []string{"100.64.0.2"}, // Node IP
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "10.10.10.0/24"}, // Only one subnet allowed
|
|
},
|
|
},
|
|
},
|
|
},
|
|
want: []netip.Prefix{
|
|
netip.MustParsePrefix("10.10.10.0/24"),
|
|
},
|
|
},
|
|
{
|
|
name: "node-with-access-to-one-subnet-and-partial-overlap",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 2,
|
|
IPv4: ap("100.64.0.2"),
|
|
User: &types.User{Name: "node"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.10.10.0/24"),
|
|
netip.MustParsePrefix("10.10.11.0/24"),
|
|
netip.MustParsePrefix("10.10.10.0/16"), // Overlaps with the first one
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
SrcIPs: []string{"100.64.0.2"},
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "10.10.10.0/24"}, // Only specific subnet
|
|
},
|
|
},
|
|
},
|
|
},
|
|
want: []netip.Prefix{
|
|
netip.MustParsePrefix("10.10.10.0/24"),
|
|
netip.MustParsePrefix("10.10.10.0/16"), // With current implementation, this is included because it overlaps with the allowed subnet
|
|
},
|
|
},
|
|
{
|
|
name: "node-with-access-to-wildcard-subnet",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 2,
|
|
IPv4: ap("100.64.0.2"),
|
|
User: &types.User{Name: "node"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.10.10.0/24"),
|
|
netip.MustParsePrefix("10.10.11.0/24"),
|
|
netip.MustParsePrefix("10.10.12.0/24"),
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
SrcIPs: []string{"100.64.0.2"},
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "10.10.0.0/16"}, // Broader subnet that includes all three
|
|
},
|
|
},
|
|
},
|
|
},
|
|
want: []netip.Prefix{
|
|
netip.MustParsePrefix("10.10.10.0/24"),
|
|
netip.MustParsePrefix("10.10.11.0/24"),
|
|
netip.MustParsePrefix("10.10.12.0/24"),
|
|
},
|
|
},
|
|
{
|
|
name: "multiple-nodes-with-different-subnet-permissions",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 2,
|
|
IPv4: ap("100.64.0.2"),
|
|
User: &types.User{Name: "node"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.10.10.0/24"),
|
|
netip.MustParsePrefix("10.10.11.0/24"),
|
|
netip.MustParsePrefix("10.10.12.0/24"),
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
SrcIPs: []string{"100.64.0.1"}, // Different node
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "10.10.11.0/24"},
|
|
},
|
|
},
|
|
{
|
|
SrcIPs: []string{"100.64.0.2"}, // Our node
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "10.10.10.0/24"},
|
|
},
|
|
},
|
|
{
|
|
SrcIPs: []string{"100.64.0.3"}, // Different node
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "10.10.12.0/24"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
want: []netip.Prefix{
|
|
netip.MustParsePrefix("10.10.10.0/24"),
|
|
},
|
|
},
|
|
{
|
|
name: "exactly-matching-users-acl-example",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 2,
|
|
IPv4: ap("100.64.0.2"), // node with IP 100.64.0.2
|
|
User: &types.User{Name: "node"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.10.10.0/24"),
|
|
netip.MustParsePrefix("10.10.11.0/24"),
|
|
netip.MustParsePrefix("10.10.12.0/24"),
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
// This represents the rule: action: accept, src: ["*"], dst: ["router:0"]
|
|
SrcIPs: []string{"*"}, // Any source
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "100.64.0.1"}, // Router IP
|
|
},
|
|
},
|
|
{
|
|
// This represents the rule: action: accept, src: ["node"], dst: ["10.10.10.0/24:*"]
|
|
SrcIPs: []string{"100.64.0.2"}, // Node IP
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "10.10.10.0/24", Ports: tailcfg.PortRangeAny}, // All ports on this subnet
|
|
},
|
|
},
|
|
},
|
|
},
|
|
want: []netip.Prefix{
|
|
netip.MustParsePrefix("10.10.10.0/24"),
|
|
},
|
|
},
|
|
{
|
|
name: "acl-all-source-nodes-can-access-router-only-node-can-access-10.10.10.0-24",
|
|
args: args{
|
|
// When testing from router node's perspective
|
|
node: &types.Node{
|
|
ID: 1,
|
|
IPv4: ap("100.64.0.1"), // router with IP 100.64.0.1
|
|
User: &types.User{Name: "router"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.10.10.0/24"),
|
|
netip.MustParsePrefix("10.10.11.0/24"),
|
|
netip.MustParsePrefix("10.10.12.0/24"),
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
SrcIPs: []string{"*"},
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "100.64.0.1"}, // Router can be accessed by all
|
|
},
|
|
},
|
|
{
|
|
SrcIPs: []string{"100.64.0.2"}, // Only node
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "10.10.10.0/24"}, // Can access this subnet
|
|
},
|
|
},
|
|
// Add a rule for router to access its own routes
|
|
{
|
|
SrcIPs: []string{"100.64.0.1"}, // Router node
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "*"}, // Can access everything
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// Router needs explicit rules to access routes
|
|
want: []netip.Prefix{
|
|
netip.MustParsePrefix("10.10.10.0/24"),
|
|
netip.MustParsePrefix("10.10.11.0/24"),
|
|
netip.MustParsePrefix("10.10.12.0/24"),
|
|
},
|
|
},
|
|
{
|
|
name: "acl-specific-port-ranges-for-subnets",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 2,
|
|
IPv4: ap("100.64.0.2"), // node
|
|
User: &types.User{Name: "node"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.10.10.0/24"),
|
|
netip.MustParsePrefix("10.10.11.0/24"),
|
|
netip.MustParsePrefix("10.10.12.0/24"),
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
SrcIPs: []string{"100.64.0.2"}, // node
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "10.10.10.0/24", Ports: tailcfg.PortRange{First: 22, Last: 22}}, // Only SSH
|
|
},
|
|
},
|
|
{
|
|
SrcIPs: []string{"100.64.0.2"}, // node
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "10.10.11.0/24", Ports: tailcfg.PortRange{First: 80, Last: 80}}, // Only HTTP
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// Should get both subnets with specific port ranges
|
|
want: []netip.Prefix{
|
|
netip.MustParsePrefix("10.10.10.0/24"),
|
|
netip.MustParsePrefix("10.10.11.0/24"),
|
|
},
|
|
},
|
|
{
|
|
name: "acl-order-of-rules-and-rule-specificity",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 2,
|
|
IPv4: ap("100.64.0.2"), // node
|
|
User: &types.User{Name: "node"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.10.10.0/24"),
|
|
netip.MustParsePrefix("10.10.11.0/24"),
|
|
netip.MustParsePrefix("10.10.12.0/24"),
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
// First rule allows all traffic
|
|
{
|
|
SrcIPs: []string{"*"}, // Any source
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "*", Ports: tailcfg.PortRangeAny}, // Any destination and any port
|
|
},
|
|
},
|
|
// Second rule is more specific but should be overridden by the first rule
|
|
{
|
|
SrcIPs: []string{"100.64.0.2"}, // node
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "10.10.10.0/24"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// Due to the first rule allowing all traffic, node should have access to all routes
|
|
want: []netip.Prefix{
|
|
netip.MustParsePrefix("10.10.10.0/24"),
|
|
netip.MustParsePrefix("10.10.11.0/24"),
|
|
netip.MustParsePrefix("10.10.12.0/24"),
|
|
},
|
|
},
|
|
{
|
|
name: "return-path-subnet-router-to-regular-node-issue-2608",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 2,
|
|
IPv4: ap("100.123.45.89"), // Node B - regular node
|
|
User: &types.User{Name: "node-b"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("192.168.1.0/24"), // Subnet connected to Node A
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
// Policy allows 192.168.1.0/24 and group:routers to access *:*
|
|
SrcIPs: []string{
|
|
"192.168.1.0/24", // Subnet behind router
|
|
"100.123.45.67", // Node A (router, part of group:routers)
|
|
},
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "*", Ports: tailcfg.PortRangeAny}, // Access to everything
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// Node B should receive the 192.168.1.0/24 route for return traffic
|
|
// even though Node B cannot initiate connections to that network
|
|
want: []netip.Prefix{
|
|
netip.MustParsePrefix("192.168.1.0/24"),
|
|
},
|
|
},
|
|
{
|
|
name: "return-path-router-perspective-2608",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 1,
|
|
IPv4: ap("100.123.45.67"), // Node A - router node
|
|
User: &types.User{Name: "router"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("192.168.1.0/24"), // Subnet connected to this router
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
// Policy allows 192.168.1.0/24 and group:routers to access *:*
|
|
SrcIPs: []string{
|
|
"192.168.1.0/24", // Subnet behind router
|
|
"100.123.45.67", // Node A (router, part of group:routers)
|
|
},
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "*", Ports: tailcfg.PortRangeAny}, // Access to everything
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// Router should have access to its own routes
|
|
want: []netip.Prefix{
|
|
netip.MustParsePrefix("192.168.1.0/24"),
|
|
},
|
|
},
|
|
{
|
|
name: "subnet-behind-router-bidirectional-connectivity-issue-2608",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 2,
|
|
IPv4: ap("100.123.45.89"), // Node B - regular node that should be reachable
|
|
User: &types.User{Name: "node-b"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("192.168.1.0/24"), // Subnet behind router
|
|
netip.MustParsePrefix("10.0.0.0/24"), // Another subnet
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
// Only 192.168.1.0/24 and routers can access everything
|
|
SrcIPs: []string{
|
|
"192.168.1.0/24", // Subnet that can connect to Node B
|
|
"100.123.45.67", // Router node
|
|
},
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "*", Ports: tailcfg.PortRangeAny},
|
|
},
|
|
},
|
|
{
|
|
// Node B cannot access anything (no rules with Node B as source)
|
|
SrcIPs: []string{"100.123.45.89"},
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
// No destinations - Node B cannot initiate connections
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// Node B should still get the 192.168.1.0/24 route for return traffic
|
|
// but should NOT get 10.0.0.0/24 since nothing allows that subnet to connect to Node B
|
|
want: []netip.Prefix{
|
|
netip.MustParsePrefix("192.168.1.0/24"),
|
|
},
|
|
},
|
|
{
|
|
name: "no-route-leakage-when-no-connection-allowed-2608",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 3,
|
|
IPv4: ap("100.123.45.99"), // Node C - isolated node
|
|
User: &types.User{Name: "isolated-node"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("192.168.1.0/24"), // Subnet behind router
|
|
netip.MustParsePrefix("10.0.0.0/24"), // Another private subnet
|
|
netip.MustParsePrefix("172.16.0.0/24"), // Yet another subnet
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
// Only specific subnets and routers can access specific destinations
|
|
SrcIPs: []string{
|
|
"192.168.1.0/24", // This subnet can access everything
|
|
"100.123.45.67", // Router node can access everything
|
|
},
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "100.123.45.89", Ports: tailcfg.PortRangeAny}, // Only to Node B
|
|
},
|
|
},
|
|
{
|
|
// 10.0.0.0/24 can only access router
|
|
SrcIPs: []string{"10.0.0.0/24"},
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "100.123.45.67", Ports: tailcfg.PortRangeAny}, // Only to router
|
|
},
|
|
},
|
|
{
|
|
// 172.16.0.0/24 has no access rules at all
|
|
},
|
|
},
|
|
},
|
|
// Node C should get NO routes because:
|
|
// - 192.168.1.0/24 can only connect to Node B (not Node C)
|
|
// - 10.0.0.0/24 can only connect to router (not Node C)
|
|
// - 172.16.0.0/24 has no rules allowing it to connect anywhere
|
|
// - Node C is not in any rules as a destination
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "original-issue-2608-with-slash14-network",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 2,
|
|
IPv4: ap("100.123.45.89"), // Node B - regular node
|
|
User: &types.User{Name: "node-b"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("192.168.1.0/14"), // Network 192.168.1.0/14 as mentioned in original issue
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
// Policy allows 192.168.1.0/24 (part of /14) and group:routers to access *:*
|
|
SrcIPs: []string{
|
|
"192.168.1.0/24", // Subnet behind router (part of the larger /14 network)
|
|
"100.123.45.67", // Node A (router, part of group:routers)
|
|
},
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "*", Ports: tailcfg.PortRangeAny}, // Access to everything
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// Node B should receive the 192.168.1.0/14 route for return traffic
|
|
// even though only 192.168.1.0/24 (part of /14) can connect to Node B
|
|
// This is the exact scenario from the original issue
|
|
want: []netip.Prefix{
|
|
netip.MustParsePrefix("192.168.1.0/14"),
|
|
},
|
|
},
|
|
// Subnet-to-subnet tests for issue #3157.
|
|
// When an ACL references subnet CIDRs as both source and destination,
|
|
// the subnet routers for those subnets must receive routes to each
|
|
// other's subnets.
|
|
{
|
|
name: "subnet-to-subnet-src-router-gets-dst-route-3157",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 1,
|
|
IPv4: ap("100.64.0.1"),
|
|
User: &types.User{Name: "router-a"},
|
|
Hostinfo: &tailcfg.Hostinfo{
|
|
RoutableIPs: []netip.Prefix{
|
|
netip.MustParsePrefix("10.88.8.0/24"),
|
|
},
|
|
},
|
|
ApprovedRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.88.8.0/24"),
|
|
},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.99.9.0/24"),
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
SrcIPs: []string{"10.88.8.0/24"},
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
want: []netip.Prefix{
|
|
netip.MustParsePrefix("10.99.9.0/24"),
|
|
},
|
|
},
|
|
{
|
|
name: "subnet-to-subnet-dst-router-gets-src-route-3157",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 2,
|
|
IPv4: ap("100.64.0.2"),
|
|
User: &types.User{Name: "router-b"},
|
|
Hostinfo: &tailcfg.Hostinfo{
|
|
RoutableIPs: []netip.Prefix{
|
|
netip.MustParsePrefix("10.99.9.0/24"),
|
|
},
|
|
},
|
|
ApprovedRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.99.9.0/24"),
|
|
},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.88.8.0/24"),
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
SrcIPs: []string{"10.88.8.0/24"},
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
want: []netip.Prefix{
|
|
netip.MustParsePrefix("10.88.8.0/24"),
|
|
},
|
|
},
|
|
{
|
|
name: "subnet-to-subnet-regular-node-no-route-leak-3157",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 3,
|
|
IPv4: ap("100.64.0.3"),
|
|
User: &types.User{Name: "regular-node"},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.88.8.0/24"),
|
|
netip.MustParsePrefix("10.99.9.0/24"),
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
SrcIPs: []string{"10.88.8.0/24"},
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "subnet-to-subnet-unrelated-router-no-route-leak-3157",
|
|
args: args{
|
|
node: &types.Node{
|
|
ID: 4,
|
|
IPv4: ap("100.64.0.4"),
|
|
User: &types.User{Name: "router-c"},
|
|
Hostinfo: &tailcfg.Hostinfo{
|
|
RoutableIPs: []netip.Prefix{
|
|
netip.MustParsePrefix("172.16.0.0/24"),
|
|
},
|
|
},
|
|
ApprovedRoutes: []netip.Prefix{
|
|
netip.MustParsePrefix("172.16.0.0/24"),
|
|
},
|
|
},
|
|
routes: []netip.Prefix{
|
|
netip.MustParsePrefix("10.88.8.0/24"),
|
|
},
|
|
rules: []tailcfg.FilterRule{
|
|
{
|
|
SrcIPs: []string{"10.88.8.0/24"},
|
|
DstPorts: []tailcfg.NetPortRange{
|
|
{IP: "10.99.9.0/24", Ports: tailcfg.PortRangeAny},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
want: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
matchers := matcher.MatchesFromFilterRules(tt.args.rules)
|
|
|
|
got := ReduceRoutes(
|
|
tt.args.node.View(),
|
|
tt.args.routes,
|
|
matchers,
|
|
)
|
|
if diff := cmp.Diff(tt.want, got, util.Comparers...); diff != "" {
|
|
t.Errorf("ReduceRoutes() unexpected result (-want +got):\n%s", diff)
|
|
}
|
|
})
|
|
}
|
|
}
|