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
|
// As with most container things, configuration is passed through environment
|
||||||
// variables. All configuration is optional.
|
// 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_HOSTNAME: the hostname to request for the node.
|
||||||
// - TS_ROUTES: subnet routes to advertise. Explicitly setting it to an empty
|
// - 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
|
// 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
|
// - TS_EXPERIMENTAL_VERSIONED_CONFIG_DIR: if specified, a path to a
|
||||||
// directory that containers tailscaled config in file. The config file needs to be
|
// 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,
|
// 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,
|
// TS_ROUTES, TS_ACCEPT_DNS env vars must not be set. If this is set,
|
||||||
// containerboot only runs `tailscaled --config <path-to-this-configfile>`
|
// containerboot only runs `tailscaled --config <path-to-this-configfile>`
|
||||||
// and not `tailscale up` or `tailscale set`.
|
// and not `tailscale up` or `tailscale set`.
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ import (
|
|||||||
|
|
||||||
// settings is all the configuration for containerboot.
|
// settings is all the configuration for containerboot.
|
||||||
type settings struct {
|
type settings struct {
|
||||||
AuthKey string
|
AuthKey string
|
||||||
Hostname string
|
ClientID string
|
||||||
Routes *string
|
ClientSecret string
|
||||||
|
IDToken string
|
||||||
|
Hostname string
|
||||||
|
Routes *string
|
||||||
// ProxyTargetIP is the destination IP to which all incoming
|
// ProxyTargetIP is the destination IP to which all incoming
|
||||||
// Tailscale traffic should be proxied. If empty, no proxying
|
// Tailscale traffic should be proxied. If empty, no proxying
|
||||||
// is done. This is typically a locally reachable IP.
|
// is done. This is typically a locally reachable IP.
|
||||||
@@ -86,6 +89,9 @@ type settings struct {
|
|||||||
func configFromEnv() (*settings, error) {
|
func configFromEnv() (*settings, error) {
|
||||||
cfg := &settings{
|
cfg := &settings{
|
||||||
AuthKey: defaultEnvs([]string{"TS_AUTHKEY", "TS_AUTH_KEY"}, ""),
|
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", ""),
|
Hostname: defaultEnv("TS_HOSTNAME", ""),
|
||||||
Routes: defaultEnvStringPointer("TS_ROUTES"),
|
Routes: defaultEnvStringPointer("TS_ROUTES"),
|
||||||
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
|
ServeConfigPath: defaultEnv("TS_SERVE_CONFIG", ""),
|
||||||
@@ -241,8 +247,17 @@ func (s *settings) validate() error {
|
|||||||
if s.TailnetTargetFQDN != "" && s.TailnetTargetIP != "" {
|
if s.TailnetTargetFQDN != "" && s.TailnetTargetIP != "" {
|
||||||
return errors.New("Both TS_TAILNET_TARGET_IP and TS_TAILNET_FQDN cannot be set")
|
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 != "") {
|
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.")
|
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 {
|
if s.AllowProxyingClusterTrafficViaIngress && s.UserspaceMode {
|
||||||
return errors.New("EXPERIMENTAL_ALLOW_PROXYING_CLUSTER_TRAFFIC_VIA_INGRESS is not supported in userspace mode")
|
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.
|
// Return early if we already have an auth key or are using OAuth/WIF.
|
||||||
if cfg.AuthKey != "" || isOneStepConfig(cfg) {
|
if cfg.AuthKey != "" || cfg.ClientID != "" || cfg.ClientSecret != "" || isOneStepConfig(cfg) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func Test_parseAcceptDNS(t *testing.T) {
|
func Test_parseAcceptDNS(t *testing.T) {
|
||||||
tests := []struct {
|
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 != "" {
|
if cfg.AuthKey != "" {
|
||||||
args = append(args, "--authkey="+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
|
// --advertise-routes can be passed an empty string to configure a
|
||||||
// device (that might have previously advertised subnet routes) to not
|
// device (that might have previously advertised subnet routes) to not
|
||||||
// advertise any routes. Respect an empty string passed by a user and
|
// advertise any routes. Respect an empty string passed by a user and
|
||||||
|
|||||||
Reference in New Issue
Block a user