diff --git a/cmd/containerboot/main.go b/cmd/containerboot/main.go index 011c1830a..a520b5756 100644 --- a/cmd/containerboot/main.go +++ b/cmd/containerboot/main.go @@ -20,8 +20,12 @@ // - TS_ID_TOKEN: the ID token from the identity provider for workload identity federation. // Must be used together with TS_CLIENT_ID. If the value begins with "file:", it is // treated as a path to a file containing the token. -// - Note: TS_AUTHKEY is mutually exclusive with TS_CLIENT_ID, TS_CLIENT_SECRET, and TS_ID_TOKEN. -// TS_CLIENT_SECRET and TS_ID_TOKEN cannot be used together. +// - TS_AUDIENCE: the audience to use when requesting an ID token from a well-known identity provider +// to exchange with the control server for workload identity federation. Must be used together +// with TS_CLIENT_ID. +// - Note: TS_AUTHKEY is mutually exclusive with TS_CLIENT_ID, TS_CLIENT_SECRET, TS_ID_TOKEN, +// and TS_AUDIENCE. +// TS_CLIENT_SECRET, TS_ID_TOKEN, and TS_AUDIENCE cannot be used together. // - TS_HOSTNAME: the hostname to request for the node. // - TS_ROUTES: subnet routes to advertise. Explicitly setting it to an empty // value will cause containerboot to stop acting as a subnet router for any @@ -78,7 +82,7 @@ // directory that containers tailscaled config in file. The config file needs to be // named cap-.hujson. If this is set, TS_HOSTNAME, // TS_EXTRA_ARGS, TS_AUTHKEY, TS_CLIENT_ID, TS_CLIENT_SECRET, TS_ID_TOKEN, -// TS_ROUTES, TS_ACCEPT_DNS env vars must not be set. If this is set, +// TS_ROUTES, TS_ACCEPT_DNS, TS_AUDIENCE env vars must not be set. If this is set, // containerboot only runs `tailscaled --config ` // and not `tailscale up` or `tailscale set`. // The config file contents are currently read once on container start. diff --git a/cmd/containerboot/settings.go b/cmd/containerboot/settings.go index 216dd766e..aab2b8631 100644 --- a/cmd/containerboot/settings.go +++ b/cmd/containerboot/settings.go @@ -26,6 +26,7 @@ type settings struct { ClientID string ClientSecret string IDToken string + Audience string Hostname string Routes *string // ProxyTargetIP is the destination IP to which all incoming @@ -92,6 +93,7 @@ func configFromEnv() (*settings, error) { ClientID: defaultEnv("TS_CLIENT_ID", ""), ClientSecret: defaultEnv("TS_CLIENT_SECRET", ""), IDToken: defaultEnv("TS_ID_TOKEN", ""), + Audience: defaultEnv("TS_AUDIENCE", ""), Hostname: defaultEnv("TS_HOSTNAME", ""), Routes: defaultEnvStringPointer("TS_ROUTES"), ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""), @@ -247,17 +249,46 @@ func (s *settings) validate() error { 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 != "" || s.ClientID != "" || s.ClientSecret != "" || s.IDToken != "") { - return errors.New("TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR cannot be set in combination with TS_HOSTNAME, TS_EXTRA_ARGS, TS_AUTHKEY, TS_ROUTES, TS_ACCEPT_DNS, TS_CLIENT_ID, TS_CLIENT_SECRET, TS_ID_TOKEN.") + if s.TailscaledConfigFilePath != "" && + (s.AcceptDNS != nil || + s.AuthKey != "" || + s.Routes != nil || + s.ExtraArgs != "" || + s.Hostname != "" || + s.ClientID != "" || + s.ClientSecret != "" || + s.IDToken != "" || + s.Audience != "") { + conflictingArgs := []string{ + "TS_HOSTNAME", + "TS_EXTRA_ARGS", + "TS_AUTHKEY", + "TS_ROUTES", + "TS_ACCEPT_DNS", + "TS_CLIENT_ID", + "TS_CLIENT_SECRET", + "TS_ID_TOKEN", + "TS_AUDIENCE", + } + return fmt.Errorf("TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR cannot be set in combination with %s.", strings.Join(conflictingArgs, ", ")) } if s.IDToken != "" && s.ClientID == "" { return errors.New("TS_ID_TOKEN is set but TS_CLIENT_ID is not set") } + if s.Audience != "" && s.ClientID == "" { + return errors.New("TS_AUDIENCE is set but TS_CLIENT_ID is not set") + } if s.IDToken != "" && s.ClientSecret != "" { return errors.New("TS_ID_TOKEN and TS_CLIENT_SECRET cannot both be set") } - if s.AuthKey != "" && (s.ClientID != "" || s.ClientSecret != "" || s.IDToken != "") { - return errors.New("TS_AUTHKEY cannot be used with TS_CLIENT_ID, TS_CLIENT_SECRET, or TS_ID_TOKEN") + if s.IDToken != "" && s.Audience != "" { + return errors.New("TS_ID_TOKEN and TS_AUDIENCE cannot both be set") + } + if s.Audience != "" && s.ClientSecret != "" { + return errors.New("TS_AUDIENCE and TS_CLIENT_SECRET cannot both be set") + } + if s.AuthKey != "" && (s.ClientID != "" || s.ClientSecret != "" || s.IDToken != "" || s.Audience != "") { + return errors.New("TS_AUTHKEY cannot be used with TS_CLIENT_ID, TS_CLIENT_SECRET, TS_ID_TOKEN, or TS_AUDIENCE.") } if s.AllowProxyingClusterTrafficViaIngress && s.UserspaceMode { return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is not supported in userspace mode") diff --git a/cmd/containerboot/settings_test.go b/cmd/containerboot/settings_test.go index d97e786e6..576ea7f3e 100644 --- a/cmd/containerboot/settings_test.go +++ b/cmd/containerboot/settings_test.go @@ -117,6 +117,7 @@ func TestValidateAuthMethods(t *testing.T) { clientID string clientSecret string idToken string + audience string errContains string }{ { @@ -144,11 +145,21 @@ func TestValidateAuthMethods(t *testing.T) { clientID: "client-id", idToken: "id-token", }, + { + name: "wif_client_id_and_audience", + clientID: "client-id", + audience: "audience", + }, { name: "id_token_without_client_id", idToken: "id-token", errContains: "TS_ID_TOKEN is set but TS_CLIENT_ID is not set", }, + { + name: "audience_without_client_id", + audience: "audience", + errContains: "TS_AUDIENCE is set but TS_CLIENT_ID is not set", + }, { name: "authkey_with_client_secret", authKey: "tskey-auth-xxx", @@ -156,12 +167,19 @@ func TestValidateAuthMethods(t *testing.T) { errContains: "TS_AUTHKEY cannot be used with", }, { - name: "authkey_with_wif", + name: "authkey_with_id_token", authKey: "tskey-auth-xxx", clientID: "client-id", idToken: "id-token", errContains: "TS_AUTHKEY cannot be used with", }, + { + name: "authkey_with_audience", + authKey: "tskey-auth-xxx", + clientID: "client-id", + audience: "audience", + errContains: "TS_AUTHKEY cannot be used with", + }, { name: "id_token_with_client_secret", clientID: "client-id", @@ -169,6 +187,20 @@ func TestValidateAuthMethods(t *testing.T) { idToken: "id-token", errContains: "TS_ID_TOKEN and TS_CLIENT_SECRET cannot both be set", }, + { + name: "id_token_with_audience", + clientID: "client-id", + idToken: "id-token", + audience: "audience", + errContains: "TS_ID_TOKEN and TS_AUDIENCE cannot both be set", + }, + { + name: "audience_with_client_secret", + clientID: "client-id", + clientSecret: "tskey-client-xxx", + audience: "audience", + errContains: "TS_AUDIENCE and TS_CLIENT_SECRET cannot both be set", + }, } for _, tt := range tests { @@ -178,6 +210,7 @@ func TestValidateAuthMethods(t *testing.T) { ClientID: tt.clientID, ClientSecret: tt.clientSecret, IDToken: tt.idToken, + Audience: tt.audience, } err := s.validate() if tt.errContains != "" { diff --git a/cmd/containerboot/tailscaled.go b/cmd/containerboot/tailscaled.go index 1374b1802..e5b0b8b8e 100644 --- a/cmd/containerboot/tailscaled.go +++ b/cmd/containerboot/tailscaled.go @@ -129,6 +129,9 @@ func tailscaleUp(ctx context.Context, cfg *settings) error { if cfg.IDToken != "" { args = append(args, "--id-token="+cfg.IDToken) } + if cfg.Audience != "" { + args = append(args, "--audience="+cfg.Audience) + } // --advertise-routes can be passed an empty string to configure a // device (that might have previously advertised subnet routes) to not // advertise any routes. Respect an empty string passed by a user and