mirror of
https://github.com/tailscale/tailscale.git
synced 2025-12-23 09:06:24 +00:00
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>
This commit is contained in:
@@ -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+
|
||||||
|
|||||||
239
ipn/conffile/serveconf.go
Normal file
239
ipn/conffile/serveconf.go
Normal file
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user