mirror of
https://github.com/fatedier/frp.git
synced 2025-10-30 14:17:31 +00:00
Compare commits
21 Commits
v0.63.0
...
2f5e1f7945
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f5e1f7945 | ||
|
|
b5e90c03a1 | ||
|
|
b642a6323c | ||
|
|
6561107945 | ||
|
|
abf4942e8a | ||
|
|
7cfa546b55 | ||
|
|
0a798a7a69 | ||
|
|
604700cea5 | ||
|
|
610e5ed479 | ||
|
|
80d3f332e1 | ||
|
|
14253afe2f | ||
|
|
024c334d9d | ||
|
|
22ae8166d3 | ||
|
|
f795950742 | ||
|
|
024e4f5f1d | ||
|
|
dc3bc9182c | ||
|
|
e6dacf3a67 | ||
|
|
7fe295f4f4 | ||
|
|
c3bf952d8f | ||
|
|
f9065a6a78 | ||
|
|
61330d4d79 |
@@ -2,7 +2,7 @@ version: 2
|
||||
jobs:
|
||||
go-version-latest:
|
||||
docker:
|
||||
- image: cimg/go:1.23-node
|
||||
- image: cimg/go:1.24-node
|
||||
resource_class: large
|
||||
steps:
|
||||
- checkout
|
||||
|
||||
14
.github/workflows/golangci-lint.yml
vendored
14
.github/workflows/golangci-lint.yml
vendored
@@ -17,20 +17,10 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
go-version: '1.24'
|
||||
cache: false
|
||||
- name: golangci-lint
|
||||
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
|
||||
|
||||
2
.github/workflows/goreleaser.yml
vendored
2
.github/workflows/goreleaser.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
go-version: '1.24'
|
||||
|
||||
- name: Make All
|
||||
run: |
|
||||
|
||||
@@ -73,6 +73,9 @@ linters:
|
||||
- linters:
|
||||
- revive
|
||||
text: unused-parameter
|
||||
- linters:
|
||||
- revive
|
||||
text: "avoid meaningless package names"
|
||||
- linters:
|
||||
- unparam
|
||||
text: is always false
|
||||
|
||||
41
README.md
41
README.md
@@ -13,19 +13,43 @@ frp is an open source project with its ongoing development made possible entirel
|
||||
|
||||
<h3 align="center">Gold Sponsors</h3>
|
||||
<!--gold sponsors start-->
|
||||
<p align="center">
|
||||
<a href="https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp" target="_blank">
|
||||
<b>Recall.ai - API for meeting recordings</b><br>
|
||||
<br>
|
||||
<sup>If you're looking for a meeting recording API, consider checking out Recall.ai, an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.</sup>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://go.warp.dev/frp" target="_blank">
|
||||
<img width="360px" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-01.png">
|
||||
<br>
|
||||
<b>Warp, built for collaborating with AI Agents</b>
|
||||
<br>
|
||||
<sub>Available for macOS, Linux and Windows</sub>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://jb.gg/frp" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||
<br>
|
||||
<b>The complete IDE crafted for professional Go developers</b>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/daytonaio/daytona" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_daytona.png">
|
||||
<br>
|
||||
<b>Secure and Elastic Infrastructure for Running Your AI-Generated Code</b>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/beclab/Olares" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
|
||||
<br>
|
||||
<b>The sovereign cloud that puts you in control</b>
|
||||
<br>
|
||||
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
||||
</a>
|
||||
</p>
|
||||
<!--gold sponsors end-->
|
||||
@@ -502,7 +526,7 @@ name = "ssh"
|
||||
type = "tcp"
|
||||
localIP = "127.0.0.1"
|
||||
localPort = 22
|
||||
remotePort = "{{ .Envs.FRP_SSH_REMOTE_PORT }}"
|
||||
remotePort = {{ .Envs.FRP_SSH_REMOTE_PORT }}
|
||||
```
|
||||
|
||||
With the config above, variables can be passed into `frpc` program like this:
|
||||
@@ -612,6 +636,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.
|
||||
|
||||
24
README_zh.md
24
README_zh.md
@@ -15,19 +15,43 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
||||
|
||||
<h3 align="center">Gold Sponsors</h3>
|
||||
<!--gold sponsors start-->
|
||||
<p align="center">
|
||||
<a href="https://www.recall.ai/?utm_source=github&utm_medium=sponsorship&utm_campaign=fatedier-frp" target="_blank">
|
||||
<b>Recall.ai - API for meeting recordings</b><br>
|
||||
<br>
|
||||
<sup>If you're looking for a meeting recording API, consider checking out Recall.ai, an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.</sup>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://go.warp.dev/frp" target="_blank">
|
||||
<img width="360px" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-01.png">
|
||||
<br>
|
||||
<b>Warp, built for collaborating with AI Agents</b>
|
||||
<br>
|
||||
<sub>Available for macOS, Linux and Windows</sub>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://jb.gg/frp" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||
<br>
|
||||
<b>The complete IDE crafted for professional Go developers</b>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/daytonaio/daytona" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_daytona.png">
|
||||
<br>
|
||||
<b>Secure and Elastic Infrastructure for Running Your AI-Generated Code</b>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/beclab/Olares" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_olares.jpeg">
|
||||
<br>
|
||||
<b>The sovereign cloud that puts you in control</b>
|
||||
<br>
|
||||
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
||||
</a>
|
||||
</p>
|
||||
<!--gold sponsors end-->
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
## 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.
|
||||
* Add NAT traversal configuration options for XTCP proxies and visitors. Support disabling assisted addresses to avoid using slow VPN connections during NAT hole punching.
|
||||
* Enhanced OIDC client configuration with support for custom TLS certificate verification and proxy settings. Added `trustedCaFile`, `insecureSkipVerify`, and `proxyURL` options for OIDC token endpoint connections.
|
||||
* Added detailed Prometheus metrics with `proxy_counts_detailed` metric that includes both proxy type and proxy name labels, enabling monitoring of individual proxy connections instead of just aggregate counts.
|
||||
|
||||
@@ -17,7 +17,6 @@ package client
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -48,7 +47,7 @@ type defaultConnectorImpl struct {
|
||||
cfg *v1.ClientCommonConfig
|
||||
|
||||
muxSession *fmux.Session
|
||||
quicConn quic.Connection
|
||||
quicConn *quic.Conn
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
@@ -115,7 +114,8 @@ func (c *defaultConnectorImpl) Open() error {
|
||||
|
||||
fmuxCfg := fmux.DefaultConfig()
|
||||
fmuxCfg.KeepAliveInterval = time.Duration(c.cfg.Transport.TCPMuxKeepaliveInterval) * time.Second
|
||||
fmuxCfg.LogOutput = io.Discard
|
||||
// Use trace level for yamux logs
|
||||
fmuxCfg.LogOutput = xlog.NewTraceWriter(xl)
|
||||
fmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024
|
||||
session, err := fmux.Client(conn, fmuxCfg)
|
||||
if err != nil {
|
||||
|
||||
@@ -276,10 +276,12 @@ func (ctl *Control) heartbeatWorker() {
|
||||
}
|
||||
|
||||
func (ctl *Control) worker() {
|
||||
xl := ctl.xl
|
||||
go ctl.heartbeatWorker()
|
||||
go ctl.msgDispatcher.Run()
|
||||
|
||||
<-ctl.msgDispatcher.Done()
|
||||
xl.Debugf("control message dispatcher exited")
|
||||
ctl.closeSession()
|
||||
|
||||
ctl.pm.Close()
|
||||
|
||||
@@ -64,11 +64,19 @@ func (pxy *XTCPProxy) InWorkConn(conn net.Conn, startWorkConnMsg *msg.StartWorkC
|
||||
}
|
||||
|
||||
xl.Tracef("nathole prepare start")
|
||||
prepareResult, err := nathole.Prepare([]string{pxy.clientCfg.NatHoleSTUNServer})
|
||||
|
||||
// Prepare NAT traversal options
|
||||
var opts nathole.PrepareOptions
|
||||
if pxy.cfg.NatTraversal != nil && pxy.cfg.NatTraversal.DisableAssistedAddrs {
|
||||
opts.DisableAssistedAddrs = true
|
||||
}
|
||||
|
||||
prepareResult, err := nathole.Prepare([]string{pxy.clientCfg.NatHoleSTUNServer}, opts)
|
||||
if err != nil {
|
||||
xl.Warnf("nathole prepare error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
xl.Infof("nathole prepare success, nat type: %s, behavior: %s, addresses: %v, assistedAddresses: %v",
|
||||
prepareResult.NatType, prepareResult.Behavior, prepareResult.Addrs, prepareResult.AssistedAddrs)
|
||||
defer prepareResult.ListenConn.Close()
|
||||
|
||||
@@ -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 {
|
||||
@@ -144,9 +149,15 @@ func NewService(options ServiceOptions) (*Service, error) {
|
||||
}
|
||||
webServer = ws
|
||||
}
|
||||
|
||||
authSetter, err := auth.NewAuthSetter(options.Common.Auth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Service{
|
||||
ctx: context.Background(),
|
||||
authSetter: auth.NewAuthSetter(options.Common.Auth),
|
||||
authSetter: authSetter,
|
||||
webServer: webServer,
|
||||
common: options.Common,
|
||||
configFilePath: options.ConfigFilePath,
|
||||
@@ -398,6 +409,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) {
|
||||
|
||||
@@ -145,7 +145,7 @@ func (sv *XTCPVisitor) keepTunnelOpenWorker() {
|
||||
return
|
||||
case <-ticker.C:
|
||||
xl.Debugf("keepTunnelOpenWorker try to check tunnel...")
|
||||
conn, err := sv.getTunnelConn()
|
||||
conn, err := sv.getTunnelConn(sv.ctx)
|
||||
if err != nil {
|
||||
xl.Warnf("keepTunnelOpenWorker get tunnel connection error: %v", err)
|
||||
_ = sv.retryLimiter.Wait(sv.ctx)
|
||||
@@ -161,9 +161,9 @@ func (sv *XTCPVisitor) keepTunnelOpenWorker() {
|
||||
|
||||
func (sv *XTCPVisitor) handleConn(userConn net.Conn) {
|
||||
xl := xlog.FromContextSafe(sv.ctx)
|
||||
isConnTransfered := false
|
||||
isConnTransferred := false
|
||||
defer func() {
|
||||
if !isConnTransfered {
|
||||
if !isConnTransferred {
|
||||
userConn.Close()
|
||||
}
|
||||
}()
|
||||
@@ -172,7 +172,7 @@ func (sv *XTCPVisitor) handleConn(userConn net.Conn) {
|
||||
|
||||
// Open a tunnel connection to the server. If there is already a successful hole-punching connection,
|
||||
// it will be reused. Otherwise, it will block and wait for a successful hole-punching connection until timeout.
|
||||
ctx := context.Background()
|
||||
ctx := sv.ctx
|
||||
if sv.cfg.FallbackTo != "" {
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, time.Duration(sv.cfg.FallbackTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
@@ -191,7 +191,7 @@ func (sv *XTCPVisitor) handleConn(userConn net.Conn) {
|
||||
xl.Errorf("transfer connection to visitor %s error: %v", sv.cfg.FallbackTo, err)
|
||||
return
|
||||
}
|
||||
isConnTransfered = true
|
||||
isConnTransferred = true
|
||||
return
|
||||
}
|
||||
|
||||
@@ -219,40 +219,37 @@ func (sv *XTCPVisitor) handleConn(userConn net.Conn) {
|
||||
// openTunnel will open a tunnel connection to the target server.
|
||||
func (sv *XTCPVisitor) openTunnel(ctx context.Context) (conn net.Conn, err error) {
|
||||
xl := xlog.FromContextSafe(sv.ctx)
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
timeoutC := time.After(20 * time.Second)
|
||||
immediateTrigger := make(chan struct{}, 1)
|
||||
defer close(immediateTrigger)
|
||||
immediateTrigger <- struct{}{}
|
||||
timer := time.NewTimer(0)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sv.ctx.Done():
|
||||
return nil, sv.ctx.Err()
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-immediateTrigger:
|
||||
conn, err = sv.getTunnelConn()
|
||||
case <-ticker.C:
|
||||
conn, err = sv.getTunnelConn()
|
||||
case <-timeoutC:
|
||||
return nil, fmt.Errorf("open tunnel timeout")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if err != ErrNoTunnelSession {
|
||||
xl.Warnf("get tunnel connection error: %v", err)
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return nil, fmt.Errorf("open tunnel timeout")
|
||||
}
|
||||
continue
|
||||
return nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
conn, err = sv.getTunnelConn(ctx)
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrNoTunnelSession) {
|
||||
xl.Warnf("get tunnel connection error: %v", err)
|
||||
}
|
||||
timer.Reset(500 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (sv *XTCPVisitor) getTunnelConn() (net.Conn, error) {
|
||||
conn, err := sv.session.OpenConn(sv.ctx)
|
||||
func (sv *XTCPVisitor) getTunnelConn(ctx context.Context) (net.Conn, error) {
|
||||
conn, err := sv.session.OpenConn(ctx)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
@@ -279,11 +276,19 @@ func (sv *XTCPVisitor) makeNatHole() {
|
||||
}
|
||||
|
||||
xl.Tracef("nathole prepare start")
|
||||
prepareResult, err := nathole.Prepare([]string{sv.clientCfg.NatHoleSTUNServer})
|
||||
|
||||
// Prepare NAT traversal options
|
||||
var opts nathole.PrepareOptions
|
||||
if sv.cfg.NatTraversal != nil && sv.cfg.NatTraversal.DisableAssistedAddrs {
|
||||
opts.DisableAssistedAddrs = true
|
||||
}
|
||||
|
||||
prepareResult, err := nathole.Prepare([]string{sv.clientCfg.NatHoleSTUNServer}, opts)
|
||||
if err != nil {
|
||||
xl.Warnf("nathole prepare error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
xl.Infof("nathole prepare success, nat type: %s, behavior: %s, addresses: %v, assistedAddresses: %v",
|
||||
prepareResult.NatType, prepareResult.Behavior, prepareResult.Addrs, prepareResult.AssistedAddrs)
|
||||
|
||||
@@ -398,7 +403,7 @@ func (ks *KCPTunnelSession) Close() {
|
||||
}
|
||||
|
||||
type QUICTunnelSession struct {
|
||||
session quic.Connection
|
||||
session *quic.Conn
|
||||
listenConn *net.UDPConn
|
||||
mu sync.RWMutex
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -50,6 +55,20 @@ auth.token = "12345678"
|
||||
# auth.oidc.additionalEndpointParams.audience = "https://dev.auth.com/api/v2/"
|
||||
# auth.oidc.additionalEndpointParams.var1 = "foobar"
|
||||
|
||||
# OIDC TLS and proxy configuration
|
||||
# Specify a custom CA certificate file for verifying the OIDC token endpoint's TLS certificate.
|
||||
# This is useful when the OIDC provider uses a self-signed certificate or a custom CA.
|
||||
# auth.oidc.trustedCaFile = "/path/to/ca.crt"
|
||||
|
||||
# Skip TLS certificate verification for the OIDC token endpoint.
|
||||
# INSECURE: Only use this for debugging purposes, not recommended for production.
|
||||
# auth.oidc.insecureSkipVerify = false
|
||||
|
||||
# Specify a proxy server for OIDC token endpoint connections.
|
||||
# Supports http, https, socks5, and socks5h proxy protocols.
|
||||
# If not specified, no proxy is used for OIDC connections.
|
||||
# auth.oidc.proxyURL = "http://proxy.example.com:8080"
|
||||
|
||||
# Set admin address for control frpc's action by http api such as reload
|
||||
webServer.addr = "127.0.0.1"
|
||||
webServer.port = 7400
|
||||
@@ -367,6 +386,14 @@ localPort = 22
|
||||
# Otherwise, visitors from same user can connect. '*' means allow all users.
|
||||
allowUsers = ["user1", "user2"]
|
||||
|
||||
# NAT traversal configuration (optional)
|
||||
[proxies.natTraversal]
|
||||
# Disable the use of local network interfaces (assisted addresses) for NAT traversal.
|
||||
# When enabled, only STUN-discovered public addresses will be used.
|
||||
# This can improve performance when you have slow VPN connections.
|
||||
# Default: false
|
||||
disableAssistedAddrs = false
|
||||
|
||||
[[proxies]]
|
||||
name = "vnet-server"
|
||||
type = "stcp"
|
||||
@@ -406,6 +433,13 @@ minRetryInterval = 90
|
||||
# fallbackTo = "stcp_visitor"
|
||||
# fallbackTimeoutMs = 500
|
||||
|
||||
# NAT traversal configuration (optional)
|
||||
[visitors.natTraversal]
|
||||
# Disable the use of local network interfaces (assisted addresses) for NAT traversal.
|
||||
# When enabled, only STUN-discovered public addresses will be used.
|
||||
# Default: false
|
||||
disableAssistedAddrs = false
|
||||
|
||||
[[visitors]]
|
||||
name = "vnet-visitor"
|
||||
type = "stcp"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.23 AS building
|
||||
FROM golang:1.24 AS building
|
||||
|
||||
COPY . /building
|
||||
WORKDIR /building
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.23 AS building
|
||||
FROM golang:1.24 AS building
|
||||
|
||||
COPY . /building
|
||||
WORKDIR /building
|
||||
|
||||
7
go.mod
7
go.mod
@@ -1,6 +1,6 @@
|
||||
module github.com/fatedier/frp
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||
@@ -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
|
||||
@@ -83,4 +82,4 @@ require (
|
||||
)
|
||||
|
||||
// TODO(fatedier): Temporary use the modified version, update to the official version after merging into the official repository.
|
||||
replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20230628132301-7aca4898904d
|
||||
replace github.com/hashicorp/yamux => github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6
|
||||
|
||||
10
go.sum
10
go.sum
@@ -22,8 +22,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fatedier/golib v0.5.1 h1:hcKAnaw5mdI/1KWRGejxR+i1Hn/NvbY5UsMKDr7o13M=
|
||||
github.com/fatedier/golib v0.5.1/go.mod h1:W6kIYkIFxHsTzbgqg5piCxIiDo4LzwgTY6R5W8l9NFQ=
|
||||
github.com/fatedier/yamux v0.0.0-20230628132301-7aca4898904d h1:ynk1ra0RUqDWQfvFi5KtMiSobkVQ3cNc0ODb8CfIETo=
|
||||
github.com/fatedier/yamux v0.0.0-20230628132301-7aca4898904d/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
|
||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE=
|
||||
github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
|
||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
@@ -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=
|
||||
|
||||
@@ -27,16 +27,19 @@ type Setter interface {
|
||||
SetNewWorkConn(*msg.NewWorkConn) error
|
||||
}
|
||||
|
||||
func NewAuthSetter(cfg v1.AuthClientConfig) (authProvider Setter) {
|
||||
func NewAuthSetter(cfg v1.AuthClientConfig) (authProvider Setter, err error) {
|
||||
switch cfg.Method {
|
||||
case v1.AuthMethodToken:
|
||||
authProvider = NewTokenAuth(cfg.AdditionalScopes, cfg.Token)
|
||||
case v1.AuthMethodOIDC:
|
||||
authProvider = NewOidcAuthSetter(cfg.AdditionalScopes, cfg.OIDC)
|
||||
authProvider, err = NewOidcAuthSetter(cfg.AdditionalScopes, cfg.OIDC)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("wrong method: '%s'", cfg.Method))
|
||||
return nil, fmt.Errorf("unsupported auth method: %s", cfg.Method)
|
||||
}
|
||||
return authProvider
|
||||
return authProvider, nil
|
||||
}
|
||||
|
||||
type Verifier interface {
|
||||
|
||||
@@ -16,23 +16,72 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
|
||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||
"github.com/fatedier/frp/pkg/msg"
|
||||
)
|
||||
|
||||
// createOIDCHTTPClient creates an HTTP client with custom TLS and proxy configuration for OIDC token requests
|
||||
func createOIDCHTTPClient(trustedCAFile string, insecureSkipVerify bool, proxyURL string) (*http.Client, error) {
|
||||
// Clone the default transport to get all reasonable defaults
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
|
||||
// Configure TLS settings
|
||||
if trustedCAFile != "" || insecureSkipVerify {
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: insecureSkipVerify,
|
||||
}
|
||||
|
||||
if trustedCAFile != "" && !insecureSkipVerify {
|
||||
caCert, err := os.ReadFile(trustedCAFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read OIDC CA certificate file %q: %w", trustedCAFile, err)
|
||||
}
|
||||
|
||||
caCertPool := x509.NewCertPool()
|
||||
if !caCertPool.AppendCertsFromPEM(caCert) {
|
||||
return nil, fmt.Errorf("failed to parse OIDC CA certificate from file %q", trustedCAFile)
|
||||
}
|
||||
|
||||
tlsConfig.RootCAs = caCertPool
|
||||
}
|
||||
transport.TLSClientConfig = tlsConfig
|
||||
}
|
||||
|
||||
// Configure proxy settings
|
||||
if proxyURL != "" {
|
||||
parsedURL, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse OIDC proxy URL %q: %w", proxyURL, err)
|
||||
}
|
||||
transport.Proxy = http.ProxyURL(parsedURL)
|
||||
} else {
|
||||
// Explicitly disable proxy to override DefaultTransport's ProxyFromEnvironment
|
||||
transport.Proxy = nil
|
||||
}
|
||||
|
||||
return &http.Client{Transport: transport}, nil
|
||||
}
|
||||
|
||||
type OidcAuthProvider struct {
|
||||
additionalAuthScopes []v1.AuthScope
|
||||
|
||||
tokenGenerator *clientcredentials.Config
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClientConfig) *OidcAuthProvider {
|
||||
func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClientConfig) (*OidcAuthProvider, error) {
|
||||
eps := make(map[string][]string)
|
||||
for k, v := range cfg.AdditionalEndpointParams {
|
||||
eps[k] = []string{v}
|
||||
@@ -50,14 +99,30 @@ func NewOidcAuthSetter(additionalAuthScopes []v1.AuthScope, cfg v1.AuthOIDCClien
|
||||
EndpointParams: eps,
|
||||
}
|
||||
|
||||
// Create custom HTTP client if needed
|
||||
var httpClient *http.Client
|
||||
if cfg.TrustedCaFile != "" || cfg.InsecureSkipVerify || cfg.ProxyURL != "" {
|
||||
var err error
|
||||
httpClient, err = createOIDCHTTPClient(cfg.TrustedCaFile, cfg.InsecureSkipVerify, cfg.ProxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OIDC HTTP client: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &OidcAuthProvider{
|
||||
additionalAuthScopes: additionalAuthScopes,
|
||||
tokenGenerator: tokenGenerator,
|
||||
}
|
||||
httpClient: httpClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (auth *OidcAuthProvider) generateAccessToken() (accessToken string, err error) {
|
||||
tokenObj, err := auth.tokenGenerator.Token(context.Background())
|
||||
ctx := context.Background()
|
||||
if auth.httpClient != nil {
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, auth.httpClient)
|
||||
}
|
||||
|
||||
tokenObj, err := auth.tokenGenerator.Token(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("couldn't generate OIDC token for login: %v", err)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
@@ -208,6 +228,17 @@ type AuthOIDCClientConfig struct {
|
||||
// AdditionalEndpointParams specifies additional parameters to be sent
|
||||
// this field will be transfer to map[string][]string in OIDC token generator.
|
||||
AdditionalEndpointParams map[string]string `json:"additionalEndpointParams,omitempty"`
|
||||
|
||||
// TrustedCaFile specifies the path to a custom CA certificate file
|
||||
// for verifying the OIDC token endpoint's TLS certificate.
|
||||
TrustedCaFile string `json:"trustedCaFile,omitempty"`
|
||||
// InsecureSkipVerify disables TLS certificate verification for the
|
||||
// OIDC token endpoint. Only use this for debugging, not recommended for production.
|
||||
InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"`
|
||||
// ProxyURL specifies a proxy to use when connecting to the OIDC token endpoint.
|
||||
// Supports http, https, socks5, and socks5h proxy protocols.
|
||||
// If empty, no proxy is used for OIDC connections.
|
||||
ProxyURL string `json:"proxyURL,omitempty"`
|
||||
}
|
||||
|
||||
type VirtualNetConfig struct {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,9 +85,9 @@ func (c *WebServerConfig) Complete() {
|
||||
}
|
||||
|
||||
type TLSConfig struct {
|
||||
// CertPath specifies the path of the cert file that client will load.
|
||||
// CertFile specifies the path of the cert file that client will load.
|
||||
CertFile string `json:"certFile,omitempty"`
|
||||
// KeyPath specifies the path of the secret key file that client will load.
|
||||
// KeyFile specifies the path of the secret key file that client will load.
|
||||
KeyFile string `json:"keyFile,omitempty"`
|
||||
// TrustedCaFile specifies the path of the trusted ca file that will load.
|
||||
TrustedCaFile string `json:"trustedCaFile,omitempty"`
|
||||
@@ -96,6 +96,14 @@ type TLSConfig struct {
|
||||
ServerName string `json:"serverName,omitempty"`
|
||||
}
|
||||
|
||||
// NatTraversalConfig defines configuration options for NAT traversal
|
||||
type NatTraversalConfig struct {
|
||||
// DisableAssistedAddrs disables the use of local network interfaces
|
||||
// for assisted connections during NAT traversal. When enabled,
|
||||
// only STUN-discovered public addresses will be used.
|
||||
DisableAssistedAddrs bool `json:"disableAssistedAddrs,omitempty"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
// This is destination where frp should write the logs.
|
||||
// If "console" is used, logs will be printed to stdout, otherwise,
|
||||
|
||||
@@ -422,6 +422,9 @@ type XTCPProxyConfig struct {
|
||||
|
||||
Secretkey string `json:"secretKey,omitempty"`
|
||||
AllowUsers []string `json:"allowUsers,omitempty"`
|
||||
|
||||
// NatTraversal configuration for NAT traversal
|
||||
NatTraversal *NatTraversalConfig `json:"natTraversal,omitempty"`
|
||||
}
|
||||
|
||||
func (c *XTCPProxyConfig) MarshalToMsg(m *msg.NewProxy) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
93
pkg/config/v1/value_source.go
Normal file
93
pkg/config/v1/value_source.go
Normal 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
|
||||
}
|
||||
246
pkg/config/v1/value_source_test.go
Normal file
246
pkg/config/v1/value_source_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -160,6 +160,9 @@ type XTCPVisitorConfig struct {
|
||||
MinRetryInterval int `json:"minRetryInterval,omitempty"`
|
||||
FallbackTo string `json:"fallbackTo,omitempty"`
|
||||
FallbackTimeoutMs int `json:"fallbackTimeoutMs,omitempty"`
|
||||
|
||||
// NatTraversal configuration for NAT traversal
|
||||
NatTraversal *NatTraversalConfig `json:"natTraversal,omitempty"`
|
||||
}
|
||||
|
||||
func (c *XTCPVisitorConfig) Complete(g *ClientCommonConfig) {
|
||||
|
||||
@@ -14,11 +14,12 @@ const (
|
||||
var ServerMetrics metrics.ServerMetrics = newServerMetrics()
|
||||
|
||||
type serverMetrics struct {
|
||||
clientCount prometheus.Gauge
|
||||
proxyCount *prometheus.GaugeVec
|
||||
connectionCount *prometheus.GaugeVec
|
||||
trafficIn *prometheus.CounterVec
|
||||
trafficOut *prometheus.CounterVec
|
||||
clientCount prometheus.Gauge
|
||||
proxyCount *prometheus.GaugeVec
|
||||
proxyCountDetailed *prometheus.GaugeVec
|
||||
connectionCount *prometheus.GaugeVec
|
||||
trafficIn *prometheus.CounterVec
|
||||
trafficOut *prometheus.CounterVec
|
||||
}
|
||||
|
||||
func (m *serverMetrics) NewClient() {
|
||||
@@ -29,12 +30,14 @@ func (m *serverMetrics) CloseClient() {
|
||||
m.clientCount.Dec()
|
||||
}
|
||||
|
||||
func (m *serverMetrics) NewProxy(_ string, proxyType string) {
|
||||
func (m *serverMetrics) NewProxy(name string, proxyType string) {
|
||||
m.proxyCount.WithLabelValues(proxyType).Inc()
|
||||
m.proxyCountDetailed.WithLabelValues(proxyType, name).Inc()
|
||||
}
|
||||
|
||||
func (m *serverMetrics) CloseProxy(_ string, proxyType string) {
|
||||
func (m *serverMetrics) CloseProxy(name string, proxyType string) {
|
||||
m.proxyCount.WithLabelValues(proxyType).Dec()
|
||||
m.proxyCountDetailed.WithLabelValues(proxyType, name).Dec()
|
||||
}
|
||||
|
||||
func (m *serverMetrics) OpenConnection(name string, proxyType string) {
|
||||
@@ -67,6 +70,12 @@ func newServerMetrics() *serverMetrics {
|
||||
Name: "proxy_counts",
|
||||
Help: "The current proxy counts",
|
||||
}, []string{"type"}),
|
||||
proxyCountDetailed: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Subsystem: serverSubsystem,
|
||||
Name: "proxy_counts_detailed",
|
||||
Help: "The current number of proxies grouped by type and name",
|
||||
}, []string{"type", "name"}),
|
||||
connectionCount: prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Subsystem: serverSubsystem,
|
||||
@@ -88,6 +97,7 @@ func newServerMetrics() *serverMetrics {
|
||||
}
|
||||
prometheus.MustRegister(m.clientCount)
|
||||
prometheus.MustRegister(m.proxyCount)
|
||||
prometheus.MustRegister(m.proxyCountDetailed)
|
||||
prometheus.MustRegister(m.connectionCount)
|
||||
prometheus.MustRegister(m.trafficIn)
|
||||
prometheus.MustRegister(m.trafficOut)
|
||||
|
||||
@@ -68,6 +68,13 @@ var (
|
||||
DetectRoleReceiver = "receiver"
|
||||
)
|
||||
|
||||
// PrepareOptions defines options for NAT traversal preparation
|
||||
type PrepareOptions struct {
|
||||
// DisableAssistedAddrs disables the use of local network interfaces
|
||||
// for assisted connections during NAT traversal
|
||||
DisableAssistedAddrs bool
|
||||
}
|
||||
|
||||
type PrepareResult struct {
|
||||
Addrs []string
|
||||
AssistedAddrs []string
|
||||
@@ -108,7 +115,7 @@ func PreCheck(
|
||||
}
|
||||
|
||||
// Prepare is used to do some preparation work before penetration.
|
||||
func Prepare(stunServers []string) (*PrepareResult, error) {
|
||||
func Prepare(stunServers []string, opts PrepareOptions) (*PrepareResult, error) {
|
||||
// discover for Nat type
|
||||
addrs, localAddr, err := Discover(stunServers, "")
|
||||
if err != nil {
|
||||
@@ -133,9 +140,13 @@ func Prepare(stunServers []string) (*PrepareResult, error) {
|
||||
return nil, fmt.Errorf("listen local udp addr error: %v", err)
|
||||
}
|
||||
|
||||
assistedAddrs := make([]string, 0, len(localIPs))
|
||||
for _, ip := range localIPs {
|
||||
assistedAddrs = append(assistedAddrs, net.JoinHostPort(ip, strconv.Itoa(laddr.Port)))
|
||||
// Apply NAT traversal options
|
||||
var assistedAddrs []string
|
||||
if !opts.DisableAssistedAddrs {
|
||||
assistedAddrs = make([]string, 0, len(localIPs))
|
||||
for _, ip := range localIPs {
|
||||
assistedAddrs = append(assistedAddrs, net.JoinHostPort(ip, strconv.Itoa(laddr.Port)))
|
||||
}
|
||||
}
|
||||
return &PrepareResult{
|
||||
Addrs: addrs,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newCustomTLSKeyPair(certfile, keyfile string) (*tls.Certificate, error) {
|
||||
@@ -32,12 +33,30 @@ func newCustomTLSKeyPair(certfile, keyfile string) (*tls.Certificate, error) {
|
||||
return &tlsCert, nil
|
||||
}
|
||||
|
||||
func newRandomTLSKeyPair() *tls.Certificate {
|
||||
func newRandomTLSKeyPair() (*tls.Certificate, error) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return nil, err
|
||||
}
|
||||
template := x509.Certificate{SerialNumber: big.NewInt(1)}
|
||||
|
||||
// Generate a random positive serial number with 128 bits of entropy.
|
||||
// RFC 5280 requires serial numbers to be positive integers (not zero).
|
||||
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Ensure serial number is positive (not zero)
|
||||
if serialNumber.Sign() == 0 {
|
||||
serialNumber = big.NewInt(1)
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour * 10),
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(
|
||||
rand.Reader,
|
||||
&template,
|
||||
@@ -45,16 +64,16 @@ func newRandomTLSKeyPair() *tls.Certificate {
|
||||
&key.PublicKey,
|
||||
key)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return nil, err
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
|
||||
tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return nil, err
|
||||
}
|
||||
return &tlsCert
|
||||
return &tlsCert, nil
|
||||
}
|
||||
|
||||
// Only support one ca file to add
|
||||
@@ -76,7 +95,10 @@ func NewServerTLSConfig(certPath, keyPath, caPath string) (*tls.Config, error) {
|
||||
|
||||
if certPath == "" || keyPath == "" {
|
||||
// server will generate tls conf by itself
|
||||
cert := newRandomTLSKeyPair()
|
||||
cert, err := newRandomTLSKeyPair()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base.Certificates = []tls.Certificate{*cert}
|
||||
} else {
|
||||
cert, err := newCustomTLSKeyPair(certPath, keyPath)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
package version
|
||||
|
||||
var version = "0.63.0"
|
||||
var version = "0.65.0"
|
||||
|
||||
func Full() string {
|
||||
return version
|
||||
|
||||
65
pkg/util/xlog/log_writer.go
Normal file
65
pkg/util/xlog/log_writer.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// 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 xlog
|
||||
|
||||
import "strings"
|
||||
|
||||
// LogWriter forwards writes to frp's logger at configurable level.
|
||||
// It is safe for concurrent use as long as the underlying Logger is thread-safe.
|
||||
type LogWriter struct {
|
||||
xl *Logger
|
||||
logFunc func(string)
|
||||
}
|
||||
|
||||
func (w LogWriter) Write(p []byte) (n int, err error) {
|
||||
msg := strings.TrimSpace(string(p))
|
||||
w.logFunc(msg)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func NewTraceWriter(xl *Logger) LogWriter {
|
||||
return LogWriter{
|
||||
xl: xl,
|
||||
logFunc: func(msg string) { xl.Tracef("%s", msg) },
|
||||
}
|
||||
}
|
||||
|
||||
func NewDebugWriter(xl *Logger) LogWriter {
|
||||
return LogWriter{
|
||||
xl: xl,
|
||||
logFunc: func(msg string) { xl.Debugf("%s", msg) },
|
||||
}
|
||||
}
|
||||
|
||||
func NewInfoWriter(xl *Logger) LogWriter {
|
||||
return LogWriter{
|
||||
xl: xl,
|
||||
logFunc: func(msg string) { xl.Infof("%s", msg) },
|
||||
}
|
||||
}
|
||||
|
||||
func NewWarnWriter(xl *Logger) LogWriter {
|
||||
return LogWriter{
|
||||
xl: xl,
|
||||
logFunc: func(msg string) { xl.Warnf("%s", msg) },
|
||||
}
|
||||
}
|
||||
|
||||
func NewErrorWriter(xl *Logger) LogWriter {
|
||||
return LogWriter{
|
||||
xl: xl,
|
||||
logFunc: func(msg string) { xl.Errorf("%s", msg) },
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -262,7 +261,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)
|
||||
}
|
||||
@@ -516,7 +515,8 @@ func (svr *Service) HandleListener(l net.Listener, internal bool) {
|
||||
if lo.FromPtr(svr.cfg.Transport.TCPMux) && !internal {
|
||||
fmuxCfg := fmux.DefaultConfig()
|
||||
fmuxCfg.KeepAliveInterval = time.Duration(svr.cfg.Transport.TCPMuxKeepaliveInterval) * time.Second
|
||||
fmuxCfg.LogOutput = io.Discard
|
||||
// Use trace level for yamux logs
|
||||
fmuxCfg.LogOutput = xlog.NewTraceWriter(xlog.FromContextSafe(ctx))
|
||||
fmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024
|
||||
session, err := fmux.Server(frpConn, fmuxCfg)
|
||||
if err != nil {
|
||||
@@ -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 {
|
||||
|
||||
217
test/e2e/v1/basic/token_source.go
Normal file
217
test/e2e/v1/basic/token_source.go
Normal 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{})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user