cmd/containerboot: add OAuth and WIF auth support (#18311)

Fixes tailscale/corp#34430

Signed-off-by: Raj Singh <raj@tailscale.com>
main
Raj Singh 3 months ago committed by GitHub
parent 6c67deff38
commit e66531041b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 14
      cmd/containerboot/main.go
  2. 29
      cmd/containerboot/settings.go
  3. 89
      cmd/containerboot/settings_test.go
  4. 9
      cmd/containerboot/tailscaled.go

@ -11,7 +11,17 @@
// As with most container things, configuration is passed through environment
// variables. All configuration is optional.
//
// - TS_AUTHKEY: the authkey to use for login.
// - TS_AUTHKEY: the authkey to use for login. Also accepts TS_AUTH_KEY.
// If the value begins with "file:", it is treated as a path to a file containing the key.
// - TS_CLIENT_ID: the OAuth client ID. Can be used alone (ID token auto-generated
// in well-known environments), with TS_CLIENT_SECRET, or with TS_ID_TOKEN.
// - TS_CLIENT_SECRET: the OAuth client secret for generating authkeys.
// If the value begins with "file:", it is treated as a path to a file containing the secret.
// - 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_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
@ -67,7 +77,7 @@
// - TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR: if specified, a path to a
// directory that containers tailscaled config in file. The config file needs to be
// named cap-<current-tailscaled-cap>.hujson. If this is set, TS_HOSTNAME,
// TS_EXTRA_ARGS, TS_AUTHKEY,
// 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,
// containerboot only runs `tailscaled --config <path-to-this-configfile>`
// and not `tailscale up` or `tailscale set`.

@ -22,9 +22,12 @@ import (
// settings is all the configuration for containerboot.
type settings struct {
AuthKey string
Hostname string
Routes *string
AuthKey string
ClientID string
ClientSecret string
IDToken string
Hostname string
Routes *string
// ProxyTargetIP is the destination IP to which all incoming
// Tailscale traffic should be proxied. If empty, no proxying
// is done. This is typically a locally reachable IP.
@ -86,6 +89,9 @@ type settings struct {
func configFromEnv() (*settings, error) {
cfg := &settings{
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
ClientID: defaultEnv("TS_CLIENT_ID", ""),
ClientSecret: defaultEnv("TS_CLIENT_SECRET", ""),
IDToken: defaultEnv("TS_ID_TOKEN", ""),
Hostname: defaultEnv("TS_HOSTNAME", ""),
Routes: defaultEnvStringPointer("TS_ROUTES"),
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
@ -241,8 +247,17 @@ 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 != "") {
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.")
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.IDToken != "" && s.ClientID == "" {
return errors.New("TS_ID_TOKEN 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.AllowProxyingClusterTrafficViaIngress && s.UserspaceMode {
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is not supported in userspace mode")
@ -312,8 +327,8 @@ func (cfg *settings) setupKube(ctx context.Context, kc *kubeClient) error {
}
}
// Return early if we already have an auth key.
if cfg.AuthKey != "" || isOneStepConfig(cfg) {
// Return early if we already have an auth key or are using OAuth/WIF.
if cfg.AuthKey != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || isOneStepConfig(cfg) {
return nil
}

@ -5,7 +5,10 @@
package main
import "testing"
import (
"strings"
"testing"
)
func Test_parseAcceptDNS(t *testing.T) {
tests := []struct {
@ -106,3 +109,87 @@ func Test_parseAcceptDNS(t *testing.T) {
})
}
}
func TestValidateAuthMethods(t *testing.T) {
tests := []struct {
name string
authKey string
clientID string
clientSecret string
idToken string
errContains string
}{
{
name: "no_auth_method",
},
{
name: "authkey_only",
authKey: "tskey-auth-xxx",
},
{
name: "client_secret_only",
clientSecret: "tskey-client-xxx",
},
{
name: "client_id_alone",
clientID: "client-id",
},
{
name: "oauth_client_id_and_secret",
clientID: "client-id",
clientSecret: "tskey-client-xxx",
},
{
name: "wif_client_id_and_id_token",
clientID: "client-id",
idToken: "id-token",
},
{
name: "id_token_without_client_id",
idToken: "id-token",
errContains: "TS_ID_TOKEN is set but TS_CLIENT_ID is not set",
},
{
name: "authkey_with_client_secret",
authKey: "tskey-auth-xxx",
clientSecret: "tskey-client-xxx",
errContains: "TS_AUTHKEY cannot be used with",
},
{
name: "authkey_with_wif",
authKey: "tskey-auth-xxx",
clientID: "client-id",
idToken: "id-token",
errContains: "TS_AUTHKEY cannot be used with",
},
{
name: "id_token_with_client_secret",
clientID: "client-id",
clientSecret: "tskey-client-xxx",
idToken: "id-token",
errContains: "TS_ID_TOKEN and TS_CLIENT_SECRET cannot both be set",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &settings{
AuthKey: tt.authKey,
ClientID: tt.clientID,
ClientSecret: tt.clientSecret,
IDToken: tt.idToken,
}
err := s.validate()
if tt.errContains != "" {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
}
} else if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}

@ -120,6 +120,15 @@ func tailscaleUp(ctx context.Context, cfg *settings) error {
if cfg.AuthKey != "" {
args = append(args, "--authkey="+cfg.AuthKey)
}
if cfg.ClientID != "" {
args = append(args, "--client-id="+cfg.ClientID)
}
if cfg.ClientSecret != "" {
args = append(args, "--client-secret="+cfg.ClientSecret)
}
if cfg.IDToken != "" {
args = append(args, "--id-token="+cfg.IDToken)
}
// --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

Loading…
Cancel
Save