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 --- client/control.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/control.go b/client/control.go index 63c6c331fc9..33fe2b509bc 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 f7996399484..8d27e522871 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 4cab567e29b..af509c3f22f 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 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 1660f1e19e2..16e8324b8dd 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 184a87a3305..4b394d8a163 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 915e6b35174..125c88c05c4 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 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 | 275 +++++++++++----------------- client/service.go | 199 ++++++++++---------- 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 | 385 ++++++++++++++------------------------- server/service.go | 18 +- 11 files changed, 690 insertions(+), 529 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 16e8324b8dd..a834392cf7f 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 a348e8dd332..e775f526ad3 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 33fe2b509bc..c8d186ca14b 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) - - // 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{} + doneCh 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.lastPong.Store(time.Now()) + + cryptoRW, err := utilnet.NewCryptoReadWriter(conn, []byte(clientCfg.Auth.Token)) + if err != nil { + return nil, err } - ctl.msgTransporter = transport.NewMessageTransporter(ctl.sendCh) - ctl.pm = proxy.NewManager(ctl.ctx, clientCfg, ctl.msgTransporter) + 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())) + // 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 { + // 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) + return err + } + _ = ctl.msgDispatcher.Send(pingMsg) + return nil } - }() - 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. - if ctl.clientCfg.Transport.HeartbeatInterval > 0 { - hbSend := time.NewTicker(time.Duration(ctl.clientCfg.Transport.HeartbeatInterval) * time.Second) - defer hbSend.Stop() - hbSendCh = hbSend.C + 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, + ) } - 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 - 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 - } - ctl.sendCh <- pingMsg - case <-hbCheckCh: - if time.Since(ctl.lastPong) > time.Duration(ctl.clientCfg.Transport.HeartbeatTimeout)*time.Second { + 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 4b394d8a163..66a642c1bf0 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 - - // 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 - } - } + <-svr.ctl.Done() + + // 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 696496a2357..12c388a5229 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 00000000000..cb1eb15a307 --- /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 6bcd8ce86f7..7163a8adcb4 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 fb2ff677730..a5bbe7371c5 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 00000000000..45e0ab68a52 --- /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 f2eaaa56ad7..e651a97e846 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.lastPing.Store(time.Now()) + + cryptoRW, err := utilnet.NewCryptoReadWriter(ctl.conn, []byte(ctl.serverCfg.Auth.Token)) + if err != nil { + return nil, err } - ctl.msgTransporter = transport.NewMessageTransporter(ctl.sendCh) - return ctl + 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() - - if !started { - ctl.allShutdown.Done() - return - } - ctl.allShutdown.WaitDone() +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) } -func (ctl *Control) manager() { +func (ctl *Control) handleNewProxy(m msg.Message) { xl := ctl.xl - defer func() { - if err := recover(); err != nil { - xl.Error("panic error: %v", err) - xl.Error(string(debug.Stack())) - } - }() + inMsg := m.(*msg.NewProxy) - defer ctl.allShutdown.Start() - defer ctl.managerShutdown.Done() + 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) + } - 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 + // 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) +} - 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 - } +func (ctl *Control) handlePing(m msg.Message) { + xl := ctl.xl + inMsg := m.(*msg.Ping) - 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{} - } - } + 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.lastPing.Store(time.Now()) + xl.Debug("receive heartbeat") + _ = ctl.msgDispatcher.Send(&msg.Pong{}) } -func (ctl *Control) HandleNatHoleVisitor(m *msg.NatHoleVisitor) { - ctl.rc.NatHoleController.HandleVisitor(m, ctl.msgTransporter, ctl.loginMsg.User) +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.NatHoleClient) { - ctl.rc.NatHoleController.HandleClient(m, ctl.msgTransporter) +func (ctl *Control) handleNatHoleClient(m msg.Message) { + inMsg := m.(*msg.NatHoleClient) + ctl.rc.NatHoleController.HandleClient(inMsg, ctl.msgTransporter) } -func (ctl *Control) HandleNatHoleReport(m *msg.NatHoleReport) { - ctl.rc.NatHoleController.HandleReport(m) +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 + 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 9deffa020f0..2629b345011 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 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 e775f526ad3..84db31c5fdd 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 66a642c1bf0..0a25ae08635 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 2a5f2830a15..d98b4d3e0af 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 72b635f1bf1..eafea27e05f 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 7ae8d353b39..41c20bc21ab 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 125c88c05c4..855c7abb81e 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 a84f54f2a8f..0e6adca851f 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 4a6f0117620..adb8852e136 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 4f0cefb18fb..838ac7b65b3 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 af2c3e80704..a4013c3247a 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 eab4ba96b7d..876d53e4a3a 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 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 a834392cf7f..b4245189c41 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 84db31c5fdd..3a56a99fbaa 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 0a25ae08635..66a642c1bf0 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 d98b4d3e0af..5d478d44a58 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 eafea27e05f..56fcf67b72a 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 41c20bc21ab..7ae8d353b39 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 855c7abb81e..c4a5acb6f66 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 0e6adca851f..1b6ac5a7a6c 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 adb8852e136..1fa57d95d7c 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 838ac7b65b3..33ad3f63229 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 a4013c3247a..41d1a231dd4 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 876d53e4a3a..9bf7dbbc5be 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 c9657905f24..395063e5029 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 da23db9c1fe..d4862e529dc 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 25b99424101..b0b258db308 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 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 0a5cefcc291..d7a790c146d 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 dcd1f7b3047..4cfd61062b0 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 1fa57d95d7c..5f32fe9c353 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 bdfc5643031..247d0a6af4f 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 f8eca6b774c..51ac9c47725 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 f7257cb55f2..50f62bef1bc 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 797770a3a94..1279a499057 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 41d1a231dd4..3014eb35a46 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 9029aa73f40..52b876905d9 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 422a8082ede..72c9d0362c9 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 c42c3ecaaac..e49921e9ce7 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 7163a8adcb4..dd43fbdc0ed 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 1a7da23f72f..642d15901e3 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 73b96a85f79..2b4077cf33b 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 7b914ce9b74..1a5bea0b4a2 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 2629b345011..7478b97bd8f 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 6a7a655f266..f8b8aa03389 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 3f14a42dcf6..cf600be2ee0 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 66456f57f95..b043c57f13f 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 --- 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 8d27e522871..8d0055e6069 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 af509c3f22f..49cef0b2cc4 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 e49921e9ce7..f562be8e12f 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 00000000000..440305d4fe3 --- /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 00000000000..ce0bc52c931 --- /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 00000000000..e78c82847d9 --- /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 fe6f781b728..5ea99f1ea6c 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 7478b97bd8f..2ca501be8e3 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 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 d94e7c36543..f8326891de6 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 00000000000..2ff9b491a9a --- /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 c8d186ca14b..be028ec43f3 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 5ba63f94cce..396539c0837 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 db66cb26397..dadf648150a 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 346c6d076a8..84f24abb668 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 e67a33974f0..f9fe53bccc0 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 d7a790c146d..d8590f68df4 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 8271099bb50..b286a9318b9 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 66a642c1bf0..7c3cd03926d 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 7ae8d353b39..960509433ca 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 50170684734..00000000000 --- 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 5f32fe9c353..0cf8e4e79d7 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 8d0055e6069..d11e1ef4996 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 49cef0b2cc4..56966be2f0d 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 eb3cc010629..0c37e608372 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 f562be8e12f..03b05d9d043 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 440305d4fe3..00000000000 --- 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 7f093af1b0f..fd3e44b4637 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 06c6296a11d..65abf19d68d 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 aa498f3f1a9..4a1c85b99e5 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 fc38f62b364..81386ac6c79 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 c2e253d241f..33e87b537a3 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 20b79a099da..faf03f7d7d7 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 f186ec925ea..df68ffb469d 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 00000000000..8f87e9986c4 --- /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 00000000000..13c87b689da --- /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 ce0bc52c931..00000000000 --- 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 e78c82847d9..00000000000 --- 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 d92b1a8205c..5bc75921cbd 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 b5746f9dfb8..7b69dcafe0a 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) Spawn() *Logger { - nl := New() +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 { - nl.AppendPrefix(v) + l.prefixString += "[" + v.Value + "] " } +} + +func (l *Logger) Spawn() *Logger { + nl := New() + 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 00000000000..d0369a1af8a --- /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 5ea99f1ea6c..fe6f781b728 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 2ca501be8e3..02efec91a28 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 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 | 230 +++++++++++++++++++---------- 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 | 57 ++++--- 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 b4245189c41..ca8f3a72057 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 da8bab1bd5c..00000000000 --- 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 3a56a99fbaa..5e4d67c60fa 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 2ff9b491a9a..ba1441468ee 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 be028ec43f3..e4b01ae8c21 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 dadf648150a..12e2f6cfee8 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 f9fe53bccc0..4d06170dc90 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 d8590f68df4..38d14ff598b 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 b286a9318b9..e5e5d47e280 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 7c3cd03926d..5db1bd283d5 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 + + // 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 +} - // manager control connection with server - ctl *Control +// 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, +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 + } + s := &Service{ ctx: context.Background(), - connectorCreator: NewConnector, + 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, } -} - -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 + 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 159f46ee074..1d489bec42b 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 4cfd61062b0..d520f735ddc 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 4b235cdb113..4f31f2706ed 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 c180621c29e..ad773503e03 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 c4a5acb6f66..fffc49850de 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 1b6ac5a7a6c..4b971f531c7 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 00000000000..2eaf3f0bd70 --- /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 0c37e608372..c0e871645df 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 637783bc73b..80850f6f957 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 3014eb35a46..b5539745931 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 41bb13414b7..0752479f0c5 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 38123946a59..16fc4ccb48e 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 7a865785243..a85fcd5fe5b 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 fd3e44b4637..ac54551afbe 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 65abf19d68d..90a99b09434 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 4a1c85b99e5..ba66bfaeb93 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 81386ac6c79..a79ea3b106c 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 33e87b537a3..a230bf55bdb 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 faf03f7d7d7..a7db2657e7e 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 395063e5029..57bf77469f0 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 8f87e9986c4..07ae9808860 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 13c87b689da..042f6766f71 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 a6a25a4cbe1..b85a46a328d 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 00000000000..e49cefe49d4 --- /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 00000000000..5e1d5ccbfc1 --- /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 6f2d8a56646..c3aebcd6f6b 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 17989adcad5..6be29a4a6a9 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 1a5bea0b4a2..72ab4775cbd 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 d78082b24d0..bf91e13358f 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 29123b695d9..d529e4249e3 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 d0369a1af8a..96835a48c7f 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() - - ln := utilnet.NewInternalListener() - - svr := client.NewService(cfg, nil, nil, "") - svr.SetConnectorCreator(func(context.Context, *v1.ClientCommonConfig) client.Connector { - return &pipeConnector{ - peerListener: ln, - } - }) +func NewClient(options ClientOptions) (*Client, error) { + if options.Common != nil { + options.Common.Complete() + } + 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 e651a97e846..dbb1af0a038 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 1f290cf9a5a..00000000000 --- 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 b5a923f9456..27944b9a803 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 cafaf8f3d9a..44a462b7ecc 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 fe6f781b728..f5c850e98af 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 772c3f0d1b9..ea970818d8e 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 02efec91a28..c2410b06376 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 + } + + 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{ + 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 c76bcee1d92..ed06dc4b4e9 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 5bb742bc8e3..15477837a32 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 50deb3bf31a..740bc4fbf2c 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 356a18be978..7ee58a79c01 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 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 f6df315b1e4..c9480d52d3a 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 042f6766f71..30e79c64318 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 00000000000..1a923e9c420 --- /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 00000000000..f67d87aaf99 --- /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 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 ca8f3a72057..8e1ea863ea7 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 b5539745931..cdbb8e916f4 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 9bf7dbbc5be..b3f77800449 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 72c9d0362c9..24ec9b0d825 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 bd5ff384a56..db9d0d1a0b2 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 0752479f0c5..8e19d00481c 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 90ecd86d191..a9b2411ab3d 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 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 56fcf67b72a..fb5b08078c2 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 e49cefe49d4..99bed3640d3 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 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 | 79 +++++++++++++++++++++++++------------- pkg/ssh/server.go | 72 +++++++++++++++++++++++++--------- pkg/ssh/terminal.go | 31 +++++++++++++++ test/e2e/pkg/ssh/client.go | 2 +- 5 files changed, 153 insertions(+), 51 deletions(-) create mode 100644 pkg/ssh/terminal.go diff --git a/client/service.go b/client/service.go index 5db1bd283d5..c43f8f60497 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 c0e871645df..712e3d3fba3 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") +func RegisterClientCommonConfigFlags(cmd *cobra.Command, c *v1.ClientCommonConfig, opts ...RegisterFlagOption) { + options := ®isterFlagOptions{} + for _, opt := range opts { + opt(options) + } + + 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.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") - - c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls") } 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 30e79c64318..264669a3ec3 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 00000000000..a2e9a6ff362 --- /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 1a923e9c420..b45e39daa0d 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 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 2dc60eee6fc..ab79a55b709 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 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 fb1592879e6..ac04279ec87 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ + + + +

diff --git a/README_zh.md b/README_zh.md index 77cf797467e..ac4eeec17c1 100644 --- a/README_zh.md +++ b/README_zh.md @@ -13,6 +13,10 @@ frp 是一个专注于内网穿透的高性能的反向代理应用,支持 TCP + + + +

@@ -84,7 +88,7 @@ frp 是一个免费且开源的项目,我们欢迎任何人为其开发和进 ### 知识星球 -如果您想了解更多 frp 相关技术以及更新详解,或者寻求任何帮助及咨询,都可以通过微信扫描下方的二维码付费加入知识星球的官方社群: +如果您想了解更多 frp 相关技术以及更新详解,或者寻求任何 frp 使用方面的帮助,都可以通过微信扫描下方的二维码付费加入知识星球的官方社群: ![zsxq](/doc/pic/zsxq.jpg) 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 zcmchdWl)^K)8LnoK!7ZS;4B17@DOxy2=4A4oCOwlPY4#=-QC??g1aoRxVyU_|9Y#g z>gwv=4_8xJDytJ~&#QIA` z>r8lJvFopJ1BipPf;yO!hmM|sl$6xU@pn>gWADtFfQV#nWnc5ahRd&ru*72D&{Qx7 z57hBD-8Tjq1@&IonIXhMUB~24XlnD|hMJCv>#qoxU*T#x##WBM3?TM~5PRRyGy!4B z#N0-89b0w0t0y+hN< z-OI}f^K^wX&_~F?|H@+zUHxJ?IEh-6;?>N5|Ezkx?=mBz8Zgpil2~`a&FyTxcnfHt zyRfNCXiDg*K3q;j1S|$TlBGF)`hMZQg9$ht9aiJ*BHdbBCU^&Ep_sR17A7!<@sGO# zR+A%u`^;v&^2mTTgwDi4fFod)oAE`T^Vx)3D_1~wx;~gkO0D{+mr#Rek$VH+Q~!l^ zn*iKAKL7Q-s=E*gY?ljgYCLdfV=&bkK(=7(`ePQs=euMt7jOX~Il1dw6FR`dbyvEZ zlGgtns?(S0O`8*D0KjS_C5*}hF*Y-AQC}OOWA>|F^n|dh?i~Guez<7?Dqyu+D2J?Z z^=pxMaQB<&R};j)2SmD2WD1+n6C&TP4rS!(+n-q)ytM_a7Dv)S#79#g89B6ZY}-VD zTh!e~|05OI(moutKtw<~$v0p#o#EsyzHJe)`93+V-)k^@YEZLNx=SGD;=A&e7&J(Yyq$_^ni*#u7cXBUwcHK;` ztK|E$^gwui_3j~In))3{1Hx)`mMFH=&C`;|LqZy35nwgC`XDV}xG@0ZSg>D3A!QrT zvGXOK0(I;O*5}>NKP%+SIzSVEQYZH2l!g-(dfFl!8MLsTdKA$wj1eG}N3pX_#~Ai+*Ook>#N!I_9T zwB`KAXh0&vXQWWQ?c#(qo5qAfdHYnr#f`QeUVCZ)YuJFxZCWW0P%YLlKi=h4i%-89!nAlvf(dRJ<(faCO^f znD-b-iSQgr{QF1q=297NXBb>)ao(1( zrt?`_R_2C|pmFCwt1l(4R1EaHmhSJeqgDVdjR+alPgmC2hb2aj7i5o*JrSMkn4IL4 z+TpaYB}oJMzY)h_h*SP|!DDJ%9u8Yian|;*D@LgL-TJPB;@GU9AJ-+M`>}6&EQ1dX zU>!olOz(b-h8MpKb~qBC9_w+9HWzBUZvHDrw4IuM=z(7Vxp&dMa-JWiN3G2rTZI*5@|}-be^-%_G*VRW8UZ9Uu)I|A^&q zFz>GVzM+&-hjJ{t&Gz%x8PBZj*+I$M6vg$wuu!3<``pa^mQxV>}x^VRIl_Ruy9*+Ch z@Vg+&F1&RXaY?AhinxP1XY7f3(b)DqJlvdwI!?gzTa(lJf{>gny7MIG+sq3mNmnQb zjm?CXxo1f^mlrrXoJFs1TlqO|Wt>-;P5Lsr!t9&Pbt=VWR@i?BnDnnyGxm##q?KOB zn{}JS)zZ8z{|knSd?joRg8#I_ z`<}>g+JDEhx!teZLQNr-|63&OVs)Z`&X~v(;W(f|;AL81=Qt~_4drUsVrOacrwhjj z#Z5&j#Y5t}BgaIuHt3LDA|aSrVS|PWMyHX>i_vAE=Rjpy1V8tO0y_xa*X!JRpTWz4 zo~D99x#)d=d{Bc&QKGw;f)Pn?fB*F!AH5iKksXG3?0t~*2(s+V2&M|E zpX&0w6t-4?+MM9#7YTbLIDuM7u)#2={xQ2TM^jI>pS=i+0dqIP@hmgZ;~7L|IH@ievUF5z`BpCDl{D`b6t$?F&sc3-at;^`8< z6mxdz(x+kB$%FqOmwD{eNHqvYY(VzA5~j>qlEOC6&U!J$le**2o)pC6)6>o^c^8B4 ztX>$ga?iEP041fd64Q~#~91zh(5TKPP`?yEA z4tw}8vzoYT`>hSXD6z?UUo~_C)NCP$dxe*m4HC=hWI^Ctn3*>GBL{CA7379{eE!8^QJztJNxvWrKnno}MV(9{58LQDDydICq6gAphDAQp7$t z6z9!iPEY-U4?TF({%x(po1NZ4;6Gv02rm3B@CcyGQ&o6$*zrhB=Em2`IwI#Oq@6^Y zU3Qx_BPVP)e^=S)QyB6a3 z*O!|=-n?7Mn{i;~M2)M?Ln~`+!8SQV|agH{&}X7&f-$FSb&nnH zR&=!R3sA?BD;cbL!RqC?t<$T&l+BfrYffi}FEtl9{g0A3K)ZTlUG8!;7yd)9T>1&+ z0xO?qhtHAY2~JGtb0c*7{$xcnJ%q4p`^2gawVTn#;XK`A)TR(Dk9WoIeP??{CDu3w zLxLObap)w^lAc+?$xe$2XIMYbba+;bC8qInw4u!Ofd`vMV%QJCD|!Xf+(B+;0Ij34AV?`04Bi!un;VZ;Zib-DM|ADfz%h zA};40kLRvx*DUt$FeDhoX+hK-FJBRmzD0{@E}5RUPZ)5jS}``L?O=!a5|xAXf^5eC zL-O(|9TKw_BDBf@KDraj+A`+;8pE&9O*{zxNh782Is`*n{qP6bINQ~2g83gmV%}gH z(dfKgB^V!hF6fLRhQ?rNV3M+BM+X^0Fg%a{!$L^N&=A`2V7Zm>;FJDvhm1|^FRr$`x zFs@pCAC4b{9ZQXt@=PvBIu4-4DRVV*p)o`c=Zk71wxz#>VQw}0G1vWYT&gCY2m67v z{LE-z(SnWLa;qvxR3soAW^LV`2o6DiMi`+4U72?m941XHuKu$;#casgs30P2yaYI7 z+?hl1Lj}hWIR-+iQq2kuYCBIB7bf2y6A=GdF6+kKKsjaNXy0ITS!-=xut9^`JS2Dw zZ2)UC|9Tvp9}J@&Updrs9aa*2^1vQF;h+x?U0Q! zj=zWrJ*KxEUo7(9Q!QVt56$Z_NN{h>j-Du~zcqQgfI~^xfxNyL*1c-w>%p{lxh?X3 z(yYC7Zba47I%FlTO7`~TWX1cY8OL+=1R2s@@C?{9r>r{W!AJJtwzKM6V`bF6t2^HY zg{2SBt>28I0^;pW`qsue%(ep8ZC6j)KW;u?d#-FDt|BU>)pFYHMi@9Af80b92y^WS z3SVVmu?%=|6+(54IlxPGK3OD?%mbZfvE5F8aPbtMxIx*&b!3IlUWQuo9wl53kCib1 z_^5Y6Xpv;t3$nG5tur5`Sy%2fZcnCAQp(l6PE+yS3OXECTbj9UPSMH~`h}dmZ@qr- zGoGDrG9jKK5O^327>HX+61J@p`aQ!Mc0RgY^L~4c4yO6|Zef!VuFhY9de&9HJi(W{ z_uv@}O|2E%NF)z7|Ibyha^OAb)pw5hj(CR>p-nM@7slT6M{K~Y*#}M|DA!LEC2xg& zy3g_kpJQr2o7y|Fu#YwLZX=z3#lMbRRFb2=ZIz#c}lVeA)Hgs^|yIfKjV+ZMhy#6$rk=sKXGRb`HWy9K!ne)p3OG z@GAK5W%p@w0o$a^qAuO*i|lOu*8orl2ypH$E6*W1xkw*0A{y+V1V(wpi%2VgOgl zI0T>)x8^AO4LGb1}j#|_02)a{SHKLa9QC7K@YcBxq4=T;G*N4EQ0x2~5!a@6p2l2WcDgMZkvbK-ne)-{e;dC)#mpx30K?<6B%CoIf{b5fkM(JRN}T(SoZI&)ug*mJOH90bTDN7oXA z#!maPMrQY9*1_;2#EUXc9SzY-f+kgx7}M>%9{zfYnG#dJl-HP79>r04PzP2vAT?ge z2NJxht-C>~dx2pS_g)2-0Z{A{^+Ryy);wpc$Rs-G{wz%7Mf0u9(*T0c`GO3~P=t=r z;u1XKKsC9d^RE;rhU2#8mk~#kvx=s6InWs7mu&`}qQB4HRoJipe!ZHaJBAUDB0$7I z`M|xDk`pZphnJ)&iP+PWT8Wy2^uLXcY%g5I!8;a8eVZ+W z426a4+}QYIXC9YN>VobMeC6`IKf#+GZ<@rLN+U;ci1&b*z_T)gc9nKlORM{uHA(_6 zx@BdMznwSK<-2mY!vdfE8%{j7&Oe)1iyhcvyfATp?A{D=WgVX+@~C#m^C8TCs+DdO zShJ=Rsd!pYCJwS2t{X>|hy^u%l5ws?7g>vAw@IBmS@<0on3kbP*);*=W}M zr`kMnq5;k?7L1~0u2Kc+j^8KcpA$Q0Q2Dn zXFn6ww7MOiI#^ z;fAfpEk^cm*slt`#H7IK9vQ@N*+FB%)xL!O&?v8R zg|wE_BSAnVe+w|j!Z+G{e3Mvd{qYGz=5dn{ZAjpk+R8@fvC_3PGd|7XokLVxaeH; zVFg&WvwnF`2A}8Z9|8Py3TLAc&R7p!E(D4ipv69MUWNHefnZ!Jux;A0C-`YM&VSbF z(Al_FSB6}_8$`&xiaIe*Ev|KbE``W6l&y$Y+iZAKlGaQER25q^;AqJbr;C^%x zF)UxyTxa|NQs0~Pi|o{eR)FKgZ4)j6Z|JebSnZgqka5^2keY*-^MF-;I`EF8d*mr3 z!ckUb=!yryb>)+(5BSlc*QE?>bMSSFHL5>#PSDB2nBynd%t9@=dAOULSR@b@!1`mvC_~6;(NZ_ZjRg$ z9!UpsI|x(}$}RI-X>JYKw)FuQgjH_Q2>x-sz1E5O;Xg0J9htyOmpb{8kO_1bY$nv& zpNLF0Qb(&Fg*lW>5y>@8t9@j=g*`g${0?PVw1pCLXl*+D7o2pk4iod=UCD z@GM&mlvrf4pDhRCJTPhxa0P{az;$J)VjZGG6$06%a#CPOPG*r}q!oZPc=iM}d{I}l zjW(zJX}c9IjQn+gOyEC8TC;UhpduT6736aS1NB}9?%-=J9k_Q9gAR?Mj0O*Vk{2|h zG&#pFjH#M1M-KGos>tEszTh{CMI76gVl&kj%=cRTg*b9@)Ak8qX^U?K=H)T`CS}tO zJFdyMf1CRIX|Fc=Y5%x6;Psf#br0`z{K@03B@(Ae)A;SDPN#)s7|oKGSSCn;4I$C9 z@Bi>3#g?CQR)>i(fuxZVBBCT)fg(R_}%OGT|Wln|}KwlHO-ntUq@U2Vkm z9!UYNgGH@cyVUdA55dzD!G3&eBrN9P;nV#epmY72?-O)R=Ji@+)Md9c-yB%UacQ51 zgN-?(KStkI()D=c221*j3ziwZ2U(fQ!Cx4RD_U{(d!pEVjgF>k=o?LpUjo%qN11S?$0|WmKS;$)JHxN{Td7p%KEF7M>mM)bUtGY(IgIiVG}0Vr|SFGmUkt_ zot7=g1OFKLCC61<0!;3TjE6m35)=5zC1!@a`T*VyMzvw_cP_h*byM)R5)&^o_Z znbF`&(=+&phJ`-;L#X0SDHe^76iE2M%5@*h)Yps%PRj0*Lm!a|6txv@8;rdAW4KH} zC}Ll4v2Bi(G}S>(TPj_TrZ~^xWc5&u*S2d+7_KV=eJN`ueSp~s4#+{R#}-}Rm_`=} zblf)#9v(EsUleIMcX0g+T(NHfkrHYd^Q4?IbB*-FkgjM`QsV2G=Q4rFEEu%f5Y~nm zL$QZa;-G(`WgqHlB;9K|FSW#kI99OHfdQBXa^HmZ?0TN$& zbh3r7X8#(IvWj_7yxbR4;LK*RZ5OWCYuNl`<}IRhLj5U8lE)sk#m3=cLegEx?j%~F z*-^IC5t5p$50|Ip$(c(xW@CU9v($7}Sx%u#-;VEx)~?N`1`I1`AnPU)PF#g#<1$&f zY&IG^Qn=gs)^uv_owC2BU_EJd`{p|snpkb>%u1z(<-jQ|maPf4Edw`jn_gLqd>6Cu zH9Wx@G5pDIJ~rz=r!_Im{l?JK>8ODIW|`{l6aUhK5$w2~!oE^%Z7gBtOKu}dwnjlq zBv?HT3jZ~b{|fBRUoQVy)M;HHT(XysPr-L2VwEb65VI3`=mI|C014Y?g5PJqX3V&LGECa-i#&X$YKP9*swo7Fh@d8H z%!`E$iH%KKX(+JuD!$h_^gN%N-Q|IrUC;oZ!jFgqmGE*5w1cxv_WtFR4(ct_Z~rqk z_0=@VN%}K)^R0&L>OPKWmuAndbyT=a>(p~i&CItkb#UUN4m^YP>&&wi1aXDC=W=&7 zY+D#LgU7nO8VkR16z=2<4%`-mQa{E%8n}cR0;iOFyor!za)H}|pHle`XOR@RyUY)% zavx=bbO$3&h^;kL`jg_4Sy@t3XjipKu$h!W+ioZOvZlHr4k7GTCOHN%&)Cz!wJM8VM1|c2S zJ9~Mlob*Dfk%K`t=Xy;7Dy(6xa#gm*@htZG>#^6GC^*0ukV(E1U+&wWWBYhN>UxenHnm@o+?SIvVEVb@J8SXiOg+XD$&cmKl2*K}@w*Eb@S)nep57G$R@ zk|uvh&9?=`HAeakXij16nso*EWB3?P9j)JPkd9-@QC97*a!%+R^eIA9i|vHE=pmJH zpE!)rxs63l#Jb$?=yFvEh81dM3M(+??A|6vqzj*n__i`bD`%e^(p1}N&W;lr+tKye znu*}v-X1D%{jlKrliU1r>H|o)Z`?+ftDqqW>;f|APlN2Yp`^DPnEcGktd<73D=|ZV za=LFKIctHi;L5ApF_uHoJ=}9ZRt%)}$CT?)6Re1v>VTu&~n5o1td1 zaDI&_cV?9JJ_kxFLjm@H(UAEE^3XqyI<}qghQCCxP*KbK1vKNQ#ch8ha9M)i)k4%1Fya z6w6kxDF=+lP`pIDqYzo>PtY{0k-RMkS|EBo>W_a}7JHc#YUO&<+0gXaD>?K@jU8du zzbFhmNIs&r3>i7c&m{(dr~)j6u7*ESmjBUMDBAk0QL}PvYBaEN!{^65O^YN1x@^rY z-d++pTEM%TO*RfDN6V&0Wx2SMh@ze{!)bDQlXI2KN9MUu&8Hjkxq0S!Z!u8asF?PW z_8X70V4qfw0O8*uRs%h9C~G47uM$IFBb9IKeBTsDgViEpTn9qw6K5usb+!7tWL;H^ zN3bM~J3pxImf#S_TE%7LXA%JwT*wQ^;`EgN9n^f#!3f1{9Fa;$O>Hc4RD^#CgVm1Y zTPYQHLE7$1>%kqY^`EpH6BR|j49tT?iq0*Ttj<$7>4&wZmj!F$cI;X*TVANzI!ycS zkk0F=p6qJhKmj{%paqG@`mDOUoK{Q>w=U^a?f7Ph9C&6zq3WL^)2r?v!wMb<8NgNT z3?R@bIC8nWxy8B6>U|AD3R+F3z)9}7N3Ip(YY$lSYX&ja|NLH^AAKCjhG85@z}!+Z z#WORgAM;g=Q6nHwho9(v0syQeIR!`BtKt1W!II|NuDHX^-sg;;Pk}QwEiG~gsvDOh zQ~1h=t(yWSvkodx)@h#n7Xe0GI`k(AzL|F^YX>nW()jEoA9Nq(V2`vC&-m?}()rQL z-$Mg#?osViPty;w_hh<@esKgf3+18|4+U%=94%s8%;z+L3DO}q4_`PV_=kk6I2Z>0 zDOn5OH9?;1%i3A!tYW?n{MDP6$v>z**zIj36y5sKZuMy*6*Etv8W&@w-|1|J%t{ZX ze_D&mHSX%~kn7vT;fX2kz^Jlc()?R`&82MoRpGFm?^Su;yDRim>+sEzGCADvimTgInO{B`>qCd6|2T^4H|COnxLmZ{{c%PA@tmbn;(wuE~%$eXnJ;bSk!~ zEhd2!(kJaf1elkudR4wmLHC+9?o{VN-5R@b-=h)uY`$^<*KL-1Ys{B)XN+}bsu@dC zY`NlQY4H-QiudJ4W9n@N5tW%4=T#2ty$`sNT`Svl^LE&VBd8x5g+WE+{AqY>HEXoA z?n^jD!bc5cc8Pl#4Ay<&%ZebR?PEzJLd06K@&NN{Je_#{{HdJ6-;ySIRnZl3NO~&w z_aR|3ZcT}@a`z{S&J9D79+Zs@a|ivtm%cXqP<$NUePp-|MG@|8@$+2Jj?5)4?`8dr z|BX=wSoxi76<2f?gZ-1wXbeRFyfeJlkclX~)i(S$t+sT&k0cj)RyZMu1nUL4$r@7BEY~Mv7vpqz<;wx(H%E=tNI11~b=Ilg_+Zj?z zXul5I6;CP-MHWyP>2jvFEOt;r&*c`qyUd7(yFb(Sed$rtM|(_d(Bcuq+;^$w@vj<4 z8>~-7Q`XQLAv#eEp)fnHz>(uzRIr2Yo@QeJPslf+cS*s;BD{rPO~;15 zK+=RYco9LVY0;8!*X61-1N3#b8c)madlKd>^hDg!}TZycBl&qhMb5*!GYJcSm*Xl2VgS8vY z4uv$(cKG8uH9_$0Oz3yQbZo_StI-ew-6=~Gqf?Z0)ZpdkoI4 zC0sQ=og!Q`M9%8xpGer(8c!UOTj5D!S_OII3#g!C0!Izgi|nRPC?tiNMKVRtQe$Uw zZzB8Rpve`dzj(lgWe;@BoSYf6?SJn+f>l}8zMB(d*mWA^JWBh82;qT2q7}QIy>Y7`JLBL*ISvM(;RD3v^yHqeh1eGwX{--@s2o=e#UC&d8_|bYD ze9{ClZ;YCfM^CB76q$*!WiP{MCj9#xSt{F{W7I6Vt$3FL^p!14h&?}wsIVL1ia_m2 z7Em7U>sX0AAxu;7iZTXK$5|fho!jaeRp|s11!bKga_u^ldOk)xDTHd_*y5_VGuEIk z6&?Jul+qFkja-eTs*qBX{SZ)TtvPDPp`wyKL&q6iFSpkrF}E(BqsmYfsqR+d zk6!o}>?Ybu^Ys;GS=4?ZREpCCnOrA=p6Eo`3qGk zE`XEBPLe0i%-i=8%+Zh<%-J}V{=-W0euDVAZ2Jf%V~YC^Ca*7{l80=wY=#l;oS{jX?O+gSfw^T%C~^=3t*!rOJQ;=Hvfy5F6KY`VfF^Zzj4&D zi3Pnh$!oOX-I5Zp_$Z_z;FQdEsUzPCDRUmYZSF?OmZQbpbl@N3 zBO~Eow?WhXO$PAW{j`6)H*LDY6Jf0;(;``QcslkUN368 zc*ByDUE-_CVrJDb-s$JK0-9hRsySb3o5O_JNnmK5|X<`Ghp& zLE|WX*eO#DM1)Wao2~VX3Zt4UAm}s^25A?k6&*pLF6&G$bdx&!QdA0jE7v1b`dJ6&fN4HX zyV5XOXoSXxrB=}*pwaPtW9y^2nQxS;-Dw| zhS<~EE`Vzp03GwMYbGUfoJrMy*^2Maiw-N=YFpoXvf4#xsHR8LhJVdCKJ(GV@iaI{D)! z`d7tVwVj92sU8B3_9kLb7o(<@qT$%#1p#{ZLFaxyu&}5>x@);nPQC_rjlfO*n`)~S zSx`^a#cMs*)&2|;-^BN{LI!Ao>6q|(UOB5!V3sRmfMdTjUv~KnlCDNp_o?(s=%|aM zTP@kfResa^EcUp{&}ru4Ee6~2>S#&KW^At$(@0urf@hqx2(4VTIfSuUB$nK~7_|GS z0i7ECWh?8s$K}$Ri1=n;xwK02QPeHoG!myU3EaA$0i?un_#0XfMhurPXBI1+Y1ie= zGo!95fFWNzHl(u^1Tp+w@ay7|#!o@Dw5FjYICC zgR#0F%~sr(creEsu9{zQ{5K?1` zeRbJtN)-z#nkY+BY}5piX9;l^7P)0+G6@k*<^##-MCf79NUx_plP1Ofo&vTZdM8MD z3jE{3t0Vll@$n}X;|Pio_Jnyx#mw3W<6+M7S|pe-ZIoRM9d75?pIk=;I&Mh-Dfhim z-FLQ34Gcn-HYFVbz|?5$p{@mBd9wFmN7D{jO{7t8B9_)_GUsHaYw{+ zak|#GxLafUN3hA*GMGQVrmEwJh}-NnhEU`4!_&gNQHwhnPQDW8cR+)K|7TmMyj2~R zpGN4wa%wbn>(%Pltf~tOL}*HFtQK9a^!Mz)cj8Sq5^-cTeeajxHlz!iN=Z zbK-}edAeN??r)v6aq~e5;PEG%?9|4+!)~m|y#_a|OGWL&Nu&woJ2;WzYvFse*JWQ( zI!C;1j@@Bg`*c7YM=y3)S(3!Sb$ie7!17Pb3&o~%ArR$})aPsyi_luF(llBeSw*?Z zuUr+ig{$rTr$ktwk_Lh)^byn2b)0sC+f&6@8?e zN#vmAfi7rA+cG~69sxPg95CUQ4P)m6ot2u!-S)$kYVEl3VI|AKJS$uo1 zhJ1Sii=~Y@AC2KvQ-B`mZ=R+o)Nib0B}Ey9GE0*Z4xAlwb)-?{I>Bqh1s{#8JG>%p;2@AkHiQX>q_H1)6{iR@3aK3R}Ovj z(2B*PfD@p7de_(JU%2wyQ`tUJ#NEt3`4sk;ddWKL;hVO%GKbt?qBKL2g=}g8rA}rw z5ygG|a|~-2NmFP#-O}6t@Ue-eEwYF&fH|5(hSc-wv~)p>%jyrgB{ZCMlezUn5e|pJ zU#$13j4h8mnUyB5H1cm5*DVr?-W@4PYi)1^4b}%2j-ge&;Z9OYE^;%;HF+Dyc>wH| zyRYBhZI`<=5TTaJs~f&7)fWwQwInaLo-PDJd24Z1|JLQ&#;kcYFBzw`%4I#XBUg~z?{j*_TN@AKA*C)4gZn&-URl_Gxcc=ZqW=aQ@sah0Y71K57CM>gENsqNx4DI#^B5nSWKWHhQxWfE^yG-$JlU1zXVy`t4g^aZb~x zcsMQHc!cM9xqZ@#Ocp}uSUK)h^RXl z={p#68QK}YHUMTuW|nVE%-2-4iKoZy@Ni~=6}~=W&N*7N*!VS|Jj;^h^%m#;LqRx1A3Bevj6}9 literal 0 HcmV?d00001 From 1bc9d1a28ec68005bd96adee8d4a6452656a90bf Mon Sep 17 00:00:00 2001 From: fatedier 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 ac04279ec87..347636f27a1 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ - +   - +

diff --git a/README_zh.md b/README_zh.md index ac4eeec17c1..c54bdce784c 100644 --- a/README_zh.md +++ b/README_zh.md @@ -13,9 +13,9 @@ frp 是一个专注于内网穿透的高性能的反向代理应用,支持 TCP - +   - +

diff --git a/doc/pic/sponsor_nango.png b/doc/pic/sponsor_nango.png index 9cd54373c880d74796413071bf4eec09f540d2ef..4b83565698cf552046fea3f2a8da3ddcac17c875 100644 GIT binary patch literal 12068 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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 --- pkg/ssh/gateway.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/ssh/gateway.go b/pkg/ssh/gateway.go index 07ae9808860..90f2228ec18 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 --- 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 347636f27a1..d6e1986ea30 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 d25f6473b35..88cf60ebc66 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 00000000000..7f1a3ef9367 --- /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 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 98583c77129..9517af535fa 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 18cbaf0be8a..f166e9def62 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 d6e1986ea30..4bebb8ab607 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 7f1a3ef9367..b3dd4c34376 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 d11e1ef4996..4e178fb5393 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