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.
This commit is contained in:
@@ -5,6 +5,7 @@ type SystemConfig struct {
|
|||||||
DebugMode bool `json:"debugMode"`
|
DebugMode bool `json:"debugMode"`
|
||||||
ListenAddr string `json:"listenAddr"`
|
ListenAddr string `json:"listenAddr"`
|
||||||
ListenPort string `json:"listenPort"`
|
ListenPort string `json:"listenPort"`
|
||||||
|
NetworkProxy string `json:"networkProxy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DebugConfig holds debug-level configuration.
|
// DebugConfig holds debug-level configuration.
|
||||||
@@ -32,6 +33,7 @@ type KomariConfig struct {
|
|||||||
// QQConfig holds QQ (Napcat) Bot controller configuration.
|
// QQConfig holds QQ (Napcat) Bot controller configuration.
|
||||||
type QQConfig struct {
|
type QQConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
NetworkUseProxy bool `json:"networkUseProxy"`
|
||||||
NapcatAddr string `json:"napcatAddr"`
|
NapcatAddr string `json:"napcatAddr"`
|
||||||
NapcatPort string `json:"napcatPort"`
|
NapcatPort string `json:"napcatPort"`
|
||||||
NapcatToken string `json:"napcatToken"`
|
NapcatToken string `json:"napcatToken"`
|
||||||
@@ -44,6 +46,7 @@ type QQConfig struct {
|
|||||||
// TelegramConfig holds Telegram Bot controller configuration.
|
// TelegramConfig holds Telegram Bot controller configuration.
|
||||||
type TelegramConfig struct {
|
type TelegramConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
NetworkUseProxy bool `json:"networkUseProxy"`
|
||||||
BotToken string `json:"botToken"`
|
BotToken string `json:"botToken"`
|
||||||
ListenMethod string `json:"listenMethod"`
|
ListenMethod string `json:"listenMethod"`
|
||||||
Admins []string `json:"admins"`
|
Admins []string `json:"admins"`
|
||||||
@@ -53,6 +56,7 @@ type TelegramConfig struct {
|
|||||||
// EmailConfig holds Email notification controller configuration.
|
// EmailConfig holds Email notification controller configuration.
|
||||||
type EmailConfig struct {
|
type EmailConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
NetworkUseProxy bool `json:"networkUseProxy"`
|
||||||
SMTPHost string `json:"smtpHost"`
|
SMTPHost string `json:"smtpHost"`
|
||||||
SMTPPort int `json:"smtpPort"`
|
SMTPPort int `json:"smtpPort"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
@@ -65,6 +69,7 @@ type EmailConfig struct {
|
|||||||
// NtfyConfig holds Ntfy notification controller configuration.
|
// NtfyConfig holds Ntfy notification controller configuration.
|
||||||
type NtfyConfig struct {
|
type NtfyConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
NetworkUseProxy bool `json:"networkUseProxy"`
|
||||||
Server string `json:"server"`
|
Server string `json:"server"`
|
||||||
Topic string `json:"topic"`
|
Topic string `json:"topic"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
@@ -74,6 +79,7 @@ type NtfyConfig struct {
|
|||||||
// WebhookConfig holds Webhook notification controller configuration.
|
// WebhookConfig holds Webhook notification controller configuration.
|
||||||
type WebhookConfig struct {
|
type WebhookConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
NetworkUseProxy bool `json:"networkUseProxy"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
Headers map[string]string `json:"headers"`
|
Headers map[string]string `json:"headers"`
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
gomail "gopkg.in/mail.v2"
|
gomail "gopkg.in/mail.v2"
|
||||||
|
|
||||||
"nukumizu-backend/config"
|
"nukumizu-backend/config"
|
||||||
|
"nukumizu-backend/internal/netproxy"
|
||||||
"nukumizu-backend/internal/node"
|
"nukumizu-backend/internal/node"
|
||||||
"nukumizu-backend/internal/template"
|
"nukumizu-backend/internal/template"
|
||||||
"nukumizu-backend/postLog"
|
"nukumizu-backend/postLog"
|
||||||
@@ -18,6 +19,13 @@ type EmailController struct {
|
|||||||
|
|
||||||
// NewEmailController creates a new Email controller.
|
// NewEmailController creates a new Email controller.
|
||||||
func NewEmailController(cfg config.EmailConfig) *EmailController {
|
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}
|
return &EmailController{cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"nukumizu-backend/config"
|
"nukumizu-backend/config"
|
||||||
|
"nukumizu-backend/internal/netproxy"
|
||||||
"nukumizu-backend/internal/node"
|
"nukumizu-backend/internal/node"
|
||||||
"nukumizu-backend/internal/template"
|
"nukumizu-backend/internal/template"
|
||||||
"nukumizu-backend/postLog"
|
"nukumizu-backend/postLog"
|
||||||
@@ -22,9 +23,7 @@ type NtfyController struct {
|
|||||||
func NewNtfyController(cfg config.NtfyConfig) *NtfyController {
|
func NewNtfyController(cfg config.NtfyConfig) *NtfyController {
|
||||||
return &NtfyController{
|
return &NtfyController{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
httpClient: &http.Client{
|
httpClient: netproxy.HTTPClient(cfg.NetworkUseProxy, 10*time.Second),
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"nukumizu-backend/postLog"
|
|
||||||
"nukumizu-backend/config"
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"nukumizu-backend/config"
|
||||||
|
"nukumizu-backend/internal/netproxy"
|
||||||
|
"nukumizu-backend/postLog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// APIResponse mirrors NapCat's HTTP API response envelope.
|
// APIResponse mirrors NapCat's HTTP API response envelope.
|
||||||
@@ -30,6 +32,7 @@ type Client struct {
|
|||||||
addr string
|
addr string
|
||||||
port string
|
port string
|
||||||
token string
|
token string
|
||||||
|
useProxy bool
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
|
||||||
connMu sync.Mutex
|
connMu sync.Mutex
|
||||||
@@ -38,15 +41,16 @@ type Client struct {
|
|||||||
stopOnce sync.Once
|
stopOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a NapCat client for the given host/port/token.
|
// NewClient creates a NapCat client for the given host/port/token. When
|
||||||
func NewClient(addr, port, token string) *Client {
|
// 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{
|
return &Client{
|
||||||
addr: addr,
|
addr: addr,
|
||||||
port: port,
|
port: port,
|
||||||
token: token,
|
token: token,
|
||||||
httpClient: &http.Client{
|
useProxy: useProxy,
|
||||||
Timeout: 30 * time.Second,
|
httpClient: netproxy.HTTPClient(useProxy, 30*time.Second),
|
||||||
},
|
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,7 +269,9 @@ func (c *Client) listenOnce(onEvent func(raw []byte)) {
|
|||||||
header.Set("Authorization", "Bearer "+c.token)
|
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 {
|
if err != nil {
|
||||||
postLog.Error(fmt.Sprintf("[Napcat] Failed to connect to NapCat WebSocket: %v", err))
|
postLog.Error(fmt.Sprintf("[Napcat] Failed to connect to NapCat WebSocket: %v", err))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ type QQController struct {
|
|||||||
func NewQQController(cfg config.QQConfig) *QQController {
|
func NewQQController(cfg config.QQConfig) *QQController {
|
||||||
q := &QQController{cfg: cfg}
|
q := &QQController{cfg: cfg}
|
||||||
if cfg.Enabled {
|
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
|
return q
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"nukumizu-backend/config"
|
"nukumizu-backend/config"
|
||||||
|
"nukumizu-backend/internal/netproxy"
|
||||||
"nukumizu-backend/internal/node"
|
"nukumizu-backend/internal/node"
|
||||||
"nukumizu-backend/internal/template"
|
"nukumizu-backend/internal/template"
|
||||||
"nukumizu-backend/postLog"
|
"nukumizu-backend/postLog"
|
||||||
@@ -23,9 +24,7 @@ type WebhookController struct {
|
|||||||
func NewWebhookController(cfg config.WebhookConfig) *WebhookController {
|
func NewWebhookController(cfg config.WebhookConfig) *WebhookController {
|
||||||
return &WebhookController{
|
return &WebhookController{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
httpClient: &http.Client{
|
httpClient: netproxy.HTTPClient(cfg.NetworkUseProxy, 10*time.Second),
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user