cmd/tailscale/cli: add service list command
Updates #20166 Signed-off-by: Adriano Sela Aviles <adriano@tailscale.com>
This commit is contained in:
committed by
Adriano Sela Aviles
parent
c57b917ac1
commit
6ab63e9a85
@@ -292,6 +292,7 @@ change in the future.
|
|||||||
sshCmd,
|
sshCmd,
|
||||||
nilOrCall(maybeFunnelCmd),
|
nilOrCall(maybeFunnelCmd),
|
||||||
nilOrCall(maybeServeCmd),
|
nilOrCall(maybeServeCmd),
|
||||||
|
serviceCmd(),
|
||||||
versionCmd,
|
versionCmd,
|
||||||
nilOrCall(maybeWebCmd),
|
nilOrCall(maybeWebCmd),
|
||||||
nilOrCall(fileCmd),
|
nilOrCall(fileCmd),
|
||||||
|
|||||||
@@ -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, ", ")
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user