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:
Naman Sood
2025-10-10 11:02:35 -04:00
committed by GitHub
parent 08eae9affd
commit f157f3288d
6 changed files with 556 additions and 13 deletions
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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+