summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/local/local.go10
-rw-r--r--cmd/tailscale/cli/cli.go1
-rw-r--r--cmd/tailscale/cli/services.go68
-rw-r--r--ipn/localapi/localapi.go15
-rw-r--r--tailcfg/tailcfg.go33
-rw-r--r--types/netmap/netmap.go21
6 files changed, 148 insertions, 0 deletions
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 <subcommand>",
+ 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 {