cmd/containerboot: add OAuth and WIF auth support (#18311)
Fixes tailscale/corp#34430 Signed-off-by: Raj Singh <raj@tailscale.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user