Files
privatae-bastion/client-go/ownaddr_netns_linux_test.go
T
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

340 lines
14 KiB
Go

//go:build linux
package main
import (
"bufio"
"context"
"errors"
"net"
"os"
"os/exec"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
"golang.org/x/sys/unix"
)
const ownAddrNetnsEnv = "PRIVATAE_TEST_OWNADDR_NETNS"
// THE KERNEL'S OWN ANSWER, IN A REAL NETWORK NAMESPACE. A `local` route
// (AnyIP) makes the kernel deliver a whole range to this machine although no
// interface holds those addresses, so a service on 0.0.0.0 answers on every
// one of them. This runs the test binary again in a private user and network
// namespace (as `unshare -rn` does), adds `local 192.0.2.0/24` and
// `local 2001:db8:a::/64` on lo, a broadcast, two anycast and a multicast
// route, proves a 0.0.0.0 listener answers on the local ranges, and then asks
// every lane that dials for someone else: a coder run's proxy and the anchored
// egress lane (each vetted, and each end to end over its own connection with a
// real dialer, answering 403), and the backup lanes' URL check and dial wall.
// Each address, the IPv4-mapped and NAT64 forms of each IPv4 one, and a name
// for each are refused, each lane's reason read so that a name the lane could
// not resolve (refused for that) does not count; nothing reaches the
// listener, and an address or name with a unicast route or no route passes.
// The lookup itself is checked against the kernel's local, broadcast,
// anycast, multicast, unicast and no-route answers, so the unicast answer is
// read even on a machine with no default route. Skipped where a private
// namespace cannot be made.
//
// MUTATION: set ownRouteLookup to nil (the interface check alone).
// RESULT: red — the AnyIP destinations are vetted and reach the listener.
// MUTATION: in routeKindOfType, test `!= unix.RTN_LOCAL`.
// RESULT: red — the broadcast and anycast destinations pass every lane.
func TestAnyIPDestinationsAreRefusedInARealNamespace(t *testing.T) {
if os.Getenv(ownAddrNetnsEnv) != "1" {
if _, err := exec.LookPath("ip"); err != nil {
if _, err := os.Stat("/usr/sbin/ip"); err != nil {
t.Skip("needs ip(8)")
}
}
cmd := exec.Command(os.Args[0], "-test.run=^TestAnyIPDestinationsAreRefusedInARealNamespace$", "-test.v", "-test.count=1")
cmd.Env = append(os.Environ(), ownAddrNetnsEnv+"=1")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUSER | syscall.CLONE_NEWNET,
UidMappings: []syscall.SysProcIDMap{{ContainerID: 0, HostID: os.Getuid(), Size: 1}},
GidMappings: []syscall.SysProcIDMap{{ContainerID: 0, HostID: os.Getgid(), Size: 1}},
}
out, err := cmd.CombinedOutput()
if cmd.ProcessState == nil {
t.Skipf("no private user and network namespace here: %v", err)
}
t.Logf("in a private network namespace:\n%s", out)
if strings.Contains(string(out), "--- SKIP") {
t.Skip("the namespaced run could not set up its routes")
}
if err != nil {
t.Fatalf("the namespaced run failed: %v", err)
}
if !strings.Contains(string(out), "--- PASS: TestAnyIPDestinationsAreRefusedInARealNamespace") {
t.Fatal("the namespaced run did not run")
}
return
}
ipBin, err := exec.LookPath("ip")
if err != nil {
ipBin = "/usr/sbin/ip"
}
for _, args := range [][]string{
{"link", "set", "lo", "up"},
{"route", "add", "local", "192.0.2.0/24", "dev", "lo"},
{"-6", "route", "add", "local", "2001:db8:a::/64", "dev", "lo"},
// Plain unicast routes, so the kernel's unicast answer is read here
// whatever routes the machine running the tests has (a box with no
// default route answers every public address with no route at all).
{"route", "add", "198.51.100.0/24", "dev", "lo"},
{"-6", "route", "add", "2001:db8:b::/64", "dev", "lo"},
// The other route types the kernel answers with, none of them unicast:
// each is this machine, so a lookup that took only `local` as this
// machine would let them through.
{"route", "add", "broadcast", "198.51.101.255", "dev", "lo"},
{"route", "add", "anycast", "198.51.102.0/24", "dev", "lo"},
{"-6", "route", "add", "anycast", "2001:db8:d::/64", "dev", "lo"},
{"route", "add", "multicast", "233.252.0.0/24", "dev", "lo"},
} {
if out, err := exec.Command(ipBin, args...).CombinedOutput(); err != nil {
t.Skipf("ip %s: %v %s", strings.Join(args, " "), err, out)
}
}
// The real kernel's three answers, read by the real rtnetlink lookup.
for addr, want := range map[string]routeKind{
"192.0.2.55": routeThisMachine, "2001:db8:a::55": routeThisMachine, "::ffff:192.0.2.55": routeThisMachine,
"198.51.100.9": routeLeaves, "2001:db8:b::9": routeLeaves,
"203.0.113.9": routeNowhere, "2001:db8:c::9": routeNowhere,
"198.51.101.255": routeThisMachine, // broadcast
"198.51.102.5": routeThisMachine, // anycast
"2001:db8:d::5": routeThisMachine, // anycast
"233.252.0.5": routeThisMachine, // multicast
} {
if got, err := ownRouteLookup(net.ParseIP(addr)); err != nil || got != want {
t.Errorf("route lookup %s: got kind %d err %v, want kind %d", addr, got, err, want)
}
}
ln, err := net.Listen("tcp", ":0") // 0.0.0.0 and [::]
if err != nil {
t.Fatal(err)
}
defer ln.Close()
var hits atomic.Int64
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
hits.Add(1)
c.Close()
}
}()
_, port, _ := net.SplitHostPort(ln.Addr().String())
// The hazard is real here: the listener answers on both AnyIP ranges.
for _, a := range []string{"192.0.2.55", "2001:db8:a::55"} {
c, err := net.DialTimeout("tcp", net.JoinHostPort(a, port), 2*time.Second)
if err != nil {
t.Fatalf("the 0.0.0.0 listener should answer on %s: %v", a, err)
}
c.Close()
}
for deadline := time.Now().Add(2 * time.Second); hits.Load() < 2 && time.Now().Before(deadline); {
time.Sleep(10 * time.Millisecond)
}
if hits.Load() != 2 {
t.Fatalf("the listener saw %d of the 2 direct connections", hits.Load())
}
ownAddrReset()
names := map[string][]string{"anyip.example": {"192.0.2.55"}, "anyip6.example": {"2001:db8:a::55"},
"bcast.example": {"198.51.101.255"}, "anycast.example": {"198.51.102.5"}, "anycast6.example": {"2001:db8:d::5"},
"unicast.example": {"198.51.100.9"}, "unicast6.example": {"2001:db8:b::9"},
"nowhere.example": {"203.0.113.9"}, "nowhere6.example": {"2001:db8:c::9"}}
stubResolve(t, names)
stubEgressResolve(t, names)
ctx := context.Background()
targets := []string{
net.JoinHostPort("192.0.2.55", port), net.JoinHostPort("2001:db8:a::55", port),
net.JoinHostPort("::ffff:192.0.2.55", port), net.JoinHostPort("64:ff9b::c000:237", port),
"anyip.example:" + port, "anyip6.example:" + port,
// Broadcast and anycast routes: this machine too, by address and by name.
net.JoinHostPort("198.51.101.255", port), net.JoinHostPort("198.51.102.5", port),
net.JoinHostPort("2001:db8:d::5", port), net.JoinHostPort("::ffff:198.51.102.5", port),
"bcast.example:" + port, "anycast6.example:" + port,
// The IPv4-mapped and NAT64 forms of the broadcast and anycast addresses.
net.JoinHostPort("::ffff:198.51.101.255", port), net.JoinHostPort("64:ff9b::c633:65ff", port),
net.JoinHostPort("64:ff9b::c633:6605", port), "anycast.example:" + port,
}
// A unicast route and no route at all are both not this machine, by address
// and by name: each passes, and the address to dial is the one vetted.
passes := map[string]string{
"198.51.100.9:443": "198.51.100.9:443", "203.0.113.9:443": "203.0.113.9:443",
"[2001:db8:b::9]:443": "[2001:db8:b::9]:443", "[2001:db8:c::9]:443": "[2001:db8:c::9]:443",
"unicast.example:443": "198.51.100.9:443", "unicast6.example:443": "[2001:db8:b::9]:443",
"nowhere.example:443": "203.0.113.9:443", "nowhere6.example:443": "[2001:db8:c::9]:443",
}
// Each refusal must be the this-machine refusal. A name the lane's resolver
// could not answer would be refused too ("does not resolve"), so a refusal
// alone would not show the name was resolved and its address vetted.
for _, hp := range targets {
if got, why := coderVettedAddr(ctx, hp, nil); !strings.Contains(why, "never this machine") {
t.Errorf("coder run: %s is this machine and must be refused as such, got dial %q, %q", hp, got, why)
}
}
for hp, want := range passes {
if got, why := coderVettedAddr(ctx, hp, nil); why != "" || got != want {
t.Errorf("coder run: %s leaves this machine or goes nowhere, so it passes as %s: %q %q", hp, want, got, why)
}
}
// The same requests, sent to each lane's socket with its real dialer.
requests := []string{
"CONNECT " + targets[0] + " HTTP/1.1\r\n\r\n",
"CONNECT " + targets[1] + " HTTP/1.1\r\n\r\n",
"CONNECT " + targets[2] + " HTTP/1.1\r\n\r\n",
"CONNECT " + targets[4] + " HTTP/1.1\r\n\r\n",
"CONNECT " + targets[5] + " HTTP/1.1\r\n\r\n",
"CONNECT " + targets[6] + " HTTP/1.1\r\n\r\n",
"CONNECT " + targets[7] + " HTTP/1.1\r\n\r\n",
"CONNECT " + targets[10] + " HTTP/1.1\r\n\r\n",
"GET http://" + targets[0] + "/ HTTP/1.1\r\nHost: 192.0.2.55\r\n\r\n",
"GET http://" + targets[4] + "/ HTTP/1.1\r\nHost: anyip.example\r\n\r\n",
}
ask := func(c net.Conn, req string) string {
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
_, _ = c.Write([]byte(req))
line, _ := bufio.NewReader(c).ReadString('\n')
c.Close()
return line
}
// A coder run's proxy end to end.
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
oldChown := coderChown
t.Cleanup(func() { coderChown = oldChown })
coderChown = func(string, int, int) error { return nil }
dir := t.TempDir()
if err := coderListenEgress("run-anyip", dir, os.Getuid()); err != nil {
t.Fatal(err)
}
defer coderCloseEgress("run-anyip")
for _, req := range requests {
c, err := net.Dial("unix", dir+"/"+coderEgressSock)
if err != nil {
t.Fatal(err)
}
if line := ask(c, req); !strings.HasPrefix(line, "HTTP/1.1 403") {
t.Errorf("the proxy must refuse %q, answered %q", req, line)
}
}
// The anchored egress lane, in the same namespace: the vetting, then the
// lane end to end as the anchor host, with a real dialer.
setupBox(t)
for _, hp := range targets {
if addr, why := egressVettedAddr(ctx, hp); !strings.Contains(why, "that is this machine") {
t.Errorf("anchored egress: %s is this machine and must be refused as such, got %q, %q", hp, addr, why)
}
}
for hp, want := range passes {
if addr, why := egressVettedAddr(ctx, hp); why != "" || addr != want {
t.Errorf("anchored egress: %s leaves this machine or goes nowhere, so it passes as %s: %q %q", hp, want, addr, why)
}
}
dialer := (&net.Dialer{Timeout: 2 * time.Second}).DialContext
for _, req := range requests {
srv, cli := net.Pipe()
go handleEgressConn(srv, dialer, tAnchor)
if line := ask(cli, req); !strings.HasPrefix(line, "HTTP/1.1 403") {
t.Errorf("the anchored lane must refuse %q, answered %q", req, line)
}
}
// The backup lanes' wall: refused at the URL check and at the dial.
stubBackupLookup(t, names)
for _, hp := range targets {
if err := validateBackupURL("https://" + hp + "/b.tar"); err == nil || !strings.Contains(err.Error(), "on this machine or this network") {
t.Errorf("backup: %s is this machine and must be refused as such: %v", hp, err)
}
}
for hp := range passes {
if err := validateBackupURL("https://" + hp + "/b.tar"); err != nil {
t.Errorf("backup: %s leaves this machine or goes nowhere, so it passes: %v", hp, err)
}
}
for _, a := range []string{"192.0.2.55", "2001:db8:a::55", "198.51.101.255", "198.51.102.5", "2001:db8:d::5"} {
if backupDialAllowed(net.ParseIP(a)) {
t.Errorf("backup: the dial to %s is this machine and must be refused", a)
}
}
for _, a := range []string{"198.51.100.9", "203.0.113.9", "2001:db8:b::9"} {
if !backupDialAllowed(net.ParseIP(a)) {
t.Errorf("backup: %s leaves this machine or goes nowhere, so it passes", a)
}
}
time.Sleep(100 * time.Millisecond)
if n := hits.Load(); n != 2 {
t.Fatalf("nothing the lanes were asked for may reach the listener: it saw %d connections after the 2 direct ones", n-2)
}
}
// stubEgressResolve answers the anchored lane's name lookups from m.
func stubEgressResolve(t *testing.T, m map[string][]string) {
t.Helper()
old := egressResolve
t.Cleanup(func() { egressResolve = old })
egressResolve = func(_ context.Context, host string) ([]net.IPAddr, error) {
ips, ok := m[strings.ToLower(host)]
if !ok {
return nil, errors.New("no such host")
}
var out []net.IPAddr
for _, s := range ips {
out = append(out, net.IPAddr{IP: net.ParseIP(s)})
}
return out, nil
}
}
// stubBackupLookup answers the backup lanes' name lookups from m.
func stubBackupLookup(t *testing.T, m map[string][]string) {
t.Helper()
old := backupLookupIP
t.Cleanup(func() { backupLookupIP = old })
backupLookupIP = func(host string) ([]net.IP, error) {
if ip := net.ParseIP(host); ip != nil {
return []net.IP{ip}, nil
}
ips, ok := m[strings.ToLower(host)]
if !ok {
return nil, errors.New("no such host")
}
var out []net.IP
for _, s := range ips {
out = append(out, net.ParseIP(s))
}
return out, nil
}
}
// Only a unicast route leaves this machine. Every other route type the kernel
// can answer with is this machine: a lookup that took only `local` as this
// machine would pass broadcast, anycast and multicast destinations.
//
// MUTATION: in routeKindOfType, test `!= unix.RTN_LOCAL` instead of
// `== unix.RTN_UNICAST`.
// RESULT: red — broadcast, anycast and multicast read as leaving.
func TestOnlyAUnicastRouteLeavesThisMachine(t *testing.T) {
if got := routeKindOfType(unix.RTN_UNICAST); got != routeLeaves {
t.Fatalf("a unicast route leaves the machine: got kind %d", got)
}
for name, typ := range map[string]uint8{
"local": unix.RTN_LOCAL, "broadcast": unix.RTN_BROADCAST, "anycast": unix.RTN_ANYCAST,
"multicast": unix.RTN_MULTICAST, "unspec": unix.RTN_UNSPEC, "nat": unix.RTN_NAT, "xresolve": unix.RTN_XRESOLVE,
} {
if got := routeKindOfType(typ); got != routeThisMachine {
t.Errorf("a %s route is this machine: got kind %d", name, got)
}
}
}