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
This commit is contained in:
Krzysztof Bogacki
2025-11-17 17:20:21 +01:00
committed by GitHub
parent f736d171ac
commit 66973a03db
10 changed files with 179 additions and 20 deletions

View File

@@ -92,7 +92,7 @@ func (svr *Service) apiReload(w http.ResponseWriter, r *http.Request) {
log.Warnf("reload frpc proxy config error: %s", res.Msg) log.Warnf("reload frpc proxy config error: %s", res.Msg)
return 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.Code = 400
res.Msg = err.Error() res.Msg = err.Error()
log.Warnf("reload frpc proxy config error: %s", res.Msg) log.Warnf("reload frpc proxy config error: %s", res.Msg)

View File

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

View File

@@ -77,7 +77,9 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm
fmt.Println(err) fmt.Println(err)
os.Exit(1) 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) fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
@@ -88,7 +90,7 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
err := startService(clientCfg, []v1.ProxyConfigurer{c}, nil, "") err := startService(clientCfg, []v1.ProxyConfigurer{c}, nil, unsafeFeatures, "")
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
@@ -106,7 +108,8 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client
fmt.Println(err) fmt.Println(err)
os.Exit(1) 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) fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
@@ -117,7 +120,7 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
err := startService(clientCfg, nil, []v1.VisitorConfigurer{c}, "") err := startService(clientCfg, nil, []v1.VisitorConfigurer{c}, unsafeFeatures, "")
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)

View File

@@ -21,6 +21,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"slices"
"sync" "sync"
"syscall" "syscall"
"time" "time"
@@ -36,11 +37,18 @@ import (
"github.com/fatedier/frp/pkg/util/version" "github.com/fatedier/frp/pkg/util/version"
) )
type UnsafeFeature = string
const (
TokenSourceExec UnsafeFeature = "TokenSourceExec"
)
var ( var (
cfgFile string cfgFile string
cfgDir string cfgDir string
showVersion bool showVersion bool
strictConfigMode bool strictConfigMode bool
allowUnsafe []UnsafeFeature
) )
func init() { 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().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(&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().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{ var rootCmd = &cobra.Command{
@@ -59,15 +68,17 @@ var rootCmd = &cobra.Command{
return nil 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. // 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. // Note that it's only designed for testing. It's not guaranteed to be stable.
if cfgDir != "" { if cfgDir != "" {
_ = runMultipleClients(cfgDir) _ = runMultipleClients(cfgDir, unsafeFeatures)
return nil return nil
} }
// Do not show command usage here. // Do not show command usage here.
err := runClient(cfgFile) err := runClient(cfgFile, unsafeFeatures)
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) 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 var wg sync.WaitGroup
err := filepath.WalkDir(cfgDir, func(path string, d fs.DirEntry, err error) error { err := filepath.WalkDir(cfgDir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() { if err != nil || d.IsDir() {
@@ -86,7 +97,7 @@ func runMultipleClients(cfgDir string) error {
time.Sleep(time.Millisecond) time.Sleep(time.Millisecond)
go func() { go func() {
defer wg.Done() defer wg.Done()
err := runClient(path) err := runClient(path, unsafeFeatures)
if err != nil { if err != nil {
fmt.Printf("frpc service error for config file [%s]\n", path) 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) 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) cfg, proxyCfgs, visitorCfgs, isLegacyFormat, err := config.LoadClientConfig(cfgFilePath, strictConfigMode)
if err != nil { if err != nil {
return err 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 { if warning != nil {
fmt.Printf("WARNING: %v\n", warning) fmt.Printf("WARNING: %v\n", warning)
} }
if err != nil { if err != nil {
return err return err
} }
return startService(cfg, proxyCfgs, visitorCfgs, cfgFilePath) return startService(cfg, proxyCfgs, visitorCfgs, unsafeFeatures, cfgFilePath)
} }
func startService( func startService(
cfg *v1.ClientCommonConfig, cfg *v1.ClientCommonConfig,
proxyCfgs []v1.ProxyConfigurer, proxyCfgs []v1.ProxyConfigurer,
visitorCfgs []v1.VisitorConfigurer, visitorCfgs []v1.VisitorConfigurer,
unsafeFeatures v1.UnsafeFeatures,
cfgFile string, cfgFile string,
) error { ) error {
log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor) log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor)
@@ -153,6 +165,7 @@ func startService(
Common: cfg, Common: cfg,
ProxyCfgs: proxyCfgs, ProxyCfgs: proxyCfgs,
VisitorCfgs: visitorCfgs, VisitorCfgs: visitorCfgs,
UnsafeFeatures: unsafeFeatures,
ConfigFilePath: cfgFile, ConfigFilePath: cfgFile,
}) })
if err != nil { if err != nil {

View File

@@ -17,10 +17,12 @@ package sub
import ( import (
"fmt" "fmt"
"os" "os"
"slices"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/fatedier/frp/pkg/config" "github.com/fatedier/frp/pkg/config"
v1 "github.com/fatedier/frp/pkg/config/v1"
"github.com/fatedier/frp/pkg/config/v1/validation" "github.com/fatedier/frp/pkg/config/v1/validation"
) )
@@ -42,7 +44,8 @@ var verifyCmd = &cobra.Command{
fmt.Println(err) fmt.Println(err)
os.Exit(1) 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 { if warning != nil {
fmt.Printf("WARNING: %v\n", warning) fmt.Printf("WARNING: %v\n", warning)
} }

View File

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

View File

@@ -152,6 +152,51 @@ func (auth *OidcAuthProvider) SetNewWorkConn(newWorkConnMsg *msg.NewWorkConn) (e
return err 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 { type TokenVerifier interface {
Verify(context.Context, string) (*oidc.IDToken, error) Verify(context.Context, string) (*oidc.IDToken, error)
} }

View File

@@ -239,8 +239,16 @@ type AuthOIDCClientConfig struct {
// Supports http, https, socks5, and socks5h proxy protocols. // Supports http, https, socks5, and socks5h proxy protocols.
// If empty, no proxy is used for OIDC connections. // If empty, no proxy is used for OIDC connections.
ProxyURL string `json:"proxyURL,omitempty"` 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 { type VirtualNetConfig struct {
Address string `json:"address,omitempty"` Address string `json:"address,omitempty"`
} }
type UnsafeFeatures struct {
TokenSourceExec bool
}

View File

@@ -26,7 +26,7 @@ import (
"github.com/fatedier/frp/pkg/featuregate" "github.com/fatedier/frp/pkg/featuregate"
) )
func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) { func ValidateClientCommonConfig(c *v1.ClientCommonConfig, unsafeFeatures v1.UnsafeFeatures) (Warning, error) {
var ( var (
warnings Warning warnings Warning
errs error errs error
@@ -52,11 +52,24 @@ func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) {
// Validate tokenSource if specified // Validate tokenSource if specified
if c.Auth.TokenSource != nil { 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 { if err := c.Auth.TokenSource.Validate(); err != nil {
errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err)) 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 { if err := validateLogConfig(&c.Log); err != nil {
errs = AppendError(errs, err) errs = AppendError(errs, err)
} }
@@ -101,10 +114,10 @@ func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) {
return warnings, errs 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 var warnings Warning
if c != nil { if c != nil {
warning, err := ValidateClientCommonConfig(c) warning, err := ValidateClientCommonConfig(c, unsafeFeatures)
warnings = AppendError(warnings, warning) warnings = AppendError(warnings, warning)
if err != nil { if err != nil {
return warnings, err return warnings, err

View File

@@ -19,6 +19,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/exec"
"strings" "strings"
) )
@@ -27,6 +28,7 @@ import (
type ValueSource struct { type ValueSource struct {
Type string `json:"type"` Type string `json:"type"`
File *FileSource `json:"file,omitempty"` File *FileSource `json:"file,omitempty"`
Exec *ExecSource `json:"exec,omitempty"`
} }
// FileSource specifies how to load a value from a file. // FileSource specifies how to load a value from a file.
@@ -34,6 +36,18 @@ type FileSource struct {
Path string `json:"path"` 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. // Validate validates the ValueSource configuration.
func (v *ValueSource) Validate() error { func (v *ValueSource) Validate() error {
if v == nil { if v == nil {
@@ -46,8 +60,13 @@ func (v *ValueSource) Validate() error {
return errors.New("file configuration is required when type is 'file'") return errors.New("file configuration is required when type is 'file'")
} }
return v.File.Validate() 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: 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 { switch v.Type {
case "file": case "file":
return v.File.Resolve(ctx) return v.File.Resolve(ctx)
case "exec":
return v.Exec.Resolve(ctx)
default: default:
return "", fmt.Errorf("unsupported value source type: %s", v.Type) 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 // Trim whitespace, which is important for file-based tokens
return strings.TrimSpace(string(content)), nil 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
}