cmd/containerboot: add EXPERIMENTAL_TS_CONFIGFILE_PATH env var to allow passing tailscaled config in a file (#10759)

* cmd/containerboot: optionally configure tailscaled with a configfile.

If EXPERIMENTAL_TS_CONFIGFILE_PATH env var is set,
only run tailscaled with the provided config file.
Do not run 'tailscale up' or 'tailscale set'.

* cmd/containerboot: store containerboot accept_dns val in bool pointer

So that we can distinguish between the value being set to
false explicitly bs being unset.

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
pull/10476/head
Irbe Krumina 11 months ago committed by GitHub
parent c05c4bdce4
commit 133699284e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -48,6 +48,13 @@
// ${TS_CERT_DOMAIN}, it will be replaced with the value of the available FQDN. // ${TS_CERT_DOMAIN}, it will be replaced with the value of the available FQDN.
// It cannot be used in conjunction with TS_DEST_IP. The file is watched for changes, // It cannot be used in conjunction with TS_DEST_IP. The file is watched for changes,
// and will be re-applied when it changes. // and will be re-applied when it changes.
// - EXPERIMENTAL_TS_CONFIGFILE_PATH: if specified, a path to tailscaled
// config. If this is set, TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY,
// TS_ROUTES, TS_ACCEPT_DNS env vars must not be set. If this is set,
// containerboot only runs `tailscaled --config <path-to-this-configfile>`
// and not `tailscale up` or `tailscale set`.
// The config file contents are currently read once on container start.
// NB: This env var is currently experimental and the logic will likely change!
// //
// When running on Kubernetes, containerboot defaults to storing state in the // When running on Kubernetes, containerboot defaults to storing state in the
// "tailscale" kube secret. To store state on local disk instead, set // "tailscale" kube secret. To store state on local disk instead, set
@ -83,6 +90,7 @@ import (
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
"tailscale.com/client/tailscale" "tailscale.com/client/tailscale"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/ipn/conffile"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/types/logger" "tailscale.com/types/logger"
"tailscale.com/types/ptr" "tailscale.com/types/ptr"
@ -104,7 +112,7 @@ func main() {
cfg := &settings{ cfg := &settings{
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""), AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
Hostname: defaultEnv("TS_HOSTNAME", ""), Hostname: defaultEnv("TS_HOSTNAME", ""),
Routes: defaultEnvPointer("TS_ROUTES"), Routes: defaultEnvStringPointer("TS_ROUTES"),
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""), ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
ProxyTo: defaultEnv("TS_DEST_IP", ""), ProxyTo: defaultEnv("TS_DEST_IP", ""),
TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""), TailnetTargetIP: defaultEnv("TS_TAILNET_TARGET_IP", ""),
@ -114,27 +122,17 @@ func main() {
InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "", InKubernetes: os.Getenv("KUBERNETES_SERVICE_HOST") != "",
UserspaceMode: defaultBool("TS_USERSPACE", true), UserspaceMode: defaultBool("TS_USERSPACE", true),
StateDir: defaultEnv("TS_STATE_DIR", ""), StateDir: defaultEnv("TS_STATE_DIR", ""),
AcceptDNS: defaultBool("TS_ACCEPT_DNS", false), AcceptDNS: defaultEnvBoolPointer("TS_ACCEPT_DNS"),
KubeSecret: defaultEnv("TS_KUBE_SECRET", "tailscale"), KubeSecret: defaultEnv("TS_KUBE_SECRET", "tailscale"),
SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""), SOCKSProxyAddr: defaultEnv("TS_SOCKS5_SERVER", ""),
HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""), HTTPProxyAddr: defaultEnv("TS_OUTBOUND_HTTP_PROXY_LISTEN", ""),
Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"), Socket: defaultEnv("TS_SOCKET", "/tmp/tailscaled.sock"),
AuthOnce: defaultBool("TS_AUTH_ONCE", false), AuthOnce: defaultBool("TS_AUTH_ONCE", false),
Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"), Root: defaultEnv("TS_TEST_ONLY_ROOT", "/"),
TailscaledConfigFilePath: defaultEnv("EXPERIMENTAL_TS_CONFIGFILE_PATH", ""),
} }
if err := cfg.validate(); err != nil {
if cfg.ProxyTo != "" && cfg.UserspaceMode { log.Fatalf("invalid configuration: %v", err)
log.Fatal("TS_DEST_IP is not supported with TS_USERSPACE")
}
if cfg.TailnetTargetIP != "" && cfg.UserspaceMode {
log.Fatal("TS_TAILNET_TARGET_IP is not supported with TS_USERSPACE")
}
if cfg.TailnetTargetFQDN != "" && cfg.UserspaceMode {
log.Fatal("TS_TAILNET_TARGET_FQDN is not supported with TS_USERSPACE")
}
if cfg.TailnetTargetFQDN != "" && cfg.TailnetTargetIP != "" {
log.Fatal("Both TS_TAILNET_TARGET_IP and TS_TAILNET_FQDN cannot be set")
} }
if !cfg.UserspaceMode { if !cfg.UserspaceMode {
@ -171,7 +169,7 @@ func main() {
} }
cfg.KubernetesCanPatch = canPatch cfg.KubernetesCanPatch = canPatch
if cfg.AuthKey == "" { if cfg.AuthKey == "" && !isOneStepConfig(cfg) {
key, err := findKeyInKubeSecret(bootCtx, cfg.KubeSecret) key, err := findKeyInKubeSecret(bootCtx, cfg.KubeSecret)
if err != nil { if err != nil {
log.Fatalf("Getting authkey from kube secret: %v", err) log.Fatalf("Getting authkey from kube secret: %v", err)
@ -253,7 +251,7 @@ func main() {
return nil return nil
} }
if !cfg.AuthOnce { if isTwoStepConfigAlwaysAuth(cfg) {
if err := authTailscale(); err != nil { if err := authTailscale(); err != nil {
log.Fatalf("failed to auth tailscale: %v", err) log.Fatalf("failed to auth tailscale: %v", err)
} }
@ -269,6 +267,13 @@ authLoop:
if n.State != nil { if n.State != nil {
switch *n.State { switch *n.State {
case ipn.NeedsLogin: case ipn.NeedsLogin:
if isOneStepConfig(cfg) {
// This could happen if this is the
// first time tailscaled was run for
// this device and the auth key was not
// passed via the configfile.
log.Fatalf("invalid state: tailscaled daemon started with a config file, but tailscale is not logged in: ensure you pass a valid auth key in the config file.")
}
if err := authTailscale(); err != nil { if err := authTailscale(); err != nil {
log.Fatalf("failed to auth tailscale: %v", err) log.Fatalf("failed to auth tailscale: %v", err)
} }
@ -293,7 +298,7 @@ authLoop:
ctx, cancel := contextWithExitSignalWatch() ctx, cancel := contextWithExitSignalWatch()
defer cancel() defer cancel()
if cfg.AuthOnce { if isTwoStepConfigAuthOnce(cfg) {
// Now that we are authenticated, we can set/reset any of the // Now that we are authenticated, we can set/reset any of the
// settings that we need to. // settings that we need to.
if err := tailscaleSet(ctx, cfg); err != nil { if err := tailscaleSet(ctx, cfg); err != nil {
@ -309,7 +314,7 @@ authLoop:
} }
} }
if cfg.InKubernetes && cfg.KubeSecret != "" && cfg.KubernetesCanPatch && cfg.AuthOnce { if cfg.InKubernetes && cfg.KubeSecret != "" && cfg.KubernetesCanPatch && isTwoStepConfigAuthOnce(cfg) {
// We were told to only auth once, so any secret-bound // We were told to only auth once, so any secret-bound
// authkey is no longer needed. We don't strictly need to // authkey is no longer needed. We don't strictly need to
// wipe it, but it's good hygiene. // wipe it, but it's good hygiene.
@ -634,6 +639,9 @@ func tailscaledArgs(cfg *settings) []string {
if cfg.HTTPProxyAddr != "" { if cfg.HTTPProxyAddr != "" {
args = append(args, "--outbound-http-proxy-listen="+cfg.HTTPProxyAddr) args = append(args, "--outbound-http-proxy-listen="+cfg.HTTPProxyAddr)
} }
if cfg.TailscaledConfigFilePath != "" {
args = append(args, "--config="+cfg.TailscaledConfigFilePath)
}
if cfg.DaemonExtraArgs != "" { if cfg.DaemonExtraArgs != "" {
args = append(args, strings.Fields(cfg.DaemonExtraArgs)...) args = append(args, strings.Fields(cfg.DaemonExtraArgs)...)
} }
@ -644,7 +652,7 @@ func tailscaledArgs(cfg *settings) []string {
// if TS_AUTH_ONCE is set, only the first time containerboot starts. // if TS_AUTH_ONCE is set, only the first time containerboot starts.
func tailscaleUp(ctx context.Context, cfg *settings) error { func tailscaleUp(ctx context.Context, cfg *settings) error {
args := []string{"--socket=" + cfg.Socket, "up"} args := []string{"--socket=" + cfg.Socket, "up"}
if cfg.AcceptDNS { if cfg.AcceptDNS != nil && *cfg.AcceptDNS {
args = append(args, "--accept-dns=true") args = append(args, "--accept-dns=true")
} else { } else {
args = append(args, "--accept-dns=false") args = append(args, "--accept-dns=false")
@ -680,7 +688,7 @@ func tailscaleUp(ctx context.Context, cfg *settings) error {
// node is in Running state and only if TS_AUTH_ONCE is set. // node is in Running state and only if TS_AUTH_ONCE is set.
func tailscaleSet(ctx context.Context, cfg *settings) error { func tailscaleSet(ctx context.Context, cfg *settings) error {
args := []string{"--socket=" + cfg.Socket, "set"} args := []string{"--socket=" + cfg.Socket, "set"}
if cfg.AcceptDNS { if cfg.AcceptDNS != nil && *cfg.AcceptDNS {
args = append(args, "--accept-dns=true") args = append(args, "--accept-dns=true")
} else { } else {
args = append(args, "--accept-dns=false") args = append(args, "--accept-dns=false")
@ -880,7 +888,7 @@ type settings struct {
InKubernetes bool InKubernetes bool
UserspaceMode bool UserspaceMode bool
StateDir string StateDir string
AcceptDNS bool AcceptDNS *bool
KubeSecret string KubeSecret string
SOCKSProxyAddr string SOCKSProxyAddr string
HTTPProxyAddr string HTTPProxyAddr string
@ -888,6 +896,31 @@ type settings struct {
AuthOnce bool AuthOnce bool
Root string Root string
KubernetesCanPatch bool KubernetesCanPatch bool
TailscaledConfigFilePath string
}
func (s *settings) validate() error {
if s.TailscaledConfigFilePath != "" {
if _, err := conffile.Load(s.TailscaledConfigFilePath); err != nil {
return fmt.Errorf("error validating tailscaled configfile contents: %w", err)
}
}
if s.ProxyTo != "" && s.UserspaceMode {
return errors.New("TS_DEST_IP is not supported with TS_USERSPACE")
}
if s.TailnetTargetIP != "" && s.UserspaceMode {
return errors.New("TS_TAILNET_TARGET_IP is not supported with TS_USERSPACE")
}
if s.TailnetTargetFQDN != "" && s.UserspaceMode {
return errors.New("TS_TAILNET_TARGET_FQDN is not supported with TS_USERSPACE")
}
if s.TailnetTargetFQDN != "" && s.TailnetTargetIP != "" {
return errors.New("Both TS_TAILNET_TARGET_IP and TS_TAILNET_FQDN cannot be set")
}
if s.TailscaledConfigFilePath != "" && (s.AcceptDNS != nil || s.AuthKey != "" || s.Routes != nil || s.ExtraArgs != "" || s.Hostname != "") {
return errors.New("EXPERIMENTAL_TS_CONFIGFILE_PATH cannot be set in combination with TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY, TS_ROUTES, TS_ACCEPT_DNS.")
}
return nil
} }
// defaultEnv returns the value of the given envvar name, or defVal if // defaultEnv returns the value of the given envvar name, or defVal if
@ -899,16 +932,28 @@ func defaultEnv(name, defVal string) string {
return defVal return defVal
} }
// defaultEnvPointer returns a pointer to the given envvar value if set, else // defaultEnvStringPointer returns a pointer to the given envvar value if set, else
// returns nil. This is useful in cases where we need to distinguish between a // returns nil. This is useful in cases where we need to distinguish between a
// variable being set to empty string vs unset. // variable being set to empty string vs unset.
func defaultEnvPointer(name string) *string { func defaultEnvStringPointer(name string) *string {
if v, ok := os.LookupEnv(name); ok { if v, ok := os.LookupEnv(name); ok {
return &v return &v
} }
return nil return nil
} }
// defaultEnvBoolPointer returns a pointer to the given envvar value if set, else
// returns nil. This is useful in cases where we need to distinguish between a
// variable being explicitly set to false vs unset.
func defaultEnvBoolPointer(name string) *bool {
v := os.Getenv(name)
ret, err := strconv.ParseBool(v)
if err != nil {
return nil
}
return &ret
}
func defaultEnvs(names []string, defVal string) string { func defaultEnvs(names []string, defVal string) string {
for _, name := range names { for _, name := range names {
if v, ok := os.LookupEnv(name); ok { if v, ok := os.LookupEnv(name); ok {
@ -950,3 +995,27 @@ func contextWithExitSignalWatch() (context.Context, func()) {
} }
return ctx, f return ctx, f
} }
// isTwoStepConfigAuthOnce returns true if the Tailscale node should be configured
// in two steps and login should only happen once.
// Step 1: run 'tailscaled'
// Step 2):
// A) if this is the first time starting this node run 'tailscale up --authkey <authkey> <config opts>'
// B) if this is not the first time starting this node run 'tailscale set <config opts>'.
func isTwoStepConfigAuthOnce(cfg *settings) bool {
return cfg.AuthOnce && cfg.TailscaledConfigFilePath == ""
}
// isTwoStepConfigAlwaysAuth returns true if the Tailscale node should be configured
// in two steps and we should log in every time it starts.
// Step 1: run 'tailscaled'
// Step 2): run 'tailscale up --authkey <authkey> <config opts>'
func isTwoStepConfigAlwaysAuth(cfg *settings) bool {
return !cfg.AuthOnce && cfg.TailscaledConfigFilePath == ""
}
// isOneStepConfig returns true if the Tailscale node should always be ran and
// configured in a single step by running 'tailscaled <config opts>'
func isOneStepConfig(cfg *settings) bool {
return cfg.TailscaledConfigFilePath != ""
}

@ -52,6 +52,12 @@ func TestContainerBoot(t *testing.T) {
} }
defer kube.Close() defer kube.Close()
tailscaledConf := &ipn.ConfigVAlpha{AuthKey: func(s string) *string { return &s }("foo"), Version: "alpha0"}
tailscaledConfBytes, err := json.Marshal(tailscaledConf)
if err != nil {
t.Fatalf("error unmarshaling tailscaled config: %v", err)
}
dirs := []string{ dirs := []string{
"var/lib", "var/lib",
"usr/bin", "usr/bin",
@ -59,6 +65,7 @@ func TestContainerBoot(t *testing.T) {
"dev/net", "dev/net",
"proc/sys/net/ipv4", "proc/sys/net/ipv4",
"proc/sys/net/ipv6/conf/all", "proc/sys/net/ipv6/conf/all",
"etc",
} }
for _, path := range dirs { for _, path := range dirs {
if err := os.MkdirAll(filepath.Join(d, path), 0700); err != nil { if err := os.MkdirAll(filepath.Join(d, path), 0700); err != nil {
@ -73,6 +80,7 @@ func TestContainerBoot(t *testing.T) {
"dev/net/tun": []byte(""), "dev/net/tun": []byte(""),
"proc/sys/net/ipv4/ip_forward": []byte("0"), "proc/sys/net/ipv4/ip_forward": []byte("0"),
"proc/sys/net/ipv6/conf/all/forwarding": []byte("0"), "proc/sys/net/ipv6/conf/all/forwarding": []byte("0"),
"etc/tailscaled": tailscaledConfBytes,
} }
resetFiles := func() { resetFiles := func() {
for path, content := range files { for path, content := range files {
@ -310,7 +318,7 @@ func TestContainerBoot(t *testing.T) {
}, },
}, },
{ {
Name: "ingres proxy", Name: "ingress proxy",
Env: map[string]string{ Env: map[string]string{
"TS_AUTHKEY": "tskey-key", "TS_AUTHKEY": "tskey-key",
"TS_DEST_IP": "1.2.3.4", "TS_DEST_IP": "1.2.3.4",
@ -629,6 +637,21 @@ func TestContainerBoot(t *testing.T) {
}, },
}, },
}, },
{
Name: "experimental tailscaled configfile",
Env: map[string]string{
"EXPERIMENTAL_TS_CONFIGFILE_PATH": filepath.Join(d, "etc/tailscaled"),
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --config=/etc/tailscaled",
},
}, {
Notify: runningNotify,
},
},
},
} }
for _, test := range tests { for _, test := range tests {

Loading…
Cancel
Save