diff --git a/cmd/tailmix/completion.go b/cmd/tailmix/completion.go index 4ce30e6c4..7d7ddd27a 100644 --- a/cmd/tailmix/completion.go +++ b/cmd/tailmix/completion.go @@ -388,6 +388,15 @@ func completionCommandTree() *completionCommand { }, } + advertise := &completionCommand{ + name: "advertise", description: "Offer subnet routes and exit-node service to a tailnet", help: true, + subcommands: []*completionCommand{ + leaf("list", "List advertised routes", []completionFlag{profileFlag(), jsonFlag()}), + {name: "set", description: "Replace advertised routes", flags: []completionFlag{profileFlag(), jsonFlag()}, variadic: completionFreeValue}, + leaf("clear", "Clear advertised routes", []completionFlag{profileFlag(), jsonFlag()}), + }, + } + dnsRoutes := &completionCommand{ name: "routes", description: "Manage DNS route policy", help: true, subcommands: []*completionCommand{ @@ -452,6 +461,7 @@ func completionCommandTree() *completionCommand { profiles, routes, exitNode, + advertise, dns, wireguard, tailscaleCompletionCommand("tailscale", "Run an upstream Tailscale command for one profile", profileFlag()), diff --git a/cmd/tailmix/main.go b/cmd/tailmix/main.go index 8c963854b..9a9a95722 100644 --- a/cmd/tailmix/main.go +++ b/cmd/tailmix/main.go @@ -47,6 +47,9 @@ type managementClient interface { ExitNodes(context.Context) (controlapi.ExitNodes, error) SetExitNode(context.Context, controlapi.SetExitNodeRequest) (controlapi.ExitNodes, error) ClearExitNode(context.Context) (controlapi.ExitNodes, error) + AdvertiseRoutes(context.Context) (controlapi.AdvertisedRoutes, error) + SetAdvertiseRoutes(context.Context, controlapi.PatchAdvertiseRoutesRequest) (controlapi.AdvertisedRoutes, error) + ClearAdvertiseRoutes(context.Context, string) (controlapi.AdvertisedRoutes, error) DNSRoutes(context.Context, bool) (controlapi.DNSRoutes, error) PatchDNSRoutes(context.Context, controlapi.PatchDNSRoutesRequest) (controlapi.DNSRoutes, error) SearchDomains(context.Context) (controlapi.SearchDomains, error) @@ -119,6 +122,8 @@ func runWithDependencies(ctx context.Context, args []string, deps dependencies) err = runRoutes(ctx, client, args[1:], deps) case "exit-node": err = runExitNode(ctx, client, args[1:], deps) + case "advertise": + err = runAdvertise(ctx, client, args[1:], deps) case "dns": err = runDNS(ctx, client, args[1:], deps) case "wireguard": @@ -780,6 +785,91 @@ func runExitNode(ctx context.Context, client managementClient, args []string, de return nil } +func runAdvertise(ctx context.Context, client managementClient, args []string, deps dependencies) error { + if len(args) == 0 || isHelp(args[0]) { + fmt.Fprint(deps.stdout, advertiseHelp) + return nil + } + switch args[0] { + case "list": + rest := args[1:] + profileName, profileSet, err := takeProfile(&rest) + if err != nil { + return err + } + jsonOutput, err := takeBool(&rest, "--json") + if err != nil { + return err + } + if err := noOperands(rest); err != nil { + return err + } + result, err := client.AdvertiseRoutes(ctx) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(deps.stdout, result) + } + return writeAdvertisedRoutes(deps.stdout, result, profileName, profileSet) + case "set": + rest := args[1:] + profileName, set, err := takeProfile(&rest) + if err != nil { + return err + } + jsonOutput, err := takeBool(&rest, "--json") + if err != nil { + return err + } + if !set || strings.TrimSpace(profileName) == "" { + return usageError{"advertise set requires --profile"} + } + prefixes, err := parseAdvertisePrefixes(rest) + if err != nil { + return err + } + result, err := client.SetAdvertiseRoutes(ctx, controlapi.PatchAdvertiseRoutesRequest{ + ProfileName: profileName, + Add: prefixes, + Replace: true, + }) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(deps.stdout, result) + } + return writeAdvertisedRoutes(deps.stdout, result, profileName, true) + case "clear": + rest := args[1:] + profileName, set, err := takeProfile(&rest) + if err != nil { + return err + } + jsonOutput, err := takeBool(&rest, "--json") + if err != nil { + return err + } + if !set || strings.TrimSpace(profileName) == "" { + return usageError{"advertise clear requires --profile"} + } + if err := noOperands(rest); err != nil { + return err + } + result, err := client.ClearAdvertiseRoutes(ctx, profileName) + if err != nil { + return err + } + if jsonOutput { + return writeJSON(deps.stdout, result) + } + return writeAdvertisedRoutes(deps.stdout, result, profileName, true) + default: + return usageError{fmt.Sprintf("unknown advertise command %q", args[0])} + } +} + func runDNS(ctx context.Context, client managementClient, args []string, deps dependencies) error { if len(args) == 0 || isHelp(args[0]) { fmt.Fprint(deps.stdout, dnsHelp) @@ -1218,6 +1308,31 @@ func parsePrefixes(args []string) ([]netip.Prefix, error) { return out, nil } +// parseAdvertisePrefixes mirrors parsePrefixes but keeps default routes: +// advertising 0.0.0.0/0 and ::/0 offers exit-node service from the profile. +func parseAdvertisePrefixes(args []string) ([]netip.Prefix, error) { + if len(args) == 0 { + return nil, usageError{"at least one IP prefix is required"} + } + out := make([]netip.Prefix, 0, len(args)) + seen := map[netip.Prefix]bool{} + for _, raw := range args { + if strings.HasPrefix(raw, "-") { + return nil, usageError{fmt.Sprintf("unknown option %q", raw)} + } + prefix, err := netip.ParsePrefix(raw) + if err != nil { + return nil, usageError{fmt.Sprintf("invalid IP prefix %q: %v", raw, err)} + } + prefix = prefix.Masked() + if !seen[prefix] { + seen[prefix] = true + out = append(out, prefix) + } + } + return out, nil +} + func parseDomains(args []string, allowRoot bool) ([]string, error) { if len(args) == 0 { return nil, usageError{"at least one DNS domain is required"} @@ -1349,6 +1464,7 @@ func writeProfile(w io.Writer, profile controlapi.Profile) { {"Self DNS", profile.SelfDNSName}, {"Accept all routes", yesNo(profile.AcceptAllRoutes)}, {"Accept all DNS routes", yesNo(profile.AcceptAllDNSRoutes)}, + {"Advertised routes", joinStrings(profile.AdvertisedRoutes)}, {"LocalAPI socket", profile.LocalAPISocket}, {"Error", profile.LastError}, } @@ -1559,6 +1675,31 @@ func writeIPMutationResult(w io.Writer, result controlapi.IPRoutes, jsonOutput b return nil } +func writeAdvertisedRoutes(w io.Writer, result controlapi.AdvertisedRoutes, profileName string, filter bool) error { + table := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + fmt.Fprintln(table, "PROFILE\tDESIRED\tAPPLIED\tSTATE") + matched := false + for _, entry := range result.Profiles { + if filter && entry.ProfileName != profileName { + continue + } + matched = true + fmt.Fprintf(table, "%s\t%s\t%s\t%s\n", + entry.ProfileName, + joinStrings(entry.Routes), + joinStrings(entry.Applied), + stateLabel(entry.State, entry.Reason)) + } + if result.ReconcileError != "" { + fmt.Fprintf(table, "!\t\t\tfailed:%s\n", result.ReconcileError) + } + _ = table.Flush() + if filter && !matched { + return fmt.Errorf("profile %q not found", profileName) + } + return nil +} + func writeDNSMutationResult(w io.Writer, result controlapi.DNSRoutes, jsonOutput bool) error { if jsonOutput { return writeJSON(w, result) @@ -1619,6 +1760,7 @@ Commands: profiles Manage profile lifecycle and configuration routes Accept IP routes and pin prefixes to profiles exit-node Select one profile's exit node for default traffic + advertise Offer subnet routes and exit-node service to a tailnet dns routes Route DNS suffixes through selected profiles dns search Manage the ordered OS search-domain list wireguard Apply and inspect raw WireGuard profiles @@ -1726,6 +1868,21 @@ selected node hidden by those rules. Use --filter with a country name to show every exit node in that country. ` +const advertiseHelp = `Usage: + tailmix advertise list [--profile ] [--json] + tailmix advertise set --profile ... [--json] + tailmix advertise clear --profile [--json] + +Profile option: -p, --profile + +Advertise offers subnet routes to a profile's tailnet so other devices can +reach those prefixes through this host. This is the opposite direction from +"routes", which accepts routes other devices advertise. Advertising +0.0.0.0/0 and ::/0 offers exit-node service. Advertised prefixes are LAN or +exit prefixes, never effective IPs. Requires tun mode on Linux, and +tailscaled must be stopped first. +` + const dnsHelp = `Usage: tailmix dns routes [arguments] tailmix dns search [arguments] diff --git a/cmd/tailmix/main_test.go b/cmd/tailmix/main_test.go index 61a5e8488..103bb04cb 100644 --- a/cmd/tailmix/main_test.go +++ b/cmd/tailmix/main_test.go @@ -30,6 +30,9 @@ type fakeManagementClient struct { exitCleared bool exitNodes controlapi.ExitNodes dnsPatch controlapi.PatchDNSRoutesRequest + advertised controlapi.AdvertisedRoutes + advertiseSet controlapi.PatchAdvertiseRoutesRequest + advertiseCleared string searchReplace []string statusProfiles []controlapi.Profile serverVersion tailmixversion.Meta @@ -102,6 +105,17 @@ func (f *fakeManagementClient) ClearExitNode(context.Context) (controlapi.ExitNo f.exitCleared = true return f.exitNodes, nil } +func (f *fakeManagementClient) AdvertiseRoutes(context.Context) (controlapi.AdvertisedRoutes, error) { + return f.advertised, nil +} +func (f *fakeManagementClient) SetAdvertiseRoutes(_ context.Context, request controlapi.PatchAdvertiseRoutesRequest) (controlapi.AdvertisedRoutes, error) { + f.advertiseSet = request + return f.advertised, nil +} +func (f *fakeManagementClient) ClearAdvertiseRoutes(_ context.Context, name string) (controlapi.AdvertisedRoutes, error) { + f.advertiseCleared = name + return f.advertised, nil +} func (f *fakeManagementClient) DNSRoutes(context.Context, bool) (controlapi.DNSRoutes, error) { return controlapi.DNSRoutes{}, nil } @@ -839,6 +853,66 @@ func TestExitNodeSetAndClearRequests(t *testing.T) { } } +func TestAdvertiseSetAcceptsExitDefaults(t *testing.T) { + client := &fakeManagementClient{advertised: controlapi.AdvertisedRoutes{ + Profiles: []controlapi.ProfileAdvertisedRoutes{{ + ProfileID: "work-id", ProfileName: "work", + Routes: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")}, + Applied: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")}, + State: "installed", + }}, + }} + code := runWithDependencies(context.Background(), + []string{"advertise", "set", "--profile", "work", "0.0.0.0/0", "::/0"}, + testDependencies(client, io.Discard, io.Discard, nil)) + if code != 0 { + t.Fatalf("advertise set exit code = %d", code) + } + want := []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")} + if client.advertiseSet.ProfileName != "work" || !client.advertiseSet.Replace || + !slices.Equal(client.advertiseSet.Add, want) { + t.Fatalf("advertise set request = %+v", client.advertiseSet) + } + + code = runWithDependencies(context.Background(), + []string{"advertise", "clear", "--profile", "work"}, + testDependencies(client, io.Discard, io.Discard, nil)) + if code != 0 || client.advertiseCleared != "work" { + t.Fatalf("advertise clear code = %d, cleared = %q", code, client.advertiseCleared) + } + + var stderr bytes.Buffer + code = runWithDependencies(context.Background(), + []string{"advertise", "set", "10.0.0.0/8"}, + testDependencies(client, io.Discard, &stderr, nil)) + if code != 2 || !strings.Contains(stderr.String(), "advertise set requires --profile") { + t.Fatalf("code = %d, stderr = %q", code, stderr.String()) + } +} + +func TestAdvertiseListShowsDesiredAndApplied(t *testing.T) { + client := &fakeManagementClient{advertised: controlapi.AdvertisedRoutes{ + Profiles: []controlapi.ProfileAdvertisedRoutes{{ + ProfileID: "work-id", ProfileName: "work", + Routes: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")}, + Applied: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")}, + State: "installed", + }}, + }} + var stdout bytes.Buffer + code := runWithDependencies(context.Background(), + []string{"advertise", "list", "--profile", "work"}, + testDependencies(client, &stdout, io.Discard, nil)) + if code != 0 { + t.Fatalf("advertise list exit code = %d", code) + } + for _, want := range []string{"PROFILE", "work", "0.0.0.0/0", "::/0", "✓"} { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("advertise list does not contain %q:\n%s", want, stdout.String()) + } + } +} + func TestExitNodeListShowsSelectedAndAvailableNodes(t *testing.T) { nodeIP := netip.MustParseAddr("100.64.0.20") client := &fakeManagementClient{exitNodes: controlapi.ExitNodes{ diff --git a/cmd/tailmixd/advertise.go b/cmd/tailmixd/advertise.go new file mode 100644 index 000000000..b98b46127 --- /dev/null +++ b/cmd/tailmixd/advertise.go @@ -0,0 +1,263 @@ +package main + +import ( + "context" + "net/netip" + "os" + "runtime" + "slices" + "sort" + + "github.com/maisem/tailmix/controlapi" + tailmixprofile "github.com/maisem/tailmix/profile" + "github.com/maisem/tailmix/state" +) + +// tailscaledSocketPath and tailscaleInterfaceSysPath locate the signals used +// to detect a running tailscaled before advertising subnet routes. They are +// variables so tests can point them at scratch directories. +var ( + tailscaledSocketPath = "/var/run/tailscale/tailscaled.sock" + tailscaleInterfaceSysPath = "/sys/class/net/tailscale0" + tailscaledConflictDetected = detectTailscaledConflict +) + +func (s *supervisor) requireAdvertiseSupportedLocked() error { + if runtime.GOOS != "linux" || s.cfg.Mode != "tun" { + return controlapi.NewError("unsupported", "route advertisement requires tun mode on Linux") + } + return nil +} + +// detectTailscaledConflict reports whether a standalone tailscaled appears to +// own this host's routes or DNS. Advertising tailmix subnet routes while +// tailscaled is running would leave two daemons fighting over forwarding +// rules, so the advertise API refuses with a typed error until tailscaled is +// stopped (see docs/linux-install.md). +func detectTailscaledConflict() *controlapi.Error { + if _, err := os.Stat(tailscaledSocketPath); err == nil { + return controlapi.NewError("tailscaled_running", + "tailscaled socket is present; stop tailscaled before advertising routes") + } + if _, err := os.Stat(tailscaleInterfaceSysPath); err == nil { + return controlapi.NewError("tailscaled_running", + "tailscale0 interface is present; stop tailscaled before advertising routes") + } + return nil +} + +func (s *supervisor) AdvertiseRoutes(_ context.Context) (controlapi.AdvertisedRoutes, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.requireAdvertiseSupportedLocked(); err != nil { + return controlapi.AdvertisedRoutes{}, err + } + return s.advertisedRoutesLocked(s.statusesLocked()), nil +} + +func (s *supervisor) SetAdvertiseRoutes(_ context.Context, request controlapi.PatchAdvertiseRoutesRequest) (controlapi.AdvertisedRoutes, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.requireAdvertiseSupportedLocked(); err != nil { + return controlapi.AdvertisedRoutes{}, err + } + next := cloneState(s.st) + configured, err := profileByName(&next, request.ProfileName) + if err != nil { + return controlapi.AdvertisedRoutes{}, err + } + if configured.Removed { + return controlapi.AdvertisedRoutes{}, controlapi.NewError("profile_not_found", "profile %q is removed", request.ProfileName) + } + desired := append([]netip.Prefix(nil), configured.AdvertiseRoutes...) + if request.Replace { + desired = nil + } + for _, prefix := range request.Remove { + prefix, err := validateAdvertisePrefix(next, prefix) + if err != nil { + return controlapi.AdvertisedRoutes{}, err + } + desired = slices.DeleteFunc(desired, func(existing netip.Prefix) bool { return existing == prefix }) + } + for _, prefix := range request.Add { + prefix, err := validateAdvertisePrefix(next, prefix) + if err != nil { + return controlapi.AdvertisedRoutes{}, err + } + if !slices.Contains(desired, prefix) { + desired = append(desired, prefix) + } + } + if len(desired) != 0 { + if conflict := tailscaledConflictDetected(); conflict != nil { + return controlapi.AdvertisedRoutes{}, conflict + } + } + configured.AdvertiseRoutes = desired + if err := s.commitPolicyLocked(next); err != nil { + return controlapi.AdvertisedRoutes{}, err + } + return s.advertisedRoutesLocked(s.statusesLocked()), nil +} + +func (s *supervisor) ClearAdvertiseRoutes(_ context.Context, name string) (controlapi.AdvertisedRoutes, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.requireAdvertiseSupportedLocked(); err != nil { + return controlapi.AdvertisedRoutes{}, err + } + next := cloneState(s.st) + configured, err := profileByName(&next, name) + if err != nil { + return controlapi.AdvertisedRoutes{}, err + } + if configured.Removed { + return controlapi.AdvertisedRoutes{}, controlapi.NewError("profile_not_found", "profile %q is removed", name) + } + if len(configured.AdvertiseRoutes) == 0 { + return s.advertisedRoutesLocked(s.statusesLocked()), nil + } + configured.AdvertiseRoutes = nil + if err := s.commitPolicyLocked(next); err != nil { + return controlapi.AdvertisedRoutes{}, err + } + return s.advertisedRoutesLocked(s.statusesLocked()), nil +} + +// validateAdvertisePrefix canonicalizes an advertise-side prefix. Unlike the +// accept-side policy validator, default routes are valid here: advertising +// 0.0.0.0/0 and ::/0 offers exit-node service. Reserved-range overlap is +// rejected for subnet advertisements; a default route trivially contains +// every range, so the reserved check cannot apply to it. +func validateAdvertisePrefix(st state.State, prefix netip.Prefix) (netip.Prefix, error) { + if !prefix.IsValid() { + return netip.Prefix{}, controlapi.NewError("invalid_prefix", "invalid IP prefix") + } + prefix = prefix.Masked() + if prefix.Bits() != 0 && routeOverlapsReserved(st, prefix) { + return netip.Prefix{}, controlapi.NewError("invalid_prefix", "route %v overlaps a tailmix reserved range", prefix) + } + return prefix, nil +} + +func (s *supervisor) advertisedRoutesLocked(statuses []tailmixprofile.Status) controlapi.AdvertisedRoutes { + result := controlapi.AdvertisedRoutes{ReconcileError: s.reconcileErr} + byStatus := make(map[string]tailmixprofile.Status, len(statuses)) + for _, status := range statuses { + byStatus[status.ProfileID] = status + } + for _, configured := range s.st.Profiles { + if configured.Removed { + continue + } + entry := controlapi.ProfileAdvertisedRoutes{ + ProfileID: configured.ID, + ProfileName: profileName(configured), + Routes: append([]netip.Prefix(nil), configured.AdvertiseRoutes...), + } + status, ok := byStatus[configured.ID] + if ok { + entry.Applied = append([]netip.Prefix(nil), status.AdvertisedRoutes...) + } + switch { + case configured.Disabled: + entry.State = "disabled" + entry.Reason = "profile_disabled" + case len(entry.Routes) == 0 && len(entry.Applied) == 0: + entry.State = "off" + case !ok || (status.BackendState != "" && status.BackendState != "Running"): + entry.State = "waiting" + entry.Reason = "profile_unavailable" + case len(entry.Routes) != 0 && !engineSupportsAdvertise(s.runtimes[configured.ID]): + entry.State = "unsupported" + entry.Reason = "advertise_unsupported" + case !slices.Equal(entry.Routes, entry.Applied): + entry.State = "waiting" + entry.Reason = "advertise_not_applied" + default: + entry.State = "installed" + } + result.Profiles = append(result.Profiles, entry) + } + sort.Slice(result.Profiles, func(i, j int) bool { + return result.Profiles[i].ProfileName < result.Profiles[j].ProfileName + }) + return result +} + +func engineSupportsAdvertise(managed *managedProfile) bool { + if managed == nil { + return false + } + _, ok := managed.runtime.Engine.(tailmixprofile.AdvertiseRoutesController) + return ok +} + +// setAdvertisePreferencesLocked converges each running engine's advertised +// routes to the persisted desired set. Engines that are not Running are +// skipped; their desired state applies on the next successful reconcile. +func (s *supervisor) setAdvertisePreferencesLocked(statuses []tailmixprofile.Status) error { + desiredByProfile := make(map[string][]netip.Prefix, len(s.st.Profiles)) + for _, configured := range s.st.Profiles { + if configured.Disabled || configured.Removed || len(configured.AdvertiseRoutes) == 0 { + continue + } + desiredByProfile[configured.ID] = configured.AdvertiseRoutes + } + if len(desiredByProfile) != 0 { + if conflict := tailscaledConflictDetected(); conflict != nil { + return conflict + } + } + appliedByProfile := make(map[string][]netip.Prefix, len(statuses)) + for _, status := range statuses { + appliedByProfile[status.ProfileID] = status.AdvertisedRoutes + } + for id, managed := range s.runtimes { + desired := desiredByProfile[id] + if slices.Equal(appliedByProfile[id], desired) { + continue + } + if managed.status.BackendState != "Running" { + continue + } + controller, ok := managed.runtime.Engine.(tailmixprofile.AdvertiseRoutesController) + if !ok { + if len(desired) == 0 { + continue + } + return controlapi.NewError("advertise_unsupported", + "profile %q does not support route advertisement", s.nameForIDLocked(id)) + } + if err := controller.SetAdvertiseRoutes(s.ctx, desired); err != nil { + s.lastErrors[id] = "set advertised routes: " + err.Error() + return controlapi.NewError("reconcile_failed", + "advertise routes for profile %q: %v", s.nameForIDLocked(id), err) + } + managed.status.AdvertisedRoutes = append([]netip.Prefix(nil), desired...) + } + return nil +} + +// withdrawAdvertisedRoutesLocked clears a profile's tailnet advertisements +// before its engine closes so stale ads cannot survive a later re-add that +// reuses the same state directory. Removal continues when withdrawal fails; +// the persisted desired set is already cleared, so the next reconcile cannot +// reapply it. +func (s *supervisor) withdrawAdvertisedRoutesLocked(profileID string) { + managed, ok := s.runtimes[profileID] + if !ok || managed.status.BackendState != "Running" { + return + } + controller, ok := managed.runtime.Engine.(tailmixprofile.AdvertiseRoutesController) + if !ok { + return + } + if len(managed.status.AdvertisedRoutes) == 0 { + return + } + if err := controller.SetAdvertiseRoutes(s.ctx, nil); err != nil { + s.lastErrors[profileID] = "withdraw advertised routes: " + err.Error() + } +} diff --git a/cmd/tailmixd/advertise_test.go b/cmd/tailmixd/advertise_test.go new file mode 100644 index 000000000..d75b4d298 --- /dev/null +++ b/cmd/tailmixd/advertise_test.go @@ -0,0 +1,346 @@ +package main + +import ( + "context" + "net" + "net/netip" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/maisem/tailmix/controlapi" + tailmixprofile "github.com/maisem/tailmix/profile" + "github.com/maisem/tailmix/state" +) + +type advertiseEngine struct { + status tailmixprofile.Status + changes [][]netip.Prefix + closed bool + closeErr error +} + +func (e *advertiseEngine) Start(context.Context) error { return nil } +func (e *advertiseEngine) Close() error { + e.closed = true + return e.closeErr +} +func (e *advertiseEngine) Dial(context.Context, string, string) (net.Conn, error) { + return nil, net.ErrClosed +} +func (e *advertiseEngine) Status(context.Context) (tailmixprofile.Status, error) { + return e.status, nil +} +func (e *advertiseEngine) WatchUpdates(context.Context, func()) error { return nil } +func (e *advertiseEngine) SetAdvertiseRoutes(_ context.Context, routes []netip.Prefix) error { + e.changes = append(e.changes, append([]netip.Prefix(nil), routes...)) + e.status.AdvertisedRoutes = append([]netip.Prefix(nil), routes...) + return nil +} +func (e *advertiseEngine) SetRouteAll(_ context.Context, enabled bool) error { + e.status.RouteAll = enabled + return nil +} + +// isolateTailscaledDetection points conflict detection at scratch paths so +// tests do not observe the developer machine's own tailscaled. +func isolateTailscaledDetection(t *testing.T) { + t.Helper() + oldSocket, oldIface := tailscaledSocketPath, tailscaleInterfaceSysPath + tailscaledSocketPath = filepath.Join(t.TempDir(), "no-such.sock") + tailscaleInterfaceSysPath = filepath.Join(t.TempDir(), "no-such-iface") + t.Cleanup(func() { + tailscaledSocketPath, tailscaleInterfaceSysPath = oldSocket, oldIface + }) +} + +// staticEngine implements only tailmixprofile.Engine, modeling runtimes +// (such as raw WireGuard profiles) that cannot advertise routes. +type staticEngine struct { + status tailmixprofile.Status +} + +func (e *staticEngine) Start(context.Context) error { return nil } +func (e *staticEngine) Close() error { return nil } +func (e *staticEngine) Dial(context.Context, string, string) (net.Conn, error) { + return nil, net.ErrClosed +} +func (e *staticEngine) Status(context.Context) (tailmixprofile.Status, error) { + return e.status, nil +} +func (e *staticEngine) WatchUpdates(context.Context, func()) error { return nil } + +func advertiseTestSupervisor(st state.State, engines map[string]*managedProfile) *supervisor { + return &supervisor{ + ctx: context.Background(), + st: st, + runtimes: engines, + lastErrors: map[string]string{}, + } +} + +func TestValidateAdvertisePrefixAllowsExitDefaults(t *testing.T) { + st := state.State{ + SyntheticPool: "100.127.0.0/24", + SyntheticPoolV6: "fd6d:6e65:7400::/56", + NATIP: netip.MustParseAddr("100.127.0.1"), + } + for _, raw := range []string{"0.0.0.0/0", "::/0", "192.168.1.0/24", "10.20.3.4/16"} { + prefix, err := validateAdvertisePrefix(st, netip.MustParsePrefix(raw)) + if err != nil { + t.Fatalf("validate %q: %v", raw, err) + } + if prefix != prefix.Masked() { + t.Fatalf("validate %q did not mask: %v", raw, prefix) + } + } + for _, raw := range []string{"100.64.0.0/10", "100.127.0.0/26", "100.127.0.1/32"} { + if _, err := validateAdvertisePrefix(st, netip.MustParsePrefix(raw)); err == nil { + t.Fatalf("reserved overlap %q was accepted", raw) + } else if apiErr, ok := err.(*controlapi.Error); !ok || apiErr.Code != "invalid_prefix" { + t.Fatalf("reserved overlap %q error = %#v, want invalid_prefix", raw, err) + } + } + if _, err := validateAdvertisePrefix(st, netip.Prefix{}); err == nil { + t.Fatal("invalid prefix was accepted") + } else if apiErr, ok := err.(*controlapi.Error); !ok || apiErr.Code != "invalid_prefix" { + t.Fatalf("invalid prefix error = %#v, want invalid_prefix", err) + } +} + +func TestSetAdvertisePreferencesConverge(t *testing.T) { + isolateTailscaledDetection(t) + desired := []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")} + engine := &advertiseEngine{status: tailmixprofile.Status{ + ProfileID: "p_work", BackendState: "Running", + }} + s := advertiseTestSupervisor(state.State{ + SyntheticPool: "100.127.0.0/24", + SyntheticPoolV6: "fd6d:6e65:7400::/56", + Profiles: []state.Profile{{ID: "p_work", Name: "work", AdvertiseRoutes: desired}}, + }, map[string]*managedProfile{ + "p_work": { + runtime: runtimeProfile{State: state.Profile{ID: "p_work"}, Engine: engine}, + status: engine.status, + }, + }) + observed := []tailmixprofile.Status{engine.status} + if err := s.setAdvertisePreferencesLocked(observed); err != nil { + t.Fatal(err) + } + if len(engine.changes) != 1 || !slices.Equal(engine.changes[0], desired) { + t.Fatalf("advertise changes = %v, want [%v]", engine.changes, desired) + } + // Converged state reconciles without further backend writes. + observed = []tailmixprofile.Status{engine.status} + if err := s.setAdvertisePreferencesLocked(observed); err != nil { + t.Fatal(err) + } + if len(engine.changes) != 1 { + t.Fatalf("converged advertise changes = %v, want no further writes", engine.changes) + } +} + +func TestSetAdvertisePreferencesClearOnRemove(t *testing.T) { + isolateTailscaledDetection(t) + engine := &advertiseEngine{status: tailmixprofile.Status{ + ProfileID: "p_work", BackendState: "Running", + AdvertisedRoutes: []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")}, + }} + s := advertiseTestSupervisor(state.State{ + SyntheticPool: "100.127.0.0/24", + SyntheticPoolV6: "fd6d:6e65:7400::/56", + Profiles: []state.Profile{{ID: "p_work", Name: "work"}}, + }, map[string]*managedProfile{ + "p_work": { + runtime: runtimeProfile{State: state.Profile{ID: "p_work"}, Engine: engine}, + status: engine.status, + }, + }) + if err := s.setAdvertisePreferencesLocked([]tailmixprofile.Status{engine.status}); err != nil { + t.Fatal(err) + } + if len(engine.changes) != 1 || len(engine.changes[0]) != 0 { + t.Fatalf("withdrawal changes = %v, want one empty set", engine.changes) + } +} + +func TestSetAdvertisePreferencesSkipsUnavailableBackends(t *testing.T) { + isolateTailscaledDetection(t) + desired := []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")} + stopped := &advertiseEngine{status: tailmixprofile.Status{ + ProfileID: "p_work", BackendState: "Stopped", + }} + s := advertiseTestSupervisor(state.State{ + SyntheticPool: "100.127.0.0/24", + SyntheticPoolV6: "fd6d:6e65:7400::/56", + Profiles: []state.Profile{{ID: "p_work", Name: "work", AdvertiseRoutes: desired}}, + }, map[string]*managedProfile{ + "p_work": { + runtime: runtimeProfile{State: state.Profile{ID: "p_work"}, Engine: stopped}, + status: stopped.status, + }, + }) + if err := s.setAdvertisePreferencesLocked([]tailmixprofile.Status{stopped.status}); err != nil { + t.Fatal(err) + } + if len(stopped.changes) != 0 { + t.Fatalf("stopped backend changes = %v, want none", stopped.changes) + } +} + +func TestSetAdvertisePreferencesRejectsUnsupportedEngine(t *testing.T) { + isolateTailscaledDetection(t) + desired := []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")} + engine := &staticEngine{} + engine.status = tailmixprofile.Status{ProfileID: "p_lab", BackendState: "Running"} + s := advertiseTestSupervisor(state.State{ + SyntheticPool: "100.127.0.0/24", + SyntheticPoolV6: "fd6d:6e65:7400::/56", + Profiles: []state.Profile{{ID: "p_lab", Name: "lab", AdvertiseRoutes: desired}}, + }, map[string]*managedProfile{ + "p_lab": { + runtime: runtimeProfile{State: state.Profile{ID: "p_lab"}, Engine: engine}, + status: engine.status, + }, + }) + err := s.setAdvertisePreferencesLocked([]tailmixprofile.Status{engine.status}) + if apiErr, ok := err.(*controlapi.Error); !ok || apiErr.Code != "advertise_unsupported" { + t.Fatalf("unsupported engine error = %#v, want advertise_unsupported", err) + } +} + +func TestRemoveProfileWithdrawsAdvertisedRoutes(t *testing.T) { + isolateTailscaledDetection(t) + engine := &advertiseEngine{status: tailmixprofile.Status{ + ProfileID: "p_work", BackendState: "Running", + AdvertisedRoutes: []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")}, + }} + st := state.State{ + SyntheticPool: "100.127.0.0/24", + SyntheticPoolV6: "fd6d:6e65:7400::/56", + Profiles: []state.Profile{{ + ID: "p_work", Name: "work", + AdvertiseRoutes: []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")}, + }}, + } + s := newLifecycleTestSupervisor(t, st) + _, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + s.runtimes["p_work"] = &managedProfile{ + runtime: runtimeProfile{State: st.Profiles[0], Engine: engine}, + cancel: cancel, + status: engine.status, + } + if _, err := s.RemoveProfile(context.Background(), "work", false); err != nil { + t.Fatal(err) + } + if len(s.st.Profiles) != 1 || len(s.st.Profiles[0].AdvertiseRoutes) != 0 { + t.Fatalf("removal retained advertised routes: %+v", s.st.Profiles) + } + if len(engine.changes) == 0 || len(engine.changes[0]) != 0 { + t.Fatalf("engine changes = %v, want live withdrawal to empty before close", engine.changes) + } + if !engine.closed { + t.Fatal("engine was not closed by removal") + } +} + +func TestAdvertiseAPIRequiresTunOnLinux(t *testing.T) { + st := state.State{ + SyntheticPool: "100.127.0.0/24", + SyntheticPoolV6: "fd6d:6e65:7400::/56", + Profiles: []state.Profile{{ID: "p_work", Name: "work"}}, + } + s := newLifecycleTestSupervisor(t, st) + if _, err := s.AdvertiseRoutes(context.Background()); err == nil { + t.Fatal("advertise list succeeded in socks mode") + } else if apiErr, ok := err.(*controlapi.Error); !ok || apiErr.Code != "unsupported" { + t.Fatalf("advertise list error = %#v, want unsupported", err) + } + setReq := controlapi.PatchAdvertiseRoutesRequest{ + ProfileName: "work", + Add: []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")}, + Replace: true, + } + if _, err := s.SetAdvertiseRoutes(context.Background(), setReq); err == nil { + t.Fatal("advertise set succeeded in socks mode") + } else if apiErr, ok := err.(*controlapi.Error); !ok || apiErr.Code != "unsupported" { + t.Fatalf("advertise set error = %#v, want unsupported", err) + } + if _, err := s.ClearAdvertiseRoutes(context.Background(), "work"); err == nil { + t.Fatal("advertise clear succeeded in socks mode") + } else if apiErr, ok := err.(*controlapi.Error); !ok || apiErr.Code != "unsupported" { + t.Fatalf("advertise clear error = %#v, want unsupported", err) + } +} + +func TestTailscaledConflictDetection(t *testing.T) { + dir := t.TempDir() + oldSocket, oldIface := tailscaledSocketPath, tailscaleInterfaceSysPath + t.Cleanup(func() { + tailscaledSocketPath, tailscaleInterfaceSysPath = oldSocket, oldIface + }) + tailscaledSocketPath = filepath.Join(dir, "no-such.sock") + tailscaleInterfaceSysPath = filepath.Join(dir, "no-such-iface") + if conflict := detectTailscaledConflict(); conflict != nil { + t.Fatalf("conflict without tailscaled = %v", conflict) + } + if err := os.WriteFile(tailscaledSocketPath, []byte("x"), 0600); err != nil { + t.Fatal(err) + } + conflict := detectTailscaledConflict() + if conflict == nil || conflict.Code != "tailscaled_running" { + t.Fatalf("socket conflict = %#v, want tailscaled_running", conflict) + } + if err := os.Remove(tailscaledSocketPath); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(tailscaleInterfaceSysPath, 0755); err != nil { + t.Fatal(err) + } + if conflict := detectTailscaledConflict(); conflict == nil || conflict.Code != "tailscaled_running" { + t.Fatalf("interface conflict = %#v, want tailscaled_running", conflict) + } +} + +func TestAdvertisedRoutesLockedReportsStates(t *testing.T) { + lan := netip.MustParsePrefix("192.168.1.0/24") + running := &advertiseEngine{status: tailmixprofile.Status{ + ProfileID: "p_work", BackendState: "Running", AdvertisedRoutes: []netip.Prefix{lan}, + }} + pending := &advertiseEngine{status: tailmixprofile.Status{ + ProfileID: "p_lab", BackendState: "Running", + }} + s := advertiseTestSupervisor(state.State{ + SyntheticPool: "100.127.0.0/24", + SyntheticPoolV6: "fd6d:6e65:7400::/56", + Profiles: []state.Profile{ + {ID: "p_work", Name: "work", AdvertiseRoutes: []netip.Prefix{lan}}, + {ID: "p_lab", Name: "lab", AdvertiseRoutes: []netip.Prefix{lan}}, + {ID: "p_idle", Name: "idle"}, + {ID: "p_off", Name: "off", Disabled: true, AdvertiseRoutes: []netip.Prefix{lan}}, + }, + }, map[string]*managedProfile{ + "p_work": {runtime: runtimeProfile{State: state.Profile{ID: "p_work"}, Engine: running}, status: running.status}, + "p_lab": {runtime: runtimeProfile{State: state.Profile{ID: "p_lab"}, Engine: pending}, status: pending.status}, + }) + result := s.advertisedRoutesLocked([]tailmixprofile.Status{running.status, pending.status}) + byName := map[string]controlapi.ProfileAdvertisedRoutes{} + for _, entry := range result.Profiles { + byName[entry.ProfileName] = entry + } + if byName["work"].State != "installed" || !slices.Equal(byName["work"].Applied, []netip.Prefix{lan}) { + t.Fatalf("work entry = %+v, want installed with applied routes", byName["work"]) + } + if byName["lab"].State != "waiting" { + t.Fatalf("lab entry = %+v, want waiting", byName["lab"]) + } + if byName["idle"].State != "off" { + t.Fatalf("idle entry = %+v, want off", byName["idle"]) + } + if byName["off"].State != "disabled" || byName["off"].Reason != "profile_disabled" { + t.Fatalf("off entry = %+v, want disabled", byName["off"]) + } +} diff --git a/cmd/tailmixd/main.go b/cmd/tailmixd/main.go index 3f01b405a..b0d332e82 100644 --- a/cmd/tailmixd/main.go +++ b/cmd/tailmixd/main.go @@ -1054,6 +1054,19 @@ func tunConfigWithPolicy(st state.State, statuses []tailmixprofile.Status, lease } } } + // Advertised routes are offered to the tailnet, not installed as host + // destinations. The host only forwards tailnet-originated packets for + // them (see hosttun advertising support). Only usable (reachable) + // profiles contribute; a profile whose backend is down cannot receive + // the traffic, so its advertisements stay inert until it recovers. + for _, ps := range statuses { + for _, prefix := range advertisedRoutesForProfile(st, ps.ProfileID) { + hostCfg.Advertised = append(hostCfg.Advertised, hosttun.AdvertisedRoute{ + ProfileID: ps.ProfileID, + Prefix: prefix, + }) + } + } serviceIPs := []netip.Addr{st.DNSIP, tailmixdns.ServiceIP()} for _, serviceIP := range serviceIPs { if !serviceIP.IsValid() { @@ -1162,6 +1175,18 @@ func prefixesOverlap(a, b netip.Prefix) bool { return a.Contains(b.Addr()) || b.Contains(a.Addr()) } +func advertisedRoutesForProfile(st state.State, profileID string) []netip.Prefix { + for _, configured := range st.Profiles { + if configured.ID == profileID { + if configured.Disabled || configured.Removed { + return nil + } + return configured.AdvertiseRoutes + } + } + return nil +} + func natIPFor(st state.State, canonical netip.Addr) netip.Addr { if canonical.Is6() { return st.NATIPv6 diff --git a/cmd/tailmixd/main_test.go b/cmd/tailmixd/main_test.go index 3616edb84..88b1a95b8 100644 --- a/cmd/tailmixd/main_test.go +++ b/cmd/tailmixd/main_test.go @@ -6,10 +6,12 @@ import ( "flag" "net/netip" "path/filepath" + "slices" "testing" tailmixdns "github.com/maisem/tailmix/dns" "github.com/maisem/tailmix/effectiveip" + "github.com/maisem/tailmix/hosttun" "github.com/maisem/tailmix/packetmap" tailmixprofile "github.com/maisem/tailmix/profile" "github.com/maisem/tailmix/state" @@ -451,6 +453,40 @@ func TestTunConfigUsesSharedNATRouteSource(t *testing.T) { } } +func TestTunConfigForwardsAdvertisedRoutes(t *testing.T) { + self := netip.MustParseAddr("100.64.0.10") + statuses := []tailmixprofile.Status{{ + ProfileID: "work", + SelfIPs: []netip.Addr{self}, + }} + st := state.State{ + SyntheticPool: "100.127.0.0/24", + SyntheticPoolV6: "fd6d:6e65:7400::/120", + Profiles: []state.Profile{ + {ID: "work", Name: "work", AdvertiseRoutes: []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")}}, + {ID: "lab", Name: "lab", Disabled: true, AdvertiseRoutes: []netip.Prefix{netip.MustParsePrefix("10.9.0.0/16")}}, + }, + } + if err := ensureHostIPs(&st); err != nil { + t.Fatal(err) + } + _, hostCfg, err := tunConfig(st, statuses, nil) + if err != nil { + t.Fatal(err) + } + want := []hosttun.AdvertisedRoute{{ + ProfileID: "work", Prefix: netip.MustParsePrefix("192.168.1.0/24"), + }} + if !slices.Equal(hostCfg.Advertised, want) { + t.Fatalf("advertised forwarding = %v, want %v", hostCfg.Advertised, want) + } + for _, route := range hostCfg.Routes { + if route.Destination == netip.MustParsePrefix("192.168.1.0/24") { + t.Fatalf("advertised prefix %v is installed as a host destination", route.Destination) + } + } +} + func TestTunDNSConfigUsesEffectiveAddresses(t *testing.T) { canonicalSelf := netip.MustParseAddr("100.64.0.10") canonicalPeer := netip.MustParseAddr("100.64.0.20") diff --git a/cmd/tailmixd/supervisor.go b/cmd/tailmixd/supervisor.go index e0749ae7f..c8867ecda 100644 --- a/cmd/tailmixd/supervisor.go +++ b/cmd/tailmixd/supervisor.go @@ -459,6 +459,9 @@ func (s *supervisor) reconcileLocked() (err error) { if err := s.setRoutePreferencesLocked(); err != nil { return err } + if err := s.setAdvertisePreferencesLocked(observedStatuses); err != nil { + return err + } if err := s.setExitNodePreferencesLocked(observedStatuses); err != nil { return err } @@ -790,6 +793,7 @@ func cloneState(st state.State) state.State { cfg := st.Profiles[i].WireGuard.Clone() st.Profiles[i].WireGuard = &cfg } + st.Profiles[i].AdvertiseRoutes = append([]netip.Prefix(nil), st.Profiles[i].AdvertiseRoutes...) } st.Leases = append([]state.EffectiveLease(nil), st.Leases...) st.IPRouteBindings = append([]state.IPRouteBinding(nil), st.IPRouteBindings...) @@ -852,6 +856,7 @@ func (s *supervisor) projectProfileLocked(configured state.Profile) controlapi.P Removed: configured.Removed, AcceptAllRoutes: configured.AcceptAllRoutes, AcceptAllDNSRoutes: configured.AcceptAllDNSRoutes, + AdvertisedRoutes: append([]netip.Prefix(nil), configured.AdvertiseRoutes...), ShieldsUp: configured.Kind == state.ProfileKindWireGuard && configured.WireGuardShieldsUp, LastError: s.lastErrors[configured.ID], } @@ -1192,6 +1197,9 @@ func (s *supervisor) RemoveProfile(_ context.Context, name string, purge bool) ( configured.Removed = true configured.Disabled = true configured.WireGuardShieldsUp = false + // Drop the persisted advertisement so a later re-add starts clean even + // when the engine below cannot be reached for a live withdrawal. + configured.AdvertiseRoutes = nil if s.st.ExitNode != nil && s.st.ExitNode.ProfileID == configured.ID { s.st.ExitNode = nil } @@ -1202,6 +1210,9 @@ func (s *supervisor) RemoveProfile(_ context.Context, name string, purge bool) ( if err := s.reconcileLocked(); err != nil { return controlapi.Profile{}, controlapi.NewError("reconcile_failed", "%v", err) } + // Withdraw live tailnet advertisements before closing the engine so + // stale ads never survive a re-add from the same state directory. + s.withdrawAdvertisedRoutesLocked(configured.ID) if err := s.stopProfileLocked(configured.ID); err != nil { s.lastErrors[configured.ID] = err.Error() } diff --git a/controlapi/client.go b/controlapi/client.go index 860c55a14..93e0eaa35 100644 --- a/controlapi/client.go +++ b/controlapi/client.go @@ -152,6 +152,24 @@ func (c *Client) ClearExitNode(ctx context.Context) (ExitNodes, error) { return out, err } +func (c *Client) AdvertiseRoutes(ctx context.Context) (AdvertisedRoutes, error) { + var out AdvertisedRoutes + err := c.do(ctx, http.MethodGet, "/v1/advertise", nil, &out) + return out, err +} + +func (c *Client) SetAdvertiseRoutes(ctx context.Context, request PatchAdvertiseRoutesRequest) (AdvertisedRoutes, error) { + var out AdvertisedRoutes + err := c.do(ctx, http.MethodPut, "/v1/advertise", request, &out) + return out, err +} + +func (c *Client) ClearAdvertiseRoutes(ctx context.Context, name string) (AdvertisedRoutes, error) { + var out AdvertisedRoutes + err := c.do(ctx, http.MethodDelete, "/v1/advertise/by-name/"+url.PathEscape(name), nil, &out) + return out, err +} + func (c *Client) DNSRoutes(ctx context.Context, available bool) (DNSRoutes, error) { path := "/v1/dns/routes" if available { diff --git a/controlapi/server.go b/controlapi/server.go index b2da26c94..8593630ce 100644 --- a/controlapi/server.go +++ b/controlapi/server.go @@ -36,6 +36,10 @@ type Backend interface { SetExitNode(context.Context, SetExitNodeRequest) (ExitNodes, error) ClearExitNode(context.Context) (ExitNodes, error) + AdvertiseRoutes(context.Context) (AdvertisedRoutes, error) + SetAdvertiseRoutes(context.Context, PatchAdvertiseRoutesRequest) (AdvertisedRoutes, error) + ClearAdvertiseRoutes(context.Context, string) (AdvertisedRoutes, error) + DNSRoutes(context.Context, bool) (DNSRoutes, error) PatchDNSRoutes(context.Context, PatchDNSRoutesRequest) (DNSRoutes, error) ReplaceDNSRoutes(context.Context, ReplaceDNSRoutesRequest) (DNSRoutes, error) @@ -232,6 +236,28 @@ func Handler(backend Backend) http.Handler { writeResult(w, result, err) }) + mux.HandleFunc("GET /v1/advertise", func(w http.ResponseWriter, r *http.Request) { + result, err := backend.AdvertiseRoutes(r.Context()) + writeResult(w, result, err) + }) + mux.HandleFunc("PUT /v1/advertise", func(w http.ResponseWriter, r *http.Request) { + var request PatchAdvertiseRoutesRequest + if !decodeRequest(w, r, &request) { + return + } + result, err := backend.SetAdvertiseRoutes(r.Context(), request) + writeResult(w, result, err) + }) + mux.HandleFunc("DELETE /v1/advertise/by-name/{name}", func(w http.ResponseWriter, r *http.Request) { + name, err := url.PathUnescape(r.PathValue("name")) + if err != nil { + writeResult(w, nil, NewError("invalid_request", "invalid escaped profile name")) + return + } + result, backendErr := backend.ClearAdvertiseRoutes(r.Context(), name) + writeResult(w, result, backendErr) + }) + mux.HandleFunc("GET /v1/dns/routes", func(w http.ResponseWriter, r *http.Request) { result, err := backend.DNSRoutes(r.Context(), false) writeResult(w, result, err) diff --git a/controlapi/server_test.go b/controlapi/server_test.go index b6248e933..035a13268 100644 --- a/controlapi/server_test.go +++ b/controlapi/server_test.go @@ -18,6 +18,9 @@ type recordingBackend struct { daemonUp bool ipPatch PatchIPRoutesRequest exitRequest SetExitNodeRequest + advertisePatch PatchAdvertiseRoutesRequest + advertiseCleared string + advertised AdvertisedRoutes profiles Profiles status Status update UpdateStatus @@ -83,6 +86,20 @@ func (b *recordingBackend) SetExitNode(_ context.Context, request SetExitNodeReq }}, nil } +func (b *recordingBackend) AdvertiseRoutes(context.Context) (AdvertisedRoutes, error) { + return b.advertised, nil +} + +func (b *recordingBackend) SetAdvertiseRoutes(_ context.Context, request PatchAdvertiseRoutesRequest) (AdvertisedRoutes, error) { + b.advertisePatch = request + return b.advertised, nil +} + +func (b *recordingBackend) ClearAdvertiseRoutes(_ context.Context, name string) (AdvertisedRoutes, error) { + b.advertiseCleared = name + return b.advertised, nil +} + func (b *recordingBackend) ApplyWireGuard(_ context.Context, config wireguardcfg.Config, secrets wireguardcfg.Secrets) (WireGuardProfile, error) { b.wireGuardConfig = config b.wireGuardSecrets = secrets @@ -315,6 +332,49 @@ func TestHandlerSetsExitNode(t *testing.T) { } } +func TestHandlerAdvertisesRoutes(t *testing.T) { + backend := &recordingBackend{advertised: AdvertisedRoutes{ + Profiles: []ProfileAdvertisedRoutes{{ + ProfileID: "work-id", ProfileName: "work", + Routes: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")}, + State: "installed", + }}, + }} + request := httptest.NewRequest(http.MethodPut, "/v1/advertise", strings.NewReader(`{ + "profileName":"work", + "add":["0.0.0.0/0","::/0"], + "replace":true + }`)) + response := httptest.NewRecorder() + Handler(backend).ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + if backend.advertisePatch.ProfileName != "work" || !backend.advertisePatch.Replace || + len(backend.advertisePatch.Add) != 2 { + t.Fatalf("request = %+v", backend.advertisePatch) + } + var got AdvertisedRoutes + if err := json.NewDecoder(response.Body).Decode(&got); err != nil { + t.Fatal(err) + } + if len(got.Profiles) != 1 || got.Profiles[0].State != "installed" { + t.Fatalf("response = %+v", got) + } + + response = httptest.NewRecorder() + Handler(backend).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/v1/advertise", nil)) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"profileName":"work"`) { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + + response = httptest.NewRecorder() + Handler(backend).ServeHTTP(response, httptest.NewRequest(http.MethodDelete, "/v1/advertise/by-name/work", nil)) + if response.Code != http.StatusOK || backend.advertiseCleared != "work" { + t.Fatalf("status = %d, cleared = %q, body = %s", response.Code, backend.advertiseCleared, response.Body.String()) + } +} + func TestHandlerRejectsRemovedProfileControlURL(t *testing.T) { backend := &recordingBackend{} request := httptest.NewRequest(http.MethodPost, "/v1/profiles", strings.NewReader(`{ diff --git a/controlapi/types.go b/controlapi/types.go index e1916ee3b..6e01a9c25 100644 --- a/controlapi/types.go +++ b/controlapi/types.go @@ -48,6 +48,7 @@ type Profile struct { Removed bool `json:"removed"` AcceptAllRoutes bool `json:"acceptAllRoutes"` AcceptAllDNSRoutes bool `json:"acceptAllDnsRoutes"` + AdvertisedRoutes []netip.Prefix `json:"advertisedRoutes,omitempty"` RuntimeState string `json:"runtimeState"` BackendState string `json:"backendState,omitempty"` MagicDNSSuffix string `json:"magicDnsSuffix,omitempty"` @@ -245,6 +246,36 @@ type SetExitNodeRequest struct { Peer string `json:"peer"` } +// PatchAdvertiseRoutesRequest changes one profile's advertised subnet routes. +// Add and Remove are applied in order; Replace discards the stored set first +// and then applies Add. A replace with no routes clears the advertisement. +// Unlike the accept-side IPRoutes types, default routes are valid here: they +// advertise exit-node service. +type PatchAdvertiseRoutesRequest struct { + ProfileName string `json:"profileName"` + Add []netip.Prefix `json:"add,omitempty"` + Remove []netip.Prefix `json:"remove,omitempty"` + Replace bool `json:"replace,omitempty"` +} + +// ProfileAdvertisedRoutes is the advertise-side view of one profile. Routes +// are the daemon's desired (persisted) set; AppliedRoutes is the set the +// profile engine last reported. Advertised prefixes are LAN or exit prefixes +// offered to the tailnet, not effective IPs. +type ProfileAdvertisedRoutes struct { + ProfileID string `json:"profileId"` + ProfileName string `json:"profileName"` + Routes []netip.Prefix `json:"routes,omitempty"` + Applied []netip.Prefix `json:"appliedRoutes,omitempty"` + State string `json:"state"` + Reason string `json:"reason,omitempty"` +} + +type AdvertisedRoutes struct { + Profiles []ProfileAdvertisedRoutes `json:"profiles,omitempty"` + ReconcileError string `json:"reconcileError,omitempty"` +} + type DNSResolver struct { Addr string `json:"addr"` BootstrapResolution []netip.Addr `json:"bootstrapResolution,omitempty"` diff --git a/docs/design.md b/docs/design.md index be6a4a127..ae1161f58 100644 --- a/docs/design.md +++ b/docs/design.md @@ -269,7 +269,8 @@ Rules: - Default routes are excluded from subnet bindings and remain governed by the exit-node policy below. - The daemon does not install anti-bridging rules. -- The daemon does not automatically forward packets between tailnets. +- The daemon does not automatically forward packets between tailnets. Forwarding + for advertised prefixes is tailnet-to-LAN only, never between two tailnets. - Host-admin forwarding, NAT, and proxying are ordinary host networking outside v1 semantics. Exit/default route behavior: @@ -319,10 +320,23 @@ and `OperatorUser` permissions are evaluated in the same request path as `tailscaled`. Aggregate lifecycle commands use `tailmix profiles ...`; IP route bindings use -`tailmix routes ...`; and DNS bindings and search policy use +`tailmix routes ...`; subnet-route advertisements use `tailmix advertise ...`; +and DNS bindings and search policy use `tailmix dns routes ...` and `tailmix dns search ...`. All use a separate multi-profile API and must not silently pick one profile. +Advertising is the accept-side mirror: `routes` selects which prefixes other +devices advertise that this host may use, while `advertise` selects which +prefixes this host offers to its tailnets. An advertised prefix is a LAN or +exit prefix served through this host, never an effective IP: effective IPs +are local dial targets for inbound tailnet identities, and they are never +advertised into any tailnet. In tun mode on Linux the daemon owns host +forwarding for advertised prefixes (scoped `FORWARD` accepts, `POSTROUTING` +masquerade, and the forwarding sysctls). The `wgengine` instances behind the +fork have no Router, so the upstream subnet-router NAT never runs; tailmixd +owns that forwarding instead. Advertising requires `tailscaled` to be stopped +so the two daemons do not fight over forwarding state. + Profile-wide imports are explicit live policy: ```text diff --git a/docs/profile-management.md b/docs/profile-management.md index f1d1a3059..20a3f32c3 100644 --- a/docs/profile-management.md +++ b/docs/profile-management.md @@ -105,6 +105,7 @@ tailmix status [--json] tailmix profiles [arguments] tailmix routes [arguments] tailmix exit-node [arguments] +tailmix advertise [arguments] tailmix dns routes [arguments] tailmix dns search [arguments] tailmix {tailscale|ts} [--profile ] [arguments] @@ -316,6 +317,48 @@ desired selection, which resumes when the same profile is enabled. Removing the selected profile clears the selection. There is no automatic failover to an exit node in another profile. +### Advertised subnet routes + +Advertising is the opposite direction from accepting: `routes` controls which +routes *other* devices advertise that this host may use, while `advertise` +controls which routes *this host* offers to its tailnets so other devices can +reach them through this host: + +```text +tailmix advertise list [--profile ] [--json] +tailmix advertise set --profile ... [--json] +tailmix advertise clear --profile [--json] +``` + +`set` replaces the profile's whole advertised set; `clear` empties it. Both +persist immediately and reconcile live. `list` shows the desired set alongside +the set the profile engine last reported, plus a state of `installed`, +`waiting`, `unsupported`, `disabled`, or `off`, mirroring the exit-node +resource. Advertising `0.0.0.0/0` and `::/0` offers exit-node service from the +profile; any other prefix offers that subnet. + +Advertised prefixes are LAN or exit prefixes offered to the tailnet. They are +never effective IPs and never become host destinations: the daemon does not +install host routes for them. Instead, on Linux in tun mode the daemon owns +host forwarding for them — scoped `FORWARD` accepts and `POSTROUTING` +masquerade rules that match only packets arriving from the tailmix TUN, plus +the IPv4/IPv6 forwarding sysctls while the matching family is advertised. +The rules live in dedicated chains and are removed when the advertisement is +cleared, the profile is disabled or removed, or the daemon closes the TUN. +Sysctls are enabled but never disabled again, matching `tailscaled`. + +Route advertising requires tun mode on Linux, and `tailscaled` must be stopped +first: two daemons would fight over the same forwarding rules. The daemon +refuses `advertise set` while a `tailscaled` socket or `tailscale0` interface +is present, and surfaces the same conflict if `tailscaled` appears later. +Removing a profile withdraws its live tailnet advertisements before closing +the engine and drops the persisted set, so a later re-add starts clean. + +Prefixes that overlap tailmix's effective-IP pools, host NAT addresses, or +other daemon-reserved ranges are rejected, except that default routes are +always allowed for exit-node use. Raw WireGuard profiles cannot advertise; +requesting routes for one reports `unsupported`. + ### DNS route bindings A DNS route binding sends queries for a suffix through one selected profile: diff --git a/hosttun/advertise_linux.go b/hosttun/advertise_linux.go new file mode 100644 index 000000000..696afe56e --- /dev/null +++ b/hosttun/advertise_linux.go @@ -0,0 +1,452 @@ +//go:build linux + +package hosttun + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" +) + +// Subnet-route forwarding for advertised prefixes. +// +// A profile engine that advertises routes receives tailnet packets for those +// destinations and injects them into the shared host TUN. The host kernel +// then forwards them to the LAN and back. That forwarding needs three things +// this file owns: +// +// - FORWARD rules accepting tailnet-originated packets for advertised +// prefixes, plus stateful replies toward the TUN. +// - POSTROUTING MASQUERADE so LAN hosts reply to the router instead of +// dropping tailnet sources as martians. Rules match only packets +// arriving from the tailmix TUN, so locally-originated LAN traffic is +// never NATed. +// - IPv4/IPv6 forwarding sysctls while the matching family is advertised. +// +// All rules live in dedicated chains (TAILMIX-FWD, TAILMIX-MASQ) jumped to +// from FORWARD and POSTROUTING, mirroring the dedicated exit-route table +// approach. Every rule carries a "tailmix:" comment (or the +// "tailmix:established" marker) so reconciliation only ever touches rules it +// owns. Sysctls are enabled but never disabled again, matching tailscaled: +// other host software may depend on forwarding once it exists. +const ( + tailmixForwardChain = "TAILMIX-FWD" + tailmixMasqChain = "TAILMIX-MASQ" + + advertiseCommentPrefix = "tailmix:" + establishedComment = "tailmix:established" + + sysctlForwardV4 = "net/ipv4/ip_forward" + sysctlForwardV6 = "net/ipv6/conf/all/forwarding" +) + +// iptablesBin, ip6tablesBin, and sysctlRoot are variables so tests can point +// them at fakes and scratch directories. +var ( + iptablesBin = "iptables" + ip6tablesBin = "ip6tables" + sysctlRoot = "/proc/sys" +) + +type forwardRule struct { + binary string + table string + chain string + spec []string +} + +func (r forwardRule) key() string { + return r.binary + " " + r.table + " " + r.chain + " " + strings.Join(r.spec, " ") +} + +// desiredForwardRules converts advertised prefixes into the exact owned rule +// set. Every rule is scoped to packets arriving from the tailmix TUN. +func desiredForwardRules(tun string, advertised []AdvertisedRoute) []forwardRule { + var rules []forwardRule + var haveV4, haveV6 bool + for _, route := range advertised { + binary := iptablesBin + if route.Prefix.Addr().Is6() { + binary = ip6tablesBin + haveV6 = true + } else { + haveV4 = true + } + comment := advertiseCommentPrefix + route.ProfileID + prefix := route.Prefix.String() + rules = append(rules, + forwardRule{binary: binary, chain: tailmixForwardChain, spec: []string{ + "-i", tun, "-d", prefix, + "-m", "comment", "--comment", comment, + "-j", "ACCEPT", + }}, + forwardRule{binary: binary, table: "nat", chain: tailmixMasqChain, spec: []string{ + "-i", tun, "-d", prefix, + "-m", "comment", "--comment", comment, + "-j", "MASQUERADE", + }}, + ) + } + // Replies to accepted flows travel toward the TUN. Accepting only + // established traffic keeps the rule independent of which LAN address a + // reply comes from, including the default-route (exit-node) case where + // the source range is the whole internet. + if haveV4 { + rules = append(rules, forwardRule{binary: iptablesBin, chain: tailmixForwardChain, spec: []string{ + "-o", tun, "-m", "conntrack", "--ctstate", "ESTABLISHED,RELATED", + "-m", "comment", "--comment", establishedComment, + "-j", "ACCEPT", + }}) + } + if haveV6 { + rules = append(rules, forwardRule{binary: ip6tablesBin, chain: tailmixForwardChain, spec: []string{ + "-o", tun, "-m", "conntrack", "--ctstate", "ESTABLISHED,RELATED", + "-m", "comment", "--comment", establishedComment, + "-j", "ACCEPT", + }}) + } + slices.SortFunc(rules, func(a, b forwardRule) int { return strings.Compare(a.key(), b.key()) }) + return rules +} + +// reconcileForwardingLocked converges firewall rules and forwarding sysctls +// to the desired advertised set. Callers must hold h.mu. +func (h *linuxHost) reconcileForwardingLocked(advertised []AdvertisedRoute) error { + desired := desiredForwardRules(h.name, advertised) + jumps, err := h.forwardJumpsPresent() + if err != nil { + return err + } + if len(desired) == 0 && !jumps { + return nil + } + for _, chain := range []struct{ binary, table, name string }{ + {iptablesBin, "", tailmixForwardChain}, + {ip6tablesBin, "", tailmixForwardChain}, + {iptablesBin, "nat", tailmixMasqChain}, + {ip6tablesBin, "nat", tailmixMasqChain}, + } { + if err := h.ensureForwardChain(chain.binary, chain.table, chain.name); err != nil { + return err + } + } + if err := h.ensureForwardJump(iptablesBin, "", "FORWARD", tailmixForwardChain); err != nil { + return err + } + if err := h.ensureForwardJump(ip6tablesBin, "", "FORWARD", tailmixForwardChain); err != nil { + return err + } + if err := h.ensureForwardJump(iptablesBin, "nat", "POSTROUTING", tailmixMasqChain); err != nil { + return err + } + if err := h.ensureForwardJump(ip6tablesBin, "nat", "POSTROUTING", tailmixMasqChain); err != nil { + return err + } + wantByKey := make(map[string]forwardRule, len(desired)) + for _, rule := range desired { + wantByKey[rule.key()] = rule + } + seen := make(map[string]bool, len(desired)) + for _, chain := range []struct{ binary, table, name string }{ + {iptablesBin, "", tailmixForwardChain}, + {ip6tablesBin, "", tailmixForwardChain}, + {iptablesBin, "nat", tailmixMasqChain}, + {ip6tablesBin, "nat", tailmixMasqChain}, + } { + owned, err := h.listOwnedForwardRules(chain.binary, chain.table, chain.name) + if err != nil { + return err + } + for _, rule := range owned { + if _, ok := wantByKey[rule.key()]; ok { + seen[rule.key()] = true + continue + } + if err := h.deleteForwardRule(rule); err != nil { + return err + } + } + } + for _, rule := range desired { + if seen[rule.key()] { + continue + } + if err := h.ensureForwardRule(rule); err != nil { + return err + } + } + return h.ensureForwardingSysctls(advertised) +} + +// teardownForwardingLocked removes every owned forwarding rule, jump, and +// chain. Missing pieces are ignored so teardown converges after partial +// setup. Forwarding sysctls are intentionally left enabled. Callers must hold +// h.mu. +func (h *linuxHost) teardownForwardingLocked() error { + var errs []error + for _, chain := range []struct{ binary, table, name string }{ + {iptablesBin, "", tailmixForwardChain}, + {ip6tablesBin, "", tailmixForwardChain}, + {iptablesBin, "nat", tailmixMasqChain}, + {ip6tablesBin, "nat", tailmixMasqChain}, + } { + if owned, err := h.listOwnedForwardRules(chain.binary, chain.table, chain.name); err != nil { + errs = append(errs, err) + } else { + for _, rule := range owned { + if err := h.deleteForwardRule(rule); err != nil { + errs = append(errs, err) + } + } + } + } + for _, jump := range []struct{ binary, table, chain, target string }{ + {iptablesBin, "", "FORWARD", tailmixForwardChain}, + {ip6tablesBin, "", "FORWARD", tailmixForwardChain}, + {iptablesBin, "nat", "POSTROUTING", tailmixMasqChain}, + {ip6tablesBin, "nat", "POSTROUTING", tailmixMasqChain}, + } { + if err := h.deleteForwardJump(jump.binary, jump.table, jump.chain, jump.target); err != nil { + errs = append(errs, err) + } + } + for _, chain := range []struct{ binary, table, name string }{ + {iptablesBin, "", tailmixForwardChain}, + {ip6tablesBin, "", tailmixForwardChain}, + {iptablesBin, "nat", tailmixMasqChain}, + {ip6tablesBin, "nat", tailmixMasqChain}, + } { + if err := h.deleteForwardChain(chain.binary, chain.table, chain.name); err != nil { + errs = append(errs, err) + } + } + return joinForwardErrs(errs) +} + +func joinForwardErrs(errs []error) error { + var out []error + for _, err := range errs { + if err != nil { + out = append(out, err) + } + } + if len(out) == 0 { + return nil + } + return fmt.Errorf("remove subnet-route forwarding: %w", joinErrs(out)) +} + +func joinErrs(errs []error) error { + if len(errs) == 1 { + return errs[0] + } + msg := errs[0].Error() + for _, err := range errs[1:] { + msg += "; " + err.Error() + } + return errors.New(msg) +} + +func (h *linuxHost) forwardJumpsPresent() (bool, error) { + for _, jump := range []struct{ binary, table, chain, target string }{ + {iptablesBin, "", "FORWARD", tailmixForwardChain}, + {ip6tablesBin, "", "FORWARD", tailmixForwardChain}, + {iptablesBin, "nat", "POSTROUTING", tailmixMasqChain}, + {ip6tablesBin, "nat", "POSTROUTING", tailmixMasqChain}, + } { + args := forwardArgs(jump.table, "-C", jump.chain, "-j", jump.target) + if _, err := h.runCmd(jump.binary, args...); err == nil { + return true, nil + } + } + return false, nil +} + +func (h *linuxHost) ensureForwardChain(binary, table, chain string) error { + if _, err := h.runCmd(binary, forwardArgs(table, "-S", chain)...); err == nil { + return nil + } else if !isForwardMissingChain(err) { + return fmt.Errorf("inspect firewall chain %s: %w", chain, err) + } + if out, err := h.runCmd(binary, forwardArgs(table, "-N", chain)...); err != nil { + if isForwardExists(out) { + return nil + } + return fmt.Errorf("create firewall chain %s: %w", chain, err) + } + return nil +} + +func (h *linuxHost) ensureForwardJump(binary, table, chain, target string) error { + if _, err := h.runCmd(binary, forwardArgs(table, "-C", chain, "-j", target)...); err == nil { + return nil + } + if out, err := h.runCmd(binary, forwardArgs(table, "-I", chain, "1", "-j", target)...); err != nil { + if isForwardExists(out) { + return nil + } + return fmt.Errorf("add firewall jump %s to %s: %w", target, chain, err) + } + return nil +} + +func (h *linuxHost) ensureForwardRule(rule forwardRule) error { + if _, err := h.runCmd(rule.binary, forwardArgs(rule.table, append([]string{"-C", rule.chain}, rule.spec...)...)...); err == nil { + return nil + } + args := forwardArgs(rule.table, append([]string{"-A", rule.chain}, rule.spec...)...) + if out, err := h.runCmd(rule.binary, args...); err != nil { + if isForwardExists(out) { + return nil + } + return fmt.Errorf("add firewall forwarding rule in %s: %w", rule.chain, err) + } + return nil +} + +func (h *linuxHost) deleteForwardRule(rule forwardRule) error { + args := forwardArgs(rule.table, append([]string{"-D", rule.chain}, rule.spec...)...) + if out, err := h.runCmd(rule.binary, args...); err != nil { + if isForwardMissingRule(out) { + return nil + } + return fmt.Errorf("remove firewall forwarding rule in %s: %w", rule.chain, err) + } + return nil +} + +func (h *linuxHost) deleteForwardJump(binary, table, chain, target string) error { + if out, err := h.runCmd(binary, forwardArgs(table, "-D", chain, "-j", target)...); err != nil { + if isForwardMissingRule(out) { + return nil + } + return fmt.Errorf("remove firewall jump %s from %s: %w", target, chain, err) + } + return nil +} + +func (h *linuxHost) deleteForwardChain(binary, table, chain string) error { + if _, err := h.runCmd(binary, forwardArgs(table, "-F", chain)...); err != nil { + if !isForwardMissingChain(err) { + return fmt.Errorf("flush firewall chain %s: %w", chain, err) + } + return nil + } + if out, err := h.runCmd(binary, forwardArgs(table, "-X", chain)...); err != nil { + if isForwardMissingChain(err) || isForwardMissingRule(out) { + return nil + } + return fmt.Errorf("delete firewall chain %s: %w", chain, err) + } + return nil +} + +// listOwnedForwardRules returns the rules in chain that carry a tailmix +// ownership comment. Quoting differences in list output are normalized away +// by stripping double quotes before comparing. +func (h *linuxHost) listOwnedForwardRules(binary, table, chain string) ([]forwardRule, error) { + out, err := h.runCmd(binary, forwardArgs(table, "-S", chain)...) + if err != nil { + if isForwardMissingChain(err) { + return nil, nil + } + return nil, fmt.Errorf("list firewall chain %s: %w", chain, err) + } + var owned []forwardRule + prefix := "-A " + chain + " " + for _, line := range strings.Split(string(out), "\n") { + line = strings.ReplaceAll(strings.TrimSpace(line), "\"", "") + if !strings.HasPrefix(line, prefix) { + continue + } + if !strings.Contains(line, "--comment "+advertiseCommentPrefix) { + continue + } + owned = append(owned, forwardRule{ + binary: binary, + table: table, + chain: chain, + spec: strings.Fields(strings.TrimPrefix(line, prefix)), + }) + } + return owned, nil +} + +func forwardArgs(table string, args ...string) []string { + if table == "" { + return args + } + return append([]string{"-t", table}, args...) +} + +func isForwardMissingChain(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "No chain/target/match by that name") +} + +func isForwardMissingRule(out []byte) bool { + s := string(out) + return strings.Contains(s, "Bad rule") || + strings.Contains(s, "does not exist") || + strings.Contains(s, "No chain/target/match by that name") +} + +func isForwardExists(out []byte) bool { + return strings.Contains(string(out), "already exists") +} + +// ensureForwardingSysctls enables packet forwarding for each address family +// with advertised prefixes, then verifies the write landed. Families without +// advertisements are left untouched. +func (h *linuxHost) ensureForwardingSysctls(advertised []AdvertisedRoute) error { + var needV4, needV6 bool + for _, route := range advertised { + if route.Prefix.Addr().Is6() { + needV6 = true + } else { + needV4 = true + } + } + if needV4 { + if err := ensureSysctlForwarding(sysctlForwardV4, len(advertised)); err != nil { + return err + } + } + if needV6 { + if err := ensureSysctlForwarding(sysctlForwardV6, len(advertised)); err != nil { + return err + } + } + return nil +} + +func ensureSysctlForwarding(name string, count int) error { + path := filepath.Join(sysctlRoot, filepath.FromSlash(name)) + raw, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read forwarding sysctl %s for %d advertised route(s): %w", name, count, err) + } + if strings.TrimSpace(string(raw)) == "1" { + return nil + } + if err := os.WriteFile(path, []byte("1\n"), 0644); err != nil { + return fmt.Errorf("enable forwarding sysctl %s for %d advertised route(s): %w", name, count, err) + } + raw, err = os.ReadFile(path) + if err != nil { + return fmt.Errorf("verify forwarding sysctl %s for %d advertised route(s): %w", name, count, err) + } + if strings.TrimSpace(string(raw)) != "1" { + family := "IPv4" + if strings.Contains(name, "ipv6") { + family = "IPv6" + } + return fmt.Errorf("%s forwarding is disabled; packet forwarding for advertised routes requires it", family) + } + return nil +} diff --git a/hosttun/advertise_linux_test.go b/hosttun/advertise_linux_test.go new file mode 100644 index 000000000..b03976710 --- /dev/null +++ b/hosttun/advertise_linux_test.go @@ -0,0 +1,320 @@ +//go:build linux + +package hosttun + +import ( + "errors" + "fmt" + "net/netip" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +// fakeFirewall models just enough iptables/ip6tables behavior to exercise the +// forwarding reconciler: chains, rules, jumps, and list output. +type fakeFirewall struct { + chains map[string]bool + rules map[string][]string +} + +func newFakeFirewall() *fakeFirewall { + fw := &fakeFirewall{chains: map[string]bool{}, rules: map[string][]string{}} + // Built-in chains always exist. + for _, binary := range []string{"iptables", "ip6tables"} { + for _, table := range []string{"", "nat"} { + for _, chain := range []string{"FORWARD", "POSTROUTING"} { + fw.chains[fw.chainKey(binary, table, chain)] = true + } + } + } + return fw +} + +func (f *fakeFirewall) chainKey(binary, table, chain string) string { + return binary + "\x00" + table + "\x00" + chain +} + +func (f *fakeFirewall) run(name string, args ...string) ([]byte, error) { + table := "" + rest := args + if len(rest) >= 2 && rest[0] == "-t" { + table = rest[1] + rest = rest[2:] + } + if len(rest) < 2 { + return nil, fmt.Errorf("fake firewall: short command %v", args) + } + op, chain := rest[0], rest[1] + spec := rest[2:] + key := f.chainKey(name, table, chain) + switch op { + case "-S": + if !f.chains[key] { + return nil, errors.New("iptables: No chain/target/match by that name") + } + var out strings.Builder + for _, rule := range f.rules[key] { + out.WriteString("-A " + chain + " " + rule + "\n") + } + return []byte(out.String()), nil + case "-N": + if f.chains[key] { + return []byte("iptables: Chain already exists."), errors.New("exit status 1") + } + f.chains[key] = true + return nil, nil + case "-F": + if !f.chains[key] { + return nil, errors.New("iptables: No chain/target/match by that name") + } + f.rules[key] = nil + return nil, nil + case "-X": + if !f.chains[key] { + return nil, errors.New("iptables: No chain/target/match by that name") + } + delete(f.chains, key) + delete(f.rules, key) + return nil, nil + case "-C": + for _, rule := range f.rules[key] { + if rule == strings.Join(spec, " ") { + return nil, nil + } + } + return nil, errors.New("exit status 1") + case "-A", "-I": + if !f.chains[key] { + return nil, errors.New("iptables: No chain/target/match by that name") + } + line := strings.Join(spec, " ") + if op == "-I" { + // Position arguments are insertion metadata, not rule + // identity: a later -C/-D omits them. + if fields := strings.Fields(line); len(fields) > 0 && fields[0] == "1" { + line = strings.Join(fields[1:], " ") + } + } + for _, rule := range f.rules[key] { + if rule == line { + return nil, nil + } + } + if op == "-I" { + f.rules[key] = append([]string{line}, f.rules[key]...) + } else { + f.rules[key] = append(f.rules[key], line) + } + return nil, nil + case "-D": + line := strings.Join(spec, " ") + for i, rule := range f.rules[key] { + if rule == line { + f.rules[key] = append(f.rules[key][:i], f.rules[key][i+1:]...) + return nil, nil + } + } + return []byte("iptables: Bad rule (does a matching rule exist in that chain?)"), errors.New("exit status 1") + default: + return nil, fmt.Errorf("fake firewall: unsupported op %q", op) + } +} + +func (f *fakeFirewall) has(binary, table, chain, substr string) bool { + for _, rule := range f.rules[f.chainKey(binary, table, chain)] { + if strings.Contains(rule, substr) { + return true + } + } + return false +} + +func (f *fakeFirewall) count(binary, table, chain string) int { + return len(f.rules[f.chainKey(binary, table, chain)]) +} + +func testForwardSysctlRoot(t *testing.T) string { + t.Helper() + old := sysctlRoot + root := t.TempDir() + sysctlRoot = root + t.Cleanup(func() { sysctlRoot = old }) + for _, family := range []string{"net/ipv4/ip_forward", "net/ipv6/conf/all/forwarding"} { + path := filepath.Join(root, filepath.FromSlash(family)) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("0\n"), 0644); err != nil { + t.Fatal(err) + } + } + return root +} + +func readSysctl(t *testing.T, root, name string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(name))) + if err != nil { + t.Fatal(err) + } + return strings.TrimSpace(string(raw)) +} + +func TestNormalizeAdvertisedRoutes(t *testing.T) { + routes, err := normalizeAdvertised([]AdvertisedRoute{ + {ProfileID: "work", Prefix: netip.MustParsePrefix("192.168.1.5/24")}, + {ProfileID: "work", Prefix: netip.MustParsePrefix("192.168.1.0/24")}, + {ProfileID: "lab", Prefix: netip.MustParsePrefix("0.0.0.0/0")}, + }) + if err != nil { + t.Fatal(err) + } + want := []AdvertisedRoute{ + {ProfileID: "lab", Prefix: netip.MustParsePrefix("0.0.0.0/0")}, + {ProfileID: "work", Prefix: netip.MustParsePrefix("192.168.1.0/24")}, + } + if !slices.Equal(routes, want) { + t.Fatalf("normalized = %v, want %v", routes, want) + } + if _, err := normalizeAdvertised([]AdvertisedRoute{{Prefix: netip.MustParsePrefix("10.0.0.0/8")}}); err == nil { + t.Fatal("profile-less advertisement was accepted") + } + if _, err := normalizeAdvertised([]AdvertisedRoute{{ProfileID: "work"}}); err == nil { + t.Fatal("invalid prefix advertisement was accepted") + } +} + +func TestDesiredForwardRulesAreScopedToTUN(t *testing.T) { + rules := desiredForwardRules("tailmix0", []AdvertisedRoute{ + {ProfileID: "work", Prefix: netip.MustParsePrefix("192.168.1.0/24")}, + {ProfileID: "work", Prefix: netip.MustParsePrefix("::/0")}, + }) + if len(rules) != 6 { + t.Fatalf("rules = %v, want 6 (accept+masq per prefix, established per family)", rules) + } + for _, rule := range rules { + if !slices.Contains(rule.spec, "tailmix0") { + t.Fatalf("rule %v is not scoped to the tailmix TUN", rule.spec) + } + if !slices.Contains(rule.spec, "tailmix:work") && !slices.Contains(rule.spec, "tailmix:established") { + t.Fatalf("rule %v carries no ownership comment", rule.spec) + } + } + var masqV6 bool + for _, rule := range rules { + if rule.table == "nat" && rule.binary == ip6tablesBin { + masqV6 = true + } + } + if !masqV6 { + t.Fatal("no IPv6 masquerade rule for the advertised default route") + } + if len(desiredForwardRules("tailmix0", nil)) != 0 { + t.Fatal("empty advertisements produced forwarding rules") + } +} + +func TestForwardingConvergesAndTearsDown(t *testing.T) { + fw := newFakeFirewall() + h := testLinuxHost(linuxInstalledState{}, linuxInstalledState{}, linuxInstalledState{}) + h.runCmd = fw.run + sysctlDir := testForwardSysctlRoot(t) + + advertised := []AdvertisedRoute{ + {ProfileID: "work", Prefix: netip.MustParsePrefix("192.168.1.0/24")}, + {ProfileID: "work", Prefix: netip.MustParsePrefix("0.0.0.0/0")}, + } + if err := h.Configure(Config{Advertised: advertised}); err != nil { + t.Fatal(err) + } + if !fw.has("iptables", "", "FORWARD", "-j "+tailmixForwardChain) { + t.Fatal("FORWARD jump to the tailmix chain is missing") + } + if !fw.has("iptables", "nat", "POSTROUTING", "-j "+tailmixMasqChain) { + t.Fatal("POSTROUTING jump to the tailmix chain is missing") + } + if !fw.has("iptables", "", tailmixForwardChain, "-d 192.168.1.0/24") { + t.Fatal("FORWARD accept for the advertised subnet is missing") + } + if !fw.has("iptables", "nat", tailmixMasqChain, "-d 192.168.1.0/24") { + t.Fatal("masquerade for the advertised subnet is missing") + } + if !fw.has("iptables", "nat", tailmixMasqChain, "-d 0.0.0.0/0") { + t.Fatal("masquerade for the advertised exit default is missing") + } + if got := readSysctl(t, sysctlDir, "net/ipv4/ip_forward"); got != "1" { + t.Fatalf("IPv4 forwarding = %q, want 1", got) + } + if got := readSysctl(t, sysctlDir, "net/ipv6/conf/all/forwarding"); got != "0" { + t.Fatalf("IPv6 forwarding = %q, want untouched 0", got) + } + + // Reconciling to empty removes every owned rule but leaves the host + // forwarding sysctl enabled. The third state serves Close's cleanup read. + h2 := testLinuxHost(linuxInstalledState{}, linuxInstalledState{}, linuxInstalledState{}) + h2.runCmd = fw.run + if err := h2.Configure(Config{}); err != nil { + t.Fatal(err) + } + if fw.count("iptables", "", tailmixForwardChain) != 0 || fw.count("iptables", "nat", tailmixMasqChain) != 0 { + t.Fatal("owned forwarding rules survived the empty reconcile") + } + if got := readSysctl(t, sysctlDir, "net/ipv4/ip_forward"); got != "1" { + t.Fatalf("IPv4 forwarding = %q, want permanently enabled 1", got) + } + + if err := h2.Close(); err != nil { + t.Fatal(err) + } + for _, chain := range [][3]string{ + {"iptables", "", "FORWARD"}, + {"iptables", "nat", "POSTROUTING"}, + } { + if fw.has(chain[0], chain[1], chain[2], "-j TAILMIX-") { + t.Fatalf("jump to a tailmix chain survived close in %v", chain) + } + } + if fw.chains[fw.chainKey("iptables", "", tailmixForwardChain)] || + fw.chains[fw.chainKey("iptables", "nat", tailmixMasqChain)] { + t.Fatal("tailmix chains survived close") + } +} + +func TestForwardingSkipsWorkWithoutAdvertisements(t *testing.T) { + fw := newFakeFirewall() + calls := 0 + h := testLinuxHost(linuxInstalledState{}, linuxInstalledState{}) + h.runCmd = func(name string, args ...string) ([]byte, error) { + calls++ + return fw.run(name, args...) + } + if err := h.Configure(Config{}); err != nil { + t.Fatal(err) + } + if calls != 4 { + t.Fatalf("firewall calls = %d, want 4 jump probes and nothing else", calls) + } + for key := range fw.chains { + if strings.Contains(key, "TAILMIX-") { + t.Fatalf("tailmix chain %q created without advertisements", key) + } + } +} + +func TestForwardingSysctlFailureFailsConfigure(t *testing.T) { + fw := newFakeFirewall() + h := testLinuxHost(linuxInstalledState{}) + h.runCmd = fw.run + old := sysctlRoot + sysctlRoot = t.TempDir() + t.Cleanup(func() { sysctlRoot = old }) + err := h.Configure(Config{Advertised: []AdvertisedRoute{ + {ProfileID: "work", Prefix: netip.MustParsePrefix("192.168.1.0/24")}, + }}) + if err == nil || !strings.Contains(err.Error(), "forwarding") { + t.Fatalf("Configure error = %v, want a forwarding sysctl error", err) + } +} diff --git a/hosttun/hosttun.go b/hosttun/hosttun.go index b06d5faa0..8cd727681 100644 --- a/hosttun/hosttun.go +++ b/hosttun/hosttun.go @@ -20,6 +20,18 @@ type Route struct { type Config struct { LocalAddrs []netip.Prefix Routes []Route + // Advertised lists subnet prefixes this host forwards on behalf of the + // tailnet. They are not installed as host destinations; the Linux + // forwarding plane accepts and masquerades tailnet-originated packets + // for them. Empty on platforms without forwarding support. + Advertised []AdvertisedRoute +} + +// AdvertisedRoute is one profile's offer to route a subnet prefix for the +// tailnet. ProfileID scopes ownership of the installed forwarding rules. +type AdvertisedRoute struct { + ProfileID string + Prefix netip.Prefix } type Host interface { @@ -81,6 +93,35 @@ func normalizeConfig(cfg Config) ([]netip.Prefix, []Route, error) { return localAddrs, routes, nil } +// normalizeAdvertised canonicalizes desired advertised routes: every entry +// needs an owning profile and a valid prefix, duplicates collapse, and the +// result sorts deterministically by profile then prefix. +func normalizeAdvertised(routes []AdvertisedRoute) ([]AdvertisedRoute, error) { + seen := make(map[AdvertisedRoute]bool, len(routes)) + out := make([]AdvertisedRoute, 0, len(routes)) + for _, route := range routes { + if route.ProfileID == "" { + return nil, fmt.Errorf("advertised route %v has no owning profile", route.Prefix) + } + if !route.Prefix.IsValid() { + return nil, fmt.Errorf("advertised route for profile %q is not a valid prefix", route.ProfileID) + } + route.Prefix = route.Prefix.Masked() + if seen[route] { + continue + } + seen[route] = true + out = append(out, route) + } + sort.Slice(out, func(i, j int) bool { + if out[i].ProfileID != out[j].ProfileID { + return out[i].ProfileID < out[j].ProfileID + } + return out[i].Prefix.String() < out[j].Prefix.String() + }) + return out, nil +} + func verifyHostConfig(wantAddrs []netip.Prefix, wantRoutes []Route, gotAddrs []netip.Prefix, gotRoutes []Route) error { var errs []error wantAddrSet := make(map[netip.Prefix]bool, len(wantAddrs)) diff --git a/hosttun/hosttun_darwin.go b/hosttun/hosttun_darwin.go index a57175d0e..b7653c363 100644 --- a/hosttun/hosttun_darwin.go +++ b/hosttun/hosttun_darwin.go @@ -91,6 +91,9 @@ func (h *darwinHost) Configure(cfg Config) error { if err != nil { return err } + if len(cfg.Advertised) != 0 { + return errors.New("route advertisement requires Linux tun mode") + } h.mu.Lock() defer h.mu.Unlock() if h.closed { diff --git a/hosttun/hosttun_linux.go b/hosttun/hosttun_linux.go index fb329944b..cbf372c45 100644 --- a/hosttun/hosttun_linux.go +++ b/hosttun/hosttun_linux.go @@ -8,6 +8,7 @@ import ( "net" "net/netip" "os" + "os/exec" "slices" "sync" @@ -56,6 +57,7 @@ type linuxHost struct { link netlink.Link logf logger.Logf ops linuxNetlinkOps + runCmd func(name string, args ...string) ([]byte, error) readState func([]Route) (linuxInstalledState, error) mu sync.Mutex localAddrs []netip.Prefix @@ -99,6 +101,9 @@ func Open(cfg OpenConfig) (Host, error) { ruleAdd: netlink.RuleAdd, ruleDel: netlink.RuleDel, }, + runCmd: func(name string, args ...string) ([]byte, error) { + return exec.Command(name, args...).CombinedOutput() + }, } h.readState = h.readInstalledState return h, nil @@ -112,6 +117,10 @@ func (h *linuxHost) Configure(cfg Config) error { if err != nil { return err } + advertised, err := normalizeAdvertised(cfg.Advertised) + if err != nil { + return err + } h.mu.Lock() defer h.mu.Unlock() if h.closed { @@ -156,6 +165,9 @@ func (h *linuxHost) Configure(cfg Config) error { if err := h.addMissingRules(installed.rules, routes); err != nil { return err } + if err := h.reconcileForwardingLocked(advertised); err != nil { + return err + } wantAddrByIP := make(map[netip.Addr]bool, len(localAddrs)) for _, addr := range localAddrs { @@ -546,6 +558,9 @@ func (h *linuxHost) Close() error { h.closeOnce.Do(func() { h.mu.Lock() var errs []error + if err := h.teardownForwardingLocked(); err != nil { + errs = append(errs, err) + } state, err := h.installedState(h.routes) if err != nil { errs = append(errs, fmt.Errorf("read Linux TUN %s before close: %w", h.name, err)) diff --git a/hosttun/hosttun_linux_test.go b/hosttun/hosttun_linux_test.go index bb4ee89f9..123fbb300 100644 --- a/hosttun/hosttun_linux_test.go +++ b/hosttun/hosttun_linux_test.go @@ -35,6 +35,9 @@ func testLinuxHost(states ...linuxInstalledState) *linuxHost { }}, logf: func(string, ...any) {}, ops: noOpLinuxOps(), + runCmd: func(name string, args ...string) ([]byte, error) { + return nil, fmt.Errorf("firewall runner not configured in test") + }, } h.readState = func([]Route) (linuxInstalledState, error) { if stateIndex >= len(states) { diff --git a/profile/profile.go b/profile/profile.go index bb0e6e484..6798d8392 100644 --- a/profile/profile.go +++ b/profile/profile.go @@ -28,26 +28,27 @@ type Update struct { } type Status struct { - ProfileID string - Alias string - Kind string - MagicDNSSuffix string - BackendState string - AuthURL string - SelfNodeID string - SelfDNSName string - SelfIPs []netip.Addr - Peers []PeerStatus - Services []ServiceStatus - PeerCount int - ShieldsUp bool - AvailableRoutes []RouteStatus - DNSRoutes []DNSRouteStatus - SearchDomains []string - RouteAll bool - ExitNodeID string - PublicKey string - ListenPort uint16 + ProfileID string + Alias string + Kind string + MagicDNSSuffix string + BackendState string + AuthURL string + SelfNodeID string + SelfDNSName string + SelfIPs []netip.Addr + Peers []PeerStatus + Services []ServiceStatus + PeerCount int + ShieldsUp bool + AvailableRoutes []RouteStatus + DNSRoutes []DNSRouteStatus + SearchDomains []string + RouteAll bool + ExitNodeID string + AdvertisedRoutes []netip.Prefix + PublicKey string + ListenPort uint16 } type PeerStatus struct { @@ -99,3 +100,7 @@ type RoutePreferenceController interface { type ExitNodePreferenceController interface { SetExitNodeIP(context.Context, netip.Addr) error } + +type AdvertiseRoutesController interface { + SetAdvertiseRoutes(context.Context, []netip.Prefix) error +} diff --git a/profile/tsnetengine.go b/profile/tsnetengine.go index b37b71f04..5ae78b677 100644 --- a/profile/tsnetengine.go +++ b/profile/tsnetengine.go @@ -251,10 +251,12 @@ func (e *TSNetEngine) Status(ctx context.Context) (Status, error) { shieldsUp := false routeAll := false exitNodeID := "" + var advertisedRoutes []netip.Prefix if prefs, err := lc.GetPrefs(ctx); err == nil { shieldsUp = prefs.ShieldsUp routeAll = prefs.RouteAll exitNodeID = string(prefs.ExitNodeID) + advertisedRoutes = append([]netip.Prefix(nil), prefs.AdvertiseRoutes...) } var dnsRoutes []DNSRouteStatus var searchDomains []string @@ -306,24 +308,25 @@ func (e *TSNetEngine) Status(ctx context.Context) (Status, error) { }) sort.Strings(searchDomains) return Status{ - ProfileID: e.cfg.ProfileID, - Alias: e.cfg.Alias, - Kind: "tailscale", - MagicDNSSuffix: suffix, - BackendState: st.BackendState, - AuthURL: st.AuthURL, - SelfNodeID: selfNode, - SelfDNSName: selfDNSName, - SelfIPs: ips, - Peers: peers, - Services: services, - PeerCount: len(peers), - ShieldsUp: shieldsUp, - AvailableRoutes: availableRoutes, - DNSRoutes: dnsRoutes, - SearchDomains: searchDomains, - RouteAll: routeAll, - ExitNodeID: exitNodeID, + ProfileID: e.cfg.ProfileID, + Alias: e.cfg.Alias, + Kind: "tailscale", + MagicDNSSuffix: suffix, + BackendState: st.BackendState, + AuthURL: st.AuthURL, + SelfNodeID: selfNode, + SelfDNSName: selfDNSName, + SelfIPs: ips, + Peers: peers, + Services: services, + PeerCount: len(peers), + ShieldsUp: shieldsUp, + AvailableRoutes: availableRoutes, + DNSRoutes: dnsRoutes, + SearchDomains: searchDomains, + RouteAll: routeAll, + ExitNodeID: exitNodeID, + AdvertisedRoutes: advertisedRoutes, }, nil } @@ -408,6 +411,32 @@ func exitNodePrefs(peerIP netip.Addr) *ipn.MaskedPrefs { } } +func (e *TSNetEngine) SetAdvertiseRoutes(ctx context.Context, routes []netip.Prefix) error { + if e.server == nil { + return fmt.Errorf("tsnet server is not started") + } + lc, err := e.server.LocalClient() + if err != nil { + return err + } + // Pass the desired set through as-is: unlike the accept-side policy, + // default routes are valid here for exit-node advertisements, and the + // daemon (not the engine) owns host forwarding. AdvertiseRoutesSet + // replaces the whole set, so clearing is an explicit empty set rather + // than an unchanged mask. + _, err = lc.EditPrefs(ctx, advertiseRoutesPrefs(routes)) + return err +} + +func advertiseRoutesPrefs(routes []netip.Prefix) *ipn.MaskedPrefs { + return &ipn.MaskedPrefs{ + Prefs: ipn.Prefs{ + AdvertiseRoutes: append([]netip.Prefix(nil), routes...), + }, + AdvertiseRoutesSet: true, + } +} + func normalizeDNSRoute(domain string) string { domain = strings.ToLower(strings.TrimSpace(domain)) if domain == "" { diff --git a/profile/tsnetengine_test.go b/profile/tsnetengine_test.go index bd4d2ee18..b5b998aee 100644 --- a/profile/tsnetengine_test.go +++ b/profile/tsnetengine_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/netip" + "slices" "testing" "time" @@ -72,6 +73,42 @@ func TestExitNodePrefsUsesBackendResolutionPath(t *testing.T) { } } +func TestAdvertiseRoutesPrefsRoundTrip(t *testing.T) { + for _, test := range []struct { + name string + routes []netip.Prefix + }{ + {name: "subnet", routes: []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")}}, + {name: "exit", routes: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")}}, + {name: "clear"}, + } { + t.Run(test.name, func(t *testing.T) { + mp := advertiseRoutesPrefs(test.routes) + if !slices.Equal(mp.AdvertiseRoutes, test.routes) { + t.Fatalf("advertised routes = %v, want %v", mp.AdvertiseRoutes, test.routes) + } + if !mp.AdvertiseRoutesSet { + t.Fatalf("advertise mask is not set: %+v", mp) + } + if mp.RouteAllSet || mp.ExitNodeIDSet || mp.ExitNodeIPSet || mp.AutoExitNodeSet { + t.Fatalf("advertise selection changes unrelated preferences: %+v", mp) + } + prefs := ipn.Prefs{ + RouteAll: true, + ExitNodeID: "stable-node", + AdvertiseRoutes: []netip.Prefix{netip.MustParsePrefix("10.99.0.0/16")}, + } + prefs.ApplyEdits(mp) + if !slices.Equal(prefs.AdvertiseRoutes, test.routes) { + t.Fatalf("applied advertised routes = %v, want %v", prefs.AdvertiseRoutes, test.routes) + } + if !prefs.RouteAll || prefs.ExitNodeID != "stable-node" { + t.Fatalf("advertise selection disturbed route acceptance or exit node: %+v", prefs) + } + }) + } +} + func TestRouteAdvertiserNamePrefersReadableNodeIdentity(t *testing.T) { tests := []struct { name string diff --git a/state/jsonstore_test.go b/state/jsonstore_test.go index 9490af741..b7b0ef04e 100644 --- a/state/jsonstore_test.go +++ b/state/jsonstore_test.go @@ -4,6 +4,7 @@ import ( "net/netip" "os" "path/filepath" + "slices" "strings" "testing" @@ -151,6 +152,24 @@ func TestNormalizeMigratesProfileNameAndDNSPolicy(t *testing.T) { } } +func TestNormalizeCanonicalizesAdvertisedRoutes(t *testing.T) { + st := State{ + Profiles: []Profile{{ID: "work", AdvertiseRoutes: []netip.Prefix{ + netip.MustParsePrefix("192.168.1.99/24"), + netip.MustParsePrefix("192.168.1.0/24"), + netip.MustParsePrefix("0.0.0.0/0"), + }}}, + } + Normalize(&st) + want := []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/0"), + netip.MustParsePrefix("192.168.1.0/24"), + } + if !slices.Equal(st.Profiles[0].AdvertiseRoutes, want) { + t.Fatalf("advertised routes = %v, want %v", st.Profiles[0].AdvertiseRoutes, want) + } +} + func TestStoreRoundTripWireGuardExcludesSecrets(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") store := NewJSONStore(path) diff --git a/state/normalize.go b/state/normalize.go index a6ad64118..d28c535d8 100644 --- a/state/normalize.go +++ b/state/normalize.go @@ -26,6 +26,7 @@ func Normalize(st *State) { cfg := st.Profiles[i].WireGuard.Clone() st.Profiles[i].WireGuard = &cfg } + st.Profiles[i].AdvertiseRoutes = normalizeAdvertisedRoutes(st.Profiles[i].AdvertiseRoutes) } sort.SliceStable(st.Profiles, func(i, j int) bool { return st.Profiles[i].ID < st.Profiles[j].ID @@ -82,6 +83,29 @@ func Normalize(st *State) { } } +// normalizeAdvertisedRoutes canonicalizes persisted per-profile advertised +// prefixes: invalid entries are dropped, the rest are masked, deduplicated, +// and sorted. Default routes are preserved here; whether a default route is +// a valid advertisement is decided by the advertise policy validator, not by +// storage normalization. +func normalizeAdvertisedRoutes(routes []netip.Prefix) []netip.Prefix { + seen := make(map[netip.Prefix]bool, len(routes)) + out := routes[:0] + for _, route := range routes { + if !route.IsValid() { + continue + } + route = route.Masked() + if seen[route] { + continue + } + seen[route] = true + out = append(out, route) + } + sort.Slice(out, func(i, j int) bool { return out[i].String() < out[j].String() }) + return out +} + func normalizeDomain(raw string) string { raw = strings.ToLower(strings.TrimSpace(raw)) if raw == "" { diff --git a/state/state.go b/state/state.go index 2d26e0db5..002471644 100644 --- a/state/state.go +++ b/state/state.go @@ -49,6 +49,7 @@ type Profile struct { Removed bool `json:"removed,omitempty"` AcceptAllRoutes bool `json:"acceptAllRoutes,omitempty"` AcceptAllDNSRoutes bool `json:"acceptAllDnsRoutes,omitempty"` + AdvertiseRoutes []netip.Prefix `json:"advertiseRoutes,omitempty"` WireGuard *wireguardcfg.Config `json:"wireguard,omitempty"` WireGuardSecretFile string `json:"wireguardSecretFile,omitempty"` WireGuardShieldsUp bool `json:"wireguardShieldsUp,omitempty"`