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 <mail@nsood.in>
pull/17281/head
Naman Sood 2 months ago committed by GitHub
parent 08eae9affd
commit f157f3288d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

@ -28,10 +28,13 @@ import (
"github.com/peterbourgon/ff/v3/ffcli" "github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/local" "tailscale.com/client/local"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/ipn/conffile"
"tailscale.com/ipn/ipnstate" "tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/types/ipproto"
"tailscale.com/util/mak" "tailscale.com/util/mak"
"tailscale.com/util/prompt" "tailscale.com/util/prompt"
"tailscale.com/util/set"
"tailscale.com/util/slicesx" "tailscale.com/util/slicesx"
"tailscale.com/version" "tailscale.com/version"
) )
@ -128,6 +131,22 @@ const (
serveTypeTUN 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 = "" const noService tailcfg.ServiceName = ""
var infoMap = map[serveMode]commandInfo{ 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.", "`tailscale serve drain <service>`). This is not needed if you are using `tailscale serve` to initialize a service.",
Exec: e.runServeAdvertise, 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 { func (e *serveEnv) runServeAdvertise(ctx context.Context, args []string) error {
if len(args) == 0 { if len(args) == 0 {
return fmt.Errorf("error: missing service name argument") return errors.New("error: missing service name argument")
} }
if len(args) != 1 { if len(args) != 1 {
fmt.Fprintf(Stderr, "error: invalid number of arguments\n\n") 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) 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" 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. // validateConfig checks if the serve config is valid to serve the type wanted on the port.

@ -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/scpd from github.com/tailscale/goupnp
github.com/tailscale/goupnp/soap 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/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/tailscale/web-client-prebuilt from tailscale.com/client/web
github.com/toqueteos/webbrowser from tailscale.com/cmd/tailscale/cli+ github.com/toqueteos/webbrowser from tailscale.com/cmd/tailscale/cli+
github.com/x448/float16 from github.com/fxamacker/cbor/v2 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/hostinfo from tailscale.com/client/web+
tailscale.com/internal/client/tailscale from tailscale.com/cmd/tailscale/cli+ tailscale.com/internal/client/tailscale from tailscale.com/cmd/tailscale/cli+
tailscale.com/ipn from tailscale.com/client/local+ 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/ipn/ipnstate from tailscale.com/client/local+
tailscale.com/kube/kubetypes from tailscale.com/envknob tailscale.com/kube/kubetypes from tailscale.com/envknob
tailscale.com/licenses from tailscale.com/client/web+ 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/tsdial from tailscale.com/cmd/tailscale/cli+
💣 tailscale.com/net/tshttpproxy from tailscale.com/feature/useproxy 💣 tailscale.com/net/tshttpproxy from tailscale.com/feature/useproxy
tailscale.com/net/udprelay/status from tailscale.com/client/local+ 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/paths from tailscale.com/client/local+
💣 tailscale.com/safesocket from tailscale.com/client/local+ 💣 tailscale.com/safesocket from tailscale.com/client/local+
tailscale.com/syncs from tailscale.com/control/controlhttp+ tailscale.com/syncs from tailscale.com/control/controlhttp+

@ -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
}

@ -5,7 +5,6 @@ package tailcfg
import ( import (
"errors" "errors"
"fmt"
"strconv" "strconv"
"strings" "strings"
@ -70,14 +69,7 @@ func (ppr ProtoPortRange) String() string {
buf.Write(text) buf.Write(text)
buf.Write([]byte(":")) buf.Write([]byte(":"))
} }
pr := ppr.Ports buf.WriteString(ppr.Ports.String())
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)
}
return buf.String() return buf.String()
} }
@ -104,7 +96,7 @@ func parseProtoPortRange(ipProtoPort string) (*ProtoPortRange, error) {
if !strings.Contains(ipProtoPort, ":") { if !strings.Contains(ipProtoPort, ":") {
ipProtoPort = "*:" + ipProtoPort ipProtoPort = "*:" + ipProtoPort
} }
protoStr, portRange, err := parseHostPortRange(ipProtoPort) protoStr, portRange, err := ParseHostPortRange(ipProtoPort)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -126,9 +118,9 @@ func parseProtoPortRange(ipProtoPort string) (*ProtoPortRange, error) {
return ppr, nil 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. // 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) hostport = strings.ToLower(hostport)
colon := strings.LastIndexByte(hostport, ':') colon := strings.LastIndexByte(hostport, ':')
if colon < 0 { if colon < 0 {

@ -17,6 +17,7 @@ import (
"net/netip" "net/netip"
"reflect" "reflect"
"slices" "slices"
"strconv"
"strings" "strings"
"time" "time"
@ -1478,6 +1479,15 @@ func (pr PortRange) Contains(port uint16) bool {
var PortRangeAny = PortRange{0, 65535} 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. // NetPortRange represents a range of ports that's allowed for one or more IPs.
type NetPortRange struct { type NetPortRange struct {
_ structs.Incomparable _ structs.Incomparable

Loading…
Cancel
Save