946 lines
25 KiB
Go
946 lines
25 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package router
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math/rand"
|
|
"net/netip"
|
|
"os"
|
|
"reflect"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/tailscale/wireguard-go/tun"
|
|
"github.com/vishvananda/netlink"
|
|
"golang.org/x/exp/slices"
|
|
"tailscale.com/net/netmon"
|
|
"tailscale.com/tstest"
|
|
"tailscale.com/types/logger"
|
|
)
|
|
|
|
func TestRouterStates(t *testing.T) {
|
|
basic := `
|
|
ip rule add -4 pref 5210 fwmark 0x80000/0xff0000 table main
|
|
ip rule add -4 pref 5230 fwmark 0x80000/0xff0000 table default
|
|
ip rule add -4 pref 5250 fwmark 0x80000/0xff0000 type unreachable
|
|
ip rule add -4 pref 5270 table 52
|
|
ip rule add -6 pref 5210 fwmark 0x80000/0xff0000 table main
|
|
ip rule add -6 pref 5230 fwmark 0x80000/0xff0000 table default
|
|
ip rule add -6 pref 5250 fwmark 0x80000/0xff0000 type unreachable
|
|
ip rule add -6 pref 5270 table 52
|
|
`
|
|
states := []struct {
|
|
name string
|
|
in *Config
|
|
want string
|
|
}{
|
|
{
|
|
name: "no config",
|
|
in: nil,
|
|
want: `
|
|
up` + basic,
|
|
},
|
|
{
|
|
name: "local addr only",
|
|
in: &Config{
|
|
LocalAddrs: mustCIDRs("100.101.102.103/10"),
|
|
NetfilterMode: netfilterOff,
|
|
},
|
|
want: `
|
|
up
|
|
ip addr add 100.101.102.103/10 dev tailscale0` + basic,
|
|
},
|
|
|
|
{
|
|
name: "addr and routes",
|
|
in: &Config{
|
|
LocalAddrs: mustCIDRs("100.101.102.103/10"),
|
|
Routes: mustCIDRs("100.100.100.100/32", "192.168.16.0/24"),
|
|
NetfilterMode: netfilterOff,
|
|
},
|
|
want: `
|
|
up
|
|
ip addr add 100.101.102.103/10 dev tailscale0
|
|
ip route add 100.100.100.100/32 dev tailscale0 table 52
|
|
ip route add 192.168.16.0/24 dev tailscale0 table 52` + basic,
|
|
},
|
|
|
|
{
|
|
name: "addr and routes and subnet routes",
|
|
in: &Config{
|
|
LocalAddrs: mustCIDRs("100.101.102.103/10"),
|
|
Routes: mustCIDRs("100.100.100.100/32", "192.168.16.0/24"),
|
|
SubnetRoutes: mustCIDRs("200.0.0.0/8"),
|
|
NetfilterMode: netfilterOff,
|
|
},
|
|
want: `
|
|
up
|
|
ip addr add 100.101.102.103/10 dev tailscale0
|
|
ip route add 100.100.100.100/32 dev tailscale0 table 52
|
|
ip route add 192.168.16.0/24 dev tailscale0 table 52` + basic,
|
|
},
|
|
|
|
{
|
|
name: "addr and routes and subnet routes with netfilter",
|
|
in: &Config{
|
|
LocalAddrs: mustCIDRs("100.101.102.104/10"),
|
|
Routes: mustCIDRs("100.100.100.100/32", "10.0.0.0/8"),
|
|
SubnetRoutes: mustCIDRs("200.0.0.0/8"),
|
|
SNATSubnetRoutes: true,
|
|
NetfilterMode: netfilterOn,
|
|
},
|
|
want: `
|
|
up
|
|
ip addr add 100.101.102.104/10 dev tailscale0
|
|
ip route add 10.0.0.0/8 dev tailscale0 table 52
|
|
ip route add 100.100.100.100/32 dev tailscale0 table 52` + basic +
|
|
`v4/filter/FORWARD -j ts-forward
|
|
v4/filter/INPUT -j ts-input
|
|
v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
|
|
v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
|
|
v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP
|
|
v4/filter/ts-forward -o tailscale0 -j ACCEPT
|
|
v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT
|
|
v4/filter/ts-input ! -i tailscale0 -s 100.115.92.0/23 -j RETURN
|
|
v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP
|
|
v4/nat/POSTROUTING -j ts-postrouting
|
|
v4/nat/ts-postrouting -m mark --mark 0x40000/0xff0000 -j MASQUERADE
|
|
v6/filter/FORWARD -j ts-forward
|
|
v6/filter/INPUT -j ts-input
|
|
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
|
|
v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
|
|
v6/filter/ts-forward -o tailscale0 -j ACCEPT
|
|
v6/nat/POSTROUTING -j ts-postrouting
|
|
v6/nat/ts-postrouting -m mark --mark 0x40000/0xff0000 -j MASQUERADE
|
|
`,
|
|
},
|
|
{
|
|
name: "addr and routes with netfilter",
|
|
in: &Config{
|
|
LocalAddrs: mustCIDRs("100.101.102.104/10"),
|
|
Routes: mustCIDRs("100.100.100.100/32", "10.0.0.0/8"),
|
|
NetfilterMode: netfilterOn,
|
|
},
|
|
want: `
|
|
up
|
|
ip addr add 100.101.102.104/10 dev tailscale0
|
|
ip route add 10.0.0.0/8 dev tailscale0 table 52
|
|
ip route add 100.100.100.100/32 dev tailscale0 table 52` + basic +
|
|
`v4/filter/FORWARD -j ts-forward
|
|
v4/filter/INPUT -j ts-input
|
|
v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
|
|
v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
|
|
v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP
|
|
v4/filter/ts-forward -o tailscale0 -j ACCEPT
|
|
v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT
|
|
v4/filter/ts-input ! -i tailscale0 -s 100.115.92.0/23 -j RETURN
|
|
v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP
|
|
v4/nat/POSTROUTING -j ts-postrouting
|
|
v6/filter/FORWARD -j ts-forward
|
|
v6/filter/INPUT -j ts-input
|
|
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
|
|
v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
|
|
v6/filter/ts-forward -o tailscale0 -j ACCEPT
|
|
v6/nat/POSTROUTING -j ts-postrouting
|
|
`,
|
|
},
|
|
|
|
{
|
|
name: "addr and routes and subnet routes with netfilter but no SNAT",
|
|
in: &Config{
|
|
LocalAddrs: mustCIDRs("100.101.102.104/10"),
|
|
Routes: mustCIDRs("100.100.100.100/32", "10.0.0.0/8"),
|
|
SubnetRoutes: mustCIDRs("200.0.0.0/8"),
|
|
SNATSubnetRoutes: false,
|
|
NetfilterMode: netfilterOn,
|
|
},
|
|
want: `
|
|
up
|
|
ip addr add 100.101.102.104/10 dev tailscale0
|
|
ip route add 10.0.0.0/8 dev tailscale0 table 52
|
|
ip route add 100.100.100.100/32 dev tailscale0 table 52` + basic +
|
|
`v4/filter/FORWARD -j ts-forward
|
|
v4/filter/INPUT -j ts-input
|
|
v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
|
|
v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
|
|
v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP
|
|
v4/filter/ts-forward -o tailscale0 -j ACCEPT
|
|
v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT
|
|
v4/filter/ts-input ! -i tailscale0 -s 100.115.92.0/23 -j RETURN
|
|
v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP
|
|
v4/nat/POSTROUTING -j ts-postrouting
|
|
v6/filter/FORWARD -j ts-forward
|
|
v6/filter/INPUT -j ts-input
|
|
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
|
|
v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
|
|
v6/filter/ts-forward -o tailscale0 -j ACCEPT
|
|
v6/nat/POSTROUTING -j ts-postrouting
|
|
`,
|
|
},
|
|
{
|
|
name: "addr and routes with netfilter",
|
|
in: &Config{
|
|
LocalAddrs: mustCIDRs("100.101.102.104/10"),
|
|
Routes: mustCIDRs("100.100.100.100/32", "10.0.0.0/8"),
|
|
NetfilterMode: netfilterOn,
|
|
},
|
|
want: `
|
|
up
|
|
ip addr add 100.101.102.104/10 dev tailscale0
|
|
ip route add 10.0.0.0/8 dev tailscale0 table 52
|
|
ip route add 100.100.100.100/32 dev tailscale0 table 52` + basic +
|
|
`v4/filter/FORWARD -j ts-forward
|
|
v4/filter/INPUT -j ts-input
|
|
v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
|
|
v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
|
|
v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP
|
|
v4/filter/ts-forward -o tailscale0 -j ACCEPT
|
|
v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT
|
|
v4/filter/ts-input ! -i tailscale0 -s 100.115.92.0/23 -j RETURN
|
|
v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP
|
|
v4/nat/POSTROUTING -j ts-postrouting
|
|
v6/filter/FORWARD -j ts-forward
|
|
v6/filter/INPUT -j ts-input
|
|
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
|
|
v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
|
|
v6/filter/ts-forward -o tailscale0 -j ACCEPT
|
|
v6/nat/POSTROUTING -j ts-postrouting
|
|
`,
|
|
},
|
|
|
|
{
|
|
name: "addr and routes with half netfilter",
|
|
in: &Config{
|
|
LocalAddrs: mustCIDRs("100.101.102.104/10"),
|
|
Routes: mustCIDRs("100.100.100.100/32", "10.0.0.0/8"),
|
|
NetfilterMode: netfilterNoDivert,
|
|
},
|
|
want: `
|
|
up
|
|
ip addr add 100.101.102.104/10 dev tailscale0
|
|
ip route add 10.0.0.0/8 dev tailscale0 table 52
|
|
ip route add 100.100.100.100/32 dev tailscale0 table 52` + basic +
|
|
`v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
|
|
v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
|
|
v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP
|
|
v4/filter/ts-forward -o tailscale0 -j ACCEPT
|
|
v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT
|
|
v4/filter/ts-input ! -i tailscale0 -s 100.115.92.0/23 -j RETURN
|
|
v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP
|
|
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
|
|
v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
|
|
v6/filter/ts-forward -o tailscale0 -j ACCEPT
|
|
`,
|
|
},
|
|
{
|
|
name: "addr and routes with netfilter2",
|
|
in: &Config{
|
|
LocalAddrs: mustCIDRs("100.101.102.104/10"),
|
|
Routes: mustCIDRs("100.100.100.100/32", "10.0.0.0/8"),
|
|
NetfilterMode: netfilterOn,
|
|
},
|
|
want: `
|
|
up
|
|
ip addr add 100.101.102.104/10 dev tailscale0
|
|
ip route add 10.0.0.0/8 dev tailscale0 table 52
|
|
ip route add 100.100.100.100/32 dev tailscale0 table 52` + basic +
|
|
`v4/filter/FORWARD -j ts-forward
|
|
v4/filter/INPUT -j ts-input
|
|
v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
|
|
v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
|
|
v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP
|
|
v4/filter/ts-forward -o tailscale0 -j ACCEPT
|
|
v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT
|
|
v4/filter/ts-input ! -i tailscale0 -s 100.115.92.0/23 -j RETURN
|
|
v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP
|
|
v4/nat/POSTROUTING -j ts-postrouting
|
|
v6/filter/FORWARD -j ts-forward
|
|
v6/filter/INPUT -j ts-input
|
|
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
|
|
v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
|
|
v6/filter/ts-forward -o tailscale0 -j ACCEPT
|
|
v6/nat/POSTROUTING -j ts-postrouting
|
|
`,
|
|
},
|
|
{
|
|
name: "addr, routes, and local routes with netfilter",
|
|
in: &Config{
|
|
LocalAddrs: mustCIDRs("100.101.102.104/10"),
|
|
Routes: mustCIDRs("100.100.100.100/32", "0.0.0.0/0"),
|
|
LocalRoutes: mustCIDRs("10.0.0.0/8"),
|
|
NetfilterMode: netfilterOn,
|
|
},
|
|
want: `
|
|
up
|
|
ip addr add 100.101.102.104/10 dev tailscale0
|
|
ip route add 0.0.0.0/0 dev tailscale0 table 52
|
|
ip route add 100.100.100.100/32 dev tailscale0 table 52
|
|
ip route add throw 10.0.0.0/8 table 52` + basic +
|
|
`v4/filter/FORWARD -j ts-forward
|
|
v4/filter/INPUT -j ts-input
|
|
v4/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
|
|
v4/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
|
|
v4/filter/ts-forward -o tailscale0 -s 100.64.0.0/10 -j DROP
|
|
v4/filter/ts-forward -o tailscale0 -j ACCEPT
|
|
v4/filter/ts-input -i lo -s 100.101.102.104 -j ACCEPT
|
|
v4/filter/ts-input ! -i tailscale0 -s 100.115.92.0/23 -j RETURN
|
|
v4/filter/ts-input ! -i tailscale0 -s 100.64.0.0/10 -j DROP
|
|
v4/nat/POSTROUTING -j ts-postrouting
|
|
v6/filter/FORWARD -j ts-forward
|
|
v6/filter/INPUT -j ts-input
|
|
v6/filter/ts-forward -i tailscale0 -j MARK --set-mark 0x40000/0xff0000
|
|
v6/filter/ts-forward -m mark --mark 0x40000/0xff0000 -j ACCEPT
|
|
v6/filter/ts-forward -o tailscale0 -j ACCEPT
|
|
v6/nat/POSTROUTING -j ts-postrouting
|
|
`,
|
|
},
|
|
{
|
|
name: "addr, routes, and local routes with no netfilter",
|
|
in: &Config{
|
|
LocalAddrs: mustCIDRs("100.101.102.104/10"),
|
|
Routes: mustCIDRs("100.100.100.100/32", "0.0.0.0/0"),
|
|
LocalRoutes: mustCIDRs("10.0.0.0/8", "192.168.0.0/24"),
|
|
NetfilterMode: netfilterOff,
|
|
},
|
|
want: `
|
|
up
|
|
ip addr add 100.101.102.104/10 dev tailscale0
|
|
ip route add 0.0.0.0/0 dev tailscale0 table 52
|
|
ip route add 100.100.100.100/32 dev tailscale0 table 52
|
|
ip route add throw 10.0.0.0/8 table 52
|
|
ip route add throw 192.168.0.0/24 table 52` + basic,
|
|
},
|
|
}
|
|
|
|
mon, err := netmon.New(logger.Discard)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mon.Start()
|
|
defer mon.Close()
|
|
|
|
fake := NewFakeOS(t)
|
|
router, err := newUserspaceRouterAdvanced(t.Logf, "tailscale0", mon, fake.netfilter4, fake.netfilter6, fake, true, true)
|
|
if err != nil {
|
|
t.Fatalf("failed to create router: %v", err)
|
|
}
|
|
if err := router.Up(); err != nil {
|
|
t.Fatalf("failed to up router: %v", err)
|
|
}
|
|
|
|
testState := func(t *testing.T, i int) {
|
|
t.Helper()
|
|
if err := router.Set(states[i].in); err != nil {
|
|
t.Fatalf("failed to set router config: %v", err)
|
|
}
|
|
got := fake.String()
|
|
want := adjustFwmask(t, strings.TrimSpace(states[i].want))
|
|
if diff := cmp.Diff(got, want); diff != "" {
|
|
t.Fatalf("unexpected OS state (-got+want):\n%s", diff)
|
|
}
|
|
}
|
|
|
|
for i, state := range states {
|
|
t.Run(state.name, func(t *testing.T) { testState(t, i) })
|
|
}
|
|
|
|
// Cycle through a bunch of states in pseudorandom order, to
|
|
// verify that we transition cleanly from state to state no matter
|
|
// the order.
|
|
for randRun := 0; randRun < 5*len(states); randRun++ {
|
|
i := rand.Intn(len(states))
|
|
state := states[i]
|
|
t.Run(state.name, func(t *testing.T) { testState(t, i) })
|
|
}
|
|
}
|
|
|
|
type fakeNetfilter struct {
|
|
t *testing.T
|
|
n map[string][]string
|
|
}
|
|
|
|
func newNetfilter(t *testing.T) *fakeNetfilter {
|
|
return &fakeNetfilter{
|
|
t: t,
|
|
n: map[string][]string{
|
|
"filter/INPUT": nil,
|
|
"filter/OUTPUT": nil,
|
|
"filter/FORWARD": nil,
|
|
"nat/PREROUTING": nil,
|
|
"nat/OUTPUT": nil,
|
|
"nat/POSTROUTING": nil,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (n *fakeNetfilter) Insert(table, chain string, pos int, args ...string) error {
|
|
k := table + "/" + chain
|
|
if rules, ok := n.n[k]; ok {
|
|
if pos > len(rules)+1 {
|
|
n.t.Errorf("bad position %d in %s", pos, k)
|
|
return errExec
|
|
}
|
|
rules = append(rules, "")
|
|
copy(rules[pos:], rules[pos-1:])
|
|
rules[pos-1] = strings.Join(args, " ")
|
|
n.n[k] = rules
|
|
} else {
|
|
n.t.Errorf("unknown table/chain %s", k)
|
|
return errExec
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (n *fakeNetfilter) Append(table, chain string, args ...string) error {
|
|
k := table + "/" + chain
|
|
return n.Insert(table, chain, len(n.n[k])+1, args...)
|
|
}
|
|
|
|
func (n *fakeNetfilter) Exists(table, chain string, args ...string) (bool, error) {
|
|
k := table + "/" + chain
|
|
if rules, ok := n.n[k]; ok {
|
|
for _, rule := range rules {
|
|
if rule == strings.Join(args, " ") {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
} else {
|
|
n.t.Errorf("unknown table/chain %s", k)
|
|
return false, errExec
|
|
}
|
|
}
|
|
|
|
func (n *fakeNetfilter) Delete(table, chain string, args ...string) error {
|
|
k := table + "/" + chain
|
|
if rules, ok := n.n[k]; ok {
|
|
for i, rule := range rules {
|
|
if rule == strings.Join(args, " ") {
|
|
rules = append(rules[:i], rules[i+1:]...)
|
|
n.n[k] = rules
|
|
return nil
|
|
}
|
|
}
|
|
n.t.Errorf("delete of unknown rule %q from %s", strings.Join(args, " "), k)
|
|
return errExec
|
|
} else {
|
|
n.t.Errorf("unknown table/chain %s", k)
|
|
return errExec
|
|
}
|
|
}
|
|
|
|
func (n *fakeNetfilter) ClearChain(table, chain string) error {
|
|
k := table + "/" + chain
|
|
if _, ok := n.n[k]; ok {
|
|
n.n[k] = nil
|
|
return nil
|
|
} else {
|
|
n.t.Logf("note: ClearChain: unknown table/chain %s", k)
|
|
return errors.New("exitcode:1")
|
|
}
|
|
}
|
|
|
|
func (n *fakeNetfilter) NewChain(table, chain string) error {
|
|
k := table + "/" + chain
|
|
if _, ok := n.n[k]; ok {
|
|
n.t.Errorf("table/chain %s already exists", k)
|
|
return errExec
|
|
}
|
|
n.n[k] = nil
|
|
return nil
|
|
}
|
|
|
|
func (n *fakeNetfilter) DeleteChain(table, chain string) error {
|
|
k := table + "/" + chain
|
|
if rules, ok := n.n[k]; ok {
|
|
if len(rules) != 0 {
|
|
n.t.Errorf("%s is not empty", k)
|
|
return errExec
|
|
}
|
|
delete(n.n, k)
|
|
return nil
|
|
} else {
|
|
n.t.Errorf("%s does not exist", k)
|
|
return errExec
|
|
}
|
|
}
|
|
|
|
// fakeOS implements commandRunner and provides v4 and v6
|
|
// netfilterRunners, but captures changes without touching the OS.
|
|
type fakeOS struct {
|
|
t *testing.T
|
|
up bool
|
|
ips []string
|
|
routes []string
|
|
rules []string
|
|
netfilter4 *fakeNetfilter
|
|
netfilter6 *fakeNetfilter
|
|
}
|
|
|
|
func NewFakeOS(t *testing.T) *fakeOS {
|
|
return &fakeOS{
|
|
t: t,
|
|
netfilter4: newNetfilter(t),
|
|
netfilter6: newNetfilter(t),
|
|
}
|
|
}
|
|
|
|
var errExec = errors.New("execution failed")
|
|
|
|
func (o *fakeOS) String() string {
|
|
var b strings.Builder
|
|
if o.up {
|
|
b.WriteString("up\n")
|
|
} else {
|
|
b.WriteString("down\n")
|
|
}
|
|
|
|
for _, ip := range o.ips {
|
|
fmt.Fprintf(&b, "ip addr add %s\n", ip)
|
|
}
|
|
|
|
for _, route := range o.routes {
|
|
fmt.Fprintf(&b, "ip route add %s\n", route)
|
|
}
|
|
|
|
for _, rule := range o.rules {
|
|
fmt.Fprintf(&b, "ip rule add %s\n", rule)
|
|
}
|
|
|
|
var chains []string
|
|
for chain := range o.netfilter4.n {
|
|
chains = append(chains, chain)
|
|
}
|
|
sort.Strings(chains)
|
|
for _, chain := range chains {
|
|
for _, rule := range o.netfilter4.n[chain] {
|
|
fmt.Fprintf(&b, "v4/%s %s\n", chain, rule)
|
|
}
|
|
}
|
|
|
|
chains = nil
|
|
for chain := range o.netfilter6.n {
|
|
chains = append(chains, chain)
|
|
}
|
|
sort.Strings(chains)
|
|
for _, chain := range chains {
|
|
for _, rule := range o.netfilter6.n[chain] {
|
|
fmt.Fprintf(&b, "v6/%s %s\n", chain, rule)
|
|
}
|
|
}
|
|
|
|
return b.String()[:len(b.String())-1]
|
|
}
|
|
|
|
func (o *fakeOS) run(args ...string) error {
|
|
unexpected := func() error {
|
|
o.t.Errorf("unexpected invocation %q", strings.Join(args, " "))
|
|
return errors.New("unrecognized invocation")
|
|
}
|
|
if args[0] != "ip" {
|
|
return unexpected()
|
|
}
|
|
|
|
if len(args) == 2 && args[1] == "rule" {
|
|
// naked invocation of `ip rule` is a feature test. Return
|
|
// successfully.
|
|
return nil
|
|
}
|
|
|
|
family := ""
|
|
rest := strings.Join(args[3:], " ")
|
|
if args[1] == "-4" || args[1] == "-6" {
|
|
family = args[1]
|
|
copy(args[1:], args[2:])
|
|
args = args[:len(args)-1]
|
|
rest = family + " " + strings.Join(args[3:], " ")
|
|
}
|
|
|
|
var l *[]string
|
|
switch args[1] {
|
|
case "link":
|
|
got := strings.Join(args[2:], " ")
|
|
switch got {
|
|
case "set dev tailscale0 up":
|
|
o.up = true
|
|
case "set dev tailscale0 down":
|
|
o.up = false
|
|
default:
|
|
return unexpected()
|
|
}
|
|
return nil
|
|
case "addr":
|
|
l = &o.ips
|
|
case "route":
|
|
l = &o.routes
|
|
case "rule":
|
|
l = &o.rules
|
|
default:
|
|
return unexpected()
|
|
}
|
|
|
|
switch args[2] {
|
|
case "add":
|
|
for _, el := range *l {
|
|
if el == rest {
|
|
o.t.Errorf("can't add %q, already present", rest)
|
|
return errors.New("already exists")
|
|
}
|
|
}
|
|
*l = append(*l, rest)
|
|
sort.Strings(*l)
|
|
case "del":
|
|
found := false
|
|
for i, el := range *l {
|
|
if el == rest {
|
|
found = true
|
|
*l = append((*l)[:i], (*l)[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
o.t.Logf("note: can't delete %q, not present", rest)
|
|
// 'ip rule del' exits with code 2 when a row is
|
|
// missing. We don't want to consider that an error,
|
|
// for cleanup purposes.
|
|
|
|
// TODO(apenwarr): this is a hack.
|
|
// I'd like to return an exec.ExitError(2) here, but
|
|
// I can't, because the ExitCode is implemented in
|
|
// os.ProcessState, which is an opaque object I can't
|
|
// instantiate or modify. Go's 75 levels of abstraction
|
|
// between me and an 8-bit int are really paying off
|
|
// here, as you can see.
|
|
return errors.New("exitcode:2")
|
|
}
|
|
default:
|
|
return unexpected()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (o *fakeOS) output(args ...string) ([]byte, error) {
|
|
want := "ip rule list priority 10000"
|
|
got := strings.Join(args, " ")
|
|
if got != want {
|
|
o.t.Errorf("unexpected command that wants output: %v", got)
|
|
return nil, errExec
|
|
}
|
|
|
|
var ret []string
|
|
for _, rule := range o.rules {
|
|
if strings.Contains(rule, "10000") {
|
|
ret = append(ret, rule)
|
|
}
|
|
}
|
|
return []byte(strings.Join(ret, "\n")), nil
|
|
}
|
|
|
|
var tunTestNum int64
|
|
|
|
func createTestTUN(t *testing.T) tun.Device {
|
|
const minimalMTU = 1280
|
|
tunName := fmt.Sprintf("tuntest%d", atomic.AddInt64(&tunTestNum, 1))
|
|
tun, err := tun.CreateTUN(tunName, minimalMTU)
|
|
if err != nil {
|
|
t.Fatalf("CreateTUN(%q): %v", tunName, err)
|
|
}
|
|
return tun
|
|
}
|
|
|
|
type linuxTest struct {
|
|
tun tun.Device
|
|
mon *netmon.Monitor
|
|
r *linuxRouter
|
|
logOutput tstest.MemLogger
|
|
}
|
|
|
|
func (lt *linuxTest) Close() error {
|
|
if lt.tun != nil {
|
|
lt.tun.Close()
|
|
}
|
|
if lt.mon != nil {
|
|
lt.mon.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func newLinuxRootTest(t *testing.T) *linuxTest {
|
|
if os.Getuid() != 0 {
|
|
t.Skip("test requires root")
|
|
}
|
|
|
|
lt := new(linuxTest)
|
|
lt.tun = createTestTUN(t)
|
|
|
|
logf := lt.logOutput.Logf
|
|
|
|
mon, err := netmon.New(logger.Discard)
|
|
if err != nil {
|
|
lt.Close()
|
|
t.Fatal(err)
|
|
}
|
|
mon.Start()
|
|
lt.mon = mon
|
|
|
|
r, err := newUserspaceRouter(logf, lt.tun, mon)
|
|
if err != nil {
|
|
lt.Close()
|
|
t.Fatal(err)
|
|
}
|
|
lr := r.(*linuxRouter)
|
|
if err := lr.upInterface(); err != nil {
|
|
lt.Close()
|
|
t.Fatal(err)
|
|
}
|
|
lt.r = lr
|
|
return lt
|
|
}
|
|
|
|
func TestDelRouteIdempotent(t *testing.T) {
|
|
lt := newLinuxRootTest(t)
|
|
defer lt.Close()
|
|
|
|
for _, s := range []string{
|
|
"192.0.2.0/24", // RFC 5737
|
|
"2001:DB8::/32", // RFC 3849
|
|
} {
|
|
cidr := netip.MustParsePrefix(s)
|
|
if err := lt.r.addRoute(cidr); err != nil {
|
|
t.Error(err)
|
|
continue
|
|
}
|
|
for i := 0; i < 2; i++ {
|
|
if err := lt.r.delRoute(cidr); err != nil {
|
|
t.Errorf("delRoute(i=%d): %v", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if t.Failed() {
|
|
out := lt.logOutput.String()
|
|
t.Logf("Log output:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestAddRemoveRules(t *testing.T) {
|
|
lt := newLinuxRootTest(t)
|
|
defer lt.Close()
|
|
r := lt.r
|
|
|
|
step := func(name string, f func() error) {
|
|
t.Logf("Doing %v ...", name)
|
|
if err := f(); err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
rules, err := netlink.RuleList(netlink.FAMILY_ALL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, r := range rules {
|
|
if r.Priority >= 5000 && r.Priority <= 5999 {
|
|
t.Logf("Rule: %+v", r)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
step("init_del_and_add", r.addIPRules)
|
|
step("dup_add", r.justAddIPRules)
|
|
step("del", r.delIPRules)
|
|
step("dup_del", r.delIPRules)
|
|
|
|
}
|
|
|
|
func TestDebugListLinks(t *testing.T) {
|
|
links, err := netlink.LinkList()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, ln := range links {
|
|
t.Logf("Link: %+v", ln)
|
|
}
|
|
}
|
|
|
|
func TestDebugListRoutes(t *testing.T) {
|
|
// We need to pass a non-nil route to RouteListFiltered, along
|
|
// with the netlink.RT_FILTER_TABLE bit set in the filter
|
|
// mask, otherwise it ignores non-main routes.
|
|
filter := &netlink.Route{}
|
|
routes, err := netlink.RouteListFiltered(netlink.FAMILY_ALL, filter, netlink.RT_FILTER_TABLE)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, r := range routes {
|
|
t.Logf("Route: %+v", r)
|
|
}
|
|
}
|
|
|
|
var famName = map[int]string{
|
|
netlink.FAMILY_ALL: "all",
|
|
netlink.FAMILY_V4: "v4",
|
|
netlink.FAMILY_V6: "v6",
|
|
}
|
|
|
|
func TestDebugListRules(t *testing.T) {
|
|
for _, fam := range []int{netlink.FAMILY_V4, netlink.FAMILY_V6, netlink.FAMILY_ALL} {
|
|
t.Run(famName[fam], func(t *testing.T) {
|
|
rules, err := netlink.RuleList(fam)
|
|
if err != nil {
|
|
t.Skipf("skip; RuleList fails with: %v", err)
|
|
}
|
|
for _, r := range rules {
|
|
t.Logf("Rule: %+v", r)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckIPRuleSupportsV6(t *testing.T) {
|
|
err := checkIPRuleSupportsV6(t.Logf)
|
|
if err != nil && os.Getuid() != 0 {
|
|
t.Skipf("skipping, error when not root: %v", err)
|
|
}
|
|
// Just log it. For interactive testing only.
|
|
// Some machines running our tests might not have IPv6.
|
|
t.Logf("Got: %v", err)
|
|
}
|
|
|
|
func TestBusyboxParseVersion(t *testing.T) {
|
|
input := `BusyBox v1.34.1 (2022-09-01 16:10:29 UTC) multi-call binary.
|
|
BusyBox is copyrighted by many authors between 1998-2015.
|
|
Licensed under GPLv2. See source distribution for detailed
|
|
copyright notices.
|
|
|
|
Usage: busybox [function [arguments]...]
|
|
or: busybox --list[-full]
|
|
or: busybox --show SCRIPT
|
|
or: busybox --install [-s] [DIR]
|
|
or: function [arguments]...
|
|
|
|
BusyBox is a multi-call binary that combines many common Unix
|
|
utilities into a single executable. Most people will create a
|
|
link to busybox for each function they wish to use and BusyBox
|
|
will act like whatever it was invoked as.
|
|
`
|
|
|
|
v1, v2, v3, err := busyboxParseVersion(input)
|
|
if err != nil {
|
|
t.Fatalf("busyboxParseVersion() failed: %v", err)
|
|
}
|
|
|
|
if got, want := fmt.Sprintf("%d.%d.%d", v1, v2, v3), "1.34.1"; got != want {
|
|
t.Errorf("version = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestCIDRDiff(t *testing.T) {
|
|
pfx := func(p ...string) []netip.Prefix {
|
|
var ret []netip.Prefix
|
|
for _, s := range p {
|
|
ret = append(ret, netip.MustParsePrefix(s))
|
|
}
|
|
return ret
|
|
}
|
|
tests := []struct {
|
|
old []netip.Prefix
|
|
new []netip.Prefix
|
|
wantAdd []netip.Prefix
|
|
wantDel []netip.Prefix
|
|
final []netip.Prefix
|
|
}{
|
|
{
|
|
old: nil,
|
|
new: pfx("1.1.1.1/32"),
|
|
wantAdd: pfx("1.1.1.1/32"),
|
|
final: pfx("1.1.1.1/32"),
|
|
},
|
|
{
|
|
old: pfx("1.1.1.1/32"),
|
|
new: pfx("1.1.1.1/32"),
|
|
final: pfx("1.1.1.1/32"),
|
|
},
|
|
{
|
|
old: pfx("1.1.1.1/32", "2.3.4.5/32"),
|
|
new: pfx("1.1.1.1/32"),
|
|
wantDel: pfx("2.3.4.5/32"),
|
|
final: pfx("1.1.1.1/32"),
|
|
},
|
|
{
|
|
old: pfx("1.1.1.1/32", "2.3.4.5/32"),
|
|
new: pfx("1.0.0.0/32", "3.4.5.6/32"),
|
|
wantDel: pfx("1.1.1.1/32", "2.3.4.5/32"),
|
|
wantAdd: pfx("1.0.0.0/32", "3.4.5.6/32"),
|
|
final: pfx("1.0.0.0/32", "3.4.5.6/32"),
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
om := make(map[netip.Prefix]bool)
|
|
for _, p := range tc.old {
|
|
om[p] = true
|
|
}
|
|
var added []netip.Prefix
|
|
var deleted []netip.Prefix
|
|
fm, err := cidrDiff("test", om, tc.new, func(p netip.Prefix) error {
|
|
if len(deleted) > 0 {
|
|
t.Error("delete called before add")
|
|
}
|
|
added = append(added, p)
|
|
return nil
|
|
}, func(p netip.Prefix) error {
|
|
deleted = append(deleted, p)
|
|
return nil
|
|
}, t.Logf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
slices.SortFunc(added, func(a, b netip.Prefix) bool { return a.Addr().Less(b.Addr()) })
|
|
slices.SortFunc(deleted, func(a, b netip.Prefix) bool { return a.Addr().Less(b.Addr()) })
|
|
if !reflect.DeepEqual(added, tc.wantAdd) {
|
|
t.Errorf("added = %v, want %v", added, tc.wantAdd)
|
|
}
|
|
if !reflect.DeepEqual(deleted, tc.wantDel) {
|
|
t.Errorf("deleted = %v, want %v", deleted, tc.wantDel)
|
|
}
|
|
|
|
// Check that the final state is correct.
|
|
if len(fm) != len(tc.final) {
|
|
t.Fatalf("final state = %v, want %v", fm, tc.final)
|
|
}
|
|
for _, p := range tc.final {
|
|
if !fm[p] {
|
|
t.Errorf("final state = %v, want %v", fm, tc.final)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var (
|
|
fwmaskSupported bool
|
|
fwmaskSupportedOnce sync.Once
|
|
fwmaskAdjustRe = regexp.MustCompile(`(?m)(fwmark 0x[0-9a-f]+)/0x[0-9a-f]+`)
|
|
)
|
|
|
|
// adjustFwmask removes the "/0xmask" string from fwmask stanzas if the
|
|
// installed 'ip' binary does not support that format.
|
|
func adjustFwmask(t *testing.T, s string) string {
|
|
t.Helper()
|
|
fwmaskSupportedOnce.Do(func() {
|
|
fwmaskSupported, _ = ipCmdSupportsFwmask()
|
|
})
|
|
if fwmaskSupported {
|
|
return s
|
|
}
|
|
|
|
return fwmaskAdjustRe.ReplaceAllString(s, "$1")
|
|
}
|