|
|
@@ -7,14 +7,27 @@ package cli
|
|
|
import (
|
|
|
"context"
|
|
|
"encoding/json"
|
|
|
+ "errors"
|
|
|
"flag"
|
|
|
"fmt"
|
|
|
"io"
|
|
|
+ "net"
|
|
|
+ "net/url"
|
|
|
"os"
|
|
|
+ "path"
|
|
|
+ "path/filepath"
|
|
|
+ "reflect"
|
|
|
+ "sort"
|
|
|
+ "strconv"
|
|
|
+ "strings"
|
|
|
|
|
|
"github.com/peterbourgon/ff/v3/ffcli"
|
|
|
+ "golang.org/x/exp/slices"
|
|
|
"tailscale.com/ipn"
|
|
|
+ "tailscale.com/ipn/ipnstate"
|
|
|
+ "tailscale.com/tailcfg"
|
|
|
"tailscale.com/util/mak"
|
|
|
+ "tailscale.com/version"
|
|
|
)
|
|
|
|
|
|
var serveCmd = newServeCommand(&serveEnv{})
|
|
|
@@ -22,31 +35,80 @@ var serveCmd = newServeCommand(&serveEnv{})
|
|
|
// newServeCommand returns a new "serve" subcommand using e as its environmment.
|
|
|
func newServeCommand(e *serveEnv) *ffcli.Command {
|
|
|
return &ffcli.Command{
|
|
|
- Name: "serve",
|
|
|
- ShortHelp: "TODO",
|
|
|
- ShortUsage: "serve {show-config|https|tcp|ingress} <args>",
|
|
|
- LongHelp: "", // TODO
|
|
|
- Exec: e.runServe,
|
|
|
- FlagSet: e.newFlags("serve", func(fs *flag.FlagSet) {}),
|
|
|
+ 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>`),
|
|
|
+ LongHelp: strings.TrimSpace(`
|
|
|
+*** ALPHA; all of this is subject to change ***
|
|
|
+
|
|
|
+The 'tailscale serve' set of commands allows you to serve
|
|
|
+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
|
|
|
+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
|
|
|
+
|
|
|
+ - 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
|
|
|
+
|
|
|
+ - To serve simple static text:
|
|
|
+ $ tailscale serve / text "Hello, world!"
|
|
|
+`),
|
|
|
+ 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)")
|
|
|
+ }),
|
|
|
+ UsageFunc: usageFunc,
|
|
|
Subcommands: []*ffcli.Command{
|
|
|
{
|
|
|
- Name: "show-config",
|
|
|
- Exec: e.runServeShowConfig,
|
|
|
- ShortHelp: "show current serve config",
|
|
|
+ Name: "status",
|
|
|
+ Exec: e.runServeStatus,
|
|
|
+ ShortHelp: "show current serve 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 --terminate-tls tcp 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: "ingress",
|
|
|
- Exec: e.runServeIngress,
|
|
|
- ShortHelp: "enable or disable ingress",
|
|
|
- FlagSet: e.newFlags("serve-ingress", func(fs *flag.FlagSet) {}),
|
|
|
+ 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,
|
|
|
},
|
|
|
},
|
|
|
}
|
|
|
@@ -58,13 +120,44 @@ func newServeCommand(e *serveEnv) *ffcli.Command {
|
|
|
// 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)
|
|
|
|
|
|
// optional stuff for tests:
|
|
|
- testFlagOut io.Writer
|
|
|
- testGetServeConfig func(context.Context) (*ipn.ServeConfig, error)
|
|
|
- testSetServeConfig func(context.Context, *ipn.ServeConfig) error
|
|
|
- testStdout io.Writer
|
|
|
+ testFlagOut io.Writer
|
|
|
+ testGetServeConfig func(context.Context) (*ipn.ServeConfig, error)
|
|
|
+ testSetServeConfig func(context.Context, *ipn.ServeConfig) error
|
|
|
+ testGetLocalClientStatus func(context.Context) (*ipnstate.Status, error)
|
|
|
+ testStdout io.Writer
|
|
|
+}
|
|
|
+
|
|
|
+func (e *serveEnv) getSelfDNSName(ctx context.Context) (string, error) {
|
|
|
+ st, err := e.getLocalClientStatus(ctx)
|
|
|
+ if err != nil {
|
|
|
+ return "", fmt.Errorf("getting client status: %w", err)
|
|
|
+ }
|
|
|
+ return strings.TrimSuffix(st.Self.DNSName, "."), nil
|
|
|
+}
|
|
|
+
|
|
|
+func (e *serveEnv) getLocalClientStatus(ctx context.Context) (*ipnstate.Status, error) {
|
|
|
+ if e.testGetLocalClientStatus != nil {
|
|
|
+ return e.testGetLocalClientStatus(ctx)
|
|
|
+ }
|
|
|
+ st, err := localClient.Status(ctx)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fixTailscaledConnectError(err)
|
|
|
+ }
|
|
|
+ description, ok := isRunningOrStarting(st)
|
|
|
+ if !ok {
|
|
|
+ fmt.Fprintf(os.Stderr, "%s\n", description)
|
|
|
+ os.Exit(1)
|
|
|
+ }
|
|
|
+ if st.Self == nil {
|
|
|
+ return nil, errors.New("no self node")
|
|
|
+ }
|
|
|
+ return st, nil
|
|
|
}
|
|
|
|
|
|
func (e *serveEnv) newFlags(name string, setup func(fs *flag.FlagSet)) *flag.FlagSet {
|
|
|
@@ -101,7 +194,39 @@ func (e *serveEnv) stdout() io.Writer {
|
|
|
return os.Stdout
|
|
|
}
|
|
|
|
|
|
+// 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)
|
|
|
+ }
|
|
|
+ // make sure e.servePort is 443, 8443 or 10000
|
|
|
+ if port != 443 && port != 8443 && port != 10000 {
|
|
|
+ return 0, fmt.Errorf("serve-port %d is invalid; must be 443, 8443 or 10000", 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!"
|
|
|
func (e *serveEnv) runServe(ctx context.Context, args []string) error {
|
|
|
+ if len(args) == 0 {
|
|
|
+ return flag.ErrHelp
|
|
|
+ }
|
|
|
+
|
|
|
+ srvPort, err := e.validateServePort()
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ srvPortStr := strconv.Itoa(int(srvPort))
|
|
|
+
|
|
|
// Undocumented debug command (not using ffcli subcommands) to set raw
|
|
|
// configs from stdin for now (2022-11-13).
|
|
|
if len(args) == 1 && args[0] == "set-raw" {
|
|
|
@@ -115,31 +240,471 @@ func (e *serveEnv) runServe(ctx context.Context, args []string) error {
|
|
|
}
|
|
|
return localClient.SetServeConfig(ctx, sc)
|
|
|
}
|
|
|
- panic("TODO")
|
|
|
+
|
|
|
+ if !(len(args) == 3 || (e.remove && len(args) >= 1)) {
|
|
|
+ fmt.Fprintf(os.Stderr, "error: invalid number of arguments\n\n")
|
|
|
+ return flag.ErrHelp
|
|
|
+ }
|
|
|
+
|
|
|
+ mount, err := cleanMountPoint(args[0])
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+
|
|
|
+ if e.remove {
|
|
|
+ return e.handleWebServeRemove(ctx, mount)
|
|
|
+ }
|
|
|
+
|
|
|
+ h := new(ipn.HTTPHandler)
|
|
|
+
|
|
|
+ switch args[1] {
|
|
|
+ case "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]) {
|
|
|
+ fmt.Fprintf(os.Stderr, "error: path must be absolute\n\n")
|
|
|
+ return flag.ErrHelp
|
|
|
+ }
|
|
|
+ fi, err := os.Stat(args[2])
|
|
|
+ if err != nil {
|
|
|
+ fmt.Fprintf(os.Stderr, "error: invalid path: %v\n\n", err)
|
|
|
+ return flag.ErrHelp
|
|
|
+ }
|
|
|
+ if fi.IsDir() && !strings.HasSuffix(mount, "/") {
|
|
|
+ // dir mount points must end in /
|
|
|
+ // 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":
|
|
|
+ h.Text = args[2]
|
|
|
+ default:
|
|
|
+ fmt.Fprintf(os.Stderr, "error: unknown serve type %q\n\n", args[1])
|
|
|
+ return flag.ErrHelp
|
|
|
+ }
|
|
|
+
|
|
|
+ cursc, err := e.getServeConfig(ctx)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ sc := cursc.Clone() // nil if no config
|
|
|
+ if sc == nil {
|
|
|
+ sc = new(ipn.ServeConfig)
|
|
|
+ }
|
|
|
+ dnsName, err := e.getSelfDNSName(ctx)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ hp := ipn.HostPort(net.JoinHostPort(dnsName, srvPortStr))
|
|
|
+
|
|
|
+ if isTCPForwardingOnPort(sc, srvPort) {
|
|
|
+ fmt.Fprintf(os.Stderr, "error: cannot serve web; already serving TCP\n")
|
|
|
+ return flag.ErrHelp
|
|
|
+ }
|
|
|
+
|
|
|
+ mak.Set(&sc.TCP, srvPort, &ipn.TCPPortHandler{HTTPS: true})
|
|
|
+
|
|
|
+ if _, ok := sc.Web[hp]; !ok {
|
|
|
+ mak.Set(&sc.Web, hp, new(ipn.WebServerConfig))
|
|
|
+ }
|
|
|
+ mak.Set(&sc.Web[hp].Handlers, mount, h)
|
|
|
+
|
|
|
+ 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)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if !reflect.DeepEqual(cursc, sc) {
|
|
|
+ if err := e.setServeConfig(ctx, sc); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return nil
|
|
|
}
|
|
|
|
|
|
-func (e *serveEnv) runServeShowConfig(ctx context.Context, args []string) error {
|
|
|
+func (e *serveEnv) handleWebServeRemove(ctx context.Context, mount string) error {
|
|
|
+ srvPort, err := e.validateServePort()
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ srvPortStr := strconv.Itoa(int(srvPort))
|
|
|
sc, err := e.getServeConfig(ctx)
|
|
|
if err != nil {
|
|
|
return err
|
|
|
}
|
|
|
- j, err := json.MarshalIndent(sc, "", " ")
|
|
|
+ if sc == nil {
|
|
|
+ return errors.New("error: serve config does not exist")
|
|
|
+ }
|
|
|
+ dnsName, err := e.getSelfDNSName(ctx)
|
|
|
if err != nil {
|
|
|
return err
|
|
|
}
|
|
|
- j = append(j, '\n')
|
|
|
- e.stdout().Write(j)
|
|
|
+ if isTCPForwardingOnPort(sc, srvPort) {
|
|
|
+ return errors.New("cannot remove web handler; currently serving TCP")
|
|
|
+ }
|
|
|
+ hp := ipn.HostPort(net.JoinHostPort(dnsName, srvPortStr))
|
|
|
+ if !httpHandlerExists(sc, hp, mount) {
|
|
|
+ return errors.New("error: serve config 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
|
|
|
+ }
|
|
|
+ if err := e.setServeConfig(ctx, sc); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+func httpHandlerExists(sc *ipn.ServeConfig, hp ipn.HostPort, mount string) bool {
|
|
|
+ h := getHTTPHandler(sc, hp, mount)
|
|
|
+ return h != nil
|
|
|
+}
|
|
|
+
|
|
|
+func getHTTPHandler(sc *ipn.ServeConfig, hp ipn.HostPort, mount string) *ipn.HTTPHandler {
|
|
|
+ if sc != nil && sc.Web[hp] != nil {
|
|
|
+ return sc.Web[hp].Handlers[mount]
|
|
|
+ }
|
|
|
return nil
|
|
|
}
|
|
|
|
|
|
+func cleanMountPoint(mount string) (string, error) {
|
|
|
+ if mount == "" {
|
|
|
+ return "", errors.New("mount point cannot be empty")
|
|
|
+ }
|
|
|
+ if !strings.HasPrefix(mount, "/") {
|
|
|
+ mount = "/" + mount
|
|
|
+ }
|
|
|
+ c := path.Clean(mount)
|
|
|
+ if mount == c || mount == c+"/" {
|
|
|
+ return mount, nil
|
|
|
+ }
|
|
|
+ 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
|
|
|
+ }
|
|
|
+ if !strings.Contains(target, "://") {
|
|
|
+ target = "http://" + target
|
|
|
+ }
|
|
|
+ u, err := url.ParseRequestURI(target)
|
|
|
+ if err != nil {
|
|
|
+ return "", fmt.Errorf("parsing url: %w", err)
|
|
|
+ }
|
|
|
+ switch u.Scheme {
|
|
|
+ case "http", "https", "https+insecure":
|
|
|
+ // ok
|
|
|
+ default:
|
|
|
+ return "", fmt.Errorf("must be a URL starting with http://, https://, or https+insecure://")
|
|
|
+ }
|
|
|
+ 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:
|
|
|
+ return "", fmt.Errorf("only localhost or 127.0.0.1 proxies are currently supported")
|
|
|
+ }
|
|
|
+ url := u.Scheme + "://" + host
|
|
|
+ if u.Port() != "" {
|
|
|
+ url += ":" + u.Port()
|
|
|
+ }
|
|
|
+ return url, nil
|
|
|
+}
|
|
|
+
|
|
|
+// isTCPForwardingAny checks if any TCP port is being forwarded.
|
|
|
+func isTCPForwardingAny(sc *ipn.ServeConfig) bool {
|
|
|
+ if sc == nil || len(sc.TCP) == 0 {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ for _, h := range sc.TCP {
|
|
|
+ if h.TCPForward != "" {
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false
|
|
|
+}
|
|
|
+
|
|
|
+// isTCPForwardingOnPort checks serve config to see if
|
|
|
+// we're specifically forwarding TCP on the given port.
|
|
|
+func isTCPForwardingOnPort(sc *ipn.ServeConfig, port uint16) bool {
|
|
|
+ if sc == nil || sc.TCP[port] == nil {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ return !sc.TCP[port].HTTPS
|
|
|
+}
|
|
|
+
|
|
|
+// isServingWeb checks serve config to see if
|
|
|
+// we're serving a web handler on the given port.
|
|
|
+func isServingWeb(sc *ipn.ServeConfig, port uint16) bool {
|
|
|
+ if sc == nil || sc.Web == nil || sc.TCP == nil ||
|
|
|
+ sc.TCP[port] == nil || sc.TCP[port].HTTPS == false {
|
|
|
+ // not listening on port
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ return true
|
|
|
+}
|
|
|
+
|
|
|
+func allNumeric(s string) bool {
|
|
|
+ for i := 0; i < len(s); i++ {
|
|
|
+ if s[i] < '0' || s[i] > '9' {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return s != ""
|
|
|
+}
|
|
|
+
|
|
|
+// runServeStatus prints the current serve config.
|
|
|
+//
|
|
|
+// Examples:
|
|
|
+// - tailscale status
|
|
|
+// - tailscale status --json
|
|
|
+func (e *serveEnv) runServeStatus(ctx context.Context, args []string) error {
|
|
|
+ sc, err := e.getServeConfig(ctx)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ if e.json {
|
|
|
+ j, err := json.MarshalIndent(sc, "", " ")
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ j = append(j, '\n')
|
|
|
+ e.stdout().Write(j)
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ if sc == nil || (len(sc.TCP) == 0 && len(sc.Web) == 0 && len(sc.AllowFunnel) == 0) {
|
|
|
+ printf("No serve config\n")
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ st, err := e.getLocalClientStatus(ctx)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ if isTCPForwardingAny(sc) {
|
|
|
+ if err := printTCPStatusTree(ctx, sc, st); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ printf("\n")
|
|
|
+ }
|
|
|
+ for hp := range sc.Web {
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+func printTCPStatusTree(ctx context.Context, sc *ipn.ServeConfig, st *ipnstate.Status) error {
|
|
|
+ dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
|
|
|
+ for p, h := range sc.TCP {
|
|
|
+ if h.TCPForward == "" {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(p))))
|
|
|
+ tlsStatus := "TLS over TCP"
|
|
|
+ if h.TerminateTLS != "" {
|
|
|
+ tlsStatus = "TLS terminated"
|
|
|
+ }
|
|
|
+ fStatus := "tailnet only"
|
|
|
+ if isFunnelOn(sc, hp) {
|
|
|
+ fStatus = "Funnel on"
|
|
|
+ }
|
|
|
+ printf("|-- tcp://%s (%s, %s)\n", hp, tlsStatus, fStatus)
|
|
|
+ for _, a := range st.TailscaleIPs {
|
|
|
+ ipp := net.JoinHostPort(a.String(), strconv.Itoa(int(p)))
|
|
|
+ printf("|-- tcp://%s\n", ipp)
|
|
|
+ }
|
|
|
+ printf("|--> tcp://%s\n", h.TCPForward)
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+func printWebStatusTree(sc *ipn.ServeConfig, hp ipn.HostPort) {
|
|
|
+ if sc == nil {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ fStatus := "tailnet only"
|
|
|
+ if isFunnelOn(sc, hp) {
|
|
|
+ fStatus = "Funnel on"
|
|
|
+ }
|
|
|
+ host, portStr, _ := net.SplitHostPort(string(hp))
|
|
|
+ if portStr == "443" {
|
|
|
+ printf("https://%s (%s)\n", host, fStatus)
|
|
|
+ } else {
|
|
|
+ printf("https://%s:%s (%s)\n", host, portStr, fStatus)
|
|
|
+ }
|
|
|
+ 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 "", ""
|
|
|
+ }
|
|
|
+
|
|
|
+ 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)
|
|
|
+ printf("%s %s%s %-5s %s\n", "|--", m, strings.Repeat(" ", maxLen-len(m)), t, d)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func elipticallyTruncate(s string, max int) string {
|
|
|
+ if len(s) <= max {
|
|
|
+ return s
|
|
|
+ }
|
|
|
+ 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-port=8443 tcp 4430
|
|
|
+// - tailscale --serve-port=10000 --terminate-tls tcp 8080
|
|
|
func (e *serveEnv) runServeTCP(ctx context.Context, args []string) error {
|
|
|
- panic("TODO")
|
|
|
+ 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.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 e.remove {
|
|
|
+ if isServingWeb(sc, srvPort) {
|
|
|
+ return errors.New("cannot remove TCP port; currently serving web")
|
|
|
+ }
|
|
|
+ if sc.TCP != nil && sc.TCP[srvPort] != nil &&
|
|
|
+ sc.TCP[srvPort].TCPForward == fwdAddr {
|
|
|
+ delete(sc.TCP, srvPort)
|
|
|
+ // clear map mostly for testing
|
|
|
+ if len(sc.TCP) == 0 {
|
|
|
+ sc.TCP = nil
|
|
|
+ }
|
|
|
+ return e.setServeConfig(ctx, sc)
|
|
|
+ }
|
|
|
+
|
|
|
+ return errors.New("error: serve config does not exist")
|
|
|
+ }
|
|
|
+
|
|
|
+ if isServingWeb(sc, srvPort) {
|
|
|
+ fmt.Fprintf(os.Stderr, "error: cannot serve TCP; already serving Web\n\n")
|
|
|
+ return flag.ErrHelp
|
|
|
+ }
|
|
|
+
|
|
|
+ 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.setServeConfig(ctx, sc); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return nil
|
|
|
}
|
|
|
|
|
|
-func (e *serveEnv) runServeIngress(ctx context.Context, args []string) error {
|
|
|
+// 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":
|
|
|
@@ -151,9 +716,17 @@ func (e *serveEnv) runServeIngress(ctx context.Context, args []string) error {
|
|
|
if err != nil {
|
|
|
return err
|
|
|
}
|
|
|
- var key ipn.HostPort = "foo:123" // TODO(bradfitz,shayne): fix
|
|
|
- if on && sc != nil && sc.AllowIngress[key] ||
|
|
|
- !on && (sc == nil || !sc.AllowIngress[key]) {
|
|
|
+ st, err := e.getLocalClientStatus(ctx)
|
|
|
+ if err != nil {
|
|
|
+ return fmt.Errorf("getting client status: %w", err)
|
|
|
+ }
|
|
|
+ if !slices.Contains(st.Self.Capabilities, tailcfg.NodeAttrFunnel) {
|
|
|
+ return errors.New("Funnel not available. See https://tailscale.com/s/no-funnel")
|
|
|
+ }
|
|
|
+ dnsName := strings.TrimSuffix(st.Self.DNSName, ".")
|
|
|
+ hp := ipn.HostPort(dnsName + ":" + srvPortStr)
|
|
|
+ if on && sc != nil && sc.AllowFunnel[hp] ||
|
|
|
+ !on && (sc == nil || !sc.AllowFunnel[hp]) {
|
|
|
// Nothing to do.
|
|
|
return nil
|
|
|
}
|
|
|
@@ -161,9 +734,20 @@ func (e *serveEnv) runServeIngress(ctx context.Context, args []string) error {
|
|
|
sc = &ipn.ServeConfig{}
|
|
|
}
|
|
|
if on {
|
|
|
- mak.Set(&sc.AllowIngress, "foo:123", true)
|
|
|
+ mak.Set(&sc.AllowFunnel, hp, true)
|
|
|
} else {
|
|
|
- delete(sc.AllowIngress, "foo:123")
|
|
|
+ delete(sc.AllowFunnel, hp)
|
|
|
+ // clear map mostly for testing
|
|
|
+ if len(sc.AllowFunnel) == 0 {
|
|
|
+ sc.AllowFunnel = nil
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if err := e.setServeConfig(ctx, sc); err != nil {
|
|
|
+ return err
|
|
|
}
|
|
|
- return e.setServeConfig(ctx, sc)
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+func isFunnelOn(sc *ipn.ServeConfig, hp ipn.HostPort) bool {
|
|
|
+ return sc != nil && sc.AllowFunnel[hp]
|
|
|
}
|