summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdriano Sela Aviles <adriano@tailscale.com>2026-03-15 10:08:21 -0700
committerAdriano Sela Aviles <adriano@tailscale.com>2026-04-09 11:38:50 -0700
commitef240c1add9c5d0dff3d6650f2688df33ce873a4 (patch)
tree53639fd86ca050bcb0a819d9c6e024de1c1462d6
parentc1faa7f0977da80186c4d1148699091b88258889 (diff)
downloadtailscale-adrianosela/proxy-svc-experiment.tar.xz
tailscale-adrianosela/proxy-svc-experiment.zip
tsnet,client,cmd/tailscale/cli: expose service details on all clientsadrianosela/proxy-svc-experiment
-rw-r--r--client/local/local.go12
-rw-r--r--cmd/tailscale/cli/cli.go1
-rw-r--r--cmd/tailscale/cli/service.go103
-rw-r--r--tsnet/tsnet.go13
4 files changed, 129 insertions, 0 deletions
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 @@ change in the future.
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 <subcommand> [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 f28179773..3360a14f7 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 {