WIP: Support --config=kube:<secret-name>

This commit is contained in:
Tom Proctor 2024-09-26 12:22:05 +01:00
parent f6d4d03355
commit a51f41812b
11 changed files with 138 additions and 94 deletions

View File

@ -8,11 +8,9 @@
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net/http" "net/http"
"net/netip" "net/netip"
"os"
"tailscale.com/kube/kubeapi" "tailscale.com/kube/kubeapi"
"tailscale.com/kube/kubeclient" "tailscale.com/kube/kubeclient"
@ -85,9 +83,4 @@ func initKubeClient(root string) {
if err != nil { if err != nil {
log.Fatalf("Error creating kube client: %v", err) log.Fatalf("Error creating kube client: %v", err)
} }
if (root != "/") || os.Getenv("TS_KUBERNETES_READ_API_SERVER_ADDRESS_FROM_ENV") == "true" {
// Derive the API server address from the environment variables
// Used to set http server in tests, or optionally enabled by flag
kc.SetURL(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")))
}
} }

View File

@ -96,13 +96,13 @@
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
"iter"
"log" "log"
"math" "math"
"net" "net"
"net/netip" "net/netip"
"os" "os"
"os/signal" "os/signal"
"path"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
@ -721,7 +721,16 @@ func tailscaledConfigFilePath() string {
if err != nil { if err != nil {
log.Fatalf("error reading tailscaled config directory %q: %v", dir, err) log.Fatalf("error reading tailscaled config directory %q: %v", dir, err)
} }
maxCompatVer := tailcfg.CapabilityVersion(-1) selectedFile := kubeutils.SelectConfigFileName(fileNames(fe))
if selectedFile == "" {
log.Fatalf("no tailscaled config file found in %q for current capability version %q", dir, tailcfg.CurrentCapabilityVersion)
}
log.Printf("Using tailscaled config file %q", selectedFile)
return filepath.Join(dir, selectedFile)
}
func fileNames(fe []fs.DirEntry) iter.Seq[string] {
return func(yield func(string) bool) {
for _, e := range fe { for _, e := range fe {
// We don't check if type if file as in most cases this will // We don't check if type if file as in most cases this will
// come from a mounted kube Secret, where the directory contents // come from a mounted kube Secret, where the directory contents
@ -729,18 +738,9 @@ func tailscaledConfigFilePath() string {
if e.Type().IsDir() { if e.Type().IsDir() {
continue continue
} }
cv, err := kubeutils.CapVerFromFileName(e.Name()) if !yield(e.Name()) {
if err != nil { return
log.Printf("skipping file %q in tailscaled config directory %q: %v", e.Name(), dir, err)
continue
}
if cv > maxCompatVer && cv <= tailcfg.CurrentCapabilityVersion {
maxCompatVer = cv
} }
} }
if maxCompatVer == -1 {
log.Fatalf("no tailscaled config file found in %q for current capability version %q", dir, tailcfg.CurrentCapabilityVersion)
} }
log.Printf("Using tailscaled config file %q for capability version %q", maxCompatVer, tailcfg.CurrentCapabilityVersion)
return path.Join(dir, kubeutils.TailscaledConfigFileName(maxCompatVer))
} }

View File

@ -459,6 +459,7 @@ func pgTailscaledConfig(pg *tsapi.ProxyGroup, class *tsapi.ProxyClass, idx int32
conf.AuthKey = key conf.AuthKey = key
} }
capVerConfigs := make(map[tailcfg.CapabilityVersion]ipn.ConfigVAlpha) capVerConfigs := make(map[tailcfg.CapabilityVersion]ipn.ConfigVAlpha)
capVerConfigs[0] = *conf // Becomes "tailscaled" key.
capVerConfigs[106] = *conf capVerConfigs[106] = *conf
return capVerConfigs, nil return capVerConfigs, nil
} }

View File

@ -12,6 +12,7 @@
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"maps"
"net/http" "net/http"
"os" "os"
"slices" "slices"
@ -359,19 +360,12 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
return "", "", nil, fmt.Errorf("error calculating hash of tailscaled configs: %w", err) return "", "", nil, fmt.Errorf("error calculating hash of tailscaled configs: %w", err)
} }
latest := tailcfg.CapabilityVersion(-1) for capVer, cfg := range configs {
var latestConfig ipn.ConfigVAlpha b, err := json.Marshal(cfg)
for key, val := range configs {
fn := tsoperator.TailscaledConfigFileName(key)
b, err := json.Marshal(val)
if err != nil { if err != nil {
return "", "", nil, fmt.Errorf("error marshalling tailscaled config: %w", err) return "", "", nil, fmt.Errorf("error marshalling tailscaled config: %w", err)
} }
mak.Set(&secret.StringData, fn, string(b)) mak.Set(&secret.StringData, tsoperator.TailscaledConfigFileName(capVer), string(b))
if key > latest {
latest = key
latestConfig = val
}
} }
if stsC.ServeConfig != nil { if stsC.ServeConfig != nil {
@ -383,12 +377,12 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
} }
if orig != nil { if orig != nil {
logger.Debugf("patching the existing proxy Secret with tailscaled config %s", sanitizeConfigBytes(latestConfig)) logger.Debugf("patching the existing proxy Secret with tailscaled config %s", sanitizeConfigBytes(configs))
if err := a.Patch(ctx, secret, client.MergeFrom(orig)); err != nil { if err := a.Patch(ctx, secret, client.MergeFrom(orig)); err != nil {
return "", "", nil, err return "", "", nil, err
} }
} else { } else {
logger.Debugf("creating a new Secret for the proxy with tailscaled config %s", sanitizeConfigBytes(latestConfig)) logger.Debugf("creating a new Secret for the proxy with tailscaled config %s", sanitizeConfigBytes(configs))
if err := a.Create(ctx, secret); err != nil { if err := a.Create(ctx, secret); err != nil {
return "", "", nil, err return "", "", nil, err
} }
@ -396,13 +390,21 @@ func (a *tailscaleSTSReconciler) createOrGetSecret(ctx context.Context, logger *
return secret.Name, hash, configs, nil return secret.Name, hash, configs, nil
} }
// sanitizeConfigBytes returns ipn.ConfigVAlpha in string form with redacted // sanitizeConfigBytes returns latest ipn.ConfigVAlpha in string form with
// auth key. // redacted auth key.
func sanitizeConfigBytes(c ipn.ConfigVAlpha) string { func sanitizeConfigBytes(c tailscaledConfigs) string {
if c.AuthKey != nil { maxCapVer := tailcfg.CapabilityVersion(-1)
c.AuthKey = ptr.To("**redacted**") var latestConfig ipn.ConfigVAlpha
for capVer, cfg := range c {
if (capVer > maxCapVer && maxCapVer != 0) || capVer == 0 {
maxCapVer = capVer
latestConfig = cfg
} }
sanitizedBytes, err := json.Marshal(c) }
if latestConfig.AuthKey != nil {
latestConfig.AuthKey = ptr.To("**redacted**")
}
sanitizedBytes, err := json.Marshal(latestConfig)
if err != nil { if err != nil {
return "invalid config" return "invalid config"
} }
@ -831,6 +833,7 @@ func tailscaledConfig(stsC *tailscaleSTSConfig, newAuthkey string, oldSecret *co
conf.AuthKey = key conf.AuthKey = key
} }
capVerConfigs := make(map[tailcfg.CapabilityVersion]ipn.ConfigVAlpha) capVerConfigs := make(map[tailcfg.CapabilityVersion]ipn.ConfigVAlpha)
capVerConfigs[0] = *conf // Becomes "tailscaled" key.
capVerConfigs[95] = *conf capVerConfigs[95] = *conf
// legacy config should not contain NoStatefulFiltering field. // legacy config should not contain NoStatefulFiltering field.
conf.NoStatefulFiltering.Clear() conf.NoStatefulFiltering.Clear()
@ -838,30 +841,16 @@ func tailscaledConfig(stsC *tailscaleSTSConfig, newAuthkey string, oldSecret *co
return capVerConfigs, nil return capVerConfigs, nil
} }
func authKeyFromSecret(s *corev1.Secret) (key *string, err error) { func authKeyFromSecret(s *corev1.Secret) (*string, error) {
latest := tailcfg.CapabilityVersion(-1) selectedKey := tsoperator.SelectConfigFileName(maps.Keys(s.Data))
latestStr := ""
for k, data := range s.Data {
// write to StringData, read from Data as StringData is write-only
if len(data) == 0 {
continue
}
v, err := tsoperator.CapVerFromFileName(k)
if err != nil {
continue
}
if v > latest {
latestStr = k
latest = v
}
}
// Allow for configs that don't contain an auth key. Perhaps // Allow for configs that don't contain an auth key. Perhaps
// users have some mechanisms to delete them. Auth key is // users have some mechanisms to delete them. Auth key is
// normally not needed after the initial login. // normally not needed after the initial login.
if latestStr != "" { if selectedKey == "" {
return readAuthKey(s, latestStr) return nil, nil
} }
return key, nil
return readAuthKey(s, selectedKey)
} }
// shouldRetainAuthKey returns true if the state stored in a proxy's state Secret suggests that auth key should be // shouldRetainAuthKey returns true if the state stored in a proxy's state Secret suggests that auth key should be

View File

@ -119,7 +119,7 @@ func defaultPort() uint16 {
tunname string tunname string
cleanUp bool cleanUp bool
confFile string // empty, file path, or "vm:user-data" confFile string // empty, file path, or "vm:user-data", or "kube:<secret-name>"
debug string debug string
port uint16 port uint16
statepath string statepath string
@ -166,13 +166,13 @@ func main() {
flag.StringVar(&args.httpProxyAddr, "outbound-http-proxy-listen", "", `optional [ip]:port to run an outbound HTTP proxy (e.g. "localhost:8080")`) flag.StringVar(&args.httpProxyAddr, "outbound-http-proxy-listen", "", `optional [ip]:port to run an outbound HTTP proxy (e.g. "localhost:8080")`)
flag.StringVar(&args.tunname, "tun", defaultTunName(), `tunnel interface name; use "userspace-networking" (beta) to not use TUN`) flag.StringVar(&args.tunname, "tun", defaultTunName(), `tunnel interface name; use "userspace-networking" (beta) to not use TUN`)
flag.Var(flagtype.PortValue(&args.port, defaultPort()), "port", "UDP port to listen on for WireGuard and peer-to-peer traffic; 0 means automatically select") flag.Var(flagtype.PortValue(&args.port, defaultPort()), "port", "UDP port to listen on for WireGuard and peer-to-peer traffic; 0 means automatically select")
flag.StringVar(&args.statepath, "state", "", "absolute path of state file; use 'kube:<secret-name>' to use Kubernetes secrets or 'arn:aws:ssm:...' to store in AWS SSM; use 'mem:' to not store state and register as an ephemeral node. If empty and --statedir is provided, the default is <statedir>/tailscaled.state. Default: "+paths.DefaultTailscaledStateFile()) flag.StringVar(&args.statepath, "state", "", "absolute path of state file; use 'kube:<secret-name>' to use a Kubernetes Secret or 'arn:aws:ssm:...' to store in AWS SSM; use 'mem:' to not store state and register as an ephemeral node. If empty and --statedir is provided, the default is <statedir>/tailscaled.state. Default: "+paths.DefaultTailscaledStateFile())
flag.StringVar(&args.statedir, "statedir", "", "path to directory for storage of config state, TLS certs, temporary incoming Taildrop files, etc. If empty, it's derived from --state when possible.") flag.StringVar(&args.statedir, "statedir", "", "path to directory for storage of config state, TLS certs, temporary incoming Taildrop files, etc. If empty, it's derived from --state when possible.")
flag.StringVar(&args.socketpath, "socket", paths.DefaultTailscaledSocket(), "path of the service unix socket") flag.StringVar(&args.socketpath, "socket", paths.DefaultTailscaledSocket(), "path of the service unix socket")
flag.StringVar(&args.birdSocketPath, "bird-socket", "", "path of the bird unix socket") flag.StringVar(&args.birdSocketPath, "bird-socket", "", "path of the bird unix socket")
flag.BoolVar(&printVersion, "version", false, "print version information and exit") flag.BoolVar(&printVersion, "version", false, "print version information and exit")
flag.BoolVar(&args.disableLogs, "no-logs-no-support", false, "disable log uploads; this also disables any technical support") flag.BoolVar(&args.disableLogs, "no-logs-no-support", false, "disable log uploads; this also disables any technical support")
flag.StringVar(&args.confFile, "config", "", "path to config file, or 'vm:user-data' to use the VM's user-data (EC2)") flag.StringVar(&args.confFile, "config", "", "path to config file, or 'vm:user-data' to use the VM's user-data (EC2), or 'kube:<secret-name>' to read the '.data.tailscaled' key from a Kubernetes Secret")
if len(os.Args) > 0 && filepath.Base(os.Args[0]) == "tailscale" && beCLI != nil { if len(os.Args) > 0 && filepath.Base(os.Args[0]) == "tailscale" && beCLI != nil {
beCLI() beCLI()

View File

@ -10,6 +10,7 @@
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"strings"
"github.com/tailscale/hujson" "github.com/tailscale/hujson"
"tailscale.com/ipn" "tailscale.com/ipn"
@ -17,7 +18,7 @@
// Config describes a config file. // Config describes a config file.
type Config struct { type Config struct {
Path string // disk path of HuJSON, or VMUserDataPath Path string // disk path of HuJSON, or [VMUserDataPath], or kube:<secret-name>
Raw []byte // raw bytes from disk, in HuJSON form Raw []byte // raw bytes from disk, in HuJSON form
Std []byte // standardized JSON form Std []byte // standardized JSON form
Version string // "alpha0" for now Version string // "alpha0" for now
@ -35,9 +36,16 @@ func (c *Config) WantRunning() bool {
return c != nil && !c.Parsed.Enabled.EqualBool(false) return c != nil && !c.Parsed.Enabled.EqualBool(false)
} }
const (
// VMUserDataPath is a sentinel value for Load to use to get the data // VMUserDataPath is a sentinel value for Load to use to get the data
// from the VM's metadata service's user-data field. // from the VM's metadata service's user-data field.
const VMUserDataPath = "vm:user-data" VMUserDataPath = "vm:user-data"
// kubePrefix indicates the config should be read from a Kubernetes Secret.
// The remaining string should be the name of the Secret within the same
// namespace as tailscaled's own pod.
kubePrefix = "kube:"
)
// Load reads and parses the config file at the provided path on disk. // Load reads and parses the config file at the provided path on disk.
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
@ -45,9 +53,11 @@ func Load(path string) (*Config, error) {
c.Path = path c.Path = path
var err error var err error
switch path { switch {
case VMUserDataPath: case path == VMUserDataPath:
c.Raw, err = readVMUserData() c.Raw, err = readVMUserData()
case strings.HasPrefix(path, "kube:"):
c.Raw, err = readKubeSecret(strings.TrimPrefix(path, "kube:"))
default: default:
c.Raw, err = os.ReadFile(path) c.Raw, err = os.ReadFile(path)
} }
@ -74,7 +84,9 @@ func Load(path string) (*Config, error) {
c.Version = ver.Version c.Version = ver.Version
jd := json.NewDecoder(bytes.NewReader(c.Std)) jd := json.NewDecoder(bytes.NewReader(c.Std))
jd.DisallowUnknownFields() // Not not disallow unknown fields. Older clients need to be able to read
// newer configs to support version tearing between the creator and
// consumer of config files.
err = jd.Decode(&c.Parsed) err = jd.Decode(&c.Parsed)
if err != nil { if err != nil {
return nil, fmt.Errorf("error parsing config file %s: %w", path, err) return nil, fmt.Errorf("error parsing config file %s: %w", path, err)

32
ipn/conffile/kube.go Normal file
View File

@ -0,0 +1,32 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package conffile
import (
"context"
"fmt"
"time"
"tailscale.com/kube/kubeclient"
)
func readKubeSecret(name string) ([]byte, error) {
c, err := kubeclient.New()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
secret, err := c.GetSecret(ctx, name)
if err != nil {
return nil, fmt.Errorf("failed to read config from Secret %q: %w", name, err)
}
if contents, ok := secret.Data["tailscaled.hujson"]; ok {
return contents, nil
}
return secret.Data["tailscaled"], nil
}

View File

@ -8,7 +8,6 @@
"context" "context"
"fmt" "fmt"
"net" "net"
"os"
"strings" "strings"
"time" "time"
@ -31,10 +30,6 @@ func New(_ logger.Logf, secretName string) (*Store, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if os.Getenv("TS_KUBERNETES_READ_API_SERVER_ADDRESS_FROM_ENV") == "true" {
// Derive the API server address from the environment variables
c.SetURL(fmt.Sprintf("https://%s:%s", os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT_HTTPS")))
}
canPatch, _, err := c.CheckSecretPermissions(context.Background(), secretName) canPatch, _, err := c.CheckSecretPermissions(context.Background(), secretName)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -8,6 +8,7 @@
import ( import (
"fmt" "fmt"
"iter"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
) )
@ -41,10 +42,33 @@ func TailscaledConfigFileName(cap tailcfg.CapabilityVersion) string {
// CapVerFromFileName parses the capability version from a tailscaled // CapVerFromFileName parses the capability version from a tailscaled
// config file name previously generated by TailscaledConfigFileNameForCap. // config file name previously generated by TailscaledConfigFileNameForCap.
func CapVerFromFileName(name string) (tailcfg.CapabilityVersion, error) { func CapVerFromFileName(name string) (tailcfg.CapabilityVersion, error) {
if name == "tailscaled" { switch name {
case "tailscaled", "tailscaled.hujson":
// Unversioned names.
return 0, nil return 0, nil
} default:
var cap tailcfg.CapabilityVersion var cap tailcfg.CapabilityVersion
_, err := fmt.Sscanf(name, "cap-%d.hujson", &cap) _, err := fmt.Sscanf(name, "cap-%d.hujson", &cap)
return cap, err return cap, err
} }
}
func SelectConfigFileName(files iter.Seq[string]) string {
maxCapVer := tailcfg.CapabilityVersion(-1)
var selectedName string
for fileName := range files {
capVer, err := CapVerFromFileName(fileName)
if err != nil {
continue
}
// 0 is "unversioned" (by capability - there is still a version inside
// the config itself). Always prefer it to files that have a capability
// version.
if (capVer > maxCapVer && maxCapVer != 0) || capVer == 0 {
maxCapVer = capVer
selectedName = fileName
}
}
return selectedName
}

View File

@ -33,6 +33,8 @@
const ( const (
saPath = "/var/run/secrets/kubernetes.io/serviceaccount" saPath = "/var/run/secrets/kubernetes.io/serviceaccount"
defaultURL = "https://kubernetes.default.svc" defaultURL = "https://kubernetes.default.svc"
envAPIHost = "KUBERNETES_SERVICE_HOST"
envAPIPort = "KUBERNETES_SERVICE_PORT_HTTPS"
) )
// rootPathForTests is set by tests to override the root path to the // rootPathForTests is set by tests to override the root path to the
@ -61,7 +63,6 @@ type Client interface {
JSONPatchSecret(context.Context, string, []JSONPatch) error JSONPatchSecret(context.Context, string, []JSONPatch) error
CheckSecretPermissions(context.Context, string) (bool, bool, error) CheckSecretPermissions(context.Context, string) (bool, bool, error)
SetDialer(dialer func(context.Context, string, string) (net.Conn, error)) SetDialer(dialer func(context.Context, string, string) (net.Conn, error))
SetURL(string)
} }
type client struct { type client struct {
@ -87,8 +88,12 @@ func New() (Client, error) {
if ok := cp.AppendCertsFromPEM(caCert); !ok { if ok := cp.AppendCertsFromPEM(caCert); !ok {
return nil, fmt.Errorf("kube: error in creating root cert pool") return nil, fmt.Errorf("kube: error in creating root cert pool")
} }
url := defaultURL
if host, port := os.Getenv(envAPIHost), os.Getenv(envAPIPort); host != "" && port != "" {
url = fmt.Sprintf("https://%s:%s", host, port)
}
return &client{ return &client{
url: defaultURL, url: url,
ns: string(ns), ns: string(ns),
client: &http.Client{ client: &http.Client{
Transport: &http.Transport{ Transport: &http.Transport{
@ -100,12 +105,6 @@ func New() (Client, error) {
}, nil }, nil
} }
// SetURL sets the URL to use for the Kubernetes API.
// This is used only for testing.
func (c *client) SetURL(url string) {
c.url = url
}
// SetDialer sets the dialer to use when establishing a connection // SetDialer sets the dialer to use when establishing a connection
// to the Kubernetes API server. // to the Kubernetes API server.
func (c *client) SetDialer(dialer func(ctx context.Context, network, addr string) (net.Conn, error)) { func (c *client) SetDialer(dialer func(ctx context.Context, network, addr string) (net.Conn, error)) {

View File

@ -23,7 +23,6 @@ func (fc *FakeClient) CheckSecretPermissions(ctx context.Context, name string) (
func (fc *FakeClient) GetSecret(ctx context.Context, name string) (*kubeapi.Secret, error) { func (fc *FakeClient) GetSecret(ctx context.Context, name string) (*kubeapi.Secret, error) {
return fc.GetSecretImpl(ctx, name) return fc.GetSecretImpl(ctx, name)
} }
func (fc *FakeClient) SetURL(_ string) {}
func (fc *FakeClient) SetDialer(dialer func(ctx context.Context, network, addr string) (net.Conn, error)) { func (fc *FakeClient) SetDialer(dialer func(ctx context.Context, network, addr string) (net.Conn, error)) {
} }
func (fc *FakeClient) StrategicMergePatchSecret(context.Context, string, *kubeapi.Secret, string) error { func (fc *FakeClient) StrategicMergePatchSecret(context.Context, string, *kubeapi.Secret, string) error {