@ -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 != ""
}