From 6e10596b04c1d9bbff9b86de7c889bbf123d4f4c Mon Sep 17 00:00:00 2001 From: Adriano Sela Aviles Date: Sun, 15 Mar 2026 10:08:21 -0700 Subject: [PATCH] tsnet,client,cmd/tailscale/cli: expose service details on all clients --- client/local/local.go | 12 ++++ cmd/tailscale/cli/cli.go | 1 + cmd/tailscale/cli/service.go | 103 +++++++++++++++++++++++++++++++++++ tsnet/tsnet.go | 13 +++++ 4 files changed, 129 insertions(+) create mode 100644 cmd/tailscale/cli/service.go diff --git a/client/local/local.go b/client/local/local.go index e72589306..0aa0a04b0 100644 --- a/client/local/local.go +++ b/client/local/local.go @@ -686,6 +686,18 @@ func (lc *Client) status(ctx context.Context, queryString string) (*ipnstate.Sta return decodeJSON[*ipnstate.Status](body) } +// GetServiceDetails returns the VIP services that the control plane has +// approved this node to serve, including their names, assigned IP addresses, +// ports, and annotations (e.g. proxy service configuration). Returns nil if +// no service details are present. +func (lc *Client) GetServiceDetails(ctx context.Context) ([]*tailcfg.ServiceDetail, error) { + body, err := lc.get200(ctx, "/localapi/v0/service-details") + if err != nil { + return nil, err + } + return decodeJSON[[]*tailcfg.ServiceDetail](body) +} + // IDToken is a request to get an OIDC ID token for an audience. // The token can be presented to any resource provider which offers OIDC // Federation. diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index 8a2c2b9ef..2bb400ef0 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -269,6 +269,7 @@ func newRootCmd() *ffcli.Command { nilOrCall(maybeNetlockCmd), licensesCmd, exitNodeCmd(), + serviceCmd(), nilOrCall(maybeUpdateCmd), whoisCmd, debugCmd(), diff --git a/cmd/tailscale/cli/service.go b/cmd/tailscale/cli/service.go new file mode 100644 index 000000000..cde314707 --- /dev/null +++ b/cmd/tailscale/cli/service.go @@ -0,0 +1,103 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package cli + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "sort" + "strings" + "text/tabwriter" + + "github.com/peterbourgon/ff/v3/ffcli" + "tailscale.com/tailcfg" +) + +func serviceCmd() *ffcli.Command { + return &ffcli.Command{ + Name: "service", + ShortUsage: "tailscale service [flags]", + ShortHelp: "Manage and inspect Tailscale VIP services", + Subcommands: []*ffcli.Command{ + { + Name: "list", + ShortUsage: "tailscale service list [--json]", + ShortHelp: "List VIP services approved for this node", + LongHelp: strings.TrimSpace(` +The 'tailscale service list' command shows the VIP services that the control +plane has approved this node to serve, including their assigned IP addresses, +accepted ports, and any application-specific annotations. +`), + Exec: runServiceList, + FlagSet: (func() *flag.FlagSet { + fs := newFlagSet("list") + fs.BoolVar(&serviceArgs.json, "json", false, "output in JSON format") + return fs + })(), + }, + }, + } +} + +var serviceArgs struct { + json bool +} + +func runServiceList(ctx context.Context, args []string) error { + if len(args) > 0 { + return errors.New("unexpected non-flag arguments to 'tailscale service list'") + } + details, err := localClient.GetServiceDetails(ctx) + if err != nil { + return err + } + if serviceArgs.json { + enc := json.NewEncoder(Stdout) + enc.SetIndent("", " ") + enc.Encode(details) + return nil + } + if len(details) == 0 { + printf("No VIP services configured for this node.\n") + return nil + } + printServiceDetails(details) + return nil +} + +func printServiceDetails(details []*tailcfg.ServiceDetail) { + w := tabwriter.NewWriter(Stdout, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "NAME\tADDRS\tPORTS\t") + fmt.Fprintln(w, "----\t-----\t-----\t") + for _, svc := range details { + addrs := make([]string, len(svc.Addrs)) + for i, a := range svc.Addrs { + addrs[i] = a.String() + } + ports := make([]string, len(svc.Ports)) + for i, p := range svc.Ports { + ports[i] = p.String() + } + fmt.Fprintf(w, "%s\t%s\t%s\t\n", + svc.Name, + strings.Join(addrs, ", "), + strings.Join(ports, ", "), + ) + if len(svc.Annotations) > 0 { + // Print annotations sorted by key, indented under the service row. + keys := make([]string, 0, len(svc.Annotations)) + for k := range svc.Annotations { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(w, " %s: %s\t\t\t\n", k, svc.Annotations[k]) + } + } + } + w.Flush() +} diff --git a/tsnet/tsnet.go b/tsnet/tsnet.go index 71452f662..db14eac4d 100644 --- a/tsnet/tsnet.go +++ b/tsnet/tsnet.go @@ -564,6 +564,19 @@ func (s *Server) TailscaleIPs() (ip4, ip6 netip.Addr) { return ip4, ip6 } +// GetServiceDetails returns the VIP services that the control plane has +// approved this node to serve, including their names, assigned IP addresses, +// ports, and application-specific annotations (e.g. proxy service +// configuration). Returns nil if the server is not running or no service +// details have been received yet. +func (s *Server) GetServiceDetails() []*tailcfg.ServiceDetail { + nm := s.lb.NetMap() + if nm == nil { + return nil + } + return nm.GetVIPServiceDetails() +} + // LogtailWriter returns an [io.Writer] that writes to Tailscale's logging service and will be only visible to Tailscale's // support team. Logs written there cannot be retrieved by the user. This method always returns a non-nil value. func (s *Server) LogtailWriter() io.Writer {