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>main
parent
08eae9affd
commit
f157f3288d
@ -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 |
||||
} |
||||
Loading…
Reference in new issue