cmd/tailscale/cli: [serve] rework commands based on feedback (#6521)

```
$ tailscale serve https:<port> <mount-point> <source> [off]
$ tailscale serve tcp:<port> tcp://localhost:<local-port> [off]
$ tailscale serve tls-terminated-tcp:<port> tcp://localhost:<local-port> [off]
$ tailscale serve status [--json]

$ tailscale funnel <serve-port> {on|off}
$ tailscale funnel status [--json]
```

Fixes: #6674

Signed-off-by: Shayne Sweeney <shayne@tailscale.com>
pull/7701/head
shayne 1 year ago committed by GitHub
parent 6ef2105a8e
commit 2b892ad6e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -147,6 +147,8 @@ change in the future.
switch {
case slices.Contains(args, "debug"):
rootCmd.Subcommands = append(rootCmd.Subcommands, debugCmd)
case slices.Contains(args, "funnel"):
rootCmd.Subcommands = append(rootCmd.Subcommands, funnelCmd)
case slices.Contains(args, "serve"):
rootCmd.Subcommands = append(rootCmd.Subcommands, serveCmd)
case slices.Contains(args, "update"):

@ -0,0 +1,138 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package cli
import (
"context"
"flag"
"fmt"
"net"
"os"
"strconv"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/ipn"
"tailscale.com/util/mak"
)
var funnelCmd = newFunnelCommand(&serveEnv{lc: &localClient})
// newFunnelCommand returns a new "funnel" subcommand using e as its environment.
// The funnel subcommand is used to turn on/off the Funnel service.
// Funnel is off by default.
// Funnel allows you to publish a 'tailscale serve' server publicly, open to the
// entire internet.
// newFunnelCommand shares the same serveEnv as the "serve" subcommand. See
// newServeCommand and serve.go for more details.
func newFunnelCommand(e *serveEnv) *ffcli.Command {
return &ffcli.Command{
Name: "funnel",
ShortHelp: "[ALPHA] turn Tailscale Funnel on or off",
ShortUsage: strings.TrimSpace(`
funnel <serve-port> {on|off}
funnel status [--json]
`),
LongHelp: strings.Join([]string{
"Funnel allows you to publish a 'tailscale serve'",
"server publicly, open to the entire internet.",
"",
"Turning off Funnel only turns off serving to the internet.",
"It does not affect serving to your tailnet.",
}, "\n"),
Exec: e.runFunnel,
UsageFunc: usageFunc,
Subcommands: []*ffcli.Command{
{
Name: "status",
Exec: e.runServeStatus,
ShortHelp: "show current serve/funnel status",
FlagSet: e.newFlags("funnel-status", func(fs *flag.FlagSet) {
fs.BoolVar(&e.json, "json", false, "output JSON")
}),
UsageFunc: usageFunc,
},
},
}
}
// runFunnel is the entry point for the "tailscale funnel" subcommand and
// manages turning on/off funnel. Funnel is off by default.
//
// Note: funnel is only supported on single DNS name for now. (2022-11-15)
func (e *serveEnv) runFunnel(ctx context.Context, args []string) error {
if len(args) != 2 {
return flag.ErrHelp
}
var on bool
switch args[1] {
case "on", "off":
on = args[1] == "on"
default:
return flag.ErrHelp
}
sc, err := e.lc.GetServeConfig(ctx)
if err != nil {
return err
}
if sc == nil {
sc = new(ipn.ServeConfig)
}
st, err := e.getLocalClientStatus(ctx)
if err != nil {
return fmt.Errorf("getting client status: %w", err)
}
port64, err := strconv.ParseUint(args[0], 10, 16)
if err != nil {
return err
}
port := uint16(port64)
if err := ipn.CheckFunnelAccess(port, st.Self.Capabilities); err != nil {
return err
}
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
hp := ipn.HostPort(dnsName + ":" + strconv.Itoa(int(port)))
if on == sc.AllowFunnel[hp] {
printFunnelWarning(sc)
// Nothing to do.
return nil
}
if on {
mak.Set(&sc.AllowFunnel, hp, true)
} else {
delete(sc.AllowFunnel, hp)
// clear map mostly for testing
if len(sc.AllowFunnel) == 0 {
sc.AllowFunnel = nil
}
}
if err := e.lc.SetServeConfig(ctx, sc); err != nil {
return err
}
printFunnelWarning(sc)
return nil
}
// printFunnelWarning prints a warning if the Funnel is on but there is no serve
// config for its host:port.
func printFunnelWarning(sc *ipn.ServeConfig) {
var warn bool
for hp, a := range sc.AllowFunnel {
if !a {
continue
}
_, portStr, _ := net.SplitHostPort(string(hp))
p, _ := strconv.ParseUint(portStr, 10, 16)
if _, ok := sc.TCP[uint16(p)]; !ok {
warn = true
fmt.Fprintf(os.Stderr, "Warning: funnel=on for %s, but no serve config\n", hp)
}
}
if warn {
fmt.Fprintf(os.Stderr, " run: `tailscale serve --help` to see how to configure handlers\n")
}
}

@ -35,8 +35,11 @@ func newServeCommand(e *serveEnv) *ffcli.Command {
Name: "serve",
ShortHelp: "[ALPHA] Serve from your Tailscale node",
ShortUsage: strings.TrimSpace(`
serve [flags] <mount-point> {proxy|path|text} <arg>
serve [flags] <sub-command> [sub-flags] <args>`),
serve https:<port> <mount-point> <source> [off]
serve tcp:<port> tcp://localhost:<local-port> [off]
serve tls-terminated-tcp:<port> tcp://localhost:<local-port> [off]
serve status [--json]
`),
LongHelp: strings.TrimSpace(`
*** ALPHA; all of this is subject to change ***
@ -45,68 +48,42 @@ content and local servers from your Tailscale node to
your tailnet.
You can also choose to enable the Tailscale Funnel with:
'tailscale serve funnel on'. Funnel allows you to publish
'tailscale funnel on'. Funnel allows you to publish
a 'tailscale serve' server publicly, open to the entire
internet. See https://tailscale.com/funnel.
EXAMPLES
- To proxy requests to a web server at 127.0.0.1:3000:
$ tailscale serve / proxy 3000
$ tailscale serve https:443 / http://127.0.0.1:3000
Or, using the default port:
$ tailscale serve https / http://127.0.0.1:3000
- To serve a single file or a directory of files:
$ tailscale serve / path /home/alice/blog/index.html
$ tailscale serve /images/ path /home/alice/blog/images
$ tailscale serve https / /home/alice/blog/index.html
$ tailscale serve https /images/ /home/alice/blog/images
- To serve simple static text:
$ tailscale serve / text "Hello, world!"
$ tailscale serve https:8080 / text:"Hello, world!"
- To forward raw TCP packets to a local TCP server on port 5432:
$ tailscale serve tcp:2222 tcp://localhost:22
- To forward raw, TLS-terminated TCP packets to a local TCP server on port 80:
$ tailscale serve tls-terminated-tcp:443 tcp://localhost:80
`),
Exec: e.runServe,
FlagSet: e.newFlags("serve", func(fs *flag.FlagSet) {
fs.BoolVar(&e.remove, "remove", false, "remove an existing serve config")
fs.UintVar(&e.servePort, "serve-port", 443, "port to serve on (443, 8443 or 10000)")
}),
Exec: e.runServe,
UsageFunc: usageFunc,
Subcommands: []*ffcli.Command{
{
Name: "status",
Exec: e.runServeStatus,
ShortHelp: "show current serve status",
ShortHelp: "show current serve/funnel status",
FlagSet: e.newFlags("serve-status", func(fs *flag.FlagSet) {
fs.BoolVar(&e.json, "json", false, "output JSON")
}),
UsageFunc: usageFunc,
},
{
Name: "tcp",
Exec: e.runServeTCP,
ShortHelp: "add or remove a TCP port forward",
LongHelp: strings.Join([]string{
"EXAMPLES",
" - Forward TLS over TCP to a local TCP server on port 5432:",
" $ tailscale serve tcp 5432",
"",
" - Forward raw, TLS-terminated TCP packets to a local TCP server on port 5432:",
" $ tailscale serve tcp --terminate-tls 5432",
}, "\n"),
FlagSet: e.newFlags("serve-tcp", func(fs *flag.FlagSet) {
fs.BoolVar(&e.terminateTLS, "terminate-tls", false, "terminate TLS before forwarding TCP connection")
}),
UsageFunc: usageFunc,
},
{
Name: "funnel",
Exec: e.runServeFunnel,
ShortUsage: "funnel [flags] {on|off}",
ShortHelp: "turn Tailscale Funnel on or off",
LongHelp: strings.Join([]string{
"Funnel allows you to publish a 'tailscale serve'",
"server publicly, open to the entire internet.",
"",
"Turning off Funnel only turns off serving to the internet.",
"It does not affect serving to your tailnet.",
}, "\n"),
UsageFunc: usageFunc,
},
},
}
}
@ -143,10 +120,7 @@ type localServeClient interface {
// It also contains the flags, as registered with newServeCommand.
type serveEnv struct {
// flags
servePort uint // Port to serve on. Defaults to 443.
terminateTLS bool
remove bool // remove a serve config
json bool // output JSON (status only for now)
json bool // output JSON (status only for now)
lc localServeClient // localClient interface, specific to serve
@ -186,24 +160,15 @@ func (e *serveEnv) getLocalClientStatus(ctx context.Context) (*ipnstate.Status,
return st, nil
}
// validateServePort returns --serve-port flag value,
// or an error if the port is not a valid port to serve on.
func (e *serveEnv) validateServePort() (port uint16, err error) {
// Make sure e.servePort is uint16.
port = uint16(e.servePort)
if uint(port) != e.servePort {
return 0, fmt.Errorf("serve-port %d is out of range", e.servePort)
}
return port, nil
}
// runServe is the entry point for the "serve" subcommand, managing Web
// serve config types like proxy, path, and text.
//
// Examples:
// - tailscale serve / proxy 3000
// - tailscale serve /images/ path /var/www/images/
// - tailscale --serve-port=10000 serve /motd.txt text "Hello, world!"
// - tailscale serve https / http://localhost:3000
// - tailscale serve https /images/ /var/www/images/
// - tailscale serve https:10000 /motd.txt text:"Hello, world!"
// - tailscale serve tcp:2222 tcp://localhost:22
// - tailscale serve tls-terminated-tcp:443 tcp://localhost:80
func (e *serveEnv) runServe(ctx context.Context, args []string) error {
if len(args) == 0 {
return flag.ErrHelp
@ -223,39 +188,94 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
return e.lc.SetServeConfig(ctx, sc)
}
if !(len(args) == 3 || (e.remove && len(args) >= 1)) {
fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n")
return flag.ErrHelp
parsePort := func(portStr string) (uint16, error) {
port64, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
return 0, err
}
return uint16(port64), nil
}
srvPort, err := e.validateServePort()
if err != nil {
return err
srcType, srcPortStr, found := strings.Cut(args[0], ":")
if !found {
if srcType == "https" && srcPortStr == "" {
// Default https port to 443.
srcPortStr = "443"
} else {
return flag.ErrHelp
}
}
srvPortStr := strconv.Itoa(int(srvPort))
mount, err := cleanMountPoint(args[0])
turnOff := "off" == args[len(args)-1]
if len(args) < 2 || (srcType == "https" && !turnOff && len(args) < 3) {
fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n")
return flag.ErrHelp
}
srcPort, err := parsePort(srcPortStr)
if err != nil {
return err
}
if e.remove {
return e.handleWebServeRemove(ctx, mount)
switch srcType {
case "https":
mount, err := cleanMountPoint(args[1])
if err != nil {
return err
}
if turnOff {
return e.handleWebServeRemove(ctx, srcPort, mount)
}
return e.handleWebServe(ctx, srcPort, mount, args[2])
case "tcp", "tls-terminated-tcp":
if turnOff {
return e.handleTCPServeRemove(ctx, srcPort)
}
return e.handleTCPServe(ctx, srcType, srcPort, args[1])
default:
fmt.Fprintf(os.Stderr, "error: invalid serve type %q\n", srcType)
fmt.Fprint(os.Stderr, "must be one of: https:<port>, tcp:<port> or tls-terminated-tcp:<port>\n\n", srcType)
return flag.ErrHelp
}
}
// handleWebServe handles the "tailscale serve https:..." subcommand.
// It configures the serve config to forward HTTPS connections to the
// given source.
//
// Examples:
// - tailscale serve https / http://localhost:3000
// - tailscale serve https:8443 /files/ /home/alice/shared-files/
// - tailscale serve https:10000 /motd.txt text:"Hello, world!"
func (e *serveEnv) handleWebServe(ctx context.Context, srvPort uint16, mount, source string) error {
h := new(ipn.HTTPHandler)
switch args[1] {
case "path":
ts, _, _ := strings.Cut(source, ":")
switch {
case ts == "text":
text := strings.TrimPrefix(source, "text:")
if text == "" {
return errors.New("unable to serve; text cannot be an empty string")
}
h.Text = text
case isProxyTarget(source):
t, err := expandProxyTarget(source)
if err != nil {
return err
}
h.Proxy = t
default: // assume path
if version.IsSandboxedMacOS() {
// don't allow path serving for now on macOS (2022-11-15)
return fmt.Errorf("path serving is not supported if sandboxed on macOS")
}
if !filepath.IsAbs(args[2]) {
if !filepath.IsAbs(source) {
fmt.Fprintf(os.Stderr, "error: path must be absolute\n\n")
return flag.ErrHelp
}
fi, err := os.Stat(args[2])
source = filepath.Clean(source)
fi, err := os.Stat(source)
if err != nil {
fmt.Fprintf(os.Stderr, "error: invalid path: %v\n\n", err)
return flag.ErrHelp
@ -265,21 +285,7 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
// for relative file links to work
mount += "/"
}
h.Path = args[2]
case "proxy":
t, err := expandProxyTarget(args[2])
if err != nil {
return err
}
h.Proxy = t
case "text":
if args[2] == "" {
return errors.New("unable to serve; text cannot be an empty string")
}
h.Text = args[2]
default:
fmt.Fprintf(os.Stderr, "error: unknown serve type %q\n\n", args[1])
return flag.ErrHelp
h.Path = source
}
cursc, err := e.lc.GetServeConfig(ctx)
@ -294,7 +300,7 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
if err != nil {
return err
}
hp := ipn.HostPort(net.JoinHostPort(dnsName, srvPortStr))
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
if sc.IsTCPForwardingOnPort(srvPort) {
fmt.Fprintf(os.Stderr, "error: cannot serve web; already serving TCP\n")
@ -333,12 +339,36 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
return nil
}
func (e *serveEnv) handleWebServeRemove(ctx context.Context, mount string) error {
srvPort, err := e.validateServePort()
if err != nil {
return err
// isProxyTarget reports whether source is a valid proxy target.
func isProxyTarget(source string) bool {
if strings.HasPrefix(source, "http://") ||
strings.HasPrefix(source, "https://") ||
strings.HasPrefix(source, "https+insecure://") {
return true
}
// support "localhost:3000", for example
_, portStr, ok := strings.Cut(source, ":")
if ok && allNumeric(portStr) {
return true
}
return false
}
// allNumeric reports whether s only comprises of digits
// and has at least one digit.
func allNumeric(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
srvPortStr := strconv.Itoa(int(srvPort))
return s != ""
}
// handleWebServeRemove removes a web handler from the serve config.
// The srvPort argument is the serving port and the mount argument is
// the mount point or registered path to remove.
func (e *serveEnv) handleWebServeRemove(ctx context.Context, srvPort uint16, mount string) error {
sc, err := e.lc.GetServeConfig(ctx)
if err != nil {
return err
@ -353,9 +383,9 @@ func (e *serveEnv) handleWebServeRemove(ctx context.Context, mount string) error
if sc.IsTCPForwardingOnPort(srvPort) {
return errors.New("cannot remove web handler; currently serving TCP")
}
hp := ipn.HostPort(net.JoinHostPort(dnsName, srvPortStr))
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
if !sc.WebHandlerExists(hp, mount) {
return errors.New("error: serve config does not exist")
return errors.New("error: handler does not exist")
}
// delete existing handler, then cascade delete if empty
delete(sc.Web[hp].Handlers, mount)
@ -390,18 +420,11 @@ func cleanMountPoint(mount string) (string, error) {
return "", fmt.Errorf("invalid mount point %q", mount)
}
func expandProxyTarget(target string) (string, error) {
if allNumeric(target) {
p, err := strconv.ParseUint(target, 10, 16)
if p == 0 || err != nil {
return "", fmt.Errorf("invalid port %q", target)
}
return "http://127.0.0.1:" + target, nil
func expandProxyTarget(source string) (string, error) {
if !strings.Contains(source, "://") {
source = "http://" + source
}
if !strings.Contains(target, "://") {
target = "http://" + target
}
u, err := url.ParseRequestURI(target)
u, err := url.ParseRequestURI(source)
if err != nil {
return "", fmt.Errorf("parsing url: %w", err)
}
@ -411,9 +434,14 @@ func expandProxyTarget(target string) (string, error) {
default:
return "", fmt.Errorf("must be a URL starting with http://, https://, or https+insecure://")
}
port, err := strconv.ParseUint(u.Port(), 10, 16)
if port == 0 || err != nil {
return "", fmt.Errorf("invalid port %q: %w", u.Port(), err)
}
host := u.Hostname()
switch host {
// TODO(shayne,bradfitz): do we want to do this?
case "localhost", "127.0.0.1":
host = "127.0.0.1"
default:
@ -426,16 +454,111 @@ func expandProxyTarget(target string) (string, error) {
return url, nil
}
func allNumeric(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
// handleTCPServe handles the "tailscale serve tls-terminated-tcp:..." subcommand.
// It configures the serve config to forward TCP connections to the
// given source.
//
// Examples:
// - tailscale serve tcp:2222 tcp://localhost:22
// - tailscale serve tls-terminated-tcp:8443 tcp://localhost:8080
func (e *serveEnv) handleTCPServe(ctx context.Context, srcType string, srcPort uint16, dest string) error {
var terminateTLS bool
switch srcType {
case "tcp":
terminateTLS = false
case "tls-terminated-tcp":
terminateTLS = true
default:
fmt.Fprintf(os.Stderr, "error: invalid TCP source %q\n\n", dest)
return flag.ErrHelp
}
dstURL, err := url.Parse(dest)
if err != nil {
fmt.Fprintf(os.Stderr, "error: invalid TCP source %q: %v\n\n", dest, err)
return flag.ErrHelp
}
host, dstPortStr, err := net.SplitHostPort(dstURL.Host)
if err != nil {
fmt.Fprintf(os.Stderr, "error: invalid TCP source %q: %v\n\n", dest, err)
return flag.ErrHelp
}
switch host {
case "localhost", "127.0.0.1":
// ok
default:
fmt.Fprintf(os.Stderr, "error: invalid TCP source %q\n", dest)
fmt.Fprint(os.Stderr, "must be one of: localhost or 127.0.0.1\n\n", dest)
return flag.ErrHelp
}
if p, err := strconv.ParseUint(dstPortStr, 10, 16); p == 0 || err != nil {
fmt.Fprintf(os.Stderr, "error: invalid port %q\n\n", dstPortStr)
return flag.ErrHelp
}
cursc, err := e.lc.GetServeConfig(ctx)
if err != nil {
return err
}
sc := cursc.Clone() // nil if no config
if sc == nil {
sc = new(ipn.ServeConfig)
}
fwdAddr := "127.0.0.1:" + dstPortStr
if sc.IsServingWeb(srcPort) {
return fmt.Errorf("cannot serve TCP; already serving web on %d", srcPort)
}
mak.Set(&sc.TCP, srcPort, &ipn.TCPPortHandler{TCPForward: fwdAddr})
dnsName, err := e.getSelfDNSName(ctx)
if err != nil {
return err
}
if terminateTLS {
sc.TCP[srcPort].TerminateTLS = dnsName
}
if !reflect.DeepEqual(cursc, sc) {
if err := e.lc.SetServeConfig(ctx, sc); err != nil {
return err
}
}
return s != ""
return nil
}
// handleTCPServeRemove removes the TCP forwarding configuration for the
// given srvPort, or serving port.
func (e *serveEnv) handleTCPServeRemove(ctx context.Context, src uint16) error {
cursc, err := e.lc.GetServeConfig(ctx)
if err != nil {
return err
}
sc := cursc.Clone() // nil if no config
if sc == nil {
sc = new(ipn.ServeConfig)
}
if sc.IsServingWeb(src) {
return fmt.Errorf("unable to remove; serving web, not TCP forwarding on serve port %d", src)
}
if ph := sc.GetTCPPortHandler(src); ph != nil {
delete(sc.TCP, src)
// clear map mostly for testing
if len(sc.TCP) == 0 {
sc.TCP = nil
}
return e.lc.SetServeConfig(ctx, sc)
}
return errors.New("error: serve config does not exist")
}
// runServeStatus prints the current serve config.
// runServeStatus is the entry point for the "serve status"
// subcommand and prints the current serve config.
//
// Examples:
// - tailscale status
@ -454,6 +577,7 @@ func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error {
e.stdout().Write(j)
return nil
}
printFunnelStatus(ctx)
if sc == nil || (len(sc.TCP) == 0 && len(sc.Web) == 0 && len(sc.AllowFunnel) == 0) {
printf("No serve config\n")
return nil
@ -472,17 +596,7 @@ func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error {
printWebStatusTree(sc, hp)
printf("\n")
}
// warn when funnel on without handlers
for hp, a := range sc.AllowFunnel {
if !a {
continue
}
_, portStr, _ := net.SplitHostPort(string(hp))
p, _ := strconv.ParseUint(portStr, 10, 16)
if _, ok := sc.TCP[uint16(p)]; !ok {
printf("WARNING: funnel=on for %s, but no serve config\n", hp)
}
}
printFunnelWarning(sc)
return nil
}
@ -566,133 +680,3 @@ func elipticallyTruncate(s string, max int) string {
}
return s[:max-3] + "..."
}
// runServeTCP is the entry point for the "serve tcp" subcommand and
// manages the serve config for TCP forwarding.
//
// Examples:
// - tailscale serve tcp 5432
// - tailscale serve --serve-port=8443 tcp 4430
// - tailscale serve --serve-port=10000 tcp --terminate-tls 8080
func (e *serveEnv) runServeTCP(ctx context.Context, args []string) error {
if len(args) != 1 {
fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n")
return flag.ErrHelp
}
srvPort, err := e.validateServePort()
if err != nil {
return err
}
portStr := args[0]
p, err := strconv.ParseUint(portStr, 10, 16)
if p == 0 || err != nil {
fmt.Fprintf(os.Stderr, "error: invalid port %q\n\n", portStr)
}
cursc, err := e.lc.GetServeConfig(ctx)
if err != nil {
return err
}
sc := cursc.Clone() // nil if no config
if sc == nil {
sc = new(ipn.ServeConfig)
}
fwdAddr := "127.0.0.1:" + portStr
if sc.IsServingWeb(srvPort) {
if e.remove {
return fmt.Errorf("unable to remove; serving web, not TCP forwarding on serve port %d", srvPort)
}
return fmt.Errorf("cannot serve TCP; already serving web on %d", srvPort)
}
if e.remove {
if ph := sc.GetTCPPortHandler(srvPort); ph != nil && ph.TCPForward == fwdAddr {
delete(sc.TCP, srvPort)
// clear map mostly for testing
if len(sc.TCP) == 0 {
sc.TCP = nil
}
return e.lc.SetServeConfig(ctx, sc)
}
return errors.New("error: serve config does not exist")
}
mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{TCPForward: fwdAddr})
dnsName, err := e.getSelfDNSName(ctx)
if err != nil {
return err
}
if e.terminateTLS {
sc.TCP[srvPort].TerminateTLS = dnsName
}
if !reflect.DeepEqual(cursc, sc) {
if err := e.lc.SetServeConfig(ctx, sc); err != nil {
return err
}
}
return nil
}
// runServeFunnel is the entry point for the "serve funnel" subcommand and
// manages turning on/off funnel. Funnel is off by default.
//
// Note: funnel is only supported on single DNS name for now. (2022-11-15)
func (e *serveEnv) runServeFunnel(ctx context.Context, args []string) error {
if len(args) != 1 {
return flag.ErrHelp
}
srvPort, err := e.validateServePort()
if err != nil {
return err
}
srvPortStr := strconv.Itoa(int(srvPort))
var on bool
switch args[0] {
case "on", "off":
on = args[0] == "on"
default:
return flag.ErrHelp
}
sc, err := e.lc.GetServeConfig(ctx)
if err != nil {
return err
}
if sc == nil {
sc = new(ipn.ServeConfig)
}
st, err := e.getLocalClientStatus(ctx)
if err != nil {
return fmt.Errorf("getting client status: %w", err)
}
if err := ipn.CheckFunnelAccess(srvPort, st.Self.Capabilities); err != nil {
return err
}
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
hp := ipn.HostPort(dnsName + ":" + srvPortStr)
if on == sc.AllowFunnel[hp] {
// Nothing to do.
return nil
}
if on {
mak.Set(&sc.AllowFunnel, hp, true)
} else {
delete(sc.AllowFunnel, hp)
// clear map mostly for testing
if len(sc.AllowFunnel) == 0 {
sc.AllowFunnel = nil
}
}
if err := e.lc.SetServeConfig(ctx, sc); err != nil {
return err
}
return nil
}

@ -15,6 +15,7 @@ import (
"strings"
"testing"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
@ -56,6 +57,8 @@ func TestServeConfigMutations(t *testing.T) {
want *ipn.ServeConfig // non-nil means we want a save of this value
wantErr func(error) (badErrMsg string) // nil means no error is wanted
line int // line number of addStep call, for error messages
debugBreak func()
}
var steps []step
add := func(s step) {
@ -66,19 +69,19 @@ func TestServeConfigMutations(t *testing.T) {
// funnel
add(step{reset: true})
add(step{
command: cmd("funnel on"),
command: cmd("funnel 443 on"),
want: &ipn.ServeConfig{AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true}},
})
add(step{
command: cmd("funnel on"),
command: cmd("funnel 443 on"),
want: nil, // nothing to save
})
add(step{
command: cmd("funnel off"),
command: cmd("funnel 443 off"),
want: &ipn.ServeConfig{},
})
add(step{
command: cmd("funnel off"),
command: cmd("funnel 443 off"),
want: nil, // nothing to save
})
add(step{
@ -89,27 +92,48 @@ func TestServeConfigMutations(t *testing.T) {
// https
add(step{reset: true})
add(step{
command: cmd("/ proxy 0"), // invalid port, too low
command: cmd("https:443 / http://localhost:0"), // invalid port, too low
wantErr: anyErr(),
})
add(step{
command: cmd("/ proxy 65536"), // invalid port, too high
command: cmd("https:443 / http://localhost:65536"), // invalid port, too high
wantErr: anyErr(),
})
add(step{
command: cmd("/ proxy somehost"), // invalid host
command: cmd("https:443 / http://somehost:3000"), // invalid host
wantErr: anyErr(),
})
add(step{
command: cmd("/ proxy http://otherhost"), // invalid host
command: cmd("https:443 / httpz://127.0.0.1"), // invalid scheme
wantErr: anyErr(),
})
add(step{
command: cmd("/ proxy httpz://127.0.0.1"), // invalid scheme
wantErr: anyErr(),
add(step{ // allow omitting port (default to 443)
command: cmd("https / http://localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
},
},
})
add(step{ // support non Funnel port
command: cmd("https:9999 /abc http://localhost:3001"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 9999: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/": {Proxy: "http://127.0.0.1:3000"},
}},
"foo.test.ts.net:9999": {Handlers: map[string]*ipn.HTTPHandler{
"/abc": {Proxy: "http://127.0.0.1:3001"},
}},
},
},
})
add(step{
command: cmd("/ proxy 3000"),
command: cmd("https:9999 /abc off"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
@ -120,7 +144,7 @@ func TestServeConfigMutations(t *testing.T) {
},
})
add(step{
command: cmd("--serve-port=8443 /abc proxy 3001"),
command: cmd("https:8443 /abc http://127.0.0.1:3001"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
@ -134,7 +158,7 @@ func TestServeConfigMutations(t *testing.T) {
},
})
add(step{
command: cmd("--serve-port=10000 / text hi"),
command: cmd("https:10000 / text:hi"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {HTTPS: true}, 8443: {HTTPS: true}, 10000: {HTTPS: true}},
@ -152,12 +176,12 @@ func TestServeConfigMutations(t *testing.T) {
},
})
add(step{
command: cmd("--remove /foo"),
command: cmd("https:443 /foo off"),
want: nil, // nothing to save
wantErr: anyErr(),
}) // handler doesn't exist, so we get an error
add(step{
command: cmd("--remove --serve-port=10000 /"),
command: cmd("https:10000 / off"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
@ -171,7 +195,7 @@ func TestServeConfigMutations(t *testing.T) {
},
})
add(step{
command: cmd("--remove /"),
command: cmd("https:443 / off"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
@ -182,11 +206,11 @@ func TestServeConfigMutations(t *testing.T) {
},
})
add(step{
command: cmd("--remove --serve-port=8443 /abc"),
command: cmd("https:8443 /abc off"),
want: &ipn.ServeConfig{},
})
add(step{
command: cmd("bar proxy https://127.0.0.1:8443"),
add(step{ // clean mount: "bar" becomes "/bar"
command: cmd("https:443 bar https://127.0.0.1:8443"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
@ -197,12 +221,12 @@ func TestServeConfigMutations(t *testing.T) {
},
})
add(step{
command: cmd("bar proxy https://127.0.0.1:8443"),
command: cmd("https:443 bar https://127.0.0.1:8443"),
want: nil, // nothing to save
})
add(step{reset: true})
add(step{
command: cmd("/ proxy https+insecure://127.0.0.1:3001"),
command: cmd("https:443 / https+insecure://127.0.0.1:3001"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
@ -214,7 +238,7 @@ func TestServeConfigMutations(t *testing.T) {
})
add(step{reset: true})
add(step{
command: cmd("/foo proxy localhost:3000"),
command: cmd("https:443 /foo localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
@ -225,7 +249,7 @@ func TestServeConfigMutations(t *testing.T) {
},
})
add(step{ // test a second handler on the same port
command: cmd("--serve-port=8443 /foo proxy localhost:3000"),
command: cmd("https:8443 /foo localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
@ -241,16 +265,35 @@ func TestServeConfigMutations(t *testing.T) {
// tcp
add(step{reset: true})
add(step{ // must include scheme for tcp
command: cmd("tls-terminated-tcp:443 localhost:5432"),
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
})
add(step{ // !somehost, must be localhost or 127.0.0.1
command: cmd("tls-terminated-tcp:443 tcp://somehost:5432"),
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
})
add(step{ // bad target port, too low
command: cmd("tls-terminated-tcp:443 tcp://somehost:0"),
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
})
add(step{ // bad target port, too high
command: cmd("tls-terminated-tcp:443 tcp://somehost:65536"),
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
})
add(step{
command: cmd("tcp 5432"),
command: cmd("tls-terminated-tcp:443 tcp://localhost:5432"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {TCPForward: "127.0.0.1:5432"},
443: {
TCPForward: "127.0.0.1:5432",
TerminateTLS: "foo.test.ts.net",
},
},
},
})
add(step{
command: cmd("tcp -terminate-tls 8443"),
command: cmd("tls-terminated-tcp:443 tcp://127.0.0.1:8443"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {
@ -261,11 +304,11 @@ func TestServeConfigMutations(t *testing.T) {
},
})
add(step{
command: cmd("tcp -terminate-tls 8443"),
command: cmd("tls-terminated-tcp:443 tcp://127.0.0.1:8443"),
want: nil, // nothing to save
})
add(step{
command: cmd("tcp --terminate-tls 8444"),
command: cmd("tls-terminated-tcp:443 tcp://localhost:8444"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {
@ -276,35 +319,41 @@ func TestServeConfigMutations(t *testing.T) {
},
})
add(step{
command: cmd("tcp -terminate-tls=false 8445"),
command: cmd("tls-terminated-tcp:443 tcp://127.0.0.1:8445"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {TCPForward: "127.0.0.1:8445"},
443: {
TCPForward: "127.0.0.1:8445",
TerminateTLS: "foo.test.ts.net",
},
},
},
})
add(step{reset: true})
add(step{
command: cmd("tcp 123"),
command: cmd("tls-terminated-tcp:443 tcp://localhost:123"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {TCPForward: "127.0.0.1:123"},
443: {
TCPForward: "127.0.0.1:123",
TerminateTLS: "foo.test.ts.net",
},
},
},
})
add(step{
command: cmd("--remove tcp 321"),
add(step{ // handler doesn't exist, so we get an error
command: cmd("tls-terminated-tcp:8443 off"),
wantErr: anyErr(),
}) // handler doesn't exist, so we get an error
})
add(step{
command: cmd("--remove tcp 123"),
command: cmd("tls-terminated-tcp:443 off"),
want: &ipn.ServeConfig{},
})
// text
add(step{reset: true})
add(step{
command: cmd("/ text hello"),
command: cmd("https:443 / text:hello"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
@ -325,7 +374,7 @@ func TestServeConfigMutations(t *testing.T) {
add(step{reset: true})
writeFile("foo", "this is foo")
add(step{
command: cmd("/ path " + filepath.Join(td, "foo")),
command: cmd("https:443 / " + filepath.Join(td, "foo")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
@ -338,7 +387,7 @@ func TestServeConfigMutations(t *testing.T) {
os.MkdirAll(filepath.Join(td, "subdir"), 0700)
writeFile("subdir/file-a", "this is A")
add(step{
command: cmd("/some/where path " + filepath.Join(td, "subdir/file-a")),
command: cmd("https:443 /some/where " + filepath.Join(td, "subdir/file-a")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
@ -349,13 +398,13 @@ func TestServeConfigMutations(t *testing.T) {
},
},
})
add(step{
command: cmd("/ path missing"),
add(step{ // bad path
command: cmd("https:443 / bad/path"),
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
})
add(step{reset: true})
add(step{
command: cmd("/ path " + filepath.Join(td, "subdir")),
command: cmd("https:443 / " + filepath.Join(td, "subdir")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
@ -366,14 +415,14 @@ func TestServeConfigMutations(t *testing.T) {
},
})
add(step{
command: cmd("--remove /"),
command: cmd("https:443 / off"),
want: &ipn.ServeConfig{},
})
// combos
add(step{reset: true})
add(step{
command: cmd("/ proxy 3000"),
command: cmd("https:443 / localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
@ -384,7 +433,7 @@ func TestServeConfigMutations(t *testing.T) {
},
})
add(step{
command: cmd("funnel on"),
command: cmd("funnel 443 on"),
want: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true},
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
@ -396,7 +445,7 @@ func TestServeConfigMutations(t *testing.T) {
},
})
add(step{ // serving on secondary port doesn't change funnel
command: cmd("--serve-port=8443 /bar proxy 3001"),
command: cmd("https:8443 /bar localhost:3001"),
want: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true},
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
@ -411,7 +460,7 @@ func TestServeConfigMutations(t *testing.T) {
},
})
add(step{ // turn funnel on for secondary port
command: cmd("--serve-port=8443 funnel on"),
command: cmd("funnel 8443 on"),
want: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:443": true, "foo.test.ts.net:8443": true},
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
@ -426,7 +475,7 @@ func TestServeConfigMutations(t *testing.T) {
},
})
add(step{ // turn funnel off for primary port 443
command: cmd("funnel off"),
command: cmd("funnel 443 off"),
want: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {HTTPS: true}},
@ -441,7 +490,7 @@ func TestServeConfigMutations(t *testing.T) {
},
})
add(step{ // remove secondary port
command: cmd("--serve-port=8443 --remove /bar"),
command: cmd("https:8443 /bar off"),
want: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
@ -453,7 +502,7 @@ func TestServeConfigMutations(t *testing.T) {
},
})
add(step{ // start a tcp forwarder on 8443
command: cmd("--serve-port=8443 tcp 5432"),
command: cmd("tcp:8443 tcp://localhost:5432"),
want: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}, 8443: {TCPForward: "127.0.0.1:5432"}},
@ -465,27 +514,27 @@ func TestServeConfigMutations(t *testing.T) {
},
})
add(step{ // remove primary port http handler
command: cmd("--remove /"),
command: cmd("https:443 / off"),
want: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
TCP: map[uint16]*ipn.TCPPortHandler{8443: {TCPForward: "127.0.0.1:5432"}},
},
})
add(step{ // remove tcp forwarder
command: cmd("--serve-port=8443 --remove tcp 5432"),
command: cmd("tls-terminated-tcp:8443 off"),
want: &ipn.ServeConfig{
AllowFunnel: map[ipn.HostPort]bool{"foo.test.ts.net:8443": true},
},
})
add(step{ // turn off funnel
command: cmd("--serve-port=8443 funnel off"),
command: cmd("funnel 8443 off"),
want: &ipn.ServeConfig{},
})
// tricky steps
add(step{reset: true})
add(step{ // a directory with a trailing slash mount point
command: cmd("/dir path " + filepath.Join(td, "subdir")),
command: cmd("https:443 /dir " + filepath.Join(td, "subdir")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
@ -496,7 +545,7 @@ func TestServeConfigMutations(t *testing.T) {
},
})
add(step{ // this should overwrite the previous one
command: cmd("/dir path " + filepath.Join(td, "foo")),
command: cmd("https:443 /dir " + filepath.Join(td, "foo")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
@ -508,7 +557,7 @@ func TestServeConfigMutations(t *testing.T) {
})
add(step{reset: true}) // reset and do the opposite
add(step{ // a file without a trailing slash mount point
command: cmd("/dir path " + filepath.Join(td, "foo")),
command: cmd("https:443 /dir " + filepath.Join(td, "foo")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
@ -519,7 +568,7 @@ func TestServeConfigMutations(t *testing.T) {
},
})
add(step{ // this should overwrite the previous one
command: cmd("/dir path " + filepath.Join(td, "subdir")),
command: cmd("https:443 /dir " + filepath.Join(td, "subdir")),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
@ -532,37 +581,24 @@ func TestServeConfigMutations(t *testing.T) {
// error states
add(step{reset: true})
add(step{ // make sure we can't add "tcp" as if it was a mount
command: cmd("tcp text foo"),
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
})
add(step{ // "/tcp" is fine though as a mount
command: cmd("/tcp text foo"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
"foo.test.ts.net:443": {Handlers: map[string]*ipn.HTTPHandler{
"/tcp": {Text: "foo"},
}},
},
},
})
add(step{reset: true})
add(step{ // tcp forward 5432 on serve port 443
command: cmd("tcp 5432"),
command: cmd("tls-terminated-tcp:443 tcp://localhost:5432"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{
443: {TCPForward: "127.0.0.1:5432"},
443: {
TCPForward: "127.0.0.1:5432",
TerminateTLS: "foo.test.ts.net",
},
},
},
})
add(step{ // try to start a web handler on the same port
command: cmd("/ proxy 3000"),
command: cmd("https:443 / localhost:3000"),
wantErr: exactErr(flag.ErrHelp, "flag.ErrHelp"),
})
add(step{reset: true})
add(step{ // start a web handler on port 443
command: cmd("/ proxy 3000"),
command: cmd("https:443 / localhost:3000"),
want: &ipn.ServeConfig{
TCP: map[uint16]*ipn.TCPPortHandler{443: {HTTPS: true}},
Web: map[ipn.HostPort]*ipn.WebServerConfig{
@ -572,14 +608,17 @@ func TestServeConfigMutations(t *testing.T) {
},
},
})
add(step{ // try to start a tcp forwarder on the same serve port (443 default)
command: cmd("tcp 5432"),
add(step{ // try to start a tcp forwarder on the same serve port
command: cmd("tls-terminated-tcp:443 tcp://localhost:5432"),
wantErr: anyErr(),
})
lc := &fakeLocalServeClient{}
// And now run the steps above.
for i, st := range steps {
if st.debugBreak != nil {
st.debugBreak()
}
if st.reset {
t.Logf("Executing step #%d, line %v: [reset]", i, st.line)
lc.config = nil
@ -597,8 +636,16 @@ func TestServeConfigMutations(t *testing.T) {
testStdout: &stdout,
}
lastCount := lc.setCount
cmd := newServeCommand(e)
err := cmd.ParseAndRun(context.Background(), st.command)
var cmd *ffcli.Command
var args []string
if st.command[0] == "funnel" {
cmd = newFunnelCommand(e)
args = st.command[1:]
} else {
cmd = newServeCommand(e)
args = st.command
}
err := cmd.ParseAndRun(context.Background(), args)
if flagOut.Len() > 0 {
t.Logf("flag package output: %q", flagOut.Bytes())
}
@ -689,7 +736,5 @@ func anyErr() func(error) string {
}
func cmd(s string) []string {
cmds := strings.Fields(s)
fmt.Printf("cmd: %v", cmds)
return cmds
return strings.Fields(s)
}

@ -258,6 +258,7 @@ func printFunnelStatus(ctx context.Context) {
}
printf("# - %s\n", url)
}
outln()
}
// isRunningOrStarting reports whether st is in state Running or Starting.

Loading…
Cancel
Save