From 1f6a4389b6dd64aff6e3fd8f5c6e61495fe00563 Mon Sep 17 00:00:00 2001 From: Adriano Sela Aviles Date: Fri, 3 Apr 2026 00:46:35 -0700 Subject: [PATCH] tailcfg,types/netmap: visible services in netmap ipn/localapi: serve service list over localapi cmd/tailscale: new service list command Updates --- client/local/local.go | 10 ++++++ cmd/tailscale/cli/cli.go | 1 + cmd/tailscale/cli/services.go | 68 +++++++++++++++++++++++++++++++++++ ipn/localapi/localapi.go | 15 ++++++++ tailcfg/tailcfg.go | 33 +++++++++++++++++ types/netmap/netmap.go | 21 +++++++++++ 6 files changed, 148 insertions(+) create mode 100644 cmd/tailscale/cli/services.go diff --git a/client/local/local.go b/client/local/local.go index e72589306..75fdbe5a5 100644 --- a/client/local/local.go +++ b/client/local/local.go @@ -1422,3 +1422,13 @@ func (lc *Client) GetAppConnectorRouteInfo(ctx context.Context) (appctype.RouteI } return decodeJSON[appctype.RouteInfo](body) } + +// GetServices returns the Services visible to this node, +// including their names, IP addresses, and ports, keyed by service name. +func (lc *Client) GetServices(ctx context.Context) (map[tailcfg.ServiceName]tailcfg.ServiceDetails, error) { + body, err := lc.get200(ctx, "/localapi/v0/services") + if err != nil { + return nil, err + } + return decodeJSON[map[tailcfg.ServiceName]tailcfg.ServiceDetails](body) +} diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index 8a2c2b9ef..38327ca2b 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(), + servicesCmd(), nilOrCall(maybeUpdateCmd), whoisCmd, debugCmd(), diff --git a/cmd/tailscale/cli/services.go b/cmd/tailscale/cli/services.go new file mode 100644 index 000000000..beb1d59af --- /dev/null +++ b/cmd/tailscale/cli/services.go @@ -0,0 +1,68 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package cli + +import ( + "context" + "errors" + "flag" + "fmt" + "strings" + "text/tabwriter" + + "github.com/peterbourgon/ff/v3/ffcli" +) + +func servicesCmd() *ffcli.Command { + return &ffcli.Command{ + Name: "service", + ShortUsage: "tailscale service ", + ShortHelp: "Manage and view VIP services on your tailnet", + Subcommands: []*ffcli.Command{ + { + Name: "list", + ShortUsage: "tailscale service list", + ShortHelp: "List VIP services visible to this node", + Exec: runServicesList, + }, + }, + Exec: func(ctx context.Context, args []string) error { + return flag.ErrHelp + }, + } +} + +func runServicesList(ctx context.Context, args []string) error { + if len(args) > 0 { + return errors.New("unexpected non-flag arguments to 'tailscale service list'") + } + services, err := localClient.GetServices(ctx) + if err != nil { + return err + } + if len(services) == 0 { + return errors.New("no services found") + } + + w := tabwriter.NewWriter(Stdout, 10, 5, 5, ' ', 0) + defer w.Flush() + fmt.Fprintf(w, "\n %s\t%s\t%s\t", "SERVICE", "ADDRS", "PORTS") + for _, svc := range services { + 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, "\n %s\t%s\t%s\t", + svc.Name, + strings.Join(addrs, ", "), + strings.Join(ports, ", "), + ) + } + fmt.Fprintln(w) + return nil +} diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 43942c52f..b06b69b04 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -82,6 +82,7 @@ var handler = map[string]LocalAPIHandler{ "prefs": (*Handler).servePrefs, "reload-config": (*Handler).reloadConfig, "reset-auth": (*Handler).serveResetAuth, + "services": (*Handler).serveServices, "set-expiry-sooner": (*Handler).serveSetExpirySooner, "shutdown": (*Handler).serveShutdown, "start": (*Handler).serveStart, @@ -1707,6 +1708,20 @@ func (h *Handler) serveShutdown(w http.ResponseWriter, r *http.Request) { eventbus.Publish[Shutdown](ec).Publish(Shutdown{}) } +func (h *Handler) serveServices(w http.ResponseWriter, r *http.Request) { + if r.Method != httpm.GET { + http.Error(w, "only GET allowed", http.StatusMethodNotAllowed) + return + } + nm := h.b.NetMap() + if nm == nil { + http.Error(w, "no netmap", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(nm.Services()) +} + func (h *Handler) serveGetAppcRouteInfo(w http.ResponseWriter, r *http.Request) { if !buildfeatures.HasAppConnectors { http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented) diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 3d7921d75..bc3693dab 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -2447,6 +2447,18 @@ type Oauth2Token struct { // These are also referred to as "Node Attributes" in the ACL policy file. type NodeCapability string +// NodeCapabilityPrefix is a prefix for [NodeCapMap] keys that share a common +// namespace, where each entry represents a distinct named instance (e.g. one +// per service). The full key is formed by concatenating the prefix with the +// instance name. +type NodeCapabilityPrefix string + +// ToAttribute returns the full [NodeCapability] key for the given value under +// this prefix, of the form prefix+value. +func (p NodeCapabilityPrefix) ToAttribute(value string) NodeCapability { + return NodeCapability(string(p) + value) +} + const ( CapabilityFileSharing NodeCapability = "https://tailscale.com/cap/file-sharing" CapabilityAdmin NodeCapability = "https://tailscale.com/cap/is-admin" @@ -2780,6 +2792,14 @@ const ( NodeAttrCacheNetworkMaps NodeCapability = "cache-network-maps" ) +const ( + // NodeAttrPrefixServices is the prefix for per-service [NodeCapMap] + // entries describing Services visible (accessible) to this node. The full + // key for a service named "svc:foo" is NodeAttrPrefixServices+"foo". + // Each value under such a key is of type [ServiceDetails]. + NodeAttrPrefixServices NodeCapabilityPrefix = "services/" +) + // SetDNSRequest is a request to add a DNS record. // // This is used to let tailscaled clients complete their ACME DNS-01 challenges @@ -3318,6 +3338,19 @@ const LBHeader = "Ts-Lb" // this client is hosting can be ignored. type ServiceIPMappings map[ServiceName][]netip.Addr +// ServiceDetails describes a Service visible to this node. +// It is the value type stored under [NodeAttrPrefixServices]+serviceName keys in [NodeCapMap]. +type ServiceDetails struct { + // Name is the name of the Service, of the form "svc:dns-label". + Name ServiceName + + // Addrs are the IP addresses (IPv4 and IPv6) assigned to this Service. + Addrs []netip.Addr `json:",omitempty"` + + // Ports are the protocol/port combinations the Service accepts. + Ports []ProtoPortRange `json:",omitempty"` +} + // ClientAuditAction represents an auditable action that a client can report to the // control plane. These actions must correspond to the supported actions // in the control plane. diff --git a/types/netmap/netmap.go b/types/netmap/netmap.go index ac95254da..90ed9b3fc 100644 --- a/types/netmap/netmap.go +++ b/types/netmap/netmap.go @@ -146,6 +146,27 @@ func (nm *NetworkMap) GetIPVIPServiceMap() IPServiceMappings { return res } +// Services returns the Services visible (accessible) to this node, +// decoded from [tailcfg.NodeAttrPrefixServices]+serviceName entries in the +// self node's CapMap. Returns nil if nm is nil or SelfNode is invalid. +func (nm *NetworkMap) Services() map[tailcfg.ServiceName]tailcfg.ServiceDetails { + if nm == nil || !nm.SelfNode.Valid() { + return nil + } + result := make(map[tailcfg.ServiceName]tailcfg.ServiceDetails) + for cap := range nm.SelfNode.CapMap().All() { + if !strings.HasPrefix(string(cap), string(tailcfg.NodeAttrPrefixServices)) { + continue + } + svcs, err := tailcfg.UnmarshalNodeCapViewJSON[tailcfg.ServiceDetails](nm.SelfNode.CapMap(), cap) + if err != nil || len(svcs) < 1 { + continue + } + result[svcs[0].Name] = svcs[0] + } + return result +} + // SelfNodeOrZero returns the self node, or a zero value if nm is nil. func (nm *NetworkMap) SelfNodeOrZero() tailcfg.NodeView { if nm == nil {