From f6fc7881e7dee34d7788c626c9c1f1732bb07e7f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 04:01:12 +0000 Subject: [PATCH 1/2] Initial plan From 3acda6af4ab378fad30c57465255e194a05207cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Mar 2026 04:13:48 +0000 Subject: [PATCH 2/2] Add SSH keepalive, fix socks5 format bug, create GUI web app Co-authored-by: Sesame2 <103378588+Sesame2@users.noreply.github.com> --- Makefile | 5 +- cmd/gotun-gui/main.go | 71 ++++++++ gui/app.go | 377 +++++++++++++++++++++++++++++++++++++++ internal/proxy/socks5.go | 2 +- internal/proxy/ssh.go | 60 +++++++ 5 files changed, 513 insertions(+), 2 deletions(-) create mode 100644 cmd/gotun-gui/main.go create mode 100644 gui/app.go diff --git a/Makefile b/Makefile index 9a2167c..f9870d1 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,10 @@ BINARY_NAME=gotun +GUI_BINARY_NAME=gotun-gui VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") BUILD_DIR=./build LDFLAGS=-ldflags "-X main.Version=$(VERSION) -s -w" MAIN_PACKAGE=./cmd/gotun +GUI_PACKAGE=./cmd/gotun-gui # Go命令 GOCMD=go @@ -29,7 +31,8 @@ build: @echo "Building $(BINARY_NAME)..." @mkdir -p $(BUILD_DIR) @$(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(MAIN_PACKAGE) - @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)" + @$(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(GUI_BINARY_NAME) $(GUI_PACKAGE) + @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME), $(BUILD_DIR)/$(GUI_BINARY_NAME)" # 交叉编译 build-all: build-linux build-windows build-darwin diff --git a/cmd/gotun-gui/main.go b/cmd/gotun-gui/main.go new file mode 100644 index 0000000..61c8d18 --- /dev/null +++ b/cmd/gotun-gui/main.go @@ -0,0 +1,71 @@ +// gotun-gui is a web-based graphical interface for the gotun SSH proxy tool. +// +// It starts a local web server on 127.0.0.1:8089 (by default) and opens the +// control panel in the browser, allowing users to configure and manage the +// gotun SSH tunnel without using the command line. +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/Sesame2/gotun/gui" + "github.com/Sesame2/gotun/internal/config" + "github.com/spf13/cobra" +) + +var ( + Version = "dev" + + cfg = config.NewConfig() + guiAddr string +) + +var rootCmd = &cobra.Command{ + Use: "gotun-gui", + Version: Version, + Short: "GoTun 图形界面管理工具", + Long: `gotun-gui 启动一个本地 Web 控制面板,让你通过浏览器管理 gotun 代理。 + +无需参数即可启动控制面板,在启动后可通过界面配置 SSH 服务器信息并控制代理的启停。`, + RunE: func(cmd *cobra.Command, args []string) error { + // If a SSH target is provided (user@host), pre-fill config. + if len(args) == 1 { + parts := strings.SplitN(args[0], "@", 2) + if len(parts) == 2 { + cfg.SSHUser = parts[0] + host := parts[1] + if !strings.Contains(host, ":") { + host = fmt.Sprintf("%s:%s", host, cfg.SSHPort) + } + cfg.SSHServer = host + } + } + + app := gui.NewApp(cfg, guiAddr) + fmt.Printf("GoTun GUI 正在启动,请在浏览器中打开 http://%s\n", guiAddr) + return app.Run() + }, +} + +func init() { + rootCmd.Flags().StringVar(&guiAddr, "gui-addr", "127.0.0.1:8089", "Web 控制面板监听地址") + rootCmd.Flags().StringVarP(&cfg.SSHPort, "port", "p", "22", "SSH 服务器端口") + rootCmd.Flags().StringVar(&cfg.SSHPassword, "pass", "", "SSH 密码") + rootCmd.Flags().StringVarP(&cfg.SSHKeyFile, "identity_file", "i", "", "SSH 私钥文件路径") + rootCmd.Flags().StringVarP(&cfg.ListenAddr, "http", "l", ":8080", "HTTP 代理监听地址") + rootCmd.Flags().StringVar(&cfg.SocksAddr, "socks5", ":1080", "SOCKS5 代理监听地址") + rootCmd.Flags().BoolVar(&cfg.SystemProxy, "sys-proxy", true, "自动设置/恢复系统代理") + rootCmd.Flags().BoolVarP(&cfg.Verbose, "verbose", "v", false, "启用详细日志") + rootCmd.Flags().StringVar(&cfg.RuleFile, "rules", "", "代理规则配置文件路径") + rootCmd.Args = cobra.MaximumNArgs(1) +} + +func main() { + rootCmd.Version = Version + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/gui/app.go b/gui/app.go new file mode 100644 index 0000000..adff115 --- /dev/null +++ b/gui/app.go @@ -0,0 +1,377 @@ +// Package gui provides a web-based graphical interface for managing gotun. +// +// It starts a local HTTP server that serves a simple control panel, allowing +// users to configure the SSH tunnel, start/stop the proxy, and view +// connection status through a browser. +package gui + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync" + "time" + + "github.com/Sesame2/gotun/internal/config" + "github.com/Sesame2/gotun/internal/logger" + "github.com/Sesame2/gotun/internal/proxy" + "github.com/Sesame2/gotun/internal/router" + "github.com/Sesame2/gotun/internal/sysproxy" +) + +// App is the GUI application that wraps the gotun proxy service. +type App struct { + cfg *config.Config + log *logger.Logger + addr string // web UI listen address, e.g. "127.0.0.1:8089" + mu sync.Mutex + running bool + sshClient *proxy.SSHClient + httpProxy *proxy.HTTPOverSSH + socksProxy *proxy.SOCKS5OverSSH + proxyMgr *sysproxy.Manager + server *http.Server + startedAt time.Time +} + +// StatusResponse is the JSON structure returned by the /api/status endpoint. +type StatusResponse struct { + Running bool `json:"running"` + HTTPAddr string `json:"http_addr"` + SocksAddr string `json:"socks_addr"` + SSHServer string `json:"ssh_server"` + StartedAt string `json:"started_at,omitempty"` + GitConfig string `json:"git_config,omitempty"` +} + +// NewApp creates a new GUI application. +func NewApp(cfg *config.Config, addr string) *App { + return &App{ + cfg: cfg, + log: logger.NewLogger(cfg.Verbose), + addr: addr, + } +} + +// Run starts the web UI server and blocks until the server exits. +func (a *App) Run() error { + mux := http.NewServeMux() + mux.HandleFunc("/", a.handleIndex) + mux.HandleFunc("/api/status", a.handleStatus) + mux.HandleFunc("/api/start", a.handleStart) + mux.HandleFunc("/api/stop", a.handleStop) + + a.server = &http.Server{ + Addr: a.addr, + Handler: mux, + } + + a.log.Infof("GoTun GUI 已启动,请在浏览器中打开 http://%s", a.addr) + err := a.server.ListenAndServe() + if err == http.ErrServerClosed { + return nil + } + return err +} + +// Shutdown gracefully stops the web UI server and any running proxy. +func (a *App) Shutdown() { + a.stopProxy() + if a.server != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = a.server.Shutdown(ctx) + } +} + +func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, indexHTML) +} + +func (a *App) handleStatus(w http.ResponseWriter, r *http.Request) { + a.mu.Lock() + defer a.mu.Unlock() + + resp := StatusResponse{ + Running: a.running, + HTTPAddr: a.cfg.ListenAddr, + SocksAddr: a.cfg.SocksAddr, + SSHServer: a.cfg.SSHServer, + } + if a.running { + resp.StartedAt = a.startedAt.Format(time.DateTime) + if a.cfg.SocksAddr != "" { + resp.GitConfig = fmt.Sprintf( + "# Add to ~/.ssh/config to route git SSH through the SOCKS5 proxy:\n"+ + "Host github.com\n"+ + " ProxyCommand nc -x %s %%h %%p\n"+ + " ServerAliveInterval 30\n"+ + " ServerAliveCountMax 3\n\n"+ + "# Alternative (if nc without -x support): use ncat or connect-proxy:\n"+ + "# ProxyCommand ncat --proxy %s --proxy-type socks5 %%h %%p\n"+ + "# ProxyCommand connect-proxy -S %s %%h %%p", + a.cfg.SocksAddr, a.cfg.SocksAddr, a.cfg.SocksAddr, + ) + } + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +func (a *App) handleStart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + a.mu.Lock() + if a.running { + a.mu.Unlock() + http.Error(w, "代理已在运行", http.StatusConflict) + return + } + a.mu.Unlock() + + if err := a.cfg.Validate(); err != nil { + http.Error(w, fmt.Sprintf("配置错误: %v", err), http.StatusBadRequest) + return + } + + if err := a.startProxy(); err != nil { + http.Error(w, fmt.Sprintf("启动失败: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"ok":true}`) +} + +func (a *App) handleStop(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + a.stopProxy() + + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"ok":true}`) +} + +func (a *App) startProxy() error { + sshClient, err := proxy.NewSSHClient(a.cfg, a.log) + if err != nil { + return fmt.Errorf("SSH 连接失败: %w", err) + } + + var r *router.Router + if a.cfg.RuleFile != "" { + r, err = router.NewRouter(a.cfg.RuleFile) + if err != nil { + a.log.Warnf("加载规则文件失败: %v", err) + } + } + + httpProxy, err := proxy.NewHTTPOverSSH(a.cfg, a.log, sshClient, r) + if err != nil { + sshClient.Close() + return fmt.Errorf("HTTP 代理初始化失败: %w", err) + } + + var socksProxy *proxy.SOCKS5OverSSH + if a.cfg.SocksAddr != "" { + socksProxy, err = proxy.NewSOCKS5OverSSH(a.cfg, a.log, sshClient, r) + if err != nil { + sshClient.Close() + return fmt.Errorf("SOCKS5 代理初始化失败: %w", err) + } + } + + a.mu.Lock() + a.sshClient = sshClient + a.httpProxy = httpProxy + a.socksProxy = socksProxy + a.running = true + a.startedAt = time.Now() + a.mu.Unlock() + + var proxyMgr *sysproxy.Manager + if a.cfg.SystemProxy { + proxyMgr = sysproxy.NewManager(a.log, a.cfg.ListenAddr, a.cfg.SocksAddr) + a.mu.Lock() + a.proxyMgr = proxyMgr + a.mu.Unlock() + } + + go func() { + if proxyMgr != nil { + if err := proxyMgr.Enable(); err != nil { + a.log.Errorf("设置系统代理失败: %v", err) + } + } + if err := httpProxy.Start(); err != nil { + a.log.Errorf("HTTP 代理服务启动失败: %v", err) + } + }() + + if socksProxy != nil { + go func() { + if err := socksProxy.Start(); err != nil { + a.log.Errorf("SOCKS5 代理服务启动失败: %v", err) + } + }() + } + + return nil +} + +func (a *App) stopProxy() { + a.mu.Lock() + defer a.mu.Unlock() + + if !a.running { + return + } + + a.running = false + + if a.proxyMgr != nil { + if err := a.proxyMgr.Disable(); err != nil { + a.log.Errorf("恢复系统代理设置失败: %v", err) + } + a.proxyMgr = nil + } + + if a.httpProxy != nil { + if err := a.httpProxy.Close(); err != nil { + a.log.Errorf("关闭 HTTP 代理失败: %v", err) + } + a.httpProxy = nil + } + + if a.socksProxy != nil { + if err := a.socksProxy.Close(); err != nil { + a.log.Errorf("关闭 SOCKS5 代理失败: %v", err) + } + a.socksProxy = nil + } + + if a.sshClient != nil { + if err := a.sshClient.Close(); err != nil { + a.log.Errorf("关闭 SSH 连接失败: %v", err) + } + a.sshClient = nil + } +} + +// indexHTML is the single-page HTML/JS/CSS UI for the control panel. +const indexHTML = ` + + + + +GoTun 控制面板 + + + +
+

🚀 GoTun 控制面板

+ 已停止 +
+
+
+

代理状态

+
HTTP 代理-
+
SOCKS5 代理-
+
SSH 服务器-
+
启动时间-
+
+ + +
+
+
+ +
+ + + +` diff --git a/internal/proxy/socks5.go b/internal/proxy/socks5.go index 238c4f8..fb63392 100644 --- a/internal/proxy/socks5.go +++ b/internal/proxy/socks5.go @@ -111,7 +111,7 @@ func (s *SOCKS5OverSSH) handleConnection(conn net.Conn) { start := time.Now() destConn, ruleAction, err := s.dialTarget(targetAddr, hostForRoute) if err != nil { - s.logger.Warnf("[%SOCKS5] 连接目标 %s 失败: %v", targetAddr, err) + s.logger.Warnf("[SOCKS5] 连接目标 %s 失败: %v", targetAddr, err) s.reply(conn, 0x05) // 0x05: Connection refused return } diff --git a/internal/proxy/ssh.go b/internal/proxy/ssh.go index d3bb0b9..0685d42 100644 --- a/internal/proxy/ssh.go +++ b/internal/proxy/ssh.go @@ -15,12 +15,20 @@ import ( "github.com/Sesame2/gotun/internal/utils" ) +const ( + // keepaliveInterval is the interval between SSH keepalive requests. + keepaliveInterval = 30 * time.Second + // keepaliveTimeout is the maximum time to wait for a keepalive response. + keepaliveTimeout = 15 * time.Second +) + // SSHClient 管理SSH连接 type SSHClient struct { client *ssh.Client // 这个是最终目标机器的连接 jumpClients []*ssh.Client // 这里存储所有跳板机的连接 config *ssh.ClientConfig logger *logger.Logger + stopChan chan struct{} // 用于停止 keepalive goroutine } type AuthConfig struct { @@ -85,6 +93,7 @@ func NewSSHClient(cfg *config.Config, log *logger.Logger) (*SSHClient, error) { sshClient := &SSHClient{ logger: log, jumpClients: []*ssh.Client{}, + stopChan: make(chan struct{}), } // 尝试连接所有跳板机 @@ -132,6 +141,7 @@ func NewSSHClient(cfg *config.Config, log *logger.Logger) (*SSHClient, error) { sshClient.client = finalClient log.Infof("已连接到目标服务器: %s", cfg.SSHServer) + go sshClient.keepAlive() return sshClient, nil } @@ -201,6 +211,10 @@ func trySingleConnection(user, addr string, timeout time.Duration, auths []ssh.A // Close 关闭所有连接(逆序关闭跳板机) func (s *SSHClient) Close() error { + if s.stopChan != nil { + close(s.stopChan) + s.stopChan = nil + } if s.client != nil { s.logger.Debug("关闭目标SSH连接") s.client.Close() @@ -217,6 +231,52 @@ func (s *SSHClient) Close() error { return nil } +// keepAlive 定期向SSH服务器发送 keepalive 请求,防止空闲连接被服务器或防火墙断开。 +func (s *SSHClient) keepAlive() { + stopChan := s.stopChan // capture so we hold the reference even after Close sets it to nil + ticker := time.NewTicker(keepaliveInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if s.client == nil { + return + } + // Use a deadline-based timeout: set a deadline on the underlying connection + // so that SendRequest returns promptly on an unresponsive server. + // We run it in a goroutine and guard with our own timer so that even if the + // crypto/ssh layer never returns we won't block the ticker loop. + done := make(chan error, 1) + go func() { + _, _, err := s.client.SendRequest("keepalive@openssh.com", true, nil) + done <- err + }() + select { + case err := <-done: + if err != nil { + s.logger.Debugf("SSH keepalive 失败: %v", err) + } else { + s.logger.Debug("SSH keepalive 成功") + } + case <-time.After(keepaliveTimeout): + // The server did not respond within the timeout window. + // Log a warning and close the connection so that the SendRequest + // goroutine can unblock and terminate. + s.logger.Warn("SSH keepalive 超时,连接可能已断开") + if s.client != nil { + s.client.Close() + } + return + case <-stopChan: + return + } + case <-stopChan: + return + } + } +} + func loadPrivateKey(path string) (ssh.Signer, error) { expanded := path if strings.HasPrefix(path, "~/") {