2
0
Эх сурвалжийг харах

cmd/tailscale/cli,ipn/conffile: add declarative config mode for Services (#17435)

This commit adds the subcommands `get-config` and `set-config` to Serve,
which can be used to read the current Tailscale Services configuration
in a standard syntax and provide a configuration to declaratively apply
with that same syntax.

Both commands must be provided with either `--service=svc:service` for
one service, or `--all` for all services. When writing a config,
`--set-config --all` will overwrite all existing Services configuration,
and `--set-config --service=svc:service` will overwrite all
configuration for that particular Service. Incremental changes are not
supported.

Fixes tailscale/corp#30983.

cmd/tailscale/cli: hide serve "get-config"/"set-config" commands for now

tailscale/corp#33152 tracks unhiding them when docs exist.

Signed-off-by: Naman Sood <[email protected]>
Naman Sood 4 сар өмнө
parent
commit
f157f3288d

+ 1 - 0
cmd/tailscale/cli/serve_legacy.go

@@ -172,6 +172,7 @@ type serveEnv struct {
 	yes              bool                // update without prompt
 	service          tailcfg.ServiceName // service name
 	tun              bool                // redirect traffic to OS for service
+	allServices      bool                // apply config file to all services
 
 	lc localServeClient // localClient interface, specific to serve
 

+ 299 - 1
cmd/tailscale/cli/serve_v2.go

@@ -28,10 +28,13 @@ import (
 	"github.com/peterbourgon/ff/v3/ffcli"
 	"tailscale.com/client/local"
 	"tailscale.com/ipn"
+	"tailscale.com/ipn/conffile"
 	"tailscale.com/ipn/ipnstate"
 	"tailscale.com/tailcfg"
+	"tailscale.com/types/ipproto"
 	"tailscale.com/util/mak"
 	"tailscale.com/util/prompt"
+	"tailscale.com/util/set"
 	"tailscale.com/util/slicesx"
 	"tailscale.com/version"
 )
@@ -128,6 +131,22 @@ const (
 	serveTypeTUN
 )
 
+func serveTypeFromConfString(sp conffile.ServiceProtocol) (st serveType, ok bool) {
+	switch sp {
+	case conffile.ProtoHTTP:
+		return serveTypeHTTP, true
+	case conffile.ProtoHTTPS, conffile.ProtoHTTPSInsecure, conffile.ProtoFile:
+		return serveTypeHTTPS, true
+	case conffile.ProtoTCP:
+		return serveTypeTCP, true
+	case conffile.ProtoTLSTerminatedTCP:
+		return serveTypeTLSTerminatedTCP, true
+	case conffile.ProtoTUN:
+		return serveTypeTUN, true
+	}
+	return -1, false
+}
+
 const noService tailcfg.ServiceName = ""
 
 var infoMap = map[serveMode]commandInfo{
@@ -232,6 +251,33 @@ func newServeV2Command(e *serveEnv, subcmd serveMode) *ffcli.Command {
 					"`tailscale serve drain <service>`). This is not needed if you are using `tailscale serve` to initialize a service.",
 				Exec: e.runServeAdvertise,
 			},
+			{
+				Name:       "get-config",
+				ShortUsage: fmt.Sprintf("tailscale %s get-config <file> [--service=<service>] [--all]", info.Name),
+				ShortHelp:  "Get service configuration to save to a file",
+				LongHelp: hidden + "Get the configuration for services that this node is currently hosting in a\n" +
+					"format that can later be provided to set-config. This can be used to declaratively set\n" +
+					"configuration for a service host.",
+				Exec: e.runServeGetConfig,
+				FlagSet: e.newFlags("serve-get-config", func(fs *flag.FlagSet) {
+					fs.BoolVar(&e.allServices, "all", false, "read config from all services")
+					fs.Var(&serviceNameFlag{Value: &e.service}, "service", "read config from a particular service")
+				}),
+			},
+			{
+				Name:       "set-config",
+				ShortUsage: fmt.Sprintf("tailscale %s set-config <file> [--service=<service>] [--all]", info.Name),
+				ShortHelp:  "Define service configuration from a file",
+				LongHelp: hidden + "Read the provided configuration file and use it to declaratively set the configuration\n" +
+					"for either a single service, or for all services that this node is hosting. If --service is specified,\n" +
+					"all endpoint handlers for that service are overwritten. If --all is specified, all endpoint handlers for\n" +
+					"all services are overwritten.",
+				Exec: e.runServeSetConfig,
+				FlagSet: e.newFlags("serve-set-config", func(fs *flag.FlagSet) {
+					fs.BoolVar(&e.allServices, "all", false, "apply config to all services")
+					fs.Var(&serviceNameFlag{Value: &e.service}, "service", "apply config to a particular service")
+				}),
+			},
 		},
 	}
 }
@@ -540,7 +586,7 @@ func (e *serveEnv) runServeClear(ctx context.Context, args []string) error {
 
 func (e *serveEnv) runServeAdvertise(ctx context.Context, args []string) error {
 	if len(args) == 0 {
-		return fmt.Errorf("error: missing service name argument")
+		return errors.New("error: missing service name argument")
 	}
 	if len(args) != 1 {
 		fmt.Fprintf(Stderr, "error: invalid number of arguments\n\n")
@@ -553,6 +599,258 @@ func (e *serveEnv) runServeAdvertise(ctx context.Context, args []string) error {
 	return e.addServiceToPrefs(ctx, svc)
 }
 
+func (e *serveEnv) runServeGetConfig(ctx context.Context, args []string) (err error) {
+	forSingleService := e.service.Validate() == nil
+	sc, err := e.lc.GetServeConfig(ctx)
+	if err != nil {
+		return err
+	}
+
+	prefs, err := e.lc.GetPrefs(ctx)
+	if err != nil {
+		return err
+	}
+	advertised := set.SetOf(prefs.AdvertiseServices)
+
+	st, err := e.getLocalClientStatusWithoutPeers(ctx)
+	if err != nil {
+		return err
+	}
+	magicDNSSuffix := st.CurrentTailnet.MagicDNSSuffix
+
+	handleService := func(svcName tailcfg.ServiceName, serviceConfig *ipn.ServiceConfig) (*conffile.ServiceDetailsFile, error) {
+		var sdf conffile.ServiceDetailsFile
+		// Leave unset for true case since that's the default.
+		if !advertised.Contains(svcName.String()) {
+			sdf.Advertised.Set(false)
+		}
+
+		if serviceConfig.Tun {
+			mak.Set(&sdf.Endpoints, &tailcfg.ProtoPortRange{Ports: tailcfg.PortRangeAny}, &conffile.Target{
+				Protocol:         conffile.ProtoTUN,
+				Destination:      "",
+				DestinationPorts: tailcfg.PortRange{},
+			})
+		}
+
+		for port, config := range serviceConfig.TCP {
+			sniName := fmt.Sprintf("%s.%s", svcName.WithoutPrefix(), magicDNSSuffix)
+			ppr := tailcfg.ProtoPortRange{Proto: int(ipproto.TCP), Ports: tailcfg.PortRange{First: port, Last: port}}
+			if config.TCPForward != "" {
+				var proto conffile.ServiceProtocol
+				if config.TerminateTLS != "" {
+					proto = conffile.ProtoTLSTerminatedTCP
+				} else {
+					proto = conffile.ProtoTCP
+				}
+				destHost, destPortStr, err := net.SplitHostPort(config.TCPForward)
+				if err != nil {
+					return nil, fmt.Errorf("parse TCPForward=%q: %w", config.TCPForward, err)
+				}
+				destPort, err := strconv.ParseUint(destPortStr, 10, 16)
+				if err != nil {
+					return nil, fmt.Errorf("parse port %q: %w", destPortStr, err)
+				}
+				mak.Set(&sdf.Endpoints, &ppr, &conffile.Target{
+					Protocol:         proto,
+					Destination:      destHost,
+					DestinationPorts: tailcfg.PortRange{First: uint16(destPort), Last: uint16(destPort)},
+				})
+			} else if config.HTTP || config.HTTPS {
+				webKey := ipn.HostPort(net.JoinHostPort(sniName, strconv.FormatUint(uint64(port), 10)))
+				handlers, ok := serviceConfig.Web[webKey]
+				if !ok {
+					return nil, fmt.Errorf("service %q: HTTP/HTTPS is set but no handlers in config", svcName)
+				}
+				defaultHandler, ok := handlers.Handlers["/"]
+				if !ok {
+					return nil, fmt.Errorf("service %q: root handler not set", svcName)
+				}
+				if defaultHandler.Path != "" {
+					mak.Set(&sdf.Endpoints, &ppr, &conffile.Target{
+						Protocol:         conffile.ProtoFile,
+						Destination:      defaultHandler.Path,
+						DestinationPorts: tailcfg.PortRange{},
+					})
+				} else if defaultHandler.Proxy != "" {
+					proto, rest, ok := strings.Cut(defaultHandler.Proxy, "://")
+					if !ok {
+						return nil, fmt.Errorf("service %q: invalid proxy handler %q", svcName, defaultHandler.Proxy)
+					}
+					host, portStr, err := net.SplitHostPort(rest)
+					if err != nil {
+						return nil, fmt.Errorf("service %q: invalid proxy handler %q: %w", svcName, defaultHandler.Proxy, err)
+					}
+
+					port, err := strconv.ParseUint(portStr, 10, 16)
+					if err != nil {
+						return nil, fmt.Errorf("service %q: parse port %q: %w", svcName, portStr, err)
+					}
+
+					mak.Set(&sdf.Endpoints, &ppr, &conffile.Target{
+						Protocol:         conffile.ServiceProtocol(proto),
+						Destination:      host,
+						DestinationPorts: tailcfg.PortRange{First: uint16(port), Last: uint16(port)},
+					})
+				}
+			}
+		}
+
+		return &sdf, nil
+	}
+
+	var j []byte
+
+	if e.allServices && forSingleService {
+		return errors.New("cannot specify both --all and --service")
+	} else if e.allServices {
+		var scf conffile.ServicesConfigFile
+		scf.Version = "0.0.1"
+		for svcName, serviceConfig := range sc.Services {
+			sdf, err := handleService(svcName, serviceConfig)
+			if err != nil {
+				return err
+			}
+			mak.Set(&scf.Services, svcName, sdf)
+		}
+		j, err = json.MarshalIndent(scf, "", "  ")
+		if err != nil {
+			return err
+		}
+	} else if forSingleService {
+		serviceConfig, ok := sc.Services[e.service]
+		if !ok {
+			j = []byte("{}")
+		} else {
+			sdf, err := handleService(e.service, serviceConfig)
+			if err != nil {
+				return err
+			}
+			sdf.Version = "0.0.1"
+			j, err = json.MarshalIndent(sdf, "", "  ")
+			if err != nil {
+				return err
+			}
+		}
+	} else {
+		return errors.New("must specify either --service=svc:<service-name> or --all")
+	}
+
+	j = append(j, '\n')
+	_, err = e.stdout().Write(j)
+	return err
+}
+
+func (e *serveEnv) runServeSetConfig(ctx context.Context, args []string) (err error) {
+	if len(args) != 1 {
+		return errors.New("must specify filename")
+	}
+	forSingleService := e.service.Validate() == nil
+
+	var scf *conffile.ServicesConfigFile
+	if e.allServices && forSingleService {
+		return errors.New("cannot specify both --all and --service")
+	} else if e.allServices {
+		scf, err = conffile.LoadServicesConfig(args[0], "")
+	} else if forSingleService {
+		scf, err = conffile.LoadServicesConfig(args[0], e.service.String())
+	} else {
+		return errors.New("must specify either --service=svc:<service-name> or --all")
+	}
+	if err != nil {
+		return fmt.Errorf("could not read config from file %q: %w", args[0], err)
+	}
+
+	st, err := e.getLocalClientStatusWithoutPeers(ctx)
+	if err != nil {
+		return fmt.Errorf("getting client status: %w", err)
+	}
+	magicDNSSuffix := st.CurrentTailnet.MagicDNSSuffix
+	sc, err := e.lc.GetServeConfig(ctx)
+	if err != nil {
+		return fmt.Errorf("getting current serve config: %w", err)
+	}
+
+	// Clear all existing config.
+	if forSingleService {
+		if sc.Services != nil {
+			if sc.Services[e.service] != nil {
+				delete(sc.Services, e.service)
+			}
+		}
+	} else {
+		sc.Services = map[tailcfg.ServiceName]*ipn.ServiceConfig{}
+	}
+	advertisedServices := set.Set[string]{}
+
+	for name, details := range scf.Services {
+		for ppr, ep := range details.Endpoints {
+			if ep.Protocol == conffile.ProtoTUN {
+				err := e.setServe(sc, name.String(), serveTypeTUN, 0, "", "", false, magicDNSSuffix)
+				if err != nil {
+					return err
+				}
+				// TUN mode is exclusive.
+				break
+			}
+
+			if ppr.Proto != int(ipproto.TCP) {
+				return fmt.Errorf("service %q: source ports must be TCP", name)
+			}
+			serveType, _ := serveTypeFromConfString(ep.Protocol)
+			for port := ppr.Ports.First; port <= ppr.Ports.Last; port++ {
+				var target string
+				if ep.Protocol == conffile.ProtoFile {
+					target = ep.Destination
+				} else {
+					// map source port range 1-1 to destination port range
+					destPort := ep.DestinationPorts.First + (port - ppr.Ports.First)
+					portStr := fmt.Sprint(destPort)
+					target = fmt.Sprintf("%s://%s", ep.Protocol, net.JoinHostPort(ep.Destination, portStr))
+				}
+				err := e.setServe(sc, name.String(), serveType, port, "/", target, false, magicDNSSuffix)
+				if err != nil {
+					return fmt.Errorf("service %q: %w", name, err)
+				}
+			}
+		}
+		if v, set := details.Advertised.Get(); !set || v {
+			advertisedServices.Add(name.String())
+		}
+	}
+
+	var changed bool
+	var servicesList []string
+	if e.allServices {
+		servicesList = advertisedServices.Slice()
+		changed = true
+	} else if advertisedServices.Contains(e.service.String()) {
+		// If allServices wasn't set, the only service that could have been
+		// advertised is the one that was provided as a flag.
+		prefs, err := e.lc.GetPrefs(ctx)
+		if err != nil {
+			return err
+		}
+		if !slices.Contains(prefs.AdvertiseServices, e.service.String()) {
+			servicesList = append(prefs.AdvertiseServices, e.service.String())
+			changed = true
+		}
+	}
+	if changed {
+		_, err = e.lc.EditPrefs(ctx, &ipn.MaskedPrefs{
+			AdvertiseServicesSet: true,
+			Prefs: ipn.Prefs{
+				AdvertiseServices: servicesList,
+			},
+		})
+		if err != nil {
+			return err
+		}
+	}
+
+	return e.lc.SetServeConfig(ctx, sc)
+}
+
 const backgroundExistsMsg = "background configuration already exists, use `tailscale %s --%s=%d off` to remove the existing configuration"
 
 // validateConfig checks if the serve config is valid to serve the type wanted on the port.

+ 3 - 0
cmd/tailscale/depaware.txt

@@ -61,6 +61,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
         github.com/tailscale/goupnp/scpd                             from github.com/tailscale/goupnp
         github.com/tailscale/goupnp/soap                             from github.com/tailscale/goupnp+
         github.com/tailscale/goupnp/ssdp                             from github.com/tailscale/goupnp
+        github.com/tailscale/hujson                                  from tailscale.com/ipn/conffile
         github.com/tailscale/web-client-prebuilt                     from tailscale.com/client/web
         github.com/toqueteos/webbrowser                              from tailscale.com/cmd/tailscale/cli+
         github.com/x448/float16                                      from github.com/fxamacker/cbor/v2
@@ -109,6 +110,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
         tailscale.com/hostinfo                                       from tailscale.com/client/web+
         tailscale.com/internal/client/tailscale                      from tailscale.com/cmd/tailscale/cli+
         tailscale.com/ipn                                            from tailscale.com/client/local+
+        tailscale.com/ipn/conffile                                   from tailscale.com/cmd/tailscale/cli
         tailscale.com/ipn/ipnstate                                   from tailscale.com/client/local+
         tailscale.com/kube/kubetypes                                 from tailscale.com/envknob
         tailscale.com/licenses                                       from tailscale.com/client/web+
@@ -137,6 +139,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
         tailscale.com/net/tsdial                                     from tailscale.com/cmd/tailscale/cli+
      💣 tailscale.com/net/tshttpproxy                                from tailscale.com/feature/useproxy
         tailscale.com/net/udprelay/status                            from tailscale.com/client/local+
+        tailscale.com/omit                                           from tailscale.com/ipn/conffile
         tailscale.com/paths                                          from tailscale.com/client/local+
      💣 tailscale.com/safesocket                                     from tailscale.com/client/local+
         tailscale.com/syncs                                          from tailscale.com/control/controlhttp+

+ 239 - 0
ipn/conffile/serveconf.go

@@ -0,0 +1,239 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !ts_omit_serve
+
+package conffile
+
+import (
+	"errors"
+	"fmt"
+	"net"
+	"os"
+	"path"
+	"strings"
+
+	jsonv2 "github.com/go-json-experiment/json"
+	"github.com/go-json-experiment/json/jsontext"
+	"tailscale.com/tailcfg"
+	"tailscale.com/types/opt"
+	"tailscale.com/util/mak"
+)
+
+// ServicesConfigFile is the config file format for services configuration.
+type ServicesConfigFile struct {
+	// Version is always "0.0.1" and always present.
+	Version string `json:"version"`
+
+	Services map[tailcfg.ServiceName]*ServiceDetailsFile `json:"services,omitzero"`
+}
+
+// ServiceDetailsFile is the config syntax for an individual Tailscale Service.
+type ServiceDetailsFile struct {
+	// Version is always "0.0.1", set if and only if this is not inside a
+	// [ServiceConfigFile].
+	Version string `json:"version,omitzero"`
+
+	// Endpoints are sets of reverse proxy mappings from ProtoPortRanges on a
+	// Service to Targets (proto+destination+port) on remote destinations (or
+	// localhost).
+	// For example, "tcp:443" -> "tcp://localhost:8000" is an endpoint definition
+	// mapping traffic on the TCP port 443 of the Service to port 8080 on localhost.
+	// The Proto in the key must be populated.
+	// As a special case, if the only mapping provided is "*" -> "TUN", that
+	// enables TUN/L3 mode, where packets are delivered to the Tailscale network
+	// interface with the understanding that the user will deal with them manually.
+	Endpoints map[*tailcfg.ProtoPortRange]*Target `json:"endpoints"`
+
+	// Advertised is a flag that tells control whether or not the client thinks
+	// it is ready to host a particular Tailscale Service. If unset, it is
+	// assumed to be true.
+	Advertised opt.Bool `json:"advertised,omitzero"`
+}
+
+// ServiceProtocol is the protocol of a Target.
+type ServiceProtocol string
+
+const (
+	ProtoHTTP             ServiceProtocol = "http"
+	ProtoHTTPS            ServiceProtocol = "https"
+	ProtoHTTPSInsecure    ServiceProtocol = "https+insecure"
+	ProtoTCP              ServiceProtocol = "tcp"
+	ProtoTLSTerminatedTCP ServiceProtocol = "tls-terminated-tcp"
+	ProtoFile             ServiceProtocol = "file"
+	ProtoTUN              ServiceProtocol = "TUN"
+)
+
+// Target is a destination for traffic to go to when it arrives at a Tailscale
+// Service host.
+type Target struct {
+	// The protocol over which to communicate with the Destination.
+	// Protocol == ProtoTUN is a special case, activating "TUN mode" where
+	// packets are delivered to the Tailscale TUN interface and then manually
+	// handled by the user.
+	Protocol ServiceProtocol
+
+	// If Protocol is ProtoFile, then Destination is a file path.
+	// If Protocol is ProtoTUN, then Destination is empty.
+	// Otherwise, it is a host.
+	Destination string
+
+	// If Protocol is not ProtoFile or ProtoTUN, then DestinationPorts is the
+	// set of ports on which to connect to the host referred to by Destination.
+	DestinationPorts tailcfg.PortRange
+}
+
+// UnmarshalJSON implements [jsonv1.Unmarshaler].
+func (t *Target) UnmarshalJSON(buf []byte) error {
+	return jsonv2.Unmarshal(buf, t)
+}
+
+// UnmarshalJSONFrom implements [jsonv2.UnmarshalerFrom].
+func (t *Target) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
+	var str string
+	if err := jsonv2.UnmarshalDecode(dec, &str); err != nil {
+		return err
+	}
+
+	// The TUN case does not look like a standard <url>://<proto> arrangement,
+	// so handled separately.
+	if str == "TUN" {
+		t.Protocol = ProtoTUN
+		t.Destination = ""
+		t.DestinationPorts = tailcfg.PortRangeAny
+		return nil
+	}
+
+	proto, rest, found := strings.Cut(str, "://")
+	if !found {
+		return errors.New("handler not of form <proto>://<destination>")
+	}
+
+	switch ServiceProtocol(proto) {
+	case ProtoFile:
+		target := path.Clean(rest)
+		t.Protocol = ProtoFile
+		t.Destination = target
+		t.DestinationPorts = tailcfg.PortRange{}
+	case ProtoHTTP, ProtoHTTPS, ProtoHTTPSInsecure, ProtoTCP, ProtoTLSTerminatedTCP:
+		host, portRange, err := tailcfg.ParseHostPortRange(rest)
+		if err != nil {
+			return err
+		}
+		t.Protocol = ServiceProtocol(proto)
+		t.Destination = host
+		t.DestinationPorts = portRange
+	default:
+		return errors.New("unsupported protocol")
+	}
+
+	return nil
+}
+
+func (t *Target) MarshalText() ([]byte, error) {
+	var out string
+	switch t.Protocol {
+	case ProtoFile:
+		out = fmt.Sprintf("%s://%s", t.Protocol, t.Destination)
+	case ProtoTUN:
+		out = "TUN"
+	case ProtoHTTP, ProtoHTTPS, ProtoHTTPSInsecure, ProtoTCP, ProtoTLSTerminatedTCP:
+		out = fmt.Sprintf("%s://%s", t.Protocol, net.JoinHostPort(t.Destination, t.DestinationPorts.String()))
+	default:
+		return nil, errors.New("unsupported protocol")
+	}
+	return []byte(out), nil
+}
+
+func LoadServicesConfig(filename string, forService string) (*ServicesConfigFile, error) {
+	data, err := os.ReadFile(filename)
+	if err != nil {
+		return nil, err
+	}
+	var json []byte
+	if hujsonStandardize != nil {
+		json, err = hujsonStandardize(data)
+		if err != nil {
+			return nil, err
+		}
+	} else {
+		json = data
+	}
+	var ver struct {
+		Version string `json:"version"`
+	}
+	if err = jsonv2.Unmarshal(json, &ver); err != nil {
+		return nil, fmt.Errorf("could not parse config file version: %w", err)
+	}
+	switch ver.Version {
+	case "":
+		return nil, errors.New("config file must have \"version\" field")
+	case "0.0.1":
+		return loadConfigV0(json, forService)
+	}
+	return nil, fmt.Errorf("unsupported config file version %q", ver.Version)
+}
+
+func loadConfigV0(json []byte, forService string) (*ServicesConfigFile, error) {
+	var scf ServicesConfigFile
+	if svcName := tailcfg.AsServiceName(forService); svcName != "" {
+		var sdf ServiceDetailsFile
+		err := jsonv2.Unmarshal(json, &sdf, jsonv2.RejectUnknownMembers(true))
+		if err != nil {
+			return nil, err
+		}
+		mak.Set(&scf.Services, svcName, &sdf)
+
+	} else {
+		err := jsonv2.Unmarshal(json, &scf, jsonv2.RejectUnknownMembers(true))
+		if err != nil {
+			return nil, err
+		}
+	}
+	for svcName, svc := range scf.Services {
+		if forService == "" && svc.Version != "" {
+			return nil, errors.New("services cannot be versioned separately from config file")
+		}
+		if err := svcName.Validate(); err != nil {
+			return nil, err
+		}
+		if svc.Endpoints == nil {
+			return nil, fmt.Errorf("service %q: missing \"endpoints\" field", svcName)
+		}
+		var sourcePorts []tailcfg.PortRange
+		foundTUN := false
+		foundNonTUN := false
+		for ppr, target := range svc.Endpoints {
+			if target.Protocol == "TUN" {
+				if ppr.Proto != 0 || ppr.Ports != tailcfg.PortRangeAny {
+					return nil, fmt.Errorf("service %q: destination \"TUN\" can only be used with source \"*\"", svcName)
+				}
+				foundTUN = true
+			} else {
+				if ppr.Ports.Last-ppr.Ports.First != target.DestinationPorts.Last-target.DestinationPorts.First {
+					return nil, fmt.Errorf("service %q: source and destination port ranges must be of equal size", svcName.String())
+				}
+				foundNonTUN = true
+			}
+			if foundTUN && foundNonTUN {
+				return nil, fmt.Errorf("service %q: cannot mix TUN mode with non-TUN mode", svcName)
+			}
+			if pr := findOverlappingRange(sourcePorts, ppr.Ports); pr != nil {
+				return nil, fmt.Errorf("service %q: source port ranges %q and %q overlap", svcName, pr.String(), ppr.Ports.String())
+			}
+			sourcePorts = append(sourcePorts, ppr.Ports)
+		}
+	}
+	return &scf, nil
+}
+
+// findOverlappingRange finds and returns a reference to a [tailcfg.PortRange]
+// in haystack that overlaps with needle. It returns nil if it doesn't find one.
+func findOverlappingRange(haystack []tailcfg.PortRange, needle tailcfg.PortRange) *tailcfg.PortRange {
+	for _, pr := range haystack {
+		if pr.Contains(needle.First) || pr.Contains(needle.Last) || needle.Contains(pr.First) || needle.Contains(pr.Last) {
+			return &pr
+		}
+	}
+	return nil
+}

+ 4 - 12
tailcfg/proto_port_range.go

@@ -5,7 +5,6 @@ package tailcfg
 
 import (
 	"errors"
-	"fmt"
 	"strconv"
 	"strings"
 
@@ -70,14 +69,7 @@ func (ppr ProtoPortRange) String() string {
 		buf.Write(text)
 		buf.Write([]byte(":"))
 	}
-	pr := ppr.Ports
-	if pr.First == pr.Last {
-		fmt.Fprintf(&buf, "%d", pr.First)
-	} else if pr == PortRangeAny {
-		buf.WriteByte('*')
-	} else {
-		fmt.Fprintf(&buf, "%d-%d", pr.First, pr.Last)
-	}
+	buf.WriteString(ppr.Ports.String())
 	return buf.String()
 }
 
@@ -104,7 +96,7 @@ func parseProtoPortRange(ipProtoPort string) (*ProtoPortRange, error) {
 	if !strings.Contains(ipProtoPort, ":") {
 		ipProtoPort = "*:" + ipProtoPort
 	}
-	protoStr, portRange, err := parseHostPortRange(ipProtoPort)
+	protoStr, portRange, err := ParseHostPortRange(ipProtoPort)
 	if err != nil {
 		return nil, err
 	}
@@ -126,9 +118,9 @@ func parseProtoPortRange(ipProtoPort string) (*ProtoPortRange, error) {
 	return ppr, nil
 }
 
-// parseHostPortRange parses hostport as HOST:PORTS where HOST is
+// ParseHostPortRange parses hostport as HOST:PORTS where HOST is
 // returned unchanged and PORTS is is either "*" or PORTLOW-PORTHIGH ranges.
-func parseHostPortRange(hostport string) (host string, ports PortRange, err error) {
+func ParseHostPortRange(hostport string) (host string, ports PortRange, err error) {
 	hostport = strings.ToLower(hostport)
 	colon := strings.LastIndexByte(hostport, ':')
 	if colon < 0 {

+ 10 - 0
tailcfg/tailcfg.go

@@ -17,6 +17,7 @@ import (
 	"net/netip"
 	"reflect"
 	"slices"
+	"strconv"
 	"strings"
 	"time"
 
@@ -1478,6 +1479,15 @@ func (pr PortRange) Contains(port uint16) bool {
 
 var PortRangeAny = PortRange{0, 65535}
 
+func (pr PortRange) String() string {
+	if pr.First == pr.Last {
+		return strconv.FormatUint(uint64(pr.First), 10)
+	} else if pr == PortRangeAny {
+		return "*"
+	}
+	return fmt.Sprintf("%d-%d", pr.First, pr.Last)
+}
+
 // NetPortRange represents a range of ports that's allowed for one or more IPs.
 type NetPortRange struct {
 	_     structs.Incomparable