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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cmd/tailmix/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -452,6 +461,7 @@ func completionCommandTree() *completionCommand {
profiles,
routes,
exitNode,
advertise,
dns,
wireguard,
tailscaleCompletionCommand("tailscale", "Run an upstream Tailscale command for one profile", profileFlag()),
Expand Down
157 changes: 157 additions & 0 deletions cmd/tailmix/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"}
Expand Down Expand Up @@ -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},
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <profile>] [--json]
tailmix advertise set --profile <profile> <prefix>... [--json]
tailmix advertise clear --profile <profile> [--json]

Profile option: -p, --profile <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 <command> [arguments]
tailmix dns search <command> [arguments]
Expand Down
74 changes: 74 additions & 0 deletions cmd/tailmix/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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{
Expand Down
Loading