diff --git a/client/local/local.go b/client/local/local.go index e72589306..1fdcf68e5 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 list of VIP services visible to this node, +// including their names, IP addresses, and ports. +func (lc *Client) GetServices(ctx context.Context) ([]*tailcfg.ServiceDetail, error) { + body, err := lc.get200(ctx, "/localapi/v0/services") + if err != nil { + return nil, err + } + return decodeJSON[[]*tailcfg.ServiceDetail](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 @@ func newRootCmd() *ffcli.Command { 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 ", + 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 +}