From e0261e82f62fe168f1b715e9ad379c008fc538f3 Mon Sep 17 00:00:00 2001 From: David Siewert Date: Fri, 6 Nov 2026 01:54:06 +0100 Subject: [PATCH] controlapi,profile,cmd,hosttun,state: support per-profile advertised routes Allow each profile to advertise subnet routes and exit-node default routes into its tailnet. Desired routes persist in profile state and converge through the supervisor reconcile loop onto the engine prefs, which control then lists for approval like a normal node. The daemon owns the forwarding plane on Linux: scoped FORWARD and MASQUERADE rules per advertised prefix on the shared host TUN plus IP forwarding sysctl checks, all torn down on clear, remove, or disable. Advertising refuses while tailscaled runs and outside Linux TUN mode. The CLI gains tailmix advertise list/set/clear. Change-Id: Ieeea036af9a23a2086ef40f1717cd781062bb922 --- cmd/tailmix/completion.go | 10 + cmd/tailmix/main.go | 157 +++++++++++ cmd/tailmix/main_test.go | 74 ++++++ cmd/tailmixd/advertise.go | 263 +++++++++++++++++++ cmd/tailmixd/advertise_test.go | 346 ++++++++++++++++++++++++ cmd/tailmixd/main.go | 25 ++ cmd/tailmixd/main_test.go | 36 +++ cmd/tailmixd/supervisor.go | 11 + controlapi/client.go | 18 ++ controlapi/server.go | 26 ++ controlapi/server_test.go | 60 +++++ controlapi/types.go | 31 +++ docs/design.md | 18 +- docs/profile-management.md | 43 +++ hosttun/advertise_linux.go | 452 ++++++++++++++++++++++++++++++++ hosttun/advertise_linux_test.go | 320 ++++++++++++++++++++++ hosttun/hosttun.go | 41 +++ hosttun/hosttun_darwin.go | 3 + hosttun/hosttun_linux.go | 15 ++ hosttun/hosttun_linux_test.go | 3 + profile/profile.go | 45 ++-- profile/tsnetengine.go | 65 +++-- profile/tsnetengine_test.go | 37 +++ state/jsonstore_test.go | 19 ++ state/normalize.go | 24 ++ state/state.go | 1 + 26 files changed, 2103 insertions(+), 40 deletions(-) create mode 100644 cmd/tailmixd/advertise.go create mode 100644 cmd/tailmixd/advertise_test.go create mode 100644 hosttun/advertise_linux.go create mode 100644 hosttun/advertise_linux_test.go 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"`