From 46266e4d3063703d597526a673567df4db4f2324 Mon Sep 17 00:00:00 2001 From: 0x7fff <4812302+blizard863@users.noreply.github.com> Date: Mon, 30 Oct 2023 20:24:57 +0800 Subject: [PATCH 01/21] fix: set ping (#3734) Co-authored-by: int7 <int7@gmail.com> --- client/control.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/control.go b/client/control.go index 63c6c331..33fe2b50 100644 --- a/client/control.go +++ b/client/control.go @@ -298,8 +298,8 @@ func (ctl *Control) msgHandler() { xl.Debug("send heartbeat to server") pingMsg := &msg.Ping{} if err := ctl.authSetter.SetPing(pingMsg); err != nil { - xl.Warn("error during ping authentication: %v", err) - return + xl.Warn("error during ping authentication: %v. skip sending ping message", err) + continue } ctl.sendCh <- pingMsg case <-hbCheckCh: From 5c4d820eb4eea4c5bc8e6e8c7d753d7a00ed2d65 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Tue, 31 Oct 2023 12:40:48 +0100 Subject: [PATCH 02/21] chore: Update dependencies (#3738) * chore: Update dependencies * Removed all foolish updates --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index f7996399..8d27e522 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/samber/lo v1.38.1 github.com/spf13/cobra v1.7.0 github.com/stretchr/testify v1.8.4 - golang.org/x/net v0.12.0 + golang.org/x/net v0.17.0 golang.org/x/oauth2 v0.10.0 golang.org/x/sync v0.3.0 golang.org/x/time v0.3.0 @@ -64,11 +64,11 @@ require ( github.com/templexxx/cpufeat v0.0.0-20180724012125-cef66df7f161 // indirect github.com/templexxx/xor v0.0.0-20191217153810-f85b25db303b // indirect github.com/tjfoc/gmsm v1.4.1 // indirect - golang.org/x/crypto v0.11.0 // indirect + golang.org/x/crypto v0.14.0 // indirect golang.org/x/exp v0.0.0-20221205204356-47842c84f3db // indirect golang.org/x/mod v0.10.0 // indirect - golang.org/x/sys v0.10.0 // indirect - golang.org/x/text v0.11.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect golang.org/x/tools v0.9.3 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.31.0 // indirect diff --git a/go.sum b/go.sum index 4cab567e..af509c3f 100644 --- a/go.sum +++ b/go.sum @@ -157,8 +157,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= -golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= -golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20221205204356-47842c84f3db h1:D/cFflL63o2KSLJIwjlcIt8PR064j/xsmdEJL/YvY/o= golang.org/x/exp v0.0.0-20221205204356-47842c84f3db/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= @@ -183,8 +183,8 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= -golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= -golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8= golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI= @@ -210,8 +210,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -222,8 +222,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= -golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From 5760c1cf92b87a9d734e6c31c626f9a4d282fde0 Mon Sep 17 00:00:00 2001 From: fatedier <fatedier@gmail.com> Date: Wed, 1 Nov 2023 17:06:55 +0800 Subject: [PATCH 03/21] frpc: exit with code 1 if first login failed (#3740) --- Release.md | 2 +- client/service.go | 5 ++--- cmd/frpc/sub/root.go | 8 ++------ 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/Release.md b/Release.md index 1660f1e1..16e8324b 100644 --- a/Release.md +++ b/Release.md @@ -1,3 +1,3 @@ ### Fixes -* `admin_user` is not effective in the INI configuration. +* frpc: Return code 1 when the first login attempt fails and exits. diff --git a/client/service.go b/client/service.go index 184a87a3..4b394d8a 100644 --- a/client/service.go +++ b/client/service.go @@ -83,8 +83,8 @@ func NewService( pxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer, cfgFile string, -) (svr *Service, err error) { - svr = &Service{ +) *Service { + return &Service{ authSetter: auth.NewAuthSetter(cfg.Auth), cfg: cfg, cfgFile: cfgFile, @@ -93,7 +93,6 @@ func NewService( ctx: context.Background(), exit: 0, } - return } func (svr *Service) GetController() *Control { diff --git a/cmd/frpc/sub/root.go b/cmd/frpc/sub/root.go index 915e6b35..125c88c0 100644 --- a/cmd/frpc/sub/root.go +++ b/cmd/frpc/sub/root.go @@ -139,10 +139,7 @@ func startService( log.Info("start frpc service for config file [%s]", cfgFile) defer log.Info("frpc service for config file [%s] stopped", cfgFile) } - svr, err := client.NewService(cfg, pxyCfgs, visitorCfgs, cfgFile) - if err != nil { - return err - } + svr := client.NewService(cfg, pxyCfgs, visitorCfgs, cfgFile) shouldGracefulClose := cfg.Transport.Protocol == "kcp" || cfg.Transport.Protocol == "quic" // Capture the exit signal if we use kcp or quic. @@ -150,6 +147,5 @@ func startService( go handleTermSignal(svr) } - _ = svr.Run(context.Background()) - return nil + return svr.Run(context.Background()) } From 184223cb2f240b844f90b3390645672d2225da88 Mon Sep 17 00:00:00 2001 From: fatedier <fatedier@gmail.com> Date: Mon, 6 Nov 2023 10:51:48 +0800 Subject: [PATCH 04/21] Code refactoring related to message handling and retry logic. (#3745) --- Release.md | 1 + client/admin_api.go | 9 +- client/control.go | 281 ++++++++++----------------- client/service.go | 197 +++++++++---------- pkg/metrics/metrics.go | 14 ++ pkg/msg/handler.go | 103 ++++++++++ pkg/transport/message.go | 2 + pkg/util/net/conn.go | 16 ++ pkg/util/wait/backoff.go | 197 +++++++++++++++++++ server/control.go | 403 +++++++++++++++------------------------ server/service.go | 18 +- 11 files changed, 701 insertions(+), 540 deletions(-) create mode 100644 pkg/msg/handler.go create mode 100644 pkg/util/wait/backoff.go diff --git a/Release.md b/Release.md index 16e8324b..a834392c 100644 --- a/Release.md +++ b/Release.md @@ -1,3 +1,4 @@ ### Fixes * frpc: Return code 1 when the first login attempt fails and exits. +* When auth.method is `oidc` and auth.additionalScopes contains `HeartBeats`, if obtaining AccessToken fails, the application will be unresponsive. diff --git a/client/admin_api.go b/client/admin_api.go index a348e8dd..e775f526 100644 --- a/client/admin_api.go +++ b/client/admin_api.go @@ -144,7 +144,14 @@ func (svr *Service) apiStatus(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(buf) }() - ps := svr.ctl.pm.GetAllProxyStatus() + svr.ctlMu.RLock() + ctl := svr.ctl + svr.ctlMu.RUnlock() + if ctl == nil { + return + } + + ps := ctl.pm.GetAllProxyStatus() for _, status := range ps { res[status.Type] = append(res[status.Type], NewProxyStatusResp(status, svr.cfg.ServerAddr)) } diff --git a/client/control.go b/client/control.go index 33fe2b50..c8d186ca 100644 --- a/client/control.go +++ b/client/control.go @@ -16,13 +16,10 @@ package client import ( "context" - "io" "net" - "runtime/debug" + "sync/atomic" "time" - "github.com/fatedier/golib/control/shutdown" - "github.com/fatedier/golib/crypto" "github.com/samber/lo" "github.com/fatedier/frp/client/proxy" @@ -31,6 +28,8 @@ import ( v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/transport" + utilnet "github.com/fatedier/frp/pkg/util/net" + "github.com/fatedier/frp/pkg/util/wait" "github.com/fatedier/frp/pkg/util/xlog" ) @@ -39,6 +38,12 @@ type Control struct { ctx context.Context xl *xlog.Logger + // The client configuration + clientCfg *v1.ClientCommonConfig + + // sets authentication based on selected method + authSetter auth.Setter + // Unique ID obtained from frps. // It should be attached to the login message when reconnecting. runID string @@ -50,36 +55,25 @@ type Control struct { // manage all visitors vm *visitor.Manager - // control connection + // control connection. Once conn is closed, the msgDispatcher and the entire Control will exit. conn net.Conn + // use cm to create new connections, which could be real TCP connections or virtual streams. cm *ConnectionManager - // put a message in this channel to send it over control connection to server - sendCh chan (msg.Message) + doneCh chan struct{} - // read from this channel to get the next message sent by server - readCh chan (msg.Message) - - // goroutines can block by reading from this channel, it will be closed only in reader() when control connection is closed - closedCh chan struct{} - - closedDoneCh chan struct{} - - // last time got the Pong message - lastPong time.Time - - // The client configuration - clientCfg *v1.ClientCommonConfig - - readerShutdown *shutdown.Shutdown - writerShutdown *shutdown.Shutdown - msgHandlerShutdown *shutdown.Shutdown - - // sets authentication based on selected method - authSetter auth.Setter + // of time.Time, last time got the Pong message + lastPong atomic.Value + // The role of msgTransporter is similar to HTTP2. + // It allows multiple messages to be sent simultaneously on the same control connection. + // The server's response messages will be dispatched to the corresponding waiting goroutines based on the laneKey and message type. msgTransporter transport.MessageTransporter + + // msgDispatcher is a wrapper for control connection. + // It provides a channel for sending messages, and you can register handlers to process messages based on their respective types. + msgDispatcher *msg.Dispatcher } func NewControl( @@ -88,31 +82,34 @@ func NewControl( pxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer, authSetter auth.Setter, -) *Control { +) (*Control, error) { // new xlog instance ctl := &Control{ - ctx: ctx, - xl: xlog.FromContextSafe(ctx), - runID: runID, - conn: conn, - cm: cm, - pxyCfgs: pxyCfgs, - sendCh: make(chan msg.Message, 100), - readCh: make(chan msg.Message, 100), - closedCh: make(chan struct{}), - closedDoneCh: make(chan struct{}), - clientCfg: clientCfg, - readerShutdown: shutdown.New(), - writerShutdown: shutdown.New(), - msgHandlerShutdown: shutdown.New(), - authSetter: authSetter, + ctx: ctx, + xl: xlog.FromContextSafe(ctx), + clientCfg: clientCfg, + authSetter: authSetter, + runID: runID, + pxyCfgs: pxyCfgs, + conn: conn, + cm: cm, + doneCh: make(chan struct{}), } - ctl.msgTransporter = transport.NewMessageTransporter(ctl.sendCh) - ctl.pm = proxy.NewManager(ctl.ctx, clientCfg, ctl.msgTransporter) + ctl.lastPong.Store(time.Now()) + cryptoRW, err := utilnet.NewCryptoReadWriter(conn, []byte(clientCfg.Auth.Token)) + if err != nil { + return nil, err + } + + ctl.msgDispatcher = msg.NewDispatcher(cryptoRW) + ctl.registerMsgHandlers() + ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher.SendChannel()) + + ctl.pm = proxy.NewManager(ctl.ctx, clientCfg, ctl.msgTransporter) ctl.vm = visitor.NewManager(ctl.ctx, ctl.runID, ctl.clientCfg, ctl.connectServer, ctl.msgTransporter) ctl.vm.Reload(visitorCfgs) - return ctl + return ctl, nil } func (ctl *Control) Run() { @@ -125,7 +122,7 @@ func (ctl *Control) Run() { go ctl.vm.Run() } -func (ctl *Control) HandleReqWorkConn(_ *msg.ReqWorkConn) { +func (ctl *Control) handleReqWorkConn(_ msg.Message) { xl := ctl.xl workConn, err := ctl.connectServer() if err != nil { @@ -162,8 +159,9 @@ func (ctl *Control) HandleReqWorkConn(_ *msg.ReqWorkConn) { ctl.pm.HandleWorkConn(startMsg.ProxyName, workConn, &startMsg) } -func (ctl *Control) HandleNewProxyResp(inMsg *msg.NewProxyResp) { +func (ctl *Control) handleNewProxyResp(m msg.Message) { xl := ctl.xl + inMsg := m.(*msg.NewProxyResp) // Server will return NewProxyResp message to each NewProxy message. // Start a new proxy handler if no error got err := ctl.pm.StartProxy(inMsg.ProxyName, inMsg.RemoteAddr, inMsg.Error) @@ -174,8 +172,9 @@ func (ctl *Control) HandleNewProxyResp(inMsg *msg.NewProxyResp) { } } -func (ctl *Control) HandleNatHoleResp(inMsg *msg.NatHoleResp) { +func (ctl *Control) handleNatHoleResp(m msg.Message) { xl := ctl.xl + inMsg := m.(*msg.NatHoleResp) // Dispatch the NatHoleResp message to the related proxy. ok := ctl.msgTransporter.DispatchWithType(inMsg, msg.TypeNameNatHoleResp, inMsg.TransactionID) @@ -184,6 +183,19 @@ func (ctl *Control) HandleNatHoleResp(inMsg *msg.NatHoleResp) { } } +func (ctl *Control) handlePong(m msg.Message) { + xl := ctl.xl + inMsg := m.(*msg.Pong) + + if inMsg.Error != "" { + xl.Error("Pong message contains error: %s", inMsg.Error) + ctl.conn.Close() + return + } + ctl.lastPong.Store(time.Now()) + xl.Debug("receive heartbeat from server") +} + func (ctl *Control) Close() error { return ctl.GracefulClose(0) } @@ -199,9 +211,9 @@ func (ctl *Control) GracefulClose(d time.Duration) error { return nil } -// ClosedDoneCh returns a channel that will be closed after all resources are released -func (ctl *Control) ClosedDoneCh() <-chan struct{} { - return ctl.closedDoneCh +// Done returns a channel that will be closed after all resources are released +func (ctl *Control) Done() <-chan struct{} { + return ctl.doneCh } // connectServer return a new connection to frps @@ -209,151 +221,70 @@ func (ctl *Control) connectServer() (conn net.Conn, err error) { return ctl.cm.Connect() } -// reader read all messages from frps and send to readCh -func (ctl *Control) reader() { - xl := ctl.xl - defer func() { - if err := recover(); err != nil { - xl.Error("panic error: %v", err) - xl.Error(string(debug.Stack())) - } - }() - defer ctl.readerShutdown.Done() - defer close(ctl.closedCh) - - encReader := crypto.NewReader(ctl.conn, []byte(ctl.clientCfg.Auth.Token)) - for { - m, err := msg.ReadMsg(encReader) - if err != nil { - if err == io.EOF { - xl.Debug("read from control connection EOF") - return - } - xl.Warn("read error: %v", err) - ctl.conn.Close() - return - } - ctl.readCh <- m - } +func (ctl *Control) registerMsgHandlers() { + ctl.msgDispatcher.RegisterHandler(&msg.ReqWorkConn{}, msg.AsyncHandler(ctl.handleReqWorkConn)) + ctl.msgDispatcher.RegisterHandler(&msg.NewProxyResp{}, ctl.handleNewProxyResp) + ctl.msgDispatcher.RegisterHandler(&msg.NatHoleResp{}, ctl.handleNatHoleResp) + ctl.msgDispatcher.RegisterHandler(&msg.Pong{}, ctl.handlePong) } -// writer writes messages got from sendCh to frps -func (ctl *Control) writer() { +// headerWorker sends heartbeat to server and check heartbeat timeout. +func (ctl *Control) heartbeatWorker() { xl := ctl.xl - defer ctl.writerShutdown.Done() - encWriter, err := crypto.NewWriter(ctl.conn, []byte(ctl.clientCfg.Auth.Token)) - if err != nil { - xl.Error("crypto new writer error: %v", err) - ctl.conn.Close() - return - } - for { - m, ok := <-ctl.sendCh - if !ok { - xl.Info("control writer is closing") - return - } - if err := msg.WriteMsg(encWriter, m); err != nil { - xl.Warn("write message to control connection error: %v", err) - return - } - } -} - -// msgHandler handles all channel events and performs corresponding operations. -func (ctl *Control) msgHandler() { - xl := ctl.xl - defer func() { - if err := recover(); err != nil { - xl.Error("panic error: %v", err) - xl.Error(string(debug.Stack())) - } - }() - defer ctl.msgHandlerShutdown.Done() - - var hbSendCh <-chan time.Time - // TODO(fatedier): disable heartbeat if TCPMux is enabled. - // Just keep it here to keep compatible with old version frps. + // TODO(fatedier): Change default value of HeartbeatInterval to -1 if tcpmux is enabled. + // Users can still enable heartbeat feature by setting HeartbeatInterval to a positive value. if ctl.clientCfg.Transport.HeartbeatInterval > 0 { - hbSend := time.NewTicker(time.Duration(ctl.clientCfg.Transport.HeartbeatInterval) * time.Second) - defer hbSend.Stop() - hbSendCh = hbSend.C - } - - var hbCheckCh <-chan time.Time - // Check heartbeat timeout only if TCPMux is not enabled and users don't disable heartbeat feature. - if ctl.clientCfg.Transport.HeartbeatInterval > 0 && ctl.clientCfg.Transport.HeartbeatTimeout > 0 && - !lo.FromPtr(ctl.clientCfg.Transport.TCPMux) { - hbCheck := time.NewTicker(time.Second) - defer hbCheck.Stop() - hbCheckCh = hbCheck.C - } - - ctl.lastPong = time.Now() - for { - select { - case <-hbSendCh: - // send heartbeat to server + // send heartbeat to server + sendHeartBeat := func() error { xl.Debug("send heartbeat to server") pingMsg := &msg.Ping{} if err := ctl.authSetter.SetPing(pingMsg); err != nil { - xl.Warn("error during ping authentication: %v. skip sending ping message", err) - continue + xl.Warn("error during ping authentication: %v, skip sending ping message", err) + return err } - ctl.sendCh <- pingMsg - case <-hbCheckCh: - if time.Since(ctl.lastPong) > time.Duration(ctl.clientCfg.Transport.HeartbeatTimeout)*time.Second { + _ = ctl.msgDispatcher.Send(pingMsg) + return nil + } + + go wait.BackoffUntil(sendHeartBeat, + wait.NewFastBackoffManager(wait.FastBackoffOptions{ + Duration: time.Duration(ctl.clientCfg.Transport.HeartbeatInterval) * time.Second, + InitDurationIfFail: time.Second, + Factor: 2.0, + Jitter: 0.1, + MaxDuration: time.Duration(ctl.clientCfg.Transport.HeartbeatInterval) * time.Second, + }), + true, ctl.doneCh, + ) + } + + // Check heartbeat timeout only if TCPMux is not enabled and users don't disable heartbeat feature. + if ctl.clientCfg.Transport.HeartbeatInterval > 0 && ctl.clientCfg.Transport.HeartbeatTimeout > 0 && + !lo.FromPtr(ctl.clientCfg.Transport.TCPMux) { + + go wait.Until(func() { + if time.Since(ctl.lastPong.Load().(time.Time)) > time.Duration(ctl.clientCfg.Transport.HeartbeatTimeout)*time.Second { xl.Warn("heartbeat timeout") - // let reader() stop ctl.conn.Close() return } - case rawMsg, ok := <-ctl.readCh: - if !ok { - return - } - - switch m := rawMsg.(type) { - case *msg.ReqWorkConn: - go ctl.HandleReqWorkConn(m) - case *msg.NewProxyResp: - ctl.HandleNewProxyResp(m) - case *msg.NatHoleResp: - ctl.HandleNatHoleResp(m) - case *msg.Pong: - if m.Error != "" { - xl.Error("Pong contains error: %s", m.Error) - ctl.conn.Close() - return - } - ctl.lastPong = time.Now() - xl.Debug("receive heartbeat from server") - } - } + }, time.Second, ctl.doneCh) } } -// If controler is notified by closedCh, reader and writer and handler will exit func (ctl *Control) worker() { - go ctl.msgHandler() - go ctl.reader() - go ctl.writer() + go ctl.heartbeatWorker() + go ctl.msgDispatcher.Run() - <-ctl.closedCh - // close related channels and wait until other goroutines done - close(ctl.readCh) - ctl.readerShutdown.WaitDone() - ctl.msgHandlerShutdown.WaitDone() - - close(ctl.sendCh) - ctl.writerShutdown.WaitDone() + <-ctl.msgDispatcher.Done() + ctl.conn.Close() ctl.pm.Close() ctl.vm.Close() - - close(ctl.closedDoneCh) ctl.cm.Close() + + close(ctl.doneCh) } func (ctl *Control) ReloadConf(pxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error { diff --git a/client/service.go b/client/service.go index 4b394d8a..66a642c1 100644 --- a/client/service.go +++ b/client/service.go @@ -17,6 +17,7 @@ package client import ( "context" "crypto/tls" + "errors" "fmt" "io" "net" @@ -24,7 +25,6 @@ import ( "strconv" "strings" "sync" - "sync/atomic" "time" "github.com/fatedier/golib/crypto" @@ -40,8 +40,8 @@ import ( "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/pkg/util/log" utilnet "github.com/fatedier/frp/pkg/util/net" - "github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/version" + "github.com/fatedier/frp/pkg/util/wait" "github.com/fatedier/frp/pkg/util/xlog" ) @@ -70,12 +70,11 @@ type Service struct { // string if no configuration file was used. cfgFile string - exit uint32 // 0 means not exit - // service context ctx context.Context // call cancel to stop service - cancel context.CancelFunc + cancel context.CancelFunc + gracefulDuration time.Duration } func NewService( @@ -91,7 +90,6 @@ func NewService( pxyCfgs: pxyCfgs, visitorCfgs: visitorCfgs, ctx: context.Background(), - exit: 0, } } @@ -106,8 +104,6 @@ func (svr *Service) Run(ctx context.Context) error { svr.ctx = xlog.NewContext(ctx, xlog.New()) svr.cancel = cancel - xl := xlog.FromContextSafe(svr.ctx) - // set custom DNSServer if svr.cfg.DNSServer != "" { dnsAddr := svr.cfg.DNSServer @@ -124,26 +120,9 @@ func (svr *Service) Run(ctx context.Context) error { } // login to frps - for { - conn, cm, err := svr.login() - if err != nil { - xl.Warn("login to server failed: %v", err) - - // if login_fail_exit is true, just exit this program - // otherwise sleep a while and try again to connect to server - if lo.FromPtr(svr.cfg.LoginFailExit) { - return err - } - util.RandomSleep(5*time.Second, 0.9, 1.1) - } else { - // login success - ctl := NewControl(svr.ctx, svr.runID, conn, cm, svr.cfg, svr.pxyCfgs, svr.visitorCfgs, svr.authSetter) - ctl.Run() - svr.ctlMu.Lock() - svr.ctl = ctl - svr.ctlMu.Unlock() - break - } + svr.loopLoginUntilSuccess(10*time.Second, lo.FromPtr(svr.cfg.LoginFailExit)) + if svr.ctl == nil { + return fmt.Errorf("the process exited because the first login to the server failed, and the loginFailExit feature is enabled") } go svr.keepControllerWorking() @@ -160,80 +139,35 @@ func (svr *Service) Run(ctx context.Context) error { log.Info("admin server listen on %s:%d", svr.cfg.WebServer.Addr, svr.cfg.WebServer.Port) } <-svr.ctx.Done() - // service context may not be canceled by svr.Close(), we should call it here to release resources - if atomic.LoadUint32(&svr.exit) == 0 { - svr.Close() - } + svr.stop() return nil } func (svr *Service) keepControllerWorking() { - xl := xlog.FromContextSafe(svr.ctx) - maxDelayTime := 20 * time.Second - delayTime := time.Second + <-svr.ctl.Done() - // if frpc reconnect frps, we need to limit retry times in 1min - // current retry logic is sleep 0s, 0s, 0s, 1s, 2s, 4s, 8s, ... - // when exceed 1min, we will reset delay and counts - cutoffTime := time.Now().Add(time.Minute) - reconnectDelay := time.Second - reconnectCounts := 1 - - for { - <-svr.ctl.ClosedDoneCh() - if atomic.LoadUint32(&svr.exit) != 0 { - return - } - - // the first three attempts with a low delay - if reconnectCounts > 3 { - util.RandomSleep(reconnectDelay, 0.9, 1.1) - xl.Info("wait %v to reconnect", reconnectDelay) - reconnectDelay *= 2 - } else { - util.RandomSleep(time.Second, 0, 0.5) - } - reconnectCounts++ - - now := time.Now() - if now.After(cutoffTime) { - // reset - cutoffTime = now.Add(time.Minute) - reconnectDelay = time.Second - reconnectCounts = 1 - } - - for { - if atomic.LoadUint32(&svr.exit) != 0 { - return - } - - xl.Info("try to reconnect to server...") - conn, cm, err := svr.login() - if err != nil { - xl.Warn("reconnect to server error: %v, wait %v for another retry", err, delayTime) - util.RandomSleep(delayTime, 0.9, 1.1) - - delayTime *= 2 - if delayTime > maxDelayTime { - delayTime = maxDelayTime - } - continue - } - // reconnect success, init delayTime - delayTime = time.Second - - ctl := NewControl(svr.ctx, svr.runID, conn, cm, svr.cfg, svr.pxyCfgs, svr.visitorCfgs, svr.authSetter) - ctl.Run() - svr.ctlMu.Lock() - if svr.ctl != nil { - svr.ctl.Close() - } - svr.ctl = ctl - svr.ctlMu.Unlock() - break - } - } + // There is a situation where the login is successful but due to certain reasons, + // the control immediately exits. It is necessary to limit the frequency of reconnection in this case. + // The interval for the first three retries in 1 minute will be very short, and then it will increase exponentially. + // The maximum interval is 20 seconds. + wait.BackoffUntil(func() error { + // loopLoginUntilSuccess is another layer of loop that will continuously attempt to + // login to the server until successful. + svr.loopLoginUntilSuccess(20*time.Second, false) + <-svr.ctl.Done() + return errors.New("control is closed and try another loop") + }, wait.NewFastBackoffManager( + wait.FastBackoffOptions{ + Duration: time.Second, + Factor: 2, + Jitter: 0.1, + MaxDuration: 20 * time.Second, + FastRetryCount: 3, + FastRetryDelay: 200 * time.Millisecond, + FastRetryWindow: time.Minute, + FastRetryJitter: 0.5, + }, + ), true, svr.ctx.Done()) } // login creates a connection to frps and registers it self as a client @@ -299,6 +233,54 @@ func (svr *Service) login() (conn net.Conn, cm *ConnectionManager, err error) { return } +func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginExit bool) { + xl := xlog.FromContextSafe(svr.ctx) + successCh := make(chan struct{}) + + loginFunc := func() error { + xl.Info("try to connect to server...") + conn, cm, err := svr.login() + if err != nil { + xl.Warn("connect to server error: %v", err) + if firstLoginExit { + svr.cancel() + } + return err + } + + ctl, err := NewControl(svr.ctx, svr.runID, conn, cm, + svr.cfg, svr.pxyCfgs, svr.visitorCfgs, svr.authSetter) + if err != nil { + conn.Close() + xl.Error("NewControl error: %v", err) + return err + } + + ctl.Run() + // close and replace previous control + svr.ctlMu.Lock() + if svr.ctl != nil { + svr.ctl.Close() + } + svr.ctl = ctl + svr.ctlMu.Unlock() + + close(successCh) + return nil + } + + // try to reconnect to server until success + wait.BackoffUntil(loginFunc, wait.NewFastBackoffManager( + wait.FastBackoffOptions{ + Duration: time.Second, + Factor: 2, + Jitter: 0.1, + MaxDuration: maxInterval, + }), + true, + wait.MergeAndCloseOnAnyStopChannel(svr.ctx.Done(), successCh)) +} + func (svr *Service) ReloadConf(pxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error { svr.cfgMu.Lock() svr.pxyCfgs = pxyCfgs @@ -320,20 +302,20 @@ func (svr *Service) Close() { } func (svr *Service) GracefulClose(d time.Duration) { - atomic.StoreUint32(&svr.exit, 1) + svr.gracefulDuration = d + svr.cancel() +} - svr.ctlMu.RLock() +func (svr *Service) stop() { + svr.ctlMu.Lock() + defer svr.ctlMu.Unlock() if svr.ctl != nil { - svr.ctl.GracefulClose(d) + svr.ctl.GracefulClose(svr.gracefulDuration) svr.ctl = nil } - svr.ctlMu.RUnlock() - - if svr.cancel != nil { - svr.cancel() - } } +// ConnectionManager is a wrapper for establishing connections to the server. type ConnectionManager struct { ctx context.Context cfg *v1.ClientCommonConfig @@ -349,6 +331,10 @@ func NewConnectionManager(ctx context.Context, cfg *v1.ClientCommonConfig) *Conn } } +// OpenConnection opens a underlying connection to the server. +// The underlying connection is either a TCP connection or a QUIC connection. +// After the underlying connection is established, you can call Connect() to get a stream. +// If TCPMux isn't enabled, the underlying connection is nil, you will get a new real TCP connection every time you call Connect(). func (cm *ConnectionManager) OpenConnection() error { xl := xlog.FromContextSafe(cm.ctx) @@ -411,6 +397,7 @@ func (cm *ConnectionManager) OpenConnection() error { return nil } +// Connect returns a stream from the underlying connection, or a new TCP connection if TCPMux isn't enabled. func (cm *ConnectionManager) Connect() (net.Conn, error) { if cm.quicConn != nil { stream, err := cm.quicConn.OpenStreamSync(context.Background()) diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go index 696496a2..12c388a5 100644 --- a/pkg/metrics/metrics.go +++ b/pkg/metrics/metrics.go @@ -1,3 +1,17 @@ +// Copyright 2023 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 metrics import ( diff --git a/pkg/msg/handler.go b/pkg/msg/handler.go new file mode 100644 index 00000000..cb1eb15a --- /dev/null +++ b/pkg/msg/handler.go @@ -0,0 +1,103 @@ +// Copyright 2023 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 msg + +import ( + "io" + "reflect" +) + +func AsyncHandler(f func(Message)) func(Message) { + return func(m Message) { + go f(m) + } +} + +// Dispatcher is used to send messages to net.Conn or register handlers for messages read from net.Conn. +type Dispatcher struct { + rw io.ReadWriter + + sendCh chan Message + doneCh chan struct{} + msgHandlers map[reflect.Type]func(Message) + defaultHandler func(Message) +} + +func NewDispatcher(rw io.ReadWriter) *Dispatcher { + return &Dispatcher{ + rw: rw, + sendCh: make(chan Message, 100), + doneCh: make(chan struct{}), + msgHandlers: make(map[reflect.Type]func(Message)), + } +} + +// Run will block until io.EOF or some error occurs. +func (d *Dispatcher) Run() { + go d.sendLoop() + go d.readLoop() +} + +func (d *Dispatcher) sendLoop() { + for { + select { + case <-d.doneCh: + return + case m := <-d.sendCh: + _ = WriteMsg(d.rw, m) + } + } +} + +func (d *Dispatcher) readLoop() { + for { + m, err := ReadMsg(d.rw) + if err != nil { + close(d.doneCh) + return + } + + if handler, ok := d.msgHandlers[reflect.TypeOf(m)]; ok { + handler(m) + } else if d.defaultHandler != nil { + d.defaultHandler(m) + } + } +} + +func (d *Dispatcher) Send(m Message) error { + select { + case <-d.doneCh: + return io.EOF + case d.sendCh <- m: + return nil + } +} + +func (d *Dispatcher) SendChannel() chan Message { + return d.sendCh +} + +func (d *Dispatcher) RegisterHandler(msg Message, handler func(Message)) { + d.msgHandlers[reflect.TypeOf(msg)] = handler +} + +func (d *Dispatcher) RegisterDefaultHandler(handler func(Message)) { + d.defaultHandler = handler +} + +func (d *Dispatcher) Done() chan struct{} { + return d.doneCh +} diff --git a/pkg/transport/message.go b/pkg/transport/message.go index 6bcd8ce8..7163a8ad 100644 --- a/pkg/transport/message.go +++ b/pkg/transport/message.go @@ -29,7 +29,9 @@ type MessageTransporter interface { // Recv(ctx context.Context, laneKey string, msgType string) (Message, error) // Do will first send msg, then recv msg with the same laneKey and specified msgType. Do(ctx context.Context, req msg.Message, laneKey, recvMsgType string) (msg.Message, error) + // Dispatch will dispatch message to releated channel registered in Do function by its message type and laneKey. Dispatch(m msg.Message, laneKey string) bool + // Same with Dispatch but with specified message type. DispatchWithType(m msg.Message, msgType, laneKey string) bool } diff --git a/pkg/util/net/conn.go b/pkg/util/net/conn.go index fb2ff677..a5bbe737 100644 --- a/pkg/util/net/conn.go +++ b/pkg/util/net/conn.go @@ -22,6 +22,7 @@ import ( "sync/atomic" "time" + "github.com/fatedier/golib/crypto" quic "github.com/quic-go/quic-go" "github.com/fatedier/frp/pkg/util/xlog" @@ -216,3 +217,18 @@ func (conn *wrapQuicStream) Close() error { conn.Stream.CancelRead(0) return conn.Stream.Close() } + +func NewCryptoReadWriter(rw io.ReadWriter, key []byte) (io.ReadWriter, error) { + encReader := crypto.NewReader(rw, key) + encWriter, err := crypto.NewWriter(rw, key) + if err != nil { + return nil, err + } + return struct { + io.Reader + io.Writer + }{ + Reader: encReader, + Writer: encWriter, + }, nil +} diff --git a/pkg/util/wait/backoff.go b/pkg/util/wait/backoff.go new file mode 100644 index 00000000..45e0ab68 --- /dev/null +++ b/pkg/util/wait/backoff.go @@ -0,0 +1,197 @@ +// Copyright 2023 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 wait + +import ( + "math/rand" + "time" + + "github.com/samber/lo" + + "github.com/fatedier/frp/pkg/util/util" +) + +type BackoffFunc func(previousDuration time.Duration, previousConditionError bool) time.Duration + +func (f BackoffFunc) Backoff(previousDuration time.Duration, previousConditionError bool) time.Duration { + return f(previousDuration, previousConditionError) +} + +type BackoffManager interface { + Backoff(previousDuration time.Duration, previousConditionError bool) time.Duration +} + +type FastBackoffOptions struct { + Duration time.Duration + Factor float64 + Jitter float64 + MaxDuration time.Duration + InitDurationIfFail time.Duration + + // If FastRetryCount > 0, then within the FastRetryWindow time window, + // the retry will be performed with a delay of FastRetryDelay for the first FastRetryCount calls. + FastRetryCount int + FastRetryDelay time.Duration + FastRetryJitter float64 + FastRetryWindow time.Duration +} + +type fastBackoffImpl struct { + options FastBackoffOptions + + lastCalledTime time.Time + consecutiveErrCount int + + fastRetryCutoffTime time.Time + countsInFastRetryWindow int +} + +func NewFastBackoffManager(options FastBackoffOptions) BackoffManager { + return &fastBackoffImpl{ + options: options, + countsInFastRetryWindow: 1, + } +} + +func (f *fastBackoffImpl) Backoff(previousDuration time.Duration, previousConditionError bool) time.Duration { + if f.lastCalledTime.IsZero() { + f.lastCalledTime = time.Now() + return f.options.Duration + } + now := time.Now() + f.lastCalledTime = now + + if previousConditionError { + f.consecutiveErrCount++ + } else { + f.consecutiveErrCount = 0 + } + + if f.options.FastRetryCount > 0 && previousConditionError { + f.countsInFastRetryWindow++ + if f.countsInFastRetryWindow <= f.options.FastRetryCount { + return Jitter(f.options.FastRetryDelay, f.options.FastRetryJitter) + } + if now.After(f.fastRetryCutoffTime) { + // reset + f.fastRetryCutoffTime = now.Add(f.options.FastRetryWindow) + f.countsInFastRetryWindow = 0 + } + } + + if previousConditionError { + var duration time.Duration + if f.consecutiveErrCount == 1 { + duration = util.EmptyOr(f.options.InitDurationIfFail, previousDuration) + } else { + duration = previousDuration + } + + duration = util.EmptyOr(duration, time.Second) + if f.options.Factor != 0 { + duration = time.Duration(float64(duration) * f.options.Factor) + } + if f.options.Jitter > 0 { + duration = Jitter(duration, f.options.Jitter) + } + if f.options.MaxDuration > 0 && duration > f.options.MaxDuration { + duration = f.options.MaxDuration + } + return duration + } + return f.options.Duration +} + +func BackoffUntil(f func() error, backoff BackoffManager, sliding bool, stopCh <-chan struct{}) { + var delay time.Duration + previousError := false + + ticker := time.NewTicker(backoff.Backoff(delay, previousError)) + defer ticker.Stop() + + for { + select { + case <-stopCh: + return + default: + } + + if !sliding { + delay = backoff.Backoff(delay, previousError) + } + + if err := f(); err != nil { + previousError = true + } else { + previousError = false + } + + if sliding { + delay = backoff.Backoff(delay, previousError) + } + + ticker.Reset(delay) + select { + case <-stopCh: + return + default: + } + + select { + case <-stopCh: + return + case <-ticker.C: + } + } +} + +// Jitter returns a time.Duration between duration and duration + maxFactor * +// duration. +// +// This allows clients to avoid converging on periodic behavior. If maxFactor +// is 0.0, a suggested default value will be chosen. +func Jitter(duration time.Duration, maxFactor float64) time.Duration { + if maxFactor <= 0.0 { + maxFactor = 1.0 + } + wait := duration + time.Duration(rand.Float64()*maxFactor*float64(duration)) + return wait +} + +func Until(f func(), period time.Duration, stopCh <-chan struct{}) { + ff := func() error { + f() + return nil + } + BackoffUntil(ff, BackoffFunc(func(time.Duration, bool) time.Duration { + return period + }), true, stopCh) +} + +func MergeAndCloseOnAnyStopChannel[T any](upstreams ...<-chan T) <-chan T { + out := make(chan T) + + for _, upstream := range upstreams { + ch := upstream + go lo.Try0(func() { + select { + case <-ch: + close(out) + case <-out: + } + }) + } + return out +} diff --git a/server/control.go b/server/control.go index f2eaaa56..e651a97e 100644 --- a/server/control.go +++ b/server/control.go @@ -17,15 +17,12 @@ package server import ( "context" "fmt" - "io" "net" "runtime/debug" "sync" + "sync/atomic" "time" - "github.com/fatedier/golib/control/shutdown" - "github.com/fatedier/golib/crypto" - "github.com/fatedier/golib/errors" "github.com/samber/lo" "github.com/fatedier/frp/pkg/auth" @@ -35,8 +32,10 @@ import ( "github.com/fatedier/frp/pkg/msg" plugin "github.com/fatedier/frp/pkg/plugin/server" "github.com/fatedier/frp/pkg/transport" + utilnet "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/version" + "github.com/fatedier/frp/pkg/util/wait" "github.com/fatedier/frp/pkg/util/xlog" "github.com/fatedier/frp/server/controller" "github.com/fatedier/frp/server/metrics" @@ -111,18 +110,16 @@ type Control struct { // other components can use this to communicate with client msgTransporter transport.MessageTransporter + // msgDispatcher is a wrapper for control connection. + // It provides a channel for sending messages, and you can register handlers to process messages based on their respective types. + msgDispatcher *msg.Dispatcher + // login message loginMsg *msg.Login // control connection conn net.Conn - // put a message in this channel to send it over control connection to client - sendCh chan (msg.Message) - - // read from this channel to get the next message sent by client - readCh chan (msg.Message) - // work connections workConnCh chan net.Conn @@ -136,27 +133,21 @@ type Control struct { portsUsedNum int // last time got the Ping message - lastPing time.Time + lastPing atomic.Value // A new run id will be generated when a new client login. // If run id got from login message has same run id, it means it's the same client, so we can // replace old controller instantly. runID string - readerShutdown *shutdown.Shutdown - writerShutdown *shutdown.Shutdown - managerShutdown *shutdown.Shutdown - allShutdown *shutdown.Shutdown - - started bool - mu sync.RWMutex // Server configuration information serverCfg *v1.ServerConfig - xl *xlog.Logger - ctx context.Context + xl *xlog.Logger + ctx context.Context + doneCh chan struct{} } func NewControl( @@ -168,36 +159,38 @@ func NewControl( ctlConn net.Conn, loginMsg *msg.Login, serverCfg *v1.ServerConfig, -) *Control { +) (*Control, error) { poolCount := loginMsg.PoolCount if poolCount > int(serverCfg.Transport.MaxPoolCount) { poolCount = int(serverCfg.Transport.MaxPoolCount) } ctl := &Control{ - rc: rc, - pxyManager: pxyManager, - pluginManager: pluginManager, - authVerifier: authVerifier, - conn: ctlConn, - loginMsg: loginMsg, - sendCh: make(chan msg.Message, 10), - readCh: make(chan msg.Message, 10), - workConnCh: make(chan net.Conn, poolCount+10), - proxies: make(map[string]proxy.Proxy), - poolCount: poolCount, - portsUsedNum: 0, - lastPing: time.Now(), - runID: loginMsg.RunID, - readerShutdown: shutdown.New(), - writerShutdown: shutdown.New(), - managerShutdown: shutdown.New(), - allShutdown: shutdown.New(), - serverCfg: serverCfg, - xl: xlog.FromContextSafe(ctx), - ctx: ctx, + rc: rc, + pxyManager: pxyManager, + pluginManager: pluginManager, + authVerifier: authVerifier, + conn: ctlConn, + loginMsg: loginMsg, + workConnCh: make(chan net.Conn, poolCount+10), + proxies: make(map[string]proxy.Proxy), + poolCount: poolCount, + portsUsedNum: 0, + runID: loginMsg.RunID, + serverCfg: serverCfg, + xl: xlog.FromContextSafe(ctx), + ctx: ctx, + doneCh: make(chan struct{}), } - ctl.msgTransporter = transport.NewMessageTransporter(ctl.sendCh) - return ctl + ctl.lastPing.Store(time.Now()) + + cryptoRW, err := utilnet.NewCryptoReadWriter(ctl.conn, []byte(ctl.serverCfg.Auth.Token)) + if err != nil { + return nil, err + } + ctl.msgDispatcher = msg.NewDispatcher(cryptoRW) + ctl.registerMsgHandlers() + ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher.SendChannel()) + return ctl, nil } // Start send a login success message to client and start working. @@ -208,27 +201,18 @@ func (ctl *Control) Start() { Error: "", } _ = msg.WriteMsg(ctl.conn, loginRespMsg) - ctl.mu.Lock() - ctl.started = true - ctl.mu.Unlock() - go ctl.writer() go func() { for i := 0; i < ctl.poolCount; i++ { // ignore error here, that means that this control is closed - _ = errors.PanicToError(func() { - ctl.sendCh <- &msg.ReqWorkConn{} - }) + _ = ctl.msgDispatcher.Send(&msg.ReqWorkConn{}) } }() - - go ctl.manager() - go ctl.reader() - go ctl.stoper() + go ctl.worker() } func (ctl *Control) Close() error { - ctl.allShutdown.Start() + ctl.conn.Close() return nil } @@ -236,7 +220,7 @@ func (ctl *Control) Replaced(newCtl *Control) { xl := ctl.xl xl.Info("Replaced by client [%s]", newCtl.runID) ctl.runID = "" - ctl.allShutdown.Start() + ctl.conn.Close() } func (ctl *Control) RegisterWorkConn(conn net.Conn) error { @@ -282,9 +266,7 @@ func (ctl *Control) GetWorkConn() (workConn net.Conn, err error) { xl.Debug("get work connection from pool") default: // no work connections available in the poll, send message to frpc to get more - if err = errors.PanicToError(func() { - ctl.sendCh <- &msg.ReqWorkConn{} - }); err != nil { + if err := ctl.msgDispatcher.Send(&msg.ReqWorkConn{}); err != nil { return nil, fmt.Errorf("control is already closed") } @@ -304,92 +286,39 @@ func (ctl *Control) GetWorkConn() (workConn net.Conn, err error) { } // When we get a work connection from pool, replace it with a new one. - _ = errors.PanicToError(func() { - ctl.sendCh <- &msg.ReqWorkConn{} - }) + _ = ctl.msgDispatcher.Send(&msg.ReqWorkConn{}) return } -func (ctl *Control) writer() { +func (ctl *Control) heartbeatWorker() { xl := ctl.xl - defer func() { - if err := recover(); err != nil { - xl.Error("panic error: %v", err) - xl.Error(string(debug.Stack())) - } - }() - defer ctl.allShutdown.Start() - defer ctl.writerShutdown.Done() - - encWriter, err := crypto.NewWriter(ctl.conn, []byte(ctl.serverCfg.Auth.Token)) - if err != nil { - xl.Error("crypto new writer error: %v", err) - ctl.allShutdown.Start() - return - } - for { - m, ok := <-ctl.sendCh - if !ok { - xl.Info("control writer is closing") - return - } - - if err := msg.WriteMsg(encWriter, m); err != nil { - xl.Warn("write message to control connection error: %v", err) - return - } - } -} - -func (ctl *Control) reader() { - xl := ctl.xl - defer func() { - if err := recover(); err != nil { - xl.Error("panic error: %v", err) - xl.Error(string(debug.Stack())) - } - }() - - defer ctl.allShutdown.Start() - defer ctl.readerShutdown.Done() - - encReader := crypto.NewReader(ctl.conn, []byte(ctl.serverCfg.Auth.Token)) - for { - m, err := msg.ReadMsg(encReader) - if err != nil { - if err == io.EOF { - xl.Debug("control connection closed") + // Don't need application heartbeat if TCPMux is enabled, + // yamux will do same thing. + // TODO(fatedier): let default HeartbeatTimeout to -1 if TCPMux is enabled. Users can still set it to positive value to enable it. + if !lo.FromPtr(ctl.serverCfg.Transport.TCPMux) && ctl.serverCfg.Transport.HeartbeatTimeout > 0 { + go wait.Until(func() { + if time.Since(ctl.lastPing.Load().(time.Time)) > time.Duration(ctl.serverCfg.Transport.HeartbeatTimeout)*time.Second { + xl.Warn("heartbeat timeout") return } - xl.Warn("read error: %v", err) - ctl.conn.Close() - return - } - - ctl.readCh <- m + }, time.Second, ctl.doneCh) } } -func (ctl *Control) stoper() { +// block until Control closed +func (ctl *Control) WaitClosed() { + <-ctl.doneCh +} + +func (ctl *Control) worker() { xl := ctl.xl - defer func() { - if err := recover(); err != nil { - xl.Error("panic error: %v", err) - xl.Error(string(debug.Stack())) - } - }() - ctl.allShutdown.WaitStart() + go ctl.heartbeatWorker() + go ctl.msgDispatcher.Run() + <-ctl.msgDispatcher.Done() ctl.conn.Close() - ctl.readerShutdown.WaitDone() - - close(ctl.readCh) - ctl.managerShutdown.WaitDone() - - close(ctl.sendCh) - ctl.writerShutdown.WaitDone() ctl.mu.Lock() defer ctl.mu.Unlock() @@ -419,136 +348,104 @@ func (ctl *Control) stoper() { }() } - ctl.allShutdown.Done() - xl.Info("client exit success") metrics.Server.CloseClient() + xl.Info("client exit success") + close(ctl.doneCh) } -// block until Control closed -func (ctl *Control) WaitClosed() { - ctl.mu.RLock() - started := ctl.started - ctl.mu.RUnlock() +func (ctl *Control) registerMsgHandlers() { + ctl.msgDispatcher.RegisterHandler(&msg.NewProxy{}, ctl.handleNewProxy) + ctl.msgDispatcher.RegisterHandler(&msg.Ping{}, ctl.handlePing) + ctl.msgDispatcher.RegisterHandler(&msg.NatHoleVisitor{}, msg.AsyncHandler(ctl.handleNatHoleVisitor)) + ctl.msgDispatcher.RegisterHandler(&msg.NatHoleClient{}, msg.AsyncHandler(ctl.handleNatHoleClient)) + ctl.msgDispatcher.RegisterHandler(&msg.NatHoleReport{}, msg.AsyncHandler(ctl.handleNatHoleReport)) + ctl.msgDispatcher.RegisterHandler(&msg.CloseProxy{}, ctl.handleCloseProxy) +} - if !started { - ctl.allShutdown.Done() +func (ctl *Control) handleNewProxy(m msg.Message) { + xl := ctl.xl + inMsg := m.(*msg.NewProxy) + + content := &plugin.NewProxyContent{ + User: plugin.UserInfo{ + User: ctl.loginMsg.User, + Metas: ctl.loginMsg.Metas, + RunID: ctl.loginMsg.RunID, + }, + NewProxy: *inMsg, + } + var remoteAddr string + retContent, err := ctl.pluginManager.NewProxy(content) + if err == nil { + inMsg = &retContent.NewProxy + remoteAddr, err = ctl.RegisterProxy(inMsg) + } + + // register proxy in this control + resp := &msg.NewProxyResp{ + ProxyName: inMsg.ProxyName, + } + if err != nil { + xl.Warn("new proxy [%s] type [%s] error: %v", inMsg.ProxyName, inMsg.ProxyType, err) + resp.Error = util.GenerateResponseErrorString(fmt.Sprintf("new proxy [%s] error", inMsg.ProxyName), + err, lo.FromPtr(ctl.serverCfg.DetailedErrorsToClient)) + } else { + resp.RemoteAddr = remoteAddr + xl.Info("new proxy [%s] type [%s] success", inMsg.ProxyName, inMsg.ProxyType) + metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType) + } + _ = ctl.msgDispatcher.Send(resp) +} + +func (ctl *Control) handlePing(m msg.Message) { + xl := ctl.xl + inMsg := m.(*msg.Ping) + + content := &plugin.PingContent{ + User: plugin.UserInfo{ + User: ctl.loginMsg.User, + Metas: ctl.loginMsg.Metas, + RunID: ctl.loginMsg.RunID, + }, + Ping: *inMsg, + } + retContent, err := ctl.pluginManager.Ping(content) + if err == nil { + inMsg = &retContent.Ping + err = ctl.authVerifier.VerifyPing(inMsg) + } + if err != nil { + xl.Warn("received invalid ping: %v", err) + _ = ctl.msgDispatcher.Send(&msg.Pong{ + Error: util.GenerateResponseErrorString("invalid ping", err, lo.FromPtr(ctl.serverCfg.DetailedErrorsToClient)), + }) return } - ctl.allShutdown.WaitDone() + ctl.lastPing.Store(time.Now()) + xl.Debug("receive heartbeat") + _ = ctl.msgDispatcher.Send(&msg.Pong{}) } -func (ctl *Control) manager() { +func (ctl *Control) handleNatHoleVisitor(m msg.Message) { + inMsg := m.(*msg.NatHoleVisitor) + ctl.rc.NatHoleController.HandleVisitor(inMsg, ctl.msgTransporter, ctl.loginMsg.User) +} + +func (ctl *Control) handleNatHoleClient(m msg.Message) { + inMsg := m.(*msg.NatHoleClient) + ctl.rc.NatHoleController.HandleClient(inMsg, ctl.msgTransporter) +} + +func (ctl *Control) handleNatHoleReport(m msg.Message) { + inMsg := m.(*msg.NatHoleReport) + ctl.rc.NatHoleController.HandleReport(inMsg) +} + +func (ctl *Control) handleCloseProxy(m msg.Message) { xl := ctl.xl - defer func() { - if err := recover(); err != nil { - xl.Error("panic error: %v", err) - xl.Error(string(debug.Stack())) - } - }() - - defer ctl.allShutdown.Start() - defer ctl.managerShutdown.Done() - - var heartbeatCh <-chan time.Time - // Don't need application heartbeat if TCPMux is enabled, - // yamux will do same thing. - if !lo.FromPtr(ctl.serverCfg.Transport.TCPMux) && ctl.serverCfg.Transport.HeartbeatTimeout > 0 { - heartbeat := time.NewTicker(time.Second) - defer heartbeat.Stop() - heartbeatCh = heartbeat.C - } - - for { - select { - case <-heartbeatCh: - if time.Since(ctl.lastPing) > time.Duration(ctl.serverCfg.Transport.HeartbeatTimeout)*time.Second { - xl.Warn("heartbeat timeout") - return - } - case rawMsg, ok := <-ctl.readCh: - if !ok { - return - } - - switch m := rawMsg.(type) { - case *msg.NewProxy: - content := &plugin.NewProxyContent{ - User: plugin.UserInfo{ - User: ctl.loginMsg.User, - Metas: ctl.loginMsg.Metas, - RunID: ctl.loginMsg.RunID, - }, - NewProxy: *m, - } - var remoteAddr string - retContent, err := ctl.pluginManager.NewProxy(content) - if err == nil { - m = &retContent.NewProxy - remoteAddr, err = ctl.RegisterProxy(m) - } - - // register proxy in this control - resp := &msg.NewProxyResp{ - ProxyName: m.ProxyName, - } - if err != nil { - xl.Warn("new proxy [%s] type [%s] error: %v", m.ProxyName, m.ProxyType, err) - resp.Error = util.GenerateResponseErrorString(fmt.Sprintf("new proxy [%s] error", m.ProxyName), - err, lo.FromPtr(ctl.serverCfg.DetailedErrorsToClient)) - } else { - resp.RemoteAddr = remoteAddr - xl.Info("new proxy [%s] type [%s] success", m.ProxyName, m.ProxyType) - metrics.Server.NewProxy(m.ProxyName, m.ProxyType) - } - ctl.sendCh <- resp - case *msg.NatHoleVisitor: - go ctl.HandleNatHoleVisitor(m) - case *msg.NatHoleClient: - go ctl.HandleNatHoleClient(m) - case *msg.NatHoleReport: - go ctl.HandleNatHoleReport(m) - case *msg.CloseProxy: - _ = ctl.CloseProxy(m) - xl.Info("close proxy [%s] success", m.ProxyName) - case *msg.Ping: - content := &plugin.PingContent{ - User: plugin.UserInfo{ - User: ctl.loginMsg.User, - Metas: ctl.loginMsg.Metas, - RunID: ctl.loginMsg.RunID, - }, - Ping: *m, - } - retContent, err := ctl.pluginManager.Ping(content) - if err == nil { - m = &retContent.Ping - err = ctl.authVerifier.VerifyPing(m) - } - if err != nil { - xl.Warn("received invalid ping: %v", err) - ctl.sendCh <- &msg.Pong{ - Error: util.GenerateResponseErrorString("invalid ping", err, lo.FromPtr(ctl.serverCfg.DetailedErrorsToClient)), - } - return - } - ctl.lastPing = time.Now() - xl.Debug("receive heartbeat") - ctl.sendCh <- &msg.Pong{} - } - } - } -} - -func (ctl *Control) HandleNatHoleVisitor(m *msg.NatHoleVisitor) { - ctl.rc.NatHoleController.HandleVisitor(m, ctl.msgTransporter, ctl.loginMsg.User) -} - -func (ctl *Control) HandleNatHoleClient(m *msg.NatHoleClient) { - ctl.rc.NatHoleController.HandleClient(m, ctl.msgTransporter) -} - -func (ctl *Control) HandleNatHoleReport(m *msg.NatHoleReport) { - ctl.rc.NatHoleController.HandleReport(m) + inMsg := m.(*msg.CloseProxy) + _ = ctl.CloseProxy(inMsg) + xl.Info("close proxy [%s] success", inMsg.ProxyName) } func (ctl *Control) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err error) { diff --git a/server/service.go b/server/service.go index 9deffa02..2629b345 100644 --- a/server/service.go +++ b/server/service.go @@ -516,13 +516,14 @@ func (svr *Service) HandleQUICListener(l *quic.Listener) { } } -func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login) (err error) { +func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login) error { // If client's RunID is empty, it's a new client, we just create a new controller. // Otherwise, we check if there is one controller has the same run id. If so, we release previous controller and start new one. + var err error if loginMsg.RunID == "" { loginMsg.RunID, err = util.RandID() if err != nil { - return + return err } } @@ -534,11 +535,16 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login) (err ctlConn.RemoteAddr().String(), loginMsg.Version, loginMsg.Hostname, loginMsg.Os, loginMsg.Arch) // Check auth. - if err = svr.authVerifier.VerifyLogin(loginMsg); err != nil { - return + if err := svr.authVerifier.VerifyLogin(loginMsg); err != nil { + return err } - ctl := NewControl(ctx, svr.rc, svr.pxyManager, svr.pluginManager, svr.authVerifier, ctlConn, loginMsg, svr.cfg) + ctl, err := NewControl(ctx, svr.rc, svr.pxyManager, svr.pluginManager, svr.authVerifier, ctlConn, loginMsg, svr.cfg) + if err != nil { + xl.Warn("create new controller error: %v", err) + // don't return detailed errors to client + return fmt.Errorf("unexpect error when creating new controller") + } if oldCtl := svr.ctlManager.Add(loginMsg.RunID, ctl); oldCtl != nil { oldCtl.WaitClosed() } @@ -553,7 +559,7 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login) (err ctl.WaitClosed() svr.ctlManager.Del(loginMsg.RunID, ctl) }() - return + return nil } // RegisterWorkConn register a new work connection to control and proxies need it. From e8deb65c4b173407bad116ed349534ae159107fc Mon Sep 17 00:00:00 2001 From: Aarni Koskela <akx@iki.fi> Date: Thu, 16 Nov 2023 09:42:49 +0200 Subject: [PATCH 05/21] Strict configuration parsing (#3773) * Test configuration loading more precisely * Add strict configuration parsing --- client/admin_api.go | 2 +- client/service.go | 17 ++++++---- cmd/frpc/sub/admin.go | 2 +- cmd/frpc/sub/nathole.go | 2 +- cmd/frpc/sub/proxy.go | 4 +-- cmd/frpc/sub/root.go | 18 +++++++---- cmd/frpc/sub/verify.go | 2 +- cmd/frps/root.go | 8 +++-- cmd/frps/verify.go | 2 +- pkg/config/load.go | 39 +++++++++++++++-------- pkg/config/load_test.go | 70 ++++++++++++++++++++++++++++++++++------- 11 files changed, 119 insertions(+), 47 deletions(-) diff --git a/client/admin_api.go b/client/admin_api.go index e775f526..84db31c5 100644 --- a/client/admin_api.go +++ b/client/admin_api.go @@ -57,7 +57,7 @@ func (svr *Service) apiReload(w http.ResponseWriter, _ *http.Request) { } }() - cliCfg, pxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(svr.cfgFile) + cliCfg, pxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(svr.cfgFile, svr.strictConfig) if err != nil { res.Code = 400 res.Msg = err.Error() diff --git a/client/service.go b/client/service.go index 66a642c1..0a25ae08 100644 --- a/client/service.go +++ b/client/service.go @@ -70,6 +70,9 @@ type Service struct { // string if no configuration file was used. cfgFile string + // Whether strict configuration parsing had been requested. + strictConfig bool + // service context ctx context.Context // call cancel to stop service @@ -82,14 +85,16 @@ func NewService( pxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer, cfgFile string, + strictConfig bool, ) *Service { return &Service{ - authSetter: auth.NewAuthSetter(cfg.Auth), - cfg: cfg, - cfgFile: cfgFile, - pxyCfgs: pxyCfgs, - visitorCfgs: visitorCfgs, - ctx: context.Background(), + authSetter: auth.NewAuthSetter(cfg.Auth), + cfg: cfg, + cfgFile: cfgFile, + strictConfig: strictConfig, + pxyCfgs: pxyCfgs, + visitorCfgs: visitorCfgs, + ctx: context.Background(), } } diff --git a/cmd/frpc/sub/admin.go b/cmd/frpc/sub/admin.go index 2a5f2830..d98b4d3e 100644 --- a/cmd/frpc/sub/admin.go +++ b/cmd/frpc/sub/admin.go @@ -52,7 +52,7 @@ func NewAdminCommand(name, short string, handler func(*v1.ClientCommonConfig) er Use: name, Short: short, Run: func(cmd *cobra.Command, args []string) { - cfg, _, _, _, err := config.LoadClientConfig(cfgFile) + cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfig) if err != nil { fmt.Println(err) os.Exit(1) diff --git a/cmd/frpc/sub/nathole.go b/cmd/frpc/sub/nathole.go index 72b635f1..eafea27e 100644 --- a/cmd/frpc/sub/nathole.go +++ b/cmd/frpc/sub/nathole.go @@ -48,7 +48,7 @@ var natholeDiscoveryCmd = &cobra.Command{ Short: "Discover nathole information from stun server", RunE: func(cmd *cobra.Command, args []string) error { // ignore error here, because we can use command line pameters - cfg, _, _, _, err := config.LoadClientConfig(cfgFile) + cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfig) if err != nil { cfg = &v1.ClientCommonConfig{} } diff --git a/cmd/frpc/sub/proxy.go b/cmd/frpc/sub/proxy.go index 7ae8d353..41c20bc2 100644 --- a/cmd/frpc/sub/proxy.go +++ b/cmd/frpc/sub/proxy.go @@ -84,7 +84,7 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm fmt.Println(err) os.Exit(1) } - err := startService(clientCfg, []v1.ProxyConfigurer{c}, nil, "") + err := startService(clientCfg, []v1.ProxyConfigurer{c}, nil, "", strictConfig) if err != nil { fmt.Println(err) os.Exit(1) @@ -110,7 +110,7 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client fmt.Println(err) os.Exit(1) } - err := startService(clientCfg, nil, []v1.VisitorConfigurer{c}, "") + err := startService(clientCfg, nil, []v1.VisitorConfigurer{c}, "", strictConfig) if err != nil { fmt.Println(err) os.Exit(1) diff --git a/cmd/frpc/sub/root.go b/cmd/frpc/sub/root.go index 125c88c0..855c7abb 100644 --- a/cmd/frpc/sub/root.go +++ b/cmd/frpc/sub/root.go @@ -36,15 +36,17 @@ import ( ) var ( - cfgFile string - cfgDir string - showVersion bool + cfgFile string + cfgDir string + showVersion bool + strictConfig bool ) func init() { rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "./frpc.ini", "config file of frpc") rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory") rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc") + rootCmd.PersistentFlags().BoolVarP(&strictConfig, "strict_config", "", false, "strict config parsing mode") } var rootCmd = &cobra.Command{ @@ -108,7 +110,7 @@ func handleTermSignal(svr *client.Service) { } func runClient(cfgFilePath string) error { - cfg, pxyCfgs, visitorCfgs, isLegacyFormat, err := config.LoadClientConfig(cfgFilePath) + cfg, pxyCfgs, visitorCfgs, isLegacyFormat, err := config.LoadClientConfig(cfgFilePath, strictConfig) if err != nil { return err } @@ -120,11 +122,14 @@ func runClient(cfgFilePath string) error { warning, err := validation.ValidateAllClientConfig(cfg, pxyCfgs, visitorCfgs) if warning != nil { fmt.Printf("WARNING: %v\n", warning) + if strictConfig { + return fmt.Errorf("warning: %v", warning) + } } if err != nil { return err } - return startService(cfg, pxyCfgs, visitorCfgs, cfgFilePath) + return startService(cfg, pxyCfgs, visitorCfgs, cfgFilePath, strictConfig) } func startService( @@ -132,6 +137,7 @@ func startService( pxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer, cfgFile string, + strictConfig bool, ) error { log.InitLog(cfg.Log.To, cfg.Log.Level, cfg.Log.MaxDays, cfg.Log.DisablePrintColor) @@ -139,7 +145,7 @@ func startService( log.Info("start frpc service for config file [%s]", cfgFile) defer log.Info("frpc service for config file [%s] stopped", cfgFile) } - svr := client.NewService(cfg, pxyCfgs, visitorCfgs, cfgFile) + svr := client.NewService(cfg, pxyCfgs, visitorCfgs, cfgFile, strictConfig) shouldGracefulClose := cfg.Transport.Protocol == "kcp" || cfg.Transport.Protocol == "quic" // Capture the exit signal if we use kcp or quic. diff --git a/cmd/frpc/sub/verify.go b/cmd/frpc/sub/verify.go index a84f54f2..0e6adca8 100644 --- a/cmd/frpc/sub/verify.go +++ b/cmd/frpc/sub/verify.go @@ -37,7 +37,7 @@ var verifyCmd = &cobra.Command{ return nil } - cliCfg, pxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile) + cliCfg, pxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfig) if err != nil { fmt.Println(err) os.Exit(1) diff --git a/cmd/frps/root.go b/cmd/frps/root.go index 4a6f0117..adb8852e 100644 --- a/cmd/frps/root.go +++ b/cmd/frps/root.go @@ -30,8 +30,9 @@ import ( ) var ( - cfgFile string - showVersion bool + cfgFile string + showVersion bool + strictConfig bool serverCfg v1.ServerConfig ) @@ -39,6 +40,7 @@ var ( func init() { rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file of frps") rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frps") + rootCmd.PersistentFlags().BoolVarP(&strictConfig, "strict_config", "", false, "strict config parsing mode") RegisterServerConfigFlags(rootCmd, &serverCfg) } @@ -58,7 +60,7 @@ var rootCmd = &cobra.Command{ err error ) if cfgFile != "" { - svrCfg, isLegacyFormat, err = config.LoadServerConfig(cfgFile) + svrCfg, isLegacyFormat, err = config.LoadServerConfig(cfgFile, strictConfig) if err != nil { fmt.Println(err) os.Exit(1) diff --git a/cmd/frps/verify.go b/cmd/frps/verify.go index 4f0cefb1..838ac7b6 100644 --- a/cmd/frps/verify.go +++ b/cmd/frps/verify.go @@ -36,7 +36,7 @@ var verifyCmd = &cobra.Command{ fmt.Println("frps: the configuration file is not specified") return nil } - svrCfg, _, err := config.LoadServerConfig(cfgFile) + svrCfg, _, err := config.LoadServerConfig(cfgFile, strictConfig) if err != nil { fmt.Println(err) os.Exit(1) diff --git a/pkg/config/load.go b/pkg/config/load.go index af2c3e80..a4013c32 100644 --- a/pkg/config/load.go +++ b/pkg/config/load.go @@ -27,7 +27,7 @@ import ( "github.com/samber/lo" "gopkg.in/ini.v1" "k8s.io/apimachinery/pkg/util/sets" - "k8s.io/apimachinery/pkg/util/yaml" + yaml "k8s.io/apimachinery/pkg/util/yaml" "github.com/fatedier/frp/pkg/config/legacy" v1 "github.com/fatedier/frp/pkg/config/v1" @@ -100,26 +100,39 @@ func LoadFileContentWithTemplate(path string, values *Values) ([]byte, error) { return RenderWithTemplate(b, values) } -func LoadConfigureFromFile(path string, c any) error { +func LoadConfigureFromFile(path string, c any, strict bool) error { content, err := LoadFileContentWithTemplate(path, GetValues()) if err != nil { return err } - return LoadConfigure(content, c) + return LoadConfigure(content, c, strict) } // LoadConfigure loads configuration from bytes and unmarshal into c. // Now it supports json, yaml and toml format. -func LoadConfigure(b []byte, c any) error { +func LoadConfigure(b []byte, c any, strict bool) error { var tomlObj interface{} + // Try to unmarshal as TOML first; swallow errors from that (assume it's not valid TOML). + // TODO: caller should probably be able to specify the format, so we don't need to swallow errors. if err := toml.Unmarshal(b, &tomlObj); err == nil { b, err = json.Marshal(&tomlObj) if err != nil { return err } } - decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewBuffer(b), 4096) - return decoder.Decode(c) + // If the buffer smells like JSON (first non-whitespace character is '{'), unmarshal as JSON directly. + if yaml.IsJSONBuffer(b) { + decoder := json.NewDecoder(bytes.NewBuffer(b)) + if strict { + decoder.DisallowUnknownFields() + } + return decoder.Decode(c) + } + // It wasn't JSON. Unmarshal as YAML. + if strict { + return yaml.UnmarshalStrict(b, c) + } + return yaml.Unmarshal(b, c) } func NewProxyConfigurerFromMsg(m *msg.NewProxy, serverCfg *v1.ServerConfig) (v1.ProxyConfigurer, error) { @@ -139,7 +152,7 @@ func NewProxyConfigurerFromMsg(m *msg.NewProxy, serverCfg *v1.ServerConfig) (v1. return configurer, nil } -func LoadServerConfig(path string) (*v1.ServerConfig, bool, error) { +func LoadServerConfig(path string, strict bool) (*v1.ServerConfig, bool, error) { var ( svrCfg *v1.ServerConfig isLegacyFormat bool @@ -158,7 +171,7 @@ func LoadServerConfig(path string) (*v1.ServerConfig, bool, error) { isLegacyFormat = true } else { svrCfg = &v1.ServerConfig{} - if err := LoadConfigureFromFile(path, svrCfg); err != nil { + if err := LoadConfigureFromFile(path, svrCfg, strict); err != nil { return nil, false, err } } @@ -168,7 +181,7 @@ func LoadServerConfig(path string) (*v1.ServerConfig, bool, error) { return svrCfg, isLegacyFormat, nil } -func LoadClientConfig(path string) ( +func LoadClientConfig(path string, strict bool) ( *v1.ClientCommonConfig, []v1.ProxyConfigurer, []v1.VisitorConfigurer, @@ -196,7 +209,7 @@ func LoadClientConfig(path string) ( isLegacyFormat = true } else { allCfg := v1.ClientConfig{} - if err := LoadConfigureFromFile(path, &allCfg); err != nil { + if err := LoadConfigureFromFile(path, &allCfg, strict); err != nil { return nil, nil, nil, false, err } cliCfg = &allCfg.ClientCommonConfig @@ -211,7 +224,7 @@ func LoadClientConfig(path string) ( // Load additional config from includes. // legacy ini format alredy handle this in ParseClientConfig. if len(cliCfg.IncludeConfigFiles) > 0 && !isLegacyFormat { - extPxyCfgs, extVisitorCfgs, err := LoadAdditionalClientConfigs(cliCfg.IncludeConfigFiles, isLegacyFormat) + extPxyCfgs, extVisitorCfgs, err := LoadAdditionalClientConfigs(cliCfg.IncludeConfigFiles, isLegacyFormat, strict) if err != nil { return nil, nil, nil, isLegacyFormat, err } @@ -242,7 +255,7 @@ func LoadClientConfig(path string) ( return cliCfg, pxyCfgs, visitorCfgs, isLegacyFormat, nil } -func LoadAdditionalClientConfigs(paths []string, isLegacyFormat bool) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) { +func LoadAdditionalClientConfigs(paths []string, isLegacyFormat bool, strict bool) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) { pxyCfgs := make([]v1.ProxyConfigurer, 0) visitorCfgs := make([]v1.VisitorConfigurer, 0) for _, path := range paths { @@ -265,7 +278,7 @@ func LoadAdditionalClientConfigs(paths []string, isLegacyFormat bool) ([]v1.Prox if matched, _ := filepath.Match(filepath.Join(absDir, filepath.Base(path)), absFile); matched { // support yaml/json/toml cfg := v1.ClientConfig{} - if err := LoadConfigureFromFile(absFile, &cfg); err != nil { + if err := LoadConfigureFromFile(absFile, &cfg, strict); err != nil { return nil, nil, fmt.Errorf("load additional config from %s error: %v", absFile, err) } for _, c := range cfg.Proxies { diff --git a/pkg/config/load_test.go b/pkg/config/load_test.go index eab4ba96..876d53e4 100644 --- a/pkg/config/load_test.go +++ b/pkg/config/load_test.go @@ -15,6 +15,7 @@ package config import ( + "strings" "testing" "github.com/stretchr/testify/require" @@ -22,9 +23,7 @@ import ( v1 "github.com/fatedier/frp/pkg/config/v1" ) -func TestLoadConfigure(t *testing.T) { - require := require.New(t) - content := ` +const tomlServerContent = ` bindAddr = "127.0.0.1" kcpBindPort = 7000 quicBindPort = 7001 @@ -33,13 +32,60 @@ custom404Page = "/abc.html" transport.tcpKeepalive = 10 ` - svrCfg := v1.ServerConfig{} - err := LoadConfigure([]byte(content), &svrCfg) - require.NoError(err) - require.EqualValues("127.0.0.1", svrCfg.BindAddr) - require.EqualValues(7000, svrCfg.KCPBindPort) - require.EqualValues(7001, svrCfg.QUICBindPort) - require.EqualValues(7005, svrCfg.TCPMuxHTTPConnectPort) - require.EqualValues("/abc.html", svrCfg.Custom404Page) - require.EqualValues(10, svrCfg.Transport.TCPKeepAlive) +const yamlServerContent = ` +bindAddr: 127.0.0.1 +kcpBindPort: 7000 +quicBindPort: 7001 +tcpmuxHTTPConnectPort: 7005 +custom404Page: /abc.html +transport: + tcpKeepalive: 10 +` + +const jsonServerContent = ` +{ + "bindAddr": "127.0.0.1", + "kcpBindPort": 7000, + "quicBindPort": 7001, + "tcpmuxHTTPConnectPort": 7005, + "custom404Page": "/abc.html", + "transport": { + "tcpKeepalive": 10 + } +} +` + +func TestLoadServerConfig(t *testing.T) { + for _, content := range []string{tomlServerContent, yamlServerContent, jsonServerContent} { + svrCfg := v1.ServerConfig{} + err := LoadConfigure([]byte(content), &svrCfg, true) + require := require.New(t) + require.NoError(err) + require.EqualValues("127.0.0.1", svrCfg.BindAddr) + require.EqualValues(7000, svrCfg.KCPBindPort) + require.EqualValues(7001, svrCfg.QUICBindPort) + require.EqualValues(7005, svrCfg.TCPMuxHTTPConnectPort) + require.EqualValues("/abc.html", svrCfg.Custom404Page) + require.EqualValues(10, svrCfg.Transport.TCPKeepAlive) + } +} + +// Test that loading in strict mode fails when the config is invalid. +func TestLoadServerConfigErrorMode(t *testing.T) { + for strict := range []bool{false, true} { + for _, content := range []string{tomlServerContent, yamlServerContent, jsonServerContent} { + // Break the content with an innocent typo + brokenContent := strings.Replace(content, "bindAddr", "bindAdur", 1) + svrCfg := v1.ServerConfig{} + err := LoadConfigure([]byte(brokenContent), &svrCfg, strict == 1) + require := require.New(t) + if strict == 1 { + require.ErrorContains(err, "bindAdur") + } else { + require.NoError(err) + // BindAddr didn't get parsed because of the typo. + require.EqualValues("", svrCfg.BindAddr) + } + } + } } From 526e809bd50eed4ff5c2211adc91126abb864530 Mon Sep 17 00:00:00 2001 From: fatedier <fatedier@gmail.com> Date: Thu, 16 Nov 2023 21:03:36 +0800 Subject: [PATCH 06/21] update for strict config (#3779) --- Release.md | 4 ++ client/admin_api.go | 9 +++- client/service.go | 17 +++----- cmd/frpc/sub/admin.go | 4 +- cmd/frpc/sub/nathole.go | 2 +- cmd/frpc/sub/proxy.go | 4 +- cmd/frpc/sub/root.go | 21 ++++------ cmd/frpc/sub/verify.go | 2 +- cmd/frps/root.go | 10 ++--- cmd/frps/verify.go | 2 +- pkg/config/load.go | 3 +- pkg/config/load_test.go | 74 +++++++++++++++++++++------------ pkg/sdk/client/client.go | 13 +++++- test/e2e/legacy/basic/client.go | 2 +- test/e2e/v1/basic/client.go | 2 +- 15 files changed, 99 insertions(+), 70 deletions(-) diff --git a/Release.md b/Release.md index a834392c..b4245189 100644 --- a/Release.md +++ b/Release.md @@ -1,3 +1,7 @@ +### Features + +* New command line parameter `--strict_config` is added to enable strict configuration validation mode. It will throw an error for non-existent fields instead of ignoring them. + ### Fixes * frpc: Return code 1 when the first login attempt fails and exits. diff --git a/client/admin_api.go b/client/admin_api.go index 84db31c5..3a56a99f 100644 --- a/client/admin_api.go +++ b/client/admin_api.go @@ -45,8 +45,13 @@ func (svr *Service) healthz(w http.ResponseWriter, _ *http.Request) { } // GET /api/reload -func (svr *Service) apiReload(w http.ResponseWriter, _ *http.Request) { +func (svr *Service) apiReload(w http.ResponseWriter, r *http.Request) { res := GeneralResponse{Code: 200} + strictConfigMode := false + strictStr := r.URL.Query().Get("strictConfig") + if strictStr != "" { + strictConfigMode, _ = strconv.ParseBool(strictStr) + } log.Info("api request [/api/reload]") defer func() { @@ -57,7 +62,7 @@ func (svr *Service) apiReload(w http.ResponseWriter, _ *http.Request) { } }() - cliCfg, pxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(svr.cfgFile, svr.strictConfig) + cliCfg, pxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(svr.cfgFile, strictConfigMode) if err != nil { res.Code = 400 res.Msg = err.Error() diff --git a/client/service.go b/client/service.go index 0a25ae08..66a642c1 100644 --- a/client/service.go +++ b/client/service.go @@ -70,9 +70,6 @@ type Service struct { // string if no configuration file was used. cfgFile string - // Whether strict configuration parsing had been requested. - strictConfig bool - // service context ctx context.Context // call cancel to stop service @@ -85,16 +82,14 @@ func NewService( pxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer, cfgFile string, - strictConfig bool, ) *Service { return &Service{ - authSetter: auth.NewAuthSetter(cfg.Auth), - cfg: cfg, - cfgFile: cfgFile, - strictConfig: strictConfig, - pxyCfgs: pxyCfgs, - visitorCfgs: visitorCfgs, - ctx: context.Background(), + authSetter: auth.NewAuthSetter(cfg.Auth), + cfg: cfg, + cfgFile: cfgFile, + pxyCfgs: pxyCfgs, + visitorCfgs: visitorCfgs, + ctx: context.Background(), } } diff --git a/cmd/frpc/sub/admin.go b/cmd/frpc/sub/admin.go index d98b4d3e..5d478d44 100644 --- a/cmd/frpc/sub/admin.go +++ b/cmd/frpc/sub/admin.go @@ -52,7 +52,7 @@ func NewAdminCommand(name, short string, handler func(*v1.ClientCommonConfig) er Use: name, Short: short, Run: func(cmd *cobra.Command, args []string) { - cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfig) + cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode) if err != nil { fmt.Println(err) os.Exit(1) @@ -73,7 +73,7 @@ func NewAdminCommand(name, short string, handler func(*v1.ClientCommonConfig) er func ReloadHandler(clientCfg *v1.ClientCommonConfig) error { client := clientsdk.New(clientCfg.WebServer.Addr, clientCfg.WebServer.Port) client.SetAuth(clientCfg.WebServer.User, clientCfg.WebServer.Password) - if err := client.Reload(); err != nil { + if err := client.Reload(strictConfigMode); err != nil { return err } fmt.Println("reload success") diff --git a/cmd/frpc/sub/nathole.go b/cmd/frpc/sub/nathole.go index eafea27e..56fcf67b 100644 --- a/cmd/frpc/sub/nathole.go +++ b/cmd/frpc/sub/nathole.go @@ -48,7 +48,7 @@ var natholeDiscoveryCmd = &cobra.Command{ Short: "Discover nathole information from stun server", RunE: func(cmd *cobra.Command, args []string) error { // ignore error here, because we can use command line pameters - cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfig) + cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode) if err != nil { cfg = &v1.ClientCommonConfig{} } diff --git a/cmd/frpc/sub/proxy.go b/cmd/frpc/sub/proxy.go index 41c20bc2..7ae8d353 100644 --- a/cmd/frpc/sub/proxy.go +++ b/cmd/frpc/sub/proxy.go @@ -84,7 +84,7 @@ func NewProxyCommand(name string, c v1.ProxyConfigurer, clientCfg *v1.ClientComm fmt.Println(err) os.Exit(1) } - err := startService(clientCfg, []v1.ProxyConfigurer{c}, nil, "", strictConfig) + err := startService(clientCfg, []v1.ProxyConfigurer{c}, nil, "") if err != nil { fmt.Println(err) os.Exit(1) @@ -110,7 +110,7 @@ func NewVisitorCommand(name string, c v1.VisitorConfigurer, clientCfg *v1.Client fmt.Println(err) os.Exit(1) } - err := startService(clientCfg, nil, []v1.VisitorConfigurer{c}, "", strictConfig) + err := startService(clientCfg, nil, []v1.VisitorConfigurer{c}, "") if err != nil { fmt.Println(err) os.Exit(1) diff --git a/cmd/frpc/sub/root.go b/cmd/frpc/sub/root.go index 855c7abb..c4a5acb6 100644 --- a/cmd/frpc/sub/root.go +++ b/cmd/frpc/sub/root.go @@ -36,17 +36,17 @@ import ( ) var ( - cfgFile string - cfgDir string - showVersion bool - strictConfig bool + cfgFile string + cfgDir string + showVersion bool + strictConfigMode bool ) func init() { rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "./frpc.ini", "config file of frpc") rootCmd.PersistentFlags().StringVarP(&cfgDir, "config_dir", "", "", "config directory, run one frpc service for each file in config directory") rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frpc") - rootCmd.PersistentFlags().BoolVarP(&strictConfig, "strict_config", "", false, "strict config parsing mode") + rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", false, "strict config parsing mode, unknown fields will cause an error") } var rootCmd = &cobra.Command{ @@ -110,7 +110,7 @@ func handleTermSignal(svr *client.Service) { } func runClient(cfgFilePath string) error { - cfg, pxyCfgs, visitorCfgs, isLegacyFormat, err := config.LoadClientConfig(cfgFilePath, strictConfig) + cfg, pxyCfgs, visitorCfgs, isLegacyFormat, err := config.LoadClientConfig(cfgFilePath, strictConfigMode) if err != nil { return err } @@ -122,14 +122,11 @@ func runClient(cfgFilePath string) error { warning, err := validation.ValidateAllClientConfig(cfg, pxyCfgs, visitorCfgs) if warning != nil { fmt.Printf("WARNING: %v\n", warning) - if strictConfig { - return fmt.Errorf("warning: %v", warning) - } } if err != nil { return err } - return startService(cfg, pxyCfgs, visitorCfgs, cfgFilePath, strictConfig) + return startService(cfg, pxyCfgs, visitorCfgs, cfgFilePath) } func startService( @@ -137,7 +134,6 @@ func startService( pxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer, cfgFile string, - strictConfig bool, ) error { log.InitLog(cfg.Log.To, cfg.Log.Level, cfg.Log.MaxDays, cfg.Log.DisablePrintColor) @@ -145,13 +141,12 @@ func startService( log.Info("start frpc service for config file [%s]", cfgFile) defer log.Info("frpc service for config file [%s] stopped", cfgFile) } - svr := client.NewService(cfg, pxyCfgs, visitorCfgs, cfgFile, strictConfig) + svr := client.NewService(cfg, pxyCfgs, visitorCfgs, cfgFile) shouldGracefulClose := cfg.Transport.Protocol == "kcp" || cfg.Transport.Protocol == "quic" // Capture the exit signal if we use kcp or quic. if shouldGracefulClose { go handleTermSignal(svr) } - return svr.Run(context.Background()) } diff --git a/cmd/frpc/sub/verify.go b/cmd/frpc/sub/verify.go index 0e6adca8..1b6ac5a7 100644 --- a/cmd/frpc/sub/verify.go +++ b/cmd/frpc/sub/verify.go @@ -37,7 +37,7 @@ var verifyCmd = &cobra.Command{ return nil } - cliCfg, pxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfig) + cliCfg, pxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode) if err != nil { fmt.Println(err) os.Exit(1) diff --git a/cmd/frps/root.go b/cmd/frps/root.go index adb8852e..1fa57d95 100644 --- a/cmd/frps/root.go +++ b/cmd/frps/root.go @@ -30,9 +30,9 @@ import ( ) var ( - cfgFile string - showVersion bool - strictConfig bool + cfgFile string + showVersion bool + strictConfigMode bool serverCfg v1.ServerConfig ) @@ -40,7 +40,7 @@ var ( func init() { rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file of frps") rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frps") - rootCmd.PersistentFlags().BoolVarP(&strictConfig, "strict_config", "", false, "strict config parsing mode") + rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", false, "strict config parsing mode, unknown fileds will cause error") RegisterServerConfigFlags(rootCmd, &serverCfg) } @@ -60,7 +60,7 @@ var rootCmd = &cobra.Command{ err error ) if cfgFile != "" { - svrCfg, isLegacyFormat, err = config.LoadServerConfig(cfgFile, strictConfig) + svrCfg, isLegacyFormat, err = config.LoadServerConfig(cfgFile, strictConfigMode) if err != nil { fmt.Println(err) os.Exit(1) diff --git a/cmd/frps/verify.go b/cmd/frps/verify.go index 838ac7b6..33ad3f63 100644 --- a/cmd/frps/verify.go +++ b/cmd/frps/verify.go @@ -36,7 +36,7 @@ var verifyCmd = &cobra.Command{ fmt.Println("frps: the configuration file is not specified") return nil } - svrCfg, _, err := config.LoadServerConfig(cfgFile, strictConfig) + svrCfg, _, err := config.LoadServerConfig(cfgFile, strictConfigMode) if err != nil { fmt.Println(err) os.Exit(1) diff --git a/pkg/config/load.go b/pkg/config/load.go index a4013c32..41d1a231 100644 --- a/pkg/config/load.go +++ b/pkg/config/load.go @@ -27,7 +27,7 @@ import ( "github.com/samber/lo" "gopkg.in/ini.v1" "k8s.io/apimachinery/pkg/util/sets" - yaml "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/apimachinery/pkg/util/yaml" "github.com/fatedier/frp/pkg/config/legacy" v1 "github.com/fatedier/frp/pkg/config/v1" @@ -113,7 +113,6 @@ func LoadConfigureFromFile(path string, c any, strict bool) error { func LoadConfigure(b []byte, c any, strict bool) error { var tomlObj interface{} // Try to unmarshal as TOML first; swallow errors from that (assume it's not valid TOML). - // TODO: caller should probably be able to specify the format, so we don't need to swallow errors. if err := toml.Unmarshal(b, &tomlObj); err == nil { b, err = json.Marshal(&tomlObj) if err != nil { diff --git a/pkg/config/load_test.go b/pkg/config/load_test.go index 876d53e4..9bf7dbbc 100644 --- a/pkg/config/load_test.go +++ b/pkg/config/load_test.go @@ -15,6 +15,7 @@ package config import ( + "fmt" "strings" "testing" @@ -56,36 +57,57 @@ const jsonServerContent = ` ` func TestLoadServerConfig(t *testing.T) { - for _, content := range []string{tomlServerContent, yamlServerContent, jsonServerContent} { - svrCfg := v1.ServerConfig{} - err := LoadConfigure([]byte(content), &svrCfg, true) - require := require.New(t) - require.NoError(err) - require.EqualValues("127.0.0.1", svrCfg.BindAddr) - require.EqualValues(7000, svrCfg.KCPBindPort) - require.EqualValues(7001, svrCfg.QUICBindPort) - require.EqualValues(7005, svrCfg.TCPMuxHTTPConnectPort) - require.EqualValues("/abc.html", svrCfg.Custom404Page) - require.EqualValues(10, svrCfg.Transport.TCPKeepAlive) + tests := []struct { + name string + content string + }{ + {"toml", tomlServerContent}, + {"yaml", yamlServerContent}, + {"json", jsonServerContent}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require := require.New(t) + svrCfg := v1.ServerConfig{} + err := LoadConfigure([]byte(test.content), &svrCfg, true) + require.NoError(err) + require.EqualValues("127.0.0.1", svrCfg.BindAddr) + require.EqualValues(7000, svrCfg.KCPBindPort) + require.EqualValues(7001, svrCfg.QUICBindPort) + require.EqualValues(7005, svrCfg.TCPMuxHTTPConnectPort) + require.EqualValues("/abc.html", svrCfg.Custom404Page) + require.EqualValues(10, svrCfg.Transport.TCPKeepAlive) + }) } } // Test that loading in strict mode fails when the config is invalid. -func TestLoadServerConfigErrorMode(t *testing.T) { - for strict := range []bool{false, true} { - for _, content := range []string{tomlServerContent, yamlServerContent, jsonServerContent} { - // Break the content with an innocent typo - brokenContent := strings.Replace(content, "bindAddr", "bindAdur", 1) - svrCfg := v1.ServerConfig{} - err := LoadConfigure([]byte(brokenContent), &svrCfg, strict == 1) - require := require.New(t) - if strict == 1 { - require.ErrorContains(err, "bindAdur") - } else { - require.NoError(err) - // BindAddr didn't get parsed because of the typo. - require.EqualValues("", svrCfg.BindAddr) - } +func TestLoadServerConfigStrictMode(t *testing.T) { + tests := []struct { + name string + content string + }{ + {"toml", tomlServerContent}, + {"yaml", yamlServerContent}, + {"json", jsonServerContent}, + } + + for _, strict := range []bool{false, true} { + for _, test := range tests { + t.Run(fmt.Sprintf("%s-strict-%t", test.name, strict), func(t *testing.T) { + require := require.New(t) + // Break the content with an innocent typo + brokenContent := strings.Replace(test.content, "bindAddr", "bindAdur", 1) + svrCfg := v1.ServerConfig{} + err := LoadConfigure([]byte(brokenContent), &svrCfg, strict) + if strict { + require.ErrorContains(err, "bindAdur") + } else { + require.NoError(err) + // BindAddr didn't get parsed because of the typo. + require.EqualValues("", svrCfg.BindAddr) + } + }) } } } diff --git a/pkg/sdk/client/client.go b/pkg/sdk/client/client.go index c9657905..395063e5 100644 --- a/pkg/sdk/client/client.go +++ b/pkg/sdk/client/client.go @@ -6,6 +6,7 @@ import ( "io" "net" "net/http" + "net/url" "strconv" "strings" @@ -69,8 +70,16 @@ func (c *Client) GetAllProxyStatus() (client.StatusResp, error) { return allStatus, nil } -func (c *Client) Reload() error { - req, err := http.NewRequest("GET", "http://"+c.address+"/api/reload", nil) +func (c *Client) Reload(strictMode bool) error { + v := url.Values{} + if strictMode { + v.Set("strictConfig", "true") + } + queryStr := "" + if len(v) > 0 { + queryStr = "?" + v.Encode() + } + req, err := http.NewRequest("GET", "http://"+c.address+"/api/reload"+queryStr, nil) if err != nil { return err } diff --git a/test/e2e/legacy/basic/client.go b/test/e2e/legacy/basic/client.go index da23db9c..d4862e52 100644 --- a/test/e2e/legacy/basic/client.go +++ b/test/e2e/legacy/basic/client.go @@ -69,7 +69,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() { err = client.UpdateConfig(newClientConf) framework.ExpectNoError(err) - err = client.Reload() + err = client.Reload(true) framework.ExpectNoError(err) time.Sleep(time.Second) diff --git a/test/e2e/v1/basic/client.go b/test/e2e/v1/basic/client.go index 25b99424..b0b258db 100644 --- a/test/e2e/v1/basic/client.go +++ b/test/e2e/v1/basic/client.go @@ -72,7 +72,7 @@ var _ = ginkgo.Describe("[Feature: ClientManage]", func() { err = client.UpdateConfig(newClientConf) framework.ExpectNoError(err) - err = client.Reload() + err = client.Reload(true) framework.ExpectNoError(err) time.Sleep(time.Second) From f5d5a00eefbfc060046a8ff04ad3a116334ef227 Mon Sep 17 00:00:00 2001 From: Aarni Koskela <akx@iki.fi> Date: Wed, 22 Nov 2023 08:30:22 +0200 Subject: [PATCH 07/21] Fix various typos (#3783) --- client/proxy/udp.go | 2 +- client/visitor/visitor.go | 2 +- cmd/frps/root.go | 2 +- conf/frpc_full_example.toml | 2 +- conf/legacy/frpc_legacy_full.ini | 2 +- pkg/config/legacy/client.go | 2 +- pkg/config/legacy/server.go | 2 +- pkg/config/load.go | 2 +- pkg/config/v1/client.go | 2 +- pkg/config/v1/common.go | 2 +- pkg/config/v1/server.go | 2 +- pkg/transport/message.go | 4 ++-- pkg/util/net/http.go | 14 +++++++------- pkg/util/version/version_test.go | 4 ++-- pkg/util/vhost/http.go | 2 +- server/service.go | 2 +- test/e2e/framework/framework.go | 12 ++++++------ test/e2e/legacy/plugin/server.go | 2 +- test/e2e/v1/plugin/server.go | 2 +- 19 files changed, 32 insertions(+), 32 deletions(-) diff --git a/client/proxy/udp.go b/client/proxy/udp.go index 0a5cefcc..d7a790c1 100644 --- a/client/proxy/udp.go +++ b/client/proxy/udp.go @@ -89,7 +89,7 @@ func (pxy *UDPProxy) Close() { func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) { xl := pxy.xl xl.Info("incoming a new work connection for udp proxy, %s", conn.RemoteAddr().String()) - // close resources releated with old workConn + // close resources related with old workConn pxy.Close() var rwc io.ReadWriteCloser = conn diff --git a/client/visitor/visitor.go b/client/visitor/visitor.go index dcd1f7b3..4cfd6106 100644 --- a/client/visitor/visitor.go +++ b/client/visitor/visitor.go @@ -25,7 +25,7 @@ import ( "github.com/fatedier/frp/pkg/util/xlog" ) -// Helper wrapps some functions for visitor to use. +// Helper wraps some functions for visitor to use. type Helper interface { // ConnectServer directly connects to the frp server. ConnectServer() (net.Conn, error) diff --git a/cmd/frps/root.go b/cmd/frps/root.go index 1fa57d95..5f32fe9c 100644 --- a/cmd/frps/root.go +++ b/cmd/frps/root.go @@ -40,7 +40,7 @@ var ( func init() { rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file of frps") rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frps") - rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", false, "strict config parsing mode, unknown fileds will cause error") + rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", false, "strict config parsing mode, unknown fields will cause error") RegisterServerConfigFlags(rootCmd, &serverCfg) } diff --git a/conf/frpc_full_example.toml b/conf/frpc_full_example.toml index bdfc5643..247d0a6a 100644 --- a/conf/frpc_full_example.toml +++ b/conf/frpc_full_example.toml @@ -38,7 +38,7 @@ auth.token = "12345678" # auth.oidc.clientSecret = "" # oidc.audience specifies the audience of the token in OIDC authentication. # auth.oidc.audience = "" -# oidc.scope specifies the permisssions of the token in OIDC authentication if AuthenticationMethod == "oidc". By default, this value is "". +# oidc.scope specifies the permissions of the token in OIDC authentication if AuthenticationMethod == "oidc". By default, this value is "". # auth.oidc.scope = "" # oidc.tokenEndpointURL specifies the URL which implements OIDC Token Endpoint. # It will be used to get an OIDC token. diff --git a/conf/legacy/frpc_legacy_full.ini b/conf/legacy/frpc_legacy_full.ini index f8eca6b7..51ac9c47 100644 --- a/conf/legacy/frpc_legacy_full.ini +++ b/conf/legacy/frpc_legacy_full.ini @@ -56,7 +56,7 @@ oidc_client_secret = # oidc_audience specifies the audience of the token in OIDC authentication if AuthenticationMethod == "oidc". By default, this value is "". oidc_audience = -# oidc_scope specifies the permisssions of the token in OIDC authentication if AuthenticationMethod == "oidc". By default, this value is "". +# oidc_scope specifies the permissions of the token in OIDC authentication if AuthenticationMethod == "oidc". By default, this value is "". oidc_scope = # oidc_token_endpoint_url specifies the URL which implements OIDC Token Endpoint. diff --git a/pkg/config/legacy/client.go b/pkg/config/legacy/client.go index f7257cb5..50f62bef 100644 --- a/pkg/config/legacy/client.go +++ b/pkg/config/legacy/client.go @@ -99,7 +99,7 @@ type ClientCommonConf struct { // the server must have TCP multiplexing enabled as well. By default, this // value is true. TCPMux bool `ini:"tcp_mux" json:"tcp_mux"` - // TCPMuxKeepaliveInterval specifies the keep alive interval for TCP stream multipler. + // TCPMuxKeepaliveInterval specifies the keep alive interval for TCP stream multiplier. // If TCPMux is true, heartbeat of application layer is unnecessary because it can only rely on heartbeat in TCPMux. TCPMuxKeepaliveInterval int64 `ini:"tcp_mux_keepalive_interval" json:"tcp_mux_keepalive_interval"` // User specifies a prefix for proxy names to distinguish them from other diff --git a/pkg/config/legacy/server.go b/pkg/config/legacy/server.go index 797770a3..1279a499 100644 --- a/pkg/config/legacy/server.go +++ b/pkg/config/legacy/server.go @@ -139,7 +139,7 @@ type ServerCommonConf struct { // from a client to share a single TCP connection. By default, this value // is true. TCPMux bool `ini:"tcp_mux" json:"tcp_mux"` - // TCPMuxKeepaliveInterval specifies the keep alive interval for TCP stream multipler. + // TCPMuxKeepaliveInterval specifies the keep alive interval for TCP stream multiplier. // If TCPMux is true, heartbeat of application layer is unnecessary because it can only rely on heartbeat in TCPMux. TCPMuxKeepaliveInterval int64 `ini:"tcp_mux_keepalive_interval" json:"tcp_mux_keepalive_interval"` // TCPKeepAlive specifies the interval between keep-alive probes for an active network connection between frpc and frps. diff --git a/pkg/config/load.go b/pkg/config/load.go index 41d1a231..3014eb35 100644 --- a/pkg/config/load.go +++ b/pkg/config/load.go @@ -221,7 +221,7 @@ func LoadClientConfig(path string, strict bool) ( } // Load additional config from includes. - // legacy ini format alredy handle this in ParseClientConfig. + // legacy ini format already handle this in ParseClientConfig. if len(cliCfg.IncludeConfigFiles) > 0 && !isLegacyFormat { extPxyCfgs, extVisitorCfgs, err := LoadAdditionalClientConfigs(cliCfg.IncludeConfigFiles, isLegacyFormat, strict) if err != nil { diff --git a/pkg/config/v1/client.go b/pkg/config/v1/client.go index 9029aa73..52b87690 100644 --- a/pkg/config/v1/client.go +++ b/pkg/config/v1/client.go @@ -111,7 +111,7 @@ type ClientTransportConfig struct { // the server must have TCP multiplexing enabled as well. By default, this // value is true. TCPMux *bool `json:"tcpMux,omitempty"` - // TCPMuxKeepaliveInterval specifies the keep alive interval for TCP stream multipler. + // TCPMuxKeepaliveInterval specifies the keep alive interval for TCP stream multiplier. // If TCPMux is true, heartbeat of application layer is unnecessary because it can only rely on heartbeat in TCPMux. TCPMuxKeepaliveInterval int64 `json:"tcpMuxKeepaliveInterval,omitempty"` // QUIC protocol options. diff --git a/pkg/config/v1/common.go b/pkg/config/v1/common.go index 422a8082..72c9d036 100644 --- a/pkg/config/v1/common.go +++ b/pkg/config/v1/common.go @@ -83,7 +83,7 @@ type TLSConfig struct { } type LogConfig struct { - // This is destination where frp should wirte the logs. + // This is destination where frp should write the logs. // If "console" is used, logs will be printed to stdout, otherwise, // logs will be written to the specified file. // By default, this value is "console". diff --git a/pkg/config/v1/server.go b/pkg/config/v1/server.go index c42c3eca..e49921e9 100644 --- a/pkg/config/v1/server.go +++ b/pkg/config/v1/server.go @@ -152,7 +152,7 @@ type ServerTransportConfig struct { // is true. // $HideFromDoc TCPMux *bool `json:"tcpMux,omitempty"` - // TCPMuxKeepaliveInterval specifies the keep alive interval for TCP stream multipler. + // TCPMuxKeepaliveInterval specifies the keep alive interval for TCP stream multiplier. // If TCPMux is true, heartbeat of application layer is unnecessary because it can only rely on heartbeat in TCPMux. TCPMuxKeepaliveInterval int64 `json:"tcpMuxKeepaliveInterval,omitempty"` // TCPKeepAlive specifies the interval between keep-alive probes for an active network connection between frpc and frps. diff --git a/pkg/transport/message.go b/pkg/transport/message.go index 7163a8ad..dd43fbdc 100644 --- a/pkg/transport/message.go +++ b/pkg/transport/message.go @@ -29,7 +29,7 @@ type MessageTransporter interface { // Recv(ctx context.Context, laneKey string, msgType string) (Message, error) // Do will first send msg, then recv msg with the same laneKey and specified msgType. Do(ctx context.Context, req msg.Message, laneKey, recvMsgType string) (msg.Message, error) - // Dispatch will dispatch message to releated channel registered in Do function by its message type and laneKey. + // Dispatch will dispatch message to related channel registered in Do function by its message type and laneKey. Dispatch(m msg.Message, laneKey string) bool // Same with Dispatch but with specified message type. DispatchWithType(m msg.Message, msgType, laneKey string) bool @@ -46,7 +46,7 @@ type transporterImpl struct { sendCh chan msg.Message // First key is message type and second key is lane key. - // Dispatch will dispatch message to releated channel by its message type + // Dispatch will dispatch message to related channel by its message type // and lane key. registry map[string]map[string]chan msg.Message mu sync.RWMutex diff --git a/pkg/util/net/http.go b/pkg/util/net/http.go index 1a7da23f..642d1590 100644 --- a/pkg/util/net/http.go +++ b/pkg/util/net/http.go @@ -24,21 +24,21 @@ import ( "github.com/fatedier/frp/pkg/util/util" ) -type HTTPAuthWraper struct { +type HTTPAuthWrapper struct { h http.Handler user string passwd string } -func NewHTTPBasicAuthWraper(h http.Handler, user, passwd string) http.Handler { - return &HTTPAuthWraper{ +func NewHTTPBasicAuthWrapper(h http.Handler, user, passwd string) http.Handler { + return &HTTPAuthWrapper{ h: h, user: user, passwd: passwd, } } -func (aw *HTTPAuthWraper) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (aw *HTTPAuthWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) { user, passwd, hasAuth := r.BasicAuth() if (aw.user == "" && aw.passwd == "") || (hasAuth && user == aw.user && passwd == aw.passwd) { aw.h.ServeHTTP(w, r) @@ -83,11 +83,11 @@ func (authMid *HTTPAuthMiddleware) Middleware(next http.Handler) http.Handler { }) } -type HTTPGzipWraper struct { +type HTTPGzipWrapper struct { h http.Handler } -func (gw *HTTPGzipWraper) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (gw *HTTPGzipWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request) { if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { gw.h.ServeHTTP(w, r) return @@ -100,7 +100,7 @@ func (gw *HTTPGzipWraper) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func MakeHTTPGzipHandler(h http.Handler) http.Handler { - return &HTTPGzipWraper{ + return &HTTPGzipWrapper{ h: h, } } diff --git a/pkg/util/version/version_test.go b/pkg/util/version/version_test.go index 73b96a85..2b4077cf 100644 --- a/pkg/util/version/version_test.go +++ b/pkg/util/version/version_test.go @@ -47,7 +47,7 @@ func TestVersion(t *testing.T) { proto := Proto(Full()) major := Major(Full()) minor := Minor(Full()) - parseVerion := fmt.Sprintf("%d.%d.%d", proto, major, minor) + parseVersion := fmt.Sprintf("%d.%d.%d", proto, major, minor) version := Full() - assert.Equal(parseVerion, version) + assert.Equal(parseVersion, version) } diff --git a/pkg/util/vhost/http.go b/pkg/util/vhost/http.go index 7b914ce9..1a5bea0b 100644 --- a/pkg/util/vhost/http.go +++ b/pkg/util/vhost/http.go @@ -188,7 +188,7 @@ func (rp *HTTPReverseProxy) CheckAuth(domain, location, routeByHTTPUser, user, p return true } -// getVhost trys to get vhost router by route policy. +// getVhost tries to get vhost router by route policy. func (rp *HTTPReverseProxy) getVhost(domain, location, routeByHTTPUser string) (*Router, bool) { findRouter := func(inDomain, inLocation, inRouteByHTTPUser string) (*Router, bool) { vr, ok := rp.vhostRouter.Get(inDomain, inLocation, inRouteByHTTPUser) diff --git a/server/service.go b/server/service.go index 2629b345..7478b97b 100644 --- a/server/service.go +++ b/server/service.go @@ -543,7 +543,7 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login) error if err != nil { xl.Warn("create new controller error: %v", err) // don't return detailed errors to client - return fmt.Errorf("unexpect error when creating new controller") + return fmt.Errorf("unexpected error when creating new controller") } if oldCtl := svr.ctlManager.Add(loginMsg.RunID, ctl); oldCtl != nil { oldCtl.WaitClosed() diff --git a/test/e2e/framework/framework.go b/test/e2e/framework/framework.go index 6a7a655f..f8b8aa03 100644 --- a/test/e2e/framework/framework.go +++ b/test/e2e/framework/framework.go @@ -29,8 +29,8 @@ type Framework struct { // ports used in this framework indexed by port name. usedPorts map[string]int - // record ports alloced by this framework and release them after each test - allocedPorts []int + // record ports allocated by this framework and release them after each test + allocatedPorts []int // portAllocator to alloc port for this test case. portAllocator *port.Allocator @@ -153,11 +153,11 @@ func (f *Framework) AfterEach() { } f.usedPorts = make(map[string]int) - // release alloced ports - for _, port := range f.allocedPorts { + // release allocated ports + for _, port := range f.allocatedPorts { f.portAllocator.Release(port) } - f.allocedPorts = make([]int, 0) + f.allocatedPorts = make([]int, 0) // clear os envs f.osEnvs = make([]string, 0) @@ -237,7 +237,7 @@ func (f *Framework) PortByName(name string) int { func (f *Framework) AllocPort() int { port := f.portAllocator.Get() ExpectTrue(port > 0, "alloc port failed") - f.allocedPorts = append(f.allocedPorts, port) + f.allocatedPorts = append(f.allocatedPorts, port) return port } diff --git a/test/e2e/legacy/plugin/server.go b/test/e2e/legacy/plugin/server.go index 3f14a42d..cf600be2 100644 --- a/test/e2e/legacy/plugin/server.go +++ b/test/e2e/legacy/plugin/server.go @@ -124,7 +124,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() { framework.NewRequestExpect(f).Port(remotePort).Ensure() }) - ginkgo.It("Mofify RemotePort", func() { + ginkgo.It("Modify RemotePort", func() { localPort := f.AllocPort() remotePort := f.AllocPort() handler := func(req *plugin.Request) *plugin.Response { diff --git a/test/e2e/v1/plugin/server.go b/test/e2e/v1/plugin/server.go index 66456f57..b043c57f 100644 --- a/test/e2e/v1/plugin/server.go +++ b/test/e2e/v1/plugin/server.go @@ -129,7 +129,7 @@ var _ = ginkgo.Describe("[Feature: Server-Plugins]", func() { framework.NewRequestExpect(f).Port(remotePort).Ensure() }) - ginkgo.It("Mofify RemotePort", func() { + ginkgo.It("Modify RemotePort", func() { localPort := f.AllocPort() remotePort := f.AllocPort() handler := func(req *plugin.Request) *plugin.Response { From 8b432e179d6789838c22da78014da08c7c81616a Mon Sep 17 00:00:00 2001 From: 0x7fff <4812302+blizard863@users.noreply.github.com> Date: Tue, 14 Nov 2023 15:16:24 +0800 Subject: [PATCH 08/21] feat: ssh client implement (#3671) * feat: frps support ssh * fix: comments * fix: update pkg * fix: remove useless change --------- Co-authored-by: int7 <int7@gmail.com> --- go.mod | 6 +- go.sum | 13 +- pkg/config/v1/server.go | 13 ++ pkg/config/v1/ssh.go | 72 ++++++ pkg/ssh/service.go | 497 ++++++++++++++++++++++++++++++++++++++++ pkg/ssh/vclient.go | 185 +++++++++++++++ server/proxy/proxy.go | 9 +- server/service.go | 124 ++++++++++ 8 files changed, 909 insertions(+), 10 deletions(-) create mode 100644 pkg/config/v1/ssh.go create mode 100644 pkg/ssh/service.go create mode 100644 pkg/ssh/vclient.go diff --git a/go.mod b/go.mod index 8d27e522..8d0055e6 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/samber/lo v1.38.1 github.com/spf13/cobra v1.7.0 github.com/stretchr/testify v1.8.4 + golang.org/x/crypto v0.15.0 golang.org/x/net v0.17.0 golang.org/x/oauth2 v0.10.0 golang.org/x/sync v0.3.0 @@ -64,11 +65,10 @@ require ( github.com/templexxx/cpufeat v0.0.0-20180724012125-cef66df7f161 // indirect github.com/templexxx/xor v0.0.0-20191217153810-f85b25db303b // indirect github.com/tjfoc/gmsm v1.4.1 // indirect - golang.org/x/crypto v0.14.0 // indirect golang.org/x/exp v0.0.0-20221205204356-47842c84f3db // indirect golang.org/x/mod v0.10.0 // indirect - golang.org/x/sys v0.13.0 // indirect - golang.org/x/text v0.13.0 // indirect + golang.org/x/sys v0.14.0 // indirect + golang.org/x/text v0.14.0 // indirect golang.org/x/tools v0.9.3 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.31.0 // indirect diff --git a/go.sum b/go.sum index af509c3f..49cef0b2 100644 --- a/go.sum +++ b/go.sum @@ -157,8 +157,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= -golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= +golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20221205204356-47842c84f3db h1:D/cFflL63o2KSLJIwjlcIt8PR064j/xsmdEJL/YvY/o= golang.org/x/exp v0.0.0-20221205204356-47842c84f3db/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= @@ -210,20 +210,21 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/pkg/config/v1/server.go b/pkg/config/v1/server.go index e49921e9..f562be8e 100644 --- a/pkg/config/v1/server.go +++ b/pkg/config/v1/server.go @@ -16,11 +16,21 @@ package v1 import ( "github.com/samber/lo" + "golang.org/x/crypto/ssh" "github.com/fatedier/frp/pkg/config/types" "github.com/fatedier/frp/pkg/util/util" ) +type SSHTunnelGateway struct { + BindPort int `json:"bindPort,omitempty" validate:"gte=0,lte=65535"` + PrivateKeyFilePath string `json:"privateKeyFilePath,omitempty"` + PublicKeyFilesPath string `json:"publicKeyFilesPath,omitempty"` + + // store all public key file. load all when init + PublicKeyFilesMap map[string]ssh.PublicKey +} + type ServerConfig struct { APIMetadata @@ -31,6 +41,9 @@ type ServerConfig struct { // BindPort specifies the port that the server listens on. By default, this // value is 7000. BindPort int `json:"bindPort,omitempty"` + + SSHTunnelGateway SSHTunnelGateway `json:"sshGatewayConfig,omitempty"` + // KCPBindPort specifies the KCP port that the server listens on. If this // value is 0, the server will not listen for KCP connections. KCPBindPort int `json:"kcpBindPort,omitempty"` diff --git a/pkg/config/v1/ssh.go b/pkg/config/v1/ssh.go new file mode 100644 index 00000000..440305d4 --- /dev/null +++ b/pkg/config/v1/ssh.go @@ -0,0 +1,72 @@ +package v1 + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "os" + "path/filepath" + + "golang.org/x/crypto/ssh" +) + +const ( + // custom define + SSHClientLoginUserPrefix = "_frpc_ssh_client_" +) + +// encodePrivateKeyToPEM encodes Private Key from RSA to PEM format +func GeneratePrivateKey() ([]byte, error) { + privateKey, err := generatePrivateKey() + if err != nil { + return nil, errors.New("gen private key error") + } + + privBlock := pem.Block{ + Type: "RSA PRIVATE KEY", + Headers: nil, + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + } + + return pem.EncodeToMemory(&privBlock), nil +} + +// generatePrivateKey creates a RSA Private Key of specified byte size +func generatePrivateKey() (*rsa.PrivateKey, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return nil, err + } + + err = privateKey.Validate() + if err != nil { + return nil, err + } + return privateKey, nil +} + +func LoadSSHPublicKeyFilesInDir(dirPath string) (map[string]ssh.PublicKey, error) { + fileMap := make(map[string]ssh.PublicKey) + files, err := os.ReadDir(dirPath) + if err != nil { + return nil, err + } + + for _, file := range files { + filePath := filepath.Join(dirPath, file.Name()) + content, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + parsedAuthorizedKey, _, _, _, err := ssh.ParseAuthorizedKey(content) + if err != nil { + continue + } + fileMap[ssh.FingerprintSHA256(parsedAuthorizedKey)] = parsedAuthorizedKey + } + + return fileMap, nil +} diff --git a/pkg/ssh/service.go b/pkg/ssh/service.go new file mode 100644 index 00000000..ce0bc52c --- /dev/null +++ b/pkg/ssh/service.go @@ -0,0 +1,497 @@ +package ssh + +import ( + "encoding/binary" + "errors" + "flag" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + gerror "github.com/fatedier/golib/errors" + "golang.org/x/crypto/ssh" + + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/util/log" +) + +const ( + // ssh protocol define + // https://datatracker.ietf.org/doc/html/rfc4254#page-16 + ChannelTypeServerOpenChannel = "forwarded-tcpip" + RequestTypeForward = "tcpip-forward" + + // golang ssh package define. + // https://pkg.go.dev/golang.org/x/crypto/ssh + RequestTypeHeartbeat = "keepalive@openssh.com" +) + +// 当 proxy 失败会返回该错误 +type VProxyError struct{} + +// ssh protocol define +// https://datatracker.ietf.org/doc/html/rfc4254#page-16 +// parse ssh client cmds input +type forwardedTCPPayload struct { + Addr string + Port uint32 + + // can be default empty value but do not delete it + // because ssh protocol shoule be reserved + OriginAddr string + OriginPort uint32 +} + +// custom define +// parse ssh client cmds input +type CmdPayload struct { + Address string + Port uint32 +} + +// custom define +// with frp control cmds +type ExtraPayload struct { + Type string + + // TODO port can be set by extra message and priority to ssh raw cmd + Address string + Port uint32 +} + +type Service struct { + tcpConn net.Conn + cfg *ssh.ServerConfig + + sshConn *ssh.ServerConn + gChannel <-chan ssh.NewChannel + gReq <-chan *ssh.Request + + addrPayloadCh chan CmdPayload + extraPayloadCh chan ExtraPayload + + proxyPayloadCh chan v1.ProxyConfigurer + replyCh chan interface{} + + closeCh chan struct{} + exit int32 +} + +func NewSSHService( + tcpConn net.Conn, + cfg *ssh.ServerConfig, + proxyPayloadCh chan v1.ProxyConfigurer, + replyCh chan interface{}, +) (ss *Service, err error) { + ss = &Service{ + tcpConn: tcpConn, + cfg: cfg, + + addrPayloadCh: make(chan CmdPayload), + extraPayloadCh: make(chan ExtraPayload), + + proxyPayloadCh: proxyPayloadCh, + replyCh: replyCh, + + closeCh: make(chan struct{}), + exit: 0, + } + + ss.sshConn, ss.gChannel, ss.gReq, err = ssh.NewServerConn(tcpConn, cfg) + if err != nil { + log.Error("ssh handshake error: %v", err) + return nil, err + } + + log.Info("ssh connection success") + + return ss, nil +} + +func (ss *Service) Run() { + go ss.loopGenerateProxy() + go ss.loopParseCmdPayload() + go ss.loopParseExtraPayload() + go ss.loopReply() +} + +func (ss *Service) Exit() <-chan struct{} { + return ss.closeCh +} + +func (ss *Service) Close() { + if atomic.LoadInt32(&ss.exit) == 1 { + return + } + + select { + case <-ss.closeCh: + return + default: + } + + close(ss.closeCh) + close(ss.addrPayloadCh) + close(ss.extraPayloadCh) + + _ = ss.sshConn.Wait() + + ss.sshConn.Close() + ss.tcpConn.Close() + + atomic.StoreInt32(&ss.exit, 1) + + log.Info("ssh service close") +} + +func (ss *Service) loopParseCmdPayload() { + for { + select { + case req, ok := <-ss.gReq: + if !ok { + log.Info("global request is close") + ss.Close() + return + } + + switch req.Type { + case RequestTypeForward: + var addrPayload CmdPayload + if err := ssh.Unmarshal(req.Payload, &addrPayload); err != nil { + log.Error("ssh unmarshal error: %v", err) + return + } + _ = gerror.PanicToError(func() { + ss.addrPayloadCh <- addrPayload + }) + default: + if req.Type == RequestTypeHeartbeat { + log.Debug("ssh heartbeat data") + } else { + log.Info("default req, data: %v", req) + } + } + if req.WantReply { + err := req.Reply(true, nil) + if err != nil { + log.Error("reply to ssh client error: %v", err) + } + } + case <-ss.closeCh: + log.Info("loop parse cmd payload close") + return + } + } +} + +func (ss *Service) loopSendHeartbeat(ch ssh.Channel) { + tk := time.NewTicker(time.Second * 60) + defer tk.Stop() + + for { + select { + case <-tk.C: + ok, err := ch.SendRequest("heartbeat", false, nil) + if err != nil { + log.Error("channel send req error: %v", err) + if err == io.EOF { + ss.Close() + return + } + continue + } + log.Debug("heartbeat send success, ok: %v", ok) + case <-ss.closeCh: + return + } + } +} + +func (ss *Service) loopParseExtraPayload() { + log.Info("loop parse extra payload start") + + for newChannel := range ss.gChannel { + ch, req, err := newChannel.Accept() + if err != nil { + log.Error("channel accept error: %v", err) + return + } + + go ss.loopSendHeartbeat(ch) + + go func(req <-chan *ssh.Request) { + for r := range req { + if len(r.Payload) <= 4 { + log.Info("r.payload is less than 4") + continue + } + if !strings.Contains(string(r.Payload), "tcp") && !strings.Contains(string(r.Payload), "http") { + log.Info("ssh protocol exchange data") + continue + } + + // [4byte data_len|data] + end := 4 + binary.BigEndian.Uint32(r.Payload[:4]) + if end > uint32(len(r.Payload)) { + end = uint32(len(r.Payload)) + } + p := string(r.Payload[4:end]) + + msg, err := parseSSHExtraMessage(p) + if err != nil { + log.Error("parse ssh extra message error: %v, payload: %v", err, r.Payload) + continue + } + _ = gerror.PanicToError(func() { + ss.extraPayloadCh <- msg + }) + return + } + }(req) + } +} + +func (ss *Service) SSHConn() *ssh.ServerConn { + return ss.sshConn +} + +func (ss *Service) TCPConn() net.Conn { + return ss.tcpConn +} + +func (ss *Service) loopReply() { + for { + select { + case <-ss.closeCh: + log.Info("loop reply close") + return + case req := <-ss.replyCh: + switch req.(type) { + case *VProxyError: + log.Error("run frp proxy error, close ssh service") + ss.Close() + default: + // TODO + } + } + } +} + +func (ss *Service) loopGenerateProxy() { + log.Info("loop generate proxy start") + + for { + if atomic.LoadInt32(&ss.exit) == 1 { + return + } + + wg := new(sync.WaitGroup) + wg.Add(2) + + var p1 CmdPayload + var p2 ExtraPayload + + go func() { + defer wg.Done() + for { + select { + case <-ss.closeCh: + return + case p1 = <-ss.addrPayloadCh: + return + } + } + }() + + go func() { + defer wg.Done() + for { + select { + case <-ss.closeCh: + return + case p2 = <-ss.extraPayloadCh: + return + } + } + }() + + wg.Wait() + + if atomic.LoadInt32(&ss.exit) == 1 { + return + } + + switch p2.Type { + case "http": + case "tcp": + ss.proxyPayloadCh <- &v1.TCPProxyConfig{ + ProxyBaseConfig: v1.ProxyBaseConfig{ + Name: fmt.Sprintf("ssh-proxy-%v-%v", ss.tcpConn.RemoteAddr().String(), time.Now().UnixNano()), + Type: p2.Type, + + ProxyBackend: v1.ProxyBackend{ + LocalIP: p1.Address, + }, + }, + RemotePort: int(p1.Port), + } + default: + log.Warn("invalid frp proxy type: %v", p2.Type) + } + } +} + +func parseSSHExtraMessage(s string) (p ExtraPayload, err error) { + sn := len(s) + + log.Info("parse ssh extra message: %v", s) + + ss := strings.Fields(s) + if len(ss) == 0 { + if sn != 0 { + ss = append(ss, s) + } else { + return p, fmt.Errorf("invalid ssh input, args: %v", ss) + } + } + + for i, v := range ss { + ss[i] = strings.TrimSpace(v) + } + + if ss[0] != "tcp" && ss[0] != "http" { + return p, fmt.Errorf("only support tcp/http now") + } + + switch ss[0] { + case "tcp": + tcpCmd, err := ParseTCPCommand(ss) + if err != nil { + return ExtraPayload{}, fmt.Errorf("invalid ssh input: %v", err) + } + + port, _ := strconv.Atoi(tcpCmd.Port) + + p = ExtraPayload{ + Type: "tcp", + Address: tcpCmd.Address, + Port: uint32(port), + } + case "http": + httpCmd, err := ParseHTTPCommand(ss) + if err != nil { + return ExtraPayload{}, fmt.Errorf("invalid ssh input: %v", err) + } + + _ = httpCmd + + p = ExtraPayload{ + Type: "http", + } + } + + return p, nil +} + +type HTTPCommand struct { + Domain string + BasicAuthUser string + BasicAuthPass string +} + +func ParseHTTPCommand(params []string) (*HTTPCommand, error) { + if len(params) < 2 { + return nil, errors.New("invalid HTTP command") + } + + var ( + basicAuth string + domainURL string + basicAuthUser string + basicAuthPass string + ) + + fs := flag.NewFlagSet("http", flag.ContinueOnError) + fs.StringVar(&basicAuth, "basic-auth", "", "") + fs.StringVar(&domainURL, "domain", "", "") + + fs.SetOutput(&nullWriter{}) // Disables usage output + + err := fs.Parse(params[2:]) + if err != nil { + if !errors.Is(err, flag.ErrHelp) { + return nil, err + } + } + + if basicAuth != "" { + authParts := strings.SplitN(basicAuth, ":", 2) + basicAuthUser = authParts[0] + if len(authParts) > 1 { + basicAuthPass = authParts[1] + } + } + + httpCmd := &HTTPCommand{ + Domain: domainURL, + BasicAuthUser: basicAuthUser, + BasicAuthPass: basicAuthPass, + } + return httpCmd, nil +} + +type TCPCommand struct { + Address string + Port string +} + +func ParseTCPCommand(params []string) (*TCPCommand, error) { + if len(params) == 0 || params[0] != "tcp" { + return nil, errors.New("invalid TCP command") + } + + if len(params) == 1 { + return &TCPCommand{}, nil + } + + var ( + address string + port string + ) + + fs := flag.NewFlagSet("tcp", flag.ContinueOnError) + fs.StringVar(&address, "address", "", "The IP address to listen on") + fs.StringVar(&port, "port", "", "The port to listen on") + fs.SetOutput(&nullWriter{}) // Disables usage output + + args := params[1:] + err := fs.Parse(args) + if err != nil { + if !errors.Is(err, flag.ErrHelp) { + return nil, err + } + } + + parsedAddr, err := net.ResolveIPAddr("ip", address) + if err != nil { + return nil, err + } + if _, err := net.LookupPort("tcp", port); err != nil { + return nil, err + } + + tcpCmd := &TCPCommand{ + Address: parsedAddr.String(), + Port: port, + } + return tcpCmd, nil +} + +type nullWriter struct{} + +func (w *nullWriter) Write(p []byte) (n int, err error) { return len(p), nil } diff --git a/pkg/ssh/vclient.go b/pkg/ssh/vclient.go new file mode 100644 index 00000000..e78c8284 --- /dev/null +++ b/pkg/ssh/vclient.go @@ -0,0 +1,185 @@ +package ssh + +import ( + "context" + "fmt" + "net" + "sync/atomic" + "time" + + "golang.org/x/crypto/ssh" + + "github.com/fatedier/frp/pkg/config" + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/msg" + plugin "github.com/fatedier/frp/pkg/plugin/server" + "github.com/fatedier/frp/pkg/util/log" + frp_net "github.com/fatedier/frp/pkg/util/net" + "github.com/fatedier/frp/pkg/util/util" + "github.com/fatedier/frp/pkg/util/xlog" + "github.com/fatedier/frp/server/controller" + "github.com/fatedier/frp/server/proxy" +) + +// VirtualService is a client VirtualService run in frps +type VirtualService struct { + clientCfg v1.ClientCommonConfig + pxyCfg v1.ProxyConfigurer + serverCfg v1.ServerConfig + + sshSvc *Service + + // uniq id got from frps, attach it in loginMsg + runID string + loginMsg *msg.Login + + // All resource managers and controllers + rc *controller.ResourceController + + exit uint32 // 0 means not exit + // SSHService context + ctx context.Context + // call cancel to stop SSHService + cancel context.CancelFunc + + replyCh chan interface{} + pxy proxy.Proxy +} + +func NewVirtualService( + ctx context.Context, + clientCfg v1.ClientCommonConfig, + serverCfg v1.ServerConfig, + logMsg msg.Login, + rc *controller.ResourceController, + pxyCfg v1.ProxyConfigurer, + sshSvc *Service, + replyCh chan interface{}, +) (svr *VirtualService, err error) { + svr = &VirtualService{ + clientCfg: clientCfg, + serverCfg: serverCfg, + rc: rc, + + loginMsg: &logMsg, + + sshSvc: sshSvc, + pxyCfg: pxyCfg, + + ctx: ctx, + exit: 0, + + replyCh: replyCh, + } + + svr.runID, err = util.RandID() + if err != nil { + return nil, err + } + + go svr.loopCheck() + + return +} + +func (svr *VirtualService) Run(ctx context.Context) (err error) { + ctx, cancel := context.WithCancel(ctx) + svr.ctx = xlog.NewContext(ctx, xlog.New()) + svr.cancel = cancel + + remoteAddr, err := svr.RegisterProxy(&msg.NewProxy{ + ProxyName: svr.pxyCfg.(*v1.TCPProxyConfig).Name, + ProxyType: svr.pxyCfg.(*v1.TCPProxyConfig).Type, + RemotePort: svr.pxyCfg.(*v1.TCPProxyConfig).RemotePort, + }) + if err != nil { + return err + } + + log.Info("run a reverse proxy on port: %v", remoteAddr) + + return nil +} + +func (svr *VirtualService) Close() { + svr.GracefulClose(time.Duration(0)) +} + +func (svr *VirtualService) GracefulClose(d time.Duration) { + atomic.StoreUint32(&svr.exit, 1) + svr.pxy.Close() + + if svr.cancel != nil { + svr.cancel() + } + + svr.replyCh <- &VProxyError{} +} + +func (svr *VirtualService) loopCheck() { + <-svr.sshSvc.Exit() + svr.pxy.Close() + log.Info("virtual client service close") +} + +func (svr *VirtualService) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err error) { + var pxyConf v1.ProxyConfigurer + pxyConf, err = config.NewProxyConfigurerFromMsg(pxyMsg, &svr.serverCfg) + if err != nil { + return + } + + // User info + userInfo := plugin.UserInfo{ + User: svr.loginMsg.User, + Metas: svr.loginMsg.Metas, + RunID: svr.runID, + } + + svr.pxy, err = proxy.NewProxy(svr.ctx, &proxy.Options{ + LoginMsg: svr.loginMsg, + UserInfo: userInfo, + Configurer: pxyConf, + ResourceController: svr.rc, + + GetWorkConnFn: svr.GetWorkConn, + PoolCount: 10, + + ServerCfg: &svr.serverCfg, + }) + if err != nil { + return remoteAddr, err + } + + remoteAddr, err = svr.pxy.Run() + if err != nil { + log.Warn("proxy run error: %v", err) + return + } + + defer func() { + if err != nil { + log.Warn("proxy close") + svr.pxy.Close() + } + }() + + return +} + +func (svr *VirtualService) GetWorkConn() (workConn net.Conn, err error) { + // tell ssh client open a new stream for work + payload := forwardedTCPPayload{ + Addr: svr.serverCfg.BindAddr, // TODO refine + Port: uint32(svr.pxyCfg.(*v1.TCPProxyConfig).RemotePort), + } + + channel, reqs, err := svr.sshSvc.SSHConn().OpenChannel(ChannelTypeServerOpenChannel, ssh.Marshal(payload)) + if err != nil { + return nil, fmt.Errorf("open ssh channel error: %v", err) + } + go ssh.DiscardRequests(reqs) + + workConn = frp_net.WrapReadWriteCloserToConn(channel, svr.sshSvc.tcpConn) + return workConn, nil +} diff --git a/server/proxy/proxy.go b/server/proxy/proxy.go index fe6f781b..5ea99f1e 100644 --- a/server/proxy/proxy.go +++ b/server/proxy/proxy.go @@ -21,6 +21,7 @@ import ( "net" "reflect" "strconv" + "strings" "sync" "time" @@ -229,8 +230,14 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) { return } + var workConn net.Conn + // try all connections from the pool - workConn, err := pxy.GetWorkConnFromPool(userConn.RemoteAddr(), userConn.LocalAddr()) + if strings.HasPrefix(pxy.GetLoginMsg().User, v1.SSHClientLoginUserPrefix) { + workConn, err = pxy.getWorkConnFn() + } else { + workConn, err = pxy.GetWorkConnFromPool(userConn.RemoteAddr(), userConn.LocalAddr()) + } if err != nil { return } diff --git a/server/service.go b/server/service.go index 7478b97b..2ca501be 100644 --- a/server/service.go +++ b/server/service.go @@ -18,10 +18,13 @@ import ( "bytes" "context" "crypto/tls" + "errors" "fmt" "io" "net" "net/http" + "os" + "reflect" "strconv" "time" @@ -29,6 +32,7 @@ import ( fmux "github.com/hashicorp/yamux" quic "github.com/quic-go/quic-go" "github.com/samber/lo" + "golang.org/x/crypto/ssh" "github.com/fatedier/frp/assets" "github.com/fatedier/frp/pkg/auth" @@ -37,6 +41,7 @@ import ( "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/nathole" plugin "github.com/fatedier/frp/pkg/plugin/server" + frpssh "github.com/fatedier/frp/pkg/ssh" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/pkg/util/log" utilnet "github.com/fatedier/frp/pkg/util/net" @@ -66,6 +71,10 @@ type Service struct { // Accept connections from client listener net.Listener + // Accept connections using ssh + sshListener net.Listener + sshConfig *ssh.ServerConfig + // Accept connections using kcp kcpListener net.Listener @@ -199,6 +208,67 @@ func NewService(cfg *v1.ServerConfig) (svr *Service, err error) { svr.listener = ln log.Info("frps tcp listen on %s", address) + if cfg.SSHTunnelGateway.BindPort > 0 { + + if cfg.SSHTunnelGateway.PublicKeyFilesPath != "" { + cfg.SSHTunnelGateway.PublicKeyFilesMap, err = v1.LoadSSHPublicKeyFilesInDir(cfg.SSHTunnelGateway.PublicKeyFilesPath) + if err != nil { + return nil, fmt.Errorf("load ssh all public key files error: %v", err) + } + log.Info("load %v public key files success", cfg.SSHTunnelGateway.PublicKeyFilesPath) + } + + svr.sshConfig = &ssh.ServerConfig{ + NoClientAuth: lo.If(cfg.SSHTunnelGateway.PublicKeyFilesPath == "", true).Else(false), + + PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + parsedAuthorizedKey, ok := cfg.SSHTunnelGateway.PublicKeyFilesMap[ssh.FingerprintSHA256(key)] + if !ok { + return nil, errors.New("cannot find public key file") + } + + if key.Type() == parsedAuthorizedKey.Type() && reflect.DeepEqual(parsedAuthorizedKey, key) { + return &ssh.Permissions{ + Extensions: map[string]string{}, + }, nil + } + return nil, fmt.Errorf("unknown public key for %q", conn.User()) + }, + } + + var privateBytes []byte + if cfg.SSHTunnelGateway.PrivateKeyFilePath != "" { + privateBytes, err = os.ReadFile(cfg.SSHTunnelGateway.PrivateKeyFilePath) + if err != nil { + log.Error("Failed to load private key") + return nil, err + } + log.Info("load %v private key file success", cfg.SSHTunnelGateway.PrivateKeyFilePath) + } else { + privateBytes, err = v1.GeneratePrivateKey() + if err != nil { + log.Error("Failed to load private key") + return nil, err + } + log.Info("auto gen private key file success") + } + private, err := ssh.ParsePrivateKey(privateBytes) + if err != nil { + log.Error("Failed to parse private key, error: %v", err) + return nil, err + } + + svr.sshConfig.AddHostKey(private) + + sshAddr := net.JoinHostPort(cfg.BindAddr, strconv.Itoa(cfg.SSHTunnelGateway.BindPort)) + svr.sshListener, err = net.Listen("tcp", sshAddr) + if err != nil { + log.Error("Failed to listen on %v, error: %v", sshAddr, err) + return nil, err + } + log.Info("ssh server listening on %v", sshAddr) + } + // Listen for accepting connections from client using kcp protocol. if cfg.KCPBindPort > 0 { address := net.JoinHostPort(cfg.BindAddr, strconv.Itoa(cfg.KCPBindPort)) @@ -326,6 +396,10 @@ func (svr *Service) Run(ctx context.Context) { svr.ctx = ctx svr.cancel = cancel + if svr.sshListener != nil { + go svr.HandleSSHListener(svr.sshListener) + } + if svr.kcpListener != nil { go svr.HandleListener(svr.kcpListener) } @@ -348,6 +422,10 @@ func (svr *Service) Run(ctx context.Context) { } func (svr *Service) Close() error { + if svr.sshListener != nil { + svr.sshListener.Close() + svr.sshListener = nil + } if svr.kcpListener != nil { svr.kcpListener.Close() svr.kcpListener = nil @@ -493,6 +571,52 @@ func (svr *Service) HandleListener(l net.Listener) { } } +func (svr *Service) HandleSSHListener(listener net.Listener) { + for { + tcpConn, err := listener.Accept() + if err != nil { + log.Error("failed to accept incoming ssh connection (%s)", err) + return + } + log.Info("new tcp conn connected: %v", tcpConn.RemoteAddr().String()) + + pxyPayloadCh := make(chan v1.ProxyConfigurer) + replyCh := make(chan interface{}) + + ss, err := frpssh.NewSSHService(tcpConn, svr.sshConfig, pxyPayloadCh, replyCh) + if err != nil { + log.Error("new ssh service error: %v", err) + continue + } + ss.Run() + + go func() { + for { + pxyCfg := <-pxyPayloadCh + + ctx := context.Background() + + // TODO fill client common config and login msg + vs, err := frpssh.NewVirtualService(ctx, v1.ClientCommonConfig{}, *svr.cfg, + msg.Login{User: v1.SSHClientLoginUserPrefix + tcpConn.RemoteAddr().String()}, + svr.rc, pxyCfg, ss, replyCh) + if err != nil { + log.Error("new virtual service error: %v", err) + ss.Close() + return + } + + err = vs.Run(ctx) + if err != nil { + log.Error("proxy run error: %v", err) + vs.Close() + return + } + } + }() + } +} + func (svr *Service) HandleQUICListener(l *quic.Listener) { // Listen for incoming connections from client. for { From d5b41f1e1485f7205d96ea4522ed7655d145d47e Mon Sep 17 00:00:00 2001 From: fatedier <fatedier@gmail.com> Date: Tue, 21 Nov 2023 11:19:35 +0800 Subject: [PATCH 09/21] sshTunnelGateway refactor (#3784) --- Makefile | 4 +- client/connector.go | 223 +++++++++++ client/control.go | 18 +- client/proxy/proxy.go | 17 +- client/proxy/proxy_manager.go | 12 +- client/proxy/proxy_wrapper.go | 4 + client/proxy/sudp.go | 2 + client/proxy/udp.go | 2 + client/proxy/xtcp.go | 2 + client/service.go | 234 ++--------- cmd/frpc/sub/proxy.go | 7 +- cmd/frps/flags.go | 110 ------ cmd/frps/root.go | 2 +- go.mod | 2 +- go.sum | 6 +- {cmd/frpc/sub => pkg/config}/flags.go | 89 ++++- pkg/config/v1/server.go | 27 +- pkg/config/v1/ssh.go | 72 ---- pkg/plugin/client/http2https.go | 2 + pkg/plugin/client/http_proxy.go | 2 + pkg/plugin/client/https2http.go | 2 + pkg/plugin/client/https2https.go | 2 + pkg/plugin/client/socks5.go | 2 + pkg/plugin/client/static_file.go | 2 + pkg/plugin/client/unix_domain_socket.go | 2 + pkg/ssh/gateway.go | 149 +++++++ pkg/ssh/server.go | 279 +++++++++++++ pkg/ssh/service.go | 497 ------------------------ pkg/ssh/vclient.go | 185 --------- pkg/transport/tls.go | 12 + pkg/util/xlog/xlog.go | 59 ++- pkg/virtual/client.go | 92 +++++ server/proxy/proxy.go | 9 +- server/service.go | 162 ++------ 34 files changed, 1036 insertions(+), 1255 deletions(-) create mode 100644 client/connector.go delete mode 100644 cmd/frps/flags.go rename {cmd/frpc/sub => pkg/config}/flags.go (61%) delete mode 100644 pkg/config/v1/ssh.go create mode 100644 pkg/ssh/gateway.go create mode 100644 pkg/ssh/server.go delete mode 100644 pkg/ssh/service.go delete mode 100644 pkg/ssh/vclient.go create mode 100644 pkg/virtual/client.go diff --git a/Makefile b/Makefile index d94e7c36..f8326891 100644 --- a/Makefile +++ b/Makefile @@ -26,10 +26,10 @@ vet: go vet ./... frps: - env CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/frps ./cmd/frps + env CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -tags frps -o bin/frps ./cmd/frps frpc: - env CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/frpc ./cmd/frpc + env CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -tags frpc -o bin/frpc ./cmd/frpc test: gotest diff --git a/client/connector.go b/client/connector.go new file mode 100644 index 00000000..2ff9b491 --- /dev/null +++ b/client/connector.go @@ -0,0 +1,223 @@ +// Copyright 2023 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 client + +import ( + "context" + "crypto/tls" + "io" + "net" + "strconv" + "strings" + "time" + + libdial "github.com/fatedier/golib/net/dial" + fmux "github.com/hashicorp/yamux" + quic "github.com/quic-go/quic-go" + "github.com/samber/lo" + + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/transport" + utilnet "github.com/fatedier/frp/pkg/util/net" + "github.com/fatedier/frp/pkg/util/xlog" +) + +// Connector is a interface for establishing connections to the server. +type Connector interface { + Open() error + Connect() (net.Conn, error) + Close() error +} + +// defaultConnectorImpl is the default implementation of Connector for normal frpc. +type defaultConnectorImpl struct { + ctx context.Context + cfg *v1.ClientCommonConfig + + muxSession *fmux.Session + quicConn quic.Connection +} + +func NewConnector(ctx context.Context, cfg *v1.ClientCommonConfig) Connector { + return &defaultConnectorImpl{ + ctx: ctx, + cfg: cfg, + } +} + +// Open opens a underlying connection to the server. +// The underlying connection is either a TCP connection or a QUIC connection. +// After the underlying connection is established, you can call Connect() to get a stream. +// If TCPMux isn't enabled, the underlying connection is nil, you will get a new real TCP connection every time you call Connect(). +func (c *defaultConnectorImpl) Open() error { + xl := xlog.FromContextSafe(c.ctx) + + // special for quic + if strings.EqualFold(c.cfg.Transport.Protocol, "quic") { + var tlsConfig *tls.Config + var err error + sn := c.cfg.Transport.TLS.ServerName + if sn == "" { + sn = c.cfg.ServerAddr + } + if lo.FromPtr(c.cfg.Transport.TLS.Enable) { + tlsConfig, err = transport.NewClientTLSConfig( + c.cfg.Transport.TLS.CertFile, + c.cfg.Transport.TLS.KeyFile, + c.cfg.Transport.TLS.TrustedCaFile, + sn) + } else { + tlsConfig, err = transport.NewClientTLSConfig("", "", "", sn) + } + if err != nil { + xl.Warn("fail to build tls configuration, err: %v", err) + return err + } + tlsConfig.NextProtos = []string{"frp"} + + conn, err := quic.DialAddr( + c.ctx, + net.JoinHostPort(c.cfg.ServerAddr, strconv.Itoa(c.cfg.ServerPort)), + tlsConfig, &quic.Config{ + MaxIdleTimeout: time.Duration(c.cfg.Transport.QUIC.MaxIdleTimeout) * time.Second, + MaxIncomingStreams: int64(c.cfg.Transport.QUIC.MaxIncomingStreams), + KeepAlivePeriod: time.Duration(c.cfg.Transport.QUIC.KeepalivePeriod) * time.Second, + }) + if err != nil { + return err + } + c.quicConn = conn + return nil + } + + if !lo.FromPtr(c.cfg.Transport.TCPMux) { + return nil + } + + conn, err := c.realConnect() + if err != nil { + return err + } + + fmuxCfg := fmux.DefaultConfig() + fmuxCfg.KeepAliveInterval = time.Duration(c.cfg.Transport.TCPMuxKeepaliveInterval) * time.Second + fmuxCfg.LogOutput = io.Discard + fmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024 + session, err := fmux.Client(conn, fmuxCfg) + if err != nil { + return err + } + c.muxSession = session + return nil +} + +// Connect returns a stream from the underlying connection, or a new TCP connection if TCPMux isn't enabled. +func (c *defaultConnectorImpl) Connect() (net.Conn, error) { + if c.quicConn != nil { + stream, err := c.quicConn.OpenStreamSync(context.Background()) + if err != nil { + return nil, err + } + return utilnet.QuicStreamToNetConn(stream, c.quicConn), nil + } else if c.muxSession != nil { + stream, err := c.muxSession.OpenStream() + if err != nil { + return nil, err + } + return stream, nil + } + + return c.realConnect() +} + +func (c *defaultConnectorImpl) realConnect() (net.Conn, error) { + xl := xlog.FromContextSafe(c.ctx) + var tlsConfig *tls.Config + var err error + tlsEnable := lo.FromPtr(c.cfg.Transport.TLS.Enable) + if c.cfg.Transport.Protocol == "wss" { + tlsEnable = true + } + if tlsEnable { + sn := c.cfg.Transport.TLS.ServerName + if sn == "" { + sn = c.cfg.ServerAddr + } + + tlsConfig, err = transport.NewClientTLSConfig( + c.cfg.Transport.TLS.CertFile, + c.cfg.Transport.TLS.KeyFile, + c.cfg.Transport.TLS.TrustedCaFile, + sn) + if err != nil { + xl.Warn("fail to build tls configuration, err: %v", err) + return nil, err + } + } + + proxyType, addr, auth, err := libdial.ParseProxyURL(c.cfg.Transport.ProxyURL) + if err != nil { + xl.Error("fail to parse proxy url") + return nil, err + } + dialOptions := []libdial.DialOption{} + protocol := c.cfg.Transport.Protocol + switch protocol { + case "websocket": + protocol = "tcp" + dialOptions = append(dialOptions, libdial.WithAfterHook(libdial.AfterHook{Hook: utilnet.DialHookWebsocket(protocol, "")})) + dialOptions = append(dialOptions, libdial.WithAfterHook(libdial.AfterHook{ + Hook: utilnet.DialHookCustomTLSHeadByte(tlsConfig != nil, lo.FromPtr(c.cfg.Transport.TLS.DisableCustomTLSFirstByte)), + })) + dialOptions = append(dialOptions, libdial.WithTLSConfig(tlsConfig)) + case "wss": + protocol = "tcp" + dialOptions = append(dialOptions, libdial.WithTLSConfigAndPriority(100, tlsConfig)) + // Make sure that if it is wss, the websocket hook is executed after the tls hook. + dialOptions = append(dialOptions, libdial.WithAfterHook(libdial.AfterHook{Hook: utilnet.DialHookWebsocket(protocol, tlsConfig.ServerName), Priority: 110})) + default: + dialOptions = append(dialOptions, libdial.WithAfterHook(libdial.AfterHook{ + Hook: utilnet.DialHookCustomTLSHeadByte(tlsConfig != nil, lo.FromPtr(c.cfg.Transport.TLS.DisableCustomTLSFirstByte)), + })) + dialOptions = append(dialOptions, libdial.WithTLSConfig(tlsConfig)) + } + + if c.cfg.Transport.ConnectServerLocalIP != "" { + dialOptions = append(dialOptions, libdial.WithLocalAddr(c.cfg.Transport.ConnectServerLocalIP)) + } + dialOptions = append(dialOptions, + libdial.WithProtocol(protocol), + libdial.WithTimeout(time.Duration(c.cfg.Transport.DialServerTimeout)*time.Second), + libdial.WithKeepAlive(time.Duration(c.cfg.Transport.DialServerKeepAlive)*time.Second), + libdial.WithProxy(proxyType, addr), + libdial.WithProxyAuth(auth), + ) + conn, err := libdial.DialContext( + c.ctx, + net.JoinHostPort(c.cfg.ServerAddr, strconv.Itoa(c.cfg.ServerPort)), + dialOptions..., + ) + return conn, err +} + +func (c *defaultConnectorImpl) Close() error { + if c.quicConn != nil { + _ = c.quicConn.CloseWithError(0, "") + } + if c.muxSession != nil { + _ = c.muxSession.Close() + } + return nil +} diff --git a/client/control.go b/client/control.go index c8d186ca..be028ec4 100644 --- a/client/control.go +++ b/client/control.go @@ -58,8 +58,8 @@ type Control struct { // control connection. Once conn is closed, the msgDispatcher and the entire Control will exit. conn net.Conn - // use cm to create new connections, which could be real TCP connections or virtual streams. - cm *ConnectionManager + // use connector to create new connections, which could be real TCP connections or virtual streams. + connector Connector doneCh chan struct{} @@ -77,7 +77,7 @@ type Control struct { } func NewControl( - ctx context.Context, runID string, conn net.Conn, cm *ConnectionManager, + ctx context.Context, runID string, conn net.Conn, connector Connector, clientCfg *v1.ClientCommonConfig, pxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer, @@ -92,7 +92,7 @@ func NewControl( runID: runID, pxyCfgs: pxyCfgs, conn: conn, - cm: cm, + connector: connector, doneCh: make(chan struct{}), } ctl.lastPong.Store(time.Now()) @@ -122,6 +122,10 @@ func (ctl *Control) Run() { go ctl.vm.Run() } +func (ctl *Control) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) { + ctl.pm.SetInWorkConnCallback(cb) +} + func (ctl *Control) handleReqWorkConn(_ msg.Message) { xl := ctl.xl workConn, err := ctl.connectServer() @@ -207,7 +211,7 @@ func (ctl *Control) GracefulClose(d time.Duration) error { time.Sleep(d) ctl.conn.Close() - ctl.cm.Close() + ctl.connector.Close() return nil } @@ -218,7 +222,7 @@ func (ctl *Control) Done() <-chan struct{} { // connectServer return a new connection to frps func (ctl *Control) connectServer() (conn net.Conn, err error) { - return ctl.cm.Connect() + return ctl.connector.Connect() } func (ctl *Control) registerMsgHandlers() { @@ -282,7 +286,7 @@ func (ctl *Control) worker() { ctl.pm.Close() ctl.vm.Close() - ctl.cm.Close() + ctl.connector.Close() close(ctl.doneCh) } diff --git a/client/proxy/proxy.go b/client/proxy/proxy.go index 5ba63f94..396539c0 100644 --- a/client/proxy/proxy.go +++ b/client/proxy/proxy.go @@ -47,10 +47,9 @@ func RegisterProxyFactory(proxyConfType reflect.Type, factory func(*BaseProxy, v // Proxy defines how to handle work connections for different proxy type. type Proxy interface { Run() error - // InWorkConn accept work connections registered to server. InWorkConn(net.Conn, *msg.StartWorkConn) - + SetInWorkConnCallback(func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) /* continue */ bool) Close() } @@ -89,7 +88,8 @@ type BaseProxy struct { limiter *rate.Limiter // proxyPlugin is used to handle connections instead of dialing to local service. // It's only validate for TCP protocol now. - proxyPlugin plugin.Plugin + proxyPlugin plugin.Plugin + inWorkConnCallback func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) /* continue */ bool mu sync.RWMutex xl *xlog.Logger @@ -113,7 +113,16 @@ func (pxy *BaseProxy) Close() { } } +func (pxy *BaseProxy) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) { + pxy.inWorkConnCallback = cb +} + func (pxy *BaseProxy) InWorkConn(conn net.Conn, m *msg.StartWorkConn) { + if pxy.inWorkConnCallback != nil { + if !pxy.inWorkConnCallback(pxy.baseCfg, conn, m) { + return + } + } pxy.HandleTCPWorkConnection(conn, m, []byte(pxy.clientCfg.Auth.Token)) } @@ -132,7 +141,7 @@ func (pxy *BaseProxy) HandleTCPWorkConnection(workConn net.Conn, m *msg.StartWor }) } - xl.Trace("handle tcp work connection, use_encryption: %t, use_compression: %t", + xl.Trace("handle tcp work connection, useEncryption: %t, useCompression: %t", baseCfg.Transport.UseEncryption, baseCfg.Transport.UseCompression) if baseCfg.Transport.UseEncryption { remote, err = libio.WithEncryption(remote, encKey) diff --git a/client/proxy/proxy_manager.go b/client/proxy/proxy_manager.go index db66cb26..dadf6481 100644 --- a/client/proxy/proxy_manager.go +++ b/client/proxy/proxy_manager.go @@ -31,8 +31,9 @@ import ( ) type Manager struct { - proxies map[string]*Wrapper - msgTransporter transport.MessageTransporter + proxies map[string]*Wrapper + msgTransporter transport.MessageTransporter + inWorkConnCallback func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool closed bool mu sync.RWMutex @@ -71,6 +72,10 @@ func (pm *Manager) StartProxy(name string, remoteAddr string, serverRespErr stri return nil } +func (pm *Manager) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) { + pm.inWorkConnCallback = cb +} + func (pm *Manager) Close() { pm.mu.Lock() defer pm.mu.Unlock() @@ -146,6 +151,9 @@ func (pm *Manager) Reload(pxyCfgs []v1.ProxyConfigurer) { name := cfg.GetBaseConfig().Name if _, ok := pm.proxies[name]; !ok { pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.HandleEvent, pm.msgTransporter) + if pm.inWorkConnCallback != nil { + pxy.SetInWorkConnCallback(pm.inWorkConnCallback) + } pm.proxies[name] = pxy addPxyNames = append(addPxyNames, name) diff --git a/client/proxy/proxy_wrapper.go b/client/proxy/proxy_wrapper.go index 346c6d07..84f24abb 100644 --- a/client/proxy/proxy_wrapper.go +++ b/client/proxy/proxy_wrapper.go @@ -121,6 +121,10 @@ func NewWrapper( return pw } +func (pw *Wrapper) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) { + pw.pxy.SetInWorkConnCallback(cb) +} + func (pw *Wrapper) SetRunningStatus(remoteAddr string, respErr string) error { pw.mu.Lock() defer pw.mu.Unlock() diff --git a/client/proxy/sudp.go b/client/proxy/sudp.go index e67a3397..f9fe53bc 100644 --- a/client/proxy/sudp.go +++ b/client/proxy/sudp.go @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build !frps + package proxy import ( diff --git a/client/proxy/udp.go b/client/proxy/udp.go index d7a790c1..d8590f68 100644 --- a/client/proxy/udp.go +++ b/client/proxy/udp.go @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build !frps + package proxy import ( diff --git a/client/proxy/xtcp.go b/client/proxy/xtcp.go index 8271099b..b286a931 100644 --- a/client/proxy/xtcp.go +++ b/client/proxy/xtcp.go @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build !frps + package proxy import ( diff --git a/client/service.go b/client/service.go index 66a642c1..7c3cd039 100644 --- a/client/service.go +++ b/client/service.go @@ -16,30 +16,22 @@ package client import ( "context" - "crypto/tls" "errors" "fmt" - "io" "net" "runtime" "strconv" - "strings" "sync" "time" "github.com/fatedier/golib/crypto" - libdial "github.com/fatedier/golib/net/dial" - fmux "github.com/hashicorp/yamux" - quic "github.com/quic-go/quic-go" "github.com/samber/lo" "github.com/fatedier/frp/assets" "github.com/fatedier/frp/pkg/auth" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" - "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/pkg/util/log" - utilnet "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/version" "github.com/fatedier/frp/pkg/util/wait" "github.com/fatedier/frp/pkg/util/xlog" @@ -75,6 +67,9 @@ type Service struct { // call cancel to stop service cancel context.CancelFunc gracefulDuration time.Duration + + connectorCreator func(context.Context, *v1.ClientCommonConfig) Connector + inWorkConnCallback func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool } func NewService( @@ -84,15 +79,24 @@ func NewService( cfgFile string, ) *Service { return &Service{ - authSetter: auth.NewAuthSetter(cfg.Auth), - cfg: cfg, - cfgFile: cfgFile, - pxyCfgs: pxyCfgs, - visitorCfgs: visitorCfgs, - ctx: context.Background(), + authSetter: auth.NewAuthSetter(cfg.Auth), + cfg: cfg, + cfgFile: cfgFile, + pxyCfgs: pxyCfgs, + visitorCfgs: visitorCfgs, + ctx: context.Background(), + connectorCreator: NewConnector, } } +func (svr *Service) SetConnectorCreator(h func(context.Context, *v1.ClientCommonConfig) Connector) { + svr.connectorCreator = h +} + +func (svr *Service) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) { + svr.inWorkConnCallback = cb +} + func (svr *Service) GetController() *Control { svr.ctlMu.RLock() defer svr.ctlMu.RUnlock() @@ -101,7 +105,7 @@ func (svr *Service) GetController() *Control { func (svr *Service) Run(ctx context.Context) error { ctx, cancel := context.WithCancel(ctx) - svr.ctx = xlog.NewContext(ctx, xlog.New()) + svr.ctx = xlog.NewContext(ctx, xlog.FromContextSafe(ctx)) svr.cancel = cancel // set custom DNSServer @@ -173,21 +177,20 @@ func (svr *Service) keepControllerWorking() { // login creates a connection to frps and registers it self as a client // conn: control connection // session: if it's not nil, using tcp mux -func (svr *Service) login() (conn net.Conn, cm *ConnectionManager, err error) { +func (svr *Service) login() (conn net.Conn, connector Connector, err error) { xl := xlog.FromContextSafe(svr.ctx) - cm = NewConnectionManager(svr.ctx, svr.cfg) - - if err = cm.OpenConnection(); err != nil { + connector = svr.connectorCreator(svr.ctx, svr.cfg) + if err = connector.Open(); err != nil { return nil, nil, err } defer func() { if err != nil { - cm.Close() + connector.Close() } }() - conn, err = cm.Connect() + conn, err = connector.Connect() if err != nil { return } @@ -226,8 +229,7 @@ func (svr *Service) login() (conn net.Conn, cm *ConnectionManager, err error) { } svr.runID = loginRespMsg.RunID - xl.ResetPrefixes() - xl.AppendPrefix(svr.runID) + xl.AddPrefix(xlog.LogPrefix{Name: "runID", Value: svr.runID}) xl.Info("login to server success, get run id [%s]", loginRespMsg.RunID) return @@ -239,7 +241,7 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE loginFunc := func() error { xl.Info("try to connect to server...") - conn, cm, err := svr.login() + conn, connector, err := svr.login() if err != nil { xl.Warn("connect to server error: %v", err) if firstLoginExit { @@ -248,13 +250,14 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE return err } - ctl, err := NewControl(svr.ctx, svr.runID, conn, cm, + ctl, err := NewControl(svr.ctx, svr.runID, conn, connector, svr.cfg, svr.pxyCfgs, svr.visitorCfgs, svr.authSetter) if err != nil { conn.Close() xl.Error("NewControl error: %v", err) return err } + ctl.SetInWorkConnCallback(svr.inWorkConnCallback) ctl.Run() // close and replace previous control @@ -314,184 +317,3 @@ func (svr *Service) stop() { svr.ctl = nil } } - -// ConnectionManager is a wrapper for establishing connections to the server. -type ConnectionManager struct { - ctx context.Context - cfg *v1.ClientCommonConfig - - muxSession *fmux.Session - quicConn quic.Connection -} - -func NewConnectionManager(ctx context.Context, cfg *v1.ClientCommonConfig) *ConnectionManager { - return &ConnectionManager{ - ctx: ctx, - cfg: cfg, - } -} - -// OpenConnection opens a underlying connection to the server. -// The underlying connection is either a TCP connection or a QUIC connection. -// After the underlying connection is established, you can call Connect() to get a stream. -// If TCPMux isn't enabled, the underlying connection is nil, you will get a new real TCP connection every time you call Connect(). -func (cm *ConnectionManager) OpenConnection() error { - xl := xlog.FromContextSafe(cm.ctx) - - // special for quic - if strings.EqualFold(cm.cfg.Transport.Protocol, "quic") { - var tlsConfig *tls.Config - var err error - sn := cm.cfg.Transport.TLS.ServerName - if sn == "" { - sn = cm.cfg.ServerAddr - } - if lo.FromPtr(cm.cfg.Transport.TLS.Enable) { - tlsConfig, err = transport.NewClientTLSConfig( - cm.cfg.Transport.TLS.CertFile, - cm.cfg.Transport.TLS.KeyFile, - cm.cfg.Transport.TLS.TrustedCaFile, - sn) - } else { - tlsConfig, err = transport.NewClientTLSConfig("", "", "", sn) - } - if err != nil { - xl.Warn("fail to build tls configuration, err: %v", err) - return err - } - tlsConfig.NextProtos = []string{"frp"} - - conn, err := quic.DialAddr( - cm.ctx, - net.JoinHostPort(cm.cfg.ServerAddr, strconv.Itoa(cm.cfg.ServerPort)), - tlsConfig, &quic.Config{ - MaxIdleTimeout: time.Duration(cm.cfg.Transport.QUIC.MaxIdleTimeout) * time.Second, - MaxIncomingStreams: int64(cm.cfg.Transport.QUIC.MaxIncomingStreams), - KeepAlivePeriod: time.Duration(cm.cfg.Transport.QUIC.KeepalivePeriod) * time.Second, - }) - if err != nil { - return err - } - cm.quicConn = conn - return nil - } - - if !lo.FromPtr(cm.cfg.Transport.TCPMux) { - return nil - } - - conn, err := cm.realConnect() - if err != nil { - return err - } - - fmuxCfg := fmux.DefaultConfig() - fmuxCfg.KeepAliveInterval = time.Duration(cm.cfg.Transport.TCPMuxKeepaliveInterval) * time.Second - fmuxCfg.LogOutput = io.Discard - fmuxCfg.MaxStreamWindowSize = 6 * 1024 * 1024 - session, err := fmux.Client(conn, fmuxCfg) - if err != nil { - return err - } - cm.muxSession = session - return nil -} - -// Connect returns a stream from the underlying connection, or a new TCP connection if TCPMux isn't enabled. -func (cm *ConnectionManager) Connect() (net.Conn, error) { - if cm.quicConn != nil { - stream, err := cm.quicConn.OpenStreamSync(context.Background()) - if err != nil { - return nil, err - } - return utilnet.QuicStreamToNetConn(stream, cm.quicConn), nil - } else if cm.muxSession != nil { - stream, err := cm.muxSession.OpenStream() - if err != nil { - return nil, err - } - return stream, nil - } - - return cm.realConnect() -} - -func (cm *ConnectionManager) realConnect() (net.Conn, error) { - xl := xlog.FromContextSafe(cm.ctx) - var tlsConfig *tls.Config - var err error - tlsEnable := lo.FromPtr(cm.cfg.Transport.TLS.Enable) - if cm.cfg.Transport.Protocol == "wss" { - tlsEnable = true - } - if tlsEnable { - sn := cm.cfg.Transport.TLS.ServerName - if sn == "" { - sn = cm.cfg.ServerAddr - } - - tlsConfig, err = transport.NewClientTLSConfig( - cm.cfg.Transport.TLS.CertFile, - cm.cfg.Transport.TLS.KeyFile, - cm.cfg.Transport.TLS.TrustedCaFile, - sn) - if err != nil { - xl.Warn("fail to build tls configuration, err: %v", err) - return nil, err - } - } - - proxyType, addr, auth, err := libdial.ParseProxyURL(cm.cfg.Transport.ProxyURL) - if err != nil { - xl.Error("fail to parse proxy url") - return nil, err - } - dialOptions := []libdial.DialOption{} - protocol := cm.cfg.Transport.Protocol - switch protocol { - case "websocket": - protocol = "tcp" - dialOptions = append(dialOptions, libdial.WithAfterHook(libdial.AfterHook{Hook: utilnet.DialHookWebsocket(protocol, "")})) - dialOptions = append(dialOptions, libdial.WithAfterHook(libdial.AfterHook{ - Hook: utilnet.DialHookCustomTLSHeadByte(tlsConfig != nil, lo.FromPtr(cm.cfg.Transport.TLS.DisableCustomTLSFirstByte)), - })) - dialOptions = append(dialOptions, libdial.WithTLSConfig(tlsConfig)) - case "wss": - protocol = "tcp" - dialOptions = append(dialOptions, libdial.WithTLSConfigAndPriority(100, tlsConfig)) - // Make sure that if it is wss, the websocket hook is executed after the tls hook. - dialOptions = append(dialOptions, libdial.WithAfterHook(libdial.AfterHook{Hook: utilnet.DialHookWebsocket(protocol, tlsConfig.ServerName), Priority: 110})) - default: - dialOptions = append(dialOptions, libdial.WithAfterHook(libdial.AfterHook{ - Hook: utilnet.DialHookCustomTLSHeadByte(tlsConfig != nil, lo.FromPtr(cm.cfg.Transport.TLS.DisableCustomTLSFirstByte)), - })) - dialOptions = append(dialOptions, libdial.WithTLSConfig(tlsConfig)) - } - - if cm.cfg.Transport.ConnectServerLocalIP != "" { - dialOptions = append(dialOptions, libdial.WithLocalAddr(cm.cfg.Transport.ConnectServerLocalIP)) - } - dialOptions = append(dialOptions, - libdial.WithProtocol(protocol), - libdial.WithTimeout(time.Duration(cm.cfg.Transport.DialServerTimeout)*time.Second), - libdial.WithKeepAlive(time.Duration(cm.cfg.Transport.DialServerKeepAlive)*time.Second), - libdial.WithProxy(proxyType, addr), - libdial.WithProxyAuth(auth), - ) - conn, err := libdial.DialContext( - cm.ctx, - net.JoinHostPort(cm.cfg.ServerAddr, strconv.Itoa(cm.cfg.ServerPort)), - dialOptions..., - ) - return conn, err -} - -func (cm *ConnectionManager) Close() error { - if cm.quicConn != nil { - _ = cm.quicConn.CloseWithError(0, "") - } - if cm.muxSession != nil { - _ = cm.muxSession.Close() - } - return nil -} diff --git a/cmd/frpc/sub/proxy.go b/cmd/frpc/sub/proxy.go index 7ae8d353..96050943 100644 --- a/cmd/frpc/sub/proxy.go +++ b/cmd/frpc/sub/proxy.go @@ -21,6 +21,7 @@ import ( "github.com/samber/lo" "github.com/spf13/cobra" + "github.com/fatedier/frp/pkg/config" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/config/v1/validation" ) @@ -50,8 +51,8 @@ func init() { } clientCfg := v1.ClientCommonConfig{} cmd := NewProxyCommand(string(typ), c, &clientCfg) - RegisterClientCommonConfigFlags(cmd, &clientCfg) - RegisterProxyFlags(cmd, c) + config.RegisterClientCommonConfigFlags(cmd, &clientCfg) + config.RegisterProxyFlags(cmd, c) // add sub command for visitor if lo.Contains(visitorTypes, v1.VisitorType(typ)) { @@ -60,7 +61,7 @@ func init() { panic("visitor type: " + typ + " not support") } visitorCmd := NewVisitorCommand(string(typ), vc, &clientCfg) - RegisterVisitorFlags(visitorCmd, vc) + config.RegisterVisitorFlags(visitorCmd, vc) cmd.AddCommand(visitorCmd) } rootCmd.AddCommand(cmd) diff --git a/cmd/frps/flags.go b/cmd/frps/flags.go deleted file mode 100644 index 50170684..00000000 --- a/cmd/frps/flags.go +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright 2023 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 main - -import ( - "strconv" - - "github.com/spf13/cobra" - - "github.com/fatedier/frp/pkg/config/types" - v1 "github.com/fatedier/frp/pkg/config/v1" -) - -type PortsRangeSliceFlag struct { - V *[]types.PortsRange -} - -func (f *PortsRangeSliceFlag) String() string { - if f.V == nil { - return "" - } - return types.PortsRangeSlice(*f.V).String() -} - -func (f *PortsRangeSliceFlag) Set(s string) error { - slice, err := types.NewPortsRangeSliceFromString(s) - if err != nil { - return err - } - *f.V = slice - return nil -} - -func (f *PortsRangeSliceFlag) Type() string { - return "string" -} - -type BoolFuncFlag struct { - TrueFunc func() - FalseFunc func() - - v bool -} - -func (f *BoolFuncFlag) String() string { - return strconv.FormatBool(f.v) -} - -func (f *BoolFuncFlag) Set(s string) error { - f.v = strconv.FormatBool(f.v) == "true" - - if !f.v { - if f.FalseFunc != nil { - f.FalseFunc() - } - return nil - } - - if f.TrueFunc != nil { - f.TrueFunc() - } - return nil -} - -func (f *BoolFuncFlag) Type() string { - return "bool" -} - -func RegisterServerConfigFlags(cmd *cobra.Command, c *v1.ServerConfig) { - cmd.PersistentFlags().StringVarP(&c.BindAddr, "bind_addr", "", "0.0.0.0", "bind address") - cmd.PersistentFlags().IntVarP(&c.BindPort, "bind_port", "p", 7000, "bind port") - cmd.PersistentFlags().IntVarP(&c.KCPBindPort, "kcp_bind_port", "", 0, "kcp bind udp port") - cmd.PersistentFlags().StringVarP(&c.ProxyBindAddr, "proxy_bind_addr", "", "0.0.0.0", "proxy bind address") - cmd.PersistentFlags().IntVarP(&c.VhostHTTPPort, "vhost_http_port", "", 0, "vhost http port") - cmd.PersistentFlags().IntVarP(&c.VhostHTTPSPort, "vhost_https_port", "", 0, "vhost https port") - cmd.PersistentFlags().Int64VarP(&c.VhostHTTPTimeout, "vhost_http_timeout", "", 60, "vhost http response header timeout") - cmd.PersistentFlags().StringVarP(&c.WebServer.Addr, "dashboard_addr", "", "0.0.0.0", "dashboard address") - cmd.PersistentFlags().IntVarP(&c.WebServer.Port, "dashboard_port", "", 0, "dashboard port") - cmd.PersistentFlags().StringVarP(&c.WebServer.User, "dashboard_user", "", "admin", "dashboard user") - cmd.PersistentFlags().StringVarP(&c.WebServer.Password, "dashboard_pwd", "", "admin", "dashboard password") - cmd.PersistentFlags().BoolVarP(&c.EnablePrometheus, "enable_prometheus", "", false, "enable prometheus dashboard") - cmd.PersistentFlags().StringVarP(&c.Log.To, "log_file", "", "console", "log file") - cmd.PersistentFlags().StringVarP(&c.Log.Level, "log_level", "", "info", "log level") - cmd.PersistentFlags().Int64VarP(&c.Log.MaxDays, "log_max_days", "", 3, "log max days") - cmd.PersistentFlags().BoolVarP(&c.Log.DisablePrintColor, "disable_log_color", "", false, "disable log color in console") - cmd.PersistentFlags().StringVarP(&c.Auth.Token, "token", "t", "", "auth token") - cmd.PersistentFlags().StringVarP(&c.SubDomainHost, "subdomain_host", "", "", "subdomain host") - cmd.PersistentFlags().VarP(&PortsRangeSliceFlag{V: &c.AllowPorts}, "allow_ports", "", "allow ports") - cmd.PersistentFlags().Int64VarP(&c.MaxPortsPerClient, "max_ports_per_client", "", 0, "max ports per client") - cmd.PersistentFlags().BoolVarP(&c.Transport.TLS.Force, "tls_only", "", false, "frps tls only") - - webServerTLS := v1.TLSConfig{} - cmd.PersistentFlags().StringVarP(&webServerTLS.CertFile, "dashboard_tls_cert_file", "", "", "dashboard tls cert file") - cmd.PersistentFlags().StringVarP(&webServerTLS.KeyFile, "dashboard_tls_key_file", "", "", "dashboard tls key file") - cmd.PersistentFlags().VarP(&BoolFuncFlag{ - TrueFunc: func() { c.WebServer.TLS = &webServerTLS }, - }, "dashboard_tls_mode", "", "if enable dashboard tls mode") -} diff --git a/cmd/frps/root.go b/cmd/frps/root.go index 5f32fe9c..0cf8e4e7 100644 --- a/cmd/frps/root.go +++ b/cmd/frps/root.go @@ -42,7 +42,7 @@ func init() { rootCmd.PersistentFlags().BoolVarP(&showVersion, "version", "v", false, "version of frps") rootCmd.PersistentFlags().BoolVarP(&strictConfigMode, "strict_config", "", false, "strict config parsing mode, unknown fields will cause error") - RegisterServerConfigFlags(rootCmd, &serverCfg) + config.RegisterServerConfigFlags(rootCmd, &serverCfg) } var rootCmd = &cobra.Command{ diff --git a/go.mod b/go.mod index 8d0055e6..d11e1ef4 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/quic-go/quic-go v0.37.4 github.com/rodaine/table v1.1.0 github.com/samber/lo v1.38.1 - github.com/spf13/cobra v1.7.0 + github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.8.4 golang.org/x/crypto v0.15.0 golang.org/x/net v0.17.0 diff --git a/go.sum b/go.sum index 49cef0b2..56966be2 100644 --- a/go.sum +++ b/go.sum @@ -16,7 +16,7 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/coreos/go-oidc/v3 v3.6.0 h1:AKVxfYw1Gmkn/w96z0DbT/B/xFnzTd3MkZvWLjF4n/o= github.com/coreos/go-oidc/v3 v3.6.0/go.mod h1:ZpHUsHBucTUj6WOkrP4E20UPynbLZzhTQ1XKCXkxyPc= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -128,8 +128,8 @@ github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUz github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/cmd/frpc/sub/flags.go b/pkg/config/flags.go similarity index 61% rename from cmd/frpc/sub/flags.go rename to pkg/config/flags.go index eb3cc010..0c37e608 100644 --- a/cmd/frpc/sub/flags.go +++ b/pkg/config/flags.go @@ -12,10 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -package sub +package config import ( "fmt" + "strconv" "github.com/spf13/cobra" @@ -123,3 +124,89 @@ func RegisterClientCommonConfigFlags(cmd *cobra.Command, c *v1.ClientCommonConfi c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls") } + +type PortsRangeSliceFlag struct { + V *[]types.PortsRange +} + +func (f *PortsRangeSliceFlag) String() string { + if f.V == nil { + return "" + } + return types.PortsRangeSlice(*f.V).String() +} + +func (f *PortsRangeSliceFlag) Set(s string) error { + slice, err := types.NewPortsRangeSliceFromString(s) + if err != nil { + return err + } + *f.V = slice + return nil +} + +func (f *PortsRangeSliceFlag) Type() string { + return "string" +} + +type BoolFuncFlag struct { + TrueFunc func() + FalseFunc func() + + v bool +} + +func (f *BoolFuncFlag) String() string { + return strconv.FormatBool(f.v) +} + +func (f *BoolFuncFlag) Set(s string) error { + f.v = strconv.FormatBool(f.v) == "true" + + if !f.v { + if f.FalseFunc != nil { + f.FalseFunc() + } + return nil + } + + if f.TrueFunc != nil { + f.TrueFunc() + } + return nil +} + +func (f *BoolFuncFlag) Type() string { + return "bool" +} + +func RegisterServerConfigFlags(cmd *cobra.Command, c *v1.ServerConfig) { + cmd.PersistentFlags().StringVarP(&c.BindAddr, "bind_addr", "", "0.0.0.0", "bind address") + cmd.PersistentFlags().IntVarP(&c.BindPort, "bind_port", "p", 7000, "bind port") + cmd.PersistentFlags().IntVarP(&c.KCPBindPort, "kcp_bind_port", "", 0, "kcp bind udp port") + cmd.PersistentFlags().StringVarP(&c.ProxyBindAddr, "proxy_bind_addr", "", "0.0.0.0", "proxy bind address") + cmd.PersistentFlags().IntVarP(&c.VhostHTTPPort, "vhost_http_port", "", 0, "vhost http port") + cmd.PersistentFlags().IntVarP(&c.VhostHTTPSPort, "vhost_https_port", "", 0, "vhost https port") + cmd.PersistentFlags().Int64VarP(&c.VhostHTTPTimeout, "vhost_http_timeout", "", 60, "vhost http response header timeout") + cmd.PersistentFlags().StringVarP(&c.WebServer.Addr, "dashboard_addr", "", "0.0.0.0", "dashboard address") + cmd.PersistentFlags().IntVarP(&c.WebServer.Port, "dashboard_port", "", 0, "dashboard port") + cmd.PersistentFlags().StringVarP(&c.WebServer.User, "dashboard_user", "", "admin", "dashboard user") + cmd.PersistentFlags().StringVarP(&c.WebServer.Password, "dashboard_pwd", "", "admin", "dashboard password") + cmd.PersistentFlags().BoolVarP(&c.EnablePrometheus, "enable_prometheus", "", false, "enable prometheus dashboard") + cmd.PersistentFlags().StringVarP(&c.Log.To, "log_file", "", "console", "log file") + cmd.PersistentFlags().StringVarP(&c.Log.Level, "log_level", "", "info", "log level") + cmd.PersistentFlags().Int64VarP(&c.Log.MaxDays, "log_max_days", "", 3, "log max days") + cmd.PersistentFlags().BoolVarP(&c.Log.DisablePrintColor, "disable_log_color", "", false, "disable log color in console") + cmd.PersistentFlags().StringVarP(&c.Auth.Token, "token", "t", "", "auth token") + cmd.PersistentFlags().StringVarP(&c.SubDomainHost, "subdomain_host", "", "", "subdomain host") + cmd.PersistentFlags().VarP(&PortsRangeSliceFlag{V: &c.AllowPorts}, "allow_ports", "", "allow ports") + cmd.PersistentFlags().Int64VarP(&c.MaxPortsPerClient, "max_ports_per_client", "", 0, "max ports per client") + cmd.PersistentFlags().BoolVarP(&c.Transport.TLS.Force, "tls_only", "", false, "frps tls only") + + webServerTLS := v1.TLSConfig{} + cmd.PersistentFlags().StringVarP(&webServerTLS.CertFile, "dashboard_tls_cert_file", "", "", "dashboard tls cert file") + cmd.PersistentFlags().StringVarP(&webServerTLS.KeyFile, "dashboard_tls_key_file", "", "", "dashboard tls key file") + cmd.PersistentFlags().VarP(&BoolFuncFlag{ + TrueFunc: func() { c.WebServer.TLS = &webServerTLS }, + }, "dashboard_tls_mode", "", "if enable dashboard tls mode") +} diff --git a/pkg/config/v1/server.go b/pkg/config/v1/server.go index f562be8e..03b05d9d 100644 --- a/pkg/config/v1/server.go +++ b/pkg/config/v1/server.go @@ -16,21 +16,11 @@ package v1 import ( "github.com/samber/lo" - "golang.org/x/crypto/ssh" "github.com/fatedier/frp/pkg/config/types" "github.com/fatedier/frp/pkg/util/util" ) -type SSHTunnelGateway struct { - BindPort int `json:"bindPort,omitempty" validate:"gte=0,lte=65535"` - PrivateKeyFilePath string `json:"privateKeyFilePath,omitempty"` - PublicKeyFilesPath string `json:"publicKeyFilesPath,omitempty"` - - // store all public key file. load all when init - PublicKeyFilesMap map[string]ssh.PublicKey -} - type ServerConfig struct { APIMetadata @@ -41,9 +31,6 @@ type ServerConfig struct { // BindPort specifies the port that the server listens on. By default, this // value is 7000. BindPort int `json:"bindPort,omitempty"` - - SSHTunnelGateway SSHTunnelGateway `json:"sshGatewayConfig,omitempty"` - // KCPBindPort specifies the KCP port that the server listens on. If this // value is 0, the server will not listen for KCP connections. KCPBindPort int `json:"kcpBindPort,omitempty"` @@ -80,6 +67,8 @@ type ServerConfig struct { // value is "", a default page will be displayed. Custom404Page string `json:"custom404Page,omitempty"` + SSHTunnelGateway SSHTunnelGateway `json:"sshTunnelGateway,omitempty"` + WebServer WebServerConfig `json:"webServer,omitempty"` // EnablePrometheus will export prometheus metrics on webserver address // in /metrics api. @@ -114,6 +103,7 @@ func (c *ServerConfig) Complete() { c.Log.Complete() c.Transport.Complete() c.WebServer.Complete() + c.SSHTunnelGateway.Complete() c.BindAddr = util.EmptyOr(c.BindAddr, "0.0.0.0") c.BindPort = util.EmptyOr(c.BindPort, 7000) @@ -202,3 +192,14 @@ type TLSServerConfig struct { TLSConfig } + +type SSHTunnelGateway struct { + BindPort int `json:"bindPort,omitempty"` + PrivateKeyFile string `json:"privateKeyFile,omitempty"` + AutoGenPrivateKeyPath string `json:"autoGenPrivateKeyPath,omitempty"` + AuthorizedKeysFile string `json:"authorizedKeysFile,omitempty"` +} + +func (c *SSHTunnelGateway) Complete() { + c.AutoGenPrivateKeyPath = util.EmptyOr(c.AutoGenPrivateKeyPath, "./.autogen_ssh_key") +} diff --git a/pkg/config/v1/ssh.go b/pkg/config/v1/ssh.go deleted file mode 100644 index 440305d4..00000000 --- a/pkg/config/v1/ssh.go +++ /dev/null @@ -1,72 +0,0 @@ -package v1 - -import ( - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/pem" - "errors" - "os" - "path/filepath" - - "golang.org/x/crypto/ssh" -) - -const ( - // custom define - SSHClientLoginUserPrefix = "_frpc_ssh_client_" -) - -// encodePrivateKeyToPEM encodes Private Key from RSA to PEM format -func GeneratePrivateKey() ([]byte, error) { - privateKey, err := generatePrivateKey() - if err != nil { - return nil, errors.New("gen private key error") - } - - privBlock := pem.Block{ - Type: "RSA PRIVATE KEY", - Headers: nil, - Bytes: x509.MarshalPKCS1PrivateKey(privateKey), - } - - return pem.EncodeToMemory(&privBlock), nil -} - -// generatePrivateKey creates a RSA Private Key of specified byte size -func generatePrivateKey() (*rsa.PrivateKey, error) { - privateKey, err := rsa.GenerateKey(rand.Reader, 4096) - if err != nil { - return nil, err - } - - err = privateKey.Validate() - if err != nil { - return nil, err - } - return privateKey, nil -} - -func LoadSSHPublicKeyFilesInDir(dirPath string) (map[string]ssh.PublicKey, error) { - fileMap := make(map[string]ssh.PublicKey) - files, err := os.ReadDir(dirPath) - if err != nil { - return nil, err - } - - for _, file := range files { - filePath := filepath.Join(dirPath, file.Name()) - content, err := os.ReadFile(filePath) - if err != nil { - return nil, err - } - - parsedAuthorizedKey, _, _, _, err := ssh.ParseAuthorizedKey(content) - if err != nil { - continue - } - fileMap[ssh.FingerprintSHA256(parsedAuthorizedKey)] = parsedAuthorizedKey - } - - return fileMap, nil -} diff --git a/pkg/plugin/client/http2https.go b/pkg/plugin/client/http2https.go index 7f093af1..fd3e44b4 100644 --- a/pkg/plugin/client/http2https.go +++ b/pkg/plugin/client/http2https.go @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build !frps + package plugin import ( diff --git a/pkg/plugin/client/http_proxy.go b/pkg/plugin/client/http_proxy.go index 06c6296a..65abf19d 100644 --- a/pkg/plugin/client/http_proxy.go +++ b/pkg/plugin/client/http_proxy.go @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build !frps + package plugin import ( diff --git a/pkg/plugin/client/https2http.go b/pkg/plugin/client/https2http.go index aa498f3f..4a1c85b9 100644 --- a/pkg/plugin/client/https2http.go +++ b/pkg/plugin/client/https2http.go @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build !frps + package plugin import ( diff --git a/pkg/plugin/client/https2https.go b/pkg/plugin/client/https2https.go index fc38f62b..81386ac6 100644 --- a/pkg/plugin/client/https2https.go +++ b/pkg/plugin/client/https2https.go @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build !frps + package plugin import ( diff --git a/pkg/plugin/client/socks5.go b/pkg/plugin/client/socks5.go index c2e253d2..33e87b53 100644 --- a/pkg/plugin/client/socks5.go +++ b/pkg/plugin/client/socks5.go @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build !frps + package plugin import ( diff --git a/pkg/plugin/client/static_file.go b/pkg/plugin/client/static_file.go index 20b79a09..faf03f7d 100644 --- a/pkg/plugin/client/static_file.go +++ b/pkg/plugin/client/static_file.go @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build !frps + package plugin import ( diff --git a/pkg/plugin/client/unix_domain_socket.go b/pkg/plugin/client/unix_domain_socket.go index f186ec92..df68ffb4 100644 --- a/pkg/plugin/client/unix_domain_socket.go +++ b/pkg/plugin/client/unix_domain_socket.go @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:build !frps + package plugin import ( diff --git a/pkg/ssh/gateway.go b/pkg/ssh/gateway.go new file mode 100644 index 00000000..8f87e998 --- /dev/null +++ b/pkg/ssh/gateway.go @@ -0,0 +1,149 @@ +// Copyright 2023 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 ssh + +import ( + "fmt" + "net" + "os" + "strconv" + "strings" + + "golang.org/x/crypto/ssh" + + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/transport" + "github.com/fatedier/frp/pkg/util/log" + utilnet "github.com/fatedier/frp/pkg/util/net" +) + +type Gateway struct { + bindPort int + ln net.Listener + + serverPeerListener *utilnet.InternalListener + + sshConfig *ssh.ServerConfig +} + +func NewGateway( + cfg v1.SSHTunnelGateway, bindAddr string, + serverPeerListener *utilnet.InternalListener, +) (*Gateway, error) { + sshConfig := &ssh.ServerConfig{} + + // privateKey + var ( + privateKeyBytes []byte + err error + ) + if cfg.PrivateKeyFile != "" { + privateKeyBytes, err = os.ReadFile(cfg.PrivateKeyFile) + } else { + if cfg.AutoGenPrivateKeyPath != "" { + privateKeyBytes, _ = os.ReadFile(cfg.AutoGenPrivateKeyPath) + } + if len(privateKeyBytes) == 0 { + privateKeyBytes, err = transport.NewRandomPrivateKey() + if err == nil && cfg.AutoGenPrivateKeyPath != "" { + err = os.WriteFile(cfg.AutoGenPrivateKeyPath, privateKeyBytes, 0o600) + } + } + } + if err != nil { + return nil, err + } + privateKey, err := ssh.ParsePrivateKey(privateKeyBytes) + if err != nil { + return nil, err + } + sshConfig.AddHostKey(privateKey) + + sshConfig.PublicKeyCallback = func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if cfg.AuthorizedKeysFile == "" { + return &ssh.Permissions{ + Extensions: map[string]string{ + "user": "", + }, + }, nil + } + + authorizedKeysMap, err := loadAuthorizedKeysFromFile(cfg.AuthorizedKeysFile) + if err != nil { + return nil, fmt.Errorf("internal error") + } + + user, ok := authorizedKeysMap[string(key.Marshal())] + if !ok { + return nil, fmt.Errorf("unknown public key for remoteAddr %q", conn.RemoteAddr()) + } + return &ssh.Permissions{ + Extensions: map[string]string{ + "user": user, + }, + }, nil + } + + ln, err := net.Listen("tcp", net.JoinHostPort(bindAddr, strconv.Itoa(cfg.BindPort))) + if err != nil { + return nil, err + } + return &Gateway{ + bindPort: cfg.BindPort, + ln: ln, + serverPeerListener: serverPeerListener, + sshConfig: sshConfig, + }, nil +} + +func (g *Gateway) Run() { + for { + conn, err := g.ln.Accept() + if err != nil { + return + } + go g.handleConn(conn) + } +} + +func (g *Gateway) handleConn(conn net.Conn) { + defer conn.Close() + + ts, err := NewTunnelServer(conn, g.sshConfig, g.serverPeerListener) + if err != nil { + return + } + if err := ts.Run(); err != nil { + log.Error("ssh tunnel server run error: %v", err) + } +} + +func loadAuthorizedKeysFromFile(path string) (map[string]string, error) { + authorizedKeysMap := make(map[string]string) // value is username + authorizedKeysBytes, err := os.ReadFile(path) + if err != nil { + return nil, err + } + for len(authorizedKeysBytes) > 0 { + pubKey, comment, _, rest, err := ssh.ParseAuthorizedKey(authorizedKeysBytes) + if err != nil { + return nil, err + } + + authorizedKeysMap[string(pubKey.Marshal())] = strings.TrimSpace(comment) + authorizedKeysBytes = rest + } + return authorizedKeysMap, nil +} diff --git a/pkg/ssh/server.go b/pkg/ssh/server.go new file mode 100644 index 00000000..13c87b68 --- /dev/null +++ b/pkg/ssh/server.go @@ -0,0 +1,279 @@ +// Copyright 2023 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 ssh + +import ( + "context" + "encoding/binary" + "fmt" + "net" + "strings" + "time" + + libio "github.com/fatedier/golib/io" + "github.com/samber/lo" + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" + + "github.com/fatedier/frp/pkg/config" + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/msg" + utilnet "github.com/fatedier/frp/pkg/util/net" + "github.com/fatedier/frp/pkg/util/util" + "github.com/fatedier/frp/pkg/util/xlog" + "github.com/fatedier/frp/pkg/virtual" +) + +const ( + // https://datatracker.ietf.org/doc/html/rfc4254#page-16 + ChannelTypeServerOpenChannel = "forwarded-tcpip" + RequestTypeForward = "tcpip-forward" +) + +type tcpipForward struct { + Host string + Port uint32 +} + +// https://datatracker.ietf.org/doc/html/rfc4254#page-16 +type forwardedTCPPayload struct { + Addr string + Port uint32 + + // can be default empty value but do not delete it + // because ssh protocol shoule be reserved + OriginAddr string + OriginPort uint32 +} + +type TunnelServer struct { + underlyingConn net.Conn + sshConn *ssh.ServerConn + sc *ssh.ServerConfig + + vc *virtual.Client + serverPeerListener *utilnet.InternalListener + doneCh chan struct{} +} + +func NewTunnelServer(conn net.Conn, sc *ssh.ServerConfig, serverPeerListener *utilnet.InternalListener) (*TunnelServer, error) { + s := &TunnelServer{ + underlyingConn: conn, + sc: sc, + serverPeerListener: serverPeerListener, + doneCh: make(chan struct{}), + } + return s, nil +} + +func (s *TunnelServer) Run() error { + sshConn, channels, requests, err := ssh.NewServerConn(s.underlyingConn, s.sc) + if err != nil { + return err + } + s.sshConn = sshConn + + addr, extraPayload, err := s.waitForwardAddrAndExtraPayload(channels, requests, 3*time.Second) + if err != nil { + return err + } + + clientCfg, pc, err := s.parseClientAndProxyConfigurer(addr, extraPayload) + if err != nil { + return err + } + clientCfg.User = util.EmptyOr(sshConn.Permissions.Extensions["user"], clientCfg.User) + pc.Complete(clientCfg.User) + + s.vc = virtual.NewClient(clientCfg) + // join workConn and ssh channel + s.vc.SetInWorkConnCallback(func(base *v1.ProxyBaseConfig, workConn net.Conn, m *msg.StartWorkConn) bool { + c, err := s.openConn(addr) + if err != nil { + return false + } + libio.Join(c, workConn) + return false + }) + // transfer connection from virtual client to server peer listener + go func() { + l := s.vc.PeerListener() + for { + conn, err := l.Accept() + if err != nil { + return + } + _ = s.serverPeerListener.PutConn(conn) + } + }() + xl := xlog.New().AddPrefix(xlog.LogPrefix{Name: "sshVirtualClient", Value: "sshVirtualClient", Priority: 100}) + ctx := xlog.NewContext(context.Background(), xl) + go func() { + _ = s.vc.Run(ctx) + }() + + s.vc.UpdateProxyConfigurer([]v1.ProxyConfigurer{pc}) + + _ = sshConn.Wait() + _ = sshConn.Close() + s.vc.Close() + close(s.doneCh) + return nil +} + +func (s *TunnelServer) waitForwardAddrAndExtraPayload( + channels <-chan ssh.NewChannel, + requests <-chan *ssh.Request, + timeout time.Duration, +) (*tcpipForward, string, error) { + addrCh := make(chan *tcpipForward, 1) + extraPayloadCh := make(chan string, 1) + + // get forward address + go func() { + addrGot := false + for req := range requests { + switch req.Type { + case RequestTypeForward: + if !addrGot { + payload := tcpipForward{} + if err := ssh.Unmarshal(req.Payload, &payload); err != nil { + return + } + addrGot = true + addrCh <- &payload + } + default: + if req.WantReply { + _ = req.Reply(true, nil) + } + } + } + }() + + // get extra payload + go func() { + for newChannel := range channels { + // extraPayload will send to extraPayloadCh + go s.handleNewChannel(newChannel, extraPayloadCh) + } + }() + + var ( + addr *tcpipForward + extraPayload string + ) + + timer := time.NewTimer(timeout) + defer timer.Stop() + for { + select { + case v := <-addrCh: + addr = v + case extra := <-extraPayloadCh: + extraPayload = extra + case <-timer.C: + return nil, "", fmt.Errorf("get addr and extra payload timeout") + } + if addr != nil && extraPayload != "" { + break + } + } + return addr, extraPayload, nil +} + +func (s *TunnelServer) parseClientAndProxyConfigurer(_ *tcpipForward, extraPayload string) (*v1.ClientCommonConfig, v1.ProxyConfigurer, error) { + cmd := &cobra.Command{} + args := strings.Split(extraPayload, " ") + if len(args) < 1 { + return nil, nil, fmt.Errorf("invalid extra payload") + } + proxyType := strings.TrimSpace(args[0]) + supportTypes := []string{"tcp", "http", "https", "tcpmux", "stcp"} + if !lo.Contains(supportTypes, proxyType) { + return nil, nil, fmt.Errorf("invalid proxy type: %s, support types: %v", proxyType, supportTypes) + } + pc := v1.NewProxyConfigurerByType(v1.ProxyType(proxyType)) + if pc == nil { + return nil, nil, fmt.Errorf("new proxy configurer error") + } + config.RegisterProxyFlags(cmd, pc) + + clientCfg := v1.ClientCommonConfig{} + config.RegisterClientCommonConfigFlags(cmd, &clientCfg) + + if err := cmd.ParseFlags(args); err != nil { + return nil, nil, fmt.Errorf("parse flags from ssh client error: %v", err) + } + return &clientCfg, pc, nil +} + +func (s *TunnelServer) handleNewChannel(channel ssh.NewChannel, extraPayloadCh chan string) { + ch, reqs, err := channel.Accept() + if err != nil { + return + } + go s.keepAlive(ch) + + for req := range reqs { + if req.Type != "exec" { + continue + } + if len(req.Payload) <= 4 { + continue + } + end := 4 + binary.BigEndian.Uint32(req.Payload[:4]) + if len(req.Payload) < int(end) { + continue + } + extraPayload := string(req.Payload[4:end]) + select { + case extraPayloadCh <- extraPayload: + default: + } + } +} + +func (s *TunnelServer) keepAlive(ch ssh.Channel) { + tk := time.NewTicker(time.Second * 30) + defer tk.Stop() + + for { + select { + case <-tk.C: + _, err := ch.SendRequest("heartbeat", false, nil) + if err != nil { + return + } + case <-s.doneCh: + return + } + } +} + +func (s *TunnelServer) openConn(addr *tcpipForward) (net.Conn, error) { + payload := forwardedTCPPayload{ + Addr: addr.Host, + Port: addr.Port, + } + channel, reqs, err := s.sshConn.OpenChannel(ChannelTypeServerOpenChannel, ssh.Marshal(&payload)) + if err != nil { + return nil, fmt.Errorf("open ssh channel error: %v", err) + } + go ssh.DiscardRequests(reqs) + + conn := utilnet.WrapReadWriteCloserToConn(channel, s.underlyingConn) + return conn, nil +} diff --git a/pkg/ssh/service.go b/pkg/ssh/service.go deleted file mode 100644 index ce0bc52c..00000000 --- a/pkg/ssh/service.go +++ /dev/null @@ -1,497 +0,0 @@ -package ssh - -import ( - "encoding/binary" - "errors" - "flag" - "fmt" - "io" - "net" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - - gerror "github.com/fatedier/golib/errors" - "golang.org/x/crypto/ssh" - - v1 "github.com/fatedier/frp/pkg/config/v1" - "github.com/fatedier/frp/pkg/util/log" -) - -const ( - // ssh protocol define - // https://datatracker.ietf.org/doc/html/rfc4254#page-16 - ChannelTypeServerOpenChannel = "forwarded-tcpip" - RequestTypeForward = "tcpip-forward" - - // golang ssh package define. - // https://pkg.go.dev/golang.org/x/crypto/ssh - RequestTypeHeartbeat = "keepalive@openssh.com" -) - -// 当 proxy 失败会返回该错误 -type VProxyError struct{} - -// ssh protocol define -// https://datatracker.ietf.org/doc/html/rfc4254#page-16 -// parse ssh client cmds input -type forwardedTCPPayload struct { - Addr string - Port uint32 - - // can be default empty value but do not delete it - // because ssh protocol shoule be reserved - OriginAddr string - OriginPort uint32 -} - -// custom define -// parse ssh client cmds input -type CmdPayload struct { - Address string - Port uint32 -} - -// custom define -// with frp control cmds -type ExtraPayload struct { - Type string - - // TODO port can be set by extra message and priority to ssh raw cmd - Address string - Port uint32 -} - -type Service struct { - tcpConn net.Conn - cfg *ssh.ServerConfig - - sshConn *ssh.ServerConn - gChannel <-chan ssh.NewChannel - gReq <-chan *ssh.Request - - addrPayloadCh chan CmdPayload - extraPayloadCh chan ExtraPayload - - proxyPayloadCh chan v1.ProxyConfigurer - replyCh chan interface{} - - closeCh chan struct{} - exit int32 -} - -func NewSSHService( - tcpConn net.Conn, - cfg *ssh.ServerConfig, - proxyPayloadCh chan v1.ProxyConfigurer, - replyCh chan interface{}, -) (ss *Service, err error) { - ss = &Service{ - tcpConn: tcpConn, - cfg: cfg, - - addrPayloadCh: make(chan CmdPayload), - extraPayloadCh: make(chan ExtraPayload), - - proxyPayloadCh: proxyPayloadCh, - replyCh: replyCh, - - closeCh: make(chan struct{}), - exit: 0, - } - - ss.sshConn, ss.gChannel, ss.gReq, err = ssh.NewServerConn(tcpConn, cfg) - if err != nil { - log.Error("ssh handshake error: %v", err) - return nil, err - } - - log.Info("ssh connection success") - - return ss, nil -} - -func (ss *Service) Run() { - go ss.loopGenerateProxy() - go ss.loopParseCmdPayload() - go ss.loopParseExtraPayload() - go ss.loopReply() -} - -func (ss *Service) Exit() <-chan struct{} { - return ss.closeCh -} - -func (ss *Service) Close() { - if atomic.LoadInt32(&ss.exit) == 1 { - return - } - - select { - case <-ss.closeCh: - return - default: - } - - close(ss.closeCh) - close(ss.addrPayloadCh) - close(ss.extraPayloadCh) - - _ = ss.sshConn.Wait() - - ss.sshConn.Close() - ss.tcpConn.Close() - - atomic.StoreInt32(&ss.exit, 1) - - log.Info("ssh service close") -} - -func (ss *Service) loopParseCmdPayload() { - for { - select { - case req, ok := <-ss.gReq: - if !ok { - log.Info("global request is close") - ss.Close() - return - } - - switch req.Type { - case RequestTypeForward: - var addrPayload CmdPayload - if err := ssh.Unmarshal(req.Payload, &addrPayload); err != nil { - log.Error("ssh unmarshal error: %v", err) - return - } - _ = gerror.PanicToError(func() { - ss.addrPayloadCh <- addrPayload - }) - default: - if req.Type == RequestTypeHeartbeat { - log.Debug("ssh heartbeat data") - } else { - log.Info("default req, data: %v", req) - } - } - if req.WantReply { - err := req.Reply(true, nil) - if err != nil { - log.Error("reply to ssh client error: %v", err) - } - } - case <-ss.closeCh: - log.Info("loop parse cmd payload close") - return - } - } -} - -func (ss *Service) loopSendHeartbeat(ch ssh.Channel) { - tk := time.NewTicker(time.Second * 60) - defer tk.Stop() - - for { - select { - case <-tk.C: - ok, err := ch.SendRequest("heartbeat", false, nil) - if err != nil { - log.Error("channel send req error: %v", err) - if err == io.EOF { - ss.Close() - return - } - continue - } - log.Debug("heartbeat send success, ok: %v", ok) - case <-ss.closeCh: - return - } - } -} - -func (ss *Service) loopParseExtraPayload() { - log.Info("loop parse extra payload start") - - for newChannel := range ss.gChannel { - ch, req, err := newChannel.Accept() - if err != nil { - log.Error("channel accept error: %v", err) - return - } - - go ss.loopSendHeartbeat(ch) - - go func(req <-chan *ssh.Request) { - for r := range req { - if len(r.Payload) <= 4 { - log.Info("r.payload is less than 4") - continue - } - if !strings.Contains(string(r.Payload), "tcp") && !strings.Contains(string(r.Payload), "http") { - log.Info("ssh protocol exchange data") - continue - } - - // [4byte data_len|data] - end := 4 + binary.BigEndian.Uint32(r.Payload[:4]) - if end > uint32(len(r.Payload)) { - end = uint32(len(r.Payload)) - } - p := string(r.Payload[4:end]) - - msg, err := parseSSHExtraMessage(p) - if err != nil { - log.Error("parse ssh extra message error: %v, payload: %v", err, r.Payload) - continue - } - _ = gerror.PanicToError(func() { - ss.extraPayloadCh <- msg - }) - return - } - }(req) - } -} - -func (ss *Service) SSHConn() *ssh.ServerConn { - return ss.sshConn -} - -func (ss *Service) TCPConn() net.Conn { - return ss.tcpConn -} - -func (ss *Service) loopReply() { - for { - select { - case <-ss.closeCh: - log.Info("loop reply close") - return - case req := <-ss.replyCh: - switch req.(type) { - case *VProxyError: - log.Error("run frp proxy error, close ssh service") - ss.Close() - default: - // TODO - } - } - } -} - -func (ss *Service) loopGenerateProxy() { - log.Info("loop generate proxy start") - - for { - if atomic.LoadInt32(&ss.exit) == 1 { - return - } - - wg := new(sync.WaitGroup) - wg.Add(2) - - var p1 CmdPayload - var p2 ExtraPayload - - go func() { - defer wg.Done() - for { - select { - case <-ss.closeCh: - return - case p1 = <-ss.addrPayloadCh: - return - } - } - }() - - go func() { - defer wg.Done() - for { - select { - case <-ss.closeCh: - return - case p2 = <-ss.extraPayloadCh: - return - } - } - }() - - wg.Wait() - - if atomic.LoadInt32(&ss.exit) == 1 { - return - } - - switch p2.Type { - case "http": - case "tcp": - ss.proxyPayloadCh <- &v1.TCPProxyConfig{ - ProxyBaseConfig: v1.ProxyBaseConfig{ - Name: fmt.Sprintf("ssh-proxy-%v-%v", ss.tcpConn.RemoteAddr().String(), time.Now().UnixNano()), - Type: p2.Type, - - ProxyBackend: v1.ProxyBackend{ - LocalIP: p1.Address, - }, - }, - RemotePort: int(p1.Port), - } - default: - log.Warn("invalid frp proxy type: %v", p2.Type) - } - } -} - -func parseSSHExtraMessage(s string) (p ExtraPayload, err error) { - sn := len(s) - - log.Info("parse ssh extra message: %v", s) - - ss := strings.Fields(s) - if len(ss) == 0 { - if sn != 0 { - ss = append(ss, s) - } else { - return p, fmt.Errorf("invalid ssh input, args: %v", ss) - } - } - - for i, v := range ss { - ss[i] = strings.TrimSpace(v) - } - - if ss[0] != "tcp" && ss[0] != "http" { - return p, fmt.Errorf("only support tcp/http now") - } - - switch ss[0] { - case "tcp": - tcpCmd, err := ParseTCPCommand(ss) - if err != nil { - return ExtraPayload{}, fmt.Errorf("invalid ssh input: %v", err) - } - - port, _ := strconv.Atoi(tcpCmd.Port) - - p = ExtraPayload{ - Type: "tcp", - Address: tcpCmd.Address, - Port: uint32(port), - } - case "http": - httpCmd, err := ParseHTTPCommand(ss) - if err != nil { - return ExtraPayload{}, fmt.Errorf("invalid ssh input: %v", err) - } - - _ = httpCmd - - p = ExtraPayload{ - Type: "http", - } - } - - return p, nil -} - -type HTTPCommand struct { - Domain string - BasicAuthUser string - BasicAuthPass string -} - -func ParseHTTPCommand(params []string) (*HTTPCommand, error) { - if len(params) < 2 { - return nil, errors.New("invalid HTTP command") - } - - var ( - basicAuth string - domainURL string - basicAuthUser string - basicAuthPass string - ) - - fs := flag.NewFlagSet("http", flag.ContinueOnError) - fs.StringVar(&basicAuth, "basic-auth", "", "") - fs.StringVar(&domainURL, "domain", "", "") - - fs.SetOutput(&nullWriter{}) // Disables usage output - - err := fs.Parse(params[2:]) - if err != nil { - if !errors.Is(err, flag.ErrHelp) { - return nil, err - } - } - - if basicAuth != "" { - authParts := strings.SplitN(basicAuth, ":", 2) - basicAuthUser = authParts[0] - if len(authParts) > 1 { - basicAuthPass = authParts[1] - } - } - - httpCmd := &HTTPCommand{ - Domain: domainURL, - BasicAuthUser: basicAuthUser, - BasicAuthPass: basicAuthPass, - } - return httpCmd, nil -} - -type TCPCommand struct { - Address string - Port string -} - -func ParseTCPCommand(params []string) (*TCPCommand, error) { - if len(params) == 0 || params[0] != "tcp" { - return nil, errors.New("invalid TCP command") - } - - if len(params) == 1 { - return &TCPCommand{}, nil - } - - var ( - address string - port string - ) - - fs := flag.NewFlagSet("tcp", flag.ContinueOnError) - fs.StringVar(&address, "address", "", "The IP address to listen on") - fs.StringVar(&port, "port", "", "The port to listen on") - fs.SetOutput(&nullWriter{}) // Disables usage output - - args := params[1:] - err := fs.Parse(args) - if err != nil { - if !errors.Is(err, flag.ErrHelp) { - return nil, err - } - } - - parsedAddr, err := net.ResolveIPAddr("ip", address) - if err != nil { - return nil, err - } - if _, err := net.LookupPort("tcp", port); err != nil { - return nil, err - } - - tcpCmd := &TCPCommand{ - Address: parsedAddr.String(), - Port: port, - } - return tcpCmd, nil -} - -type nullWriter struct{} - -func (w *nullWriter) Write(p []byte) (n int, err error) { return len(p), nil } diff --git a/pkg/ssh/vclient.go b/pkg/ssh/vclient.go deleted file mode 100644 index e78c8284..00000000 --- a/pkg/ssh/vclient.go +++ /dev/null @@ -1,185 +0,0 @@ -package ssh - -import ( - "context" - "fmt" - "net" - "sync/atomic" - "time" - - "golang.org/x/crypto/ssh" - - "github.com/fatedier/frp/pkg/config" - v1 "github.com/fatedier/frp/pkg/config/v1" - "github.com/fatedier/frp/pkg/msg" - plugin "github.com/fatedier/frp/pkg/plugin/server" - "github.com/fatedier/frp/pkg/util/log" - frp_net "github.com/fatedier/frp/pkg/util/net" - "github.com/fatedier/frp/pkg/util/util" - "github.com/fatedier/frp/pkg/util/xlog" - "github.com/fatedier/frp/server/controller" - "github.com/fatedier/frp/server/proxy" -) - -// VirtualService is a client VirtualService run in frps -type VirtualService struct { - clientCfg v1.ClientCommonConfig - pxyCfg v1.ProxyConfigurer - serverCfg v1.ServerConfig - - sshSvc *Service - - // uniq id got from frps, attach it in loginMsg - runID string - loginMsg *msg.Login - - // All resource managers and controllers - rc *controller.ResourceController - - exit uint32 // 0 means not exit - // SSHService context - ctx context.Context - // call cancel to stop SSHService - cancel context.CancelFunc - - replyCh chan interface{} - pxy proxy.Proxy -} - -func NewVirtualService( - ctx context.Context, - clientCfg v1.ClientCommonConfig, - serverCfg v1.ServerConfig, - logMsg msg.Login, - rc *controller.ResourceController, - pxyCfg v1.ProxyConfigurer, - sshSvc *Service, - replyCh chan interface{}, -) (svr *VirtualService, err error) { - svr = &VirtualService{ - clientCfg: clientCfg, - serverCfg: serverCfg, - rc: rc, - - loginMsg: &logMsg, - - sshSvc: sshSvc, - pxyCfg: pxyCfg, - - ctx: ctx, - exit: 0, - - replyCh: replyCh, - } - - svr.runID, err = util.RandID() - if err != nil { - return nil, err - } - - go svr.loopCheck() - - return -} - -func (svr *VirtualService) Run(ctx context.Context) (err error) { - ctx, cancel := context.WithCancel(ctx) - svr.ctx = xlog.NewContext(ctx, xlog.New()) - svr.cancel = cancel - - remoteAddr, err := svr.RegisterProxy(&msg.NewProxy{ - ProxyName: svr.pxyCfg.(*v1.TCPProxyConfig).Name, - ProxyType: svr.pxyCfg.(*v1.TCPProxyConfig).Type, - RemotePort: svr.pxyCfg.(*v1.TCPProxyConfig).RemotePort, - }) - if err != nil { - return err - } - - log.Info("run a reverse proxy on port: %v", remoteAddr) - - return nil -} - -func (svr *VirtualService) Close() { - svr.GracefulClose(time.Duration(0)) -} - -func (svr *VirtualService) GracefulClose(d time.Duration) { - atomic.StoreUint32(&svr.exit, 1) - svr.pxy.Close() - - if svr.cancel != nil { - svr.cancel() - } - - svr.replyCh <- &VProxyError{} -} - -func (svr *VirtualService) loopCheck() { - <-svr.sshSvc.Exit() - svr.pxy.Close() - log.Info("virtual client service close") -} - -func (svr *VirtualService) RegisterProxy(pxyMsg *msg.NewProxy) (remoteAddr string, err error) { - var pxyConf v1.ProxyConfigurer - pxyConf, err = config.NewProxyConfigurerFromMsg(pxyMsg, &svr.serverCfg) - if err != nil { - return - } - - // User info - userInfo := plugin.UserInfo{ - User: svr.loginMsg.User, - Metas: svr.loginMsg.Metas, - RunID: svr.runID, - } - - svr.pxy, err = proxy.NewProxy(svr.ctx, &proxy.Options{ - LoginMsg: svr.loginMsg, - UserInfo: userInfo, - Configurer: pxyConf, - ResourceController: svr.rc, - - GetWorkConnFn: svr.GetWorkConn, - PoolCount: 10, - - ServerCfg: &svr.serverCfg, - }) - if err != nil { - return remoteAddr, err - } - - remoteAddr, err = svr.pxy.Run() - if err != nil { - log.Warn("proxy run error: %v", err) - return - } - - defer func() { - if err != nil { - log.Warn("proxy close") - svr.pxy.Close() - } - }() - - return -} - -func (svr *VirtualService) GetWorkConn() (workConn net.Conn, err error) { - // tell ssh client open a new stream for work - payload := forwardedTCPPayload{ - Addr: svr.serverCfg.BindAddr, // TODO refine - Port: uint32(svr.pxyCfg.(*v1.TCPProxyConfig).RemotePort), - } - - channel, reqs, err := svr.sshSvc.SSHConn().OpenChannel(ChannelTypeServerOpenChannel, ssh.Marshal(payload)) - if err != nil { - return nil, fmt.Errorf("open ssh channel error: %v", err) - } - go ssh.DiscardRequests(reqs) - - workConn = frp_net.WrapReadWriteCloserToConn(channel, svr.sshSvc.tcpConn) - return workConn, nil -} diff --git a/pkg/transport/tls.go b/pkg/transport/tls.go index d92b1a82..5bc75921 100644 --- a/pkg/transport/tls.go +++ b/pkg/transport/tls.go @@ -128,3 +128,15 @@ func NewClientTLSConfig(certPath, keyPath, caPath, serverName string) (*tls.Conf return base, nil } + +func NewRandomPrivateKey() ([]byte, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + keyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + return keyPEM, nil +} diff --git a/pkg/util/xlog/xlog.go b/pkg/util/xlog/xlog.go index b5746f9d..7b69dcaf 100644 --- a/pkg/util/xlog/xlog.go +++ b/pkg/util/xlog/xlog.go @@ -15,40 +15,81 @@ package xlog import ( + "sort" + "github.com/fatedier/frp/pkg/util/log" ) +type LogPrefix struct { + // Name is the name of the prefix, it won't be displayed in log but used to identify the prefix. + Name string + // Value is the value of the prefix, it will be displayed in log. + Value string + // The prefix with higher priority will be displayed first, default is 10. + Priority int +} + // Logger is not thread safety for operations on prefix type Logger struct { - prefixes []string + prefixes []LogPrefix prefixString string } func New() *Logger { return &Logger{ - prefixes: make([]string, 0), + prefixes: make([]LogPrefix, 0), } } -func (l *Logger) ResetPrefixes() (old []string) { +func (l *Logger) ResetPrefixes() (old []LogPrefix) { old = l.prefixes - l.prefixes = make([]string, 0) + l.prefixes = make([]LogPrefix, 0) l.prefixString = "" return } func (l *Logger) AppendPrefix(prefix string) *Logger { - l.prefixes = append(l.prefixes, prefix) - l.prefixString += "[" + prefix + "] " + return l.AddPrefix(LogPrefix{ + Name: prefix, + Value: prefix, + Priority: 10, + }) +} + +func (l *Logger) AddPrefix(prefix LogPrefix) *Logger { + found := false + if prefix.Priority <= 0 { + prefix.Priority = 10 + } + for _, p := range l.prefixes { + if p.Name == prefix.Name { + found = true + p.Value = prefix.Value + p.Priority = prefix.Priority + } + } + if !found { + l.prefixes = append(l.prefixes, prefix) + } + l.renderPrefixString() return l } +func (l *Logger) renderPrefixString() { + sort.SliceStable(l.prefixes, func(i, j int) bool { + return l.prefixes[i].Priority < l.prefixes[j].Priority + }) + l.prefixString = "" + for _, v := range l.prefixes { + l.prefixString += "[" + v.Value + "] " + } +} + func (l *Logger) Spawn() *Logger { nl := New() - for _, v := range l.prefixes { - nl.AppendPrefix(v) - } + nl.prefixes = append(nl.prefixes, l.prefixes...) + nl.renderPrefixString() return nl } diff --git a/pkg/virtual/client.go b/pkg/virtual/client.go new file mode 100644 index 00000000..d0369a1a --- /dev/null +++ b/pkg/virtual/client.go @@ -0,0 +1,92 @@ +// Copyright 2023 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 virtual + +import ( + "context" + "net" + + "github.com/fatedier/frp/client" + v1 "github.com/fatedier/frp/pkg/config/v1" + "github.com/fatedier/frp/pkg/msg" + utilnet "github.com/fatedier/frp/pkg/util/net" +) + +type Client struct { + l *utilnet.InternalListener + svr *client.Service +} + +func NewClient(cfg *v1.ClientCommonConfig) *Client { + cfg.Complete() + + ln := utilnet.NewInternalListener() + + svr := client.NewService(cfg, nil, nil, "") + svr.SetConnectorCreator(func(context.Context, *v1.ClientCommonConfig) client.Connector { + return &pipeConnector{ + peerListener: ln, + } + }) + + return &Client{ + l: ln, + svr: svr, + } +} + +func (c *Client) PeerListener() net.Listener { + return c.l +} + +func (c *Client) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) { + c.svr.SetInWorkConnCallback(cb) +} + +func (c *Client) UpdateProxyConfigurer(proxyCfgs []v1.ProxyConfigurer) { + _ = c.svr.ReloadConf(proxyCfgs, nil) +} + +func (c *Client) Run(ctx context.Context) error { + return c.svr.Run(ctx) +} + +func (c *Client) Close() { + c.l.Close() + c.svr.Close() +} + +type pipeConnector struct { + peerListener *utilnet.InternalListener +} + +func (pc *pipeConnector) Open() error { + return nil +} + +func (pc *pipeConnector) Connect() (net.Conn, error) { + c1, c2 := net.Pipe() + if err := pc.peerListener.PutConn(c1); err != nil { + c1.Close() + c2.Close() + return nil, err + } + return c2, nil +} + +func (pc *pipeConnector) Close() error { + pc.peerListener.Close() + return nil +} diff --git a/server/proxy/proxy.go b/server/proxy/proxy.go index 5ea99f1e..fe6f781b 100644 --- a/server/proxy/proxy.go +++ b/server/proxy/proxy.go @@ -21,7 +21,6 @@ import ( "net" "reflect" "strconv" - "strings" "sync" "time" @@ -230,14 +229,8 @@ func (pxy *BaseProxy) handleUserTCPConnection(userConn net.Conn) { return } - var workConn net.Conn - // try all connections from the pool - if strings.HasPrefix(pxy.GetLoginMsg().User, v1.SSHClientLoginUserPrefix) { - workConn, err = pxy.getWorkConnFn() - } else { - workConn, err = pxy.GetWorkConnFromPool(userConn.RemoteAddr(), userConn.LocalAddr()) - } + workConn, err := pxy.GetWorkConnFromPool(userConn.RemoteAddr(), userConn.LocalAddr()) if err != nil { return } diff --git a/server/service.go b/server/service.go index 2ca501be..02efec91 100644 --- a/server/service.go +++ b/server/service.go @@ -18,13 +18,10 @@ import ( "bytes" "context" "crypto/tls" - "errors" "fmt" "io" "net" "net/http" - "os" - "reflect" "strconv" "time" @@ -32,7 +29,6 @@ import ( fmux "github.com/hashicorp/yamux" quic "github.com/quic-go/quic-go" "github.com/samber/lo" - "golang.org/x/crypto/ssh" "github.com/fatedier/frp/assets" "github.com/fatedier/frp/pkg/auth" @@ -41,7 +37,7 @@ import ( "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/nathole" plugin "github.com/fatedier/frp/pkg/plugin/server" - frpssh "github.com/fatedier/frp/pkg/ssh" + "github.com/fatedier/frp/pkg/ssh" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/pkg/util/log" utilnet "github.com/fatedier/frp/pkg/util/net" @@ -71,10 +67,6 @@ type Service struct { // Accept connections from client listener net.Listener - // Accept connections using ssh - sshListener net.Listener - sshConfig *ssh.ServerConfig - // Accept connections using kcp kcpListener net.Listener @@ -87,6 +79,8 @@ type Service struct { // Accept frp tls connections tlsListener net.Listener + virtualListener *utilnet.InternalListener + // Manage all controllers ctlManager *ControlManager @@ -102,6 +96,8 @@ type Service struct { // All resource managers and controllers rc *controller.ResourceController + sshTunnelGateway *ssh.Gateway + // Verifies authentication based on selected method authVerifier auth.Verifier @@ -133,6 +129,7 @@ func NewService(cfg *v1.ServerConfig) (svr *Service, err error) { TCPPortManager: ports.NewManager("tcp", cfg.ProxyBindAddr, cfg.AllowPorts), UDPPortManager: ports.NewManager("udp", cfg.ProxyBindAddr, cfg.AllowPorts), }, + virtualListener: utilnet.NewInternalListener(), httpVhostRouter: vhost.NewRouters(), authVerifier: auth.NewAuthVerifier(cfg.Auth), tlsConfig: tlsConfig, @@ -208,67 +205,6 @@ func NewService(cfg *v1.ServerConfig) (svr *Service, err error) { svr.listener = ln log.Info("frps tcp listen on %s", address) - if cfg.SSHTunnelGateway.BindPort > 0 { - - if cfg.SSHTunnelGateway.PublicKeyFilesPath != "" { - cfg.SSHTunnelGateway.PublicKeyFilesMap, err = v1.LoadSSHPublicKeyFilesInDir(cfg.SSHTunnelGateway.PublicKeyFilesPath) - if err != nil { - return nil, fmt.Errorf("load ssh all public key files error: %v", err) - } - log.Info("load %v public key files success", cfg.SSHTunnelGateway.PublicKeyFilesPath) - } - - svr.sshConfig = &ssh.ServerConfig{ - NoClientAuth: lo.If(cfg.SSHTunnelGateway.PublicKeyFilesPath == "", true).Else(false), - - PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { - parsedAuthorizedKey, ok := cfg.SSHTunnelGateway.PublicKeyFilesMap[ssh.FingerprintSHA256(key)] - if !ok { - return nil, errors.New("cannot find public key file") - } - - if key.Type() == parsedAuthorizedKey.Type() && reflect.DeepEqual(parsedAuthorizedKey, key) { - return &ssh.Permissions{ - Extensions: map[string]string{}, - }, nil - } - return nil, fmt.Errorf("unknown public key for %q", conn.User()) - }, - } - - var privateBytes []byte - if cfg.SSHTunnelGateway.PrivateKeyFilePath != "" { - privateBytes, err = os.ReadFile(cfg.SSHTunnelGateway.PrivateKeyFilePath) - if err != nil { - log.Error("Failed to load private key") - return nil, err - } - log.Info("load %v private key file success", cfg.SSHTunnelGateway.PrivateKeyFilePath) - } else { - privateBytes, err = v1.GeneratePrivateKey() - if err != nil { - log.Error("Failed to load private key") - return nil, err - } - log.Info("auto gen private key file success") - } - private, err := ssh.ParsePrivateKey(privateBytes) - if err != nil { - log.Error("Failed to parse private key, error: %v", err) - return nil, err - } - - svr.sshConfig.AddHostKey(private) - - sshAddr := net.JoinHostPort(cfg.BindAddr, strconv.Itoa(cfg.SSHTunnelGateway.BindPort)) - svr.sshListener, err = net.Listen("tcp", sshAddr) - if err != nil { - log.Error("Failed to listen on %v, error: %v", sshAddr, err) - return nil, err - } - log.Info("ssh server listening on %v", sshAddr) - } - // Listen for accepting connections from client using kcp protocol. if cfg.KCPBindPort > 0 { address := net.JoinHostPort(cfg.BindAddr, strconv.Itoa(cfg.KCPBindPort)) @@ -293,7 +229,17 @@ func NewService(cfg *v1.ServerConfig) (svr *Service, err error) { err = fmt.Errorf("listen on quic udp address %s error: %v", address, err) return } - log.Info("frps quic listen on quic %s", address) + log.Info("frps quic listen on %s", address) + } + + if cfg.SSHTunnelGateway.BindPort > 0 { + sshGateway, err := ssh.NewGateway(cfg.SSHTunnelGateway, cfg.ProxyBindAddr, svr.virtualListener) + if err != nil { + err = fmt.Errorf("create ssh gateway error: %v", err) + return nil, err + } + svr.sshTunnelGateway = sshGateway + log.Info("frps sshTunnelGateway listen on port %d", cfg.SSHTunnelGateway.BindPort) } // Listen for accepting connections from client using websocket protocol. @@ -396,23 +342,26 @@ func (svr *Service) Run(ctx context.Context) { svr.ctx = ctx svr.cancel = cancel - if svr.sshListener != nil { - go svr.HandleSSHListener(svr.sshListener) - } + go svr.HandleListener(svr.virtualListener, true) if svr.kcpListener != nil { - go svr.HandleListener(svr.kcpListener) + go svr.HandleListener(svr.kcpListener, false) } if svr.quicListener != nil { go svr.HandleQUICListener(svr.quicListener) } - go svr.HandleListener(svr.websocketListener) - go svr.HandleListener(svr.tlsListener) + go svr.HandleListener(svr.websocketListener, false) + go svr.HandleListener(svr.tlsListener, false) if svr.rc.NatHoleController != nil { go svr.rc.NatHoleController.CleanWorker(svr.ctx) } - svr.HandleListener(svr.listener) + + if svr.sshTunnelGateway != nil { + go svr.sshTunnelGateway.Run() + } + + svr.HandleListener(svr.listener, false) <-svr.ctx.Done() // service context may not be canceled by svr.Close(), we should call it here to release resources @@ -422,10 +371,6 @@ func (svr *Service) Run(ctx context.Context) { } func (svr *Service) Close() error { - if svr.sshListener != nil { - svr.sshListener.Close() - svr.sshListener = nil - } if svr.kcpListener != nil { svr.kcpListener.Close() svr.kcpListener = nil @@ -516,7 +461,7 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn) { } } -func (svr *Service) HandleListener(l net.Listener) { +func (svr *Service) HandleListener(l net.Listener, internal bool) { // Listen for incoming connections from client. for { c, err := l.Accept() @@ -532,8 +477,9 @@ func (svr *Service) HandleListener(l net.Listener) { log.Trace("start check TLS connection...") originConn := c + forceTLS := svr.cfg.Transport.TLS.Force && !internal var isTLS, custom bool - c, isTLS, custom, err = utilnet.CheckAndEnableTLSServerConnWithTimeout(c, svr.tlsConfig, svr.cfg.Transport.TLS.Force, connReadTimeout) + c, isTLS, custom, err = utilnet.CheckAndEnableTLSServerConnWithTimeout(c, svr.tlsConfig, forceTLS, connReadTimeout) if err != nil { log.Warn("CheckAndEnableTLSServerConnWithTimeout error: %v", err) originConn.Close() @@ -543,7 +489,7 @@ func (svr *Service) HandleListener(l net.Listener) { // Start a new goroutine to handle connection. go func(ctx context.Context, frpConn net.Conn) { - if lo.FromPtr(svr.cfg.Transport.TCPMux) { + 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 @@ -571,52 +517,6 @@ func (svr *Service) HandleListener(l net.Listener) { } } -func (svr *Service) HandleSSHListener(listener net.Listener) { - for { - tcpConn, err := listener.Accept() - if err != nil { - log.Error("failed to accept incoming ssh connection (%s)", err) - return - } - log.Info("new tcp conn connected: %v", tcpConn.RemoteAddr().String()) - - pxyPayloadCh := make(chan v1.ProxyConfigurer) - replyCh := make(chan interface{}) - - ss, err := frpssh.NewSSHService(tcpConn, svr.sshConfig, pxyPayloadCh, replyCh) - if err != nil { - log.Error("new ssh service error: %v", err) - continue - } - ss.Run() - - go func() { - for { - pxyCfg := <-pxyPayloadCh - - ctx := context.Background() - - // TODO fill client common config and login msg - vs, err := frpssh.NewVirtualService(ctx, v1.ClientCommonConfig{}, *svr.cfg, - msg.Login{User: v1.SSHClientLoginUserPrefix + tcpConn.RemoteAddr().String()}, - svr.rc, pxyCfg, ss, replyCh) - if err != nil { - log.Error("new virtual service error: %v", err) - ss.Close() - return - } - - err = vs.Run(ctx) - if err != nil { - log.Error("proxy run error: %v", err) - vs.Close() - return - } - } - }() - } -} - func (svr *Service) HandleQUICListener(l *quic.Listener) { // Listen for incoming connections from client. for { From 69ae2b0b690b659d023d1ec2d5b4b33e89def555 Mon Sep 17 00:00:00 2001 From: fatedier <fatedier@gmail.com> Date: Mon, 27 Nov 2023 15:47:49 +0800 Subject: [PATCH 10/21] optimize some code (#3801) --- Release.md | 5 +- client/admin.go | 85 ----------- client/admin_api.go | 39 ++++- client/connector.go | 28 ++-- client/control.go | 123 ++++++++------- client/proxy/proxy_manager.go | 17 ++- client/proxy/sudp.go | 4 +- client/proxy/udp.go | 4 +- client/proxy/xtcp.go | 6 +- client/service.go | 232 ++++++++++++++++++----------- client/visitor/sudp.go | 4 +- client/visitor/visitor.go | 6 +- client/visitor/visitor_manager.go | 18 ++- client/visitor/xtcp.go | 6 +- cmd/frpc/sub/root.go | 18 ++- cmd/frpc/sub/verify.go | 4 +- pkg/auth/pass.go | 31 ++++ pkg/config/flags.go | 5 + pkg/config/legacy/parse.go | 4 +- pkg/config/load.go | 27 ++-- pkg/config/v1/proxy.go | 4 +- pkg/config/v1/validation/client.go | 4 +- pkg/msg/msg.go | 12 ++ pkg/plugin/client/http2https.go | 4 +- pkg/plugin/client/http_proxy.go | 4 +- pkg/plugin/client/https2http.go | 4 +- pkg/plugin/client/https2https.go | 4 +- pkg/plugin/client/socks5.go | 4 +- pkg/plugin/client/static_file.go | 8 +- pkg/sdk/client/client.go | 4 +- pkg/ssh/gateway.go | 19 +-- pkg/ssh/server.go | 107 ++++++++++--- pkg/util/{util => http}/http.go | 2 +- pkg/util/http/server.go | 128 ++++++++++++++++ pkg/util/net/dns.go | 33 ++++ pkg/util/net/listener.go | 5 +- pkg/util/tcpmux/httpconnect.go | 10 +- pkg/util/vhost/http.go | 18 +-- pkg/util/vhost/resource.go | 4 +- pkg/util/vhost/vhost.go | 4 +- pkg/virtual/client.go | 55 ++++--- server/control.go | 18 ++- server/dashboard.go | 99 ------------ server/dashboard_api.go | 43 +++++- server/proxy/http.go | 6 +- server/proxy/proxy.go | 4 +- server/proxy/udp.go | 4 +- server/service.go | 176 +++++++++++----------- server/visitor/visitor.go | 15 +- test/e2e/legacy/basic/tcpmux.go | 4 +- test/e2e/pkg/request/request.go | 4 +- test/e2e/v1/basic/tcpmux.go | 4 +- 52 files changed, 880 insertions(+), 600 deletions(-) delete mode 100644 client/admin.go create mode 100644 pkg/auth/pass.go rename pkg/util/{util => http}/http.go (99%) create mode 100644 pkg/util/http/server.go create mode 100644 pkg/util/net/dns.go delete mode 100644 server/dashboard.go diff --git a/Release.md b/Release.md index b4245189..ca8f3a72 100644 --- a/Release.md +++ b/Release.md @@ -1,6 +1,9 @@ ### Features -* New command line parameter `--strict_config` is added to enable strict configuration validation mode. It will throw an error for non-existent fields instead of ignoring them. +* New command line parameter `--strict_config` is added to enable strict configuration validation mode. It will throw an error for non-existent fields instead of ignoring them. In future versions, we may set the default value of this parameter to true. +* Support `SSH reverse tunneling`. With this feature, you can expose your local service without running frpc, only using SSH. The SSH reverse tunnel agent has many functional limitations compared to the frpc agent. The currently supported proxy types are tcp, http, https, tcpmux, and stcp. +* The frpc tcpmux command line parameters have been updated to support configuring `http_user` and `http_pwd`. +* The frpc stcp/sudp/xtcp command line parameters have been updated to support configuring `allow_users`. ### Fixes diff --git a/client/admin.go b/client/admin.go deleted file mode 100644 index da8bab1b..00000000 --- a/client/admin.go +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright 2017 fatedier, fatedier@gmail.com -// -// 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 client - -import ( - "net" - "net/http" - "net/http/pprof" - "time" - - "github.com/gorilla/mux" - - "github.com/fatedier/frp/assets" - utilnet "github.com/fatedier/frp/pkg/util/net" -) - -var ( - httpServerReadTimeout = 60 * time.Second - httpServerWriteTimeout = 60 * time.Second -) - -func (svr *Service) RunAdminServer(address string) (err error) { - // url router - router := mux.NewRouter() - - router.HandleFunc("/healthz", svr.healthz) - - // debug - if svr.cfg.WebServer.PprofEnable { - router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) - router.HandleFunc("/debug/pprof/profile", pprof.Profile) - router.HandleFunc("/debug/pprof/symbol", pprof.Symbol) - router.HandleFunc("/debug/pprof/trace", pprof.Trace) - router.PathPrefix("/debug/pprof/").HandlerFunc(pprof.Index) - } - - subRouter := router.NewRoute().Subrouter() - user, passwd := svr.cfg.WebServer.User, svr.cfg.WebServer.Password - subRouter.Use(utilnet.NewHTTPAuthMiddleware(user, passwd).SetAuthFailDelay(200 * time.Millisecond).Middleware) - - // api, see admin_api.go - subRouter.HandleFunc("/api/reload", svr.apiReload).Methods("GET") - subRouter.HandleFunc("/api/stop", svr.apiStop).Methods("POST") - subRouter.HandleFunc("/api/status", svr.apiStatus).Methods("GET") - subRouter.HandleFunc("/api/config", svr.apiGetConfig).Methods("GET") - subRouter.HandleFunc("/api/config", svr.apiPutConfig).Methods("PUT") - - // view - subRouter.Handle("/favicon.ico", http.FileServer(assets.FileSystem)).Methods("GET") - subRouter.PathPrefix("/static/").Handler(utilnet.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(assets.FileSystem)))).Methods("GET") - subRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/static/", http.StatusMovedPermanently) - }) - - server := &http.Server{ - Addr: address, - Handler: router, - ReadTimeout: httpServerReadTimeout, - WriteTimeout: httpServerWriteTimeout, - } - if address == "" { - address = ":http" - } - ln, err := net.Listen("tcp", address) - if err != nil { - return err - } - - go func() { - _ = server.Serve(ln) - }() - return -} diff --git a/client/admin_api.go b/client/admin_api.go index 3a56a99f..5e4d67c6 100644 --- a/client/admin_api.go +++ b/client/admin_api.go @@ -31,7 +31,9 @@ import ( "github.com/fatedier/frp/client/proxy" "github.com/fatedier/frp/pkg/config" "github.com/fatedier/frp/pkg/config/v1/validation" + httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/pkg/util/log" + netpkg "github.com/fatedier/frp/pkg/util/net" ) type GeneralResponse struct { @@ -39,6 +41,29 @@ type GeneralResponse struct { Msg string } +func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) { + helper.Router.HandleFunc("/healthz", svr.healthz) + subRouter := helper.Router.NewRoute().Subrouter() + + subRouter.Use(helper.AuthMiddleware.Middleware) + + // api, see admin_api.go + subRouter.HandleFunc("/api/reload", svr.apiReload).Methods("GET") + subRouter.HandleFunc("/api/stop", svr.apiStop).Methods("POST") + subRouter.HandleFunc("/api/status", svr.apiStatus).Methods("GET") + subRouter.HandleFunc("/api/config", svr.apiGetConfig).Methods("GET") + subRouter.HandleFunc("/api/config", svr.apiPutConfig).Methods("PUT") + + // view + subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET") + subRouter.PathPrefix("/static/").Handler( + netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))), + ).Methods("GET") + subRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/static/", http.StatusMovedPermanently) + }) +} + // /healthz func (svr *Service) healthz(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) @@ -62,21 +87,21 @@ func (svr *Service) apiReload(w http.ResponseWriter, r *http.Request) { } }() - cliCfg, pxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(svr.cfgFile, strictConfigMode) + cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(svr.configFilePath, strictConfigMode) if err != nil { res.Code = 400 res.Msg = err.Error() log.Warn("reload frpc proxy config error: %s", res.Msg) return } - if _, err := validation.ValidateAllClientConfig(cliCfg, pxyCfgs, visitorCfgs); err != nil { + if _, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs); err != nil { res.Code = 400 res.Msg = err.Error() log.Warn("reload frpc proxy config error: %s", res.Msg) return } - if err := svr.ReloadConf(pxyCfgs, visitorCfgs); err != nil { + if err := svr.UpdateAllConfigurer(proxyCfgs, visitorCfgs); err != nil { res.Code = 500 res.Msg = err.Error() log.Warn("reload frpc proxy config error: %s", res.Msg) @@ -158,7 +183,7 @@ func (svr *Service) apiStatus(w http.ResponseWriter, _ *http.Request) { ps := ctl.pm.GetAllProxyStatus() for _, status := range ps { - res[status.Type] = append(res[status.Type], NewProxyStatusResp(status, svr.cfg.ServerAddr)) + res[status.Type] = append(res[status.Type], NewProxyStatusResp(status, svr.common.ServerAddr)) } for _, arrs := range res { @@ -184,14 +209,14 @@ func (svr *Service) apiGetConfig(w http.ResponseWriter, _ *http.Request) { } }() - if svr.cfgFile == "" { + if svr.configFilePath == "" { res.Code = 400 res.Msg = "frpc has no config file path" log.Warn("%s", res.Msg) return } - content, err := os.ReadFile(svr.cfgFile) + content, err := os.ReadFile(svr.configFilePath) if err != nil { res.Code = 400 res.Msg = err.Error() @@ -230,7 +255,7 @@ func (svr *Service) apiPutConfig(w http.ResponseWriter, r *http.Request) { return } - if err := os.WriteFile(svr.cfgFile, body, 0o644); err != nil { + if err := os.WriteFile(svr.configFilePath, body, 0o644); err != nil { res.Code = 500 res.Msg = fmt.Sprintf("write content to frpc config file error: %v", err) log.Warn("%s", res.Msg) diff --git a/client/connector.go b/client/connector.go index 2ff9b491..ba144146 100644 --- a/client/connector.go +++ b/client/connector.go @@ -21,6 +21,7 @@ import ( "net" "strconv" "strings" + "sync" "time" libdial "github.com/fatedier/golib/net/dial" @@ -30,7 +31,7 @@ import ( v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/transport" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/xlog" ) @@ -48,6 +49,7 @@ type defaultConnectorImpl struct { muxSession *fmux.Session quicConn quic.Connection + closeOnce sync.Once } func NewConnector(ctx context.Context, cfg *v1.ClientCommonConfig) Connector { @@ -130,7 +132,7 @@ func (c *defaultConnectorImpl) Connect() (net.Conn, error) { if err != nil { return nil, err } - return utilnet.QuicStreamToNetConn(stream, c.quicConn), nil + return netpkg.QuicStreamToNetConn(stream, c.quicConn), nil } else if c.muxSession != nil { stream, err := c.muxSession.OpenStream() if err != nil { @@ -177,19 +179,19 @@ func (c *defaultConnectorImpl) realConnect() (net.Conn, error) { switch protocol { case "websocket": protocol = "tcp" - dialOptions = append(dialOptions, libdial.WithAfterHook(libdial.AfterHook{Hook: utilnet.DialHookWebsocket(protocol, "")})) + dialOptions = append(dialOptions, libdial.WithAfterHook(libdial.AfterHook{Hook: netpkg.DialHookWebsocket(protocol, "")})) dialOptions = append(dialOptions, libdial.WithAfterHook(libdial.AfterHook{ - Hook: utilnet.DialHookCustomTLSHeadByte(tlsConfig != nil, lo.FromPtr(c.cfg.Transport.TLS.DisableCustomTLSFirstByte)), + Hook: netpkg.DialHookCustomTLSHeadByte(tlsConfig != nil, lo.FromPtr(c.cfg.Transport.TLS.DisableCustomTLSFirstByte)), })) dialOptions = append(dialOptions, libdial.WithTLSConfig(tlsConfig)) case "wss": protocol = "tcp" dialOptions = append(dialOptions, libdial.WithTLSConfigAndPriority(100, tlsConfig)) // Make sure that if it is wss, the websocket hook is executed after the tls hook. - dialOptions = append(dialOptions, libdial.WithAfterHook(libdial.AfterHook{Hook: utilnet.DialHookWebsocket(protocol, tlsConfig.ServerName), Priority: 110})) + dialOptions = append(dialOptions, libdial.WithAfterHook(libdial.AfterHook{Hook: netpkg.DialHookWebsocket(protocol, tlsConfig.ServerName), Priority: 110})) default: dialOptions = append(dialOptions, libdial.WithAfterHook(libdial.AfterHook{ - Hook: utilnet.DialHookCustomTLSHeadByte(tlsConfig != nil, lo.FromPtr(c.cfg.Transport.TLS.DisableCustomTLSFirstByte)), + Hook: netpkg.DialHookCustomTLSHeadByte(tlsConfig != nil, lo.FromPtr(c.cfg.Transport.TLS.DisableCustomTLSFirstByte)), })) dialOptions = append(dialOptions, libdial.WithTLSConfig(tlsConfig)) } @@ -213,11 +215,13 @@ func (c *defaultConnectorImpl) realConnect() (net.Conn, error) { } func (c *defaultConnectorImpl) Close() error { - if c.quicConn != nil { - _ = c.quicConn.CloseWithError(0, "") - } - if c.muxSession != nil { - _ = c.muxSession.Close() - } + c.closeOnce.Do(func() { + if c.quicConn != nil { + _ = c.quicConn.CloseWithError(0, "") + } + if c.muxSession != nil { + _ = c.muxSession.Close() + } + }) return nil } diff --git a/client/control.go b/client/control.go index be028ec4..e4b01ae8 100644 --- a/client/control.go +++ b/client/control.go @@ -28,39 +28,42 @@ import ( v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/transport" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/wait" "github.com/fatedier/frp/pkg/util/xlog" ) +type SessionContext struct { + // The client common configuration. + Common *v1.ClientCommonConfig + + // Unique ID obtained from frps. + // It should be attached to the login message when reconnecting. + RunID string + // Underlying control connection. Once conn is closed, the msgDispatcher and the entire Control will exit. + Conn net.Conn + // Indicates whether the connection is encrypted. + ConnEncrypted bool + // Sets authentication based on selected method + AuthSetter auth.Setter + // Connector is used to create new connections, which could be real TCP connections or virtual streams. + Connector Connector +} + type Control struct { // service context ctx context.Context xl *xlog.Logger - // The client configuration - clientCfg *v1.ClientCommonConfig - - // sets authentication based on selected method - authSetter auth.Setter - - // Unique ID obtained from frps. - // It should be attached to the login message when reconnecting. - runID string + // session context + sessionCtx *SessionContext // manage all proxies - pxyCfgs []v1.ProxyConfigurer - pm *proxy.Manager + pm *proxy.Manager // manage all visitors vm *visitor.Manager - // control connection. Once conn is closed, the msgDispatcher and the entire Control will exit. - conn net.Conn - - // use connector to create new connections, which could be real TCP connections or virtual streams. - connector Connector - doneCh chan struct{} // of time.Time, last time got the Pong message @@ -76,50 +79,41 @@ type Control struct { msgDispatcher *msg.Dispatcher } -func NewControl( - ctx context.Context, runID string, conn net.Conn, connector Connector, - clientCfg *v1.ClientCommonConfig, - pxyCfgs []v1.ProxyConfigurer, - visitorCfgs []v1.VisitorConfigurer, - authSetter auth.Setter, -) (*Control, error) { +func NewControl(ctx context.Context, sessionCtx *SessionContext) (*Control, error) { // new xlog instance ctl := &Control{ ctx: ctx, xl: xlog.FromContextSafe(ctx), - clientCfg: clientCfg, - authSetter: authSetter, - runID: runID, - pxyCfgs: pxyCfgs, - conn: conn, - connector: connector, + sessionCtx: sessionCtx, doneCh: make(chan struct{}), } ctl.lastPong.Store(time.Now()) - cryptoRW, err := utilnet.NewCryptoReadWriter(conn, []byte(clientCfg.Auth.Token)) - if err != nil { - return nil, err + if sessionCtx.ConnEncrypted { + cryptoRW, err := netpkg.NewCryptoReadWriter(sessionCtx.Conn, []byte(sessionCtx.Common.Auth.Token)) + if err != nil { + return nil, err + } + ctl.msgDispatcher = msg.NewDispatcher(cryptoRW) + } else { + ctl.msgDispatcher = msg.NewDispatcher(sessionCtx.Conn) } - - ctl.msgDispatcher = msg.NewDispatcher(cryptoRW) ctl.registerMsgHandlers() ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher.SendChannel()) - ctl.pm = proxy.NewManager(ctl.ctx, clientCfg, ctl.msgTransporter) - ctl.vm = visitor.NewManager(ctl.ctx, ctl.runID, ctl.clientCfg, ctl.connectServer, ctl.msgTransporter) - ctl.vm.Reload(visitorCfgs) + ctl.pm = proxy.NewManager(ctl.ctx, sessionCtx.Common, ctl.msgTransporter) + ctl.vm = visitor.NewManager(ctl.ctx, sessionCtx.RunID, sessionCtx.Common, ctl.connectServer, ctl.msgTransporter) return ctl, nil } -func (ctl *Control) Run() { +func (ctl *Control) Run(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) { go ctl.worker() // start all proxies - ctl.pm.Reload(ctl.pxyCfgs) + ctl.pm.UpdateAll(proxyCfgs) // start all visitors - go ctl.vm.Run() + ctl.vm.UpdateAll(visitorCfgs) } func (ctl *Control) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) { @@ -135,9 +129,9 @@ func (ctl *Control) handleReqWorkConn(_ msg.Message) { } m := &msg.NewWorkConn{ - RunID: ctl.runID, + RunID: ctl.sessionCtx.RunID, } - if err = ctl.authSetter.SetNewWorkConn(m); err != nil { + if err = ctl.sessionCtx.AuthSetter.SetNewWorkConn(m); err != nil { xl.Warn("error during NewWorkConn authentication: %v", err) return } @@ -193,13 +187,19 @@ func (ctl *Control) handlePong(m msg.Message) { if inMsg.Error != "" { xl.Error("Pong message contains error: %s", inMsg.Error) - ctl.conn.Close() + ctl.closeSession() return } ctl.lastPong.Store(time.Now()) xl.Debug("receive heartbeat from server") } +// closeSession closes the control connection. +func (ctl *Control) closeSession() { + ctl.sessionCtx.Conn.Close() + ctl.sessionCtx.Connector.Close() +} + func (ctl *Control) Close() error { return ctl.GracefulClose(0) } @@ -210,8 +210,7 @@ func (ctl *Control) GracefulClose(d time.Duration) error { time.Sleep(d) - ctl.conn.Close() - ctl.connector.Close() + ctl.closeSession() return nil } @@ -221,8 +220,8 @@ func (ctl *Control) Done() <-chan struct{} { } // connectServer return a new connection to frps -func (ctl *Control) connectServer() (conn net.Conn, err error) { - return ctl.connector.Connect() +func (ctl *Control) connectServer() (net.Conn, error) { + return ctl.sessionCtx.Connector.Connect() } func (ctl *Control) registerMsgHandlers() { @@ -238,12 +237,12 @@ func (ctl *Control) heartbeatWorker() { // TODO(fatedier): Change default value of HeartbeatInterval to -1 if tcpmux is enabled. // Users can still enable heartbeat feature by setting HeartbeatInterval to a positive value. - if ctl.clientCfg.Transport.HeartbeatInterval > 0 { + if ctl.sessionCtx.Common.Transport.HeartbeatInterval > 0 { // send heartbeat to server sendHeartBeat := func() error { xl.Debug("send heartbeat to server") pingMsg := &msg.Ping{} - if err := ctl.authSetter.SetPing(pingMsg); err != nil { + if err := ctl.sessionCtx.AuthSetter.SetPing(pingMsg); err != nil { xl.Warn("error during ping authentication: %v, skip sending ping message", err) return err } @@ -253,24 +252,24 @@ func (ctl *Control) heartbeatWorker() { go wait.BackoffUntil(sendHeartBeat, wait.NewFastBackoffManager(wait.FastBackoffOptions{ - Duration: time.Duration(ctl.clientCfg.Transport.HeartbeatInterval) * time.Second, + Duration: time.Duration(ctl.sessionCtx.Common.Transport.HeartbeatInterval) * time.Second, InitDurationIfFail: time.Second, Factor: 2.0, Jitter: 0.1, - MaxDuration: time.Duration(ctl.clientCfg.Transport.HeartbeatInterval) * time.Second, + MaxDuration: time.Duration(ctl.sessionCtx.Common.Transport.HeartbeatInterval) * time.Second, }), true, ctl.doneCh, ) } // Check heartbeat timeout only if TCPMux is not enabled and users don't disable heartbeat feature. - if ctl.clientCfg.Transport.HeartbeatInterval > 0 && ctl.clientCfg.Transport.HeartbeatTimeout > 0 && - !lo.FromPtr(ctl.clientCfg.Transport.TCPMux) { + if ctl.sessionCtx.Common.Transport.HeartbeatInterval > 0 && ctl.sessionCtx.Common.Transport.HeartbeatTimeout > 0 && + !lo.FromPtr(ctl.sessionCtx.Common.Transport.TCPMux) { go wait.Until(func() { - if time.Since(ctl.lastPong.Load().(time.Time)) > time.Duration(ctl.clientCfg.Transport.HeartbeatTimeout)*time.Second { + if time.Since(ctl.lastPong.Load().(time.Time)) > time.Duration(ctl.sessionCtx.Common.Transport.HeartbeatTimeout)*time.Second { xl.Warn("heartbeat timeout") - ctl.conn.Close() + ctl.closeSession() return } }, time.Second, ctl.doneCh) @@ -282,17 +281,15 @@ func (ctl *Control) worker() { go ctl.msgDispatcher.Run() <-ctl.msgDispatcher.Done() - ctl.conn.Close() + ctl.closeSession() ctl.pm.Close() ctl.vm.Close() - ctl.connector.Close() - close(ctl.doneCh) } -func (ctl *Control) ReloadConf(pxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error { - ctl.vm.Reload(visitorCfgs) - ctl.pm.Reload(pxyCfgs) +func (ctl *Control) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error { + ctl.vm.UpdateAll(visitorCfgs) + ctl.pm.UpdateAll(proxyCfgs) return nil } diff --git a/client/proxy/proxy_manager.go b/client/proxy/proxy_manager.go index dadf6481..12e2f6cf 100644 --- a/client/proxy/proxy_manager.go +++ b/client/proxy/proxy_manager.go @@ -120,9 +120,18 @@ func (pm *Manager) GetAllProxyStatus() []*WorkingStatus { return ps } -func (pm *Manager) Reload(pxyCfgs []v1.ProxyConfigurer) { +func (pm *Manager) GetProxyStatus(name string) (*WorkingStatus, bool) { + pm.mu.RLock() + defer pm.mu.RUnlock() + if pxy, ok := pm.proxies[name]; ok { + return pxy.GetStatus(), true + } + return nil, false +} + +func (pm *Manager) UpdateAll(proxyCfgs []v1.ProxyConfigurer) { xl := xlog.FromContextSafe(pm.ctx) - pxyCfgsMap := lo.KeyBy(pxyCfgs, func(c v1.ProxyConfigurer) string { + proxyCfgsMap := lo.KeyBy(proxyCfgs, func(c v1.ProxyConfigurer) string { return c.GetBaseConfig().Name }) pm.mu.Lock() @@ -131,7 +140,7 @@ func (pm *Manager) Reload(pxyCfgs []v1.ProxyConfigurer) { delPxyNames := make([]string, 0) for name, pxy := range pm.proxies { del := false - cfg, ok := pxyCfgsMap[name] + cfg, ok := proxyCfgsMap[name] if !ok || !reflect.DeepEqual(pxy.Cfg, cfg) { del = true } @@ -147,7 +156,7 @@ func (pm *Manager) Reload(pxyCfgs []v1.ProxyConfigurer) { } addPxyNames := make([]string, 0) - for _, cfg := range pxyCfgs { + for _, cfg := range proxyCfgs { name := cfg.GetBaseConfig().Name if _, ok := pm.proxies[name]; !ok { pxy := NewWrapper(pm.ctx, cfg, pm.clientCfg, pm.HandleEvent, pm.msgTransporter) diff --git a/client/proxy/sudp.go b/client/proxy/sudp.go index f9fe53bc..4d06170d 100644 --- a/client/proxy/sudp.go +++ b/client/proxy/sudp.go @@ -31,7 +31,7 @@ import ( "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/proto/udp" "github.com/fatedier/frp/pkg/util/limit" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" ) func init() { @@ -101,7 +101,7 @@ func (pxy *SUDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) { if pxy.cfg.Transport.UseCompression { rwc = libio.WithCompression(rwc) } - conn = utilnet.WrapReadWriteCloserToConn(rwc, conn) + conn = netpkg.WrapReadWriteCloserToConn(rwc, conn) workConn := conn readCh := make(chan *msg.UDPPacket, 1024) diff --git a/client/proxy/udp.go b/client/proxy/udp.go index d8590f68..38d14ff5 100644 --- a/client/proxy/udp.go +++ b/client/proxy/udp.go @@ -30,7 +30,7 @@ import ( "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/proto/udp" "github.com/fatedier/frp/pkg/util/limit" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" ) func init() { @@ -112,7 +112,7 @@ func (pxy *UDPProxy) InWorkConn(conn net.Conn, _ *msg.StartWorkConn) { if pxy.cfg.Transport.UseCompression { rwc = libio.WithCompression(rwc) } - conn = utilnet.WrapReadWriteCloserToConn(rwc, conn) + conn = netpkg.WrapReadWriteCloserToConn(rwc, conn) pxy.mu.Lock() pxy.workConn = conn diff --git a/client/proxy/xtcp.go b/client/proxy/xtcp.go index b286a931..e5e5d47e 100644 --- a/client/proxy/xtcp.go +++ b/client/proxy/xtcp.go @@ -29,7 +29,7 @@ import ( "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/nathole" "github.com/fatedier/frp/pkg/transport" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" ) func init() { @@ -133,7 +133,7 @@ func (pxy *XTCPProxy) listenByKCP(listenConn *net.UDPConn, raddr *net.UDPAddr, s } defer lConn.Close() - remote, err := utilnet.NewKCPConnFromUDP(lConn, true, raddr.String()) + remote, err := netpkg.NewKCPConnFromUDP(lConn, true, raddr.String()) if err != nil { xl.Warn("create kcp connection from udp connection error: %v", err) return @@ -194,6 +194,6 @@ func (pxy *XTCPProxy) listenByQUIC(listenConn *net.UDPConn, _ *net.UDPAddr, star _ = c.CloseWithError(0, "") return } - go pxy.HandleTCPWorkConnection(utilnet.QuicStreamToNetConn(stream, c), startWorkConnMsg, []byte(pxy.cfg.Secretkey)) + go pxy.HandleTCPWorkConnection(netpkg.QuicStreamToNetConn(stream, c), startWorkConnMsg, []byte(pxy.cfg.Secretkey)) } } diff --git a/client/service.go b/client/service.go index 7c3cd039..5db1bd28 100644 --- a/client/service.go +++ b/client/service.go @@ -20,18 +20,19 @@ import ( "fmt" "net" "runtime" - "strconv" "sync" "time" "github.com/fatedier/golib/crypto" "github.com/samber/lo" - "github.com/fatedier/frp/assets" + "github.com/fatedier/frp/client/proxy" "github.com/fatedier/frp/pkg/auth" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" + httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/pkg/util/log" + netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/version" "github.com/fatedier/frp/pkg/util/wait" "github.com/fatedier/frp/pkg/util/xlog" @@ -41,66 +42,106 @@ func init() { crypto.DefaultSalt = "frp" } -// Service is a client service. -type Service struct { - // uniq id got from frps, attach it in loginMsg - runID string +// ServiceOptions contains options for creating a new client service. +type ServiceOptions struct { + Common *v1.ClientCommonConfig + ProxyCfgs []v1.ProxyConfigurer + VisitorCfgs []v1.VisitorConfigurer - // manager control connection with server - ctl *Control + // ConfigFilePath is the path to the configuration file used to initialize. + // If it is empty, it means that the configuration file is not used for initialization. + // It may be initialized using command line parameters or called directly. + ConfigFilePath string + + // ClientSpec is the client specification that control the client behavior. + ClientSpec *msg.ClientSpec + + // ConnectorCreator is a function that creates a new connector to make connections to the server. + // The Connector shields the underlying connection details, whether it is through TCP or QUIC connection, + // and regardless of whether multiplexing is used. + // + // If it is not set, the default frpc connector will be used. + // By using a custom Connector, it can be used to implement a VirtualClient, which connects to frps + // through a pipe instead of a real physical connection. + ConnectorCreator func(context.Context, *v1.ClientCommonConfig) Connector + + // HandleWorkConnCb is a callback function that is called when a new work connection is created. + // + // If it is not set, the default frpc implementation will be used. + HandleWorkConnCb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool +} + +// setServiceOptionsDefault sets the default values for ServiceOptions. +func setServiceOptionsDefault(options *ServiceOptions) { + if options.Common != nil { + options.Common.Complete() + } + if options.ConnectorCreator == nil { + options.ConnectorCreator = NewConnector + } +} + +// Service is the client service that connects to frps and provides proxy services. +type Service struct { ctlMu sync.RWMutex + // manager control connection with server + ctl *Control + // Uniq id got from frps, it will be attached to loginMsg. + runID string // Sets authentication based on selected method authSetter auth.Setter - cfg *v1.ClientCommonConfig - pxyCfgs []v1.ProxyConfigurer - visitorCfgs []v1.VisitorConfigurer + // web server for admin UI and apis + webServer *httppkg.Server + cfgMu sync.RWMutex + common *v1.ClientCommonConfig + proxyCfgs []v1.ProxyConfigurer + visitorCfgs []v1.VisitorConfigurer + clientSpec *msg.ClientSpec // The configuration file used to initialize this client, or an empty // string if no configuration file was used. - cfgFile string + configFilePath string // service context ctx context.Context // call cancel to stop service - cancel context.CancelFunc - gracefulDuration time.Duration + cancel context.CancelFunc + gracefulShutdownDuration time.Duration - connectorCreator func(context.Context, *v1.ClientCommonConfig) Connector - inWorkConnCallback func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool + connectorCreator func(context.Context, *v1.ClientCommonConfig) Connector + handleWorkConnCb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool } -func NewService( - cfg *v1.ClientCommonConfig, - pxyCfgs []v1.ProxyConfigurer, - visitorCfgs []v1.VisitorConfigurer, - cfgFile string, -) *Service { - return &Service{ - authSetter: auth.NewAuthSetter(cfg.Auth), - cfg: cfg, - cfgFile: cfgFile, - pxyCfgs: pxyCfgs, - visitorCfgs: visitorCfgs, - ctx: context.Background(), - connectorCreator: NewConnector, +func NewService(options ServiceOptions) (*Service, error) { + setServiceOptionsDefault(&options) + + var webServer *httppkg.Server + if options.Common.WebServer.Port > 0 { + ws, err := httppkg.NewServer(options.Common.WebServer) + if err != nil { + return nil, err + } + webServer = ws } -} - -func (svr *Service) SetConnectorCreator(h func(context.Context, *v1.ClientCommonConfig) Connector) { - svr.connectorCreator = h -} - -func (svr *Service) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) { - svr.inWorkConnCallback = cb -} - -func (svr *Service) GetController() *Control { - svr.ctlMu.RLock() - defer svr.ctlMu.RUnlock() - return svr.ctl + s := &Service{ + ctx: context.Background(), + authSetter: auth.NewAuthSetter(options.Common.Auth), + webServer: webServer, + common: options.Common, + configFilePath: options.ConfigFilePath, + proxyCfgs: options.ProxyCfgs, + visitorCfgs: options.VisitorCfgs, + clientSpec: options.ClientSpec, + connectorCreator: options.ConnectorCreator, + handleWorkConnCb: options.HandleWorkConnCb, + } + if webServer != nil { + webServer.RouteRegister(s.registerRouteHandlers) + } + return s, nil } func (svr *Service) Run(ctx context.Context) error { @@ -109,38 +150,25 @@ func (svr *Service) Run(ctx context.Context) error { svr.cancel = cancel // set custom DNSServer - if svr.cfg.DNSServer != "" { - dnsAddr := svr.cfg.DNSServer - if _, _, err := net.SplitHostPort(dnsAddr); err != nil { - dnsAddr = net.JoinHostPort(dnsAddr, "53") - } - // Change default dns server for frpc - net.DefaultResolver = &net.Resolver{ - PreferGo: true, - Dial: func(ctx context.Context, network, address string) (net.Conn, error) { - return net.Dial("udp", dnsAddr) - }, - } + if svr.common.DNSServer != "" { + netpkg.SetDefaultDNSAddress(svr.common.DNSServer) } - // login to frps - svr.loopLoginUntilSuccess(10*time.Second, lo.FromPtr(svr.cfg.LoginFailExit)) + // first login to frps + svr.loopLoginUntilSuccess(10*time.Second, lo.FromPtr(svr.common.LoginFailExit)) if svr.ctl == nil { return fmt.Errorf("the process exited because the first login to the server failed, and the loginFailExit feature is enabled") } go svr.keepControllerWorking() - if svr.cfg.WebServer.Port != 0 { - // Init admin server assets - assets.Load(svr.cfg.WebServer.AssetsDir) - - address := net.JoinHostPort(svr.cfg.WebServer.Addr, strconv.Itoa(svr.cfg.WebServer.Port)) - err := svr.RunAdminServer(address) - if err != nil { - log.Warn("run admin server error: %v", err) - } - log.Info("admin server listen on %s:%d", svr.cfg.WebServer.Addr, svr.cfg.WebServer.Port) + if svr.webServer != nil { + go func() { + log.Info("admin server listen on %s", svr.webServer.Address()) + if err := svr.webServer.Run(); err != nil { + log.Warn("admin server exit with error: %v", err) + } + }() } <-svr.ctx.Done() svr.stop() @@ -158,8 +186,12 @@ func (svr *Service) keepControllerWorking() { // loopLoginUntilSuccess is another layer of loop that will continuously attempt to // login to the server until successful. svr.loopLoginUntilSuccess(20*time.Second, false) - <-svr.ctl.Done() - return errors.New("control is closed and try another loop") + if svr.ctl != nil { + <-svr.ctl.Done() + return errors.New("control is closed and try another loop") + } + // If the control is nil, it means that the login failed and the service is also closed. + return nil }, wait.NewFastBackoffManager( wait.FastBackoffOptions{ Duration: time.Second, @@ -179,7 +211,7 @@ func (svr *Service) keepControllerWorking() { // session: if it's not nil, using tcp mux func (svr *Service) login() (conn net.Conn, connector Connector, err error) { xl := xlog.FromContextSafe(svr.ctx) - connector = svr.connectorCreator(svr.ctx, svr.cfg) + connector = svr.connectorCreator(svr.ctx, svr.common) if err = connector.Open(); err != nil { return nil, nil, err } @@ -198,12 +230,15 @@ func (svr *Service) login() (conn net.Conn, connector Connector, err error) { loginMsg := &msg.Login{ Arch: runtime.GOARCH, Os: runtime.GOOS, - PoolCount: svr.cfg.Transport.PoolCount, - User: svr.cfg.User, + PoolCount: svr.common.Transport.PoolCount, + User: svr.common.User, Version: version.Full(), Timestamp: time.Now().Unix(), RunID: svr.runID, - Metas: svr.cfg.Metadatas, + Metas: svr.common.Metadatas, + } + if svr.clientSpec != nil { + loginMsg.ClientSpec = *svr.clientSpec } // Add auth @@ -250,16 +285,31 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE return err } - ctl, err := NewControl(svr.ctx, svr.runID, conn, connector, - svr.cfg, svr.pxyCfgs, svr.visitorCfgs, svr.authSetter) + svr.cfgMu.RLock() + proxyCfgs := svr.proxyCfgs + visitorCfgs := svr.visitorCfgs + svr.cfgMu.RUnlock() + connEncrypted := true + if svr.clientSpec != nil && svr.clientSpec.Type == "ssh-tunnel" { + connEncrypted = false + } + sessionCtx := &SessionContext{ + Common: svr.common, + RunID: svr.runID, + Conn: conn, + ConnEncrypted: connEncrypted, + AuthSetter: svr.authSetter, + Connector: connector, + } + ctl, err := NewControl(svr.ctx, sessionCtx) if err != nil { conn.Close() xl.Error("NewControl error: %v", err) return err } - ctl.SetInWorkConnCallback(svr.inWorkConnCallback) + ctl.SetInWorkConnCallback(svr.handleWorkConnCb) - ctl.Run() + ctl.Run(proxyCfgs, visitorCfgs) // close and replace previous control svr.ctlMu.Lock() if svr.ctl != nil { @@ -284,9 +334,9 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE wait.MergeAndCloseOnAnyStopChannel(svr.ctx.Done(), successCh)) } -func (svr *Service) ReloadConf(pxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error { +func (svr *Service) UpdateAllConfigurer(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error { svr.cfgMu.Lock() - svr.pxyCfgs = pxyCfgs + svr.proxyCfgs = proxyCfgs svr.visitorCfgs = visitorCfgs svr.cfgMu.Unlock() @@ -295,7 +345,7 @@ func (svr *Service) ReloadConf(pxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.Vi svr.ctlMu.RUnlock() if ctl != nil { - return svr.ctl.ReloadConf(pxyCfgs, visitorCfgs) + return svr.ctl.UpdateAllConfigurer(proxyCfgs, visitorCfgs) } return nil } @@ -305,7 +355,7 @@ func (svr *Service) Close() { } func (svr *Service) GracefulClose(d time.Duration) { - svr.gracefulDuration = d + svr.gracefulShutdownDuration = d svr.cancel() } @@ -313,7 +363,23 @@ func (svr *Service) stop() { svr.ctlMu.Lock() defer svr.ctlMu.Unlock() if svr.ctl != nil { - svr.ctl.GracefulClose(svr.gracefulDuration) + svr.ctl.GracefulClose(svr.gracefulShutdownDuration) svr.ctl = nil } } + +// TODO(fatedier): Use StatusExporter to provide query interfaces instead of directly using methods from the Service. +func (svr *Service) GetProxyStatus(name string) (*proxy.WorkingStatus, error) { + svr.ctlMu.RLock() + ctl := svr.ctl + svr.ctlMu.RUnlock() + + if ctl == nil { + return nil, fmt.Errorf("control is not running") + } + ws, ok := ctl.pm.GetProxyStatus(name) + if !ok { + return nil, fmt.Errorf("proxy [%s] is not found", name) + } + return ws, nil +} diff --git a/client/visitor/sudp.go b/client/visitor/sudp.go index 159f46ee..1d489bec 100644 --- a/client/visitor/sudp.go +++ b/client/visitor/sudp.go @@ -28,7 +28,7 @@ import ( v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/proto/udp" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/xlog" ) @@ -242,7 +242,7 @@ func (sv *SUDPVisitor) getNewVisitorConn() (net.Conn, error) { if sv.cfg.Transport.UseCompression { remote = libio.WithCompression(remote) } - return utilnet.WrapReadWriteCloserToConn(remote, visitorConn), nil + return netpkg.WrapReadWriteCloserToConn(remote, visitorConn), nil } func (sv *SUDPVisitor) Close() { diff --git a/client/visitor/visitor.go b/client/visitor/visitor.go index 4cfd6106..d520f735 100644 --- a/client/visitor/visitor.go +++ b/client/visitor/visitor.go @@ -21,7 +21,7 @@ import ( v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/transport" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/xlog" ) @@ -56,7 +56,7 @@ func NewVisitor( clientCfg: clientCfg, helper: helper, ctx: xlog.NewContext(ctx, xl), - internalLn: utilnet.NewInternalListener(), + internalLn: netpkg.NewInternalListener(), } switch cfg := cfg.(type) { case *v1.STCPVisitorConfig: @@ -84,7 +84,7 @@ type BaseVisitor struct { clientCfg *v1.ClientCommonConfig helper Helper l net.Listener - internalLn *utilnet.InternalListener + internalLn *netpkg.InternalListener mu sync.RWMutex ctx context.Context diff --git a/client/visitor/visitor_manager.go b/client/visitor/visitor_manager.go index 4b235cdb..4f31f270 100644 --- a/client/visitor/visitor_manager.go +++ b/client/visitor/visitor_manager.go @@ -35,7 +35,8 @@ type Manager struct { visitors map[string]Visitor helper Helper - checkInterval time.Duration + checkInterval time.Duration + keepVisitorsRunningOnce sync.Once mu sync.RWMutex ctx context.Context @@ -67,7 +68,9 @@ func NewManager( return m } -func (vm *Manager) Run() { +// keepVisitorsRunning checks all visitors' status periodically, if some visitor is not running, start it. +// It will only start after Reload is called and a new visitor is added. +func (vm *Manager) keepVisitorsRunning() { xl := xlog.FromContextSafe(vm.ctx) ticker := time.NewTicker(vm.checkInterval) @@ -76,7 +79,7 @@ func (vm *Manager) Run() { for { select { case <-vm.stopCh: - xl.Info("gracefully shutdown visitor manager") + xl.Trace("gracefully shutdown visitor manager") return case <-ticker.C: vm.mu.Lock() @@ -120,7 +123,14 @@ func (vm *Manager) startVisitor(cfg v1.VisitorConfigurer) (err error) { return } -func (vm *Manager) Reload(cfgs []v1.VisitorConfigurer) { +func (vm *Manager) UpdateAll(cfgs []v1.VisitorConfigurer) { + if len(cfgs) > 0 { + // Only start keepVisitorsRunning goroutine once and only when there is at least one visitor. + vm.keepVisitorsRunningOnce.Do(func() { + go vm.keepVisitorsRunning() + }) + } + xl := xlog.FromContextSafe(vm.ctx) cfgsMap := lo.KeyBy(cfgs, func(c v1.VisitorConfigurer) string { return c.GetBaseConfig().Name diff --git a/client/visitor/xtcp.go b/client/visitor/xtcp.go index c180621c..ad773503 100644 --- a/client/visitor/xtcp.go +++ b/client/visitor/xtcp.go @@ -33,7 +33,7 @@ import ( "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/nathole" "github.com/fatedier/frp/pkg/transport" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/xlog" ) @@ -349,7 +349,7 @@ func (ks *KCPTunnelSession) Init(listenConn *net.UDPConn, raddr *net.UDPAddr) er if err != nil { return fmt.Errorf("dial udp error: %v", err) } - remote, err := utilnet.NewKCPConnFromUDP(lConn, true, raddr.String()) + remote, err := netpkg.NewKCPConnFromUDP(lConn, true, raddr.String()) if err != nil { return fmt.Errorf("create kcp connection from udp connection error: %v", err) } @@ -440,7 +440,7 @@ func (qs *QUICTunnelSession) OpenConn(ctx context.Context) (net.Conn, error) { if err != nil { return nil, err } - return utilnet.QuicStreamToNetConn(stream, session), nil + return netpkg.QuicStreamToNetConn(stream, session), nil } func (qs *QUICTunnelSession) Close() { diff --git a/cmd/frpc/sub/root.go b/cmd/frpc/sub/root.go index c4a5acb6..fffc4985 100644 --- a/cmd/frpc/sub/root.go +++ b/cmd/frpc/sub/root.go @@ -110,7 +110,7 @@ func handleTermSignal(svr *client.Service) { } func runClient(cfgFilePath string) error { - cfg, pxyCfgs, visitorCfgs, isLegacyFormat, err := config.LoadClientConfig(cfgFilePath, strictConfigMode) + cfg, proxyCfgs, visitorCfgs, isLegacyFormat, err := config.LoadClientConfig(cfgFilePath, strictConfigMode) if err != nil { return err } @@ -119,19 +119,19 @@ func runClient(cfgFilePath string) error { "please use yaml/json/toml format instead!\n") } - warning, err := validation.ValidateAllClientConfig(cfg, pxyCfgs, visitorCfgs) + warning, err := validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs) if warning != nil { fmt.Printf("WARNING: %v\n", warning) } if err != nil { return err } - return startService(cfg, pxyCfgs, visitorCfgs, cfgFilePath) + return startService(cfg, proxyCfgs, visitorCfgs, cfgFilePath) } func startService( cfg *v1.ClientCommonConfig, - pxyCfgs []v1.ProxyConfigurer, + proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer, cfgFile string, ) error { @@ -141,7 +141,15 @@ func startService( log.Info("start frpc service for config file [%s]", cfgFile) defer log.Info("frpc service for config file [%s] stopped", cfgFile) } - svr := client.NewService(cfg, pxyCfgs, visitorCfgs, cfgFile) + svr, err := client.NewService(client.ServiceOptions{ + Common: cfg, + ProxyCfgs: proxyCfgs, + VisitorCfgs: visitorCfgs, + ConfigFilePath: cfgFile, + }) + if err != nil { + return err + } shouldGracefulClose := cfg.Transport.Protocol == "kcp" || cfg.Transport.Protocol == "quic" // Capture the exit signal if we use kcp or quic. diff --git a/cmd/frpc/sub/verify.go b/cmd/frpc/sub/verify.go index 1b6ac5a7..4b971f53 100644 --- a/cmd/frpc/sub/verify.go +++ b/cmd/frpc/sub/verify.go @@ -37,12 +37,12 @@ var verifyCmd = &cobra.Command{ return nil } - cliCfg, pxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode) + cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, strictConfigMode) if err != nil { fmt.Println(err) os.Exit(1) } - warning, err := validation.ValidateAllClientConfig(cliCfg, pxyCfgs, visitorCfgs) + warning, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs) if warning != nil { fmt.Printf("WARNING: %v\n", warning) } diff --git a/pkg/auth/pass.go b/pkg/auth/pass.go new file mode 100644 index 00000000..2eaf3f0b --- /dev/null +++ b/pkg/auth/pass.go @@ -0,0 +1,31 @@ +// Copyright 2023 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 auth + +import ( + "github.com/fatedier/frp/pkg/msg" +) + +var AlwaysPassVerifier = &alwaysPass{} + +var _ Verifier = &alwaysPass{} + +type alwaysPass struct{} + +func (*alwaysPass) VerifyLogin(*msg.Login) error { return nil } + +func (*alwaysPass) VerifyPing(*msg.Ping) error { return nil } + +func (*alwaysPass) VerifyNewWorkConn(*msg.NewWorkConn) error { return nil } diff --git a/pkg/config/flags.go b/pkg/config/flags.go index 0c37e608..c0e87164 100644 --- a/pkg/config/flags.go +++ b/pkg/config/flags.go @@ -59,12 +59,17 @@ func RegisterProxyFlags(cmd *cobra.Command, c v1.ProxyConfigurer) { case *v1.TCPMuxProxyConfig: registerProxyDomainConfigFlags(cmd, &cc.DomainConfig) cmd.Flags().StringVarP(&cc.Multiplexer, "mux", "", "", "multiplexer") + cmd.Flags().StringVarP(&cc.HTTPUser, "http_user", "", "", "http auth user") + cmd.Flags().StringVarP(&cc.HTTPPassword, "http_pwd", "", "", "http auth password") case *v1.STCPProxyConfig: cmd.Flags().StringVarP(&cc.Secretkey, "sk", "", "", "secret key") + cmd.Flags().StringSliceVarP(&cc.AllowUsers, "allow_users", "", []string{}, "allow visitor users") case *v1.SUDPProxyConfig: cmd.Flags().StringVarP(&cc.Secretkey, "sk", "", "", "secret key") + cmd.Flags().StringSliceVarP(&cc.AllowUsers, "allow_users", "", []string{}, "allow visitor users") case *v1.XTCPProxyConfig: cmd.Flags().StringVarP(&cc.Secretkey, "sk", "", "", "secret key") + cmd.Flags().StringSliceVarP(&cc.AllowUsers, "allow_users", "", []string{}, "allow visitor users") } } diff --git a/pkg/config/legacy/parse.go b/pkg/config/legacy/parse.go index 637783bc..80850f6f 100644 --- a/pkg/config/legacy/parse.go +++ b/pkg/config/legacy/parse.go @@ -23,7 +23,7 @@ import ( func ParseClientConfig(filePath string) ( cfg ClientCommonConf, - pxyCfgs map[string]ProxyConf, + proxyCfgs map[string]ProxyConf, visitorCfgs map[string]VisitorConf, err error, ) { @@ -56,7 +56,7 @@ func ParseClientConfig(filePath string) ( configBuffer.Write(buf) // Parse all proxy and visitor configs. - pxyCfgs, visitorCfgs, err = LoadAllProxyConfsFromIni(cfg.User, configBuffer.Bytes(), cfg.Start) + proxyCfgs, visitorCfgs, err = LoadAllProxyConfsFromIni(cfg.User, configBuffer.Bytes(), cfg.Start) if err != nil { return } diff --git a/pkg/config/load.go b/pkg/config/load.go index 3014eb35..b5539745 100644 --- a/pkg/config/load.go +++ b/pkg/config/load.go @@ -110,6 +110,7 @@ func LoadConfigureFromFile(path string, c any, strict bool) error { // LoadConfigure loads configuration from bytes and unmarshal into c. // Now it supports json, yaml and toml format. +// TODO(fatedier): strict is not valide for ProxyConfigurer/VisitorConfigurer/ClientPluginOptions. func LoadConfigure(b []byte, c any, strict bool) error { var tomlObj interface{} // Try to unmarshal as TOML first; swallow errors from that (assume it's not valid TOML). @@ -188,19 +189,19 @@ func LoadClientConfig(path string, strict bool) ( ) { var ( cliCfg *v1.ClientCommonConfig - pxyCfgs = make([]v1.ProxyConfigurer, 0) + proxyCfgs = make([]v1.ProxyConfigurer, 0) visitorCfgs = make([]v1.VisitorConfigurer, 0) isLegacyFormat bool ) if DetectLegacyINIFormatFromFile(path) { - legacyCommon, legacyPxyCfgs, legacyVisitorCfgs, err := legacy.ParseClientConfig(path) + legacyCommon, legacyProxyCfgs, legacyVisitorCfgs, err := legacy.ParseClientConfig(path) if err != nil { return nil, nil, nil, true, err } cliCfg = legacy.Convert_ClientCommonConf_To_v1(&legacyCommon) - for _, c := range legacyPxyCfgs { - pxyCfgs = append(pxyCfgs, legacy.Convert_ProxyConf_To_v1(c)) + for _, c := range legacyProxyCfgs { + proxyCfgs = append(proxyCfgs, legacy.Convert_ProxyConf_To_v1(c)) } for _, c := range legacyVisitorCfgs { visitorCfgs = append(visitorCfgs, legacy.Convert_VisitorConf_To_v1(c)) @@ -213,7 +214,7 @@ func LoadClientConfig(path string, strict bool) ( } cliCfg = &allCfg.ClientCommonConfig for _, c := range allCfg.Proxies { - pxyCfgs = append(pxyCfgs, c.ProxyConfigurer) + proxyCfgs = append(proxyCfgs, c.ProxyConfigurer) } for _, c := range allCfg.Visitors { visitorCfgs = append(visitorCfgs, c.VisitorConfigurer) @@ -223,18 +224,18 @@ func LoadClientConfig(path string, strict bool) ( // Load additional config from includes. // legacy ini format already handle this in ParseClientConfig. if len(cliCfg.IncludeConfigFiles) > 0 && !isLegacyFormat { - extPxyCfgs, extVisitorCfgs, err := LoadAdditionalClientConfigs(cliCfg.IncludeConfigFiles, isLegacyFormat, strict) + extProxyCfgs, extVisitorCfgs, err := LoadAdditionalClientConfigs(cliCfg.IncludeConfigFiles, isLegacyFormat, strict) if err != nil { return nil, nil, nil, isLegacyFormat, err } - pxyCfgs = append(pxyCfgs, extPxyCfgs...) + proxyCfgs = append(proxyCfgs, extProxyCfgs...) visitorCfgs = append(visitorCfgs, extVisitorCfgs...) } // Filter by start if len(cliCfg.Start) > 0 { startSet := sets.New(cliCfg.Start...) - pxyCfgs = lo.Filter(pxyCfgs, func(c v1.ProxyConfigurer, _ int) bool { + proxyCfgs = lo.Filter(proxyCfgs, func(c v1.ProxyConfigurer, _ int) bool { return startSet.Has(c.GetBaseConfig().Name) }) visitorCfgs = lo.Filter(visitorCfgs, func(c v1.VisitorConfigurer, _ int) bool { @@ -245,17 +246,17 @@ func LoadClientConfig(path string, strict bool) ( if cliCfg != nil { cliCfg.Complete() } - for _, c := range pxyCfgs { + for _, c := range proxyCfgs { c.Complete(cliCfg.User) } for _, c := range visitorCfgs { c.Complete(cliCfg) } - return cliCfg, pxyCfgs, visitorCfgs, isLegacyFormat, nil + return cliCfg, proxyCfgs, visitorCfgs, isLegacyFormat, nil } func LoadAdditionalClientConfigs(paths []string, isLegacyFormat bool, strict bool) ([]v1.ProxyConfigurer, []v1.VisitorConfigurer, error) { - pxyCfgs := make([]v1.ProxyConfigurer, 0) + proxyCfgs := make([]v1.ProxyConfigurer, 0) visitorCfgs := make([]v1.VisitorConfigurer, 0) for _, path := range paths { absDir, err := filepath.Abs(filepath.Dir(path)) @@ -281,7 +282,7 @@ func LoadAdditionalClientConfigs(paths []string, isLegacyFormat bool, strict boo return nil, nil, fmt.Errorf("load additional config from %s error: %v", absFile, err) } for _, c := range cfg.Proxies { - pxyCfgs = append(pxyCfgs, c.ProxyConfigurer) + proxyCfgs = append(proxyCfgs, c.ProxyConfigurer) } for _, c := range cfg.Visitors { visitorCfgs = append(visitorCfgs, c.VisitorConfigurer) @@ -289,5 +290,5 @@ func LoadAdditionalClientConfigs(paths []string, isLegacyFormat bool, strict boo } } } - return pxyCfgs, visitorCfgs, nil + return proxyCfgs, visitorCfgs, nil } diff --git a/pkg/config/v1/proxy.go b/pkg/config/v1/proxy.go index 41bb1341..0752479f 100644 --- a/pkg/config/v1/proxy.go +++ b/pkg/config/v1/proxy.go @@ -224,7 +224,9 @@ func NewProxyConfigurerByType(proxyType ProxyType) ProxyConfigurer { if !ok { return nil } - return reflect.New(v).Interface().(ProxyConfigurer) + pc := reflect.New(v).Interface().(ProxyConfigurer) + pc.GetBaseConfig().Type = string(proxyType) + return pc } var _ ProxyConfigurer = &TCPProxyConfig{} diff --git a/pkg/config/v1/validation/client.go b/pkg/config/v1/validation/client.go index 38123946..16fc4ccb 100644 --- a/pkg/config/v1/validation/client.go +++ b/pkg/config/v1/validation/client.go @@ -80,7 +80,7 @@ func ValidateClientCommonConfig(c *v1.ClientCommonConfig) (Warning, error) { return warnings, errs } -func ValidateAllClientConfig(c *v1.ClientCommonConfig, pxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) (Warning, error) { +func ValidateAllClientConfig(c *v1.ClientCommonConfig, proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) (Warning, error) { var warnings Warning if c != nil { warning, err := ValidateClientCommonConfig(c) @@ -90,7 +90,7 @@ func ValidateAllClientConfig(c *v1.ClientCommonConfig, pxyCfgs []v1.ProxyConfigu } } - for _, c := range pxyCfgs { + for _, c := range proxyCfgs { if err := ValidateProxyConfigurerForClient(c); err != nil { return warnings, fmt.Errorf("proxy %s: %v", c.GetBaseConfig().Name, err) } diff --git a/pkg/msg/msg.go b/pkg/msg/msg.go index 7a865785..a85fcd5f 100644 --- a/pkg/msg/msg.go +++ b/pkg/msg/msg.go @@ -63,6 +63,15 @@ var msgTypeMap = map[byte]interface{}{ var TypeNameNatHoleResp = reflect.TypeOf(&NatHoleResp{}).Elem().Name() +type ClientSpec struct { + // Due to the support of VirtualClient, frps needs to know the client type in order to + // differentiate the processing logic. + // Optional values: ssh-tunnel + Type string `json:"type,omitempty"` + // If the value is true, the client will not require authentication. + AlwaysAuthPass bool `json:"always_auth_pass,omitempty"` +} + // When frpc start, client send this message to login to server. type Login struct { Version string `json:"version,omitempty"` @@ -75,6 +84,9 @@ type Login struct { RunID string `json:"run_id,omitempty"` Metas map[string]string `json:"metas,omitempty"` + // Currently only effective for VirtualClient. + ClientSpec ClientSpec `json:"client_spec,omitempty"` + // Some global configures. PoolCount int `json:"pool_count,omitempty"` } diff --git a/pkg/plugin/client/http2https.go b/pkg/plugin/client/http2https.go index fd3e44b4..ac54551a 100644 --- a/pkg/plugin/client/http2https.go +++ b/pkg/plugin/client/http2https.go @@ -24,7 +24,7 @@ import ( "net/http/httputil" v1 "github.com/fatedier/frp/pkg/config/v1" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" ) func init() { @@ -79,7 +79,7 @@ func NewHTTP2HTTPSPlugin(options v1.ClientPluginOptions) (Plugin, error) { } func (p *HTTP2HTTPSPlugin) Handle(conn io.ReadWriteCloser, realConn net.Conn, _ *ExtraInfo) { - wrapConn := utilnet.WrapReadWriteCloserToConn(conn, realConn) + wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn) _ = p.l.PutConn(wrapConn) } diff --git a/pkg/plugin/client/http_proxy.go b/pkg/plugin/client/http_proxy.go index 65abf19d..90a99b09 100644 --- a/pkg/plugin/client/http_proxy.go +++ b/pkg/plugin/client/http_proxy.go @@ -29,7 +29,7 @@ import ( libnet "github.com/fatedier/golib/net" v1 "github.com/fatedier/frp/pkg/config/v1" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/util" ) @@ -68,7 +68,7 @@ func (hp *HTTPProxy) Name() string { } func (hp *HTTPProxy) Handle(conn io.ReadWriteCloser, realConn net.Conn, _ *ExtraInfo) { - wrapConn := utilnet.WrapReadWriteCloserToConn(conn, realConn) + wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn) sc, rd := libnet.NewSharedConn(wrapConn) firstBytes := make([]byte, 7) diff --git a/pkg/plugin/client/https2http.go b/pkg/plugin/client/https2http.go index 4a1c85b9..ba66bfae 100644 --- a/pkg/plugin/client/https2http.go +++ b/pkg/plugin/client/https2http.go @@ -26,7 +26,7 @@ import ( v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/transport" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" ) func init() { @@ -98,7 +98,7 @@ func (p *HTTPS2HTTPPlugin) genTLSConfig() (*tls.Config, error) { } func (p *HTTPS2HTTPPlugin) Handle(conn io.ReadWriteCloser, realConn net.Conn, _ *ExtraInfo) { - wrapConn := utilnet.WrapReadWriteCloserToConn(conn, realConn) + wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn) _ = p.l.PutConn(wrapConn) } diff --git a/pkg/plugin/client/https2https.go b/pkg/plugin/client/https2https.go index 81386ac6..a79ea3b1 100644 --- a/pkg/plugin/client/https2https.go +++ b/pkg/plugin/client/https2https.go @@ -26,7 +26,7 @@ import ( v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/transport" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" ) func init() { @@ -104,7 +104,7 @@ func (p *HTTPS2HTTPSPlugin) genTLSConfig() (*tls.Config, error) { } func (p *HTTPS2HTTPSPlugin) Handle(conn io.ReadWriteCloser, realConn net.Conn, _ *ExtraInfo) { - wrapConn := utilnet.WrapReadWriteCloserToConn(conn, realConn) + wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn) _ = p.l.PutConn(wrapConn) } diff --git a/pkg/plugin/client/socks5.go b/pkg/plugin/client/socks5.go index 33e87b53..a230bf55 100644 --- a/pkg/plugin/client/socks5.go +++ b/pkg/plugin/client/socks5.go @@ -24,7 +24,7 @@ import ( gosocks5 "github.com/armon/go-socks5" v1 "github.com/fatedier/frp/pkg/config/v1" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" ) func init() { @@ -52,7 +52,7 @@ func NewSocks5Plugin(options v1.ClientPluginOptions) (p Plugin, err error) { func (sp *Socks5Plugin) Handle(conn io.ReadWriteCloser, realConn net.Conn, _ *ExtraInfo) { defer conn.Close() - wrapConn := utilnet.WrapReadWriteCloserToConn(conn, realConn) + wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn) _ = sp.Server.ServeConn(wrapConn) } diff --git a/pkg/plugin/client/static_file.go b/pkg/plugin/client/static_file.go index faf03f7d..a7db2657 100644 --- a/pkg/plugin/client/static_file.go +++ b/pkg/plugin/client/static_file.go @@ -25,7 +25,7 @@ import ( "github.com/gorilla/mux" v1 "github.com/fatedier/frp/pkg/config/v1" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" ) func init() { @@ -57,8 +57,8 @@ func NewStaticFilePlugin(options v1.ClientPluginOptions) (Plugin, error) { } router := mux.NewRouter() - router.Use(utilnet.NewHTTPAuthMiddleware(opts.HTTPUser, opts.HTTPPassword).SetAuthFailDelay(200 * time.Millisecond).Middleware) - router.PathPrefix(prefix).Handler(utilnet.MakeHTTPGzipHandler(http.StripPrefix(prefix, http.FileServer(http.Dir(opts.LocalPath))))).Methods("GET") + router.Use(netpkg.NewHTTPAuthMiddleware(opts.HTTPUser, opts.HTTPPassword).SetAuthFailDelay(200 * time.Millisecond).Middleware) + router.PathPrefix(prefix).Handler(netpkg.MakeHTTPGzipHandler(http.StripPrefix(prefix, http.FileServer(http.Dir(opts.LocalPath))))).Methods("GET") sp.s = &http.Server{ Handler: router, } @@ -69,7 +69,7 @@ func NewStaticFilePlugin(options v1.ClientPluginOptions) (Plugin, error) { } func (sp *StaticFilePlugin) Handle(conn io.ReadWriteCloser, realConn net.Conn, _ *ExtraInfo) { - wrapConn := utilnet.WrapReadWriteCloserToConn(conn, realConn) + wrapConn := netpkg.WrapReadWriteCloserToConn(conn, realConn) _ = sp.l.PutConn(wrapConn) } diff --git a/pkg/sdk/client/client.go b/pkg/sdk/client/client.go index 395063e5..57bf7746 100644 --- a/pkg/sdk/client/client.go +++ b/pkg/sdk/client/client.go @@ -11,7 +11,7 @@ import ( "strings" "github.com/fatedier/frp/client" - "github.com/fatedier/frp/pkg/util/util" + httppkg "github.com/fatedier/frp/pkg/util/http" ) type Client struct { @@ -115,7 +115,7 @@ func (c *Client) UpdateConfig(content string) error { func (c *Client) setAuthHeader(req *http.Request) { if c.authUser != "" || c.authPwd != "" { - req.Header.Set("Authorization", util.BasicAuth(c.authUser, c.authPwd)) + req.Header.Set("Authorization", httppkg.BasicAuth(c.authUser, c.authPwd)) } } diff --git a/pkg/ssh/gateway.go b/pkg/ssh/gateway.go index 8f87e998..07ae9808 100644 --- a/pkg/ssh/gateway.go +++ b/pkg/ssh/gateway.go @@ -26,21 +26,21 @@ import ( v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/transport" "github.com/fatedier/frp/pkg/util/log" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" ) type Gateway struct { bindPort int ln net.Listener - serverPeerListener *utilnet.InternalListener + peerServerListener *netpkg.InternalListener sshConfig *ssh.ServerConfig } func NewGateway( cfg v1.SSHTunnelGateway, bindAddr string, - serverPeerListener *utilnet.InternalListener, + peerServerListener *netpkg.InternalListener, ) (*Gateway, error) { sshConfig := &ssh.ServerConfig{} @@ -71,15 +71,8 @@ func NewGateway( } sshConfig.AddHostKey(privateKey) + sshConfig.NoClientAuth = cfg.AuthorizedKeysFile == "" sshConfig.PublicKeyCallback = func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { - if cfg.AuthorizedKeysFile == "" { - return &ssh.Permissions{ - Extensions: map[string]string{ - "user": "", - }, - }, nil - } - authorizedKeysMap, err := loadAuthorizedKeysFromFile(cfg.AuthorizedKeysFile) if err != nil { return nil, fmt.Errorf("internal error") @@ -103,7 +96,7 @@ func NewGateway( return &Gateway{ bindPort: cfg.BindPort, ln: ln, - serverPeerListener: serverPeerListener, + peerServerListener: peerServerListener, sshConfig: sshConfig, }, nil } @@ -121,7 +114,7 @@ func (g *Gateway) Run() { func (g *Gateway) handleConn(conn net.Conn) { defer conn.Close() - ts, err := NewTunnelServer(conn, g.sshConfig, g.serverPeerListener) + ts, err := NewTunnelServer(conn, g.sshConfig, g.peerServerListener) if err != nil { return } diff --git a/pkg/ssh/server.go b/pkg/ssh/server.go index 13c87b68..042f6766 100644 --- a/pkg/ssh/server.go +++ b/pkg/ssh/server.go @@ -17,9 +17,11 @@ package ssh import ( "context" "encoding/binary" + "errors" "fmt" "net" "strings" + "sync" "time" libio "github.com/fatedier/golib/io" @@ -27,10 +29,12 @@ import ( "github.com/spf13/cobra" "golang.org/x/crypto/ssh" + "github.com/fatedier/frp/client/proxy" "github.com/fatedier/frp/pkg/config" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" - utilnet "github.com/fatedier/frp/pkg/util/net" + "github.com/fatedier/frp/pkg/util/log" + netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/xlog" "github.com/fatedier/frp/pkg/virtual" @@ -64,15 +68,16 @@ type TunnelServer struct { sc *ssh.ServerConfig vc *virtual.Client - serverPeerListener *utilnet.InternalListener + peerServerListener *netpkg.InternalListener doneCh chan struct{} + closeDoneChOnce sync.Once } -func NewTunnelServer(conn net.Conn, sc *ssh.ServerConfig, serverPeerListener *utilnet.InternalListener) (*TunnelServer, error) { +func NewTunnelServer(conn net.Conn, sc *ssh.ServerConfig, peerServerListener *netpkg.InternalListener) (*TunnelServer, error) { s := &TunnelServer{ underlyingConn: conn, sc: sc, - serverPeerListener: serverPeerListener, + peerServerListener: peerServerListener, doneCh: make(chan struct{}), } return s, nil @@ -94,19 +99,35 @@ func (s *TunnelServer) Run() error { if err != nil { return err } - clientCfg.User = util.EmptyOr(sshConn.Permissions.Extensions["user"], clientCfg.User) + clientCfg.Complete() + if sshConn.Permissions != nil { + clientCfg.User = util.EmptyOr(sshConn.Permissions.Extensions["user"], clientCfg.User) + } pc.Complete(clientCfg.User) - s.vc = virtual.NewClient(clientCfg) - // join workConn and ssh channel - s.vc.SetInWorkConnCallback(func(base *v1.ProxyBaseConfig, workConn net.Conn, m *msg.StartWorkConn) bool { - c, err := s.openConn(addr) - if err != nil { + vc, err := virtual.NewClient(virtual.ClientOptions{ + Common: clientCfg, + Spec: &msg.ClientSpec{ + Type: "ssh-tunnel", + // If ssh does not require authentication, then the virtual client needs to authenticate through a token. + // Otherwise, once ssh authentication is passed, the virtual client does not need to authenticate again. + AlwaysAuthPass: !s.sc.NoClientAuth, + }, + HandleWorkConnCb: func(base *v1.ProxyBaseConfig, workConn net.Conn, m *msg.StartWorkConn) bool { + // join workConn and ssh channel + c, err := s.openConn(addr) + if err != nil { + return false + } + libio.Join(c, workConn) return false - } - libio.Join(c, workConn) - return false + }, }) + if err != nil { + return err + } + s.vc = vc + // transfer connection from virtual client to server peer listener go func() { l := s.vc.PeerListener() @@ -115,21 +136,35 @@ func (s *TunnelServer) Run() error { if err != nil { return } - _ = s.serverPeerListener.PutConn(conn) + _ = s.peerServerListener.PutConn(conn) } }() xl := xlog.New().AddPrefix(xlog.LogPrefix{Name: "sshVirtualClient", Value: "sshVirtualClient", Priority: 100}) ctx := xlog.NewContext(context.Background(), xl) go func() { _ = s.vc.Run(ctx) + // If vc.Run returns, it means that the virtual client has been closed, and the ssh tunnel connection should be closed. + // One scenario is that the virtual client exits due to login failure. + s.closeDoneChOnce.Do(func() { + _ = sshConn.Close() + close(s.doneCh) + }) }() s.vc.UpdateProxyConfigurer([]v1.ProxyConfigurer{pc}) - _ = sshConn.Wait() - _ = sshConn.Close() + if err := s.waitProxyStatusReady(pc.GetBaseConfig().Name, time.Second); err != nil { + log.Warn("wait proxy status ready error: %v", err) + } else { + _ = sshConn.Wait() + } + s.vc.Close() - close(s.doneCh) + log.Trace("ssh tunnel connection from %v closed", sshConn.RemoteAddr()) + s.closeDoneChOnce.Do(func() { + _ = sshConn.Close() + close(s.doneCh) + }) return nil } @@ -217,6 +252,14 @@ func (s *TunnelServer) parseClientAndProxyConfigurer(_ *tcpipForward, extraPaylo if err := cmd.ParseFlags(args); err != nil { return nil, nil, fmt.Errorf("parse flags from ssh client error: %v", err) } + // if name is not set, generate a random one + if pc.GetBaseConfig().Name == "" { + id, err := util.RandIDWithLen(8) + if err != nil { + return nil, nil, fmt.Errorf("generate random id error: %v", err) + } + pc.GetBaseConfig().Name = fmt.Sprintf("sshtunnel-%s-%s", proxyType, id) + } return &clientCfg, pc, nil } @@ -274,6 +317,34 @@ func (s *TunnelServer) openConn(addr *tcpipForward) (net.Conn, error) { } go ssh.DiscardRequests(reqs) - conn := utilnet.WrapReadWriteCloserToConn(channel, s.underlyingConn) + conn := netpkg.WrapReadWriteCloserToConn(channel, s.underlyingConn) return conn, nil } + +func (s *TunnelServer) waitProxyStatusReady(name string, timeout time.Duration) error { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + timer := time.NewTimer(timeout) + defer timer.Stop() + + for { + select { + case <-ticker.C: + ps, err := s.vc.Service().GetProxyStatus(name) + if err != nil { + continue + } + switch ps.Phase { + case proxy.ProxyPhaseRunning: + return nil + case proxy.ProxyPhaseStartErr, proxy.ProxyPhaseClosed: + return errors.New(ps.Err) + } + case <-timer.C: + return fmt.Errorf("wait proxy status ready timeout") + case <-s.doneCh: + return fmt.Errorf("ssh tunnel server closed") + } + } +} diff --git a/pkg/util/util/http.go b/pkg/util/http/http.go similarity index 99% rename from pkg/util/util/http.go rename to pkg/util/http/http.go index a6a25a4c..b85a46a3 100644 --- a/pkg/util/util/http.go +++ b/pkg/util/http/http.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package util +package http import ( "encoding/base64" diff --git a/pkg/util/http/server.go b/pkg/util/http/server.go new file mode 100644 index 00000000..e49cefe4 --- /dev/null +++ b/pkg/util/http/server.go @@ -0,0 +1,128 @@ +// Copyright 2023 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 http + +import ( + "crypto/tls" + "net" + "net/http" + "net/http/pprof" + "strconv" + "time" + + "github.com/gorilla/mux" + + "github.com/fatedier/frp/assets" + v1 "github.com/fatedier/frp/pkg/config/v1" + netpkg "github.com/fatedier/frp/pkg/util/net" +) + +var ( + defaultReadTimeout = 60 * time.Second + defaultWriteTimeout = 60 * time.Second +) + +type Server struct { + addr string + ln net.Listener + tlsCfg *tls.Config + + router *mux.Router + hs *http.Server + + authMiddleware mux.MiddlewareFunc +} + +func NewServer(cfg v1.WebServerConfig) (*Server, error) { + if cfg.AssetsDir != "" { + assets.Load(cfg.AssetsDir) + } + + addr := net.JoinHostPort(cfg.Addr, strconv.Itoa(cfg.Port)) + if addr == ":" { + addr = ":http" + } + + ln, err := net.Listen("tcp", addr) + if err != nil { + return nil, err + } + + router := mux.NewRouter() + hs := &http.Server{ + Addr: addr, + Handler: router, + ReadTimeout: defaultReadTimeout, + WriteTimeout: defaultWriteTimeout, + } + s := &Server{ + addr: addr, + ln: ln, + hs: hs, + router: router, + } + if cfg.PprofEnable { + s.registerPprofHandlers() + } + if cfg.TLS != nil { + cert, err := tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile) + if err != nil { + return nil, err + } + s.tlsCfg = &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + } + s.authMiddleware = netpkg.NewHTTPAuthMiddleware(cfg.User, cfg.Password).SetAuthFailDelay(200 * time.Millisecond).Middleware + return s, nil +} + +func (s *Server) Address() string { + return s.addr +} + +func (s *Server) Run() error { + ln := s.ln + if s.tlsCfg != nil { + ln = tls.NewListener(ln, s.tlsCfg) + } + return s.hs.Serve(ln) +} + +func (s *Server) Close() error { + return s.hs.Close() +} + +type RouterRegisterHelper struct { + Router *mux.Router + AssetsFS http.FileSystem + AuthMiddleware mux.MiddlewareFunc +} + +func (s *Server) RouteRegister(register func(helper *RouterRegisterHelper)) { + register(&RouterRegisterHelper{ + Router: s.router, + AssetsFS: assets.FileSystem, + AuthMiddleware: s.authMiddleware, + }) +} + +func (s *Server) registerPprofHandlers() { + s.router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + s.router.HandleFunc("/debug/pprof/profile", pprof.Profile) + s.router.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + s.router.HandleFunc("/debug/pprof/trace", pprof.Trace) + s.router.PathPrefix("/debug/pprof/").HandlerFunc(pprof.Index) +} diff --git a/pkg/util/net/dns.go b/pkg/util/net/dns.go new file mode 100644 index 00000000..5e1d5ccb --- /dev/null +++ b/pkg/util/net/dns.go @@ -0,0 +1,33 @@ +// Copyright 2023 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 net + +import ( + "context" + "net" +) + +func SetDefaultDNSAddress(dnsAddress string) { + if _, _, err := net.SplitHostPort(dnsAddress); err != nil { + dnsAddress = net.JoinHostPort(dnsAddress, "53") + } + // Change default dns server + net.DefaultResolver = &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + return net.Dial("udp", dnsAddress) + }, + } +} diff --git a/pkg/util/net/listener.go b/pkg/util/net/listener.go index 6f2d8a56..c3aebcd6 100644 --- a/pkg/util/net/listener.go +++ b/pkg/util/net/listener.go @@ -52,7 +52,10 @@ func (l *InternalListener) PutConn(conn net.Conn) error { conn.Close() } }) - return err + if err != nil { + return fmt.Errorf("put conn error: listener is closed") + } + return nil } func (l *InternalListener) Close() error { diff --git a/pkg/util/tcpmux/httpconnect.go b/pkg/util/tcpmux/httpconnect.go index 17989adc..6be29a4a 100644 --- a/pkg/util/tcpmux/httpconnect.go +++ b/pkg/util/tcpmux/httpconnect.go @@ -24,7 +24,7 @@ import ( libnet "github.com/fatedier/golib/net" - "github.com/fatedier/frp/pkg/util/util" + httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/pkg/util/vhost" ) @@ -59,10 +59,10 @@ func (muxer *HTTPConnectTCPMuxer) readHTTPConnectRequest(rd io.Reader) (host, ht return } - host, _ = util.CanonicalHost(req.Host) + host, _ = httppkg.CanonicalHost(req.Host) proxyAuth := req.Header.Get("Proxy-Authorization") if proxyAuth != "" { - httpUser, httpPwd, _ = util.ParseBasicAuth(proxyAuth) + httpUser, httpPwd, _ = httppkg.ParseBasicAuth(proxyAuth) } return } @@ -71,7 +71,7 @@ func (muxer *HTTPConnectTCPMuxer) sendConnectResponse(c net.Conn, _ map[string]s if muxer.passthrough { return nil } - res := util.OkResponse() + res := httppkg.OkResponse() if res.Body != nil { defer res.Body.Close() } @@ -85,7 +85,7 @@ func (muxer *HTTPConnectTCPMuxer) auth(c net.Conn, username, password string, re return true, nil } - resp := util.ProxyUnauthorizedResponse() + resp := httppkg.ProxyUnauthorizedResponse() if resp.Body != nil { defer resp.Body.Close() } diff --git a/pkg/util/vhost/http.go b/pkg/util/vhost/http.go index 1a5bea0b..72ab4775 100644 --- a/pkg/util/vhost/http.go +++ b/pkg/util/vhost/http.go @@ -31,8 +31,8 @@ import ( libio "github.com/fatedier/golib/io" "github.com/fatedier/golib/pool" - frpLog "github.com/fatedier/frp/pkg/util/log" - "github.com/fatedier/frp/pkg/util/util" + httppkg "github.com/fatedier/frp/pkg/util/http" + logpkg "github.com/fatedier/frp/pkg/util/log" ) var ErrNoRouteFound = errors.New("no route found") @@ -61,7 +61,7 @@ func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) * Director: func(req *http.Request) { req.URL.Scheme = "http" reqRouteInfo := req.Context().Value(RouteInfoKey).(*RequestRouteInfo) - oldHost, _ := util.CanonicalHost(reqRouteInfo.Host) + oldHost, _ := httppkg.CanonicalHost(reqRouteInfo.Host) rc := rp.GetRouteConfig(oldHost, reqRouteInfo.URL, reqRouteInfo.HTTPUser) if rc != nil { @@ -74,7 +74,7 @@ func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) * // ignore error here, it will use CreateConnFn instead later endpoint, _ = rc.ChooseEndpointFn() reqRouteInfo.Endpoint = endpoint - frpLog.Trace("choose endpoint name [%s] for http request host [%s] path [%s] httpuser [%s]", + logpkg.Trace("choose endpoint name [%s] for http request host [%s] path [%s] httpuser [%s]", endpoint, oldHost, reqRouteInfo.URL, reqRouteInfo.HTTPUser) } // Set {domain}.{location}.{routeByHTTPUser}.{endpoint} as URL host here to let http transport reuse connections. @@ -116,7 +116,7 @@ func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) * BufferPool: newWrapPool(), ErrorLog: log.New(newWrapLogger(), "", 0), ErrorHandler: func(rw http.ResponseWriter, req *http.Request, err error) { - frpLog.Warn("do http proxy request [host: %s] error: %v", req.Host, err) + logpkg.Warn("do http proxy request [host: %s] error: %v", req.Host, err) rw.WriteHeader(http.StatusNotFound) _, _ = rw.Write(getNotFoundPageContent()) }, @@ -143,7 +143,7 @@ func (rp *HTTPReverseProxy) UnRegister(routeCfg RouteConfig) { func (rp *HTTPReverseProxy) GetRouteConfig(domain, location, routeByHTTPUser string) *RouteConfig { vr, ok := rp.getVhost(domain, location, routeByHTTPUser) if ok { - frpLog.Debug("get new HTTP request host [%s] path [%s] httpuser [%s]", domain, location, routeByHTTPUser) + logpkg.Debug("get new HTTP request host [%s] path [%s] httpuser [%s]", domain, location, routeByHTTPUser) return vr.payload.(*RouteConfig) } return nil @@ -159,7 +159,7 @@ func (rp *HTTPReverseProxy) GetHeaders(domain, location, routeByHTTPUser string) // CreateConnection create a new connection by route config func (rp *HTTPReverseProxy) CreateConnection(reqRouteInfo *RequestRouteInfo, byEndpoint bool) (net.Conn, error) { - host, _ := util.CanonicalHost(reqRouteInfo.Host) + host, _ := httppkg.CanonicalHost(reqRouteInfo.Host) vr, ok := rp.getVhost(host, reqRouteInfo.URL, reqRouteInfo.HTTPUser) if ok { if byEndpoint { @@ -303,7 +303,7 @@ func (rp *HTTPReverseProxy) injectRequestInfoToCtx(req *http.Request) *http.Requ } func (rp *HTTPReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) { - domain, _ := util.CanonicalHost(req.Host) + domain, _ := httppkg.CanonicalHost(req.Host) location := req.URL.Path user, passwd, _ := req.BasicAuth() if !rp.CheckAuth(domain, location, user, user, passwd) { @@ -333,6 +333,6 @@ type wrapLogger struct{} func newWrapLogger() *wrapLogger { return &wrapLogger{} } func (l *wrapLogger) Write(p []byte) (n int, err error) { - frpLog.Warn("%s", string(bytes.TrimRight(p, "\n"))) + logpkg.Warn("%s", string(bytes.TrimRight(p, "\n"))) return len(p), nil } diff --git a/pkg/util/vhost/resource.go b/pkg/util/vhost/resource.go index d78082b2..bf91e133 100644 --- a/pkg/util/vhost/resource.go +++ b/pkg/util/vhost/resource.go @@ -20,7 +20,7 @@ import ( "net/http" "os" - frpLog "github.com/fatedier/frp/pkg/util/log" + logpkg "github.com/fatedier/frp/pkg/util/log" "github.com/fatedier/frp/pkg/util/version" ) @@ -58,7 +58,7 @@ func getNotFoundPageContent() []byte { if NotFoundPagePath != "" { buf, err = os.ReadFile(NotFoundPagePath) if err != nil { - frpLog.Warn("read custom 404 page error: %v", err) + logpkg.Warn("read custom 404 page error: %v", err) buf = []byte(NotFound) } } else { diff --git a/pkg/util/vhost/vhost.go b/pkg/util/vhost/vhost.go index 29123b69..d529e424 100644 --- a/pkg/util/vhost/vhost.go +++ b/pkg/util/vhost/vhost.go @@ -22,7 +22,7 @@ import ( "github.com/fatedier/golib/errors" "github.com/fatedier/frp/pkg/util/log" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/xlog" ) @@ -284,7 +284,7 @@ func (l *Listener) Accept() (net.Conn, error) { xl.Debug("rewrite host to [%s] success", l.rewriteHost) conn = sConn } - return utilnet.NewContextConn(l.ctx, conn), nil + return netpkg.NewContextConn(l.ctx, conn), nil } func (l *Listener) Close() error { diff --git a/pkg/virtual/client.go b/pkg/virtual/client.go index d0369a1a..96835a48 100644 --- a/pkg/virtual/client.go +++ b/pkg/virtual/client.go @@ -21,55 +21,70 @@ import ( "github.com/fatedier/frp/client" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/msg" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" ) +type ClientOptions struct { + Common *v1.ClientCommonConfig + Spec *msg.ClientSpec + HandleWorkConnCb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool +} + type Client struct { - l *utilnet.InternalListener + l *netpkg.InternalListener svr *client.Service } -func NewClient(cfg *v1.ClientCommonConfig) *Client { - cfg.Complete() +func NewClient(options ClientOptions) (*Client, error) { + if options.Common != nil { + options.Common.Complete() + } - ln := utilnet.NewInternalListener() - - svr := client.NewService(cfg, nil, nil, "") - svr.SetConnectorCreator(func(context.Context, *v1.ClientCommonConfig) client.Connector { - return &pipeConnector{ - peerListener: ln, - } - }) + ln := netpkg.NewInternalListener() + serviceOptions := client.ServiceOptions{ + Common: options.Common, + ClientSpec: options.Spec, + ConnectorCreator: func(context.Context, *v1.ClientCommonConfig) client.Connector { + return &pipeConnector{ + peerListener: ln, + } + }, + HandleWorkConnCb: options.HandleWorkConnCb, + } + svr, err := client.NewService(serviceOptions) + if err != nil { + return nil, err + } return &Client{ l: ln, svr: svr, - } + }, nil } func (c *Client) PeerListener() net.Listener { return c.l } -func (c *Client) SetInWorkConnCallback(cb func(*v1.ProxyBaseConfig, net.Conn, *msg.StartWorkConn) bool) { - c.svr.SetInWorkConnCallback(cb) -} - func (c *Client) UpdateProxyConfigurer(proxyCfgs []v1.ProxyConfigurer) { - _ = c.svr.ReloadConf(proxyCfgs, nil) + _ = c.svr.UpdateAllConfigurer(proxyCfgs, nil) } func (c *Client) Run(ctx context.Context) error { return c.svr.Run(ctx) } +func (c *Client) Service() *client.Service { + return c.svr +} + func (c *Client) Close() { - c.l.Close() c.svr.Close() + c.l.Close() } type pipeConnector struct { - peerListener *utilnet.InternalListener + peerListener *netpkg.InternalListener } func (pc *pipeConnector) Open() error { diff --git a/server/control.go b/server/control.go index e651a97e..dbb1af0a 100644 --- a/server/control.go +++ b/server/control.go @@ -32,7 +32,7 @@ import ( "github.com/fatedier/frp/pkg/msg" plugin "github.com/fatedier/frp/pkg/plugin/server" "github.com/fatedier/frp/pkg/transport" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/version" "github.com/fatedier/frp/pkg/util/wait" @@ -150,6 +150,7 @@ type Control struct { doneCh chan struct{} } +// TODO(fatedier): Referencing the implementation of frpc, encapsulate the input parameters as SessionContext. func NewControl( ctx context.Context, rc *controller.ResourceController, @@ -157,6 +158,7 @@ func NewControl( pluginManager *plugin.Manager, authVerifier auth.Verifier, ctlConn net.Conn, + ctlConnEncrypted bool, loginMsg *msg.Login, serverCfg *v1.ServerConfig, ) (*Control, error) { @@ -183,11 +185,15 @@ func NewControl( } ctl.lastPing.Store(time.Now()) - cryptoRW, err := utilnet.NewCryptoReadWriter(ctl.conn, []byte(ctl.serverCfg.Auth.Token)) - if err != nil { - return nil, err + if ctlConnEncrypted { + cryptoRW, err := netpkg.NewCryptoReadWriter(ctl.conn, []byte(ctl.serverCfg.Auth.Token)) + if err != nil { + return nil, err + } + ctl.msgDispatcher = msg.NewDispatcher(cryptoRW) + } else { + ctl.msgDispatcher = msg.NewDispatcher(ctl.conn) } - ctl.msgDispatcher = msg.NewDispatcher(cryptoRW) ctl.registerMsgHandlers() ctl.msgTransporter = transport.NewMessageTransporter(ctl.msgDispatcher.SendChannel()) return ctl, nil @@ -300,6 +306,7 @@ func (ctl *Control) heartbeatWorker() { go wait.Until(func() { if time.Since(ctl.lastPing.Load().(time.Time)) > time.Duration(ctl.serverCfg.Transport.HeartbeatTimeout)*time.Second { xl.Warn("heartbeat timeout") + ctl.conn.Close() return } }, time.Second, ctl.doneCh) @@ -555,6 +562,5 @@ func (ctl *Control) CloseProxy(closeMsg *msg.CloseProxy) (err error) { go func() { _ = ctl.pluginManager.CloseProxy(notifyContent) }() - return } diff --git a/server/dashboard.go b/server/dashboard.go deleted file mode 100644 index 1f290cf9..00000000 --- a/server/dashboard.go +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright 2017 fatedier, fatedier@gmail.com -// -// 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 server - -import ( - "crypto/tls" - "net" - "net/http" - "net/http/pprof" - "time" - - "github.com/gorilla/mux" - "github.com/prometheus/client_golang/prometheus/promhttp" - - "github.com/fatedier/frp/assets" - utilnet "github.com/fatedier/frp/pkg/util/net" -) - -var ( - httpServerReadTimeout = 60 * time.Second - httpServerWriteTimeout = 60 * time.Second -) - -func (svr *Service) RunDashboardServer(address string) (err error) { - // url router - router := mux.NewRouter() - router.HandleFunc("/healthz", svr.Healthz) - - // debug - if svr.cfg.WebServer.PprofEnable { - router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) - router.HandleFunc("/debug/pprof/profile", pprof.Profile) - router.HandleFunc("/debug/pprof/symbol", pprof.Symbol) - router.HandleFunc("/debug/pprof/trace", pprof.Trace) - router.PathPrefix("/debug/pprof/").HandlerFunc(pprof.Index) - } - - subRouter := router.NewRoute().Subrouter() - - user, passwd := svr.cfg.WebServer.User, svr.cfg.WebServer.Password - subRouter.Use(utilnet.NewHTTPAuthMiddleware(user, passwd).SetAuthFailDelay(200 * time.Millisecond).Middleware) - - // metrics - if svr.cfg.EnablePrometheus { - subRouter.Handle("/metrics", promhttp.Handler()) - } - - // api, see dashboard_api.go - subRouter.HandleFunc("/api/serverinfo", svr.APIServerInfo).Methods("GET") - subRouter.HandleFunc("/api/proxy/{type}", svr.APIProxyByType).Methods("GET") - subRouter.HandleFunc("/api/proxy/{type}/{name}", svr.APIProxyByTypeAndName).Methods("GET") - subRouter.HandleFunc("/api/traffic/{name}", svr.APIProxyTraffic).Methods("GET") - - // view - subRouter.Handle("/favicon.ico", http.FileServer(assets.FileSystem)).Methods("GET") - subRouter.PathPrefix("/static/").Handler(utilnet.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(assets.FileSystem)))).Methods("GET") - - subRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/static/", http.StatusMovedPermanently) - }) - - server := &http.Server{ - Addr: address, - Handler: router, - ReadTimeout: httpServerReadTimeout, - WriteTimeout: httpServerWriteTimeout, - } - ln, err := net.Listen("tcp", address) - if err != nil { - return err - } - - if svr.cfg.WebServer.TLS != nil { - cert, err := tls.LoadX509KeyPair(svr.cfg.WebServer.TLS.CertFile, svr.cfg.WebServer.TLS.KeyFile) - if err != nil { - return err - } - tlsCfg := &tls.Config{ - Certificates: []tls.Certificate{cert}, - } - ln = tls.NewListener(ln, tlsCfg) - } - go func() { - _ = server.Serve(ln) - }() - return -} diff --git a/server/dashboard_api.go b/server/dashboard_api.go index b5a923f9..27944b9a 100644 --- a/server/dashboard_api.go +++ b/server/dashboard_api.go @@ -19,19 +19,52 @@ import ( "net/http" "github.com/gorilla/mux" + "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/fatedier/frp/pkg/config/types" v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/metrics/mem" + httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/pkg/util/log" + netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/version" ) +// TODO(fatedier): add an API to clean status of all offline proxies. + type GeneralResponse struct { Code int Msg string } +func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) { + helper.Router.HandleFunc("/healthz", svr.healthz) + subRouter := helper.Router.NewRoute().Subrouter() + + subRouter.Use(helper.AuthMiddleware.Middleware) + + // metrics + if svr.cfg.EnablePrometheus { + subRouter.Handle("/metrics", promhttp.Handler()) + } + + // apis + subRouter.HandleFunc("/api/serverinfo", svr.apiServerInfo).Methods("GET") + subRouter.HandleFunc("/api/proxy/{type}", svr.apiProxyByType).Methods("GET") + subRouter.HandleFunc("/api/proxy/{type}/{name}", svr.apiProxyByTypeAndName).Methods("GET") + subRouter.HandleFunc("/api/traffic/{name}", svr.apiProxyTraffic).Methods("GET") + + // view + subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET") + subRouter.PathPrefix("/static/").Handler( + netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))), + ).Methods("GET") + + subRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/static/", http.StatusMovedPermanently) + }) +} + type serverInfoResp struct { Version string `json:"version"` BindPort int `json:"bindPort"` @@ -55,12 +88,12 @@ type serverInfoResp struct { } // /healthz -func (svr *Service) Healthz(w http.ResponseWriter, _ *http.Request) { +func (svr *Service) healthz(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) } // /api/serverinfo -func (svr *Service) APIServerInfo(w http.ResponseWriter, r *http.Request) { +func (svr *Service) apiServerInfo(w http.ResponseWriter, r *http.Request) { res := GeneralResponse{Code: 200} defer func() { log.Info("Http response [%s]: code [%d]", r.URL.Path, res.Code) @@ -177,7 +210,7 @@ type GetProxyInfoResp struct { } // /api/proxy/:type -func (svr *Service) APIProxyByType(w http.ResponseWriter, r *http.Request) { +func (svr *Service) apiProxyByType(w http.ResponseWriter, r *http.Request) { res := GeneralResponse{Code: 200} params := mux.Vars(r) proxyType := params["type"] @@ -245,7 +278,7 @@ type GetProxyStatsResp struct { } // /api/proxy/:type/:name -func (svr *Service) APIProxyByTypeAndName(w http.ResponseWriter, r *http.Request) { +func (svr *Service) apiProxyByTypeAndName(w http.ResponseWriter, r *http.Request) { res := GeneralResponse{Code: 200} params := mux.Vars(r) proxyType := params["type"] @@ -314,7 +347,7 @@ type GetProxyTrafficResp struct { TrafficOut []int64 `json:"trafficOut"` } -func (svr *Service) APIProxyTraffic(w http.ResponseWriter, r *http.Request) { +func (svr *Service) apiProxyTraffic(w http.ResponseWriter, r *http.Request) { res := GeneralResponse{Code: 200} params := mux.Vars(r) name := params["name"] diff --git a/server/proxy/http.go b/server/proxy/http.go index cafaf8f3..44a462b7 100644 --- a/server/proxy/http.go +++ b/server/proxy/http.go @@ -24,7 +24,7 @@ import ( v1 "github.com/fatedier/frp/pkg/config/v1" "github.com/fatedier/frp/pkg/util/limit" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/vhost" "github.com/fatedier/frp/server/metrics" @@ -180,8 +180,8 @@ func (pxy *HTTPProxy) GetRealConn(remoteAddr string) (workConn net.Conn, err err }) } - workConn = utilnet.WrapReadWriteCloserToConn(rwc, tmpConn) - workConn = utilnet.WrapStatsConn(workConn, pxy.updateStatsAfterClosedConn) + workConn = netpkg.WrapReadWriteCloserToConn(rwc, tmpConn) + workConn = netpkg.WrapStatsConn(workConn, pxy.updateStatsAfterClosedConn) metrics.Server.OpenConnection(pxy.GetName(), pxy.GetConfigurer().GetBaseConfig().Type) return } diff --git a/server/proxy/proxy.go b/server/proxy/proxy.go index fe6f781b..f5c850e9 100644 --- a/server/proxy/proxy.go +++ b/server/proxy/proxy.go @@ -32,7 +32,7 @@ import ( "github.com/fatedier/frp/pkg/msg" plugin "github.com/fatedier/frp/pkg/plugin/server" "github.com/fatedier/frp/pkg/util/limit" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/xlog" "github.com/fatedier/frp/server/controller" "github.com/fatedier/frp/server/metrics" @@ -130,7 +130,7 @@ func (pxy *BaseProxy) GetWorkConnFromPool(src, dst net.Addr) (workConn net.Conn, } xl.Debug("get a new work connection: [%s]", workConn.RemoteAddr().String()) xl.Spawn().AppendPrefix(pxy.GetName()) - workConn = utilnet.NewContextConn(pxy.ctx, workConn) + workConn = netpkg.NewContextConn(pxy.ctx, workConn) var ( srcAddr string diff --git a/server/proxy/udp.go b/server/proxy/udp.go index 772c3f0d..ea970818 100644 --- a/server/proxy/udp.go +++ b/server/proxy/udp.go @@ -30,7 +30,7 @@ import ( "github.com/fatedier/frp/pkg/msg" "github.com/fatedier/frp/pkg/proto/udp" "github.com/fatedier/frp/pkg/util/limit" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/server/metrics" ) @@ -222,7 +222,7 @@ func (pxy *UDPProxy) Run() (remoteAddr string, err error) { }) } - pxy.workConn = utilnet.WrapReadWriteCloserToConn(rwc, workConn) + pxy.workConn = netpkg.WrapReadWriteCloserToConn(rwc, workConn) ctx, cancel := context.WithCancel(context.Background()) go workConnReaderFn(pxy.workConn) go workConnSenderFn(pxy.workConn, ctx) diff --git a/server/service.go b/server/service.go index 02efec91..c2410b06 100644 --- a/server/service.go +++ b/server/service.go @@ -30,7 +30,6 @@ import ( quic "github.com/quic-go/quic-go" "github.com/samber/lo" - "github.com/fatedier/frp/assets" "github.com/fatedier/frp/pkg/auth" v1 "github.com/fatedier/frp/pkg/config/v1" modelmetrics "github.com/fatedier/frp/pkg/metrics" @@ -39,8 +38,9 @@ import ( plugin "github.com/fatedier/frp/pkg/plugin/server" "github.com/fatedier/frp/pkg/ssh" "github.com/fatedier/frp/pkg/transport" + httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/pkg/util/log" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/tcpmux" "github.com/fatedier/frp/pkg/util/util" "github.com/fatedier/frp/pkg/util/version" @@ -79,7 +79,8 @@ type Service struct { // Accept frp tls connections tlsListener net.Listener - virtualListener *utilnet.InternalListener + // Accept pipe connections from ssh tunnel gateway + sshTunnelListener *netpkg.InternalListener // Manage all controllers ctlManager *ControlManager @@ -96,6 +97,9 @@ type Service struct { // All resource managers and controllers rc *controller.ResourceController + // web server for dashboard UI and apis + webServer *httppkg.Server + sshTunnelGateway *ssh.Gateway // Verifies authentication based on selected method @@ -111,16 +115,30 @@ type Service struct { cancel context.CancelFunc } -func NewService(cfg *v1.ServerConfig) (svr *Service, err error) { +func NewService(cfg *v1.ServerConfig) (*Service, error) { tlsConfig, err := transport.NewServerTLSConfig( cfg.Transport.TLS.CertFile, cfg.Transport.TLS.KeyFile, cfg.Transport.TLS.TrustedCaFile) if err != nil { - return + return nil, err } - svr = &Service{ + var webServer *httppkg.Server + if cfg.WebServer.Port > 0 { + ws, err := httppkg.NewServer(cfg.WebServer) + if err != nil { + return nil, err + } + webServer = ws + + modelmetrics.EnableMem() + if cfg.EnablePrometheus { + modelmetrics.EnablePrometheus() + } + } + + svr := &Service{ ctlManager: NewControlManager(), pxyManager: proxy.NewManager(), pluginManager: plugin.NewManager(), @@ -129,12 +147,16 @@ func NewService(cfg *v1.ServerConfig) (svr *Service, err error) { TCPPortManager: ports.NewManager("tcp", cfg.ProxyBindAddr, cfg.AllowPorts), UDPPortManager: ports.NewManager("udp", cfg.ProxyBindAddr, cfg.AllowPorts), }, - virtualListener: utilnet.NewInternalListener(), - httpVhostRouter: vhost.NewRouters(), - authVerifier: auth.NewAuthVerifier(cfg.Auth), - tlsConfig: tlsConfig, - cfg: cfg, - ctx: context.Background(), + sshTunnelListener: netpkg.NewInternalListener(), + httpVhostRouter: vhost.NewRouters(), + authVerifier: auth.NewAuthVerifier(cfg.Auth), + webServer: webServer, + tlsConfig: tlsConfig, + cfg: cfg, + ctx: context.Background(), + } + if webServer != nil { + webServer.RouteRegister(svr.registerRouteHandlers) } // Create tcpmux httpconnect multiplexer. @@ -143,14 +165,12 @@ func NewService(cfg *v1.ServerConfig) (svr *Service, err error) { address := net.JoinHostPort(cfg.ProxyBindAddr, strconv.Itoa(cfg.TCPMuxHTTPConnectPort)) l, err = net.Listen("tcp", address) if err != nil { - err = fmt.Errorf("create server listener error, %v", err) - return + return nil, fmt.Errorf("create server listener error, %v", err) } svr.rc.TCPMuxHTTPConnectMuxer, err = tcpmux.NewHTTPConnectTCPMuxer(l, cfg.TCPMuxPassthrough, vhostReadWriteTimeout) if err != nil { - err = fmt.Errorf("create vhost tcpMuxer error, %v", err) - return + return nil, fmt.Errorf("create vhost tcpMuxer error, %v", err) } log.Info("tcpmux httpconnect multiplexer listen on %s, passthough: %v", address, cfg.TCPMuxPassthrough) } @@ -191,8 +211,7 @@ func NewService(cfg *v1.ServerConfig) (svr *Service, err error) { address := net.JoinHostPort(cfg.BindAddr, strconv.Itoa(cfg.BindPort)) ln, err := net.Listen("tcp", address) if err != nil { - err = fmt.Errorf("create server listener error, %v", err) - return + return nil, fmt.Errorf("create server listener error, %v", err) } svr.muxer = mux.NewMux(ln) @@ -208,10 +227,9 @@ func NewService(cfg *v1.ServerConfig) (svr *Service, err error) { // Listen for accepting connections from client using kcp protocol. if cfg.KCPBindPort > 0 { address := net.JoinHostPort(cfg.BindAddr, strconv.Itoa(cfg.KCPBindPort)) - svr.kcpListener, err = utilnet.ListenKcp(address) + svr.kcpListener, err = netpkg.ListenKcp(address) if err != nil { - err = fmt.Errorf("listen on kcp udp address %s error: %v", address, err) - return + return nil, fmt.Errorf("listen on kcp udp address %s error: %v", address, err) } log.Info("frps kcp listen on udp %s", address) } @@ -226,28 +244,26 @@ func NewService(cfg *v1.ServerConfig) (svr *Service, err error) { KeepAlivePeriod: time.Duration(cfg.Transport.QUIC.KeepalivePeriod) * time.Second, }) if err != nil { - err = fmt.Errorf("listen on quic udp address %s error: %v", address, err) - return + return nil, fmt.Errorf("listen on quic udp address %s error: %v", address, err) } log.Info("frps quic listen on %s", address) } if cfg.SSHTunnelGateway.BindPort > 0 { - sshGateway, err := ssh.NewGateway(cfg.SSHTunnelGateway, cfg.ProxyBindAddr, svr.virtualListener) + sshGateway, err := ssh.NewGateway(cfg.SSHTunnelGateway, cfg.ProxyBindAddr, svr.sshTunnelListener) if err != nil { - err = fmt.Errorf("create ssh gateway error: %v", err) - return nil, err + return nil, fmt.Errorf("create ssh gateway error: %v", err) } svr.sshTunnelGateway = sshGateway log.Info("frps sshTunnelGateway listen on port %d", cfg.SSHTunnelGateway.BindPort) } // Listen for accepting connections from client using websocket protocol. - websocketPrefix := []byte("GET " + utilnet.FrpWebsocketPath) + websocketPrefix := []byte("GET " + netpkg.FrpWebsocketPath) websocketLn := svr.muxer.Listen(0, uint32(len(websocketPrefix)), func(data []byte) bool { return bytes.Equal(data, websocketPrefix) }) - svr.websocketListener = utilnet.NewWebsocketListener(websocketLn) + svr.websocketListener = netpkg.NewWebsocketListener(websocketLn) // Create http vhost muxer. if cfg.VhostHTTPPort > 0 { @@ -267,8 +283,7 @@ func NewService(cfg *v1.ServerConfig) (svr *Service, err error) { } else { l, err = net.Listen("tcp", address) if err != nil { - err = fmt.Errorf("create vhost http listener error, %v", err) - return + return nil, fmt.Errorf("create vhost http listener error, %v", err) } } go func() { @@ -286,55 +301,30 @@ func NewService(cfg *v1.ServerConfig) (svr *Service, err error) { address := net.JoinHostPort(cfg.ProxyBindAddr, strconv.Itoa(cfg.VhostHTTPSPort)) l, err = net.Listen("tcp", address) if err != nil { - err = fmt.Errorf("create server listener error, %v", err) - return + return nil, fmt.Errorf("create server listener error, %v", err) } log.Info("https service listen on %s", address) } svr.rc.VhostHTTPSMuxer, err = vhost.NewHTTPSMuxer(l, vhostReadWriteTimeout) if err != nil { - err = fmt.Errorf("create vhost httpsMuxer error, %v", err) - return + return nil, fmt.Errorf("create vhost httpsMuxer error, %v", err) } } // frp tls listener svr.tlsListener = svr.muxer.Listen(2, 1, func(data []byte) bool { // tls first byte can be 0x16 only when vhost https port is not same with bind port - return int(data[0]) == utilnet.FRPTLSHeadByte || int(data[0]) == 0x16 + return int(data[0]) == netpkg.FRPTLSHeadByte || int(data[0]) == 0x16 }) // Create nat hole controller. nc, err := nathole.NewController(time.Duration(cfg.NatHoleAnalysisDataReserveHours) * time.Hour) if err != nil { - err = fmt.Errorf("create nat hole controller error, %v", err) - return + return nil, fmt.Errorf("create nat hole controller error, %v", err) } svr.rc.NatHoleController = nc - - var statsEnable bool - // Create dashboard web server. - if cfg.WebServer.Port > 0 { - // Init dashboard assets - assets.Load(cfg.WebServer.AssetsDir) - - address := net.JoinHostPort(cfg.WebServer.Addr, strconv.Itoa(cfg.WebServer.Port)) - err = svr.RunDashboardServer(address) - if err != nil { - err = fmt.Errorf("create dashboard web server error, %v", err) - return - } - log.Info("Dashboard listen on %s", address) - statsEnable = true - } - if statsEnable { - modelmetrics.EnableMem() - if cfg.EnablePrometheus { - modelmetrics.EnablePrometheus() - } - } - return + return svr, nil } func (svr *Service) Run(ctx context.Context) { @@ -342,7 +332,17 @@ func (svr *Service) Run(ctx context.Context) { svr.ctx = ctx svr.cancel = cancel - go svr.HandleListener(svr.virtualListener, true) + // run dashboard web server. + if svr.webServer != nil { + go func() { + log.Info("dashboard listen on %s", svr.webServer.Address()) + if err := svr.webServer.Run(); err != nil { + log.Warn("dashboard server exit with error: %v", err) + } + }() + } + + go svr.HandleListener(svr.sshTunnelListener, true) if svr.kcpListener != nil { go svr.HandleListener(svr.kcpListener, false) @@ -398,7 +398,7 @@ func (svr *Service) Close() error { return nil } -func (svr *Service) handleConnection(ctx context.Context, conn net.Conn) { +func (svr *Service) handleConnection(ctx context.Context, conn net.Conn, internal bool) { xl := xlog.FromContextSafe(ctx) var ( @@ -424,7 +424,7 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn) { retContent, err := svr.pluginManager.Login(content) if err == nil { m = &retContent.Login - err = svr.RegisterControl(conn, m) + err = svr.RegisterControl(conn, m, internal) } // If login failed, send error message there. @@ -461,6 +461,9 @@ func (svr *Service) handleConnection(ctx context.Context, conn net.Conn) { } } +// HandleListener accepts connections from client and call handleConnection to handle them. +// If internal is true, it means that this listener is used for internal communication like ssh tunnel gateway. +// TODO(fatedier): Pass some parameters of listener/connection through context to avoid passing too many parameters. func (svr *Service) HandleListener(l net.Listener, internal bool) { // Listen for incoming connections from client. for { @@ -473,19 +476,21 @@ func (svr *Service) HandleListener(l net.Listener, internal bool) { xl := xlog.New() ctx := context.Background() - c = utilnet.NewContextConn(xlog.NewContext(ctx, xl), c) + c = netpkg.NewContextConn(xlog.NewContext(ctx, xl), c) - log.Trace("start check TLS connection...") - originConn := c - forceTLS := svr.cfg.Transport.TLS.Force && !internal - var isTLS, custom bool - c, isTLS, custom, err = utilnet.CheckAndEnableTLSServerConnWithTimeout(c, svr.tlsConfig, forceTLS, connReadTimeout) - if err != nil { - log.Warn("CheckAndEnableTLSServerConnWithTimeout error: %v", err) - originConn.Close() - continue + if !internal { + log.Trace("start check TLS connection...") + originConn := c + forceTLS := svr.cfg.Transport.TLS.Force + var isTLS, custom bool + c, isTLS, custom, err = netpkg.CheckAndEnableTLSServerConnWithTimeout(c, svr.tlsConfig, forceTLS, connReadTimeout) + if err != nil { + log.Warn("CheckAndEnableTLSServerConnWithTimeout error: %v", err) + originConn.Close() + continue + } + log.Trace("check TLS connection success, isTLS: %v custom: %v internal: %v", isTLS, custom, internal) } - log.Trace("check TLS connection success, isTLS: %v custom: %v", isTLS, custom) // Start a new goroutine to handle connection. go func(ctx context.Context, frpConn net.Conn) { @@ -508,10 +513,10 @@ func (svr *Service) HandleListener(l net.Listener, internal bool) { session.Close() return } - go svr.handleConnection(ctx, stream) + go svr.handleConnection(ctx, stream, internal) } } else { - svr.handleConnection(ctx, frpConn) + svr.handleConnection(ctx, frpConn, internal) } }(ctx, c) } @@ -534,13 +539,13 @@ func (svr *Service) HandleQUICListener(l *quic.Listener) { _ = frpConn.CloseWithError(0, "") return } - go svr.handleConnection(ctx, utilnet.QuicStreamToNetConn(stream, frpConn)) + go svr.handleConnection(ctx, netpkg.QuicStreamToNetConn(stream, frpConn), false) } }(context.Background(), c) } } -func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login) error { +func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login, internal bool) error { // If client's RunID is empty, it's a new client, we just create a new controller. // Otherwise, we check if there is one controller has the same run id. If so, we release previous controller and start new one. var err error @@ -551,7 +556,7 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login) error } } - ctx := utilnet.NewContextFromConn(ctlConn) + ctx := netpkg.NewContextFromConn(ctlConn) xl := xlog.FromContextSafe(ctx) xl.AppendPrefix(loginMsg.RunID) ctx = xlog.NewContext(ctx, xl) @@ -559,11 +564,16 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login) error ctlConn.RemoteAddr().String(), loginMsg.Version, loginMsg.Hostname, loginMsg.Os, loginMsg.Arch) // Check auth. - if err := svr.authVerifier.VerifyLogin(loginMsg); err != nil { + authVerifier := svr.authVerifier + if internal && loginMsg.ClientSpec.AlwaysAuthPass { + authVerifier = auth.AlwaysPassVerifier + } + if err := authVerifier.VerifyLogin(loginMsg); err != nil { return err } - ctl, err := NewControl(ctx, svr.rc, svr.pxyManager, svr.pluginManager, svr.authVerifier, ctlConn, loginMsg, svr.cfg) + // TODO(fatedier): use SessionContext + ctl, err := NewControl(ctx, svr.rc, svr.pxyManager, svr.pluginManager, authVerifier, ctlConn, !internal, loginMsg, svr.cfg) if err != nil { xl.Warn("create new controller error: %v", err) // don't return detailed errors to client @@ -588,7 +598,7 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login) error // RegisterWorkConn register a new work connection to control and proxies need it. func (svr *Service) RegisterWorkConn(workConn net.Conn, newMsg *msg.NewWorkConn) error { - xl := utilnet.NewLogFromConn(workConn) + xl := netpkg.NewLogFromConn(workConn) ctl, exist := svr.ctlManager.GetByID(newMsg.RunID) if !exist { xl.Warn("No client control found for run id [%s]", newMsg.RunID) @@ -607,7 +617,7 @@ func (svr *Service) RegisterWorkConn(workConn net.Conn, newMsg *msg.NewWorkConn) if err == nil { newMsg = &retContent.NewWorkConn // Check auth. - err = svr.authVerifier.VerifyNewWorkConn(newMsg) + err = ctl.authVerifier.VerifyNewWorkConn(newMsg) } if err != nil { xl.Warn("invalid NewWorkConn with run id [%s]", newMsg.RunID) diff --git a/server/visitor/visitor.go b/server/visitor/visitor.go index c76bcee1..ed06dc4b 100644 --- a/server/visitor/visitor.go +++ b/server/visitor/visitor.go @@ -23,12 +23,12 @@ import ( libio "github.com/fatedier/golib/io" "github.com/samber/lo" - utilnet "github.com/fatedier/frp/pkg/util/net" + netpkg "github.com/fatedier/frp/pkg/util/net" "github.com/fatedier/frp/pkg/util/util" ) type listenerBundle struct { - l *utilnet.InternalListener + l *netpkg.InternalListener sk string allowUsers []string } @@ -46,22 +46,21 @@ func NewManager() *Manager { } } -func (vm *Manager) Listen(name string, sk string, allowUsers []string) (l *utilnet.InternalListener, err error) { +func (vm *Manager) Listen(name string, sk string, allowUsers []string) (*netpkg.InternalListener, error) { vm.mu.Lock() defer vm.mu.Unlock() if _, ok := vm.listeners[name]; ok { - err = fmt.Errorf("custom listener for [%s] is repeated", name) - return + return nil, fmt.Errorf("custom listener for [%s] is repeated", name) } - l = utilnet.NewInternalListener() + l := netpkg.NewInternalListener() vm.listeners[name] = &listenerBundle{ l: l, sk: sk, allowUsers: allowUsers, } - return + return l, nil } func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey string, @@ -91,7 +90,7 @@ func (vm *Manager) NewConn(name string, conn net.Conn, timestamp int64, signKey if useCompression { rwc = libio.WithCompression(rwc) } - err = l.l.PutConn(utilnet.WrapReadWriteCloserToConn(rwc, conn)) + err = l.l.PutConn(netpkg.WrapReadWriteCloserToConn(rwc, conn)) } else { err = fmt.Errorf("custom listener for [%s] doesn't exist", name) return diff --git a/test/e2e/legacy/basic/tcpmux.go b/test/e2e/legacy/basic/tcpmux.go index 5bb742bc..15477837 100644 --- a/test/e2e/legacy/basic/tcpmux.go +++ b/test/e2e/legacy/basic/tcpmux.go @@ -8,7 +8,7 @@ import ( "github.com/onsi/ginkgo/v2" - "github.com/fatedier/frp/pkg/util/util" + httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server/streamserver" @@ -176,7 +176,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() { connectRequestHost = req.Host // return ok response - res := util.OkResponse() + res := httppkg.OkResponse() if res.Body != nil { defer res.Body.Close() } diff --git a/test/e2e/pkg/request/request.go b/test/e2e/pkg/request/request.go index 50deb3bf..740bc4fb 100644 --- a/test/e2e/pkg/request/request.go +++ b/test/e2e/pkg/request/request.go @@ -14,7 +14,7 @@ import ( libdial "github.com/fatedier/golib/net/dial" - "github.com/fatedier/frp/pkg/util/util" + httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/test/e2e/pkg/rpc" ) @@ -115,7 +115,7 @@ func (r *Request) HTTPHeaders(headers map[string]string) *Request { } func (r *Request) HTTPAuth(user, password string) *Request { - r.authValue = util.BasicAuth(user, password) + r.authValue = httppkg.BasicAuth(user, password) return r } diff --git a/test/e2e/v1/basic/tcpmux.go b/test/e2e/v1/basic/tcpmux.go index 356a18be..7ee58a79 100644 --- a/test/e2e/v1/basic/tcpmux.go +++ b/test/e2e/v1/basic/tcpmux.go @@ -8,7 +8,7 @@ import ( "github.com/onsi/ginkgo/v2" - "github.com/fatedier/frp/pkg/util/util" + httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/test/e2e/framework" "github.com/fatedier/frp/test/e2e/framework/consts" "github.com/fatedier/frp/test/e2e/mock/server/streamserver" @@ -180,7 +180,7 @@ var _ = ginkgo.Describe("[Feature: TCPMUX httpconnect]", func() { connectRequestHost = req.Host // return ok response - res := util.OkResponse() + res := httppkg.OkResponse() if res.Body != nil { defer res.Body.Close() } From 7c799ee9216521fddebab57aa160153f2aa09216 Mon Sep 17 00:00:00 2001 From: fatedier <fatedier@gmail.com> Date: Tue, 28 Nov 2023 13:48:32 +0800 Subject: [PATCH 11/21] add e2e tests for ssh tunnel (#3805) --- .gitignore | 1 + pkg/ssh/server.go | 35 +++--- test/e2e/pkg/ssh/client.go | 89 +++++++++++++ test/e2e/v1/features/ssh_tunnel.go | 193 +++++++++++++++++++++++++++++ 4 files changed, 300 insertions(+), 18 deletions(-) create mode 100644 test/e2e/pkg/ssh/client.go create mode 100644 test/e2e/v1/features/ssh_tunnel.go diff --git a/.gitignore b/.gitignore index f6df315b..c9480d52 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ lastversion/ dist/ .idea/ .vscode/ +.autogen_ssh_key # Cache *.swp diff --git a/pkg/ssh/server.go b/pkg/ssh/server.go index 042f6766..30e79c64 100644 --- a/pkg/ssh/server.go +++ b/pkg/ssh/server.go @@ -56,8 +56,6 @@ type forwardedTCPPayload struct { Addr string Port uint32 - // can be default empty value but do not delete it - // because ssh protocol shoule be reserved OriginAddr string OriginPort uint32 } @@ -117,6 +115,8 @@ func (s *TunnelServer) Run() error { // join workConn and ssh channel c, err := s.openConn(addr) if err != nil { + log.Trace("open conn error: %v", err) + workConn.Close() return false } libio.Join(c, workConn) @@ -180,20 +180,16 @@ func (s *TunnelServer) waitForwardAddrAndExtraPayload( go func() { addrGot := false for req := range requests { - switch req.Type { - case RequestTypeForward: - if !addrGot { - payload := tcpipForward{} - if err := ssh.Unmarshal(req.Payload, &payload); err != nil { - return - } - addrGot = true - addrCh <- &payload - } - default: - if req.WantReply { - _ = req.Reply(true, nil) + if req.Type == RequestTypeForward && !addrGot { + payload := tcpipForward{} + if err := ssh.Unmarshal(req.Payload, &payload); err != nil { + return } + addrGot = true + addrCh <- &payload + } + if req.WantReply { + _ = req.Reply(true, nil) } } }() @@ -271,10 +267,10 @@ func (s *TunnelServer) handleNewChannel(channel ssh.NewChannel, extraPayloadCh c go s.keepAlive(ch) for req := range reqs { - if req.Type != "exec" { - continue + if req.WantReply { + _ = req.Reply(true, nil) } - if len(req.Payload) <= 4 { + if req.Type != "exec" || len(req.Payload) <= 4 { continue } end := 4 + binary.BigEndian.Uint32(req.Payload[:4]) @@ -310,6 +306,9 @@ func (s *TunnelServer) openConn(addr *tcpipForward) (net.Conn, error) { payload := forwardedTCPPayload{ Addr: addr.Host, Port: addr.Port, + // Note: Here is just for compatibility, not the real source address. + OriginAddr: addr.Host, + OriginPort: addr.Port, } channel, reqs, err := s.sshConn.OpenChannel(ChannelTypeServerOpenChannel, ssh.Marshal(&payload)) if err != nil { diff --git a/test/e2e/pkg/ssh/client.go b/test/e2e/pkg/ssh/client.go new file mode 100644 index 00000000..1a923e9c --- /dev/null +++ b/test/e2e/pkg/ssh/client.go @@ -0,0 +1,89 @@ +package ssh + +import ( + "net" + + libio "github.com/fatedier/golib/io" + "golang.org/x/crypto/ssh" +) + +type TunnelClient struct { + localAddr string + sshServer string + commands string + + sshConn *ssh.Client + ln net.Listener +} + +func NewTunnelClient(localAddr string, sshServer string, commands string) *TunnelClient { + return &TunnelClient{ + localAddr: localAddr, + sshServer: sshServer, + commands: commands, + } +} + +func (c *TunnelClient) Start() error { + config := &ssh.ClientConfig{ + User: "v0", + HostKeyCallback: func(string, net.Addr, ssh.PublicKey) error { return nil }, + } + + conn, err := ssh.Dial("tcp", c.sshServer, config) + if err != nil { + return err + } + c.sshConn = conn + + l, err := conn.Listen("tcp", "0.0.0.0:80") + if err != nil { + return err + } + c.ln = l + ch, req, err := conn.OpenChannel("direct", []byte("")) + if err != nil { + return err + } + defer ch.Close() + go ssh.DiscardRequests(req) + + type command struct { + Cmd string + } + _, err = ch.SendRequest("exec", false, ssh.Marshal(command{Cmd: c.commands})) + if err != nil { + return err + } + + go c.serveListener() + return nil +} + +func (c *TunnelClient) Close() { + if c.sshConn != nil { + _ = c.sshConn.Close() + } + if c.ln != nil { + _ = c.ln.Close() + } +} + +func (c *TunnelClient) serveListener() { + for { + conn, err := c.ln.Accept() + if err != nil { + return + } + go c.hanldeConn(conn) + } +} + +func (c *TunnelClient) hanldeConn(conn net.Conn) { + defer conn.Close() + local, err := net.Dial("tcp", c.localAddr) + if err != nil { + return + } + _, _, _ = libio.Join(local, conn) +} diff --git a/test/e2e/v1/features/ssh_tunnel.go b/test/e2e/v1/features/ssh_tunnel.go new file mode 100644 index 00000000..f67d87aa --- /dev/null +++ b/test/e2e/v1/features/ssh_tunnel.go @@ -0,0 +1,193 @@ +package features + +import ( + "crypto/tls" + "fmt" + "time" + + "github.com/onsi/ginkgo/v2" + + "github.com/fatedier/frp/pkg/transport" + "github.com/fatedier/frp/test/e2e/framework" + "github.com/fatedier/frp/test/e2e/framework/consts" + "github.com/fatedier/frp/test/e2e/mock/server/httpserver" + "github.com/fatedier/frp/test/e2e/mock/server/streamserver" + "github.com/fatedier/frp/test/e2e/pkg/request" + "github.com/fatedier/frp/test/e2e/pkg/ssh" +) + +var _ = ginkgo.Describe("[Feature: SSH Tunnel]", func() { + f := framework.NewDefaultFramework() + + ginkgo.It("tcp", func() { + sshPort := f.AllocPort() + serverConf := consts.DefaultServerConfig + fmt.Sprintf(` + sshTunnelGateway.bindPort = %d + `, sshPort) + + f.RunProcesses([]string{serverConf}, nil) + + localPort := f.PortByName(framework.TCPEchoServerPort) + remotePort := f.AllocPort() + tc := ssh.NewTunnelClient( + fmt.Sprintf("127.0.0.1:%d", localPort), + fmt.Sprintf("127.0.0.1:%d", sshPort), + fmt.Sprintf("tcp --remote_port %d", remotePort), + ) + framework.ExpectNoError(tc.Start()) + defer tc.Close() + + time.Sleep(time.Second) + framework.NewRequestExpect(f).Port(remotePort).Ensure() + }) + + ginkgo.It("http", func() { + sshPort := f.AllocPort() + vhostPort := f.AllocPort() + serverConf := consts.DefaultServerConfig + fmt.Sprintf(` + vhostHTTPPort = %d + sshTunnelGateway.bindPort = %d + `, vhostPort, sshPort) + + f.RunProcesses([]string{serverConf}, nil) + + localPort := f.PortByName(framework.HTTPSimpleServerPort) + tc := ssh.NewTunnelClient( + fmt.Sprintf("127.0.0.1:%d", localPort), + fmt.Sprintf("127.0.0.1:%d", sshPort), + "http --custom_domain test.example.com", + ) + framework.ExpectNoError(tc.Start()) + defer tc.Close() + + time.Sleep(time.Second) + framework.NewRequestExpect(f).Port(vhostPort). + RequestModify(func(r *request.Request) { + r.HTTP().HTTPHost("test.example.com") + }). + Ensure() + }) + + ginkgo.It("https", func() { + sshPort := f.AllocPort() + vhostPort := f.AllocPort() + serverConf := consts.DefaultServerConfig + fmt.Sprintf(` + vhostHTTPSPort = %d + sshTunnelGateway.bindPort = %d + `, vhostPort, sshPort) + + f.RunProcesses([]string{serverConf}, nil) + + localPort := f.AllocPort() + testDomain := "test.example.com" + tc := ssh.NewTunnelClient( + fmt.Sprintf("127.0.0.1:%d", localPort), + fmt.Sprintf("127.0.0.1:%d", sshPort), + fmt.Sprintf("https --custom_domain %s", testDomain), + ) + framework.ExpectNoError(tc.Start()) + defer tc.Close() + + tlsConfig, err := transport.NewServerTLSConfig("", "", "") + framework.ExpectNoError(err) + localServer := httpserver.New( + httpserver.WithBindPort(localPort), + httpserver.WithTLSConfig(tlsConfig), + httpserver.WithResponse([]byte("test")), + ) + f.RunServer("", localServer) + + time.Sleep(time.Second) + framework.NewRequestExpect(f). + Port(vhostPort). + RequestModify(func(r *request.Request) { + r.HTTPS().HTTPHost(testDomain).TLSConfig(&tls.Config{ + ServerName: testDomain, + InsecureSkipVerify: true, + }) + }). + ExpectResp([]byte("test")). + Ensure() + }) + + ginkgo.It("tcpmux", func() { + sshPort := f.AllocPort() + tcpmuxPort := f.AllocPort() + serverConf := consts.DefaultServerConfig + fmt.Sprintf(` + tcpmuxHTTPConnectPort = %d + sshTunnelGateway.bindPort = %d + `, tcpmuxPort, sshPort) + + f.RunProcesses([]string{serverConf}, nil) + + localPort := f.AllocPort() + testDomain := "test.example.com" + tc := ssh.NewTunnelClient( + fmt.Sprintf("127.0.0.1:%d", localPort), + fmt.Sprintf("127.0.0.1:%d", sshPort), + fmt.Sprintf("tcpmux --mux=httpconnect --custom_domain %s", testDomain), + ) + framework.ExpectNoError(tc.Start()) + defer tc.Close() + + localServer := streamserver.New( + streamserver.TCP, + streamserver.WithBindPort(localPort), + streamserver.WithRespContent([]byte("test")), + ) + f.RunServer("", localServer) + + time.Sleep(time.Second) + // Request without HTTP connect should get error + framework.NewRequestExpect(f). + Port(tcpmuxPort). + ExpectError(true). + Explain("request without HTTP connect expect error"). + Ensure() + + proxyURL := fmt.Sprintf("http://127.0.0.1:%d", tcpmuxPort) + // Request with incorrect connect hostname + framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { + r.Addr("invalid").Proxy(proxyURL) + }).ExpectError(true).Explain("request without HTTP connect expect error").Ensure() + + // Request with correct connect hostname + framework.NewRequestExpect(f).RequestModify(func(r *request.Request) { + r.Addr(testDomain).Proxy(proxyURL) + }).ExpectResp([]byte("test")).Ensure() + }) + + ginkgo.It("stcp", func() { + sshPort := f.AllocPort() + serverConf := consts.DefaultServerConfig + fmt.Sprintf(` + sshTunnelGateway.bindPort = %d + `, sshPort) + + bindPort := f.AllocPort() + visitorConf := consts.DefaultClientConfig + fmt.Sprintf(` + [[visitors]] + name = "stcp-test-visitor" + type = "stcp" + serverName = "stcp-test" + secretKey = "abcdefg" + bindPort = %d + `, bindPort) + + f.RunProcesses([]string{serverConf}, []string{visitorConf}) + + localPort := f.PortByName(framework.TCPEchoServerPort) + tc := ssh.NewTunnelClient( + fmt.Sprintf("127.0.0.1:%d", localPort), + fmt.Sprintf("127.0.0.1:%d", sshPort), + "stcp -n stcp-test --sk=abcdefg --allow_users=\"*\"", + ) + framework.ExpectNoError(tc.Start()) + defer tc.Close() + + time.Sleep(time.Second) + + framework.NewRequestExpect(f). + Port(bindPort). + Ensure() + }) +}) From 38f297a395cab2cb44507e96ed10fb0ee80ecee7 Mon Sep 17 00:00:00 2001 From: fatedier <fatedier@gmail.com> Date: Tue, 28 Nov 2023 18:43:33 +0800 Subject: [PATCH 12/21] Improve the strict configuration validation (#3809) --- Release.md | 2 +- pkg/config/load.go | 5 +++- pkg/config/load_test.go | 53 ++++++++++++++++++++++++++++++++++++++++ pkg/config/v1/common.go | 14 +++++++++++ pkg/config/v1/plugin.go | 16 +++++++++++- pkg/config/v1/proxy.go | 7 +++++- pkg/config/v1/visitor.go | 11 +++++++-- 7 files changed, 102 insertions(+), 6 deletions(-) diff --git a/Release.md b/Release.md index ca8f3a72..8e1ea863 100644 --- a/Release.md +++ b/Release.md @@ -1,6 +1,6 @@ ### Features -* New command line parameter `--strict_config` is added to enable strict configuration validation mode. It will throw an error for non-existent fields instead of ignoring them. In future versions, we may set the default value of this parameter to true. +* The new command line parameter `--strict_config` has been added to enable strict configuration validation mode. It will throw an error for unknown fields instead of ignoring them. In future versions, we will set the default value of this parameter to true to avoid misconfigurations. * Support `SSH reverse tunneling`. With this feature, you can expose your local service without running frpc, only using SSH. The SSH reverse tunnel agent has many functional limitations compared to the frpc agent. The currently supported proxy types are tcp, http, https, tcpmux, and stcp. * The frpc tcpmux command line parameters have been updated to support configuring `http_user` and `http_pwd`. * The frpc stcp/sudp/xtcp command line parameters have been updated to support configuring `allow_users`. diff --git a/pkg/config/load.go b/pkg/config/load.go index b5539745..cdbb8e91 100644 --- a/pkg/config/load.go +++ b/pkg/config/load.go @@ -110,8 +110,11 @@ func LoadConfigureFromFile(path string, c any, strict bool) error { // LoadConfigure loads configuration from bytes and unmarshal into c. // Now it supports json, yaml and toml format. -// TODO(fatedier): strict is not valide for ProxyConfigurer/VisitorConfigurer/ClientPluginOptions. func LoadConfigure(b []byte, c any, strict bool) error { + v1.DisallowUnknownFieldsMu.Lock() + defer v1.DisallowUnknownFieldsMu.Unlock() + v1.DisallowUnknownFields = strict + var tomlObj interface{} // Try to unmarshal as TOML first; swallow errors from that (assume it's not valid TOML). if err := toml.Unmarshal(b, &tomlObj); err == nil { diff --git a/pkg/config/load_test.go b/pkg/config/load_test.go index 9bf7dbbc..b3f77800 100644 --- a/pkg/config/load_test.go +++ b/pkg/config/load_test.go @@ -111,3 +111,56 @@ func TestLoadServerConfigStrictMode(t *testing.T) { } } } + +func TestCustomStructStrictMode(t *testing.T) { + require := require.New(t) + + proxyStr := ` +serverPort = 7000 + +[[proxies]] +name = "test" +type = "tcp" +remotePort = 6000 +` + clientCfg := v1.ClientConfig{} + err := LoadConfigure([]byte(proxyStr), &clientCfg, true) + require.NoError(err) + + proxyStr += `unknown = "unknown"` + err = LoadConfigure([]byte(proxyStr), &clientCfg, true) + require.Error(err) + + visitorStr := ` +serverPort = 7000 + +[[visitors]] +name = "test" +type = "stcp" +bindPort = 6000 +serverName = "server" +` + err = LoadConfigure([]byte(visitorStr), &clientCfg, true) + require.NoError(err) + + visitorStr += `unknown = "unknown"` + err = LoadConfigure([]byte(visitorStr), &clientCfg, true) + require.Error(err) + + pluginStr := ` +serverPort = 7000 + +[[proxies]] +name = "test" +type = "tcp" +remotePort = 6000 +[proxies.plugin] +type = "unix_domain_socket" +unixPath = "/tmp/uds.sock" +` + err = LoadConfigure([]byte(pluginStr), &clientCfg, true) + require.NoError(err) + pluginStr += `unknown = "unknown"` + err = LoadConfigure([]byte(pluginStr), &clientCfg, true) + require.Error(err) +} diff --git a/pkg/config/v1/common.go b/pkg/config/v1/common.go index 72c9d036..24ec9b0d 100644 --- a/pkg/config/v1/common.go +++ b/pkg/config/v1/common.go @@ -15,9 +15,23 @@ package v1 import ( + "sync" + "github.com/fatedier/frp/pkg/util/util" ) +// TODO(fatedier): Due to the current implementation issue of the go json library, the UnmarshalJSON method +// of a custom struct cannot access the DisallowUnknownFields parameter of the parent decoder. +// Here, a global variable is temporarily used to control whether unknown fields are allowed. +// Once the v2 version is implemented by the community, we can switch to a standardized approach. +// +// https://github.com/golang/go/issues/41144 +// https://github.com/golang/go/discussions/63397 +var ( + DisallowUnknownFields = false + DisallowUnknownFieldsMu sync.Mutex +) + type AuthScope string const ( diff --git a/pkg/config/v1/plugin.go b/pkg/config/v1/plugin.go index bd5ff384..db9d0d1a 100644 --- a/pkg/config/v1/plugin.go +++ b/pkg/config/v1/plugin.go @@ -15,6 +15,7 @@ package v1 import ( + "bytes" "encoding/json" "fmt" "reflect" @@ -49,7 +50,13 @@ func (c *TypedClientPluginOptions) UnmarshalJSON(b []byte) error { return fmt.Errorf("unknown plugin type: %s", typeStruct.Type) } options := reflect.New(v).Interface().(ClientPluginOptions) - if err := json.Unmarshal(b, options); err != nil { + + decoder := json.NewDecoder(bytes.NewBuffer(b)) + if DisallowUnknownFields { + decoder.DisallowUnknownFields() + } + + if err := decoder.Decode(options); err != nil { return err } c.ClientPluginOptions = options @@ -77,17 +84,20 @@ var clientPluginOptionsTypeMap = map[string]reflect.Type{ } type HTTP2HTTPSPluginOptions struct { + Type string `json:"type,omitempty"` LocalAddr string `json:"localAddr,omitempty"` HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"` RequestHeaders HeaderOperations `json:"requestHeaders,omitempty"` } type HTTPProxyPluginOptions struct { + Type string `json:"type,omitempty"` HTTPUser string `json:"httpUser,omitempty"` HTTPPassword string `json:"httpPassword,omitempty"` } type HTTPS2HTTPPluginOptions struct { + Type string `json:"type,omitempty"` LocalAddr string `json:"localAddr,omitempty"` HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"` RequestHeaders HeaderOperations `json:"requestHeaders,omitempty"` @@ -96,6 +106,7 @@ type HTTPS2HTTPPluginOptions struct { } type HTTPS2HTTPSPluginOptions struct { + Type string `json:"type,omitempty"` LocalAddr string `json:"localAddr,omitempty"` HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"` RequestHeaders HeaderOperations `json:"requestHeaders,omitempty"` @@ -104,11 +115,13 @@ type HTTPS2HTTPSPluginOptions struct { } type Socks5PluginOptions struct { + Type string `json:"type,omitempty"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` } type StaticFilePluginOptions struct { + Type string `json:"type,omitempty"` LocalPath string `json:"localPath,omitempty"` StripPrefix string `json:"stripPrefix,omitempty"` HTTPUser string `json:"httpUser,omitempty"` @@ -116,5 +129,6 @@ type StaticFilePluginOptions struct { } type UnixDomainSocketPluginOptions struct { + Type string `json:"type,omitempty"` UnixPath string `json:"unixPath,omitempty"` } diff --git a/pkg/config/v1/proxy.go b/pkg/config/v1/proxy.go index 0752479f..8e19d004 100644 --- a/pkg/config/v1/proxy.go +++ b/pkg/config/v1/proxy.go @@ -15,6 +15,7 @@ package v1 import ( + "bytes" "encoding/json" "errors" "fmt" @@ -177,7 +178,11 @@ func (c *TypedProxyConfig) UnmarshalJSON(b []byte) error { if configurer == nil { return fmt.Errorf("unknown proxy type: %s", typeStruct.Type) } - if err := json.Unmarshal(b, configurer); err != nil { + decoder := json.NewDecoder(bytes.NewBuffer(b)) + if DisallowUnknownFields { + decoder.DisallowUnknownFields() + } + if err := decoder.Decode(configurer); err != nil { return err } c.ProxyConfigurer = configurer diff --git a/pkg/config/v1/visitor.go b/pkg/config/v1/visitor.go index 90ecd86d..a9b2411a 100644 --- a/pkg/config/v1/visitor.go +++ b/pkg/config/v1/visitor.go @@ -15,6 +15,7 @@ package v1 import ( + "bytes" "encoding/json" "errors" "fmt" @@ -108,7 +109,11 @@ func (c *TypedVisitorConfig) UnmarshalJSON(b []byte) error { if configurer == nil { return fmt.Errorf("unknown visitor type: %s", typeStruct.Type) } - if err := json.Unmarshal(b, configurer); err != nil { + decoder := json.NewDecoder(bytes.NewBuffer(b)) + if DisallowUnknownFields { + decoder.DisallowUnknownFields() + } + if err := decoder.Decode(configurer); err != nil { return err } c.VisitorConfigurer = configurer @@ -120,7 +125,9 @@ func NewVisitorConfigurerByType(t VisitorType) VisitorConfigurer { if !ok { return nil } - return reflect.New(v).Interface().(VisitorConfigurer) + vc := reflect.New(v).Interface().(VisitorConfigurer) + vc.GetBaseConfig().Type = string(t) + return vc } var _ VisitorConfigurer = &STCPVisitorConfig{} From 97d3cf1a3bbb4314545ae31f17da0f150926fbd3 Mon Sep 17 00:00:00 2001 From: fatedier <fatedier@gmail.com> Date: Tue, 28 Nov 2023 19:02:51 +0800 Subject: [PATCH 13/21] call config complete in nathole discover (#3813) --- cmd/frpc/sub/nathole.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/frpc/sub/nathole.go b/cmd/frpc/sub/nathole.go index 56fcf67b..fb5b0807 100644 --- a/cmd/frpc/sub/nathole.go +++ b/cmd/frpc/sub/nathole.go @@ -51,6 +51,7 @@ var natholeDiscoveryCmd = &cobra.Command{ cfg, _, _, _, err := config.LoadClientConfig(cfgFile, strictConfigMode) if err != nil { cfg = &v1.ClientCommonConfig{} + cfg.Complete() } if natHoleSTUNServer != "" { cfg.NatHoleSTUNServer = natHoleSTUNServer From 6d9e0c20f6031402f19b13e08ce44a5265c925fe Mon Sep 17 00:00:00 2001 From: im_zhou <32025208+im-zhou@users.noreply.github.com> Date: Thu, 30 Nov 2023 10:59:08 +0800 Subject: [PATCH 14/21] fix static assets (#3816) --- pkg/util/http/server.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pkg/util/http/server.go b/pkg/util/http/server.go index e49cefe4..99bed364 100644 --- a/pkg/util/http/server.go +++ b/pkg/util/http/server.go @@ -46,9 +46,7 @@ type Server struct { } func NewServer(cfg v1.WebServerConfig) (*Server, error) { - if cfg.AssetsDir != "" { - assets.Load(cfg.AssetsDir) - } + assets.Load(cfg.AssetsDir) addr := net.JoinHostPort(cfg.Addr, strconv.Itoa(cfg.Port)) if addr == ":" { From 95cf4189636327ef405568cf1a7a82a57e5c5bc6 Mon Sep 17 00:00:00 2001 From: fatedier <fatedier@gmail.com> Date: Fri, 1 Dec 2023 20:18:13 +0800 Subject: [PATCH 15/21] ssh: return informations to client (#3821) --- client/service.go | 20 +++++++--- pkg/config/flags.go | 81 +++++++++++++++++++++++++------------- pkg/ssh/server.go | 72 ++++++++++++++++++++++++--------- pkg/ssh/terminal.go | 31 +++++++++++++++ test/e2e/pkg/ssh/client.go | 2 +- 5 files changed, 154 insertions(+), 52 deletions(-) create mode 100644 pkg/ssh/terminal.go diff --git a/client/service.go b/client/service.go index 5db1bd28..c43f8f60 100644 --- a/client/service.go +++ b/client/service.go @@ -42,6 +42,14 @@ func init() { crypto.DefaultSalt = "frp" } +type cancelErr struct { + Err error +} + +func (e cancelErr) Error() string { + return e.Err.Error() +} + // ServiceOptions contains options for creating a new client service. type ServiceOptions struct { Common *v1.ClientCommonConfig @@ -108,7 +116,7 @@ type Service struct { // service context ctx context.Context // call cancel to stop service - cancel context.CancelFunc + cancel context.CancelCauseFunc gracefulShutdownDuration time.Duration connectorCreator func(context.Context, *v1.ClientCommonConfig) Connector @@ -145,7 +153,7 @@ func NewService(options ServiceOptions) (*Service, error) { } func (svr *Service) Run(ctx context.Context) error { - ctx, cancel := context.WithCancel(ctx) + ctx, cancel := context.WithCancelCause(ctx) svr.ctx = xlog.NewContext(ctx, xlog.FromContextSafe(ctx)) svr.cancel = cancel @@ -157,7 +165,9 @@ func (svr *Service) Run(ctx context.Context) error { // first login to frps svr.loopLoginUntilSuccess(10*time.Second, lo.FromPtr(svr.common.LoginFailExit)) if svr.ctl == nil { - return fmt.Errorf("the process exited because the first login to the server failed, and the loginFailExit feature is enabled") + cancelCause := cancelErr{} + _ = errors.As(context.Cause(svr.ctx), &cancelCause) + return fmt.Errorf("login to the server failed: %v. With loginFailExit enabled, no additional retries will be attempted", cancelCause.Err) } go svr.keepControllerWorking() @@ -280,7 +290,7 @@ func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginE if err != nil { xl.Warn("connect to server error: %v", err) if firstLoginExit { - svr.cancel() + svr.cancel(cancelErr{Err: err}) } return err } @@ -356,7 +366,7 @@ func (svr *Service) Close() { func (svr *Service) GracefulClose(d time.Duration) { svr.gracefulShutdownDuration = d - svr.cancel() + svr.cancel(nil) } func (svr *Service) stop() { diff --git a/pkg/config/flags.go b/pkg/config/flags.go index c0e87164..712e3d3f 100644 --- a/pkg/config/flags.go +++ b/pkg/config/flags.go @@ -25,6 +25,18 @@ import ( "github.com/fatedier/frp/pkg/config/v1/validation" ) +type RegisterFlagOption func(*registerFlagOptions) + +type registerFlagOptions struct { + sshMode bool +} + +func WithSSHMode() RegisterFlagOption { + return func(o *registerFlagOptions) { + o.sshMode = true + } +} + type BandwidthQuantityFlag struct { V *types.BandwidthQuantity } @@ -41,8 +53,9 @@ func (f *BandwidthQuantityFlag) Type() string { return "string" } -func RegisterProxyFlags(cmd *cobra.Command, c v1.ProxyConfigurer) { - registerProxyBaseConfigFlags(cmd, c.GetBaseConfig()) +func RegisterProxyFlags(cmd *cobra.Command, c v1.ProxyConfigurer, opts ...RegisterFlagOption) { + registerProxyBaseConfigFlags(cmd, c.GetBaseConfig(), opts...) + switch cc := c.(type) { case *v1.TCPProxyConfig: cmd.Flags().IntVarP(&cc.RemotePort, "remote_port", "r", 0, "remote port") @@ -73,17 +86,25 @@ func RegisterProxyFlags(cmd *cobra.Command, c v1.ProxyConfigurer) { } } -func registerProxyBaseConfigFlags(cmd *cobra.Command, c *v1.ProxyBaseConfig) { +func registerProxyBaseConfigFlags(cmd *cobra.Command, c *v1.ProxyBaseConfig, opts ...RegisterFlagOption) { if c == nil { return } + options := ®isterFlagOptions{} + for _, opt := range opts { + opt(options) + } + cmd.Flags().StringVarP(&c.Name, "proxy_name", "n", "", "proxy name") - cmd.Flags().StringVarP(&c.LocalIP, "local_ip", "i", "127.0.0.1", "local ip") - cmd.Flags().IntVarP(&c.LocalPort, "local_port", "l", 0, "local port") - cmd.Flags().BoolVarP(&c.Transport.UseEncryption, "ue", "", false, "use encryption") - cmd.Flags().BoolVarP(&c.Transport.UseCompression, "uc", "", false, "use compression") - cmd.Flags().StringVarP(&c.Transport.BandwidthLimitMode, "bandwidth_limit_mode", "", types.BandwidthLimitModeClient, "bandwidth limit mode") - cmd.Flags().VarP(&BandwidthQuantityFlag{V: &c.Transport.BandwidthLimit}, "bandwidth_limit", "", "bandwidth limit (e.g. 100KB or 1MB)") + + if !options.sshMode { + cmd.Flags().StringVarP(&c.LocalIP, "local_ip", "i", "127.0.0.1", "local ip") + cmd.Flags().IntVarP(&c.LocalPort, "local_port", "l", 0, "local port") + cmd.Flags().BoolVarP(&c.Transport.UseEncryption, "ue", "", false, "use encryption") + cmd.Flags().BoolVarP(&c.Transport.UseCompression, "uc", "", false, "use compression") + cmd.Flags().StringVarP(&c.Transport.BandwidthLimitMode, "bandwidth_limit_mode", "", types.BandwidthLimitModeClient, "bandwidth limit mode") + cmd.Flags().VarP(&BandwidthQuantityFlag{V: &c.Transport.BandwidthLimit}, "bandwidth_limit", "", "bandwidth limit (e.g. 100KB or 1MB)") + } } func registerProxyDomainConfigFlags(cmd *cobra.Command, c *v1.DomainConfig) { @@ -94,13 +115,13 @@ func registerProxyDomainConfigFlags(cmd *cobra.Command, c *v1.DomainConfig) { cmd.Flags().StringVarP(&c.SubDomain, "sd", "", "", "sub domain") } -func RegisterVisitorFlags(cmd *cobra.Command, c v1.VisitorConfigurer) { - registerVisitorBaseConfigFlags(cmd, c.GetBaseConfig()) +func RegisterVisitorFlags(cmd *cobra.Command, c v1.VisitorConfigurer, opts ...RegisterFlagOption) { + registerVisitorBaseConfigFlags(cmd, c.GetBaseConfig(), opts...) // add visitor flags if exist } -func registerVisitorBaseConfigFlags(cmd *cobra.Command, c *v1.VisitorBaseConfig) { +func registerVisitorBaseConfigFlags(cmd *cobra.Command, c *v1.VisitorBaseConfig, _ ...RegisterFlagOption) { if c == nil { return } @@ -113,21 +134,27 @@ func registerVisitorBaseConfigFlags(cmd *cobra.Command, c *v1.VisitorBaseConfig) cmd.Flags().IntVarP(&c.BindPort, "bind_port", "", 0, "bind port") } -func RegisterClientCommonConfigFlags(cmd *cobra.Command, c *v1.ClientCommonConfig) { - cmd.PersistentFlags().StringVarP(&c.ServerAddr, "server_addr", "s", "127.0.0.1", "frp server's address") - cmd.PersistentFlags().IntVarP(&c.ServerPort, "server_port", "P", 7000, "frp server's port") - cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user") - cmd.PersistentFlags().StringVarP(&c.Transport.Protocol, "protocol", "p", "tcp", - fmt.Sprintf("optional values are %v", validation.SupportedTransportProtocols)) - cmd.PersistentFlags().StringVarP(&c.Auth.Token, "token", "t", "", "auth token") - cmd.PersistentFlags().StringVarP(&c.Log.Level, "log_level", "", "info", "log level") - cmd.PersistentFlags().StringVarP(&c.Log.To, "log_file", "", "console", "console or file path") - cmd.PersistentFlags().Int64VarP(&c.Log.MaxDays, "log_max_days", "", 3, "log file reversed days") - cmd.PersistentFlags().BoolVarP(&c.Log.DisablePrintColor, "disable_log_color", "", false, "disable log color in console") - cmd.PersistentFlags().StringVarP(&c.Transport.TLS.ServerName, "tls_server_name", "", "", "specify the custom server name of tls certificate") - cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server instead of using system default one") +func RegisterClientCommonConfigFlags(cmd *cobra.Command, c *v1.ClientCommonConfig, opts ...RegisterFlagOption) { + options := ®isterFlagOptions{} + for _, opt := range opts { + opt(options) + } - c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls") + if !options.sshMode { + cmd.PersistentFlags().StringVarP(&c.ServerAddr, "server_addr", "s", "127.0.0.1", "frp server's address") + cmd.PersistentFlags().IntVarP(&c.ServerPort, "server_port", "P", 7000, "frp server's port") + cmd.PersistentFlags().StringVarP(&c.Transport.Protocol, "protocol", "p", "tcp", + fmt.Sprintf("optional values are %v", validation.SupportedTransportProtocols)) + cmd.PersistentFlags().StringVarP(&c.Log.Level, "log_level", "", "info", "log level") + cmd.PersistentFlags().StringVarP(&c.Log.To, "log_file", "", "console", "console or file path") + cmd.PersistentFlags().Int64VarP(&c.Log.MaxDays, "log_max_days", "", 3, "log file reversed days") + cmd.PersistentFlags().BoolVarP(&c.Log.DisablePrintColor, "disable_log_color", "", false, "disable log color in console") + cmd.PersistentFlags().StringVarP(&c.Transport.TLS.ServerName, "tls_server_name", "", "", "specify the custom server name of tls certificate") + cmd.PersistentFlags().StringVarP(&c.DNSServer, "dns_server", "", "", "specify dns server instead of using system default one") + c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls") + } + cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user") + cmd.PersistentFlags().StringVarP(&c.Auth.Token, "token", "t", "", "auth token") } type PortsRangeSliceFlag struct { @@ -185,7 +212,7 @@ func (f *BoolFuncFlag) Type() string { return "bool" } -func RegisterServerConfigFlags(cmd *cobra.Command, c *v1.ServerConfig) { +func RegisterServerConfigFlags(cmd *cobra.Command, c *v1.ServerConfig, opts ...RegisterFlagOption) { cmd.PersistentFlags().StringVarP(&c.BindAddr, "bind_addr", "", "0.0.0.0", "bind address") cmd.PersistentFlags().IntVarP(&c.BindPort, "bind_port", "p", 7000, "bind port") cmd.PersistentFlags().IntVarP(&c.KCPBindPort, "kcp_bind_port", "", 0, "kcp bind udp port") diff --git a/pkg/ssh/server.go b/pkg/ssh/server.go index 30e79c64..264669a3 100644 --- a/pkg/ssh/server.go +++ b/pkg/ssh/server.go @@ -27,6 +27,7 @@ import ( libio "github.com/fatedier/golib/io" "github.com/samber/lo" "github.com/spf13/cobra" + flag "github.com/spf13/pflag" "golang.org/x/crypto/ssh" "github.com/fatedier/frp/client/proxy" @@ -64,6 +65,7 @@ type TunnelServer struct { underlyingConn net.Conn sshConn *ssh.ServerConn sc *ssh.ServerConfig + firstChannel ssh.Channel vc *virtual.Client peerServerListener *netpkg.InternalListener @@ -86,6 +88,7 @@ func (s *TunnelServer) Run() error { if err != nil { return err } + s.sshConn = sshConn addr, extraPayload, err := s.waitForwardAddrAndExtraPayload(channels, requests, 3*time.Second) @@ -93,9 +96,14 @@ func (s *TunnelServer) Run() error { return err } - clientCfg, pc, err := s.parseClientAndProxyConfigurer(addr, extraPayload) + clientCfg, pc, helpMessage, err := s.parseClientAndProxyConfigurer(addr, extraPayload) if err != nil { - return err + if errors.Is(err, flag.ErrHelp) { + s.writeToClient(helpMessage) + return nil + } + s.writeToClient(err.Error()) + return fmt.Errorf("parse flags from ssh client error: %v", err) } clientCfg.Complete() if sshConn.Permissions != nil { @@ -142,7 +150,11 @@ func (s *TunnelServer) Run() error { xl := xlog.New().AddPrefix(xlog.LogPrefix{Name: "sshVirtualClient", Value: "sshVirtualClient", Priority: 100}) ctx := xlog.NewContext(context.Background(), xl) go func() { - _ = s.vc.Run(ctx) + vcErr := s.vc.Run(ctx) + if vcErr != nil { + s.writeToClient(vcErr.Error()) + } + // If vc.Run returns, it means that the virtual client has been closed, and the ssh tunnel connection should be closed. // One scenario is that the virtual client exits due to login failure. s.closeDoneChOnce.Do(func() { @@ -153,9 +165,12 @@ func (s *TunnelServer) Run() error { s.vc.UpdateProxyConfigurer([]v1.ProxyConfigurer{pc}) - if err := s.waitProxyStatusReady(pc.GetBaseConfig().Name, time.Second); err != nil { + if ps, err := s.waitProxyStatusReady(pc.GetBaseConfig().Name, time.Second); err != nil { + s.writeToClient(err.Error()) log.Warn("wait proxy status ready error: %v", err) } else { + // success + s.writeToClient(createSuccessInfo(clientCfg.User, pc, ps)) _ = sshConn.Wait() } @@ -168,6 +183,13 @@ func (s *TunnelServer) Run() error { return nil } +func (s *TunnelServer) writeToClient(data string) { + if s.firstChannel == nil { + return + } + _, _ = s.firstChannel.Write([]byte(data + "\n")) +} + func (s *TunnelServer) waitForwardAddrAndExtraPayload( channels <-chan ssh.NewChannel, requests <-chan *ssh.Request, @@ -225,38 +247,47 @@ func (s *TunnelServer) waitForwardAddrAndExtraPayload( return addr, extraPayload, nil } -func (s *TunnelServer) parseClientAndProxyConfigurer(_ *tcpipForward, extraPayload string) (*v1.ClientCommonConfig, v1.ProxyConfigurer, error) { - cmd := &cobra.Command{} +func (s *TunnelServer) parseClientAndProxyConfigurer(_ *tcpipForward, extraPayload string) (*v1.ClientCommonConfig, v1.ProxyConfigurer, string, error) { + helpMessage := "" + cmd := &cobra.Command{ + Use: "ssh v0@{address} [command]", + Short: "ssh v0@{address} [command]", + Run: func(*cobra.Command, []string) {}, + } args := strings.Split(extraPayload, " ") if len(args) < 1 { - return nil, nil, fmt.Errorf("invalid extra payload") + return nil, nil, helpMessage, fmt.Errorf("invalid extra payload") } proxyType := strings.TrimSpace(args[0]) supportTypes := []string{"tcp", "http", "https", "tcpmux", "stcp"} if !lo.Contains(supportTypes, proxyType) { - return nil, nil, fmt.Errorf("invalid proxy type: %s, support types: %v", proxyType, supportTypes) + return nil, nil, helpMessage, fmt.Errorf("invalid proxy type: %s, support types: %v", proxyType, supportTypes) } pc := v1.NewProxyConfigurerByType(v1.ProxyType(proxyType)) if pc == nil { - return nil, nil, fmt.Errorf("new proxy configurer error") + return nil, nil, helpMessage, fmt.Errorf("new proxy configurer error") } - config.RegisterProxyFlags(cmd, pc) + config.RegisterProxyFlags(cmd, pc, config.WithSSHMode()) clientCfg := v1.ClientCommonConfig{} - config.RegisterClientCommonConfigFlags(cmd, &clientCfg) + config.RegisterClientCommonConfigFlags(cmd, &clientCfg, config.WithSSHMode()) + cmd.InitDefaultHelpCmd() if err := cmd.ParseFlags(args); err != nil { - return nil, nil, fmt.Errorf("parse flags from ssh client error: %v", err) + if errors.Is(err, flag.ErrHelp) { + helpMessage = cmd.UsageString() + } + return nil, nil, helpMessage, err } // if name is not set, generate a random one if pc.GetBaseConfig().Name == "" { id, err := util.RandIDWithLen(8) if err != nil { - return nil, nil, fmt.Errorf("generate random id error: %v", err) + return nil, nil, helpMessage, fmt.Errorf("generate random id error: %v", err) } pc.GetBaseConfig().Name = fmt.Sprintf("sshtunnel-%s-%s", proxyType, id) } - return &clientCfg, pc, nil + return &clientCfg, pc, helpMessage, nil } func (s *TunnelServer) handleNewChannel(channel ssh.NewChannel, extraPayloadCh chan string) { @@ -264,6 +295,9 @@ func (s *TunnelServer) handleNewChannel(channel ssh.NewChannel, extraPayloadCh c if err != nil { return } + if s.firstChannel == nil { + s.firstChannel = ch + } go s.keepAlive(ch) for req := range reqs { @@ -320,7 +354,7 @@ func (s *TunnelServer) openConn(addr *tcpipForward) (net.Conn, error) { return conn, nil } -func (s *TunnelServer) waitProxyStatusReady(name string, timeout time.Duration) error { +func (s *TunnelServer) waitProxyStatusReady(name string, timeout time.Duration) (*proxy.WorkingStatus, error) { ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() @@ -336,14 +370,14 @@ func (s *TunnelServer) waitProxyStatusReady(name string, timeout time.Duration) } switch ps.Phase { case proxy.ProxyPhaseRunning: - return nil + return ps, nil case proxy.ProxyPhaseStartErr, proxy.ProxyPhaseClosed: - return errors.New(ps.Err) + return ps, errors.New(ps.Err) } case <-timer.C: - return fmt.Errorf("wait proxy status ready timeout") + return nil, fmt.Errorf("wait proxy status ready timeout") case <-s.doneCh: - return fmt.Errorf("ssh tunnel server closed") + return nil, fmt.Errorf("ssh tunnel server closed") } } } diff --git a/pkg/ssh/terminal.go b/pkg/ssh/terminal.go new file mode 100644 index 00000000..a2e9a6ff --- /dev/null +++ b/pkg/ssh/terminal.go @@ -0,0 +1,31 @@ +// Copyright 2023 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 ssh + +import ( + "github.com/fatedier/frp/client/proxy" + v1 "github.com/fatedier/frp/pkg/config/v1" +) + +func createSuccessInfo(user string, pc v1.ProxyConfigurer, ps *proxy.WorkingStatus) string { + base := pc.GetBaseConfig() + out := "\n" + out += "frp (via SSH) (Ctrl+C to quit)\n\n" + out += "User: " + user + "\n" + out += "ProxyName: " + base.Name + "\n" + out += "Type: " + base.Type + "\n" + out += "RemoteAddress: " + ps.RemoteAddr + "\n" + return out +} diff --git a/test/e2e/pkg/ssh/client.go b/test/e2e/pkg/ssh/client.go index 1a923e9c..b45e39da 100644 --- a/test/e2e/pkg/ssh/client.go +++ b/test/e2e/pkg/ssh/client.go @@ -41,7 +41,7 @@ func (c *TunnelClient) Start() error { return err } c.ln = l - ch, req, err := conn.OpenChannel("direct", []byte("")) + ch, req, err := conn.OpenChannel("session", []byte("")) if err != nil { return err } From 9ecafeab40c7d3fb06cfbef5a0e65e01610af3a8 Mon Sep 17 00:00:00 2001 From: fatedier <fatedier@gmail.com> Date: Fri, 1 Dec 2023 20:44:50 +0800 Subject: [PATCH 16/21] bump version to v0.53.0 (#3822) --- pkg/util/version/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/util/version/version.go b/pkg/util/version/version.go index 2dc60eee..ab79a55b 100644 --- a/pkg/util/version/version.go +++ b/pkg/util/version/version.go @@ -19,7 +19,7 @@ import ( "strings" ) -var version = "0.52.3" +var version = "0.53.0" func Full() string { return version From 7ad62818bd8c31c7b6235acc8f8d98a7c224d3f3 Mon Sep 17 00:00:00 2001 From: fatedier <fatedier@gmail.com> Date: Sat, 2 Dec 2023 16:12:37 +0800 Subject: [PATCH 17/21] update sponsor doc (#3823) --- README.md | 4 ++++ README_zh.md | 6 +++++- doc/pic/donate-alipay.png | Bin 37153 -> 0 bytes doc/pic/sponsor_asocks.jpg | Bin 29877 -> 0 bytes doc/pic/sponsor_nango.png | Bin 0 -> 14710 bytes 5 files changed, 9 insertions(+), 1 deletion(-) delete mode 100644 doc/pic/donate-alipay.png delete mode 100644 doc/pic/sponsor_asocks.jpg create mode 100644 doc/pic/sponsor_nango.png diff --git a/README.md b/README.md index fb159287..ac04279e 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ <a href="https://workos.com/?utm_campaign=github_repo&utm_medium=referral&utm_content=frp&utm_source=github" target="_blank"> <img width="350px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_workos.png"> </a> + + <a href="https://www.nango.dev?utm_source=github&utm_medium=oss-banner&utm_campaign=fatedier-frp" target="_blank"> + <img width="350px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_nango.png"> + </a> </p> <!--gold sponsors end--> diff --git a/README_zh.md b/README_zh.md index 77cf7974..ac4eeec1 100644 --- a/README_zh.md +++ b/README_zh.md @@ -13,6 +13,10 @@ frp 是一个专注于内网穿透的高性能的反向代理应用,支持 TCP <a href="https://workos.com/?utm_campaign=github_repo&utm_medium=referral&utm_content=frp&utm_source=github" target="_blank"> <img width="350px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_workos.png"> </a> + + <a href="https://www.nango.dev?utm_source=github&utm_medium=oss-banner&utm_campaign=fatedier-frp" target="_blank"> + <img width="350px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_nango.png"> + </a> </p> <!--gold sponsors end--> @@ -84,7 +88,7 @@ frp 是一个免费且开源的项目,我们欢迎任何人为其开发和进 ### 知识星球 -如果您想了解更多 frp 相关技术以及更新详解,或者寻求任何帮助及咨询,都可以通过微信扫描下方的二维码付费加入知识星球的官方社群: +如果您想了解更多 frp 相关技术以及更新详解,或者寻求任何 frp 使用方面的帮助,都可以通过微信扫描下方的二维码付费加入知识星球的官方社群:  diff --git a/doc/pic/donate-alipay.png b/doc/pic/donate-alipay.png deleted file mode 100644 index f717145ca67ec9c686b9b9e0de4e8281c22608b8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37153 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	u-}^>I)(Nl4 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 D3XZ)? diff --git a/doc/pic/sponsor_asocks.jpg b/doc/pic/sponsor_asocks.jpg deleted file mode 100644 index c970decf817bc1f0e1a678fb84f1dcec2f0108a0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29877 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 N|Dn|P*+)JP|34!RG-3b% diff --git a/doc/pic/sponsor_nango.png b/doc/pic/sponsor_nango.png new file mode 100644 index 0000000000000000000000000000000000000000..9cd54373c880d74796413071bf4eec09f540d2ef GIT binary patch literal 14710 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 literal 0 HcmV?d00001 From 1bc9d1a28ec68005bd96adee8d4a6452656a90bf Mon Sep 17 00:00:00 2001 From: fatedier <fatedier@gmail.com> Date: Sat, 2 Dec 2023 16:39:35 +0800 Subject: [PATCH 18/21] update sponsor doc --- README.md | 4 ++-- README_zh.md | 4 ++-- doc/pic/sponsor_nango.png | Bin 14710 -> 12068 bytes 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ac04279e..347636f2 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ <a href="https://workos.com/?utm_campaign=github_repo&utm_medium=referral&utm_content=frp&utm_source=github" target="_blank"> <img width="350px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_workos.png"> </a> - + <a> </a> <a href="https://www.nango.dev?utm_source=github&utm_medium=oss-banner&utm_campaign=fatedier-frp" target="_blank"> - <img width="350px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_nango.png"> + <img width="400px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_nango.png"> </a> </p> <!--gold sponsors end--> diff --git a/README_zh.md b/README_zh.md index ac4eeec1..c54bdce7 100644 --- a/README_zh.md +++ b/README_zh.md @@ -13,9 +13,9 @@ frp 是一个专注于内网穿透的高性能的反向代理应用,支持 TCP <a href="https://workos.com/?utm_campaign=github_repo&utm_medium=referral&utm_content=frp&utm_source=github" target="_blank"> <img width="350px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_workos.png"> </a> - + <a> </a> <a href="https://www.nango.dev?utm_source=github&utm_medium=oss-banner&utm_campaign=fatedier-frp" target="_blank"> - <img width="350px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_nango.png"> + <img width="400px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_nango.png"> </a> </p> <!--gold sponsors end--> diff --git a/doc/pic/sponsor_nango.png b/doc/pic/sponsor_nango.png index 9cd54373c880d74796413071bf4eec09f540d2ef..4b83565698cf552046fea3f2a8da3ddcac17c875 100644 GIT binary patch literal 12068 zcma*NbzGEB_%;dxOD-WH@DKt9B_Xg2h#)P9beD84OG}r?(kUQFBQ27mOM^>yvrBiU zbRWL&U+;U)`F+lL{+Va)x#pUgC+41e<_cF;mIo8j6JcRtffe4$sAFN_0$5nsyaYJ+ z76BEJt@{MmTJpUl7FJ~}@wF-5eIDCYU0w>SY=B|&KJ!FXNmKUm<Hy5GcXeHh!lL4q zFpv23GOwUm6%B*@%Fd37LoRNf&(Rri{VjJ`SdXz3WF$2`r*<+nzvYUOzv1^;xS2ou z=wo2nt!_d~HE<#F-R4RyRPkxC8x$+>DWe27)&dBt1&kGV-z_K>790y}1`7*`jpc;% zzZbNSvTsLC*6sUm)SPwbwU<9YE{@39yM4_)dv<Vy1xMO6E%@rReUZ4oHSXZkY&#gj zk)U2c5K}146AmL*u$@p^eGBzkCipGjuj~S)f;b<sW}L!BgBAJ}NVJPR?;NRjOs>t^ zau_8T7Z3vG)@t)<$A+Kj*Zha_NCSbgm+2e5_Oxqw@6_)Jo<CJGn<b`!>ZxpDAs3IM zxWJ8(e>Khjl?8pEa6)0imZ@4~E`aABO$@P-i?Dpea1&v(3yi#DKl(a35Ev*1HJVZ; zec!mRn{KxO1wS8wOWwCJP>9$amkDLSf~M<$_dlQ5V-gG2?lFcnh%=(_^6kg>Ef=Z> zU!-thFCdl=cPF07^o!oxtXpeH*1s}r;Nu||>nj~Xu~kHlm9(+o!m^dE0b6+YBd19D z*C(ZWcO0M`rtX3@b8y2J(N_n`hjM#*#;DV3VZqNYDMI?KsDWfoS~==KK>1~E*BHyV z;NNzy7IW=H_<iS^1RXr1*dzFFsX~*LEyWZ`U-z<MLw%n%8PXC2+%aal;}$a@UaWT6 z;9EyIOY7z&p8Le@@7Kf7Jh|&ax3(cNH#d!re(o(`A97AEz{-`@yPaL~D`n`x(jl+1 zis)2iuAp+7QLHjLqMR(nP~pYx>#0K_Z*!ftQx&II^&C~-Gfo%B)5W3yaXyEx_p(Ls zDsIkFy<w`Ja1=ZHZ9wh36TR}*@z2h~^qlPk`#QPZ?Y;6SxYr26$RXd^LjEN0pr7(N z(N>Tb<II7ntu)W0it;On7^<iINcov_N^R?t!5&Vsw=RW_H}iwa8wh8%2I`C-54FAN zEyu9m{k&RBzY7M$@icg3=H&hy>=#LufAdjo$I3#oY#EkPnYndkuKMgNv$>+;>JbKM z<qbQkuF`_}uA!KaPk=a<7KSRnp!HuEWOD}ff#6sR%AP@%l+Ivw*roj3Ett#L>_&dR zep72k_1s=hDFP73)lmR;R!kW}lRWle32gfa^$Lrx_7e}3mO%AS<+OIUlV?q?rixL5 zV<>Rwcdi`qe!q-;?PsJ-6~ZoJ9X#?qG(y+D20?(Kh|=g0C<%Q_xRVQ%F1&SF2?WIR z9u{-%d2;3$U{YDAG!yDd&o7L0P_g7&@FHKAe?IkgCY=cYqD-0;xoE%{kMRmkY4pg~ zp!PF<nvt@aXFE2_sU%L9(5%8R-LwGWy0)luSVE}Ks-yLd<KK90*EJ}<<j-;cK&Cb- z<RN``)ZH7PWgv%}F<|`WWTd?Bnb3^WbzJUXQ}#g=!-StqBC_@F=XLl*;`?6&st%>< ze~SJt{6XeG1GeeMvwKl=Ua;r06<r?Qjxd5&Qe(&K5Nzm9;18isTw<B|ot9fLEl20} zL;FOI&l?SBJ}>KE-uXa5XNbzY_}6a(o7~n<wJs&EOx-R_>_mtc5CI|O<J5j4t`l=Q z_6g6nvlLs0S1EUF$zR0ox&4^o<Gp&{Pf4V+R~XQw*zc|6;nJ6`$y4#Nqy^l0^RZ?m zD4;o;sp`zjf#>fkh}y0eZF8HWzlT_5<|9=561%(lK+duUK2-AozQsiIb_rqL3$7D= z>{*!2RE-xp^|_@@3G!%q6MNWqEZ#%fZdteMMH!zKS6u=CGb$;otH5zW-I9zq9r*=+ zsv7`j{s?+F()x4DPIrvt2u^Evq2A}~^31df59%q|w7O+qjs@L`F19MeEY%aW)^$EJ z|Ff&Hg{f8{vLS!^LVFEl)Kj$9>hQ$8m}MLElv9wN^ZsROD8`pSHoD55#smN?0bfDf zs-usEWT8b<0-A;RqB&c65x|I-{Eo&{M3sD9cWrCesq^N^kGELRU#q-WE@wqYC#Et* zyn(8<@+LPIwCZrxPZaH9O-FFmajD4n1*{<|nPsM`-H}_~bYVjG&GDISLTZeE4-Dx* zB`qWhm>!sepRj2Xf&^rt8<#$zW8{UxuP8_0sv~p+FTOA@AR4>Ghcw=gxPad(`7K&x z>G^WJ9nygFjVyYSBUwD~zV_U<^Z)n)uNbNP+oFUA7fz0=?Z>l1&j0qWsJ1HF?Dona z#Fs3}n^|b5^Z4KqthWeyYFxFEQ0}H)%t3VDA6-}#=QBZ6L^N(~f;Hy>9`EpyZ2PCz zQz#-R?%}eS14ZrFN&xEuf^n8_#|$w{`&V-|dlD&`ooE^O=t_xrmal7_<|+dCq-)0= z-3G>ANcPAYH{!a!Rwi?pAzux4o}K_Ji~B8Tz|n~Zd}={}I10?KYu(^zN>Kj=NW9); zoT@WzADz*hd931T!xi~o`C!r3bP_PIfv6}4xZy3}tWCtiyb93g5CGkXbq{89y!SM2 z(!>IJ$wYTPP;@M#274t?1YJPjyzSRm88n-o%c}edX!mK5gw`(h>*rZ#K7jgu0y^~U z-5j2AxS3hI{1E9Y*ajd28e_>Xx2?7Tbbd4t<BHl4C;M5zC%vQ`yF2@stda&^vBYV^ zJ#MiLH#K?!7f!kGkB~!!%VNLyGwb>tXuIOjoSXpvi>s-*p;|R>h1ucJ-TzE`2ze9I zer*}j;?N@aD(Eoz=_ne!{C+;zjj!60xuCF?_@3zy)csKb)gaQC9f0D5va5wAs>`FJ z;pCmAC1;N8jaj!~<f2nY219_3^cH9p#Jpu0eKx~KTG^x;?Pg>T6<!RUC%6;`=e{H~ z`00e=fAUIX>OG3Uq0i)}7XKB8v;b6CO<Hzb$Rjwxee!p02vg1%-S?;K*l_e0%dE`} zNFM22h%WQ%P%F4<hKVM>z0`ag_{%e$+h<oRu&hmXKH?w1J!@ZV5~hl^%A@8N)BrNG zg|KC=b8}$|S=T6a4W`z$^?xaM>l=~B@a&b|#^q;#)Xb%{uV4T?`sK;H2P1zz!U>FC z$R!;c{&0t<WKJ$VvD5V`OWIYqC&s5{L%q<SH&e`29peMf^4Xi%YaT96kAQ#wz&-o- zW&DMGO?#@B^GAAS4|cdPl6Lctkk8gG(H%M&u3Ak!HR}A{&StC&EWp)IR{x$MR3y#z zz-W%N=QLzIKM){g7ZyYd#LKSga5#9N{*5U(GRw~vs*KyLf`slU`UC<6YQJ($Nc!`b z1YfcoHf~D90;Jr+4V6^tz1o*#7bUj<{4G=Du>I`2(~zcR#6|eWv*v!lysEKkOq)?4 zw*}11SA<pphp@C8z*V`{_0wuiPgDv<s$hx9*zogTk|CPloQkG>Mrhw)xAIrTGbd>( zU4^v2J#wM+PN;Bv>ZMncR5V15tDqnTB{T}0kSJR_QeypIp%*^&NVtcV_l*y{sH3QM zcNtN~u=8`{9;k`~KOd0{(JN;1n{EXs(F*-(oKY1hX}tH0uR(32!+21J*!fEIffCTi z15e?l)-O$-=?CW<Kk`Fr&BhrL#BT@OFSvXj;vcFTYs}bQ{|5e%nJ~iZ>sXj)?s%zx z*5)LNdUjCWJ2p!E!!{og2b)ENTh!~1UOO9zzK&n=XAyOP%f|z5L>qWMO;}J7fS<}j z{I_^(@dOB~=B7L2o;S_C4o4nbo*Ax`T)y*n3AJ@QD;G+T8_TeC#+FdEX#syo7Y-W* zT@6m$a&BMqc97gbSegSWZd=n>@&ZQxJ&G{p3;6IFLyPZ(di0cZL^VK(?f4OcL~raL zoEfLgHq+IrLaxjWS`2j%pd7SyMypQph?Mk#1A6bb>HnbOq%s-^gN_3UA^OXRYLZx> zUtMgHJ5vDiiDy7!XaWp1bk=0ygo;7Awge{T_Sdg%)TUazl97wObSrRjqrvp^oh12D zBR0BRwfpI2KE_r*LU7h=o!!_vy1tH{R(%1nA3&@4j?_Pzen1D7!T@}mxiHz-MgYnm zN4K@%p1Uz(rj@NpX<e~ZL?fUXCwh@%hXTG2bX{~nVjo~>D<HbB((fay;c&lDZGkuk zi`LL5Xc^p0TTi7}e%a2Poj@*1xfCH6-LHG}roW{!LP{UNg@YIxO2?e$VnUZncB!EW z3ka^>98E7~>LukF5-t52+4~tn|I9BdpuG8QmPI^+H;4+kXm{rKY-{<vVYd2Ap=^@D zhBX!#c#hwk-vZ%B;Z<!aQeI>p;=+a5;3qP&d^Zh*R~G^KpBPCLaNx)#q?x!H1%5z3 z&DMmq3+T7;5+bsI^t*us;zF_IT}FLwX%;l%<FB|*@Z?X?8Ddg3Oe^;hv1_fh70E}Z z0orw<*)42nI*0iHDYy4efxFIHuKVb~0l)8lp}zF-AO+hA=gBsyT%wq28vcw^>q@I8 z{i~>$Al&<?gH<@|P<kIf;^@A@>pEl>(W@!G?EaQK#s$zhTgj`$NB{Te=YI-?{vR>Y z|7*bXzoSqAI~miTR@e#NXw326-8CB|+2G2U*OmDASS{EVj_`9jX+GAYpr9}f4hsvb zkd(Q<(2rSJSz+Y&M##O50Jt}O2H%_hg8mQO{{;L0!rfYh5BNm-t4&O0Z^?f5zaf7U zdS}^qvy@4H^&J+*`8hjR_$<vo|4$+EH27qir+Xsu!Xp;z<2m!hw8XSV55nZwlspZh z$Z!qj?-WER&MeuesI9lBVL`mVF22nDLC!q+_CS9*c0Of7KNRJdHf{cR?bS)aEiJKz z1PV01v-ngh6q=`$-yY%n0>!(0n=qOLL2O^Qr0&Ye{%K#^2xk&6bvyr>@tN%Cny~Rk zgyysn)|Ejaa(Fnn0|W+K7HFTSK?Ez(h#_L8**s`+r_ehHM5a2DabQX4_wI0;$~UUL z>bo_g*^V-*>6RWDcseM4G{ZmblwJCKWMu%8{cS?M!3;$hN&&nHo&C@S5;DB{Ys2RL zWo%wzXEn2F5JU=o5L*6j$l&F;!f5SX<Y5>#th+t73Y+z@VO7r5SQz*h>W&1cnDZ9^ zKC-<$#78fD8~0p3xhUs0zU8lJs(qDZz!pXAg#h2yu$*M3Kaz)?|LhFkw?>63dj{yH zSau*><+oym<i{Ok5&~BWNon`@1_~#N77DN!Ik_YBWJ9H*Tp_fk!4->QYDhR+>+w_0 zrrX7ft2{o|l1R~SlgtMJIb)NYJY8(d6?FM?WrwNJe%(}A;G)gP1rxdi*;flSPbKs` z50Kr6V%AK?n}pMDKWU?ME00NqOPc-aob9`}0BVnH?3E|hXkSmoIzmkKLHT%esc0Fq zZ};Cc0gAnkJ|n4OPnnt>3AK|_zlFyPsQh?lc3M2AeA|}HwK3<rg52iaI=2=ljapTA zBRrSkt4ZbabURxq+w|Kf<$U1Vs0kvgLyMkYmRD&u`&W_`sOOxrxIgtWZH|koGYRGe z5Vw49FsdrdUTvTPR5q6d$TcH|4Le52V?Ywk`z*V4H1k2j{#eMz2Cv=5Amt_JpE#9~ z>wb+Fafh$#S|>ru+b`Tre%=ihCWnz!MUQH~K8bnhVVrSl+e$Ky(Qq%#q@lx`v2G_7 za?N~Ts4Bp7?yEheJNiSk8!%_1|MxdhLe-jG3iX?n#BwX}VyV?g?1$^p=)R9l*bk(W zFfqYoP=Y`4YU=L|5L4&)!m#Lv@(ZTEM4qowTZlQ)4JY!Um-eJvIO!AG&Ivn-;F8P8 zeNt*hfcxM@3|J|&oGOPIa*|Q)PO8K_U3O}*m>ox#&1X>m&kcl5?BIU#@i1c2z46(y zk5jOY>-IRTg!w&%;1U5!3|q;bu~BiixGTvn3umu-3Gq!AFZHEf1F`6eXR<ktfqeWT zy{3CN*$3MuzT7^Zy1w%)z)F7RF>iAVNcfq09d=J(zm~C85SWvqX4V2#L1r`eHws(} z$*fn{Lz4#@f5j|-1a#4=K0Q^f{H7yjDn!CI$2gYDh1&qkq<@_UFmpWO2IPKUz5!dr zfa_i-)r}1I2?2czn<3y*V!78GHk_1{7$Rg!3%#b>Ch}=MFnDqNvc3R7Q-OaT%_ik( zV;0^phxQ+*8XVz4vWK@lzk*TXCgZjerom<F&9|zD6p&+1qA;)(P|e~LEO6uJt(y}v zS^-Z5$2c7F07??@81M~>^@quHb?bw{A(+5d7#|xYFN|;59>}_x^S0j{H8aWl{mc3A zK%iJ{_mB82AJjdzxEKDitZ6`vUfh9oGiN&*9rw-lhpG4c@0h~Y-)rB%JDIi_;IjQ- z#$tFIXnfqTgCF>JBY^ik43|27<TE&2K*Egfpi-am-DdJ1SqNVe<E=O#ylsglr$-5O zJl>Ss!o>X^(0Ai{;zSJVl2+kQkEW-x#EmI@1=02I7c`+Ncq)=qMSl?#V(qZ@qzc~} zb_&B7`*`F2`Tlcy&gr#ssw^atznPVrT6nLvRG+rH{3@f7q}VX0vEB;B)bUG?Ef~%i zr5NH(z}-#K1sWHXzjR{lf9)t>+X2ch-c#j|kgcNtj5iDDQ#cr#G~1e*#I4gbcD5g1 zrQ=jrCLh)fUO}dG_}+@(D)b7u_9IG+9L(+c-H2+<q$}r+m=C)|#zLys3I!|7@^id0 zivlx)(G}GTB0gK11aA47RSa*QqjErT`5$$7Y*N~}Nziffp=h3NItnYXDQr-M6Wzy2 z>W(cN?8hw(3^Cz9U|pVMM&CH<smMUIL*4B2R?<X%X87)Lzp*ia9}2k|r4hlG{ne6x zo_mB6^&osDLc^(ZQk*}XyAFaHCQ<$#bJjy_j0_=GX|)zOdo`BYN>CEN;yG`D`eHV7 zcbh`<?AM~c*K(Z7*am4^&^;qk%2UDbbX6o+VZ@wq(^3crc7Wkd@sSLo)hEWTxFkBX z0XM-Xgv^)`<$ufR(xM?<`kc3l#dSLCH!&PxSuuG&4eMeTuP!!#4W)OyHkuF8Uzd-# zRh6{MA`#X4S=s6;;OW-q?u^O=M<`^{yL@<Z2ddNYz5nipW*jcX0TS~7MnG&&-bPMN zpO68dDv_+9F~7e5q*RA<2Z|rA{|a3GS>4J{3d)KP_rpNmuW*5qwG^0Abe_mR-oT!O z4OytYe?nErp0|zDonsaRezHcjSHTh|UNoU+2Bm&|#D;fji1fYuYV%Ou*c?^E+Ly=F z>v~skN(G4-t!LA}9iaxOu8!TZz{xene?eQ4moP*V@1_qV#cS6RX@<kdnXWX4#{y~i zHZHriDiC6pu+nY-qn_jr_ig*ELm0NSGIR$Qe*U3Lqau>iy573e!R#M9aphb3ugxfg z($$;pRoiqE-#pbF&kK*A*X9{u;s^pth%V{rSmwsRyeR?3`95l3Cogl22B7kxw32T8 zSW-zn`6L0SPbu>gmnm#G=pAGz<&B=4AgZFp^0daZ=XS4ybu@T5V;*tn)?^+t7z)eV zDvSISIQvfs*lCx>v@w{kPr?rND@=#*+dcDu%AmZ|u1S`r;~2;zn-6NZX{ePHsYs!^ za$2DyCc`>2=}h0Nm1Wohc7qCr`_^19vRCeT$Ky_z(>5iz5obL>jDvQr;2qZtn9fWl z5PWF_aa(2jjl*4&1;8qLpXvbC@8|nK0xKF&J+C%I9n>w<#+S|dx48ITwW%zHuhA3C zyc29gz1+JG)Yyi=#H<yf7gEqpDb<n}!)ETm;M*R&7cq{m*sHd?8h+st%IF1&R>bMV z^BxtyWbo;}B~CfkYe55MTC!_G$az2<c#8>uP5H-w{f2Y5?F%quw7J=Ht4%CktWY$T zgxPd^Hn&dXNw)yruID*3`puQox)R0jO$<RhYa>78qi$_4=x(BEaaW-I1>|}oRf<w* zN<8kBwKUi0yQ!7yS(uGu=n1}^`tR*pwh%8qwRUlu&F4WA_E5j~kwQPS$}NIcrA+|J zND@j&ktsKNq{gm2>ew0+H~H{OweE_^wi>hbb8(kQFjoW;h-CP@GORsg*vWb#6}V@B zA@3NiAdlQhuWML^HPc4=mbSBsbRIDR52`adYIRCp#q9{;T-9}heCtdeoDiiX1JC6* zH%y04g@@vk^nO;filz9@;iFAL{lb1lXp*7%gh%7+4xniAa%z6_;M5g9=2%H@U?%Ot zisWnd>6bR+=>h*tu(MeArD$k3#UUxML8B4<T$DuAyQzqQ1F#Ei)ydrQ>CBwfW9M6X zgO3)cs_a3adw5znb1;GRrU~cXe?i|&;Es5Y>;fkh#^b=_vb&)e@;|1`f@#L{ASQxA zH^P{6KL|IwSqFkDbws`gB>Q`=CHR3YqS#OLox!4aveRUrj(wIk`ptXnqT!0$f$AX5 z8Kf*E`wy!^(nLCVAyVdedELOn%Kapu=eNU-k1^6rGdKi;>VE4jGM6oQNs(Tuy2$;P z0yBnm=l~h2mIS`?B$4rH*rddSl}g6X##Wk+;0Js9A)-M()iZY%;_ZlVRvNTwnq>Rb zEb%%M7^l7N;dGQ_no~%EONobK`<PPN8HQXBDt>!<e$XS;@8)?RPubW7Qok-r(KA7t z9KL$s|I5)PbmCYsq~<8}`{ajx+>DSv8-746!#MXo@X#%&=oQ1#nT}u#CWU-}J{}ve z6QFNma?HU?egyH9KeV<I(yeyLvclQKS%3wm=AX`z--<;x2(&be;-j57rQ9QQO;P^y zwNo4Ty&&Ruv)cvK%{-sKab*0+8xldE<~%&^51!@0nF=XJ%$_p=nZM~wxu3cbs|sXW zKbq!pt@gw5$F)Lj_tMP*ufinUi`Mw9ad<Rz#;Cr*m&7PQIL5n-Q!gC~k5bd$*CCRZ zHNra}C%Iqg&Qv~p@go^{P$+1MQ*<Y4kLdk8rrl6|^2#uSa=84PZwk1A40M*`cT70( z_kgrW#P~B=Wlp2~9EVvrm^ZCexz)F=2Wm<AqDD~62GL+0^()xiqMSSK*=ahmboK=# za1bx&>p(U~tpZbctZXK^c8_<$t0B*g7{G3y<BLH1#j413wk0iuZ{8Zyvd%F8rcVRc ze1ClL5D?}r9;UvHzpL~ph>ZRriuDoAzkk;>x$}@<>6gO#vG%2aMkh?lH}JCqn+NQ% z^1Z+LUg#MqFUe#(*AL>C|3!U4%ve~F_=>5Ff4&vbS;YpgXjQN7?*mDZ?eRyWQkcEP z!0TmMDX}TsfBoaZ@S<mGDNk?|rd{JBSTlg<#Ta6xL?)&|^cx!nu`%4Mj=@U4gPx|6 zLRX0C7FE1g`y-J_=L;zgX*)IXQ<!e^l^y4s%h}^eW2aL&2y81*5wdat3Q!HEgmcdQ zMOezuhzK|kF0&asL6an#Yxk+b!0bt_z-i4zpeybN3!IjBx^CaIOPEjBfg#nFb)e;m zP?q)wi2HhE`kLE$d-!2v%h<4`;b&n&L6Y)ev4I2qbTb=T;kxi3_fHs#2BU+m&#wUx z6AIR`kE0RFc=^lS;dE{gQF)O~N^h5Dfm56RCN1Jmg(-%@1C2EWZRj{ar(lb={K&po z>YN(BKiacq5_cjtrX)gyoU;O#pTBo?Q;W}`<tE09Zff5W;Io17pHF>F^?5(r!EsR0 zu418_rc*H?wZ866;m!x{#K+ccnVf-VIR@h4tALCP%iG4kK56eAL}O`sknXqc%VtgN zsu<}Wp5p?lj0cpwH7mlj3J~3?Wl{+BzA29Py3j_L!Jz)Qn(Mbta}=luU%}&acs_dU z`-NpWjM3Rbw!BY%=r;cf7Umel+(M%L_B)3NC0Tx5=g~Tc=AdL$nPQC<s-$>uK(<FK zX+2nQ&el9Cvq|bf1o;&1mIf(nfuKIgI?V#W_Al!=)X@;Bj=%e;dFbhu3!PO1=M(GY zWIR|?!^3fP|IqgjkzP!RQ3m3gwwmwk6ybS=oQ1QjT0cHLCyh!>yOG;8C(d-E8m91% zZ#B8NwVRuG36UEM;IM+~41f_%y}rJRs|xM2zvgTLk$RX2kAlycpYx7)+<)vJyOvZR z5rZ!QMnTKe7miGxXA+o`qpWkYi``tP%BxE4+wBtzNW}sBdFi=*>-QC^!=pYBjTLHs z;!}&Uu4^f?gc+oQ54cSTWRtrgDYyDx8Bw5ZuYtchC<Jq`e)5J{;-26hQo*`%c>S62 zf0_Sm@?|z&-M~ytO)p|5Ss3|QRowXjU0q%OSib*~6fie8_kBY&G*&po#KiY?LP9Vt zfB*jduZAh4qT-(Le}Mh}#r;oo+*uaEkCeqOv@6u0O*th=x6lYe3DtuaU@>ZznbOU7 zH+MrN4e^~UCjoPpjcT9h4ys{85I;U$M9IbDRW#wE@Ic`Q`pLU3uD(i>LmW#b`ic>8 zg{Sd6?=Qs#m_ns8K3o`9C%tSjw4nUaDtnTyeUUaS@oFcNb8pe<ifMED;GZR9W34u{ zkn`fG=mr;%xl8wSh)WGc)(PzJ1~R<MAXvXnv)`NHs<|pSercGsx;m#HBFp@9zXZgl zopoZq$D8^kj04eJ7}LNr^$PqdsqQuVjKPHP&-GL~EG*K5|Fi)AHu*d{O*;f}Gd!#g zkz;pj`m=^+@QzkqGwENG8hDI}Ft#)kl>77%U03h@0S#f3m)+Lufbju;{j)|AzJ=2c zs(%$8El8F8nomwhM2ji!N$;b>0<!csc@+N9&S_ZpTP=(2La0L;t$+})cilg3dn*+5 z)uw7w4f{SoVp;HKM;=yK7+bk$wcGSAwDTS8Z6xBY;pB^F`@F2N?vu^7&6hftyE46G z!;fZI%W&I_AYqU2ubRzKs4M*H#dY+;0+VvmMTf6h$gLja+J<+=TzZD7URPmK=f-TT zZVzXMG+2_WhH<z@>QNl6sCr`y=;R&xz-6Kj@u%i5wEzd~U>hCs$fHA~&ejYk9xsWD zTQQsdg-_-{$2@X75=Nd_ps63{4#6Th{!wKwOHu}@5!l(5f3!7Ws<<1;`=B~#Z!jM0 zI=RI44x$R-f23D7^qt)5FcHjkl#Y2bE(J><`m23iJz|=i0yaLM?*Sog`*+_x8YR`$ z-uF83tltQF|6}!w(~GX1b?V`EZrF^&*R)@WlOKk|jeW##A{uH3XgvJDUu4RiIZIaJ zza-L!eEZ&m2fZ3l`<huDQM=laGAF-mG;TV=FkWD7tKk;`DwBxOyx+TD>Gx9Qf3r-E zw{(Y#79FIlZ$F-?VxcCXG_Rss3EX)!Oiq{Psx2fgLz;K|wsTyRxa`+N?7L|GoEXt} zipkksR4*DYgDh}(2&*Jl;l=Th7R>$nAgf#=IY{Cjw%-TQc{}|d8Xlq+o_Q}_QB!eP zF!uMizQ-pwFaoL!HbB$ei&(JU@O6RgNokswT+*u5=*)hO*Fb_rNHLCefKk<M1c&*` zR<U8C=Nslby^Eyw+-@Z_|2opxHu`YcXd-r+YW@b{@G#jgy(Y0hTa7eT1z-+M#5wZK zE@r-|w3fDt#Pj#*1HGzZ;m&2jk4XPjUsvE{)BZ4s=H;T6q;-e*UI{Cj59BNgXm-~a zho27D1;n^6ZCw`oypi;`LXBn2YU}G^r!Bb;hL=95XazT4zYIWe5J1ptYJ;K=V8}PL zjN8dJj@H3-@DWjd0lMPwJFSisyE^_Ww>3F2#5;9&NY{35xKRp<W2)V3lxI4hp`1j> zBDg#U3o}K1HaGbzCs=gqZTKpJOF)F)Yg=<z`qnarcUdWIL$IO8JwL*(7~BVHX!`24 zz=edlA|bCJ7SxiRH<{r0{;#LM=<BCxlhVFN_J4?0xOr*M*)p;1jG%jHXCdXMnmnK; z0GN#|=qqG^`xS`M3o!-mhF^YsT6dlxnnIr)$_<@m(~RzgZ4J28@A2;_q*8#jUroYo zt@2`xhYz-wsJc^mo<XPzAhDt*(&PFvxdxRvQC{=%%m>iV2wWUk@drED29egT$GA86 z9I%6w92y6`%O~3XocLoKk2mz0u%{zOL)5(Z@AziyHotC^X*AgL&Je^qB}bBp0s}+@ z*BE(PD^%*}Z-y6q+V%gSVKf31<KPi^b6MgV#VttGG(+oK>aPI>*Xnd_DddwkpGNW* zdYtR>Cn}qBG)uE$G=_eMQ&M4D!>kM(#91{oCzYzU>Z>HXXx@uU_|@&;z`E?cA_s8N z=3H|eqX3`}ok0a0+f-$MjZZDD7)4v2TVn(I^CDVjE={Y#)shUZ0}Fz)457{tlTp!L z<-bfL5RJH4FrdV%vX9Ol?#rPE2%uSSAJ)dVX&6KVLb+4Gb=xRXE$!uQ$A!o-?GyMU zax{J99fX|l)%G;OL%)o@!EmsKLJ02TU&NSfpO@8^!St7N*pK?cm1BozIW((_TwXaH z71eo{Uf_mIfJ@G5efSxUKbWA{t~PVoWN{#$P0dL#kJn}|+CYS3yP*S<CR9ncA5pfC z`42wrT&04E*2Ro*oM{`4N;{<zy9Gj1W)LZ0Gum<hV#8p)VGv38Q&ljjn3jZei=pgv zXJs2ni7G{@_7Cs)kyJm!m86qUDnRX26Pd53FBFp@6Kpi51)K08$kr?3-c+-bt36Fj z;(AL3!?tP~k%#ifk_}!H>1eDxr|Ucck;+ycDWNXp?$~;x#$)w}#q4woAS)4aqt`jw z2O>1pDwWHr)dGK^H=Sm=TQ*cK>U2h6oW#Rx{XT&kIjs%U<srx?-NcTE(Js5hY#7zo zbfyzync%u=NivRwTLmRpilXAB4E|p%V2l%aq?_Sb)c^UpP2pkl{(%0N$TL8tK+~1x zpziXZV9C|mFa3eM!x>EV3yW$~7Z@mG8hZaPneL=wx2dZ&%3YPi`<EHcYOKZvZcqsv zfwGhQ^1OO03X+Z0O#_Q_v<&VyEq{U;_a<EBnqW^OWPf@+vc;gz&_63YzYqc`*&SsI zBg@}UcWfafguLA$j~Do-rl6K}UdnC~Y&@vq18J)*!Wh$5BDV%CJjct$xkmGer(%;L z?31)pZVtSPVl#1M;1gh=&%5V7EPvuM<H3~xc6W9y#RNXM6#_nig`%Fms5Ml1o(xms z$e~=gf_W}Q9((x3JNL#-5){<Wv(O#?YGLP>cohu#s7=~|N--GMJX4Q@r96BHqg@h= zq_yJWkQ*YU(hhPa5|(KEi*SDbF&IoroB5`P<N?0WGza1@=*Z3W+yFiFy3z;oIiM9` zCQ_&=$%A<;2SMTiQ?8pwbF7$eDmO*uiuk$T=FM=lwPQ^qzM7|yZkNBH=&|eb;{3;w zn76!>ZM>TJ=~vIMtIs@}CA=wSsPqGONYvAO?Vk3`rFFFGz^n{v`|+T4R^ZFDkK=b- zz!uHTUR|r7y9$dAPs?g1t6XaI5gC&7?qBBp+Kndz1*mXec&g+|T{~`cH}`^KIps6_ zMxcY@9583o%HS*$4t1pfx5^+|t9jd;4$q%~1x2)L>2W5g+F1>yr>kKpc<qhmDwP4= zpEm}MaeW|S7I{(zrG2?ZA!k$)XS(gvn1$}=RC|}KsHsQA@JrS!qeR|^r<y>U%X*}k zhsKcwtxVV}Io@74>n^wc)23d~3cGuv=!=hOycj2n8Lf*CK9j$>pA+DE$dkw2-OUrY z(3z-0!;@ecdxaW4R2Cp0CG8udR~eF}_%|`ZW2T_00g%S)lLt@&Xv8T(kE#$o%EDU4 z(gD8x;}AcsCpuoFyhqJk8ovFbI(eNvq6d8IQ=2n7>=FuW&Cnma!~&lMRi=5!aG*{c zS?3(c-smky4Y<vGzm#XKZhKwU$BU9n>zRrwVxmXd@~B-yuGuKo?S*I1DA6Bm76`Q$ z)GDe1aK7c}2T-Tg4a!2ht>UJ1-q%jSsaIsY$kz06USQmI8ds!_ynr*5@HKs0Qxq^~ z;PJ8E)UhbERI>F98Ppt?c$!<28)RSsABsw|o-r|4FV1w5?)RrL&U8|vZ;lQa`LG1? z79#DGqU9DXH02TEu$h4KzFbas!TE_;=~w6l8#GfM*oDnfT$<+PuFaTCBje^mF=rM( z03i&U*UlH}@P8nv)BNYPrjU2=E|Jqb#fJl};FV~<Gr703#0DH!miGgEtwHc<0($>c zBXrnnJHbs6Za-z>mN#MoG`RIy(>Zj7ukBOTxFMdq+RuUjNK=T~U!_6b?hZ4b{OnJ$ zNS4fuD;uLfl{Ra;+DkrH`ju`wBGB_^JeMm<YJY?ev8632`aMK5#=p+8JL6~!h_)%k zzNYr#WXa0S!y?M*atL#fP|_il7izis43s1lmFi_WKK*0mQkO5H(e|A$X?J5d4ij}n z3J+t`{uZX&)fdvZSmQO-?Xq&K0k4oBP}J_J(ttR!fx0_CSt)k^?pk=98>jGvx{1l_ zjqhbhZEmUSF!K-V8FKi{AFjiaLq7{Ikp^#rRS@M>er)X`EhL10Z2HsHJH3cvu|K4R zH;T53b)D&eAyw}cIr1BZH5ieg3!(Xh<mw5D+7Rr;Y+#12fT!?m9rYNm)K;d~$C@j8 zE-vS(tekv`>V%iQqf!e9WF6jeu3L5Xz?%&pgMqI?Ttou>?Jn;L05|VV={Mu?-TiCd z{W+f=<#@;Y-47`1FB##42Xe7%fA&)O9bn&GOXHU!G+DD_5_>$Ik6D}C{+IR)1C-={ zU|+D?Cj9znprDNGgdbXA%KM%%F1E_^>X`2X$-(ne&@&o=Gt;sf8i&&bE!mojF6m1< zkMC_@A&meQfj04z*IHg9+NWpjp~cU-mt=eFI>4pIEg0t~9vv_1sC(J$>1hjGDbpc& zjuKyV55vd=>XYod`jW^5w3|I0wkx2bs|aajTiz6lj%l<9&$Bh(@Jbbtme%Ug-Qfs( zC9GOYHr=n7>y7l&MlfOu>|^~cY|u=69dYgSb7iDD#ivs(;p6h&2#p@@-n=XP?bClA z#bd2`S-Om0bceD(oLm92@U9y2kHu++w)L5?<!k~e*b*>iA>5A>G2_HatdODUU9txo z6&g~pUekRc(4SmeJv`|o?a4Kn<Vaa-Es*r7N2`)FUSzkCM*010d-2aHo*y6fN{wp_ zlf<_Jr~VqP=rF?{=g8gyHDu6Yjz@Kq@HO-<zTkBR(bniMlZ+?5=IG@j1$#e_CS*=p zVzGjHs=%o^Rpi*a(QL3(Q$pggNYI{~Em3}P!LE;b;?LdU3>nP;k>oNzG4E3?9K}&H zZ55|y#CjQvCj_yp%AYaH)=;;OweNlfQZvnWYZ-n`fzed=TJLLIbCwY1Hn8z~kN$hT z!Flb>P@NF9CO8mi0f%-!^N$<VXROkt@qI{*buVFgDMA2$f3NZO1w-Tb$?}u)1t<G$ zHt2+n<f2-)4w+q0-<@PsMgNx${68tgfAWX_lnwuB!T%G3|EC83PbdC=0{;Ia@2Oe_ bTX_w4USyN!Db(H*V=2fg%alo(eEPot1F#nn literal 14710 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 From e66e77cb8f596e9cc76552a46f1918e2c17afea0 Mon Sep 17 00:00:00 2001 From: 0x7fff <4812302+blizard863@users.noreply.github.com> Date: Thu, 7 Dec 2023 17:25:22 +0800 Subject: [PATCH 19/21] add error (#3833) Co-authored-by: int7 <int7@gmail.com> --- pkg/ssh/gateway.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/ssh/gateway.go b/pkg/ssh/gateway.go index 07ae9808..90f2228e 100644 --- a/pkg/ssh/gateway.go +++ b/pkg/ssh/gateway.go @@ -75,6 +75,7 @@ func NewGateway( sshConfig.PublicKeyCallback = func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { authorizedKeysMap, err := loadAuthorizedKeysFromFile(cfg.AuthorizedKeysFile) if err != nil { + log.Error("load authorized keys file error: %v", err) return nil, fmt.Errorf("internal error") } From e7652f4ccc2c01350ddb0ccf422a2a2d94986215 Mon Sep 17 00:00:00 2001 From: 0x7fff <4812302+blizard863@users.noreply.github.com> Date: Thu, 14 Dec 2023 20:32:40 +0800 Subject: [PATCH 20/21] feat: ssh doc (#3841) * feat: add example * feat: add ssh doc --------- Co-authored-by: int7 <int7@gmail.com> --- README.md | 39 +++++++++ conf/frps_full_example.toml | 8 ++ doc/ssh_tunnel_gateway.md | 164 ++++++++++++++++++++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 doc/ssh_tunnel_gateway.md diff --git a/README.md b/README.md index 347636f2..d6e1986e 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ frp also offers a P2P connect mode. * [Connecting to frps via HTTP PROXY](#connecting-to-frps-via-http-proxy) * [Client Plugins](#client-plugins) * [Server Manage Plugins](#server-manage-plugins) + * [SSH Tunnel Gateway](#ssh-tunnel-gateway) * [Contributing](#contributing) * [Donation](#donation) * [GitHub Sponsors](#github-sponsors) @@ -1169,6 +1170,44 @@ Read the [document](/doc/server_plugin.md). Find more plugins in [gofrp/plugin](https://github.com/gofrp/plugin). +### SSH Tunnel Gateway +*added in v0.53.0* + +frp supports listening to an SSH port on the frps side and achieves TCP protocol proxying through the SSH -R protocol, without relying on frpc. + +```toml +# frps.toml +sshTunnelGateway.bindPort = 2200 +``` + +When running ./frps -c frps.toml, a private key file named .autogen_ssh_key will be automatically created in the current working directory. This generated private key file will be used by the SSH server in frps. + +Executing the command + +```bash +ssh -R :80:127.0.0.1:8080 v0@{frp address} -p 2200 tcp --proxy_name "test-tcp" --remote_port 9090 +``` + +sets up a proxy on frps that forwards the local 8080 service to the port 9090. + +```bash +frp (via SSH) (Ctrl+C to quit) + +User: +ProxyName: test-tcp +Type: tcp +RemoteAddress: :9090 + +``` + +This is equivalent to: + +```bash +frpc tcp --proxy_name "test-tcp" --local_ip 127.0.0.1 --local_port 8080 --remote_port 9090 +``` + +Find more arguments in [document](/doc/ssh_tunnel_gateway.md). + ## Contributing Interested in getting involved? We would like to help you! diff --git a/conf/frps_full_example.toml b/conf/frps_full_example.toml index d25f6473..88cf60eb 100644 --- a/conf/frps_full_example.toml +++ b/conf/frps_full_example.toml @@ -143,6 +143,14 @@ udpPacketSize = 1500 # Retention time for NAT hole punching strategy data. natholeAnalysisDataReserveHours = 168 +# ssh tunnel gateway +# If you want to enable this feature, the bindPort parameter is required, while others are optional. +# By default, this feature is disabled. It will be enabled if bindPort is greater than 0. +# sshTunnelGateway.bindPort = 2200 +# sshTunnelGateway.privateKeyFile = "/home/frp-user/.ssh/id_rsa" +# sshTunnelGateway.autoGenPrivateKeyPath = "" +# sshTunnelGateway.authorizedKeysFile = "/home/frp-user/.ssh/authorized_keys" + [[httpPlugins]] name = "user-manager" addr = "127.0.0.1:9000" diff --git a/doc/ssh_tunnel_gateway.md b/doc/ssh_tunnel_gateway.md new file mode 100644 index 00000000..7f1a3ef9 --- /dev/null +++ b/doc/ssh_tunnel_gateway.md @@ -0,0 +1,164 @@ +### SSH Tunnel Gateway + +*Added in v0.53.0* + + +### Concept +SSH supports reverse proxy capabilities [rfc](https://www.rfc-editor.org/rfc/rfc4254#page-16). + +frp supports listening on an SSH port on the frps side to achieve TCP protocol proxying using the SSH -R protocol. This mode does not rely on frpc. + +SSH reverse tunneling proxying and proxying SSH ports through frp are two different concepts. SSH reverse tunneling proxying is essentially a basic reverse proxying accomplished by connecting to frps via an SSH client when you don't want to use frpc. + + +```toml +# frps.toml +sshTunnelGateway.bindPort = 0 +sshTunnelGateway.privateKeyFile = "" +sshTunnelGateway.autoGenPrivateKeyPath = "" +sshTunnelGateway.authorizedKeysFile = "" +``` + +| Field | Type | Description | Required | +| :--- | :--- | :--- | :--- | +| bindPort| int | The ssh server port that frps listens on.| Yes | +| privateKeyFile | string | Default value is empty. The private key file used by the ssh server. If it is empty, frps will read the private key file under the autoGenPrivateKeyPath path. It can reuse the /home/user/.ssh/id_rsa file on the local machine, or a custom path can be specified.| No | +| autoGenPrivateKeyPath | string |Default value is ./.autogen_ssh_key. If the file does not exist or its content is empty, frps will automatically generate RSA private key file content and store it in this file.|No| +| authorizedKeysFile | string |Default value is empty. If it is empty, ssh client authentication is not authenticated. If it is not empty, it can implement ssh password-free login authentication. It can reuse the local /home/user/.ssh/authorized_keys file or a custom path can be specified.| No | + + +### Basic Usage +#### Server-side frps + +Minimal configuration: + +```toml +sshTunnelGateway.bindPort = 2200 +``` + +Place the above configuration in frps.toml and run ./frps -c frps.toml. It will listen on port 2200 and accept SSH reverse proxy requests. + +Note: + +1. When using the minimal configuration, a .autogen_ssh_key private key file will be automatically created in the current working directory. The SSH server of frps will use this private key file for encryption and decryption. Alternatively, you can reuse an existing private key file on your local machine, such as /home/user/.ssh/id_rsa. + +2. When running frps in the minimal configuration mode, connecting to frps via SSH does not require authentication. It is strongly recommended to configure a token in frps and specify the token in the SSH command line. + +#### Client-side SSH +The command format is: + +```bash +ssh -R :80:{local_ip:port} v0@{frps_address} -p {frps_ssh_listen_port} {tcp|http|https|stcp|tcpmux} --remote_port {real_remote_port} --proxy_name {proxy_name} --token {frp_token} +``` + +1. --proxy_name is optional, and if left empty, a random one will be generated. +The username for logging in to frps is always "v0" and currently has no significance, i.e., v0@{frps_address}. +2. The server-side proxy listens on the port determined by --remote_port. +3. {tcp|http|https|stcp|tcpmux} supports the complete command parameters, which can be obtained by using --help. For example: ssh -R :80::8080 v0@127.0.0.1 -p 2200 http --help. +4. The token is optional, but for security reasons, it is strongly recommended to configure the token in frps. + +#### TCP Proxy + +```bash +ssh -R :80:127.0.0.1:8080 v0@{frp_address} -p 2200 tcp --proxy_name "test-tcp" --remote_port 9090 +``` + +This sets up a proxy on frps that listens on port 9090 and proxies local service on port 8080. + +```bash +frp (via SSH) (Ctrl+C to quit) + +User: +ProxyName: test-tcp +Type: tcp +RemoteAddress: :9090 +``` + +Equivalent to: + +```bash +frpc tcp --proxy_name "test-tcp" --local_ip 127.0.0.1 --local_port 8080 --remote_port 9090 +``` + +More parameters can be obtained by executing --help. + + +#### HTTP Proxy + +```bash +ssh -R :80:127.0.0.1:8080 v0@{frp address} -p 2200 http --proxy_name "test-http" --custom_domain test-http.frps.com +``` + +Equivalent to: +```bash +frpc http --proxy_name "test-http" --custom_domain test-http.frps.com +``` + +You can access the HTTP service using the following command: + +curl 'http://test-http.frps.com' + +More parameters can be obtained by executing --help. + + +#### HTTPS/STCP/TCPMUX Proxy +To obtain the usage instructions, use the following command: + +```bash +ssh -R :80:127.0.0.1:8080 v0@{frp address} -p 2200 {https|stcp|tcpmux} --help +``` + + +### Advanced Usage +#### Reusing the id_rsa File on the Local Machine + +```toml +# frps.toml +sshTunnelGateway.bindPort = 2200 +sshTunnelGateway.privateKeyFile = "/home/user/.ssh/id_rsa" +``` + +During the SSH protocol handshake, public keys are exchanged for data encryption. Therefore, the SSH server on the frps side needs to specify a private key file, which can be reused from an existing file on the local machine. If the privateKeyFile field is empty, frps will automatically create an RSA private key file. + + +#### Specifying the Auto-Generated Private Key File Path + +```toml +# frps.toml +sshTunnelGateway.bindPort = 2200 +sshTunnelGateway.autoGenPrivateKeyPath = "/var/frp/ssh-private-key-file" +``` + +frps will automatically create a private key file and store it at the specified path. + +Note: Changing the private key file in frps can cause SSH client login failures. If you need to log in successfully, you can delete the old records from the /home/user/.ssh/known_hosts file. + + +#### Using an Existing authorized_keys File for SSH Public Key Authentication + +```toml +# frps.toml +sshTunnelGateway.bindPort = 2200 +sshTunnelGateway.authorizedKeysFile = "/home/user/.ssh/authorized_keys" +``` + +The authorizedKeysFile is the file used for SSH public key authentication, which contains the public key information for users, with one key per line. + +If authorizedKeysFile is empty, frps won't perform any authentication for SSH clients. Frps does not support SSH username and password authentication. + +You can reuse an existing authorized_keys file on your local machine for client authentication. + +Note: authorizedKeysFile is for user authentication during the SSH login phase, while the token is for frps authentication. These two authentication methods are independent. SSH authentication comes first, followed by frps token authentication. It is strongly recommended to enable at least one of them. If authorizedKeysFile is empty, it is highly recommended to enable token authentication in frps to avoid security risks. + + +#### Using a Custom authorized_keys File for SSH Public Key Authentication + +```toml +# frps.toml +sshTunnelGateway.bindPort = 2200 +sshTunnelGateway.authorizedKeysFile = "/var/frps/custom_authorized_keys_file" +``` + +Specify the path to a custom authorized_keys file. + +Note that changes to the authorizedKeysFile file may result in SSH authentication failures. You may need to re-add the public key information to the authorizedKeysFile. From cc2076970f872abf5508e65d0e428ceb2eb723b9 Mon Sep 17 00:00:00 2001 From: fatedier <fatedier@gmail.com> Date: Thu, 14 Dec 2023 20:54:03 +0800 Subject: [PATCH 21/21] update doc (#3844) --- .github/workflows/golangci-lint.yml | 2 +- .golangci.yml | 2 +- README.md | 23 ++++++++++++++---- doc/ssh_tunnel_gateway.md | 36 +++++++++++++---------------- go.mod | 2 +- 5 files changed, 37 insertions(+), 28 deletions(-) diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 98583c77..9517af53 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -22,7 +22,7 @@ jobs: uses: golangci/golangci-lint-action@v3 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: v1.53 + version: v1.55 # Optional: golangci-lint command line arguments. # args: --issues-exit-code=0 diff --git a/.golangci.yml b/.golangci.yml index 18cbaf0b..f166e9de 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,5 +1,5 @@ service: - golangci-lint-version: 1.51.x # use the fixed version to not introduce new linters unexpectedly + golangci-lint-version: 1.55.x # use the fixed version to not introduce new linters unexpectedly run: concurrency: 4 diff --git a/README.md b/README.md index d6e1986e..4bebb8ab 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ frp also offers a P2P connect mode. * [Using Environment Variables](#using-environment-variables) * [Split Configures Into Different Files](#split-configures-into-different-files) * [Server Dashboard](#server-dashboard) - * [Admin UI](#admin-ui) + * [Client Admin UI](#client-admin-ui) * [Monitor](#monitor) * [Prometheus](#prometheus) * [Authenticating the Client](#authenticating-the-client) @@ -75,7 +75,7 @@ frp also offers a P2P connect mode. * [Custom Subdomain Names](#custom-subdomain-names) * [URL Routing](#url-routing) * [TCP Port Multiplexing](#tcp-port-multiplexing) - * [Connecting to frps via HTTP PROXY](#connecting-to-frps-via-http-proxy) + * [Connecting to frps via PROXY](#connecting-to-frps-via-proxy) * [Client Plugins](#client-plugins) * [Server Manage Plugins](#server-manage-plugins) * [SSH Tunnel Gateway](#ssh-tunnel-gateway) @@ -510,6 +510,7 @@ includes = ["./confd/*.toml"] ```toml # ./confd/test.toml + [[proxies]] name = "ssh" type = "tcp" @@ -621,6 +622,7 @@ The features are off by default. You can turn on encryption and/or compression: ```toml # frpc.toml + [[proxies]] name = "ssh" type = "tcp" @@ -776,6 +778,7 @@ We would like to try to allow multiple proxies bind a same remote port with diff ```toml # frpc.toml + [[proxies]] name = "ssh" type = "tcp" @@ -881,6 +884,7 @@ This feature is only available for types `tcp`, `http`, `tcpmux` now. ```toml # frpc.toml + [[proxies]] name = "test1" type = "tcp" @@ -916,6 +920,7 @@ With health check type **tcp**, the service port will be pinged (TCPing): ```toml # frpc.toml + [[proxies]] name = "test1" type = "tcp" @@ -935,6 +940,7 @@ With health check type **http**, an HTTP request will be sent to the service and ```toml # frpc.toml + [[proxies]] name = "web" type = "http" @@ -959,6 +965,7 @@ However, speaking of web servers and HTTP requests, your web server might rely o ```toml # frpc.toml + [[proxies]] name = "web" type = "http" @@ -975,6 +982,7 @@ Similar to `Host`, You can override other HTTP request headers with proxy type ` ```toml # frpc.toml + [[proxies]] name = "web" type = "http" @@ -1002,6 +1010,7 @@ Here is an example for https service: ```toml # frpc.toml + [[proxies]] name = "web" type = "https" @@ -1024,6 +1033,7 @@ It can only be enabled when proxy type is http. ```toml # frpc.toml + [[proxies]] name = "web" type = "http" @@ -1048,6 +1058,7 @@ Resolve `*.frps.com` to the frps server's IP. This is usually called a Wildcard ```toml # frpc.toml + [[proxies]] name = "web" type = "http" @@ -1067,6 +1078,7 @@ frp supports forwarding HTTP requests to different backend web services by url r ```toml # frpc.toml + [[proxies]] name = "web01" type = "http" @@ -1152,6 +1164,7 @@ Using plugin **http_proxy**: ```toml # frpc.toml + [[proxies]] name = "http_proxy" type = "tcp" @@ -1171,6 +1184,7 @@ Read the [document](/doc/server_plugin.md). Find more plugins in [gofrp/plugin](https://github.com/gofrp/plugin). ### SSH Tunnel Gateway + *added in v0.53.0* frp supports listening to an SSH port on the frps side and achieves TCP protocol proxying through the SSH -R protocol, without relying on frpc. @@ -1180,7 +1194,7 @@ frp supports listening to an SSH port on the frps side and achieves TCP protocol sshTunnelGateway.bindPort = 2200 ``` -When running ./frps -c frps.toml, a private key file named .autogen_ssh_key will be automatically created in the current working directory. This generated private key file will be used by the SSH server in frps. +When running `./frps -c frps.toml`, a private key file named `.autogen_ssh_key` will be automatically created in the current working directory. This generated private key file will be used by the SSH server in frps. Executing the command @@ -1197,7 +1211,6 @@ User: ProxyName: test-tcp Type: tcp RemoteAddress: :9090 - ``` This is equivalent to: @@ -1206,7 +1219,7 @@ This is equivalent to: frpc tcp --proxy_name "test-tcp" --local_ip 127.0.0.1 --local_port 8080 --remote_port 9090 ``` -Find more arguments in [document](/doc/ssh_tunnel_gateway.md). +Please refer to this [document](/doc/ssh_tunnel_gateway.md) for more information. ## Contributing diff --git a/doc/ssh_tunnel_gateway.md b/doc/ssh_tunnel_gateway.md index 7f1a3ef9..b3dd4c34 100644 --- a/doc/ssh_tunnel_gateway.md +++ b/doc/ssh_tunnel_gateway.md @@ -2,15 +2,14 @@ *Added in v0.53.0* - ### Concept + SSH supports reverse proxy capabilities [rfc](https://www.rfc-editor.org/rfc/rfc4254#page-16). frp supports listening on an SSH port on the frps side to achieve TCP protocol proxying using the SSH -R protocol. This mode does not rely on frpc. SSH reverse tunneling proxying and proxying SSH ports through frp are two different concepts. SSH reverse tunneling proxying is essentially a basic reverse proxying accomplished by connecting to frps via an SSH client when you don't want to use frpc. - ```toml # frps.toml sshTunnelGateway.bindPort = 0 @@ -26,8 +25,8 @@ sshTunnelGateway.authorizedKeysFile = "" | autoGenPrivateKeyPath | string |Default value is ./.autogen_ssh_key. If the file does not exist or its content is empty, frps will automatically generate RSA private key file content and store it in this file.|No| | authorizedKeysFile | string |Default value is empty. If it is empty, ssh client authentication is not authenticated. If it is not empty, it can implement ssh password-free login authentication. It can reuse the local /home/user/.ssh/authorized_keys file or a custom path can be specified.| No | - ### Basic Usage + #### Server-side frps Minimal configuration: @@ -36,26 +35,27 @@ Minimal configuration: sshTunnelGateway.bindPort = 2200 ``` -Place the above configuration in frps.toml and run ./frps -c frps.toml. It will listen on port 2200 and accept SSH reverse proxy requests. +Place the above configuration in frps.toml and run `./frps -c frps.toml`. It will listen on port 2200 and accept SSH reverse proxy requests. Note: -1. When using the minimal configuration, a .autogen_ssh_key private key file will be automatically created in the current working directory. The SSH server of frps will use this private key file for encryption and decryption. Alternatively, you can reuse an existing private key file on your local machine, such as /home/user/.ssh/id_rsa. +1. When using the minimal configuration, a `.autogen_ssh_key` private key file will be automatically created in the current working directory. The SSH server of frps will use this private key file for encryption and decryption. Alternatively, you can reuse an existing private key file on your local machine, such as `/home/user/.ssh/id_rsa`. 2. When running frps in the minimal configuration mode, connecting to frps via SSH does not require authentication. It is strongly recommended to configure a token in frps and specify the token in the SSH command line. #### Client-side SSH + The command format is: ```bash ssh -R :80:{local_ip:port} v0@{frps_address} -p {frps_ssh_listen_port} {tcp|http|https|stcp|tcpmux} --remote_port {real_remote_port} --proxy_name {proxy_name} --token {frp_token} ``` -1. --proxy_name is optional, and if left empty, a random one will be generated. -The username for logging in to frps is always "v0" and currently has no significance, i.e., v0@{frps_address}. -2. The server-side proxy listens on the port determined by --remote_port. -3. {tcp|http|https|stcp|tcpmux} supports the complete command parameters, which can be obtained by using --help. For example: ssh -R :80::8080 v0@127.0.0.1 -p 2200 http --help. -4. The token is optional, but for security reasons, it is strongly recommended to configure the token in frps. +1. `--proxy_name` is optional, and if left empty, a random one will be generated. +2. The username for logging in to frps is always "v0" and currently has no significance, i.e., `v0@{frps_address}`. +3. The server-side proxy listens on the port determined by `--remote_port`. +4. `{tcp|http|https|stcp|tcpmux}` supports the complete command parameters, which can be obtained by using `--help`. For example: `ssh -R :80::8080 v0@127.0.0.1 -p 2200 http --help`. +5. The token is optional, but for security reasons, it is strongly recommended to configure the token in frps. #### TCP Proxy @@ -80,8 +80,7 @@ Equivalent to: frpc tcp --proxy_name "test-tcp" --local_ip 127.0.0.1 --local_port 8080 --remote_port 9090 ``` -More parameters can be obtained by executing --help. - +More parameters can be obtained by executing `--help`. #### HTTP Proxy @@ -100,16 +99,16 @@ curl 'http://test-http.frps.com' More parameters can be obtained by executing --help. - #### HTTPS/STCP/TCPMUX Proxy + To obtain the usage instructions, use the following command: ```bash ssh -R :80:127.0.0.1:8080 v0@{frp address} -p 2200 {https|stcp|tcpmux} --help ``` - ### Advanced Usage + #### Reusing the id_rsa File on the Local Machine ```toml @@ -120,7 +119,6 @@ sshTunnelGateway.privateKeyFile = "/home/user/.ssh/id_rsa" During the SSH protocol handshake, public keys are exchanged for data encryption. Therefore, the SSH server on the frps side needs to specify a private key file, which can be reused from an existing file on the local machine. If the privateKeyFile field is empty, frps will automatically create an RSA private key file. - #### Specifying the Auto-Generated Private Key File Path ```toml @@ -131,8 +129,7 @@ sshTunnelGateway.autoGenPrivateKeyPath = "/var/frp/ssh-private-key-file" frps will automatically create a private key file and store it at the specified path. -Note: Changing the private key file in frps can cause SSH client login failures. If you need to log in successfully, you can delete the old records from the /home/user/.ssh/known_hosts file. - +Note: Changing the private key file in frps can cause SSH client login failures. If you need to log in successfully, you can delete the old records from the `/home/user/.ssh/known_hosts` file. #### Using an Existing authorized_keys File for SSH Public Key Authentication @@ -146,11 +143,10 @@ The authorizedKeysFile is the file used for SSH public key authentication, which If authorizedKeysFile is empty, frps won't perform any authentication for SSH clients. Frps does not support SSH username and password authentication. -You can reuse an existing authorized_keys file on your local machine for client authentication. +You can reuse an existing `authorized_keys` file on your local machine for client authentication. Note: authorizedKeysFile is for user authentication during the SSH login phase, while the token is for frps authentication. These two authentication methods are independent. SSH authentication comes first, followed by frps token authentication. It is strongly recommended to enable at least one of them. If authorizedKeysFile is empty, it is highly recommended to enable token authentication in frps to avoid security risks. - #### Using a Custom authorized_keys File for SSH Public Key Authentication ```toml @@ -159,6 +155,6 @@ sshTunnelGateway.bindPort = 2200 sshTunnelGateway.authorizedKeysFile = "/var/frps/custom_authorized_keys_file" ``` -Specify the path to a custom authorized_keys file. +Specify the path to a custom `authorized_keys` file. Note that changes to the authorizedKeysFile file may result in SSH authentication failures. You may need to re-add the public key information to the authorizedKeysFile. diff --git a/go.mod b/go.mod index d11e1ef4..4e178fb5 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/rodaine/table v1.1.0 github.com/samber/lo v1.38.1 github.com/spf13/cobra v1.8.0 + github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.4 golang.org/x/crypto v0.15.0 golang.org/x/net v0.17.0 @@ -61,7 +62,6 @@ require ( github.com/prometheus/procfs v0.10.1 // indirect github.com/quic-go/qtls-go1-20 v0.3.1 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect github.com/templexxx/cpufeat v0.0.0-20180724012125-cef66df7f161 // indirect github.com/templexxx/xor v0.0.0-20191217153810-f85b25db303b // indirect github.com/tjfoc/gmsm v1.4.1 // indirect