Files
Havline/internal/frp/agent_tunnel.go
T
mrlee 2a0740a60f
CI / verify (push) Canceled after 0s
Docker Release / build-and-push (amd64, linux/amd64) (push) Canceled after 0s
Docker Release / build-and-push (arm64, linux/arm64) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Docker Release / create-manifest (push) Canceled after 0s
chore(release): 发布 v1.9.2
- 「公网服务端」新增 Nginx 启停控制(Agent 0.15.2,停止带二次确认)
- frps 启动失败不再误报成功,并增强启动诊断
- frps vhost 端口禁止 80/443,避免与 VPS 上 Nginx 冲突
- 旧服务端配置的 tcp_mux / login_fail_exit 默认值补齐
- 新增安全审查、页面布局方案与场景操作文档
2026-10-07 19:26:02 +08:00

353 lines
7.7 KiB
Go

package frp
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
const agentRemoteAddr = "127.0.0.1:7700"
const (
agentTunnelKeepalive = 30 * time.Second
agentTunnelKeepaliveTimeout = 5 * time.Second
agentTunnelBackoffMin = time.Second
agentTunnelBackoffMax = 60 * time.Second
)
type agentTunnel struct {
serverID int64
localPort int
remoteAddr string
ssh SSHInstaller
ctx context.Context
cancel context.CancelFunc
connectMu sync.Mutex
mu sync.Mutex
listener net.Listener
client *ssh.Client
state string
lastError string
wg sync.WaitGroup
connsMu sync.Mutex
conns map[net.Conn]struct{}
}
func newAgentTunnel(parent context.Context, serverID int64, localPort int, installer SSHInstaller) *agentTunnel {
return newAgentTunnelWithListener(parent, serverID, localPort, installer, nil)
}
func newAgentTunnelWithListener(parent context.Context, serverID int64, localPort int, installer SSHInstaller, listener net.Listener) *agentTunnel {
ctx, cancel := context.WithCancel(parent)
return &agentTunnel{
serverID: serverID,
localPort: localPort,
remoteAddr: agentRemoteAddr,
ssh: installer,
ctx: ctx,
cancel: cancel,
listener: listener,
state: "connecting",
conns: map[net.Conn]struct{}{},
}
}
func (t *agentTunnel) Start() error {
if t.listener == nil {
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", t.localPort))
if err != nil {
t.setState("failed", err)
return fmt.Errorf("监听隧道本地端口失败: %w", err)
}
t.listener = listener
}
if t.localPort == 0 {
if addr, ok := t.listener.Addr().(*net.TCPAddr); ok {
t.localPort = addr.Port
}
}
t.mu.Lock()
t.state = "connecting"
t.lastError = ""
t.mu.Unlock()
t.wg.Add(2)
go t.acceptLoop()
go t.monitorLoop()
return nil
}
// monitorLoop 主动保活并在断开后按指数退避重建 SSH 连接,避免只在有流量时才尝试重连。
func (t *agentTunnel) monitorLoop() {
defer t.wg.Done()
backoff := agentTunnelBackoffMin
for {
err := t.check()
wait := agentTunnelKeepalive
if err != nil {
t.setState("reconnecting", err)
backoff = nextTunnelBackoff(backoff)
wait = backoff
} else {
t.setState("connected", nil)
backoff = agentTunnelBackoffMin
}
if !t.sleep(wait) {
return
}
}
}
// check 同时验证 SSH 连接和远端 Agent 端口,避免只连上 SSH 就误判隧道可用。
func (t *agentTunnel) check() error {
client, err := t.ensureClient()
if err != nil {
return err
}
if err := t.keepalive(client); err != nil {
t.invalidateClient(client)
return fmt.Errorf("SSH keepalive 失败: %w", err)
}
remote, err := client.Dial("tcp", t.remoteAddr)
if err != nil {
return fmt.Errorf("连接远端 Agent 端口失败: %w", err)
}
_ = remote.Close()
return nil
}
func (t *agentTunnel) sleep(d time.Duration) bool {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-t.ctx.Done():
return false
case <-timer.C:
return true
}
}
func nextTunnelBackoff(current time.Duration) time.Duration {
if current < agentTunnelBackoffMin {
return agentTunnelBackoffMin
}
next := current * 2
if next > agentTunnelBackoffMax {
return agentTunnelBackoffMax
}
return next
}
func (t *agentTunnel) acceptLoop() {
defer t.wg.Done()
for {
conn, err := t.listener.Accept()
if err != nil {
if t.ctx.Err() != nil {
return
}
t.setState("failed", err)
return
}
t.wg.Add(1)
if !t.trackConn(conn) {
t.wg.Done()
return
}
go func() {
defer t.wg.Done()
defer t.untrackConn(conn)
t.handle(conn)
}()
}
}
func (t *agentTunnel) handle(local net.Conn) {
defer local.Close()
client, err := t.ensureClient()
if err != nil {
t.setState("reconnecting", err)
writeTunnelError(local, err)
return
}
remote, err := client.Dial("tcp", t.remoteAddr)
if err != nil {
remote, err = t.retryRemoteDial(client, err)
if err != nil {
t.setState("reconnecting", err)
writeTunnelError(local, err)
return
}
}
defer remote.Close()
t.setState("connected", nil)
proxyTunnel(local, remote)
}
// retryRemoteDial 仅在 SSH 连接本身失效时重连,远端 Agent 端口暂时不可用不会关闭共享连接。
func (t *agentTunnel) retryRemoteDial(client *ssh.Client, firstErr error) (net.Conn, error) {
if err := t.keepalive(client); err == nil {
return nil, fmt.Errorf("连接远端 Agent 端口失败: %w", firstErr)
}
t.invalidateClient(client)
reconnected, err := t.ensureClient()
if err != nil {
return nil, fmt.Errorf("SSH 连接失效,重连失败: %w", err)
}
remote, err := reconnected.Dial("tcp", t.remoteAddr)
if err != nil {
return nil, fmt.Errorf("SSH 已重连,但远端 Agent 端口仍不可用: %w", err)
}
return remote, nil
}
func (t *agentTunnel) ensureClient() (*ssh.Client, error) {
// 单飞:监控循环和请求处理可能同时触发建连,串行化后只允许一条 SSH 握手。
// 正常请求只复用连接,不在热路径做 keepalive,避免多个接口互相排队。
t.connectMu.Lock()
defer t.connectMu.Unlock()
t.mu.Lock()
if t.client != nil {
client := t.client
t.mu.Unlock()
return client, nil
}
t.mu.Unlock()
ctx, cancel := context.WithTimeout(t.ctx, 15*time.Second)
defer cancel()
client, err := t.ssh.client(ctx)
if err != nil {
return nil, err
}
t.mu.Lock()
if t.client != nil {
existing := t.client
t.mu.Unlock()
_ = client.Close()
return existing, nil
}
t.client = client
t.mu.Unlock()
return client, nil
}
func (t *agentTunnel) keepalive(client *ssh.Client) error {
done := make(chan error, 1)
go func() {
_, _, err := client.SendRequest("keepalive@openssh.com", true, nil)
done <- err
}()
select {
case err := <-done:
return err
case <-time.After(agentTunnelKeepaliveTimeout):
return fmt.Errorf("SSH keepalive 超时")
case <-t.ctx.Done():
return t.ctx.Err()
}
}
// writeTunnelError 让本地 HTTP 客户端立即得到明确错误,而不是一直等到 context deadline。
func writeTunnelError(conn net.Conn, err error) {
body, _ := json.Marshal(map[string]string{"error": "SSH 隧道不可用: " + err.Error()})
_, _ = fmt.Fprintf(conn,
"HTTP/1.1 502 Bad Gateway\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s",
len(body), body)
}
func (t *agentTunnel) invalidateClient(client *ssh.Client) {
t.mu.Lock()
if t.client == client {
t.client = nil
}
t.mu.Unlock()
_ = client.Close()
}
func (t *agentTunnel) State() (string, string) {
t.mu.Lock()
defer t.mu.Unlock()
return t.state, t.lastError
}
func (t *agentTunnel) setState(state string, err error) {
t.mu.Lock()
defer t.mu.Unlock()
t.state = state
if err == nil {
t.lastError = ""
return
}
t.lastError = err.Error()
}
func (t *agentTunnel) Stop() {
t.cancel()
t.mu.Lock()
if t.listener != nil {
_ = t.listener.Close()
}
if t.client != nil {
_ = t.client.Close()
}
t.mu.Unlock()
t.closeConns()
t.wg.Wait()
}
func (t *agentTunnel) trackConn(conn net.Conn) bool {
t.connsMu.Lock()
defer t.connsMu.Unlock()
if t.ctx.Err() != nil {
_ = conn.Close()
return false
}
t.conns[conn] = struct{}{}
return true
}
func (t *agentTunnel) untrackConn(conn net.Conn) {
t.connsMu.Lock()
delete(t.conns, conn)
t.connsMu.Unlock()
}
func (t *agentTunnel) closeConns() {
t.connsMu.Lock()
conns := make([]net.Conn, 0, len(t.conns))
for conn := range t.conns {
conns = append(conns, conn)
}
t.connsMu.Unlock()
for _, conn := range conns {
_ = conn.Close()
}
}
func proxyTunnel(a, b net.Conn) {
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
_, _ = io.Copy(a, b)
_ = a.Close()
_ = b.Close()
}()
go func() {
defer wg.Done()
_, _ = io.Copy(b, a)
_ = a.Close()
_ = b.Close()
}()
wg.Wait()
}