Compare commits

...

5 Commits
v0.63.0 ... dev

Author SHA1 Message Date
fatedier
e6dacf3a67 Fix SSH tunnel gateway binding address issue #4900 (#4902)
- Fix SSH tunnel gateway incorrectly binding to proxyBindAddr instead of bindAddr
- This caused external connections to fail when proxyBindAddr was set to 127.0.0.1
- SSH tunnel gateway now correctly binds to bindAddr for external accessibility
- Update Release.md with bug fix description

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-28 15:19:56 +08:00
fatedier
7fe295f4f4 update golangci-lint version (#4897) 2025-07-25 17:10:32 +08:00
maguowei
c3bf952d8f fix webserver port not being released on frpc svr.Close() (#4896) 2025-07-24 10:16:44 +08:00
fatedier
f9065a6a78 add tokenSource support for auth configuration (#4865) 2025-07-03 13:17:21 +08:00
fatedier
61330d4d79 Update quic-go dependency from v0.48.2 to v0.53.0 (#4862)
- Update go.mod to use github.com/quic-go/quic-go v0.53.0
- Replace quic.Connection interface with *quic.Conn struct
- Replace quic.Stream interface with *quic.Stream struct
- Update all affected files to use new API:
  - pkg/util/net/conn.go: Update QuicStreamToNetConn function and wrapQuicStream struct
  - server/service.go: Update HandleQUICListener function parameter
  - client/visitor/xtcp.go: Update QUICTunnelSession struct field
  - client/connector.go: Update defaultConnectorImpl struct field

Fixes #4852

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-01 18:56:46 +08:00
28 changed files with 854 additions and 47 deletions

View File

@@ -23,14 +23,4 @@ jobs:
uses: golangci/golangci-lint-action@v8
with:
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
version: v2.1
# Optional: golangci-lint command line arguments.
# args: --issues-exit-code=0
# Optional: show only new issues if it's a pull request. The default value is `false`.
# only-new-issues: true
# Optional: if set to true then the all caching functionality will be complete disabled,
# takes precedence over all other caching options.
# skip-cache: true
version: v2.3

View File

@@ -73,6 +73,9 @@ linters:
- linters:
- revive
text: unused-parameter
- linters:
- revive
text: "avoid meaningless package names"
- linters:
- unparam
text: is always false

View File

@@ -612,6 +612,21 @@ When specifying `auth.method = "token"` in `frpc.toml` and `frps.toml` - token b
Make sure to specify the same `auth.token` in `frps.toml` and `frpc.toml` for frpc to pass frps validation
##### Token Source
frp supports reading authentication tokens from external sources using the `tokenSource` configuration. Currently, file-based token source is supported.
**File-based token source:**
```toml
# frpc.toml
auth.method = "token"
auth.tokenSource.type = "file"
auth.tokenSource.file.path = "/path/to/token/file"
```
The token will be read from the specified file at startup. This is useful for scenarios where tokens are managed by external systems or need to be kept separate from configuration files for security reasons.
#### OIDC Authentication
When specifying `auth.method = "oidc"` in `frpc.toml` and `frps.toml` - OIDC based authentication will be used.

View File

@@ -1,4 +1,7 @@
## Features
* Support for YAML merge functionality (anchors and references with dot-prefixed fields) in strict configuration mode without requiring `--strict-config=false` parameter.
* Support for proxy protocol in UDP proxies to preserve real client IP addresses.
* Support tokenSource for loading authentication tokens from files
## Fixes
* Fix SSH tunnel gateway incorrectly binding to proxyBindAddr instead of bindAddr, which caused external connections to fail when proxyBindAddr was set to 127.0.0.1

View File

@@ -48,7 +48,7 @@ type defaultConnectorImpl struct {
cfg *v1.ClientCommonConfig
muxSession *fmux.Session
quicConn quic.Connection
quicConn *quic.Conn
closeOnce sync.Once
}

View File

@@ -88,13 +88,16 @@ type ServiceOptions struct {
}
// setServiceOptionsDefault sets the default values for ServiceOptions.
func setServiceOptionsDefault(options *ServiceOptions) {
func setServiceOptionsDefault(options *ServiceOptions) error {
if options.Common != nil {
options.Common.Complete()
if err := options.Common.Complete(); err != nil {
return err
}
}
if options.ConnectorCreator == nil {
options.ConnectorCreator = NewConnector
}
return nil
}
// Service is the client service that connects to frps and provides proxy services.
@@ -134,7 +137,9 @@ type Service struct {
}
func NewService(options ServiceOptions) (*Service, error) {
setServiceOptionsDefault(&options)
if err := setServiceOptionsDefault(&options); err != nil {
return nil, err
}
var webServer *httppkg.Server
if options.Common.WebServer.Port > 0 {
@@ -398,6 +403,10 @@ func (svr *Service) stop() {
svr.ctl.GracefulClose(svr.gracefulShutdownDuration)
svr.ctl = nil
}
if svr.webServer != nil {
svr.webServer.Close()
svr.webServer = nil
}
}
func (svr *Service) getProxyStatus(name string) (*proxy.WorkingStatus, bool) {

View File

@@ -398,7 +398,7 @@ func (ks *KCPTunnelSession) Close() {
}
type QUICTunnelSession struct {
session quic.Connection
session *quic.Conn
listenConn *net.UDPConn
mu sync.RWMutex

View File

@@ -51,7 +51,10 @@ var natholeDiscoveryCmd = &cobra.Command{
cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode)
if err != nil {
cfg = &v1.ClientCommonConfig{}
cfg.Complete()
if err := cfg.Complete(); err != nil {
fmt.Printf("failed to complete config: %v\n", err)
os.Exit(1)
}
}
if natHoleSTUNServer != "" {
cfg.NatHoleSTUNServer = natHoleSTUNServer

View File

@@ -73,7 +73,10 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm
Use: name,
Short: fmt.Sprintf("Run frpc with a single %s proxy", name),
Run: func(cmd *cobra.Command, args []string) {
clientCfg.Complete()
if err := clientCfg.Complete(); err != nil {
fmt.Println(err)
os.Exit(1)
}
if _, err := validation.ValidateClientCommonConfig(clientCfg); err != nil {
fmt.Println(err)
os.Exit(1)
@@ -99,7 +102,10 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client
Use: "visitor",
Short: fmt.Sprintf("Run frpc with a single %s visitor", name),
Run: func(cmd *cobra.Command, args []string) {
clientCfg.Complete()
if err := clientCfg.Complete(); err != nil {
fmt.Println(err)
os.Exit(1)
}
if _, err := validation.ValidateClientCommonConfig(clientCfg); err != nil {
fmt.Println(err)
os.Exit(1)

View File

@@ -70,7 +70,10 @@ var rootCmd = &cobra.Command{
"please use yaml/json/toml format instead!\n")
}
} else {
serverCfg.Complete()
if err := serverCfg.Complete(); err != nil {
fmt.Printf("failed to complete server config: %v\n", err)
os.Exit(1)
}
svrCfg = &serverCfg
}

View File

@@ -32,6 +32,11 @@ auth.method = "token"
# auth token
auth.token = "12345678"
# alternatively, you can use tokenSource to load the token from a file
# this is mutually exclusive with auth.token
# auth.tokenSource.type = "file"
# auth.tokenSource.file.path = "/etc/frp/token"
# oidc.clientID specifies the client ID to use to get a token in OIDC authentication.
# auth.oidc.clientID = ""
# oidc.clientSecret specifies the client secret to use to get a token in OIDC authentication.

View File

@@ -105,6 +105,11 @@ auth.method = "token"
# auth token
auth.token = "12345678"
# alternatively, you can use tokenSource to load the token from a file
# this is mutually exclusive with auth.token
# auth.tokenSource.type = "file"
# auth.tokenSource.file.path = "/etc/frp/token"
# oidc issuer specifies the issuer to verify OIDC tokens with.
auth.oidc.issuer = ""
# oidc audience specifies the audience OIDC tokens should contain when validated.

3
go.mod
View File

@@ -16,7 +16,7 @@ require (
github.com/pion/stun/v2 v2.0.0
github.com/pires/go-proxyproto v0.7.0
github.com/prometheus/client_golang v1.19.1
github.com/quic-go/quic-go v0.48.2
github.com/quic-go/quic-go v0.53.0
github.com/rodaine/table v1.2.0
github.com/samber/lo v1.47.0
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8
@@ -68,7 +68,6 @@ require (
github.com/vishvananda/netns v0.0.4 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect

6
go.sum
View File

@@ -105,8 +105,8 @@ github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSz
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KWE=
github.com/quic-go/quic-go v0.48.2/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs=
github.com/quic-go/quic-go v0.53.0 h1:QHX46sISpG2S03dPeZBgVIZp8dGagIaiu2FiVYvpCZI=
github.com/quic-go/quic-go v0.53.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rodaine/table v1.2.0 h1:38HEnwK4mKSHQJIkavVj+bst1TEY7j9zhLMWu4QJrMA=
@@ -167,8 +167,6 @@ golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98y
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d h1:0olWaB5pg3+oychR51GUVCEsGkeCU/2JxjBgIo4f3M0=
golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=

View File

@@ -212,7 +212,9 @@ func LoadServerConfig(path string, strict bool) (*v1.ServerConfig, bool, error)
}
}
if svrCfg != nil {
svrCfg.Complete()
if err := svrCfg.Complete(); err != nil {
return nil, isLegacyFormat, err
}
}
return svrCfg, isLegacyFormat, nil
}
@@ -280,7 +282,9 @@ func LoadClientConfig(path string, strict bool) (
}
if cliCfg != nil {
cliCfg.Complete()
if err := cliCfg.Complete(); err != nil {
return nil, nil, nil, isLegacyFormat, err
}
}
for _, c := range proxyCfgs {
c.Complete(cliCfg.User)

View File

@@ -15,6 +15,8 @@
package v1
import (
"context"
"fmt"
"os"
"github.com/samber/lo"
@@ -77,18 +79,21 @@ type ClientCommonConfig struct {
IncludeConfigFiles []string `json:"includes,omitempty"`
}
func (c *ClientCommonConfig) Complete() {
func (c *ClientCommonConfig) Complete() error {
c.ServerAddr = util.EmptyOr(c.ServerAddr, "0.0.0.0")
c.ServerPort = util.EmptyOr(c.ServerPort, 7000)
c.LoginFailExit = util.EmptyOr(c.LoginFailExit, lo.ToPtr(true))
c.NatHoleSTUNServer = util.EmptyOr(c.NatHoleSTUNServer, "stun.easyvoip.com:3478")
c.Auth.Complete()
if err := c.Auth.Complete(); err != nil {
return err
}
c.Log.Complete()
c.Transport.Complete()
c.WebServer.Complete()
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
return nil
}
type ClientTransportConfig struct {
@@ -184,12 +189,27 @@ type AuthClientConfig struct {
// Token specifies the authorization token used to create keys to be sent
// to the server. The server must have a matching token for authorization
// to succeed. By default, this value is "".
Token string `json:"token,omitempty"`
OIDC AuthOIDCClientConfig `json:"oidc,omitempty"`
Token string `json:"token,omitempty"`
// TokenSource specifies a dynamic source for the authorization token.
// This is mutually exclusive with Token field.
TokenSource *ValueSource `json:"tokenSource,omitempty"`
OIDC AuthOIDCClientConfig `json:"oidc,omitempty"`
}
func (c *AuthClientConfig) Complete() {
func (c *AuthClientConfig) Complete() error {
c.Method = util.EmptyOr(c.Method, "token")
// Resolve tokenSource during configuration loading
if c.Method == AuthMethodToken && c.TokenSource != nil {
token, err := c.TokenSource.Resolve(context.Background())
if err != nil {
return fmt.Errorf("failed to resolve auth.tokenSource: %w", err)
}
// Move the resolved token to the Token field and clear TokenSource
c.Token = token
c.TokenSource = nil
}
return nil
}
type AuthOIDCClientConfig struct {

View File

@@ -15,6 +15,8 @@
package v1
import (
"os"
"path/filepath"
"testing"
"github.com/samber/lo"
@@ -24,7 +26,8 @@ import (
func TestClientConfigComplete(t *testing.T) {
require := require.New(t)
c := &ClientConfig{}
c.Complete()
err := c.Complete()
require.NoError(err)
require.EqualValues("token", c.Auth.Method)
require.Equal(true, lo.FromPtr(c.Transport.TCPMux))
@@ -33,3 +36,70 @@ func TestClientConfigComplete(t *testing.T) {
require.Equal(true, lo.FromPtr(c.Transport.TLS.DisableCustomTLSFirstByte))
require.NotEmpty(c.NatHoleSTUNServer)
}
func TestAuthClientConfig_Complete(t *testing.T) {
// Create a temporary file for testing
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test_token")
testContent := "client-token-value"
err := os.WriteFile(testFile, []byte(testContent), 0o600)
require.NoError(t, err)
tests := []struct {
name string
config AuthClientConfig
expectToken string
expectPanic bool
}{
{
name: "tokenSource resolved to token",
config: AuthClientConfig{
Method: AuthMethodToken,
TokenSource: &ValueSource{
Type: "file",
File: &FileSource{
Path: testFile,
},
},
},
expectToken: testContent,
expectPanic: false,
},
{
name: "direct token unchanged",
config: AuthClientConfig{
Method: AuthMethodToken,
Token: "direct-token",
},
expectToken: "direct-token",
expectPanic: false,
},
{
name: "invalid tokenSource should panic",
config: AuthClientConfig{
Method: AuthMethodToken,
TokenSource: &ValueSource{
Type: "file",
File: &FileSource{
Path: "/non/existent/file",
},
},
},
expectPanic: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.expectPanic {
err := tt.config.Complete()
require.Error(t, err)
} else {
err := tt.config.Complete()
require.NoError(t, err)
require.Equal(t, tt.expectToken, tt.config.Token)
require.Nil(t, tt.config.TokenSource, "TokenSource should be cleared after resolution")
}
})
}
}

View File

@@ -15,6 +15,9 @@
package v1
import (
"context"
"fmt"
"github.com/samber/lo"
"github.com/fatedier/frp/pkg/config/types"
@@ -98,8 +101,10 @@ type ServerConfig struct {
HTTPPlugins []HTTPPluginOptions `json:"httpPlugins,omitempty"`
}
func (c *ServerConfig) Complete() {
c.Auth.Complete()
func (c *ServerConfig) Complete() error {
if err := c.Auth.Complete(); err != nil {
return err
}
c.Log.Complete()
c.Transport.Complete()
c.WebServer.Complete()
@@ -120,17 +125,31 @@ func (c *ServerConfig) Complete() {
c.UserConnTimeout = util.EmptyOr(c.UserConnTimeout, 10)
c.UDPPacketSize = util.EmptyOr(c.UDPPacketSize, 1500)
c.NatHoleAnalysisDataReserveHours = util.EmptyOr(c.NatHoleAnalysisDataReserveHours, 7*24)
return nil
}
type AuthServerConfig struct {
Method AuthMethod `json:"method,omitempty"`
AdditionalScopes []AuthScope `json:"additionalScopes,omitempty"`
Token string `json:"token,omitempty"`
TokenSource *ValueSource `json:"tokenSource,omitempty"`
OIDC AuthOIDCServerConfig `json:"oidc,omitempty"`
}
func (c *AuthServerConfig) Complete() {
func (c *AuthServerConfig) Complete() error {
c.Method = util.EmptyOr(c.Method, "token")
// Resolve tokenSource during configuration loading
if c.Method == AuthMethodToken && c.TokenSource != nil {
token, err := c.TokenSource.Resolve(context.Background())
if err != nil {
return fmt.Errorf("failed to resolve auth.tokenSource: %w", err)
}
// Move the resolved token to the Token field and clear TokenSource
c.Token = token
c.TokenSource = nil
}
return nil
}
type AuthOIDCServerConfig struct {

View File

@@ -15,6 +15,8 @@
package v1
import (
"os"
"path/filepath"
"testing"
"github.com/samber/lo"
@@ -24,9 +26,77 @@ import (
func TestServerConfigComplete(t *testing.T) {
require := require.New(t)
c := &ServerConfig{}
c.Complete()
err := c.Complete()
require.NoError(err)
require.EqualValues("token", c.Auth.Method)
require.Equal(true, lo.FromPtr(c.Transport.TCPMux))
require.Equal(true, lo.FromPtr(c.DetailedErrorsToClient))
}
func TestAuthServerConfig_Complete(t *testing.T) {
// Create a temporary file for testing
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test_token")
testContent := "file-token-value"
err := os.WriteFile(testFile, []byte(testContent), 0o600)
require.NoError(t, err)
tests := []struct {
name string
config AuthServerConfig
expectToken string
expectPanic bool
}{
{
name: "tokenSource resolved to token",
config: AuthServerConfig{
Method: AuthMethodToken,
TokenSource: &ValueSource{
Type: "file",
File: &FileSource{
Path: testFile,
},
},
},
expectToken: testContent,
expectPanic: false,
},
{
name: "direct token unchanged",
config: AuthServerConfig{
Method: AuthMethodToken,
Token: "direct-token",
},
expectToken: "direct-token",
expectPanic: false,
},
{
name: "invalid tokenSource should panic",
config: AuthServerConfig{
Method: AuthMethodToken,
TokenSource: &ValueSource{
Type: "file",
File: &FileSource{
Path: "/non/existent/file",
},
},
},
expectPanic: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.expectPanic {
err := tt.config.Complete()
require.Error(t, err)
} else {
err := tt.config.Complete()
require.NoError(t, err)
require.Equal(t, tt.expectToken, tt.config.Token)
require.Nil(t, tt.config.TokenSource, "TokenSource should be cleared after resolution")
}
})
}
}

View File

@@ -45,6 +45,18 @@ func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) {
errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes))
}
// Validate token/tokenSource mutual exclusivity
if c.Auth.Token != "" && c.Auth.TokenSource != nil {
errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource"))
}
// Validate tokenSource if specified
if c.Auth.TokenSource != nil {
if err := c.Auth.TokenSource.Validate(); err != nil {
errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
}
}
if err := validateLogConfig(&c.Log); err != nil {
errs = AppendError(errs, err)
}

View File

@@ -35,6 +35,18 @@ func ValidateServerConfig(c *v1.ServerConfig) (Warning, error) {
errs = AppendError(errs, fmt.Errorf("invalid auth additional scopes, optional values are %v", SupportedAuthAdditionalScopes))
}
// Validate token/tokenSource mutual exclusivity
if c.Auth.Token != "" && c.Auth.TokenSource != nil {
errs = AppendError(errs, fmt.Errorf("cannot specify both auth.token and auth.tokenSource"))
}
// Validate tokenSource if specified
if c.Auth.TokenSource != nil {
if err := c.Auth.TokenSource.Validate(); err != nil {
errs = AppendError(errs, fmt.Errorf("invalid auth.tokenSource: %v", err))
}
}
if err := validateLogConfig(&c.Log); err != nil {
errs = AppendError(errs, err)
}

View File

@@ -0,0 +1,93 @@
// Copyright 2025 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package v1
import (
"context"
"errors"
"fmt"
"os"
"strings"
)
// ValueSource provides a way to dynamically resolve configuration values
// from various sources like files, environment variables, or external services.
type ValueSource struct {
Type string `json:"type"`
File *FileSource `json:"file,omitempty"`
}
// FileSource specifies how to load a value from a file.
type FileSource struct {
Path string `json:"path"`
}
// Validate validates the ValueSource configuration.
func (v *ValueSource) Validate() error {
if v == nil {
return errors.New("valueSource cannot be nil")
}
switch v.Type {
case "file":
if v.File == nil {
return errors.New("file configuration is required when type is 'file'")
}
return v.File.Validate()
default:
return fmt.Errorf("unsupported value source type: %s (only 'file' is supported)", v.Type)
}
}
// Resolve resolves the value from the configured source.
func (v *ValueSource) Resolve(ctx context.Context) (string, error) {
if err := v.Validate(); err != nil {
return "", err
}
switch v.Type {
case "file":
return v.File.Resolve(ctx)
default:
return "", fmt.Errorf("unsupported value source type: %s", v.Type)
}
}
// Validate validates the FileSource configuration.
func (f *FileSource) Validate() error {
if f == nil {
return errors.New("fileSource cannot be nil")
}
if f.Path == "" {
return errors.New("file path cannot be empty")
}
return nil
}
// Resolve reads and returns the content from the specified file.
func (f *FileSource) Resolve(_ context.Context) (string, error) {
if err := f.Validate(); err != nil {
return "", err
}
content, err := os.ReadFile(f.Path)
if err != nil {
return "", fmt.Errorf("failed to read file %s: %v", f.Path, err)
}
// Trim whitespace, which is important for file-based tokens
return strings.TrimSpace(string(content)), nil
}

View File

@@ -0,0 +1,246 @@
// Copyright 2025 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package v1
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestValueSource_Validate(t *testing.T) {
tests := []struct {
name string
vs *ValueSource
wantErr bool
}{
{
name: "nil valueSource",
vs: nil,
wantErr: true,
},
{
name: "unsupported type",
vs: &ValueSource{
Type: "unsupported",
},
wantErr: true,
},
{
name: "file type without file config",
vs: &ValueSource{
Type: "file",
File: nil,
},
wantErr: true,
},
{
name: "valid file type with absolute path",
vs: &ValueSource{
Type: "file",
File: &FileSource{
Path: "/tmp/test",
},
},
wantErr: false,
},
{
name: "valid file type with relative path",
vs: &ValueSource{
Type: "file",
File: &FileSource{
Path: "configs/token",
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.vs.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("ValueSource.Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestFileSource_Validate(t *testing.T) {
tests := []struct {
name string
fs *FileSource
wantErr bool
}{
{
name: "nil fileSource",
fs: nil,
wantErr: true,
},
{
name: "empty path",
fs: &FileSource{
Path: "",
},
wantErr: true,
},
{
name: "relative path (allowed)",
fs: &FileSource{
Path: "relative/path",
},
wantErr: false,
},
{
name: "absolute path",
fs: &FileSource{
Path: "/absolute/path",
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.fs.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("FileSource.Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestFileSource_Resolve(t *testing.T) {
// Create a temporary file for testing
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test_token")
testContent := "test-token-value\n\t "
expectedContent := "test-token-value"
err := os.WriteFile(testFile, []byte(testContent), 0o600)
if err != nil {
t.Fatalf("failed to create test file: %v", err)
}
tests := []struct {
name string
fs *FileSource
want string
wantErr bool
}{
{
name: "valid file path",
fs: &FileSource{
Path: testFile,
},
want: expectedContent,
wantErr: false,
},
{
name: "non-existent file",
fs: &FileSource{
Path: "/non/existent/file",
},
want: "",
wantErr: true,
},
{
name: "path traversal attempt (should fail validation)",
fs: &FileSource{
Path: "../../../etc/passwd",
},
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.fs.Resolve(context.Background())
if (err != nil) != tt.wantErr {
t.Errorf("FileSource.Resolve() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("FileSource.Resolve() = %v, want %v", got, tt.want)
}
})
}
}
func TestValueSource_Resolve(t *testing.T) {
// Create a temporary file for testing
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test_token")
testContent := "test-token-value"
err := os.WriteFile(testFile, []byte(testContent), 0o600)
if err != nil {
t.Fatalf("failed to create test file: %v", err)
}
tests := []struct {
name string
vs *ValueSource
want string
wantErr bool
}{
{
name: "valid file type",
vs: &ValueSource{
Type: "file",
File: &FileSource{
Path: testFile,
},
},
want: testContent,
wantErr: false,
},
{
name: "unsupported type",
vs: &ValueSource{
Type: "unsupported",
},
want: "",
wantErr: true,
},
{
name: "file type with path traversal",
vs: &ValueSource{
Type: "file",
File: &FileSource{
Path: "../../../etc/passwd",
},
},
want: "",
wantErr: true,
},
}
ctx := context.Background()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.vs.Resolve(ctx)
if (err != nil) != tt.wantErr {
t.Errorf("ValueSource.Resolve() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("ValueSource.Resolve() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -105,7 +105,10 @@ func (s *TunnelServer) Run() error {
s.writeToClient(err.Error())
return fmt.Errorf("parse flags from ssh client error: %v", err)
}
clientCfg.Complete()
if err := clientCfg.Complete(); err != nil {
s.writeToClient(fmt.Sprintf("failed to complete client config: %v", err))
return fmt.Errorf("complete client config error: %v", err)
}
if sshConn.Permissions != nil {
clientCfg.User = util.EmptyOr(sshConn.Permissions.Extensions["user"], clientCfg.User)
}

View File

@@ -197,11 +197,11 @@ func (statsConn *StatsConn) Close() (err error) {
}
type wrapQuicStream struct {
quic.Stream
c quic.Connection
*quic.Stream
c *quic.Conn
}
func QuicStreamToNetConn(s quic.Stream, c quic.Connection) net.Conn {
func QuicStreamToNetConn(s *quic.Stream, c *quic.Conn) net.Conn {
return &wrapQuicStream{
Stream: s,
c: c,

View File

@@ -37,7 +37,9 @@ type Client struct {
func NewClient(options ClientOptions) (*Client, error) {
if options.Common != nil {
options.Common.Complete()
if err := options.Common.Complete(); err != nil {
return nil, err
}
}
ln := netpkg.NewInternalListener()

View File

@@ -262,7 +262,7 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
}
if cfg.SSHTunnelGateway.BindPort > 0 {
sshGateway, err := ssh.NewGateway(cfg.SSHTunnelGateway, cfg.ProxyBindAddr, svr.sshTunnelListener)
sshGateway, err := ssh.NewGateway(cfg.SSHTunnelGateway, cfg.BindAddr, svr.sshTunnelListener)
if err != nil {
return nil, fmt.Errorf("create ssh gateway error: %v", err)
}
@@ -550,7 +550,7 @@ func (svr *Service) HandleQUICListener(l *quic.Listener) {
return
}
// Start a new goroutine to handle connection.
go func(ctx context.Context, frpConn quic.Connection) {
go func(ctx context.Context, frpConn *quic.Conn) {
for {
stream, err := frpConn.AcceptStream(context.Background())
if err != nil {

View File

@@ -0,0 +1,217 @@
// Copyright 2025 The frp Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package basic
import (
"fmt"
"os"
"path/filepath"
"github.com/onsi/ginkgo/v2"
"github.com/fatedier/frp/test/e2e/framework"
"github.com/fatedier/frp/test/e2e/framework/consts"
"github.com/fatedier/frp/test/e2e/pkg/port"
)
var _ = ginkgo.Describe("[Feature: TokenSource]", func() {
f := framework.NewDefaultFramework()
ginkgo.Describe("File-based token loading", func() {
ginkgo.It("should work with file tokenSource", func() {
// Create a temporary token file
tmpDir := f.TempDirectory
tokenFile := filepath.Join(tmpDir, "test_token")
tokenContent := "test-token-123"
err := os.WriteFile(tokenFile, []byte(tokenContent), 0o600)
framework.ExpectNoError(err)
serverConf := consts.DefaultServerConfig
clientConf := consts.DefaultClientConfig
portName := port.GenName("TCP")
// Server config with tokenSource
serverConf += fmt.Sprintf(`
auth.tokenSource.type = "file"
auth.tokenSource.file.path = "%s"
`, tokenFile)
// Client config with matching token
clientConf += fmt.Sprintf(`
auth.token = "%s"
[[proxies]]
name = "tcp"
type = "tcp"
localPort = {{ .%s }}
remotePort = {{ .%s }}
`, tokenContent, framework.TCPEchoServerPort, portName)
f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure()
})
ginkgo.It("should work with client tokenSource", func() {
// Create a temporary token file
tmpDir := f.TempDirectory
tokenFile := filepath.Join(tmpDir, "client_token")
tokenContent := "client-token-456"
err := os.WriteFile(tokenFile, []byte(tokenContent), 0o600)
framework.ExpectNoError(err)
serverConf := consts.DefaultServerConfig
clientConf := consts.DefaultClientConfig
portName := port.GenName("TCP")
// Server config with matching token
serverConf += fmt.Sprintf(`
auth.token = "%s"
`, tokenContent)
// Client config with tokenSource
clientConf += fmt.Sprintf(`
auth.tokenSource.type = "file"
auth.tokenSource.file.path = "%s"
[[proxies]]
name = "tcp"
type = "tcp"
localPort = {{ .%s }}
remotePort = {{ .%s }}
`, tokenFile, framework.TCPEchoServerPort, portName)
f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure()
})
ginkgo.It("should work with both server and client tokenSource", func() {
// Create temporary token files
tmpDir := f.TempDirectory
serverTokenFile := filepath.Join(tmpDir, "server_token")
clientTokenFile := filepath.Join(tmpDir, "client_token")
tokenContent := "shared-token-789"
err := os.WriteFile(serverTokenFile, []byte(tokenContent), 0o600)
framework.ExpectNoError(err)
err = os.WriteFile(clientTokenFile, []byte(tokenContent), 0o600)
framework.ExpectNoError(err)
serverConf := consts.DefaultServerConfig
clientConf := consts.DefaultClientConfig
portName := port.GenName("TCP")
// Server config with tokenSource
serverConf += fmt.Sprintf(`
auth.tokenSource.type = "file"
auth.tokenSource.file.path = "%s"
`, serverTokenFile)
// Client config with tokenSource
clientConf += fmt.Sprintf(`
auth.tokenSource.type = "file"
auth.tokenSource.file.path = "%s"
[[proxies]]
name = "tcp"
type = "tcp"
localPort = {{ .%s }}
remotePort = {{ .%s }}
`, clientTokenFile, framework.TCPEchoServerPort, portName)
f.RunProcesses([]string{serverConf}, []string{clientConf})
framework.NewRequestExpect(f).PortName(portName).Ensure()
})
ginkgo.It("should fail with mismatched tokens", func() {
// Create temporary token files with different content
tmpDir := f.TempDirectory
serverTokenFile := filepath.Join(tmpDir, "server_token")
clientTokenFile := filepath.Join(tmpDir, "client_token")
err := os.WriteFile(serverTokenFile, []byte("server-token"), 0o600)
framework.ExpectNoError(err)
err = os.WriteFile(clientTokenFile, []byte("client-token"), 0o600)
framework.ExpectNoError(err)
serverConf := consts.DefaultServerConfig
clientConf := consts.DefaultClientConfig
portName := port.GenName("TCP")
// Server config with tokenSource
serverConf += fmt.Sprintf(`
auth.tokenSource.type = "file"
auth.tokenSource.file.path = "%s"
`, serverTokenFile)
// Client config with different tokenSource
clientConf += fmt.Sprintf(`
auth.tokenSource.type = "file"
auth.tokenSource.file.path = "%s"
[[proxies]]
name = "tcp"
type = "tcp"
localPort = {{ .%s }}
remotePort = {{ .%s }}
`, clientTokenFile, framework.TCPEchoServerPort, portName)
f.RunProcesses([]string{serverConf}, []string{clientConf})
// This should fail due to token mismatch - the client should not be able to connect
// We expect the request to fail because the proxy tunnel is not established
framework.NewRequestExpect(f).PortName(portName).ExpectError(true).Ensure()
})
ginkgo.It("should fail with non-existent token file", func() {
// This test verifies that server fails to start when tokenSource points to non-existent file
// We'll verify this by checking that the configuration loading itself fails
// Create a config that references a non-existent file
tmpDir := f.TempDirectory
nonExistentFile := filepath.Join(tmpDir, "non_existent_token")
serverConf := consts.DefaultServerConfig
// Server config with non-existent tokenSource file
serverConf += fmt.Sprintf(`
auth.tokenSource.type = "file"
auth.tokenSource.file.path = "%s"
`, nonExistentFile)
// The test expectation is that this will fail during the RunProcesses call
// because the server cannot load the configuration due to missing token file
defer func() {
if r := recover(); r != nil {
// Expected: server should fail to start due to missing file
ginkgo.By(fmt.Sprintf("Server correctly failed to start: %v", r))
}
}()
// This should cause a panic or error during server startup
f.RunProcesses([]string{serverConf}, []string{})
})
})
})