mirror of
https://github.com/tailscale/tailscale.git
synced 2026-07-20 21:23:07 +08:00
cmd/tailscale/cli: add service list command
Updates #20166 Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
This commit is contained in:
parent
c57b917ac1
commit
6ab63e9a85
@ -292,6 +292,7 @@ func newRootCmd(tb ...testenv.TB) *ffcli.Command {
|
||||
sshCmd,
|
||||
nilOrCall(maybeFunnelCmd),
|
||||
nilOrCall(maybeServeCmd),
|
||||
serviceCmd(),
|
||||
versionCmd,
|
||||
nilOrCall(maybeWebCmd),
|
||||
nilOrCall(fileCmd),
|
||||
|
||||
275
cmd/tailscale/cli/service.go
Normal file
275
cmd/tailscale/cli/service.go
Normal 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, ", ")
|
||||
}
|
||||
371
cmd/tailscale/cli/service_test.go
Normal file
371
cmd/tailscale/cli/service_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user