Bläddra i källkod

Add `exec` value source type (#5050)

* config: introduce ExecSource value source

* auth: introduce OidcTokenSourceAuthProvider

* auth: use OidcTokenSourceAuthProvider if tokenSource config is present on the client

* cmd: allow exec token source only if CLI flag was passed
Krzysztof Bogacki 3 dagar sedan
förälder
incheckning
66973a03db

+ 1 - 1
client/admin_api.go

@@ -92,7 +92,7 @@ func (svr *Service) apiReload(w http.ResponseWriter, r *http.Request) {
 		log.Warnf("reload frpc proxy config error: %s", res.Msg)
 		return
 	}
-	if _, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs); err != nil {
+	if _, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, svr.unsafeFeatures); err != nil {
 		res.Code = 400
 		res.Msg = err.Error()
 		log.Warnf("reload frpc proxy config error: %s", res.Msg)

+ 5 - 0
client/service.go

@@ -64,6 +64,8 @@ type ServiceOptions struct {
 	ProxyCfgs   []v1.ProxyConfigurer
 	VisitorCfgs []v1.VisitorConfigurer
 
+	UnsafeFeatures v1.UnsafeFeatures
+
 	// ConfigFilePath is the path to the configuration file used to initialize.
 	// If it is empty, it means that the configuration file is not used for initialization.
 	// It may be initialized using command line parameters or called directly.
@@ -122,6 +124,8 @@ type Service struct {
 	visitorCfgs []v1.VisitorConfigurer
 	clientSpec  *msg.ClientSpec
 
+	unsafeFeatures v1.UnsafeFeatures
+
 	// The configuration file used to initialize this client, or an empty
 	// string if no configuration file was used.
 	configFilePath string
@@ -161,6 +165,7 @@ func NewService(options ServiceOptions) (*Service, error) {
 		webServer:        webServer,
 		common:           options.Common,
 		configFilePath:   options.ConfigFilePath,
+		unsafeFeatures:   options.UnsafeFeatures,
 		proxyCfgs:        options.ProxyCfgs,
 		visitorCfgs:      options.VisitorCfgs,
 		clientSpec:       options.ClientSpec,

+ 7 - 4
cmd/frpc/sub/proxy.go

@@ -77,7 +77,9 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm
 				fmt.Println(err)
 				os.Exit(1)
 			}
-			if _, err := validation.ValidateClientCommonConfig(clientCfg); err != nil {
+
+			unsafeFeatures := v1.UnsafeFeatures{TokenSourceExec: slices.Contains(allowUnsafe, TokenSourceExec)}
+			if _, err := validation.ValidateClientCommonConfig(clientCfg, unsafeFeatures); err != nil {
 				fmt.Println(err)
 				os.Exit(1)
 			}
@@ -88,7 +90,7 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm
 				fmt.Println(err)
 				os.Exit(1)
 			}
-			err := startService(clientCfg, []v1.ProxyConfigurer{c}, nil, "")
+			err := startService(clientCfg, []v1.ProxyConfigurer{c}, nil, unsafeFeatures, "")
 			if err != nil {
 				fmt.Println(err)
 				os.Exit(1)
@@ -106,7 +108,8 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client
 				fmt.Println(err)
 				os.Exit(1)
 			}
-			if _, err := validation.ValidateClientCommonConfig(clientCfg); err != nil {
+			unsafeFeatures := v1.UnsafeFeatures{TokenSourceExec: slices.Contains(allowUnsafe, TokenSourceExec)}
+			if _, err := validation.ValidateClientCommonConfig(clientCfg, unsafeFeatures); err != nil {
 				fmt.Println(err)
 				os.Exit(1)
 			}
@@ -117,7 +120,7 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client
 				fmt.Println(err)
 				os.Exit(1)
 			}
-			err := startService(clientCfg, nil, []v1.VisitorConfigurer{c}, "")
+			err := startService(clientCfg, nil, []v1.VisitorConfigurer{c}, unsafeFeatures, "")
 			if err != nil {
 				fmt.Println(err)
 				os.Exit(1)

+ 20 - 7
cmd/frpc/sub/root.go

@@ -21,6 +21,7 @@ import (
 	"os"
 	"os/signal"
 	"path/filepath"
+	"slices"
 	"sync"
 	"syscall"
 	"time"
@@ -36,11 +37,18 @@ import (
 	"github.com/fatedier/frp/pkg/util/version"
 )
 
+type UnsafeFeature = string
+
+const (
+	TokenSourceExec UnsafeFeature = "TokenSourceExec"
+)
+
 var (
 	cfgFile          string
 	cfgDir           string
 	showVersion      bool
 	strictConfigMode bool
+	allowUnsafe      []UnsafeFeature
 )
 
 func init() {
@@ -48,6 +56,7 @@ func init() {
 	rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory")
 	rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc")
 	rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", true, "strict config parsing mode, unknown fields will cause an errors")
+	rootCmd.PersistentFlags().StringSliceVarP(&allowUnsafe, "allow_unsafe", "", []string{}, "allowed unsafe features, one or more of: TokenSourceExec")
 }
 
 var rootCmd = &cobra.Command{
@@ -59,15 +68,17 @@ var rootCmd = &cobra.Command{
 			return nil
 		}
 
+		unsafeFeatures := v1.UnsafeFeatures{TokenSourceExec: slices.Contains(allowUnsafe, TokenSourceExec)}
+
 		// If cfgDir is not empty, run multiple frpc service for each config file in cfgDir.
 		// Note that it's only designed for testing. It's not guaranteed to be stable.
 		if cfgDir != "" {
-			_ = runMultipleClients(cfgDir)
+			_ = runMultipleClients(cfgDir, unsafeFeatures)
 			return nil
 		}
 
 		// Do not show command usage here.
-		err := runClient(cfgFile)
+		err := runClient(cfgFile, unsafeFeatures)
 		if err != nil {
 			fmt.Println(err)
 			os.Exit(1)
@@ -76,7 +87,7 @@ var rootCmd = &cobra.Command{
 	},
 }
 
-func runMultipleClients(cfgDir string) error {
+func runMultipleClients(cfgDir string, unsafeFeatures v1.UnsafeFeatures) error {
 	var wg sync.WaitGroup
 	err := filepath.WalkDir(cfgDir, func(path string, d fs.DirEntry, err error) error {
 		if err != nil || d.IsDir() {
@@ -86,7 +97,7 @@ func runMultipleClients(cfgDir string) error {
 		time.Sleep(time.Millisecond)
 		go func() {
 			defer wg.Done()
-			err := runClient(path)
+			err := runClient(path, unsafeFeatures)
 			if err != nil {
 				fmt.Printf("frpc service error for config file [%s]\n", path)
 			}
@@ -111,7 +122,7 @@ func handleTermSignal(svr *client.Service) {
 	svr.GracefulClose(500 * time.Millisecond)
 }
 
-func runClient(cfgFilePath string) error {
+func runClient(cfgFilePath string, unsafeFeatures v1.UnsafeFeatures) error {
 	cfg, proxyCfgs, visitorCfgs, isLegacyFormat, err := config.LoadClientConfig(cfgFilePath, strictConfigMode)
 	if err != nil {
 		return err
@@ -127,20 +138,21 @@ func runClient(cfgFilePath string) error {
 		}
 	}
 
-	warning, err := validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs)
+	warning, err := validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs, unsafeFeatures)
 	if warning != nil {
 		fmt.Printf("WARNING: %v\n", warning)
 	}
 	if err != nil {
 		return err
 	}
-	return startService(cfg, proxyCfgs, visitorCfgs, cfgFilePath)
+	return startService(cfg, proxyCfgs, visitorCfgs, unsafeFeatures, cfgFilePath)
 }
 
 func startService(
 	cfg *v1.ClientCommonConfig,
 	proxyCfgs []v1.ProxyConfigurer,
 	visitorCfgs []v1.VisitorConfigurer,
+	unsafeFeatures v1.UnsafeFeatures,
 	cfgFile string,
 ) error {
 	log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor)
@@ -153,6 +165,7 @@ func startService(
 		Common:         cfg,
 		ProxyCfgs:      proxyCfgs,
 		VisitorCfgs:    visitorCfgs,
+		UnsafeFeatures: unsafeFeatures,
 		ConfigFilePath: cfgFile,
 	})
 	if err != nil {

+ 4 - 1
cmd/frpc/sub/verify.go

@@ -17,10 +17,12 @@ package sub
 import (
 	"fmt"
 	"os"
+	"slices"
 
 	"github.com/spf13/cobra"
 
 	"github.com/fatedier/frp/pkg/config"
+	v1 "github.com/fatedier/frp/pkg/config/v1"
 	"github.com/fatedier/frp/pkg/config/v1/validation"
 )
 
@@ -42,7 +44,8 @@ var verifyCmd = &cobra.Command{
 			fmt.Println(err)
 			os.Exit(1)
 		}
-		warning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs)
+		unsafeFeatures := v1.UnsafeFeatures{TokenSourceExec: slices.Contains(allowUnsafe, TokenSourceExec)}
+		warning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, unsafeFeatures)
 		if warning != nil {
 			fmt.Printf("WARNING: %v\n", warning)
 		}

+ 7 - 3
pkg/auth/auth.go

@@ -32,9 +32,13 @@ func NewAuthSetter(cfg v1.AuthClientConfig) (authProvider Setter, err error) {
 	case v1.AuthMethodToken:
 		authProvider = NewTokenAuth(cfg.AdditionalScopes, cfg.Token)
 	case v1.AuthMethodOIDC:
-		authProvider, err = NewOidcAuthSetter(cfg.AdditionalScopes, cfg.OIDC)
-		if err != nil {
-			return nil, err
+		if cfg.OIDC.TokenSource != nil {
+			authProvider = NewOidcTokenSourceAuthSetter(cfg.AdditionalScopes, cfg.OIDC.TokenSource)
+		} else {
+			authProvider, err = NewOidcAuthSetter(cfg.AdditionalScopes, cfg.OIDC)
+			if err != nil {
+				return nil, err
+			}
 		}
 	default:
 		return nil, fmt.Errorf("unsupported auth method: %s", cfg.Method)

+ 45 - 0
pkg/auth/oidc.go

@@ -152,6 +152,51 @@ func (auth *OidcAuthProvider) SetNewWorkConn(newWorkConnMsg *msg.NewWorkConn) (e
 	return err
 }
 
+type OidcTokenSourceAuthProvider struct {
+	additionalAuthScopes []v1.AuthScope
+
+	valueSource *v1.ValueSource
+}
+
+func NewOidcTokenSourceAuthSetter(additionalAuthScopes []v1.AuthScope, valueSource *v1.ValueSource) *OidcTokenSourceAuthProvider {
+	return &OidcTokenSourceAuthProvider{
+		additionalAuthScopes: additionalAuthScopes,
+		valueSource:          valueSource,
+	}
+}
+
+func (auth *OidcTokenSourceAuthProvider) generateAccessToken() (accessToken string, err error) {
+	ctx := context.Background()
+	accessToken, err = auth.valueSource.Resolve(ctx)
+	if err != nil {
+		return "", fmt.Errorf("couldn't acquire OIDC token for login: %v", err)
+	}
+	return
+}
+
+func (auth *OidcTokenSourceAuthProvider) SetLogin(loginMsg *msg.Login) (err error) {
+	loginMsg.PrivilegeKey, err = auth.generateAccessToken()
+	return err
+}
+
+func (auth *OidcTokenSourceAuthProvider) SetPing(pingMsg *msg.Ping) (err error) {
+	if !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeHeartBeats) {
+		return nil
+	}
+
+	pingMsg.PrivilegeKey, err = auth.generateAccessToken()
+	return err
+}
+
+func (auth *OidcTokenSourceAuthProvider) SetNewWorkConn(newWorkConnMsg *msg.NewWorkConn) (err error) {
+	if !slices.Contains(auth.additionalAuthScopes, v1.AuthScopeNewWorkConns) {
+		return nil
+	}
+
+	newWorkConnMsg.PrivilegeKey, err = auth.generateAccessToken()
+	return err
+}
+
 type TokenVerifier interface {
 	Verify(context.Context, string) (*oidc.IDToken, error)
 }

+ 8 - 0
pkg/config/v1/client.go

@@ -239,8 +239,16 @@ type AuthOIDCClientConfig struct {
 	// Supports http, https, socks5, and socks5h proxy protocols.
 	// If empty, no proxy is used for OIDC connections.
 	ProxyURL string `json:"proxyURL,omitempty"`
+
+	// TokenSource specifies a custom dynamic source for the authorization token.
+	// This is mutually exclusive with every other field of this structure.
+	TokenSource *ValueSource `json:"tokenSource,omitempty"`
 }
 
 type VirtualNetConfig struct {
 	Address string `json:"address,omitempty"`
 }
+
+type UnsafeFeatures struct {
+	TokenSourceExec bool
+}

+ 16 - 3
pkg/config/v1/validation/client.go

@@ -26,7 +26,7 @@ import (
 	"github.com/fatedier/frp/pkg/featuregate"
 )
 
-func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) {
+func ValidateClientCommonConfig(c *v1.ClientCommonConfig, unsafeFeatures v1.UnsafeFeatures) (Warning, error) {
 	var (
 		warnings Warning
 		errs     error
@@ -52,11 +52,24 @@ func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) {
 
 	// Validate tokenSource if specified
 	if c.Auth.TokenSource != nil {
+		if c.Auth.TokenSource.Type == "exec" && !unsafeFeatures.TokenSourceExec {
+			errs = AppendError(errs, fmt.Errorf("unsafe 'exec' not allowed for auth.tokenSource.type"))
+		}
 		if err := c.Auth.TokenSource.Validate(); err != nil {
 			errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
 		}
 	}
 
+	if c.Auth.OIDC.TokenSource != nil {
+		// Validate oidc.tokenSource mutual exclusivity with other fields of oidc
+		if c.Auth.OIDC.ClientID != "" || c.Auth.OIDC.ClientSecret != "" || c.Auth.OIDC.Audience != "" || c.Auth.OIDC.Scope != "" || c.Auth.OIDC.TokenEndpointURL != "" || len(c.Auth.OIDC.AdditionalEndpointParams) > 0 || c.Auth.OIDC.TrustedCaFile != "" || c.Auth.OIDC.InsecureSkipVerify || c.Auth.OIDC.ProxyURL != "" {
+			errs = AppendError(errs, fmt.Errorf("cannot specify both auth.oidc.tokenSource and any other field of auth.oidc"))
+		}
+		if c.Auth.OIDC.TokenSource.Type == "exec" && !unsafeFeatures.TokenSourceExec {
+			errs = AppendError(errs, fmt.Errorf("unsafe 'exec' not allowed for auth.oidc.tokenSource.type"))
+		}
+	}
+
 	if err := validateLogConfig(&c.Log); err != nil {
 		errs = AppendError(errs, err)
 	}
@@ -101,10 +114,10 @@ func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) {
 	return warnings, errs
 }
 
-func ValidateAllClientConfig(c *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) (Warning, error) {
+func ValidateAllClientConfig(c *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer, unsafeFeatures v1.UnsafeFeatures) (Warning, error) {
 	var warnings Warning
 	if c != nil {
-		warning, err := ValidateClientCommonConfig(c)
+		warning, err := ValidateClientCommonConfig(c, unsafeFeatures)
 		warnings = AppendError(warnings, warning)
 		if err != nil {
 			return warnings, err

+ 66 - 1
pkg/config/v1/value_source.go

@@ -19,6 +19,7 @@ import (
 	"errors"
 	"fmt"
 	"os"
+	"os/exec"
 	"strings"
 )
 
@@ -27,6 +28,7 @@ import (
 type ValueSource struct {
 	Type string      `json:"type"`
 	File *FileSource `json:"file,omitempty"`
+	Exec *ExecSource `json:"exec,omitempty"`
 }
 
 // FileSource specifies how to load a value from a file.
@@ -34,6 +36,18 @@ type FileSource struct {
 	Path string `json:"path"`
 }
 
+// ExecSource specifies how to get a value from another program launched as subprocess.
+type ExecSource struct {
+	Command string       `json:"command"`
+	Args    []string     `json:"args,omitempty"`
+	Env     []ExecEnvVar `json:"env,omitempty"`
+}
+
+type ExecEnvVar struct {
+	Name  string `json:"name"`
+	Value string `json:"value"`
+}
+
 // Validate validates the ValueSource configuration.
 func (v *ValueSource) Validate() error {
 	if v == nil {
@@ -46,8 +60,13 @@ func (v *ValueSource) Validate() error {
 			return errors.New("file configuration is required when type is 'file'")
 		}
 		return v.File.Validate()
+	case "exec":
+		if v.Exec == nil {
+			return errors.New("exec configuration is required when type is 'exec'")
+		}
+		return v.Exec.Validate()
 	default:
-		return fmt.Errorf("unsupported value source type: %s (only 'file' is supported)", v.Type)
+		return fmt.Errorf("unsupported value source type: %s (only 'file' and 'exec' are supported)", v.Type)
 	}
 }
 
@@ -60,6 +79,8 @@ func (v *ValueSource) Resolve(ctx context.Context) (string, error) {
 	switch v.Type {
 	case "file":
 		return v.File.Resolve(ctx)
+	case "exec":
+		return v.Exec.Resolve(ctx)
 	default:
 		return "", fmt.Errorf("unsupported value source type: %s", v.Type)
 	}
@@ -91,3 +112,47 @@ func (f *FileSource) Resolve(_ context.Context) (string, error) {
 	// Trim whitespace, which is important for file-based tokens
 	return strings.TrimSpace(string(content)), nil
 }
+
+// Validate validates the ExecSource configuration.
+func (e *ExecSource) Validate() error {
+	if e == nil {
+		return errors.New("execSource cannot be nil")
+	}
+
+	if e.Command == "" {
+		return errors.New("exec command cannot be empty")
+	}
+
+	for _, env := range e.Env {
+		if env.Name == "" {
+			return errors.New("exec env name cannot be empty")
+		}
+		if strings.Contains(env.Name, "=") {
+			return errors.New("exec env name cannot contain '='")
+		}
+	}
+	return nil
+}
+
+// Resolve reads and returns the content captured from stdout of launched subprocess.
+func (e *ExecSource) Resolve(ctx context.Context) (string, error) {
+	if err := e.Validate(); err != nil {
+		return "", err
+	}
+
+	cmd := exec.CommandContext(ctx, e.Command, e.Args...)
+	if len(e.Env) != 0 {
+		cmd.Env = os.Environ()
+		for _, env := range e.Env {
+			cmd.Env = append(cmd.Env, env.Name+"="+env.Value)
+		}
+	}
+
+	content, err := cmd.Output()
+	if err != nil {
+		return "", fmt.Errorf("failed to execute command %v: %v", e.Command, err)
+	}
+
+	// Trim whitespace, which is important for exec-based tokens
+	return strings.TrimSpace(string(content)), nil
+}