From e9d82767e507108ed0f4eb0ff3b46a5625af7b0c Mon Sep 17 00:00:00 2001 From: Mario Minardi Date: Tue, 13 Jan 2026 17:30:57 -0700 Subject: [PATCH] cmd/containerboot: allow for automatic ID token generation Allow for optionally specifying an audience for containerboot. This is passed to tailscale up to allow for containerboot to use automatic ID token generation for authentication. Updates https://github.com/tailscale/corp/issues/34430 Signed-off-by: Mario Minardi --- cmd/containerboot/main.go | 10 +++++--- cmd/containerboot/settings.go | 39 +++++++++++++++++++++++++++--- cmd/containerboot/settings_test.go | 35 ++++++++++++++++++++++++++- cmd/containerboot/tailscaled.go | 3 +++ 4 files changed, 79 insertions(+), 8 deletions(-) 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