From 2244badf0d806e5869b54fdd55cbb40e381ee8e3 Mon Sep 17 00:00:00 2001 From: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com> Date: Sun, 16 Mar 2025 22:18:04 -0400 Subject: [PATCH] cmd/tailscale/cli: Allow tailscale serve serving services from cli This commit adds flag to tailscale serve to enable serving a service on current node as a service host. Also added some message output to both serve and advertise for "next step" guidance. Updates tailscale/corp#22954 Signed-off-by: KevinLiang10 <37811973+KevinLiang10@users.noreply.github.com> --- cmd/tailscale/cli/advertise.go | 19 + cmd/tailscale/cli/serve_legacy.go | 45 +- cmd/tailscale/cli/serve_legacy_test.go | 8 + cmd/tailscale/cli/serve_v2.go | 360 +++++++++++--- cmd/tailscale/cli/serve_v2_test.go | 643 +++++++++++++++++++++++-- cmd/tailscale/cli/status.go | 2 +- cmd/tsidp/tsidp.go | 2 +- ipn/serve.go | 203 ++++++-- ipn/serve_test.go | 115 +++++ 9 files changed, 1220 insertions(+), 177 deletions(-) diff --git a/cmd/tailscale/cli/advertise.go b/cmd/tailscale/cli/advertise.go index 83d1a35aa..cfba800b5 100644 --- a/cmd/tailscale/cli/advertise.go +++ b/cmd/tailscale/cli/advertise.go @@ -47,6 +47,25 @@ func runAdvertise(ctx context.Context, args []string) error { if err != nil { return err } + if len(services) > 0 { + fmt.Println("Advertising this node as new destination for services:", services) + fmt.Println("This node will accept connection for services once the services are configured locally and approved on the admin console.") + sc, err := localClient.GetServeConfig(ctx) + if err != nil { + return fmt.Errorf("failed to get serve config: %w", err) + } + notServedServices := make([]string, 0) + for _, svc := range services { + if _, ok := sc.Services[tailcfg.ServiceName(svc)]; !ok { + notServedServices = append(notServedServices, svc) + } + } + if len(notServedServices) > 0 { + fmt.Println("The following services are not configured to be served yet: ", strings.Join(notServedServices, ", ")) + fmt.Println("To configure services, run tailscale serve --service=\"\" for each service.") + fmt.Printf("eg. tailscale serve --service=%q 3000\n", notServedServices[0]) + } + } _, err = localClient.EditPrefs(ctx, &ipn.MaskedPrefs{ AdvertiseServicesSet: true, diff --git a/cmd/tailscale/cli/serve_legacy.go b/cmd/tailscale/cli/serve_legacy.go index 96629b5ad..abd12f80c 100644 --- a/cmd/tailscale/cli/serve_legacy.go +++ b/cmd/tailscale/cli/serve_legacy.go @@ -141,6 +141,7 @@ type localServeClient interface { QueryFeature(ctx context.Context, feature string) (*tailcfg.QueryFeatureResponse, error) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt) (*tailscale.IPNBusWatcher, error) IncrementCounter(ctx context.Context, name string, delta int) error + GetPrefs(ctx context.Context) (*ipn.Prefs, error) } // serveEnv is the environment the serve command runs within. All I/O should be @@ -154,14 +155,16 @@ type serveEnv struct { json bool // output JSON (status only for now) // v2 specific flags - bg bool // background mode - setPath string // serve path - https uint // HTTP port - http uint // HTTP port - tcp uint // TCP port - tlsTerminatedTCP uint // a TLS terminated TCP port - subcmd serveMode // subcommand - yes bool // update without prompt + bg bgBoolFlag // background mode + setPath string // serve path + https uint // HTTP port + http uint // HTTP port + tcp uint // TCP port + tlsTerminatedTCP uint // a TLS terminated TCP port + subcmd serveMode // subcommand + yes bool // update without prompt + service string // service name + tun bool // redirect traffic to OS for service lc localServeClient // localClient interface, specific to serve @@ -354,12 +357,16 @@ func (e *serveEnv) handleWebServe(ctx context.Context, srvPort uint16, useTLS bo if err != nil { return err } - if sc.IsTCPForwardingOnPort(srvPort) { + if sc.IsTCPForwardingOnPort(srvPort, dnsName) { fmt.Fprintf(Stderr, "error: cannot serve web; already serving TCP\n") return errHelp } - sc.SetWebHandler(h, dnsName, srvPort, mount, useTLS) + st, err := e.getLocalClientStatusWithoutPeers(ctx) + if err != nil { + return fmt.Errorf("getting client status: %w", err) + } + sc.SetWebHandler(st, h, dnsName, srvPort, mount, useTLS) if !reflect.DeepEqual(cursc, sc) { if err := e.lc.SetServeConfig(ctx, sc); err != nil { @@ -411,11 +418,11 @@ func (e *serveEnv) handleWebServeRemove(ctx context.Context, srvPort uint16, mou if err != nil { return err } - if sc.IsTCPForwardingOnPort(srvPort) { + if sc.IsTCPForwardingOnPort(srvPort, dnsName) { 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) { + if !sc.WebHandlerExists(dnsName, hp, mount) { return errors.New("error: handler does not exist") } sc.RemoveWebHandler(dnsName, srvPort, []string{mount}, false) @@ -550,7 +557,7 @@ func (e *serveEnv) handleTCPServe(ctx context.Context, srcType string, srcPort u fwdAddr := "127.0.0.1:" + dstPortStr - if sc.IsServingWeb(srcPort) { + if sc.IsServingWeb(srcPort, "") { return fmt.Errorf("cannot serve TCP; already serving web on %d", srcPort) } @@ -581,11 +588,15 @@ func (e *serveEnv) handleTCPServeRemove(ctx context.Context, src uint16) error { if sc == nil { sc = new(ipn.ServeConfig) } - if sc.IsServingWeb(src) { + dnsName, err := e.getSelfDNSName(ctx) + if err != nil { + return err + } + if sc.IsServingWeb(src, dnsName) { return fmt.Errorf("unable to remove; serving web, not TCP forwarding on serve port %d", src) } - if ph := sc.GetTCPPortHandler(src); ph != nil { - sc.RemoveTCPForwarding(src) + if ph := sc.GetTCPPortHandler(src, dnsName); ph != nil { + sc.RemoveTCPForwarding(dnsName, src) return e.lc.SetServeConfig(ctx, sc) } return errors.New("error: serve config does not exist") @@ -682,7 +693,7 @@ func (e *serveEnv) printWebStatusTree(sc *ipn.ServeConfig, hp ipn.HostPort) erro } scheme := "https" - if sc.IsServingHTTP(port) { + if sc.IsServingHTTP(port, host) { scheme = "http" } diff --git a/cmd/tailscale/cli/serve_legacy_test.go b/cmd/tailscale/cli/serve_legacy_test.go index df68b5edd..a44975e07 100644 --- a/cmd/tailscale/cli/serve_legacy_test.go +++ b/cmd/tailscale/cli/serve_legacy_test.go @@ -877,6 +877,10 @@ var fakeStatus = &ipnstate.Status{ }, } +var fakePref = &ipn.Prefs{ + AdvertiseServices: []string{"svc:test"}, +} + func (lc *fakeLocalServeClient) StatusWithoutPeers(ctx context.Context) (*ipnstate.Status, error) { return fakeStatus, nil } @@ -891,6 +895,10 @@ func (lc *fakeLocalServeClient) SetServeConfig(ctx context.Context, config *ipn. return nil } +func (lc *fakeLocalServeClient) GetPrefs(ctx context.Context) (*ipn.Prefs, error) { + return fakePref, nil +} + type mockQueryFeatureResponse struct { resp *tailcfg.QueryFeatureResponse err error diff --git a/cmd/tailscale/cli/serve_v2.go b/cmd/tailscale/cli/serve_v2.go index 3e173ce28..67537ba08 100644 --- a/cmd/tailscale/cli/serve_v2.go +++ b/cmd/tailscale/cli/serve_v2.go @@ -18,6 +18,7 @@ import ( "os/signal" "path" "path/filepath" + "slices" "sort" "strconv" "strings" @@ -40,6 +41,32 @@ type commandInfo struct { LongHelp string } +type bgBoolFlag struct { + Value bool + SetByUser bool // tracks if the flag was set by the user +} + +// Set sets the boolean flag and wether it's explicitly set by user based on the string value. +func (b *bgBoolFlag) Set(s string) error { + if s == "true" { + b.Value = true + } else if s == "false" { + b.Value = false + } else { + return fmt.Errorf("invalid boolean value: %s", s) + } + b.SetByUser = true + return nil +} + +// This is a hack to make the flag package recognize that this is a boolean flag. +func (b *bgBoolFlag) IsBoolFlag() bool { return true } + +// String returns the string representation of the boolean flag. +func (b *bgBoolFlag) String() string { + return fmt.Sprintf("%t", b.Value) +} + var serveHelpCommon = strings.TrimSpace(` can be a file, directory, text, or most commonly the location to a service running on the local machine. The location to the location service can be expressed as a port number (e.g., 3000), @@ -72,6 +99,7 @@ const ( serveTypeHTTP serveTypeTCP serveTypeTLSTerminatedTCP + serveTypeTun ) var infoMap = map[serveMode]commandInfo{ @@ -119,7 +147,7 @@ func newServeV2Command(e *serveEnv, subcmd serveMode) *ffcli.Command { Exec: e.runServeCombined(subcmd), FlagSet: e.newFlags("serve-set", func(fs *flag.FlagSet) { - fs.BoolVar(&e.bg, "bg", false, "Run the command as a background process (default false)") + fs.Var(&e.bg, "bg", "Run the command as a background process (default false)") fs.StringVar(&e.setPath, "set-path", "", "Appends the specified path to the base URL for accessing the underlying service") fs.UintVar(&e.https, "https", 0, "Expose an HTTPS server at the specified port (default mode)") if subcmd == serve { @@ -127,7 +155,9 @@ func newServeV2Command(e *serveEnv, subcmd serveMode) *ffcli.Command { } fs.UintVar(&e.tcp, "tcp", 0, "Expose a TCP forwarder to forward raw TCP packets at the specified port") fs.UintVar(&e.tlsTerminatedTCP, "tls-terminated-tcp", 0, "Expose a TCP forwarder to forward TLS-terminated TCP packets at the specified port") + fs.StringVar(&e.service, "service", "", "Name of the service to serve.") fs.BoolVar(&e.yes, "yes", false, "Update without interactive prompts (default false)") + fs.BoolVar(&e.tun, "tun", false, "Forward all traffic to the local machine (default false), only supported for services") }), UsageFunc: usageFuncNoDefaultValues, Subcommands: []*ffcli.Command{ @@ -161,9 +191,16 @@ func (e *serveEnv) validateArgs(subcmd serveMode, args []string) error { fmt.Fprint(e.stderr(), "\nPlease see https://tailscale.com/kb/1242/tailscale-serve for more information.\n") return errHelpFunc(subcmd) } + if len(args) == 0 && e.tun { + return nil + } if len(args) == 0 { return flag.ErrHelp } + if e.tun && len(args) > 1 { + fmt.Fprintln(e.stderr(), "Error: invalid argument format") + return errHelpFunc(subcmd) + } if len(args) > 2 { fmt.Fprintf(e.stderr(), "Error: invalid number of arguments (%d)\n", len(args)) return errHelpFunc(subcmd) @@ -182,6 +219,9 @@ func (e *serveEnv) validateArgs(subcmd serveMode, args []string) error { // runServeCombined is the entry point for the "tailscale {serve,funnel}" commands. func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc { e.subcmd = subcmd + if !e.bg.SetByUser { + e.bg.Value = e.service != "" + } return func(ctx context.Context, args []string) error { // Undocumented debug command (not using ffcli subcommands) to set raw @@ -197,15 +237,17 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc { } return e.lc.SetServeConfig(ctx, sc) } - if err := e.validateArgs(subcmd, args); err != nil { return err } - ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) defer cancel() funnel := subcmd == funnel + if e.service != "" && funnel { + return errors.New("Error: --service flag is not supported with funnel") + } + if funnel { // verify node has funnel capabilities if err := e.verifyFunnelEnabled(ctx, 443); err != nil { @@ -213,12 +255,16 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc { } } + if e.service != "" && e.bg.SetByUser && !e.bg.Value { + return errors.New("Error: --service flag is only compatible with background mode") + } + mount, err := cleanURLPath(e.setPath) if err != nil { return fmt.Errorf("failed to clean the mount point: %w", err) } - srvType, srvPort, err := srvTypeAndPortFromFlags(e) + srvType, srvPort, wasDefaultServe, err := srvTypeAndPortFromFlags(e) if err != nil { fmt.Fprintf(e.stderr(), "error: %v\n\n", err) return errHelpFunc(subcmd) @@ -229,6 +275,11 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc { return fmt.Errorf("error getting serve config: %w", err) } + prefs, err := e.lc.GetPrefs(ctx) + if err != nil { + return fmt.Errorf("error getting prefs: %w", err) + } + // nil if no config if sc == nil { sc = new(ipn.ServeConfig) @@ -245,7 +296,7 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc { // foreground or background. parentSC := sc - turnOff := "off" == args[len(args)-1] + turnOff := len(args) > 0 && "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 @@ -262,10 +313,21 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc { } var watcher *tailscale.IPNBusWatcher - wantFg := !e.bg && !turnOff + forService := e.service != "" + if forService { + err = tailcfg.ServiceName(e.service).Validate() + if err != nil { + return fmt.Errorf("failed to parse service name: %w", err) + } + dnsName = e.service + } + if !forService && srvType == serveTypeTun { + return errors.New("tun mode is only supported for services") + } + wantFg := !forService && !e.bg.Value && !turnOff if wantFg { // validate the config before creating a WatchIPNBus session - if err := e.validateConfig(parentSC, srvPort, srvType); err != nil { + if err := e.validateConfig(parentSC, srvPort, srvType, dnsName); err != nil { return err } @@ -291,13 +353,21 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc { var msg string if turnOff { - err = e.unsetServe(sc, dnsName, srvType, srvPort, mount) + if wasDefaultServe && forService { + delete(sc.Services, tailcfg.ServiceName(dnsName)) + } else { + err = e.unsetServe(sc, st, dnsName, srvType, srvPort, mount) + } } else { - if err := e.validateConfig(parentSC, srvPort, srvType); err != nil { + if err := e.validateConfig(parentSC, srvPort, srvType, dnsName); err != nil { return err } - err = e.setServe(sc, st, dnsName, srvType, srvPort, mount, args[0], funnel) - msg = e.messageForPort(sc, st, dnsName, srvType, srvPort) + target := "" + if len(args) > 0 { + target = args[0] + } + err = e.setServe(sc, st, dnsName, srvType, srvPort, mount, target, funnel) + msg = e.messageForPort(sc, st, prefs, dnsName, srvType, srvPort) } if err != nil { fmt.Fprintf(e.stderr(), "error: %v\n\n", err) @@ -333,20 +403,42 @@ func (e *serveEnv) runServeCombined(subcmd serveMode) execFunc { const backgroundExistsMsg = "background configuration already exists, use `tailscale %s --%s=%d off` to remove the existing configuration" -func (e *serveEnv) validateConfig(sc *ipn.ServeConfig, port uint16, wantServe serveType) error { - sc, isFg := sc.FindConfig(port) - if sc == nil { - return nil +// validateConfig checks if the serve config is valid to serve the type wanted on the port. +// dnsName is a FQDN or a serviceName (with `svc:` prefix). +func (e *serveEnv) validateConfig(sc *ipn.ServeConfig, port uint16, wantServe serveType, dnsName string) error { + forService := ipn.IsServiceName(dnsName) + var tcpHandlerForPort *ipn.TCPPortHandler + if forService { + svc := sc.FindServiceConfig(tailcfg.ServiceName(dnsName)) + if svc == nil { + return nil + } + if wantServe == serveTypeTun && (svc.TCP != nil || svc.Web != nil) { + return errors.New("service already has a TCP or Web handler, cannot serve in TUN mode") + } + if svc.Tun && wantServe != serveTypeTun { + return errors.New("service is already being served in TUN mode") + } + if svc.TCP[port] == nil { + return nil + } + tcpHandlerForPort = svc.TCP[port] + } else { + sc, isFg := sc.FindConfig(port) + if sc == nil { + return nil + } + if isFg { + return errors.New("foreground already exists under this port") + } + if !e.bg.Value { + return fmt.Errorf(backgroundExistsMsg, infoMap[e.subcmd].Name, wantServe.String(), port) + } + tcpHandlerForPort = sc.TCP[port] } - if isFg { - return errors.New("foreground already exists under this port") - } - if !e.bg { - return fmt.Errorf(backgroundExistsMsg, infoMap[e.subcmd].Name, wantServe.String(), port) - } - existingServe := serveFromPortHandler(sc.TCP[port]) + existingServe := serveFromPortHandler(tcpHandlerForPort) if wantServe != existingServe { - return fmt.Errorf("want %q but port is already serving %q", wantServe, existingServe) + return fmt.Errorf("want to serve %q but port is already serving %q for %q", wantServe, existingServe, dnsName) } return nil } @@ -371,7 +463,7 @@ func (e *serveEnv) setServe(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName st switch srvType { case serveTypeHTTPS, serveTypeHTTP: useTLS := srvType == serveTypeHTTPS - err := e.applyWebServe(sc, dnsName, srvPort, useTLS, mount, target) + err := e.applyWebServe(sc, st, dnsName, srvPort, useTLS, mount, target) if err != nil { return fmt.Errorf("failed apply web serve: %w", err) } @@ -379,45 +471,59 @@ func (e *serveEnv) setServe(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName st if e.setPath != "" { return fmt.Errorf("cannot mount a path for TCP serve") } - err := e.applyTCPServe(sc, dnsName, srvType, srvPort, target) if err != nil { return fmt.Errorf("failed to apply TCP serve: %w", err) } + case serveTypeTun: + svcName := tailcfg.ServiceName(dnsName) + if _, ok := sc.Services[svcName]; !ok { + mak.Set(&sc.Services, svcName, new(ipn.ServiceConfig)) + } + sc.Services[svcName].Tun = true default: return fmt.Errorf("invalid type %q", srvType) } // update the serve config based on if funnel is enabled - e.applyFunnel(sc, dnsName, srvPort, allowFunnel) - + // Since funnel is not supported for services, we only apply it for node's serve. + if !ipn.IsServiceName(dnsName) { + e.applyFunnel(sc, dnsName, srvPort, allowFunnel) + } return nil } var ( - msgFunnelAvailable = "Available on the internet:" - msgServeAvailable = "Available within your tailnet:" - msgRunningInBackground = "%s started and running in the background." - msgDisableProxy = "To disable the proxy, run: tailscale %s --%s=%d off" - msgToExit = "Press Ctrl+C to exit." + msgFunnelAvailable = "Available on the internet:" + msgServeAvailable = "Available within your tailnet:" + msgServiceIPNotAssigned = "This service doesn't have VIPs assigned yet, once VIP is assigned, it will be available in your Tailnet as:" + msgRunningInBackground = "%s started and running in the background." + msgRunningTunServie = "IPv4 and IPv6 traffic to %s is being routed to your operating system." + msgDisableProxy = "To disable the proxy, run: tailscale %s --%s=%d off" + msgDisableServiceProxy = "To disable the proxy, run: tailscale serve --service=%s --%s=%d off" + msgDisableServiceTun = "To disable the service in TUN mode, run: tailscale serve --service=%s --tun off" + msgDisableService = "To disable the service entirely, run: tailscale serve --service=%s off" + msgServiceNotAdvertised = "This service is not advertised on this node yet, use `tailscale advertise --services=svc:%s` to advertise it." + msgToExit = "Press Ctrl+C to exit." ) // 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, srvType serveType, srvPort uint16) string { +func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, prefs *ipn.Prefs, dnsName string, srvType serveType, srvPort uint16) string { var output strings.Builder - - hp := ipn.HostPort(net.JoinHostPort(dnsName, strconv.Itoa(int(srvPort)))) - - if sc.AllowFunnel[hp] == true { - output.WriteString(msgFunnelAvailable) - } else { - output.WriteString(msgServeAvailable) + forService := ipn.IsServiceName(dnsName) + var hp ipn.HostPort + var webConfig *ipn.WebServerConfig + var tcpHandler *ipn.TCPPortHandler + ips := st.TailscaleIPs + host := dnsName + if forService { + host = tailcfg.ServiceName(dnsName).WithoutPrefix() + "." + st.CurrentTailnet.MagicDNSSuffix } - output.WriteString("\n\n") + hp = ipn.HostPort(net.JoinHostPort(host, strconv.Itoa(int(srvPort)))) scheme := "https" - if sc.IsServingHTTP(srvPort) { + if sc.IsServingHTTP(srvPort, dnsName) { scheme = "http" } @@ -438,37 +544,70 @@ func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsN } return "", "" } + if forService { + svcName := tailcfg.ServiceName(dnsName) + serviceIPMaps, err := tailcfg.UnmarshalNodeCapJSON[tailcfg.ServiceIPMappings](st.Self.CapMap, tailcfg.NodeAttrServiceHost) + if err != nil || len(serviceIPMaps) == 0 || serviceIPMaps[0][svcName] == nil { + output.WriteString(msgServiceIPNotAssigned) + ips = nil + } else { + output.WriteString(msgServeAvailable) + ips = serviceIPMaps[0][svcName] + } + output.WriteString("\n\n") + svc := sc.FindServiceConfig(svcName) + if srvType == serveTypeTun && svc.Tun { + output.WriteString(fmt.Sprintf(msgRunningTunServie, host)) + output.WriteString("\n") + output.WriteString(fmt.Sprintf(msgDisableServiceTun, dnsName)) + output.WriteString("\n") + output.WriteString(fmt.Sprintf(msgDisableService, dnsName)) + return output.String() + } + if svc != nil { + webConfig = svc.Web[hp] + tcpHandler = svc.TCP[srvPort] + } + } else { + if sc.AllowFunnel[hp] == true { + output.WriteString(msgFunnelAvailable) + } else { + output.WriteString(msgServeAvailable) + } + output.WriteString("\n\n") + webConfig = sc.Web[hp] + tcpHandler = sc.TCP[srvPort] + } - if sc.Web[hp] != nil { - mounts := slicesx.MapKeys(sc.Web[hp].Handlers) + if webConfig != nil { + mounts := slicesx.MapKeys(webConfig.Handlers) sort.Slice(mounts, func(i, j int) bool { return len(mounts[i]) < len(mounts[j]) }) for _, m := range mounts { - h := sc.Web[hp].Handlers[m] + h := webConfig.Handlers[m] t, d := srvTypeAndDesc(h) - output.WriteString(fmt.Sprintf("%s://%s%s%s\n", scheme, dnsName, portPart, m)) + output.WriteString(fmt.Sprintf("%s://%s%s%s\n", scheme, host, portPart, m)) output.WriteString(fmt.Sprintf("%s %-5s %s\n\n", "|--", t, d)) } - } else if sc.TCP[srvPort] != nil { - h := sc.TCP[srvPort] + } else if tcpHandler != nil { + h := tcpHandler tlsStatus := "TLS over TCP" if h.TerminateTLS != "" { tlsStatus = "TLS terminated" } - output.WriteString(fmt.Sprintf("%s://%s%s\n", scheme, dnsName, portPart)) output.WriteString(fmt.Sprintf("|-- tcp://%s (%s)\n", hp, tlsStatus)) - for _, a := range st.TailscaleIPs { + for _, a := range ips { 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(fmt.Sprintf("|--> tcp://%s\n\n", h.TCPForward)) } - if !e.bg { + if !forService && !e.bg.Value { output.WriteString(msgToExit) return output.String() } @@ -478,14 +617,23 @@ func (e *serveEnv) messageForPort(sc *ipn.ServeConfig, st *ipnstate.Status, dnsN output.WriteString(fmt.Sprintf(msgRunningInBackground, subCmdUpper)) output.WriteString("\n") - output.WriteString(fmt.Sprintf(msgDisableProxy, subCmd, srvType.String(), srvPort)) + if forService { + if !slices.Contains(prefs.AdvertiseServices, dnsName) { + output.WriteString(fmt.Sprintf(msgServiceNotAdvertised, dnsName)) + output.WriteString("\n") + } + output.WriteString(fmt.Sprintf(msgDisableServiceProxy, dnsName, srvType.String(), srvPort)) + output.WriteString("\n") + output.WriteString(fmt.Sprintf(msgDisableService, dnsName)) + } else { + output.WriteString(fmt.Sprintf(msgDisableProxy, subCmd, srvType.String(), srvPort)) + } return output.String() } -func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort uint16, useTLS bool, mount, target string) error { +func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, st *ipnstate.Status, 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:") @@ -521,11 +669,11 @@ func (e *serveEnv) applyWebServe(sc *ipn.ServeConfig, dnsName string, srvPort ui } // TODO: validation needs to check nested foreground configs - if sc.IsTCPForwardingOnPort(srvPort) { + if sc.IsTCPForwardingOnPort(srvPort, dnsName) { return errors.New("cannot serve web; already serving TCP") } - sc.SetWebHandler(h, dnsName, srvPort, mount, useTLS) + sc.SetWebHandler(st, h, dnsName, srvPort, mount, useTLS) return nil } @@ -552,7 +700,7 @@ func (e *serveEnv) applyTCPServe(sc *ipn.ServeConfig, dnsName string, srcType se } // TODO: needs to account for multiple configs from foreground mode - if sc.IsServingWeb(srcPort) { + if sc.IsServingWeb(srcPort, dnsName) { return fmt.Errorf("cannot serve TCP; already serving web on %d", srcPort) } @@ -577,18 +725,24 @@ func (e *serveEnv) applyFunnel(sc *ipn.ServeConfig, dnsName string, srvPort uint } // 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 { +// dnsName is a FQDN or a serviceName (with `svc:` prefix). +func (e *serveEnv) unsetServe(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName string, srvType serveType, srvPort uint16, mount string) error { switch srvType { case serveTypeHTTPS, serveTypeHTTP: - err := e.removeWebServe(sc, dnsName, srvPort, mount) + err := e.removeWebServe(sc, st, dnsName, srvPort, mount) if err != nil { return fmt.Errorf("failed to remove web serve: %w", err) } case serveTypeTCP, serveTypeTLSTerminatedTCP: - err := e.removeTCPServe(sc, srvPort) + err := e.removeTCPServe(sc, dnsName, srvPort) if err != nil { return fmt.Errorf("failed to remove TCP serve: %w", err) } + case serveTypeTun: + err := e.removeTunServe(sc, dnsName) + if err != nil { + return fmt.Errorf("failed to remove TUN serve: %w", err) + } default: return fmt.Errorf("invalid type %q", srvType) } @@ -598,7 +752,7 @@ func (e *serveEnv) unsetServe(sc *ipn.ServeConfig, dnsName string, srvType serve return nil } -func srvTypeAndPortFromFlags(e *serveEnv) (srvType serveType, srvPort uint16, err error) { +func srvTypeAndPortFromFlags(e *serveEnv) (srvType serveType, srvPort uint16, wasDefault bool, err error) { sourceMap := map[serveType]uint{ serveTypeHTTP: e.http, serveTypeHTTPS: e.https, @@ -611,22 +765,30 @@ func srvTypeAndPortFromFlags(e *serveEnv) (srvType serveType, srvPort uint16, er for k, v := range sourceMap { if v != 0 { if v > math.MaxUint16 { - return 0, 0, fmt.Errorf("port number %d is too high for %s flag", v, srvType) + return 0, 0, false, fmt.Errorf("port number %d is too high for %s flag", v, srvType) } srcTypeCount++ srvType = k srvPort = uint16(v) + wasDefault = false } } + if e.tun { + srcTypeCount++ + srvType = serveTypeTun + wasDefault = false + } + if srcTypeCount > 1 { - return 0, 0, fmt.Errorf("cannot serve multiple types for a single mount point") + return 0, 0, false, fmt.Errorf("cannot serve multiple types for a single mount point") } else if srcTypeCount == 0 { srvType = serveTypeHTTPS srvPort = 443 + wasDefault = true } - return srvType, srvPort, nil + return srvType, srvPort, wasDefault, nil } // isLegacyInvocation helps transition customers who have been using the beta @@ -727,27 +889,45 @@ func isLegacyInvocation(subcmd serveMode, args []string) (string, bool) { // 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") +func (e *serveEnv) removeWebServe(sc *ipn.ServeConfig, st *ipnstate.Status, dnsName string, srvPort uint16, mount string) error { + if sc == nil { + return nil + } + forService := ipn.IsServiceName(dnsName) + portStr := strconv.Itoa(int(srvPort)) + var hp ipn.HostPort + var webServeMap map[ipn.HostPort]*ipn.WebServerConfig + if forService { + svcName := tailcfg.ServiceName(dnsName) + dnsNameForService := svcName.WithoutPrefix() + "." + st.CurrentTailnet.MagicDNSSuffix + hp = ipn.HostPort(net.JoinHostPort(dnsNameForService, portStr)) + if svc, ok := sc.Services[svcName]; !ok || svc == nil { + return errors.New("error: service does not exist") + } else { + webServeMap = svc.Web + } + } else { + hp = ipn.HostPort(net.JoinHostPort(dnsName, portStr)) + webServeMap = sc.Web } - portStr := strconv.Itoa(int(srvPort)) - hp := ipn.HostPort(net.JoinHostPort(dnsName, portStr)) + if sc.IsTCPForwardingOnPort(srvPort, dnsName) { + return errors.New("cannot remove web handler; currently serving TCP") + } var targetExists bool var mounts []string // mount is deduced from e.setPath but it is ambiguous as // to whether the user explicitly passed "/" or it was defaulted to. if e.setPath == "" { - targetExists = sc.Web[hp] != nil && len(sc.Web[hp].Handlers) > 0 + targetExists = webServeMap[hp] != nil && len(webServeMap[hp].Handlers) > 0 if targetExists { - for mount := range sc.Web[hp].Handlers { + for mount := range webServeMap[hp].Handlers { mounts = append(mounts, mount) } } } else { - targetExists = sc.WebHandlerExists(hp, mount) + targetExists = sc.WebHandlerExists(dnsName, hp, mount) mounts = []string{mount} } @@ -756,29 +936,49 @@ func (e *serveEnv) removeWebServe(sc *ipn.ServeConfig, dnsName string, srvPort u } if len(mounts) > 1 { - msg := fmt.Sprintf("Are you sure you want to delete %d handlers under port %s?", len(mounts), portStr) + msg := fmt.Sprintf("Are you sure you want to delete %d handlers under port %s for %q?", len(mounts), portStr, dnsName) if !e.yes && !promptYesNo(msg) { return nil } } - sc.RemoveWebHandler(dnsName, srvPort, mounts, true) + if forService { + sc.RemoveServiceWebHandler(st, tailcfg.ServiceName(dnsName), srvPort, mounts) + } else { + sc.RemoveWebHandler(dnsName, srvPort, mounts, true) + } 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 { +// given srvPort, or serving port for the given dnsName. +func (e *serveEnv) removeTCPServe(sc *ipn.ServeConfig, dnsName string, src uint16) error { if sc == nil { return nil } - if sc.GetTCPPortHandler(src) == nil { + if sc.GetTCPPortHandler(src, dnsName) == nil { return errors.New("error: serve config does not exist") } - if sc.IsServingWeb(src) { + if sc.IsServingWeb(src, dnsName) { return fmt.Errorf("unable to remove; serving web, not TCP forwarding on serve port %d", src) } - sc.RemoveTCPForwarding(src) + sc.RemoveTCPForwarding(dnsName, src) + return nil +} + +func (e *serveEnv) removeTunServe(sc *ipn.ServeConfig, dnsName string) error { + if sc == nil { + return nil + } + svcName := tailcfg.ServiceName(dnsName) + svc, ok := sc.Services[svcName] + if !ok || svc == nil { + return errors.New("error: service does not exist") + } + if !svc.Tun { + return errors.New("error: service is not being served in TUN mode") + } + delete(sc.Services, svcName) return nil } diff --git a/cmd/tailscale/cli/serve_v2_test.go b/cmd/tailscale/cli/serve_v2_test.go index 5768127ad..6d0020f98 100644 --- a/cmd/tailscale/cli/serve_v2_test.go +++ b/cmd/tailscale/cli/serve_v2_test.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "fmt" + "net/netip" "os" "path/filepath" "reflect" @@ -19,6 +20,7 @@ import ( "github.com/peterbourgon/ff/v3/ffcli" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" + "tailscale.com/tailcfg" ) func TestServeDevConfigMutations(t *testing.T) { @@ -874,15 +876,17 @@ func TestValidateConfig(t *testing.T) { name string desc string cfg *ipn.ServeConfig + dns string servePort uint16 serveType serveType - bg bool + bg bgBoolFlag wantErr bool }{ { name: "nil_config", desc: "when config is nil, all requests valid", cfg: nil, + dns: "node.test.ts.net", servePort: 3000, serveType: serveTypeHTTPS, }, @@ -894,7 +898,8 @@ func TestValidateConfig(t *testing.T) { 443: {HTTPS: true}, }, }, - bg: true, + dns: "node.test.ts.net", + bg: bgBoolFlag{true, false}, servePort: 10000, serveType: serveTypeHTTPS, }, @@ -906,7 +911,8 @@ func TestValidateConfig(t *testing.T) { 443: {TCPForward: "http://localhost:4545"}, }, }, - bg: true, + dns: "node.test.ts.net", + bg: bgBoolFlag{true, false}, servePort: 443, serveType: serveTypeTCP, }, @@ -918,7 +924,8 @@ func TestValidateConfig(t *testing.T) { 443: {HTTPS: true}, }, }, - bg: true, + dns: "node.test.ts.net", + bg: bgBoolFlag{true, false}, servePort: 443, serveType: serveTypeHTTP, wantErr: true, @@ -938,6 +945,7 @@ func TestValidateConfig(t *testing.T) { }, }, }, + dns: "node.test.ts.net", servePort: 4040, serveType: serveTypeTCP, }, @@ -953,16 +961,95 @@ func TestValidateConfig(t *testing.T) { }, }, }, + dns: "node.test.ts.net", servePort: 3000, serveType: serveTypeTCP, wantErr: true, }, + { + name: "new_service_tcp", + desc: "no error when adding a new service port", + cfg: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:foo": { + TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}}, + }, + }, + }, + dns: "svc:foo", + servePort: 8080, + serveType: serveTypeTCP, + }, + { + name: "override_service_tcp", + desc: "no error when overwriting a previous service port", + cfg: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:foo": { + TCP: map[uint16]*ipn.TCPPortHandler{ + 443: {TCPForward: "http://localhost:4545"}, + }, + }, + }, + }, + dns: "svc:foo", + servePort: 443, + serveType: serveTypeTCP, + }, + { + name: "override_service_tcp", + desc: "error when overwriting a previous service port with a different serve type", + cfg: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:foo": { + TCP: map[uint16]*ipn.TCPPortHandler{ + 443: {HTTPS: true}, + }, + }, + }, + }, + dns: "svc:foo", + servePort: 443, + serveType: serveTypeHTTP, + wantErr: true, + }, + { + name: "override_service_tcp", + desc: "error when setting previous tcp service to tun mode", + cfg: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:foo": { + TCP: map[uint16]*ipn.TCPPortHandler{ + 443: {TCPForward: "http://localhost:4545"}, + }, + }, + }, + }, + dns: "svc:foo", + serveType: serveTypeTun, + wantErr: true, + }, + { + name: "override_service_tun", + desc: "error when setting previous tun service to tcp forwarder", + cfg: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:foo": { + Tun: true, + }, + }, + }, + dns: "svc:foo", + serveType: serveTypeTCP, + servePort: 443, + wantErr: true, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { se := serveEnv{bg: tc.bg} - err := se.validateConfig(tc.cfg, tc.servePort, tc.serveType) + err := se.validateConfig(tc.cfg, tc.servePort, tc.serveType, tc.dns) if err == nil && tc.wantErr { t.Fatal("expected an error but got nil") } @@ -976,58 +1063,65 @@ func TestValidateConfig(t *testing.T) { func TestSrcTypeFromFlags(t *testing.T) { tests := []struct { - name string - env *serveEnv - expectedType serveType - expectedPort uint16 - expectedErr bool + name string + env *serveEnv + expectedType serveType + expectedPort uint16 + expectedErr bool + expectedWasDefault bool }{ { - name: "only http set", - env: &serveEnv{http: 80}, - expectedType: serveTypeHTTP, - expectedPort: 80, - expectedErr: false, + name: "only http set", + env: &serveEnv{http: 80}, + expectedType: serveTypeHTTP, + expectedPort: 80, + expectedErr: false, + expectedWasDefault: false, }, { - name: "only https set", - env: &serveEnv{https: 10000}, - expectedType: serveTypeHTTPS, - expectedPort: 10000, - expectedErr: false, + name: "only https set", + env: &serveEnv{https: 10000}, + expectedType: serveTypeHTTPS, + expectedPort: 10000, + expectedErr: false, + expectedWasDefault: false, }, { - name: "only tcp set", - env: &serveEnv{tcp: 8000}, - expectedType: serveTypeTCP, - expectedPort: 8000, - expectedErr: false, + name: "only tcp set", + env: &serveEnv{tcp: 8000}, + expectedType: serveTypeTCP, + expectedPort: 8000, + expectedErr: false, + expectedWasDefault: false, }, { - name: "only tls-terminated-tcp set", - env: &serveEnv{tlsTerminatedTCP: 8080}, - expectedType: serveTypeTLSTerminatedTCP, - expectedPort: 8080, - expectedErr: false, + name: "only tls-terminated-tcp set", + env: &serveEnv{tlsTerminatedTCP: 8080}, + expectedType: serveTypeTLSTerminatedTCP, + expectedPort: 8080, + expectedErr: false, + expectedWasDefault: false, }, { - name: "defaults to https, port 443", - env: &serveEnv{}, - expectedType: serveTypeHTTPS, - expectedPort: 443, - expectedErr: false, + name: "defaults to https, port 443", + env: &serveEnv{}, + expectedType: serveTypeHTTPS, + expectedPort: 443, + expectedErr: false, + expectedWasDefault: true, }, { - name: "multiple types set", - env: &serveEnv{http: 80, https: 443}, - expectedPort: 0, - expectedErr: true, + name: "multiple types set", + env: &serveEnv{http: 80, https: 443}, + expectedPort: 0, + expectedErr: true, + expectedWasDefault: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - srcType, srcPort, err := srvTypeAndPortFromFlags(tt.env) + srcType, srcPort, explicitSet, err := srvTypeAndPortFromFlags(tt.env) if (err != nil) != tt.expectedErr { t.Errorf("Expected error: %v, got: %v", tt.expectedErr, err) } @@ -1037,6 +1131,9 @@ func TestSrcTypeFromFlags(t *testing.T) { if srcPort != tt.expectedPort { t.Errorf("Expected srcPort: %d, got: %d", tt.expectedPort, srcPort) } + if explicitSet != tt.expectedWasDefault { + t.Errorf("Expected defaultFlag: %v, got: %v", tt.expectedWasDefault, explicitSet) + } }) } } @@ -1076,11 +1173,21 @@ func TestCleanURLPath(t *testing.T) { } func TestMessageForPort(t *testing.T) { + svcIPMap := tailcfg.ServiceIPMappings{ + "svc:foo": []netip.Addr{ + netip.MustParseAddr("100.101.101.101"), + netip.MustParseAddr("fd7a:115c:a1e0:ab12:4843:cd96:6565:6565"), + }, + } + svcIPMapJSON, _ := json.Marshal(svcIPMap) + svcIPMapJSONRawMSG := tailcfg.RawMessage(svcIPMapJSON) + tests := []struct { name string subcmd serveMode serveConfig *ipn.ServeConfig status *ipnstate.Status + prefs *ipn.Prefs dnsName string srvType serveType srvPort uint16 @@ -1147,13 +1254,252 @@ func TestMessageForPort(t *testing.T) { fmt.Sprintf(msgDisableProxy, "serve", "http", 80), }, "\n"), }, + { + name: "serve service http", + subcmd: serve, + serveConfig: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:foo": { + TCP: map[uint16]*ipn.TCPPortHandler{ + 80: {HTTP: true}, + }, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:80": { + Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://localhost:3000"}, + }, + }, + }, + }, + }, + }, + status: &ipnstate.Status{ + CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"}, + Self: &ipnstate.PeerStatus{ + CapMap: tailcfg.NodeCapMap{ + tailcfg.NodeAttrServiceHost: []tailcfg.RawMessage{svcIPMapJSONRawMSG}, + }, + }, + }, + prefs: &ipn.Prefs{ + AdvertiseServices: []string{"svc:foo"}, + }, + dnsName: "svc:foo", + srvType: serveTypeHTTP, + srvPort: 80, + expected: strings.Join([]string{ + msgServeAvailable, + "", + "http://foo.test.ts.net/", + "|-- proxy http://localhost:3000", + "", + fmt.Sprintf(msgRunningInBackground, "Serve"), + fmt.Sprintf(msgDisableServiceProxy, "svc:foo", "http", 80), + fmt.Sprintf(msgDisableService, "svc:foo"), + }, "\n"), + }, + { + name: "serve service no capmap", + subcmd: serve, + serveConfig: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:bar": { + TCP: map[uint16]*ipn.TCPPortHandler{ + 80: {HTTP: true}, + }, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "bar.test.ts.net:80": { + Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://localhost:3000"}, + }, + }, + }, + }, + }, + }, + status: &ipnstate.Status{ + CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"}, + Self: &ipnstate.PeerStatus{ + CapMap: tailcfg.NodeCapMap{ + tailcfg.NodeAttrServiceHost: []tailcfg.RawMessage{svcIPMapJSONRawMSG}, + }, + }, + }, + prefs: &ipn.Prefs{ + AdvertiseServices: []string{"svc:bar"}, + }, + dnsName: "svc:bar", + srvType: serveTypeHTTP, + srvPort: 80, + expected: strings.Join([]string{ + msgServiceIPNotAssigned, + "", + "http://bar.test.ts.net/", + "|-- proxy http://localhost:3000", + "", + fmt.Sprintf(msgRunningInBackground, "Serve"), + fmt.Sprintf(msgDisableServiceProxy, "svc:bar", "http", 80), + fmt.Sprintf(msgDisableService, "svc:bar"), + }, "\n"), + }, + { + name: "serve unadvertised service http", + subcmd: serve, + serveConfig: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:foo": { + TCP: map[uint16]*ipn.TCPPortHandler{ + 80: {HTTP: true}, + }, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:80": { + Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://localhost:3000"}, + }, + }, + }, + }, + }, + }, + status: &ipnstate.Status{ + CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"}, + Self: &ipnstate.PeerStatus{ + CapMap: tailcfg.NodeCapMap{ + tailcfg.NodeAttrServiceHost: []tailcfg.RawMessage{svcIPMapJSONRawMSG}, + }, + }, + }, + prefs: ipn.NewPrefs(), + dnsName: "svc:foo", + srvType: serveTypeHTTP, + srvPort: 80, + expected: strings.Join([]string{ + msgServeAvailable, + "", + "http://foo.test.ts.net/", + "|-- proxy http://localhost:3000", + "", + fmt.Sprintf(msgRunningInBackground, "Serve"), + fmt.Sprintf(msgServiceNotAdvertised, "svc:foo"), + fmt.Sprintf(msgDisableServiceProxy, "svc:foo", "http", 80), + fmt.Sprintf(msgDisableService, "svc:foo"), + }, "\n"), + }, + { + name: "serve service https non-default port", + subcmd: serve, + serveConfig: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:foo": { + TCP: map[uint16]*ipn.TCPPortHandler{ + 2200: {HTTPS: true}, + }, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:2200": { + Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://localhost:3000"}, + }, + }, + }, + }, + }, + }, + status: &ipnstate.Status{ + CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"}, + Self: &ipnstate.PeerStatus{ + CapMap: tailcfg.NodeCapMap{ + tailcfg.NodeAttrServiceHost: []tailcfg.RawMessage{svcIPMapJSONRawMSG}, + }, + }, + }, + prefs: &ipn.Prefs{AdvertiseServices: []string{"svc:foo"}}, + dnsName: "svc:foo", + srvType: serveTypeHTTPS, + srvPort: 2200, + expected: strings.Join([]string{ + msgServeAvailable, + "", + "https://foo.test.ts.net:2200/", + "|-- proxy http://localhost:3000", + "", + fmt.Sprintf(msgRunningInBackground, "Serve"), + fmt.Sprintf(msgDisableServiceProxy, "svc:foo", "https", 2200), + fmt.Sprintf(msgDisableService, "svc:foo"), + }, "\n"), + }, + { + name: "serve service TCPForward", + subcmd: serve, + serveConfig: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:foo": { + TCP: map[uint16]*ipn.TCPPortHandler{ + 2200: {TCPForward: "localhost:3000"}, + }, + }, + }, + }, + status: &ipnstate.Status{ + CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"}, + Self: &ipnstate.PeerStatus{ + CapMap: tailcfg.NodeCapMap{ + tailcfg.NodeAttrServiceHost: []tailcfg.RawMessage{svcIPMapJSONRawMSG}, + }, + }, + }, + prefs: &ipn.Prefs{AdvertiseServices: []string{"svc:foo"}}, + dnsName: "svc:foo", + srvType: serveTypeTCP, + srvPort: 2200, + expected: strings.Join([]string{ + msgServeAvailable, + "", + "|-- tcp://foo.test.ts.net:2200 (TLS over TCP)", + "|-- tcp://100.101.101.101:2200", + "|-- tcp://[fd7a:115c:a1e0:ab12:4843:cd96:6565:6565]:2200", + "|--> tcp://localhost:3000", + "", + fmt.Sprintf(msgRunningInBackground, "Serve"), + fmt.Sprintf(msgDisableServiceProxy, "svc:foo", "tcp", 2200), + fmt.Sprintf(msgDisableService, "svc:foo"), + }, "\n"), + }, + { + name: "serve service Tun", + subcmd: serve, + serveConfig: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:foo": { + Tun: true, + }, + }, + }, + status: &ipnstate.Status{ + CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"}, + Self: &ipnstate.PeerStatus{ + CapMap: tailcfg.NodeCapMap{ + tailcfg.NodeAttrServiceHost: []tailcfg.RawMessage{svcIPMapJSONRawMSG}, + }, + }, + }, + prefs: &ipn.Prefs{AdvertiseServices: []string{"svc:foo"}}, + dnsName: "svc:foo", + srvType: serveTypeTun, + expected: strings.Join([]string{ + msgServeAvailable, + "", + fmt.Sprintf(msgRunningTunServie, "foo.test.ts.net"), + fmt.Sprintf(msgDisableServiceTun, "svc:foo"), + fmt.Sprintf(msgDisableService, "svc:foo"), + }, "\n"), + }, } for _, tt := range tests { - e := &serveEnv{bg: true, subcmd: tt.subcmd} + e := &serveEnv{bg: bgBoolFlag{true, false}, subcmd: tt.subcmd} t.Run(tt.name, func(t *testing.T) { - actual := e.messageForPort(tt.serveConfig, tt.status, tt.dnsName, tt.srvType, tt.srvPort) + actual := e.messageForPort(tt.serveConfig, tt.status, tt.prefs, tt.dnsName, tt.srvType, tt.srvPort) if actual == "" { t.Errorf("Got empty message") @@ -1277,6 +1623,219 @@ func TestIsLegacyInvocation(t *testing.T) { } } +func TestSetServe(t *testing.T) { + e := &serveEnv{} + tests := []struct { + name string + desc string + cfg *ipn.ServeConfig + st *ipnstate.Status + dnsName string + srvType serveType + srvPort uint16 + mountPath string + target string + allowFunnel bool + expected *ipn.ServeConfig + expectErr bool + }{ + { + name: "add new handler", + desc: "add a new http handler to empty config", + cfg: &ipn.ServeConfig{}, + st: &ipnstate.Status{ + CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"}, + }, + dnsName: "foo.test.ts.net", + srvType: serveTypeHTTP, + srvPort: 80, + mountPath: "/", + target: "http://localhost:3000", + expected: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:80": { + Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://localhost:3000"}, + }, + }, + }, + }, + }, + { + name: "update http handler", + desc: "update an existing http handler on the same port to same type", + cfg: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:80": { + Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://localhost:3000"}, + }, + }, + }, + }, + st: &ipnstate.Status{ + CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"}, + }, + dnsName: "foo.test.ts.net", + srvType: serveTypeHTTP, + srvPort: 80, + mountPath: "/", + target: "http://localhost:3001", + expected: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "foo.test.ts.net:80": { + Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://localhost:3001"}, + }, + }, + }, + }, + }, + { + name: "update TCP handler", + desc: "update an existing TCP handler on the same port to a http handler", + cfg: &ipn.ServeConfig{ + TCP: map[uint16]*ipn.TCPPortHandler{80: {TCPForward: "http://localhost:3000"}}, + }, + st: &ipnstate.Status{ + CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"}, + }, + dnsName: "foo.test.ts.net", + srvType: serveTypeHTTP, + srvPort: 80, + mountPath: "/", + target: "http://localhost:3001", + expectErr: true, + }, + { + name: "add new service handler", + desc: "add a new service TCP handler to empty config", + cfg: &ipn.ServeConfig{}, + st: &ipnstate.Status{ + CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"}, + }, + dnsName: "svc:bar", + srvType: serveTypeTCP, + srvPort: 80, + target: "3000", + expected: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:bar": { + TCP: map[uint16]*ipn.TCPPortHandler{80: {TCPForward: "127.0.0.1:3000"}}, + }, + }, + }, + }, + { + name: "update service handler", + desc: "update an existing service TCP handler on the same port to same type", + cfg: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:bar": { + TCP: map[uint16]*ipn.TCPPortHandler{80: {TCPForward: "127.0.0.1:3000"}}, + }, + }, + }, + st: &ipnstate.Status{ + CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"}, + }, + dnsName: "svc:bar", + srvType: serveTypeTCP, + srvPort: 80, + target: "3001", + expected: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:bar": { + TCP: map[uint16]*ipn.TCPPortHandler{80: {TCPForward: "127.0.0.1:3001"}}, + }, + }, + }, + }, + { + name: "update service handler", + desc: "update an existing service TCP handler on the same port to a http handler", + cfg: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:bar": { + TCP: map[uint16]*ipn.TCPPortHandler{80: {TCPForward: "127.0.0.1:3000"}}, + }, + }, + }, + st: &ipnstate.Status{ + CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"}, + }, + dnsName: "svc:bar", + srvType: serveTypeHTTP, + srvPort: 80, + mountPath: "/", + target: "http://localhost:3001", + expectErr: true, + }, + { + name: "add new service handler", + desc: "add a new service HTTP handler to empty config", + cfg: &ipn.ServeConfig{}, + st: &ipnstate.Status{ + CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"}, + }, + dnsName: "svc:bar", + srvType: serveTypeHTTP, + srvPort: 80, + mountPath: "/", + target: "http://localhost:3000", + expected: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:bar": { + TCP: map[uint16]*ipn.TCPPortHandler{80: {HTTP: true}}, + Web: map[ipn.HostPort]*ipn.WebServerConfig{ + "bar.test.ts.net:80": { + Handlers: map[string]*ipn.HTTPHandler{ + "/": {Proxy: "http://localhost:3000"}, + }, + }, + }, + }, + }, + }, + }, + { + name: "add new service handler", + desc: "add a new service handler in tun mode to empty config", + cfg: &ipn.ServeConfig{}, + st: &ipnstate.Status{ + CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: "test.ts.net"}, + }, + dnsName: "svc:bar", + srvType: serveTypeTun, + expected: &ipn.ServeConfig{ + Services: map[tailcfg.ServiceName]*ipn.ServiceConfig{ + "svc:bar": { + Tun: true, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := e.setServe(tt.cfg, tt.st, tt.dnsName, tt.srvType, tt.srvPort, tt.mountPath, tt.target, tt.allowFunnel) + if err != nil && !tt.expectErr { + t.Fatalf("got error: %v; did not expect error.", err) + } + if err == nil && tt.expectErr { + t.Fatalf("got no error; expected error.") + } + if !tt.expectErr && !reflect.DeepEqual(tt.cfg, tt.expected) { + t.Logf("got: %v", tt.cfg.Services["svc:bar"].TCP[80]) + t.Fatalf("got: %v; expected: %v", tt.cfg, tt.expected) + } + }) + } +} + // exactErrMsg returns an error checker that wants exactly the provided want error. // If optName is non-empty, it's used in the error message. func exactErrMsg(want error) func(error) string { diff --git a/cmd/tailscale/cli/status.go b/cmd/tailscale/cli/status.go index e4dccc247..f6ec0d75d 100644 --- a/cmd/tailscale/cli/status.go +++ b/cmd/tailscale/cli/status.go @@ -260,7 +260,7 @@ func printFunnelStatus(ctx context.Context) { } sni, portStr, _ := net.SplitHostPort(string(hp)) p, _ := strconv.ParseUint(portStr, 10, 16) - isTCP := sc.IsTCPForwardingOnPort(uint16(p)) + isTCP := sc.IsTCPForwardingOnPort(uint16(p), sni) url := "https://" if isTCP { url = "tcp://" diff --git a/cmd/tsidp/tsidp.go b/cmd/tsidp/tsidp.go index 3eabef245..4945829f9 100644 --- a/cmd/tsidp/tsidp.go +++ b/cmd/tsidp/tsidp.go @@ -263,7 +263,7 @@ func serveOnLocalTailscaled(ctx context.Context, lc *local.Client, st *ipnstate. fmt.Printf("setting funnel for %s:%v\n", serverURL, dstPort) foregroundSc.SetFunnel(serverURL, dstPort, shouldFunnel) - foregroundSc.SetWebHandler(&ipn.HTTPHandler{ + foregroundSc.SetWebHandler(st, &ipn.HTTPHandler{ Proxy: fmt.Sprintf("https://%s", net.JoinHostPort(serverURL, strconv.Itoa(int(dstPort)))), }, serverURL, uint16(*flagPort), "/", true) err = lc.SetServeConfig(ctx, sc) diff --git a/ipn/serve.go b/ipn/serve.go index ac92287bd..105ebc592 100644 --- a/ipn/serve.go +++ b/ipn/serve.go @@ -166,15 +166,26 @@ type HTTPHandler struct { // WebHandlerExists reports whether if the ServeConfig Web handler exists for // the given host:port and mount point. -func (sc *ServeConfig) WebHandlerExists(hp HostPort, mount string) bool { - h := sc.GetWebHandler(hp, mount) +func (sc *ServeConfig) WebHandlerExists(dnsName string, hp HostPort, mount string) bool { + h := sc.GetWebHandler(dnsName, hp, mount) return h != nil } // GetWebHandler returns the HTTPHandler for the given host:port and mount point. // Returns nil if the handler does not exist. -func (sc *ServeConfig) GetWebHandler(hp HostPort, mount string) *HTTPHandler { - if sc == nil || sc.Web[hp] == nil { +func (sc *ServeConfig) GetWebHandler(dnsName string, hp HostPort, mount string) *HTTPHandler { + if sc == nil { + return nil + } + if IsServiceName(dnsName) { + if svc, ok := sc.Services[tailcfg.ServiceName(dnsName)]; ok && svc.Web != nil { + if webCfg, ok := svc.Web[hp]; ok { + return webCfg.Handlers[mount] + } + } + return nil + } + if sc.Web[hp] == nil { return nil } return sc.Web[hp].Handlers[mount] @@ -182,10 +193,16 @@ func (sc *ServeConfig) GetWebHandler(hp HostPort, mount string) *HTTPHandler { // GetTCPPortHandler returns the TCPPortHandler for the given port. // If the port is not configured, nil is returned. -func (sc *ServeConfig) GetTCPPortHandler(port uint16) *TCPPortHandler { +func (sc *ServeConfig) GetTCPPortHandler(port uint16, dnsName string) *TCPPortHandler { if sc == nil { return nil } + if IsServiceName(dnsName) { + if svc, ok := sc.Services[tailcfg.ServiceName(dnsName)]; ok && svc != nil { + return svc.TCP[port] + } + return nil + } return sc.TCP[port] } @@ -227,34 +244,78 @@ func (sc *ServeConfig) IsTCPForwardingAny() bool { return false } -// IsTCPForwardingOnPort reports whether if ServeConfig is currently forwarding -// in TCPForward mode on the given port. This is exclusive of Web/HTTPS serving. -func (sc *ServeConfig) IsTCPForwardingOnPort(port uint16) bool { - if sc == nil || sc.TCP[port] == nil { +// IsServiceName reports whether if the given string is a valid service name. +func IsServiceName(s string) bool { + return tailcfg.ServiceName(s).Validate() == nil +} + +// IsTCPForwardingOnPort reports whether ServeConfig is currently forwarding +// in TCPForward mode on the given port for a DNSName. DNSName will be either node's DNSName, or a +// serviceName for service hosted on node. This is exclusive of Web/HTTPS serving. +func (sc *ServeConfig) IsTCPForwardingOnPort(port uint16, dnsName string) bool { + if sc == nil { return false } - return !sc.IsServingWeb(port) + forService := IsServiceName(dnsName) + if forService { + svc, ok := sc.Services[tailcfg.ServiceName(dnsName)] + if !ok || svc == nil { + return false + } + if svc.TCP[port] == nil { + return false + } + } else if sc.TCP[port] == nil { + return false + } + return !sc.IsServingWeb(port, dnsName) } -// IsServingWeb reports whether if ServeConfig is currently serving Web -// (HTTP/HTTPS) on the given port. This is exclusive of TCPForwarding. -func (sc *ServeConfig) IsServingWeb(port uint16) bool { - return sc.IsServingHTTP(port) || sc.IsServingHTTPS(port) +// IsServingWeb reports whether ServeConfig is currently serving Web +// (HTTP/HTTPS) on the given port for a DNSName. DNSName will be either node's DNSName, or a +// serviceName for service hosted on node. This is exclusive of TCPForwarding. +func (sc *ServeConfig) IsServingWeb(port uint16, dnsName string) bool { + return sc.IsServingHTTP(port, dnsName) || sc.IsServingHTTPS(port, dnsName) } -// IsServingHTTPS reports whether if ServeConfig is currently serving HTTPS on -// the given port. This is exclusive of HTTP and TCPForwarding. -func (sc *ServeConfig) IsServingHTTPS(port uint16) bool { - if sc == nil || sc.TCP[port] == nil { +// IsServingHTTPS reports whether ServeConfig is currently serving HTTPS on +// the given port for a DNSName. DNSName will be either node's DNSName, or a +// serviceName for service hosted on node. This is exclusive of HTTP and TCPForwarding. +func (sc *ServeConfig) IsServingHTTPS(port uint16, dnsName string) bool { + if sc == nil { + return false + } + if IsServiceName(dnsName) { + if svc, ok := sc.Services[tailcfg.ServiceName(dnsName)]; ok && svc != nil { + if svc.TCP[port] != nil { + return svc.TCP[port].HTTPS + } + } + return false + } + if sc.TCP[port] == nil { return false } return sc.TCP[port].HTTPS } -// IsServingHTTP reports whether if ServeConfig is currently serving HTTP on the -// given port. This is exclusive of HTTPS and TCPForwarding. -func (sc *ServeConfig) IsServingHTTP(port uint16) bool { - if sc == nil || sc.TCP[port] == nil { +// IsServingHTTP reports whether ServeConfig is currently serving HTTP on the +// given port for a DNSName. DNSName will be either node's DNSName, or a +// serviceName for service hosted on node. This is exclusive of HTTPS and TCPForwarding. +func (sc *ServeConfig) IsServingHTTP(port uint16, dnsName string) bool { + if sc == nil { + return false + } + if IsServiceName(dnsName) { + if svc, ok := sc.Services[tailcfg.ServiceName(dnsName)]; ok && svc != nil { + if svc.TCP[port] != nil { + return svc.TCP[port].HTTP + } + } + return false + } + + if sc.TCP[port] == nil { return false } return sc.TCP[port].HTTP @@ -278,23 +339,51 @@ func (sc *ServeConfig) FindConfig(port uint16) (*ServeConfig, bool) { return nil, false } +// FindServiceConfig finds the ServiceConfig for the given service name when it +// is hosting the given port. +func (sc *ServeConfig) FindServiceConfig(svcName tailcfg.ServiceName) *ServiceConfig { + if sc == nil { + return nil + } + if svc, ok := sc.Services[svcName]; ok && svc != nil { + return svc + } + return nil +} + // SetWebHandler sets the given HTTPHandler at the specified host, port, // and mount in the serve config. sc.TCP is also updated to reflect web // serving usage of the given port. -func (sc *ServeConfig) SetWebHandler(handler *HTTPHandler, host string, port uint16, mount string, useTLS bool) { +func (sc *ServeConfig) SetWebHandler(st *ipnstate.Status, handler *HTTPHandler, host string, port uint16, mount string, useTLS bool) { if sc == nil { sc = new(ServeConfig) } - mak.Set(&sc.TCP, port, &TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS}) - - hp := HostPort(net.JoinHostPort(host, strconv.Itoa(int(port)))) - if _, ok := sc.Web[hp]; !ok { - mak.Set(&sc.Web, hp, new(WebServerConfig)) + var hp HostPort + var webCfg *WebServerConfig + if IsServiceName(host) { + svcName := tailcfg.ServiceName(host) + dnsNameForService := svcName.WithoutPrefix() + "." + st.CurrentTailnet.MagicDNSSuffix + if _, ok := sc.Services[svcName]; !ok { + mak.Set(&sc.Services, svcName, new(ServiceConfig)) + } + mak.Set(&sc.Services[svcName].TCP, port, &TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS}) + hp = HostPort(net.JoinHostPort(dnsNameForService, strconv.Itoa(int(port)))) + if _, ok := sc.Services[svcName].Web[hp]; !ok { + mak.Set(&sc.Services[svcName].Web, hp, new(WebServerConfig)) + } + mak.Set(&sc.Services[svcName].Web[hp].Handlers, mount, handler) + webCfg = sc.Services[svcName].Web[hp] + } else { + mak.Set(&sc.TCP, port, &TCPPortHandler{HTTPS: useTLS, HTTP: !useTLS}) + hp = HostPort(net.JoinHostPort(host, strconv.Itoa(int(port)))) + if _, ok := sc.Web[hp]; !ok { + mak.Set(&sc.Web, hp, new(WebServerConfig)) + } + mak.Set(&sc.Web[hp].Handlers, mount, handler) + webCfg = sc.Web[hp] } - mak.Set(&sc.Web[hp].Handlers, mount, handler) - // TODO(tylersmalley): handle multiple web handlers from foreground mode - for k, v := range sc.Web[hp].Handlers { + for k, v := range webCfg.Handlers { if v == handler { continue } @@ -305,9 +394,10 @@ func (sc *ServeConfig) SetWebHandler(handler *HTTPHandler, host string, port uin m1 := strings.TrimSuffix(mount, "/") m2 := strings.TrimSuffix(k, "/") if m1 == m2 { - delete(sc.Web[hp].Handlers, k) + delete(webCfg.Handlers, k) } } + } // SetTCPForwarding sets the fwdAddr (IP:port form) to which to forward @@ -318,9 +408,20 @@ func (sc *ServeConfig) SetTCPForwarding(port uint16, fwdAddr string, terminateTL if sc == nil { sc = new(ServeConfig) } - mak.Set(&sc.TCP, port, &TCPPortHandler{TCPForward: fwdAddr}) + var tcpPortHandler *TCPPortHandler + if IsServiceName(host) { + svcName := tailcfg.ServiceName(host) + if _, ok := sc.Services[svcName]; !ok { + mak.Set(&sc.Services, svcName, new(ServiceConfig)) + } + mak.Set(&sc.Services[svcName].TCP, port, &TCPPortHandler{TCPForward: fwdAddr}) + tcpPortHandler = sc.Services[svcName].TCP[port] + } else { + mak.Set(&sc.TCP, port, &TCPPortHandler{TCPForward: fwdAddr}) + tcpPortHandler = sc.TCP[port] + } if terminateTLS { - sc.TCP[port].TerminateTLS = host + tcpPortHandler.TerminateTLS = host } } @@ -345,7 +446,7 @@ func (sc *ServeConfig) SetFunnel(host string, port uint16, setOn bool) { } // RemoveWebHandler deletes the web handlers at all of the given mount points -// for the provided host and port in the serve config. If cleanupFunnel is +// for the provided host and port in the serve config for node. If cleanupFunnel is // true, this also removes the funnel value for this port if no handlers remain. func (sc *ServeConfig) RemoveWebHandler(host string, port uint16, mounts []string, cleanupFunnel bool) { hp := HostPort(net.JoinHostPort(host, strconv.Itoa(int(port)))) @@ -374,9 +475,39 @@ func (sc *ServeConfig) RemoveWebHandler(host string, port uint16, mounts []strin } } +// RemoveServiceWebHandler deletes the web handlers at all of the given mount points +// for the provided host and port in the serve config for the given service. +func (sc *ServeConfig) RemoveServiceWebHandler(st *ipnstate.Status, svcName tailcfg.ServiceName, port uint16, mounts []string) { + dnsNameForService := svcName.WithoutPrefix() + "." + st.CurrentTailnet.MagicDNSSuffix + hp := HostPort(net.JoinHostPort(dnsNameForService, strconv.Itoa(int(port)))) + + svc, ok := sc.Services[svcName] + if !ok || svc == nil { + return + } + + // Delete existing handler, then cascade delete if empty. + for _, m := range mounts { + delete(svc.Web[hp].Handlers, m) + } + if len(svc.Web[hp].Handlers) == 0 { + delete(svc.Web, hp) + delete(svc.TCP, port) + } +} + // RemoveTCPForwarding deletes the TCP forwarding configuration for the given // port from the serve config. -func (sc *ServeConfig) RemoveTCPForwarding(port uint16) { +func (sc *ServeConfig) RemoveTCPForwarding(dnsName string, port uint16) { + if IsServiceName(dnsName) { + if svc, ok := sc.Services[tailcfg.ServiceName(dnsName)]; ok && svc != nil { + delete(svc.TCP, port) + if len(svc.TCP) == 0 { + svc.TCP = nil + } + } + return + } delete(sc.TCP, port) if len(sc.TCP) == 0 { sc.TCP = nil diff --git a/ipn/serve_test.go b/ipn/serve_test.go index ae1d56eef..c4a919292 100644 --- a/ipn/serve_test.go +++ b/ipn/serve_test.go @@ -127,6 +127,121 @@ func TestHasPathHandler(t *testing.T) { } } +func TestIsTCPForwardingOnPort(t *testing.T) { + tests := []struct { + name string + cfg ServeConfig + dns string + port uint16 + want bool + }{ + { + name: "empty-config", + cfg: ServeConfig{}, + dns: "foo.test.ts.net", + port: 80, + want: false, + }, + { + name: "node-tcp-config-match", + cfg: ServeConfig{ + TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}}, + }, + dns: "foo.test.ts.net", + port: 80, + want: true, + }, + { + name: "node-tcp-config-no-match", + cfg: ServeConfig{ + TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}}, + }, + dns: "foo.test.ts.net", + port: 443, + want: false, + }, + { + name: "node-tcp-config-no-match-with-service", + cfg: ServeConfig{ + TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}}, + }, + dns: "svc:bar", + port: 80, + want: false, + }, + { + name: "node-web-config-no-match", + cfg: ServeConfig{ + TCP: map[uint16]*TCPPortHandler{80: {HTTPS: true}}, + Web: map[HostPort]*WebServerConfig{ + "foo.test.ts.net:80": { + Handlers: map[string]*HTTPHandler{ + "/": {Text: "Hello, world!"}, + }, + }, + }, + }, + dns: "foo.test.ts.net", + port: 80, + want: false, + }, + { + name: "service-tcp-config-match", + cfg: ServeConfig{ + Services: map[tailcfg.ServiceName]*ServiceConfig{ + "svc:foo": { + TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}}, + }, + }, + }, + dns: "svc:foo", + port: 80, + want: true, + }, + { + name: "service-tcp-config-no-match", + cfg: ServeConfig{ + Services: map[tailcfg.ServiceName]*ServiceConfig{ + "svc:foo": { + TCP: map[uint16]*TCPPortHandler{80: {TCPForward: "10.0.0.123:3000"}}, + }, + }, + }, + dns: "svc:bar", + port: 80, + want: false, + }, + { + name: "service-web-config-no-match", + cfg: ServeConfig{ + Services: map[tailcfg.ServiceName]*ServiceConfig{ + "svc:foo": { + TCP: map[uint16]*TCPPortHandler{80: {HTTPS: true}}, + Web: map[HostPort]*WebServerConfig{ + "foo.test.ts.net:80": { + Handlers: map[string]*HTTPHandler{ + "/": {Text: "Hello, world!"}, + }, + }, + }, + }, + }, + }, + dns: "svc:foo", + port: 80, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.cfg.IsTCPForwardingOnPort(tt.port, tt.dns) + if tt.want != got { + t.Errorf("IsTCPForwardingOnPort() = %v, want %v", got, tt.want) + } + }) + } +} + func TestExpandProxyTargetDev(t *testing.T) { tests := []struct { name string