The three configuration singletons (C_globalConfig, C_botUserConfig, C_botNodeConfig) were plain variables: LoadGlobalConfig and friends assigned them from the goroutine handling a settings update, while bot pipes, the node tracker and the HTTP handlers read them from their own goroutines. That is an unsynchronized read of a concurrently written variable — a data race the race detector reports, and one that already existed before any hot-reload work because /api/webhook/add reloads the configuration while the bots run. Replace them with atomic.Pointer values behind Current(), BotUsers() and BotNodes(). Each reload builds a fresh value and publishes it atomically, so a reader either sees the previous configuration or the new one, never a partial one. Callers read through the accessor on every use instead of caching it. Two spots that read several fields of one guard now snapshot once per call, so a reload cannot split a combined check mid-flight: - qq_napcat.handleNapcatEvent, which evaluates the debug guards per event - the komari task-echo guard, now behind taskEchoEnabled() The NapCat HTTP methods keep logging on showNapcatAction alone (without requiring debugMode), matching their existing behaviour; that inconsistency with the WebSocket path is preserved, not introduced, and is called out in actionLogEnabled. Also adds TestConcurrentReloadAndRead, which drives every accessor from four reader goroutines while two writers reload the configuration, and sanitises the member IDs used as test fixtures in config/settings_test.go. Co-Authored-By: Claude Code <noreply@anthropic.com>
175 lines
5.3 KiB
Go
175 lines
5.3 KiB
Go
// 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 {
|
|
cfg := config.Current()
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
raw := cfg.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
|
|
}
|