cmd/tailscale/cli: add service list command

Updates #20166

Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
This commit is contained in:
Adriano Sela Aviles 2026-06-17 14:18:05 -07:00 committed by Adriano Sela Aviles
parent c57b917ac1
commit 6ab63e9a85
3 changed files with 647 additions and 0 deletions

View File

@ -292,6 +292,7 @@ func newRootCmd(tb ...testenv.TB) *ffcli.Command {
sshCmd,
nilOrCall(maybeFunnelCmd),
nilOrCall(maybeServeCmd),
serviceCmd(),
versionCmd,
nilOrCall(maybeWebCmd),
nilOrCall(fileCmd),

View File

@ -0,0 +1,275 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package cli
import (
"cmp"
"context"
"encoding/json"
"flag"
"fmt"
"slices"
"strings"
"text/tabwriter"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/envknob"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/types/ipproto"
)
// serviceLister is the subset of [local.Client] that the service commands
// need. It is injected via the command's context (see [withServiceLister] /
// [serviceListerFromContext]) so tests can supply a mock. Outside of tests
// it is suplied by the package-level localClient var.
//
// NOTE(adrianosela): Could be replaced by a more generic way to mock
// local.Client e.g. https://github.com/tailscale/tailscale/issues/20164.
type serviceLister interface {
GetServices(ctx context.Context) (map[tailcfg.ServiceName]tailcfg.ServiceDetails, error)
Status(ctx context.Context) (*ipnstate.Status, error)
}
type serviceListerCtxKey struct{}
// withServiceLister returns a copy of ctx carrying sl, retrievable with
// [serviceListerFromContext].
func withServiceLister(ctx context.Context, sl serviceLister) context.Context {
return context.WithValue(ctx, serviceListerCtxKey{}, sl)
}
// serviceListerFromContext returns the [serviceLister] stored in ctx by
// [withServiceLister], or the package-level localClient if none was injected.
func serviceListerFromContext(ctx context.Context) serviceLister {
if sl, ok := ctx.Value(serviceListerCtxKey{}).(serviceLister); ok {
return sl
}
return &localClient
}
const serviceListUsage = "tailscale service list"
func serviceCmd() *ffcli.Command {
// The service commands are still in development and gated behind the
// work-in-progress knob. When it's off, serviceCmd returns nil and is
// filtered out of the root command's subcommands by nonNilCmds.
if !envknob.UseWIPCode() {
return nil
}
return &ffcli.Command{
Name: "service",
ShortHelp: "Interact with Tailscale Services",
ShortUsage: "tailscale service",
LongHelp: strings.TrimSpace(`
The 'tailscale service' command groups subcommands for Tailscale Services.
A Tailscale Service is a virtual service with its own IP addresses. Which
Services this node can reach is determined by the tailnet's ACLs. Use the 'list'
subcommand to see the Services currently available to this node.
`),
UsageFunc: usageFuncNoDefaultValues,
Exec: func(context.Context, []string) error { return flag.ErrHelp },
Subcommands: []*ffcli.Command{
{
Name: "list",
ShortUsage: serviceListUsage,
ShortHelp: "List the Tailscale Services your node can access",
Exec: runServiceList,
FlagSet: func() *flag.FlagSet {
fs := newFlagSet("list")
fs.BoolVar(&serviceListArgs.json, "json", false, "output in JSON format")
return fs
}(),
},
},
}
}
var serviceListArgs struct {
json bool
}
// serviceListEntry decorates a [tailcfg.ServiceDetails] with the Service's
// MagicDNS hostname, both for the table's HOSTNAME column and so it is included
// in the JSON output.
type serviceListEntry struct {
tailcfg.ServiceDetails
Hostname string
}
// runServiceList is the entry point for the "tailscale service list" command.
func runServiceList(ctx context.Context, args []string) error {
if len(args) != 0 {
return fmt.Errorf("usage: %s", serviceListUsage)
}
lc := serviceListerFromContext(ctx)
services, err := lc.GetServices(ctx)
if err != nil {
return err
}
// We need the tailnet's MagicDNS suffix to build each Service's hostname.
st, err := lc.Status(ctx)
if err != nil {
return fixTailscaledConnectError(err)
}
var magicDNSSuffix string
if st.CurrentTailnet != nil {
magicDNSSuffix = st.CurrentTailnet.MagicDNSSuffix
}
// Sort the services by name for stable output.
names := make([]tailcfg.ServiceName, 0, len(services))
for name := range services {
names = append(names, name)
}
slices.Sort(names)
entries := make([]serviceListEntry, 0, len(names))
for _, name := range names {
svc := services[name]
entries = append(entries, serviceListEntry{
ServiceDetails: svc,
Hostname: serviceHostname(name, magicDNSSuffix),
})
}
if serviceListArgs.json {
enc := json.NewEncoder(Stdout)
enc.SetIndent("", " ")
return enc.Encode(entries)
}
if len(entries) == 0 {
fmt.Fprintln(Stdout, "No Tailscale Services are available to this node.")
return nil
}
w := tabwriter.NewWriter(Stdout, 10, 5, 5, ' ', 0)
defer w.Flush()
fmt.Fprintf(w, "\n %s\t%s\t%s\t%s\t%s\t", "IP", "HOSTNAME", "DISPLAY NAME", "ENDPOINTS", "TYPE")
for _, e := range entries {
// Show a single IP, always the first in Addrs. If a tailnet has IPv4
// disabled, the netmap only includes the v6 address, so the 0th index
// is the v6 address and that's what we show.
var ip string
if len(e.Addrs) > 0 {
ip = e.Addrs[0].String()
}
fmt.Fprintf(w, "\n %s\t%s\t%s\t%s\t%s\t",
cmp.Or(ip, "-"),
cmp.Or(e.Hostname, "-"),
cmp.Or(e.DisplayName, "-"),
joinStringers(e.Ports, "-"),
serviceActionTypes(e.ServiceDetails),
)
}
fmt.Fprintln(w)
return nil
}
// wellKnownPortActions maps well-known TCP ports to the service action type
// they conventionally correspond to. It is used to infer actions for Services
// that don't carry explicit ones.
//
// TODO(adrianosela): move this to tailcfg as
// InferredServiceActionForPort(p uint16) ServiceActionType.
var wellKnownPortActions = map[uint16]tailcfg.ServiceActionType{
22: tailcfg.ServiceActionTypeSSH,
80: tailcfg.ServiceActionTypeHTTP,
443: tailcfg.ServiceActionTypeHTTP,
1433: tailcfg.ServiceActionTypeMSSQL,
3306: tailcfg.ServiceActionTypeMySQL,
3389: tailcfg.ServiceActionTypeRDP,
5432: tailcfg.ServiceActionTypePostgreSQL,
5900: tailcfg.ServiceActionTypeVNC,
6443: tailcfg.ServiceActionTypeKubernetes,
9200: tailcfg.ServiceActionTypeElasticSearch,
26257: tailcfg.ServiceActionTypeCockroachDB,
27017: tailcfg.ServiceActionTypeMongoDB,
}
// maxNamedTypes is the most action types serviceActionTypes names before
// summarizing the remainder as "N other(s)".
const maxNamedTypes = 2
// serviceActionTypes renders a Service's action identifiers (types) for the
// TYPE column. Types are deduplicated; it names at most [maxNamedTypes] of them
// and summarizes any remainder, e.g. "-" for none, "http" for one, "http, ssh"
// for two, and "http, ssh, 2 others" for more.
//
// Explicit actions are shown by type. When a Service carries no explicit
// actions, types are inferred from well-known TCP ports (see
// [wellKnownPortActions]).
func serviceActionTypes(svc tailcfg.ServiceDetails) string {
var raw []tailcfg.ServiceActionType
if len(svc.Actions) > 0 {
for _, a := range svc.Actions {
raw = append(raw, a.Type)
}
} else {
for _, ppr := range svc.Ports {
// Only single TCP ports map to a well-known action.
if ppr.Proto != 0 && ppr.Proto != int(ipproto.TCP) {
continue
}
if ppr.Ports.First != ppr.Ports.Last {
continue
}
if t, ok := wellKnownPortActions[ppr.Ports.First]; ok {
raw = append(raw, t)
}
}
}
// Deduplicate, preserving first-seen order.
var types []string
seen := make(map[tailcfg.ServiceActionType]bool)
for _, t := range raw {
if seen[t] {
continue
}
seen[t] = true
types = append(types, string(t))
}
if len(types) == 0 {
return "-"
}
if len(types) <= maxNamedTypes {
return strings.Join(types, ", ")
}
extra := len(types) - maxNamedTypes
noun := "others"
if extra == 1 {
noun = "other"
}
return fmt.Sprintf("%s, %d %s", strings.Join(types[:maxNamedTypes], ", "), extra, noun)
}
// serviceHostname returns the MagicDNS hostname for a Service, of the form
// "<name>.<magicDNSSuffix>". It returns "" if the name or suffix is missing.
func serviceHostname(name tailcfg.ServiceName, magicDNSSuffix string) string {
bare := name.WithoutPrefix()
if bare == "" || magicDNSSuffix == "" {
return ""
}
return bare + "." + strings.Trim(magicDNSSuffix, ".")
}
// joinStringers renders a slice of fmt.Stringer-like values as a
// comma-separated string, returning empty if the slice is empty.
func joinStringers[T fmt.Stringer](vals []T, empty string) string {
if len(vals) == 0 {
return empty
}
strs := make([]string, len(vals))
for i, v := range vals {
strs[i] = v.String()
}
return strings.Join(strs, ", ")
}

View File

@ -0,0 +1,371 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package cli
import (
"context"
"encoding/json"
"errors"
"io"
"net/netip"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/tstest"
)
// fakeServiceLister is a test-only [serviceLister] injected via
// [withServiceLister] in place of the real localClient.
type fakeServiceLister struct {
services map[tailcfg.ServiceName]tailcfg.ServiceDetails
servicesErr error
status *ipnstate.Status
statusErr error
}
func (f fakeServiceLister) GetServices(context.Context) (map[tailcfg.ServiceName]tailcfg.ServiceDetails, error) {
return f.services, f.servicesErr
}
func (f fakeServiceLister) Status(context.Context) (*ipnstate.Status, error) {
return f.status, f.statusErr
}
// mustParsePorts parses ProtoPortRanges or fails the test.
func mustParsePorts(t *testing.T, ports ...string) []tailcfg.ProtoPortRange {
t.Helper()
ppr, err := tailcfg.ParseProtoPortRanges(ports)
if err != nil {
t.Fatalf("ParseProtoPortRanges(%q): %v", ports, err)
}
return ppr
}
// statusWithSuffix returns a Status carrying the given MagicDNS suffix.
func statusWithSuffix(suffix string) *ipnstate.Status {
return &ipnstate.Status{
CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: suffix},
}
}
// runList runs the list command with the given lister, capturing stdout.
func runList(t *testing.T, lc serviceLister, jsonOut bool) string {
t.Helper()
var buf strings.Builder
tstest.Replace[io.Writer](t, &Stdout, &buf)
tstest.Replace(t, &serviceListArgs.json, jsonOut)
ctx := withServiceLister(context.Background(), lc)
if err := runServiceList(ctx, nil); err != nil {
t.Fatalf("runServiceList: %v", err)
}
return buf.String()
}
func TestServiceListTable(t *testing.T) {
lc := fakeServiceLister{
status: statusWithSuffix("ts-tailnet.ts.net."),
services: map[tailcfg.ServiceName]tailcfg.ServiceDetails{
// Intentionally out of order to exercise sorting.
"svc:web": {
Name: "svc:web",
DisplayName: "Web",
Addrs: []netip.Addr{netip.MustParseAddr("100.80.0.2"), netip.MustParseAddr("fd7a::2")},
Ports: mustParsePorts(t, "tcp:443"),
// Two actions render comma-separated.
Actions: []tailcfg.ServiceAction{
{Type: tailcfg.ServiceActionTypeHTTP, Port: 443},
{Type: tailcfg.ServiceActionTypeSSH, Port: 22},
},
},
"svc:db": {
Name: "svc:db",
Addrs: []netip.Addr{netip.MustParseAddr("100.80.0.1")},
Ports: mustParsePorts(t, "tcp:5432"),
// No explicit actions, but tcp:5432 infers "postgresql".
},
},
}
out := runList(t, lc, false)
for _, h := range []string{"IP", "HOSTNAME", "DISPLAY NAME", "ENDPOINTS", "TYPE"} {
if !strings.Contains(out, h) {
t.Errorf("output missing header %q\n%s", h, out)
}
}
// Services are sorted by name, so svc:db's row precedes svc:web's.
if i, j := strings.Index(out, "db.ts-tailnet.ts.net"), strings.Index(out, "web.ts-tailnet.ts.net"); i < 0 || j < 0 || i > j {
t.Errorf("services not sorted by name (db=%d, web=%d)\n%s", i, j, out)
}
// IP is the first address only; the v6 address is not shown.
if !strings.Contains(out, "100.80.0.2") {
t.Errorf("output missing svc:web first IP\n%s", out)
}
if strings.Contains(out, "fd7a::2") {
t.Errorf("output should show only the first IP, not the v6 address\n%s", out)
}
// Endpoints come from Ports.
if !strings.Contains(out, "tcp:443") || !strings.Contains(out, "tcp:5432") {
t.Errorf("output missing endpoints\n%s", out)
}
// svc:web has two explicit actions, rendered comma-separated.
if !strings.Contains(out, "http, ssh") {
t.Errorf("output missing svc:web actions %q\n%s", "http, ssh", out)
}
// svc:db has no explicit actions but a well-known port (5432), so its
// type is inferred.
if !strings.Contains(out, "postgresql") {
t.Errorf("output missing svc:db inferred type %q\n%s", "postgresql", out)
}
}
func TestServiceActionTypes(t *testing.T) {
tests := []struct {
name string
svc tailcfg.ServiceDetails
want string
}{
{
"explicit one",
tailcfg.ServiceDetails{Actions: []tailcfg.ServiceAction{{Type: tailcfg.ServiceActionTypeSSH}}},
"ssh",
},
{
"explicit two",
tailcfg.ServiceDetails{Actions: []tailcfg.ServiceAction{
{Type: tailcfg.ServiceActionTypeHTTP},
{Type: tailcfg.ServiceActionTypeSSH},
}},
"http, ssh",
},
{
// More than maxNamedTypes: name two, summarize the rest.
"explicit three summarizes one other",
tailcfg.ServiceDetails{Actions: []tailcfg.ServiceAction{
{Type: tailcfg.ServiceActionTypeHTTP},
{Type: tailcfg.ServiceActionTypeSSH},
{Type: tailcfg.ServiceActionTypeMySQL},
}},
"http, ssh, 1 other",
},
{
"explicit four summarizes multiple others",
tailcfg.ServiceDetails{Actions: []tailcfg.ServiceAction{
{Type: tailcfg.ServiceActionTypeHTTP},
{Type: tailcfg.ServiceActionTypeSSH},
{Type: tailcfg.ServiceActionTypeMySQL},
{Type: tailcfg.ServiceActionTypeRDP},
}},
"http, ssh, 2 others",
},
{
// Duplicate types collapse before counting.
"explicit dedupes by type",
tailcfg.ServiceDetails{Actions: []tailcfg.ServiceAction{
{Type: tailcfg.ServiceActionTypeHTTP, Port: 80},
{Type: tailcfg.ServiceActionTypeHTTP, Port: 443},
{Type: tailcfg.ServiceActionTypeSSH, Port: 22},
}},
"http, ssh",
},
{
// Explicit actions win; ports are not consulted for inference.
"explicit beats inference",
tailcfg.ServiceDetails{
Actions: []tailcfg.ServiceAction{{Type: tailcfg.ServiceActionTypeTCP}},
Ports: mustParsePorts(t, "tcp:22"),
},
"tcp",
},
{
"inferred from well-known port",
tailcfg.ServiceDetails{Ports: mustParsePorts(t, "tcp:22")},
"ssh",
},
{
"inferred from multiple well-known ports",
tailcfg.ServiceDetails{Ports: mustParsePorts(t, "tcp:80", "tcp:5432")},
"http, postgresql",
},
{
// 443 and 80 both map to http; it is only listed once.
"inferred dedupes by action type",
tailcfg.ServiceDetails{Ports: mustParsePorts(t, "tcp:80", "tcp:443")},
"http",
},
{
"no actions and unknown port",
tailcfg.ServiceDetails{Ports: mustParsePorts(t, "tcp:12345")},
"-",
},
{
// A port range is not a single well-known port, so nothing is inferred.
"port range is not inferred",
tailcfg.ServiceDetails{Ports: mustParsePorts(t, "tcp:20-30")},
"-",
},
{"nothing at all", tailcfg.ServiceDetails{}, "-"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := serviceActionTypes(tt.svc); got != tt.want {
t.Errorf("serviceActionTypes() = %q, want %q", got, tt.want)
}
})
}
}
// TestServiceListIPv6Only verifies that when a Service only has a v6 address
// (e.g. a tailnet with IPv4 disabled), the v6 address is shown as the IP.
func TestServiceListIPv6Only(t *testing.T) {
lc := fakeServiceLister{
status: statusWithSuffix("ts-tailnet.ts.net"),
services: map[tailcfg.ServiceName]tailcfg.ServiceDetails{
"svc:v6": {
Name: "svc:v6",
Addrs: []netip.Addr{netip.MustParseAddr("fd7a::9")},
Ports: mustParsePorts(t, "tcp:80"),
},
},
}
out := runList(t, lc, false)
if !strings.Contains(out, "fd7a::9") {
t.Errorf("expected v6 address as IP\n%s", out)
}
}
func TestServiceListJSON(t *testing.T) {
lc := fakeServiceLister{
status: statusWithSuffix("ts-tailnet.ts.net"),
services: map[tailcfg.ServiceName]tailcfg.ServiceDetails{
// Intentionally out of order to exercise sorting, and svc:web has
// both v4 and v6 addresses to confirm the JSON carries the full
// address list (unlike the table, which shows only the first).
"svc:web": {
Name: "svc:web",
DisplayName: "Web",
Addrs: []netip.Addr{netip.MustParseAddr("100.80.0.2"), netip.MustParseAddr("fd7a::2")},
Ports: mustParsePorts(t, "tcp:443", "tcp:8443"),
},
"svc:db": {
Name: "svc:db",
Addrs: []netip.Addr{netip.MustParseAddr("100.80.0.1")},
Ports: mustParsePorts(t, "tcp:5432"),
},
},
}
out := runList(t, lc, true)
var got []serviceListEntry
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("unmarshal JSON output: %v\n%s", err, out)
}
// Entries are sorted by name and each embedded ServiceDetails (including
// the full Addrs and Ports) round-trips, decorated with the hostname.
want := []serviceListEntry{
{
ServiceDetails: tailcfg.ServiceDetails{
Name: "svc:db",
Addrs: []netip.Addr{netip.MustParseAddr("100.80.0.1")},
Ports: mustParsePorts(t, "tcp:5432"),
},
Hostname: "db.ts-tailnet.ts.net",
},
{
ServiceDetails: tailcfg.ServiceDetails{
Name: "svc:web",
DisplayName: "Web",
Addrs: []netip.Addr{netip.MustParseAddr("100.80.0.2"), netip.MustParseAddr("fd7a::2")},
Ports: mustParsePorts(t, "tcp:443", "tcp:8443"),
},
Hostname: "web.ts-tailnet.ts.net",
},
}
// netip.Addr has unexported fields but is comparable, so compare it by ==.
if diff := cmp.Diff(want, got, cmpopts.EquateComparable(netip.Addr{})); diff != "" {
t.Errorf("JSON output mismatch (-want +got):\n%s\nraw:\n%s", diff, out)
}
}
func TestServiceListEmpty(t *testing.T) {
lc := fakeServiceLister{
status: statusWithSuffix("ts-tailnet.ts.net"),
services: map[tailcfg.ServiceName]tailcfg.ServiceDetails{},
}
out := runList(t, lc, false)
if !strings.Contains(out, "No Tailscale Services are available") {
t.Errorf("expected empty message, got\n%s", out)
}
}
func TestServiceListGetServicesError(t *testing.T) {
wantErr := errors.New("boom")
lc := fakeServiceLister{servicesErr: wantErr}
ctx := withServiceLister(context.Background(), lc)
err := runServiceList(ctx, nil)
if !errors.Is(err, wantErr) {
t.Errorf("err = %v, want %v", err, wantErr)
}
}
func TestServiceListRejectsArgs(t *testing.T) {
ctx := withServiceLister(context.Background(), fakeServiceLister{})
if err := runServiceList(ctx, []string{"unexpected"}); err == nil {
t.Error("expected error for extra args, got nil")
}
}
func TestServiceHostname(t *testing.T) {
tests := []struct {
name tailcfg.ServiceName
suffix string
want string
}{
{"svc:web", "ts-tailnet.ts.net", "web.ts-tailnet.ts.net"},
{"svc:web", "ts-tailnet.ts.net.", "web.ts-tailnet.ts.net"}, // trailing dot trimmed
{"svc:web", "", ""}, // no suffix
{"not-a-service", "ts-tailnet.ts.net", ""}, // invalid name (no svc: prefix)
}
for _, tt := range tests {
if got := serviceHostname(tt.name, tt.suffix); got != tt.want {
t.Errorf("serviceHostname(%q, %q) = %q, want %q", tt.name, tt.suffix, got, tt.want)
}
}
}
// TestServiceListerFromContextDefault verifies that without injection the
// real localClient is returned.
func TestServiceListerFromContextDefault(t *testing.T) {
if got := serviceListerFromContext(context.Background()); got != &localClient {
t.Errorf("serviceListerFromContext default = %v, want &localClient", got)
}
}
// TestServiceCmdWIPGate verifies the service command is only registered when
// work-in-progress code is enabled.
func TestServiceCmdWIPGate(t *testing.T) {
t.Setenv("TAILSCALE_USE_WIP_CODE", "")
if cmd := serviceCmd(); cmd != nil {
t.Errorf("serviceCmd() = %v, want nil when WIP code is disabled", cmd)
}
t.Setenv("TAILSCALE_USE_WIP_CODE", "1")
if cmd := serviceCmd(); cmd == nil {
t.Error("serviceCmd() = nil, want non-nil when WIP code is enabled")
}
}