From 56cc3485a754bc2c24aa2a44ad058885d0f5ccdb Mon Sep 17 00:00:00 2001 From: NanamiAdmin Date: Wed, 12 Aug 2026 22:53:23 +0800 Subject: [PATCH] feat(netproxy): add network proxy module to provide unified network proxy connection. feat(internal/controller): make email, ntft, webhook, qq_napcat able to use network proxy. --- config/variables.go | 74 ++++---- internal/controller/pipes/email.go | 8 + internal/controller/pipes/ntfy.go | 7 +- internal/controller/pipes/qq_napcat/napcat.go | 30 ++-- internal/controller/pipes/qq_napcat/qq.go | 2 +- internal/controller/pipes/webhook.go | 7 +- internal/netproxy/netproxy.go | 170 ++++++++++++++++++ 7 files changed, 243 insertions(+), 55 deletions(-) create mode 100644 internal/netproxy/netproxy.go diff --git a/config/variables.go b/config/variables.go index d9bfa6d..c9e9653 100644 --- a/config/variables.go +++ b/config/variables.go @@ -2,9 +2,10 @@ package config // SystemConfig holds system-level configuration. type SystemConfig struct { - DebugMode bool `json:"debugMode"` - ListenAddr string `json:"listenAddr"` - ListenPort string `json:"listenPort"` + DebugMode bool `json:"debugMode"` + ListenAddr string `json:"listenAddr"` + ListenPort string `json:"listenPort"` + NetworkProxy string `json:"networkProxy"` } // DebugConfig holds debug-level configuration. @@ -31,53 +32,58 @@ type KomariConfig struct { // QQConfig holds QQ (Napcat) Bot controller configuration. type QQConfig struct { - Enabled bool `json:"enabled"` - NapcatAddr string `json:"napcatAddr"` - NapcatPort string `json:"napcatPort"` - NapcatToken string `json:"napcatToken"` - BotQQID int64 `json:"botQQID"` - ListenMethod string `json:"listenMethod"` - Admins []string `json:"admins"` - TrustedGroups []string `json:"trustedGroups"` + Enabled bool `json:"enabled"` + NetworkUseProxy bool `json:"networkUseProxy"` + NapcatAddr string `json:"napcatAddr"` + NapcatPort string `json:"napcatPort"` + NapcatToken string `json:"napcatToken"` + BotQQID int64 `json:"botQQID"` + ListenMethod string `json:"listenMethod"` + Admins []string `json:"admins"` + TrustedGroups []string `json:"trustedGroups"` } // TelegramConfig holds Telegram Bot controller configuration. type TelegramConfig struct { - Enabled bool `json:"enabled"` - BotToken string `json:"botToken"` - ListenMethod string `json:"listenMethod"` - Admins []string `json:"admins"` - TrustedGroups []string `json:"trustedGroups"` + Enabled bool `json:"enabled"` + NetworkUseProxy bool `json:"networkUseProxy"` + BotToken string `json:"botToken"` + ListenMethod string `json:"listenMethod"` + Admins []string `json:"admins"` + TrustedGroups []string `json:"trustedGroups"` } // EmailConfig holds Email notification controller configuration. type EmailConfig struct { - Enabled bool `json:"enabled"` - SMTPHost string `json:"smtpHost"` - SMTPPort int `json:"smtpPort"` - Username string `json:"username"` - Password string `json:"password"` - From string `json:"from"` - To []string `json:"to"` - UseTLS bool `json:"useTLS"` + Enabled bool `json:"enabled"` + NetworkUseProxy bool `json:"networkUseProxy"` + SMTPHost string `json:"smtpHost"` + SMTPPort int `json:"smtpPort"` + Username string `json:"username"` + Password string `json:"password"` + From string `json:"from"` + To []string `json:"to"` + UseTLS bool `json:"useTLS"` } // NtfyConfig holds Ntfy notification controller configuration. type NtfyConfig struct { - Enabled bool `json:"enabled"` - Server string `json:"server"` - Topic string `json:"topic"` - Token string `json:"token"` - Priority string `json:"priority"` + Enabled bool `json:"enabled"` + NetworkUseProxy bool `json:"networkUseProxy"` + Server string `json:"server"` + Topic string `json:"topic"` + Token string `json:"token"` + Priority string `json:"priority"` } // WebhookConfig holds Webhook notification controller configuration. type WebhookConfig struct { - Enabled bool `json:"enabled"` - URL string `json:"url"` - Method string `json:"method"` - Headers map[string]string `json:"headers"` - Template string `json:"template"` + Enabled bool `json:"enabled"` + NetworkUseProxy bool `json:"networkUseProxy"` + URL string `json:"url"` + Method string `json:"method"` + Headers map[string]string `json:"headers"` + Template string `json:"template"` } // ControllerMethodConfig holds all controller method configurations. diff --git a/internal/controller/pipes/email.go b/internal/controller/pipes/email.go index 870ac27..07d3f13 100644 --- a/internal/controller/pipes/email.go +++ b/internal/controller/pipes/email.go @@ -6,6 +6,7 @@ import ( gomail "gopkg.in/mail.v2" "nukumizu-backend/config" + "nukumizu-backend/internal/netproxy" "nukumizu-backend/internal/node" "nukumizu-backend/internal/template" "nukumizu-backend/postLog" @@ -18,6 +19,13 @@ type EmailController struct { // NewEmailController creates a new Email controller. func NewEmailController(cfg config.EmailConfig) *EmailController { + if cfg.NetworkUseProxy { + // Route SMTP through the HTTP CONNECT proxy. NetDialTimeout is + // gomail's documented hook for overriding how the SMTP connection is + // dialed. There is a single global email channel, so overriding it + // unconditionally when the flag is set is safe. + gomail.NetDialTimeout = netproxy.DialWithTimeout(true) + } return &EmailController{cfg: cfg} } diff --git a/internal/controller/pipes/ntfy.go b/internal/controller/pipes/ntfy.go index 2d6d841..ed5452d 100644 --- a/internal/controller/pipes/ntfy.go +++ b/internal/controller/pipes/ntfy.go @@ -7,6 +7,7 @@ import ( "time" "nukumizu-backend/config" + "nukumizu-backend/internal/netproxy" "nukumizu-backend/internal/node" "nukumizu-backend/internal/template" "nukumizu-backend/postLog" @@ -21,10 +22,8 @@ type NtfyController struct { // NewNtfyController creates a new Ntfy controller. func NewNtfyController(cfg config.NtfyConfig) *NtfyController { return &NtfyController{ - cfg: cfg, - httpClient: &http.Client{ - Timeout: 10 * time.Second, - }, + cfg: cfg, + httpClient: netproxy.HTTPClient(cfg.NetworkUseProxy, 10*time.Second), } } diff --git a/internal/controller/pipes/qq_napcat/napcat.go b/internal/controller/pipes/qq_napcat/napcat.go index e6d62aa..85278db 100644 --- a/internal/controller/pipes/qq_napcat/napcat.go +++ b/internal/controller/pipes/qq_napcat/napcat.go @@ -10,9 +10,11 @@ import ( "sync" "time" - "nukumizu-backend/postLog" - "nukumizu-backend/config" "github.com/gorilla/websocket" + + "nukumizu-backend/config" + "nukumizu-backend/internal/netproxy" + "nukumizu-backend/postLog" ) // APIResponse mirrors NapCat's HTTP API response envelope. @@ -30,6 +32,7 @@ type Client struct { addr string port string token string + useProxy bool httpClient *http.Client connMu sync.Mutex @@ -38,16 +41,17 @@ type Client struct { stopOnce sync.Once } -// NewClient creates a NapCat client for the given host/port/token. -func NewClient(addr, port, token string) *Client { +// NewClient creates a NapCat client for the given host/port/token. When +// useProxy is set, HTTP API calls and the WebSocket connection are routed +// through the system-wide network proxy. +func NewClient(addr, port, token string, useProxy bool) *Client { return &Client{ - addr: addr, - port: port, - token: token, - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, - stopCh: make(chan struct{}), + addr: addr, + port: port, + token: token, + useProxy: useProxy, + httpClient: netproxy.HTTPClient(useProxy, 30*time.Second), + stopCh: make(chan struct{}), } } @@ -265,7 +269,9 @@ func (c *Client) listenOnce(onEvent func(raw []byte)) { header.Set("Authorization", "Bearer "+c.token) } - conn, _, err := websocket.DefaultDialer.Dial(wsURL, header) + dialer := *websocket.DefaultDialer + dialer.Proxy = netproxy.ProxyFunc(c.useProxy) + conn, _, err := dialer.Dial(wsURL, header) if err != nil { postLog.Error(fmt.Sprintf("[Napcat] Failed to connect to NapCat WebSocket: %v", err)) return diff --git a/internal/controller/pipes/qq_napcat/qq.go b/internal/controller/pipes/qq_napcat/qq.go index cdb7e8e..4b30da2 100644 --- a/internal/controller/pipes/qq_napcat/qq.go +++ b/internal/controller/pipes/qq_napcat/qq.go @@ -39,7 +39,7 @@ type QQController struct { func NewQQController(cfg config.QQConfig) *QQController { q := &QQController{cfg: cfg} if cfg.Enabled { - q.napcatClient = NewClient(cfg.NapcatAddr, cfg.NapcatPort, cfg.NapcatToken) + q.napcatClient = NewClient(cfg.NapcatAddr, cfg.NapcatPort, cfg.NapcatToken, cfg.NetworkUseProxy) } return q } diff --git a/internal/controller/pipes/webhook.go b/internal/controller/pipes/webhook.go index 7d52518..f3485a8 100644 --- a/internal/controller/pipes/webhook.go +++ b/internal/controller/pipes/webhook.go @@ -8,6 +8,7 @@ import ( "time" "nukumizu-backend/config" + "nukumizu-backend/internal/netproxy" "nukumizu-backend/internal/node" "nukumizu-backend/internal/template" "nukumizu-backend/postLog" @@ -22,10 +23,8 @@ type WebhookController struct { // NewWebhookController creates a new Webhook controller. func NewWebhookController(cfg config.WebhookConfig) *WebhookController { return &WebhookController{ - cfg: cfg, - httpClient: &http.Client{ - Timeout: 10 * time.Second, - }, + cfg: cfg, + httpClient: netproxy.HTTPClient(cfg.NetworkUseProxy, 10*time.Second), } } diff --git a/internal/netproxy/netproxy.go b/internal/netproxy/netproxy.go new file mode 100644 index 0000000..d52d4c6 --- /dev/null +++ b/internal/netproxy/netproxy.go @@ -0,0 +1,170 @@ +// Package netproxy builds network clients that route traffic through the HTTP +// network proxy configured in the system config. Each caller decides whether +// to use the proxy by passing its own useProxy flag (the per-channel +// networkUseProxy setting), so proxying is opt-in per channel. +package netproxy + +import ( + "bufio" + "encoding/base64" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + "nukumizu-backend/config" +) + +// proxyURL returns the system-wide network proxy URL, or nil when none is +// configured. A missing scheme is normalized to http:// for convenience. +func proxyURL() *url.URL { + raw := config.GetConfig().System.NetworkProxy + if raw == "" { + return nil + } + if !strings.Contains(raw, "://") { + raw = "http://" + raw + } + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return nil + } + return u +} + +// ProxyFunc returns a transport proxy function that routes requests through +// the configured network proxy when enabled. It returns nil when the caller +// opts out or no proxy is configured, meaning direct connection. The returned +// function is compatible with both http.Transport.Proxy and +// websocket.Dialer.Proxy. +func ProxyFunc(useProxy bool) func(*http.Request) (*url.URL, error) { + if !useProxy { + return nil + } + u := proxyURL() + if u == nil { + return nil + } + return http.ProxyURL(u) +} + +// HTTPClient builds an http.Client that sends traffic through the configured +// network proxy when enabled, falling back to a direct connection otherwise. +// The default transport's timeouts and connection pooling are preserved. +func HTTPClient(useProxy bool, timeout time.Duration) *http.Client { + client := &http.Client{Timeout: timeout} + if p := ProxyFunc(useProxy); p != nil { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = p + client.Transport = transport + } + return client +} + +// DialWithTimeout returns a dial function that connects directly, or tunnels +// through the configured HTTP CONNECT proxy when enabled. Its signature +// matches net.DialTimeout so it can be plugged into gomail's NetDialTimeout +// to send SMTP over the proxy. +func DialWithTimeout(useProxy bool) func(network, addr string, timeout time.Duration) (net.Conn, error) { + u := proxyURL() + return func(network, addr string, timeout time.Duration) (net.Conn, error) { + if !useProxy || u == nil { + return net.DialTimeout(network, addr, timeout) + } + return dialViaProxy(u, addr, timeout) + } +} + +// bufferedConn reads any bytes buffered while parsing the CONNECT response +// before falling back to the underlying connection. Without this, tunneled +// bytes (e.g. an SMTP greeting) that arrived in the same read as the proxy +// response headers would be lost. +type bufferedConn struct { + net.Conn + r *bufio.Reader +} + +func (c *bufferedConn) Read(p []byte) (int, error) { + if c.r != nil { + n, err := c.r.Read(p) + if n > 0 { + return n, err + } + if err != nil { + return 0, err + } + // Buffer exhausted; read directly from the tunnel from now on. + c.r = nil + } + return c.Conn.Read(p) +} + +// dialViaProxy opens a TCP connection to the proxy and issues an HTTP CONNECT +// request to establish a tunnel to the target address. The returned conn is a +// raw bidirectional tunnel to target. +func dialViaProxy(proxy *url.URL, target string, timeout time.Duration) (net.Conn, error) { + conn, err := net.DialTimeout("tcp", proxy.Host, timeout) + if err != nil { + return nil, fmt.Errorf("connect to proxy %s: %w", proxy.Host, err) + } + if timeout > 0 { + conn.SetDeadline(time.Now().Add(timeout)) + } + + // Build the CONNECT request manually; http.Request.Write omits the + // CONNECT authority when the URL has a non-empty Path, so Opaque is used. + req := &http.Request{ + Method: http.MethodConnect, + URL: &url.URL{Opaque: target}, + Host: target, + Header: make(http.Header), + } + if proxy.User != nil { + pw, _ := proxy.User.Password() + creds := proxy.User.Username() + ":" + pw + req.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(creds))) + } + if err := req.Write(conn); err != nil { + conn.Close() + return nil, fmt.Errorf("write CONNECT request: %w", err) + } + + // Read the status line, then headers up to the blank line. The response has + // no body, so this must be parsed manually rather than with + // http.ReadResponse, which would treat tunneled bytes as a response body. + br := bufio.NewReader(conn) + statusLine, err := br.ReadString('\n') + if err != nil { + conn.Close() + return nil, fmt.Errorf("read proxy response: %w", err) + } + parts := strings.SplitN(strings.TrimSpace(statusLine), " ", 3) + if len(parts) < 2 || !strings.HasPrefix(parts[0], "HTTP/") { + conn.Close() + return nil, fmt.Errorf("invalid proxy response: %q", strings.TrimSpace(statusLine)) + } + var statusCode int + if _, err := fmt.Sscanf(parts[1], "%d", &statusCode); err != nil { + conn.Close() + return nil, fmt.Errorf("invalid proxy status code: %q", parts[1]) + } + for { + line, err := br.ReadString('\n') + if err != nil { + conn.Close() + return nil, fmt.Errorf("read proxy response headers: %w", err) + } + if line == "\r\n" || line == "\n" { + break + } + } + + conn.SetDeadline(time.Time{}) + if statusCode != http.StatusOK { + conn.Close() + return nil, fmt.Errorf("proxy CONNECT to %s failed: %s", target, strings.TrimSpace(statusLine)) + } + return &bufferedConn{Conn: conn, r: br}, nil +}