diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index 27e7bd2a2..73c49a2ae 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -292,6 +292,7 @@ func newRootCmd(tb ...testenv.TB) *ffcli.Command { sshCmd, nilOrCall(maybeFunnelCmd), nilOrCall(maybeServeCmd), + serviceCmd(), versionCmd, nilOrCall(maybeWebCmd), nilOrCall(fileCmd), diff --git a/cmd/tailscale/cli/service.go b/cmd/tailscale/cli/service.go new file mode 100644 index 000000000..012f56db9 --- /dev/null +++ b/cmd/tailscale/cli/service.go @@ -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 +// ".". 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, ", ") +} diff --git a/cmd/tailscale/cli/service_test.go b/cmd/tailscale/cli/service_test.go new file mode 100644 index 000000000..03059ac80 --- /dev/null +++ b/cmd/tailscale/cli/service_test.go @@ -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") + } +}