cmd/containerboot: optionally configure tailscaled with a configfile

If TS_EXPERIMENTAL_CONFIGFILE_PATH env var is set only run tailscaled with the provided config file and not tailscale up or tailscale set.

Signed-off-by: Irbe Krumina <irbe@tailscale.com>
irbekrm/containerbootdeclarativeconf
Irbe Krumina 5 months ago
parent 51279ea00c
commit 388066f2b1

@ -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.
// - TS_EXPERIMENTAL_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"
@ -102,39 +110,29 @@ func main() {
tailscale.I_Acknowledge_This_API_Is_Unstable = true tailscale.I_Acknowledge_This_API_Is_Unstable = true
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: defaultEnvStringPointer("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", ""),
TailnetTargetFQDN: defaultEnv("TS_TAILNET_TARGET_FQDN", ""), TailnetTargetFQDN: defaultEnv("TS_TAILNET_TARGET_FQDN", ""),
DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""), DaemonExtraArgs: defaultEnv("TS_TAILSCALED_EXTRA_ARGS", ""),
ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""), ExtraArgs: defaultEnv("TS_EXTRA_ARGS", ""),
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: defaultEnvBoolPointer("TS_ACCEPT_DNS"), 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("TS_EXPERIMENTAL_CONFIGFILE_PATH", ""),
}
if cfg.ProxyTo != "" && cfg.UserspaceMode { if err := cfg.validate(); err != nil {
log.Fatal("TS_DEST_IP is not supported with TS_USERSPACE") log.Fatalf("invalid containerboot configuration: %v", err)
}
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 runTailscaleSet(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)
@ -238,7 +236,7 @@ func main() {
// different points in containerboot's lifecycle, hence the helper function. // different points in containerboot's lifecycle, hence the helper function.
didLogin := false didLogin := false
authTailscale := func() error { authTailscale := func() error {
if didLogin { if didLogin || runTailscaledOnly(cfg) {
return nil return nil
} }
didLogin = true didLogin = true
@ -253,7 +251,7 @@ func main() {
return nil return nil
} }
if !cfg.AuthOnce { if !runTailscaleSet(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 runTailscaledOnly(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 runTailscaleSet(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 && runTailscaleSet(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)...)
} }
@ -873,21 +881,46 @@ type settings struct {
// TailnetTargetFQDN is an MagicDNS name to which all incoming // TailnetTargetFQDN is an MagicDNS name to which all incoming
// non-Tailscale traffic should be proxied. This must be a full Tailnet // non-Tailscale traffic should be proxied. This must be a full Tailnet
// node FQDN. // node FQDN.
TailnetTargetFQDN string TailnetTargetFQDN string
ServeConfigPath string ServeConfigPath string
DaemonExtraArgs string DaemonExtraArgs string
ExtraArgs string ExtraArgs string
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
Socket string Socket string
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("TS_EXPERIMENTAL_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
@ -962,3 +995,17 @@ func contextWithExitSignalWatch() (context.Context, func()) {
} }
return ctx, f return ctx, f
} }
// runTaiscaleSet determines whether `tailscale set` (rather than the default
// `tailscale up`) should be used to reconfigure tailscaled on every subsequent
// container restart after the tailnet device has logged in.
func runTailscaleSet(cfg *settings) bool {
return cfg.AuthOnce && cfg.TailscaledConfigFilePath == ""
}
// runTailscaledOnly determines whether tailscaled only should be ran to start,
// configure and log in the tailnet device and the `tailscale up`/`tailscale
// set` steps should be skipped.
func runTailscaledOnly(cfg *settings) bool {
return cfg.TailscaledConfigFilePath != ""
}

@ -310,7 +310,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 +629,22 @@ func TestContainerBoot(t *testing.T) {
}, },
}, },
}, },
{
Name: "experimental tailscaled configfile",
Env: map[string]string{
// TODO - create this file so we don't fail here
"TS_EXPERIMENTAL_CONFIGFILE_PATH": "/conf",
},
Phases: []phase{
{
WantCmds: []string{
"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp --tun=userspace-networking --config=/conf",
},
}, {
Notify: runningNotify,
},
},
},
} }
for _, test := range tests { for _, test := range tests {

Loading…
Cancel
Save