Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions cmd/tailmixd/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,7 @@ func TestTUNPlanInstallsSelectedExitNodeDefaults(t *testing.T) {
ProfileID: "work",
BackendState: "Running",
ExitNodeID: "exit-node",
ProfileDNS: true,
SelfNodeID: "self",
SelfIPs: []netip.Addr{
netip.MustParseAddr("100.64.0.10"),
Expand Down Expand Up @@ -892,6 +893,50 @@ func TestTUNPlanInstallsSelectedExitNodeDefaults(t *testing.T) {
}
}

func TestTUNPlanInstallsWireGuardExitNodeWithoutProfileDNS(t *testing.T) {
st := state.State{
SyntheticPool: "10.250.0.0/16",
SyntheticPoolV6: "fd6d:6e65:7400::/120",
Profiles: []state.Profile{{ID: "wg", Name: "wg"}},
ExitNode: &state.ExitNode{
ProfileID: "wg",
NodeID: "gateway",
PeerIP: netip.MustParseAddr("10.80.0.1"),
},
}
status := tailmixprofile.Status{
ProfileID: "wg",
Kind: "wireguard",
BackendState: "Running",
ExitNodeID: "gateway",
SelfIPs: []netip.Addr{netip.MustParseAddr("10.80.0.2")},
}
plan, err := buildTUNPlan(st, []tailmixprofile.Status{status})
if err != nil {
t.Fatal(err)
}
if route, ok := plan.Table.ExitRoutes.Lookup(netip.MustParseAddr("203.0.113.10")); !ok || !route.Active || route.ProfileID != "wg" {
t.Fatalf("exit route for a WireGuard exit node = %+v, %v", route, ok)
}
found := false
for _, route := range plan.HostConfig.Routes {
if route.Destination == netip.MustParsePrefix("0.0.0.0/1") {
if !route.Exit {
t.Fatalf("host route %+v is not marked as an exit route", route)
}
found = true
}
}
if !found {
t.Fatalf("host routes %+v do not contain the exit route", plan.HostConfig.Routes)
}
for _, route := range plan.DNSConfig.Routes {
if route.ProfileDNS {
t.Fatalf("profile DNS route for a WireGuard exit node: %+v", plan.DNSConfig.Routes)
}
}
}

func hasDNSRecord(records []tailmixdns.Record, name string, ip netip.Addr) bool {
for _, record := range records {
if record.Name == name && record.EffectiveIP == ip {
Expand Down
4 changes: 3 additions & 1 deletion cmd/tailmixd/policyapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func (e *exitPreferenceEngine) Close() error { return nil }
func (e *exitPreferenceEngine) Dial(context.Context, string, string) (net.Conn, error) {
return nil, net.ErrClosed
}

func (e *exitPreferenceEngine) Status(context.Context) (tailmixprofile.Status, error) {
return e.status, nil
}
Expand All @@ -38,6 +39,7 @@ func (e *exitPreferenceEngine) SetExitNodeIP(_ context.Context, peerIP netip.Add
}
return nil
}

func (e *exitPreferenceEngine) SetRouteAll(_ context.Context, enabled bool) error {
e.routeChanges = append(e.routeChanges, enabled)
e.status.RouteAll = enabled
Expand Down Expand Up @@ -179,7 +181,7 @@ func TestStatusReportsSelectedPolicyWithoutAvailableChoices(t *testing.T) {
prefix := netip.MustParsePrefix("10.20.0.0/16")
engine := &exitPreferenceEngine{status: tailmixprofile.Status{
ProfileID: "p_work", Alias: "work", BackendState: "Running",
MagicDNSSuffix: "work.example", ExitNodeID: "exit-node",
MagicDNSSuffix: "work.example", ProfileDNS: true, ExitNodeID: "exit-node",
AvailableRoutes: []tailmixprofile.RouteStatus{{
Prefix: prefix, PrimaryRouter: "subnet-router",
}},
Expand Down
30 changes: 20 additions & 10 deletions cmd/tailmixd/supervisor.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,17 @@ type supervisor struct {
mux *tunmux.Mux
dnsService tailmixdns.Service
dnsForwarders []*tailmixdns.Forwarder
socksListener net.Listener
socksRouter *socksproxy.DynamicRouter
networkErr chan error
reconcileErr string
updateMu sync.Mutex
updateWake chan struct{}
updateRestart chan updateRestart
// retiredDNSForwarders holds the forwarders replaced by the last DNS
// reconfiguration. They stay open until the next reconfiguration because
// the aggregate resolver can still have queries in flight against them.
retiredDNSForwarders []*tailmixdns.Forwarder
socksListener net.Listener
socksRouter *socksproxy.DynamicRouter
networkErr chan error
reconcileErr string
updateMu sync.Mutex
updateWake chan struct{}
updateRestart chan updateRestart

leaseActivity effectiveip.ActivityTracker
}
Expand Down Expand Up @@ -522,7 +526,8 @@ func (s *supervisor) reconcileLocked() (err error) {
})
}
if exitProfileID := activeExitProfile(s.st, statuses); exitProfileID != "" {
subnetRoutes = append(subnetRoutes,
subnetRoutes = append(
subnetRoutes,
socksproxy.SubnetRoute{
Prefix: netip.PrefixFrom(netip.IPv4Unspecified(), 0),
ProfileID: exitProfileID, Active: true,
Expand Down Expand Up @@ -713,9 +718,12 @@ func (s *supervisor) configureDNSLocked(cfg tailmixdns.LiveConfig) error {
closeForwarders(nextForwarders)
return err
}
old := s.dnsForwarders
// Rotate the retired set before closing it: the generation that was live
// before this one now takes its place.
retired := s.retiredDNSForwarders
s.retiredDNSForwarders = s.dnsForwarders
s.dnsForwarders = nextForwarders
closeForwarders(old)
closeForwarders(retired)
return nil
}

Expand All @@ -741,6 +749,8 @@ func (s *supervisor) close() error {
}
closeForwarders(s.dnsForwarders)
s.dnsForwarders = nil
closeForwarders(s.retiredDNSForwarders)
s.retiredDNSForwarders = nil
if s.dnsService != nil {
_ = s.dnsService.Close()
s.dnsService = nil
Expand Down
149 changes: 149 additions & 0 deletions cmd/tailmixd/supervisor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,23 @@ import (
"errors"
"io"
"net"
"net/netip"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/maisem/tailmix/controlapi"
tailmixdns "github.com/maisem/tailmix/dns"
"github.com/maisem/tailmix/hosttun"
tailmixprofile "github.com/maisem/tailmix/profile"
"github.com/maisem/tailmix/profilesocket"
"github.com/maisem/tailmix/socksproxy"
"github.com/maisem/tailmix/state"
"github.com/maisem/tailmix/wireguardcfg"
"github.com/tailscale/wireguard-go/tun"
"golang.org/x/net/dns/dnsmessage"
)

type lifecycleEngine struct {
Expand All @@ -33,9 +36,11 @@ func (e *lifecycleEngine) Close() error {
e.closeCalls++
return e.closeErr
}

func (e *lifecycleEngine) Dial(context.Context, string, string) (net.Conn, error) {
return nil, net.ErrClosed
}

func (e *lifecycleEngine) Status(context.Context) (tailmixprofile.Status, error) {
return e.status, nil
}
Expand Down Expand Up @@ -314,3 +319,147 @@ func TestSupervisorCloseReportsHostCleanupError(t *testing.T) {
t.Fatalf("stderr = %q, want host cleanup failure", stderr.String())
}
}

// profileDNSEngine is a lifecycleEngine whose profile exposes an effective DNS
// resolver, so the supervisor can build a profile DNS forwarder for it.
type profileDNSEngine struct {
lifecycleEngine
response []byte
}

func (e *profileDNSEngine) QueryDNS(string, dnsmessage.Type) ([]byte, error) {
return e.response, nil
}

type recordingDNSService struct {
configs []tailmixdns.LiveConfig
}

func (s *recordingDNSService) Addr() netip.AddrPort { return netip.AddrPort{} }

func (s *recordingDNSService) Configure([]tailmixdns.Domain, []tailmixdns.Record) error {
return nil
}

func (s *recordingDNSService) ConfigureFull(cfg tailmixdns.LiveConfig) error {
s.configs = append(s.configs, cfg)
return nil
}

func (s *recordingDNSService) HandlePacket([]byte) bool { return false }
func (s *recordingDNSService) Outbound() <-chan []byte { return nil }
func (s *recordingDNSService) Err() error { return nil }
func (s *recordingDNSService) Close() error { return nil }

func dnsTestQuery(t *testing.T, name string) []byte {
t.Helper()
builder := dnsmessage.NewBuilder(nil, dnsmessage.Header{ID: 0x1234, RecursionDesired: true})
if err := builder.StartQuestions(); err != nil {
t.Fatal(err)
}
if err := builder.Question(dnsmessage.Question{
Name: dnsmessage.MustNewName(name), Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET,
}); err != nil {
t.Fatal(err)
}
query, err := builder.Finish()
if err != nil {
t.Fatal(err)
}
return query
}

func dnsTestResponse(t *testing.T, query []byte, addr netip.Addr) []byte {
t.Helper()
var parser dnsmessage.Parser
header, err := parser.Start(query)
if err != nil {
t.Fatal(err)
}
question, err := parser.Question()
if err != nil {
t.Fatal(err)
}
builder := dnsmessage.NewBuilder(nil, dnsmessage.Header{
ID: header.ID, Response: true,
RecursionDesired: header.RecursionDesired, RecursionAvailable: true,
})
if err := builder.StartQuestions(); err != nil {
t.Fatal(err)
}
if err := builder.Question(question); err != nil {
t.Fatal(err)
}
if err := builder.StartAnswers(); err != nil {
t.Fatal(err)
}
resourceHeader := dnsmessage.ResourceHeader{
Name: question.Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 60,
}
if err := builder.AResource(resourceHeader, dnsmessage.AResource{A: addr.As4()}); err != nil {
t.Fatal(err)
}
response, err := builder.Finish()
if err != nil {
t.Fatal(err)
}
return response
}

func TestConfigureDNSKeepsReplacedForwardersServing(t *testing.T) {
s := newLifecycleTestSupervisor(t, lifecycleTestState())
query := dnsTestQuery(t, "db.work.ts.net.")
response := dnsTestResponse(t, query, netip.MustParseAddr("100.127.0.7"))
s.runtimes["p_work"] = &managedProfile{
runtime: runtimeProfile{Engine: &profileDNSEngine{response: response}},
}
s.dnsService = &recordingDNSService{}

first := tailmixdns.LiveConfig{Routes: []tailmixdns.Route{{
Suffix: "work.ts.net.", ProfileID: "p_work", ProfileDNS: true,
}}}
if err := s.configureDNSLocked(first); err != nil {
t.Fatal(err)
}
if len(s.dnsForwarders) != 1 {
t.Fatalf("forwarders = %d, want 1", len(s.dnsForwarders))
}
replaced := s.dnsForwarders[0]

second := tailmixdns.LiveConfig{Routes: []tailmixdns.Route{{
Suffix: "other.ts.net.", ProfileID: "p_work", ProfileDNS: true,
}}}
if err := s.configureDNSLocked(second); err != nil {
t.Fatal(err)
}
if len(s.dnsForwarders) != 1 || s.dnsForwarders[0] == replaced {
t.Fatalf("forwarders = %v, want a replacement", s.dnsForwarders)
}

// The aggregate resolver can have queries in flight against the previous
// forwarder, so it must keep answering until the next reconfiguration.
conn, err := net.Dial("udp", replaced.Resolver().Addr)
if err != nil {
t.Fatal(err)
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(2 * time.Second)); err != nil {
t.Fatal(err)
}
if _, err := conn.Write(query); err != nil {
t.Fatal(err)
}
buffer := make([]byte, 512)
n, err := conn.Read(buffer)
if err != nil {
t.Fatalf("replaced forwarder did not answer: %v", err)
}
var parser dnsmessage.Parser
header, err := parser.Start(buffer[:n])
if err != nil {
t.Fatal(err)
}
if !header.Response || header.RCode != dnsmessage.RCodeSuccess || header.ID != 0x1234 {
t.Fatalf("response header = %+v, want a successful answer", header)
}
}
Loading