cmd/tailscale/cli,client,ipn: add appc-routes cli command

Allow the user to access information about routes an app connector has
learned, such as how many routes for each domain.

Fixes tailscale/corp#32624

Signed-off-by: Fran Bull <fran@tailscale.com>
This commit is contained in:
Fran Bull 2025-09-24 15:02:57 -07:00 committed by franbull
parent 976389c0f7
commit 65d6c80695
12 changed files with 201 additions and 5 deletions

View File

@ -27,6 +27,7 @@ import (
"sync"
"time"
"tailscale.com/appc"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/drive"
"tailscale.com/envknob"
@ -1374,3 +1375,11 @@ func (lc *Client) ShutdownTailscaled(ctx context.Context) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/shutdown", 200, nil)
return err
}
func (lc *Client) GetAppConnectorRouteInfo(ctx context.Context) (appc.RouteInfo, error) {
body, err := lc.get200(ctx, "/localapi/v0/appc-route-info")
if err != nil {
return appc.RouteInfo{}, err
}
return decodeJSON[appc.RouteInfo](body)
}

View File

@ -77,6 +77,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
google.golang.org/protobuf/runtime/protoimpl from github.com/prometheus/client_model/go+
google.golang.org/protobuf/types/known/timestamppb from github.com/prometheus/client_golang/prometheus+
tailscale.com from tailscale.com/version
tailscale.com/appc from tailscale.com/client/local
💣 tailscale.com/atomicfile from tailscale.com/cmd/derper+
tailscale.com/client/local from tailscale.com/derp/derpserver
tailscale.com/client/tailscale/apitype from tailscale.com/client/local
@ -151,6 +152,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/hostinfo+
tailscale.com/util/eventbus from tailscale.com/net/netmon+
tailscale.com/util/execqueue from tailscale.com/appc
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
tailscale.com/util/lineiter from tailscale.com/hostinfo+
tailscale.com/util/mak from tailscale.com/health+

View File

@ -769,7 +769,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
sigs.k8s.io/yaml from k8s.io/apimachinery/pkg/runtime/serializer/json+
sigs.k8s.io/yaml/goyaml.v2 from sigs.k8s.io/yaml+
tailscale.com from tailscale.com/version
tailscale.com/appc from tailscale.com/ipn/ipnlocal
tailscale.com/appc from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/atomicfile from tailscale.com/ipn+
tailscale.com/client/local from tailscale.com/client/tailscale+
tailscale.com/client/tailscale from tailscale.com/cmd/k8s-operator+

View File

@ -0,0 +1,153 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package cli
import (
"context"
"encoding/json"
"flag"
"fmt"
"slices"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/appc"
)
var appcRoutesArgs struct {
all bool
domainMap bool
n bool
}
var appcRoutesCmd = &ffcli.Command{
Name: "appc-routes",
ShortUsage: "tailscale appc-routes",
Exec: runAppcRoutesInfo,
ShortHelp: "Print the current app connector routes",
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("appc-routes")
fs.BoolVar(&appcRoutesArgs.all, "all", false, "Print learned domains and routes and extra policy configured routes.")
fs.BoolVar(&appcRoutesArgs.domainMap, "map", false, "Print the map of learned domains: [routes].")
fs.BoolVar(&appcRoutesArgs.n, "n", false, "Print the total number of routes this node advertises.")
return fs
})(),
LongHelp: strings.TrimSpace(`
The 'tailscale appc-routes' command prints the current App Connector route status.
By default this command prints the domains configured in the app connector configuration and how many routes have been
learned for each domain.
--all prints the routes learned from the domains configured in the app connector configuration; and any extra routes provided
in the the policy app connector 'routes' field.
--map prints the routes learned from the domains configured in the app connector configuration.
-n prints the total number of routes advertised by this device, whether learned, set in the policy, or set locally.
For more information about App Connectors, refer to
https://tailscale.com/kb/1281/app-connectors
`),
}
func getAllOutput(ri *appc.RouteInfo) (string, error) {
domains, err := json.MarshalIndent(ri.Domains, " ", " ")
if err != nil {
return "", err
}
control, err := json.MarshalIndent(ri.Control, " ", " ")
if err != nil {
return "", err
}
s := fmt.Sprintf(`Learned Routes
==============
%s
Routes from Policy
==================
%s
`, domains, control)
return s, nil
}
type domainCount struct {
domain string
count int
}
func getSummarizeLearnedOutput(ri *appc.RouteInfo) string {
x := make([]domainCount, len(ri.Domains))
i := 0
maxDomainWidth := 0
for k, v := range ri.Domains {
if len(k) > maxDomainWidth {
maxDomainWidth = len(k)
}
x[i] = domainCount{domain: k, count: len(v)}
i++
}
slices.SortFunc(x, func(i, j domainCount) int {
if i.count > j.count {
return -1
}
if i.count < j.count {
return 1
}
if i.domain > j.domain {
return 1
}
if i.domain < j.domain {
return -1
}
return 0
})
s := ""
fmtString := fmt.Sprintf("%%-%ds %%d\n", maxDomainWidth) // eg "%-10s %d\n"
for _, dc := range x {
s += fmt.Sprintf(fmtString, dc.domain, dc.count)
}
return s
}
func runAppcRoutesInfo(ctx context.Context, args []string) error {
prefs, err := localClient.GetPrefs(ctx)
if err != nil {
return err
}
if !prefs.AppConnector.Advertise {
fmt.Println("not a connector")
return nil
}
if appcRoutesArgs.n {
fmt.Println(len(prefs.AdvertiseRoutes))
return nil
}
routeInfo, err := localClient.GetAppConnectorRouteInfo(ctx)
if err != nil {
return err
}
if appcRoutesArgs.domainMap {
domains, err := json.Marshal(routeInfo.Domains)
if err != nil {
return err
}
fmt.Println(string(domains))
return nil
}
if appcRoutesArgs.all {
s, err := getAllOutput(&routeInfo)
if err != nil {
return err
}
fmt.Println(s)
return nil
}
fmt.Print(getSummarizeLearnedOutput(&routeInfo))
return nil
}

View File

@ -276,6 +276,7 @@ change in the future.
idTokenCmd,
configureHostCmd(),
systrayCmd,
appcRoutesCmd,
),
FlagSet: rootfs,
Exec: func(ctx context.Context, args []string) error {

View File

@ -70,6 +70,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
software.sslmate.com/src/go-pkcs12 from tailscale.com/cmd/tailscale/cli
software.sslmate.com/src/go-pkcs12/internal/rc2 from software.sslmate.com/src/go-pkcs12
tailscale.com from tailscale.com/version
tailscale.com/appc from tailscale.com/client/local+
💣 tailscale.com/atomicfile from tailscale.com/cmd/tailscale/cli+
tailscale.com/client/local from tailscale.com/client/tailscale+
L tailscale.com/client/systray from tailscale.com/cmd/tailscale/cli
@ -168,6 +169,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
L 💣 tailscale.com/util/dirwalk from tailscale.com/metrics
tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+
tailscale.com/util/eventbus from tailscale.com/client/local+
tailscale.com/util/execqueue from tailscale.com/appc
tailscale.com/util/groupmember from tailscale.com/client/web
💣 tailscale.com/util/hashx from tailscale.com/util/deephash
tailscale.com/util/httpm from tailscale.com/client/tailscale+

View File

@ -51,7 +51,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
💣 go4.org/mem from tailscale.com/control/controlbase+
go4.org/netipx from tailscale.com/ipn/ipnlocal+
tailscale.com from tailscale.com/version
tailscale.com/appc from tailscale.com/ipn/ipnlocal
tailscale.com/appc from tailscale.com/ipn/ipnlocal+
tailscale.com/atomicfile from tailscale.com/ipn+
tailscale.com/client/tailscale/apitype from tailscale.com/ipn/ipnauth+
tailscale.com/clientupdate from tailscale.com/ipn/ipnlocal+

View File

@ -240,7 +240,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
gvisor.dev/gvisor/pkg/tcpip/transport/udp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+
tailscale.com from tailscale.com/version
tailscale.com/appc from tailscale.com/ipn/ipnlocal
tailscale.com/appc from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/atomicfile from tailscale.com/ipn+
LD tailscale.com/chirp from tailscale.com/cmd/tailscaled
tailscale.com/client/local from tailscale.com/client/web+

View File

@ -211,7 +211,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
gvisor.dev/gvisor/pkg/tcpip/transport/udp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+
tailscale.com from tailscale.com/version
tailscale.com/appc from tailscale.com/ipn/ipnlocal
tailscale.com/appc from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/atomicfile from tailscale.com/ipn+
tailscale.com/client/local from tailscale.com/client/web+
tailscale.com/client/tailscale from tailscale.com/internal/client/tailscale

View File

@ -7124,6 +7124,15 @@ func (b *LocalBackend) readRouteInfoLocked() (*appc.RouteInfo, error) {
return ri, nil
}
// ReadRouteInfo returns the app connector route information that is
// stored in prefs to be consistent across restarts. It should be up
// to date with the RouteInfo in memory being used by appc.
func (b *LocalBackend) ReadRouteInfo() (*appc.RouteInfo, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.readRouteInfoLocked()
}
// seamlessRenewalEnabled reports whether seamless key renewals are enabled.
//
// As of 2025-09-11, this is the default behaviour unless nodes receive

View File

@ -25,6 +25,7 @@ import (
"time"
"golang.org/x/net/dns/dnsmessage"
"tailscale.com/appc"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/clientupdate"
"tailscale.com/envknob"
@ -73,6 +74,7 @@ var handler = map[string]LocalAPIHandler{
// The other /localapi/v0/NAME handlers are exact matches and contain only NAME
// without a trailing slash:
"alpha-set-device-attrs": (*Handler).serveSetDeviceAttrs, // see tailscale/corp#24690
"appc-route-info": (*Handler).serveGetAppcRouteInfo,
"bugreport": (*Handler).serveBugReport,
"check-ip-forwarding": (*Handler).serveCheckIPForwarding,
"check-prefs": (*Handler).serveCheckPrefs,
@ -2111,3 +2113,21 @@ func (h *Handler) serveShutdown(w http.ResponseWriter, r *http.Request) {
eventbus.Publish[Shutdown](ec).Publish(Shutdown{})
}
func (h *Handler) serveGetAppcRouteInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != httpm.GET {
http.Error(w, "only GET allowed", http.StatusMethodNotAllowed)
return
}
res, err := h.b.ReadRouteInfo()
if err != nil {
if errors.Is(err, ipn.ErrStateNotExist) {
res = &appc.RouteInfo{}
} else {
WriteErrorJSON(w, err)
return
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
}

View File

@ -207,7 +207,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
gvisor.dev/gvisor/pkg/tcpip/transport/udp from gvisor.dev/gvisor/pkg/tcpip/adapters/gonet+
gvisor.dev/gvisor/pkg/waiter from gvisor.dev/gvisor/pkg/context+
tailscale.com from tailscale.com/version
tailscale.com/appc from tailscale.com/ipn/ipnlocal
tailscale.com/appc from tailscale.com/ipn/ipnlocal+
💣 tailscale.com/atomicfile from tailscale.com/ipn+
tailscale.com/client/local from tailscale.com/client/web+
tailscale.com/client/tailscale from tailscale.com/internal/client/tailscale