You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tailscale/cmd/tailscale/cli/serve_dev.go

810 lines
22 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package cli
import (
"context"
"errors"
"flag"
"fmt"
"log"
"net"
"net/url"
"os"
"os/signal"
"path"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
"github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/util/mak"
"tailscale.com/version"
)
type execFunc func(ctx context.Context, args []string) error
type commandInfo struct {
Name string
ShortHelp string
LongHelp string
}
var serveHelpCommon = strings.TrimSpace(`
<target> can be a port number (e.g., 3000), a partial URL (e.g., localhost:3000), or a
full URL including a path (e.g., http://localhost:3000/foo, https+insecure://localhost:3000/foo).
EXAMPLES
- Mount a local web server at 127.0.0.1:3000 in the foreground:
$ tailscale %s localhost:3000
- Mount a local web server at 127.0.0.1:3000 in the background:
$ tailscale %s --bg localhost:3000
For more examples and use cases visit our docs site https://tailscale.com/kb/1247/funnel-serve-use-cases
`)
type serveMode int
const (
serve serveMode = iota
funnel
)
type serveType int
const (
serveTypeHTTPS serveType = iota
serveTypeHTTP
serveTypeTCP
serveTypeTLSTerminatedTCP
)
var infoMap = map[serveMode]commandInfo{
serve: {
Name: "serve",
ShortHelp: "Serve content and local servers on your tailnet",
LongHelp: strings.Join([]string{
"Serve enables you to share a local server securely within your tailnet.\n",
"To share a local server on the internet, use `tailscale funnel`\n\n",
}, "\n"),
},
funnel: {
Name: "funnel",
ShortHelp: "Serve content and local servers on the internet",
LongHelp: strings.Join([]string{
"Funnel enables you to share a local server on the internet using Tailscale.\n",
"To share only within your tailnet, use `tailscale serve`\n\n",
}, "\n"),
},
}
func buildShortUsage(subcmd string) string {
return strings.Join([]string{
subcmd + " [flags] <target> [off]",
subcmd + " status [--json]",
subcmd + " reset",
}, "\n ")
}
// newServeDevCommand returns a new "serve" subcommand using e as its environment.
func newServeDevCommand(e *serveEnv, subcmd serveMode) *ffcli.Command {
if subcmd != serve && subcmd != funnel {
log.Fatalf("newServeDevCommand called with unknown subcmd %q", subcmd)
}
info := infoMap[subcmd]
return &ffcli.Command{
Name: info.Name,
ShortHelp: info.ShortHelp,
ShortUsage: strings.Join([]string{
fmt.Sprintf("%s <target>", info.Name),
fmt.Sprintf("%s status [--json]", info.Name),
fmt.Sprintf("%s reset", info.Name),
}, "\n "),
LongHelp: info.LongHelp + fmt.Sprintf(strings.TrimSpace(serveHelpCommon), info.Name, info.Name),
Exec: e.runServeCombined(subcmd),
FlagSet: e.newFlags("serve-set", func(fs *flag.FlagSet) {
fs.BoolVar(&e.bg, "bg", false, "run the command in the background")
fs.StringVar(&e.setPath, "set-path", "", "set a path for a specific target and run in the background")
fs.StringVar(&e.https, "https", "", "default; HTTPS listener")
fs.StringVar(&e.http, "http", "", "HTTP listener")
fs.StringVar(&e.tcp, "tcp", "", "TCP listener")
fs.StringVar(&e.tlsTerminatedTCP, "tls-terminated-tcp", "", "TLS terminated TCP listener")
}),
UsageFunc: usageFunc,
Subcommands: []*ffcli.Command{
{
Name: "status",
Exec: e.runServeStatus,
ShortHelp: "view current proxy configuration",
FlagSet: e.newFlags("serve-status", func(fs *flag.FlagSet) {
fs.BoolVar(&e.json, "json", false, "output JSON")
}),
UsageFunc: usageFunc,
},
{
Name: "reset",
ShortHelp: "reset current serve/funnel config",
Exec: e.runServeReset,
FlagSet: e.newFlags("serve-reset", nil),
UsageFunc: usageFunc,
},
},
}
}
func validateArgs(subcmd serveMode, args []string) error {
switch len(args) {
case 0:
return flag.ErrHelp
case 1, 2:
if isLegacyInvocation(subcmd, args) {
fmt.Fprintf(os.Stderr, "error: the CLI for serve and funnel has changed.")
fmt.Fprintf(os.Stderr, "Please see https://tailscale.com/kb/1242/tailscale-serve for more information.")
return errHelp
}
default:
fmt.Fprintf(os.Stderr, "error: invalid number of arguments (%d)", len(args))
return errHelp
}
return nil
}
// runServeCombined is the entry point for the "tailscale {serve,funnel}" commands.
func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc {
e.subcmd = subcmd
return func(ctx context.Context, args []string) error {
if err := validateArgs(subcmd, args); err != nil {
return err
}
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
defer cancel()
st, err := e.getLocalClientStatusWithoutPeers(ctx)
if err != nil {
return fmt.Errorf("getting client status: %w", err)
}
funnel := subcmd == funnel
if funnel {
// verify node has funnel capabilities
if err := e.verifyFunnelEnabled(ctx, st, 443); err != nil {
return err
}
}
mount, err := cleanURLPath(e.setPath)
if err != nil {
return fmt.Errorf("failed to clean the mount point: %w", err)
}
if e.setPath != "" {
// TODO(marwan-at-work): either
// 1. Warn the user that this is a side effect.
// 2. Force the user to pass --bg
// 3. Allow set-path to be in the foreground.
e.bg = true
}
srvType, srvPort, err := srvTypeAndPortFromFlags(e)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n\n", err)
return errHelp
}
sc, err := e.lc.GetServeConfig(ctx)
if err != nil {
return fmt.Errorf("error getting serve config: %w", err)
}
// nil if no config
if sc == nil {
sc = new(ipn.ServeConfig)
}
dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
// set parent serve config to always be persisted
// at the top level, but a nested config might be
// the one that gets manipulated depending on
// foreground or background.
parentSC := sc
turnOff := "off" == args[len(args)-1]
if !turnOff && srvType == serveTypeHTTPS {
// Running serve with https requires that the tailnet has enabled
// https cert provisioning. Send users through an interactive flow
// to enable this if not already done.
//
// TODO(sonia,tailscale/corp#10577): The interactive feature flow
// is behind a control flag. If the tailnet doesn't have the flag
// on, enableFeatureInteractive will error. For now, we hide that
// error and maintain the previous behavior (prior to 2023-08-15)
// of letting them edit the serve config before enabling certs.
if err := e.enableFeatureInteractive(ctx, "serve", func(caps []tailcfg.NodeCapability) bool {
return slices.Contains(caps, tailcfg.CapabilityHTTPS)
}); err != nil {
return fmt.Errorf("error enabling https feature: %w", err)
}
}
var watcher *tailscale.IPNBusWatcher
if !e.bg && !turnOff {
// if foreground mode, create a WatchIPNBus session
// and use the nested config for all following operations
// TODO(marwan-at-work): nested-config validations should happen here or previous to this point.
watcher, err = e.lc.WatchIPNBus(ctx, ipn.NotifyInitialState)
if err != nil {
return err
}
defer watcher.Close()
n, err := watcher.Next()
if err != nil {
return err
}
if n.SessionID == "" {
return errors.New("missing SessionID")
}
fsc := &ipn.ServeConfig{}
mak.Set(&sc.Foreground, n.SessionID, fsc)
sc = fsc
}
var msg string
if turnOff {
err = e.unsetServe(sc, dnsName, srvType, srvPort, mount)
} else {
if err := validateConfig(parentSC, srvPort, srvType); err != nil {
return err
}
err = e.setServe(sc, st, dnsName, srvType, srvPort, mount, args[0], funnel)
msg = e.messageForPort(sc, st, dnsName, srvPort)
}
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n\n", err)
return errHelp
}
if err := e.lc.SetServeConfig(ctx, parentSC); err != nil {
if tailscale.IsPreconditionsFailedError(err) {
fmt.Fprintln(os.Stderr, "Another client is changing the serve config; please try again.")
}
return err
}
if msg != "" {
fmt.Fprintln(os.Stderr, msg)
}
if watcher != nil {
for {
_, err = watcher.Next()
if err != nil {
if errors.Is(err, context.Canceled) {
return nil
}
return err
}
}
}
return nil
}
}
func validateConfig(sc *ipn.ServeConfig, port uint16, wantServe serveType) error {
sc, isFg := findConfig(sc, port)
if sc == nil {
return nil
}
if isFg {
return errors.New("foreground already exists under this port")
}
existingServe := serveFromPortHandler(sc.TCP[port])
if wantServe != existingServe {
return fmt.Errorf("want %q but port is already serving %q", wantServe, existingServe)
}
return nil
}
func serveFromPortHandler(tcp *ipn.TCPPortHandler) serveType {
switch {
case tcp.HTTP:
return serveTypeHTTP
case tcp.HTTPS:
return serveTypeHTTPS
case tcp.TerminateTLS != "":
return serveTypeTLSTerminatedTCP
case tcp.TCPForward != "":
return serveTypeTCP
default:
return -1
}
}
// findConfig finds a config that contains the given port, which can be
// the top level background config or an inner foreground one. The second
// result is true if it's foreground
func findConfig(sc *ipn.ServeConfig, port uint16) (*ipn.ServeConfig, bool) {
if sc == nil {
return nil, false
}
if _, ok := sc.TCP[port]; ok {
return sc, false
}
for _, sc := range sc.Foreground {
if _, ok := sc.TCP[port]; ok {
return sc, true
}
}
return nil, false
}
func (e *serveEnv) setServe(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName string, srvType serveType, srvPort uint16, mount string, target string, allowFunnel bool) error {
// update serve config based on the type
switch srvType {
case serveTypeHTTPS, serveTypeHTTP:
useTLS := srvType == serveTypeHTTPS
err := e.applyWebServe(sc, dnsName, srvPort, useTLS, mount, target)
if err != nil {
return fmt.Errorf("failed apply web serve: %w", err)
}
case serveTypeTCP, serveTypeTLSTerminatedTCP:
err := e.applyTCPServe(sc, dnsName, srvType, srvPort, target)
if err != nil {
return fmt.Errorf("failed to apply TCP serve: %w", err)
}
default:
return fmt.Errorf("invalid type %q", srvType)
}
// update the serve config based on if funnel is enabled
e.applyFunnel(sc, dnsName, srvPort, allowFunnel)
return nil
}
// messageForPort returns a message for the given port based on the
// serve config and status.
func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName string, srvPort uint16) string {
var output strings.Builder
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
if sc.AllowFunnel[hp] == true {
output.WriteString("Available on the internet:\n")
} else {
output.WriteString("Available within your tailnet:\n")
}
scheme := "https"
if sc.IsServingHTTP(srvPort) {
scheme = "http"
}
portPart := ":" + fmt.Sprint(srvPort)
if scheme == "http" && srvPort == 80 ||
scheme == "https" && srvPort == 443 {
portPart = ""
}
output.WriteString(fmt.Sprintf("%s://%s%s\n\n", scheme, dnsName, portPart))
if !e.bg {
output.WriteString("Press Ctrl+C to exit.")
return output.String()
}
srvTypeAndDesc := func(h *ipn.HTTPHandler) (string, string) {
switch {
case h.Path != "":
return "path", h.Path
case h.Proxy != "":
return "proxy", h.Proxy
case h.Text != "":
return "text", "\"" + elipticallyTruncate(h.Text, 20) + "\""
}
return "", ""
}
if sc.Web[hp] != nil {
var mounts []string
for k := range sc.Web[hp].Handlers {
mounts = append(mounts, k)
}
sort.Slice(mounts, func(i, j int) bool {
return len(mounts[i]) < len(mounts[j])
})
maxLen := len(mounts[len(mounts)-1])
for _, m := range mounts {
h := sc.Web[hp].Handlers[m]
t, d := srvTypeAndDesc(h)
output.WriteString(fmt.Sprintf("%s %s%s %-5s %s\n", "|--", m, strings.Repeat(" ", maxLen-len(m)), t, d))
}
} else if sc.TCP[srvPort] != nil {
h := sc.TCP[srvPort]
tlsStatus := "TLS over TCP"
if h.TerminateTLS != "" {
tlsStatus = "TLS terminated"
}
output.WriteString(fmt.Sprintf("|-- tcp://%s (%s)\n", hp, tlsStatus))
for _, a := range st.TailscaleIPs {
ipp := net.JoinHostPort(a.String(), strconv.Itoa(int(srvPort)))
output.WriteString(fmt.Sprintf("|-- tcp://%s\n", ipp))
}
output.WriteString(fmt.Sprintf("|--> tcp://%s\n", h.TCPForward))
}
output.WriteString("\nServe started and running in the background.\n")
output.WriteString(fmt.Sprintf("To disable the proxy, run: tailscale %s off", infoMap[e.subcmd].Name))
return output.String()
}
func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort uint16, useTLS bool, mount, target string) error {
h := new(ipn.HTTPHandler)
switch {
case strings.HasPrefix(target, "text:"):
text := strings.TrimPrefix(target, "text:")
if text == "" {
return errors.New("unable to serve; text cannot be an empty string")
}
h.Text = text
case filepath.IsAbs(target):
if version.IsSandboxedMacOS() {
// don't allow path serving for now on macOS (2022-11-15)
return errors.New("path serving is not supported if sandboxed on macOS")
}
target = filepath.Clean(target)
fi, err := os.Stat(target)
if err != nil {
return errors.New("invalid path")
}
// TODO: need to understand this further
if fi.IsDir() && !strings.HasSuffix(mount, "/") {
// dir mount points must end in /
// for relative file links to work
mount += "/"
}
h.Path = target
default:
t, err := expandProxyTargetDev(target)
if err != nil {
return err
}
h.Proxy = t
}
// TODO: validation needs to check nested foreground configs
if sc.IsTCPForwardingOnPort(srvPort) {
return errors.New("cannot serve web; already serving TCP")
}
mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS})
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
if _, ok := sc.Web[hp]; !ok {
mak.Set(&sc.Web, hp, new(ipn.WebServerConfig))
}
mak.Set(&sc.Web[hp].Handlers, mount, h)
// TODO: handle multiple web handlers from foreground mode
for k, v := range sc.Web[hp].Handlers {
if v == h {
continue
}
// If the new mount point ends in / and another mount point
// shares the same prefix, remove the other handler.
// (e.g. /foo/ overwrites /foo)
// The opposite example is also handled.
m1 := strings.TrimSuffix(mount, "/")
m2 := strings.TrimSuffix(k, "/")
if m1 == m2 {
delete(sc.Web[hp].Handlers, k)
}
}
return nil
}
func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType serveType, srcPort uint16, target string) error {
var terminateTLS bool
switch srcType {
case serveTypeTCP:
terminateTLS = false
case serveTypeTLSTerminatedTCP:
terminateTLS = true
default:
return fmt.Errorf("invalid TCP target %q", target)
}
dstURL, err := url.Parse(target)
if err != nil {
return fmt.Errorf("invalid TCP target %q: %v", target, err)
}
host, dstPortStr, err := net.SplitHostPort(dstURL.Host)
if err != nil {
return fmt.Errorf("invalid TCP target %q: %v", target, err)
}
switch host {
case "localhost", "127.0.0.1":
// ok
default:
return fmt.Errorf("invalid TCP target %q, must be one of localhost or 127.0.0.1", target)
}
if p, err := strconv.ParseUint(dstPortStr, 10, 16); p == 0 || err != nil {
return fmt.Errorf("invalid port %q", dstPortStr)
}
fwdAddr := "127.0.0.1:" + dstPortStr
// TODO: needs to account for multiple configs from foreground mode
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})
if terminateTLS {
sc.TCP[srcPort].TerminateTLS = dnsName
}
return nil
}
func (e *serveEnv) applyFunnel(sc *ipn.ServeConfig, dnsName string, srvPort uint16, allowFunnel bool) {
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
// TODO: Should we return an error? Should not be possible.
// nil if no config
if sc == nil {
sc = new(ipn.ServeConfig)
}
// TODO: should ensure there is no other conflicting funnel
// TODO: add error handling for if toggling for existing sc
if allowFunnel {
mak.Set(&sc.AllowFunnel, hp, true)
}
}
// unsetServe removes the serve config for the given serve port.
func (e *serveEnv) unsetServe(sc *ipn.ServeConfig, dnsName string, srvType serveType, srvPort uint16, mount string) error {
switch srvType {
case serveTypeHTTPS, serveTypeHTTP:
err := e.removeWebServe(sc, dnsName, srvPort, mount)
if err != nil {
return fmt.Errorf("failed to remove web serve: %w", err)
}
case serveTypeTCP, serveTypeTLSTerminatedTCP:
err := e.removeTCPServe(sc, srvPort)
if err != nil {
return fmt.Errorf("failed to remove TCP serve: %w", err)
}
default:
return fmt.Errorf("invalid type %q", srvType)
}
// TODO(tylersmalley): remove funnel
return nil
}
func srvTypeAndPortFromFlags(e *serveEnv) (srvType serveType, srvPort uint16, err error) {
sourceMap := map[serveType]string{
serveTypeHTTP: e.http,
serveTypeHTTPS: e.https,
serveTypeTCP: e.tcp,
serveTypeTLSTerminatedTCP: e.tlsTerminatedTCP,
}
var srcTypeCount int
var srcValue string
for k, v := range sourceMap {
if v != "" {
srcTypeCount++
srvType = k
srcValue = v
}
}
if srcTypeCount > 1 {
return 0, 0, fmt.Errorf("cannot serve multiple types for a single mount point")
} else if srcTypeCount == 0 {
srvType = serveTypeHTTPS
srcValue = "443"
}
srvPort, err = parseServePort(srcValue)
if err != nil {
return 0, 0, fmt.Errorf("invalid port %q: %w", srcValue, err)
}
return srvType, srvPort, nil
}
func isLegacyInvocation(subcmd serveMode, args []string) bool {
if subcmd == serve && len(args) == 2 {
prefixes := []string{"http", "https", "tcp", "tls-terminated-tcp"}
for _, prefix := range prefixes {
if strings.HasPrefix(args[0], prefix) {
return true
}
}
}
return false
}
// removeWebServe removes a web handler from the serve config
// and removes funnel if no remaining mounts exist for the serve port.
// The srvPort argument is the serving port and the mount argument is
// the mount point or registered path to remove.
func (e *serveEnv) removeWebServe(sc *ipn.ServeConfig, dnsName string, srvPort uint16, mount string) error {
if sc.IsTCPForwardingOnPort(srvPort) {
return errors.New("cannot remove web handler; currently serving TCP")
}
hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort))))
if !sc.WebHandlerExists(hp, mount) {
return errors.New("error: handler does not exist")
}
// delete existing handler, then cascade delete if empty
delete(sc.Web[hp].Handlers, mount)
if len(sc.Web[hp].Handlers) == 0 {
delete(sc.Web, hp)
delete(sc.TCP, srvPort)
}
// clear empty maps mostly for testing
if len(sc.Web) == 0 {
sc.Web = nil
}
if len(sc.TCP) == 0 {
sc.TCP = nil
}
// disable funnel if no remaining mounts exist for the serve port
if sc.Web == nil && sc.TCP == nil {
delete(sc.AllowFunnel, hp)
}
return nil
}
// removeTCPServe removes the TCP forwarding configuration for the
// given srvPort, or serving port.
func (e *serveEnv) removeTCPServe(sc *ipn.ServeConfig, src uint16) error {
if sc == nil {
return nil
}
if sc.GetTCPPortHandler(src) == nil {
return errors.New("error: serve config does not exist")
}
if sc.IsServingWeb(src) {
return fmt.Errorf("unable to remove; serving web, not TCP forwarding on serve port %d", src)
}
delete(sc.TCP, src)
// clear map mostly for testing
if len(sc.TCP) == 0 {
sc.TCP = nil
}
return nil
}
// expandProxyTargetDev expands the supported target values to be proxied
// allowing for input values to be a port number, a partial URL, or a full URL
// including a path.
//
// examples:
// - 3000
// - localhost:3000
// - http://localhost:3000
// - https://localhost:3000
// - https-insecure://localhost:3000
// - https-insecure://localhost:3000/foo
func expandProxyTargetDev(target string) (string, error) {
var (
scheme = "http"
host = "127.0.0.1"
)
// support target being a port number
if port, err := strconv.ParseUint(target, 10, 16); err == nil {
return fmt.Sprintf("%s://%s:%d", scheme, host, port), nil
}
// prepend scheme if not present
if !strings.Contains(target, "://") {
target = scheme + "://" + target
}
// make sure we can parse the target
u, err := url.ParseRequestURI(target)
if err != nil {
return "", fmt.Errorf("invalid URL %w", err)
}
// ensure a supported scheme
switch u.Scheme {
case "http", "https", "https+insecure":
default:
return "", errors.New("must be a URL starting with http://, https://, or https+insecure://")
}
// validate the port
port, err := strconv.ParseUint(u.Port(), 10, 16)
if err != nil || port == 0 {
return "", fmt.Errorf("invalid port %q", u.Port())
}
// validate the host.
switch u.Hostname() {
case "localhost", "127.0.0.1":
u.Host = fmt.Sprintf("%s:%d", host, port)
default:
return "", errors.New("only localhost or 127.0.0.1 proxies are currently supported")
}
return u.String(), nil
}
// cleanURLPath ensures the path is clean and has a leading "/".
func cleanURLPath(urlPath string) (string, error) {
if urlPath == "" {
return "/", nil
}
// TODO(tylersmalley) verify still needed with path being a flag
urlPath = cleanMinGWPathConversionIfNeeded(urlPath)
if !strings.HasPrefix(urlPath, "/") {
urlPath = "/" + urlPath
}
c := path.Clean(urlPath)
if urlPath == c || urlPath == c+"/" {
return urlPath, nil
}
return "", fmt.Errorf("invalid mount point %q", urlPath)
}
func (s serveType) String() string {
switch s {
case serveTypeHTTP:
return "http"
case serveTypeHTTPS:
return "https"
case serveTypeTCP:
return "tcp"
case serveTypeTLSTerminatedTCP:
return "tls-terminated-tcp"
default:
return "unknownServeType"
}
}