From 66973a03dbe8e300f2607ad3ada8d515948bac93 Mon Sep 17 00:00:00 2001 From: Krzysztof Bogacki Date: Mon, 17 Nov 2025 17:20:21 +0100 Subject: [PATCH] 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 --- client/admin_api.go | 2 +- client/service.go | 5 +++ cmd/frpc/sub/proxy.go | 11 +++-- cmd/frpc/sub/root.go | 27 ++++++++---- cmd/frpc/sub/verify.go | 5 ++- pkg/auth/auth.go | 10 +++-- pkg/auth/oidc.go | 45 ++++++++++++++++++++ pkg/config/v1/client.go | 8 ++++ pkg/config/v1/validation/client.go | 19 +++++++-- pkg/config/v1/value_source.go | 67 +++++++++++++++++++++++++++++- 10 files changed, 179 insertions(+), 20 deletions(-) diff --git a/client/admin_api.go b/client/admin_api.go index f161d588..b726dc33 100644 --- a/client/admin_api.go +++ b/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) diff --git a/client/service.go b/client/service.go index f906e4d0..60eb2d90 100644 --- a/client/service.go +++ b/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, diff --git a/cmd/frpc/sub/proxy.go b/cmd/frpc/sub/proxy.go index 67bd774f..64c0aade 100644 --- a/cmd/frpc/sub/proxy.go +++ b/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) diff --git a/cmd/frpc/sub/root.go b/cmd/frpc/sub/root.go index ee89c489..b9a373b1 100644 --- a/cmd/frpc/sub/root.go +++ b/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 { diff --git a/cmd/frpc/sub/verify.go b/cmd/frpc/sub/verify.go index 4b971f53..2198114c 100644 --- a/cmd/frpc/sub/verify.go +++ b/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) } diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index b954fc80..64462a20 100644 --- a/pkg/auth/auth.go +++ b/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) diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go index c5f63640..d9377f32 100644 --- a/pkg/auth/oidc.go +++ b/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) } diff --git a/pkg/config/v1/client.go b/pkg/config/v1/client.go index c6cf97a6..72a19fb3 100644 --- a/pkg/config/v1/client.go +++ b/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 +} diff --git a/pkg/config/v1/validation/client.go b/pkg/config/v1/validation/client.go index 0c8575c9..7734a5a4 100644 --- a/pkg/config/v1/validation/client.go +++ b/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 diff --git a/pkg/config/v1/value_source.go b/pkg/config/v1/value_source.go index 624a2658..88dbaff3 100644 --- a/pkg/config/v1/value_source.go +++ b/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 +}