Files
MyGoNavi/internal/app/global_proxy.go
Syngnat b94168957e 🐛 fix(update/proxy): 稳定更新检查与代理运行时
- 更新检查前恢复持久化全局代理,避免重启后代理状态未进入运行时

- 将 DNS、EOF 和临时网络异常转为本地化错误,并对可重试网络错误做短重试

- 优先读取 GitHub asset digest,缺失时再回退 SHA256SUMS 校验文件

- 新增全局代理连接测试后端接口,支持草稿代理、已保存密码复用和清除密码意图

- 更新 Wails 绑定、浏览器 mock 与代理草稿序列化测试
2026-07-08 17:36:42 +08:00

538 lines
14 KiB
Go

package app
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/logger"
proxytunnel "GoNavi-Wails/internal/proxy"
)
type globalProxySnapshot struct {
Enabled bool `json:"enabled"`
Proxy connection.ProxyConfig `json:"proxy"`
}
var globalProxyRuntime = struct {
mu sync.RWMutex
enabled bool
proxy connection.ProxyConfig
}{}
type localProxyTLSFallbackTransport struct {
primary *http.Transport
fallback *http.Transport
proxyEndpoint string
}
func currentGlobalProxyConfig() globalProxySnapshot {
globalProxyRuntime.mu.RLock()
defer globalProxyRuntime.mu.RUnlock()
if !globalProxyRuntime.enabled {
return globalProxySnapshot{
Enabled: false,
Proxy: connection.ProxyConfig{},
}
}
return globalProxySnapshot{
Enabled: true,
Proxy: globalProxyRuntime.proxy,
}
}
func setGlobalProxyConfig(enabled bool, proxyConfig connection.ProxyConfig) (globalProxySnapshot, error) {
if !enabled {
globalProxyRuntime.mu.Lock()
globalProxyRuntime.enabled = false
globalProxyRuntime.proxy = connection.ProxyConfig{}
globalProxyRuntime.mu.Unlock()
return currentGlobalProxyConfig(), nil
}
normalizedProxy, err := proxytunnel.NormalizeConfig(proxyConfig)
if err != nil {
return globalProxySnapshot{}, err
}
globalProxyRuntime.mu.Lock()
globalProxyRuntime.enabled = true
globalProxyRuntime.proxy = normalizedProxy
globalProxyRuntime.mu.Unlock()
return currentGlobalProxyConfig(), nil
}
func (a *App) ConfigureGlobalProxy(enabled bool, proxyConfig connection.ProxyConfig) connection.QueryResult {
before := currentGlobalProxyConfig()
snapshot, err := setGlobalProxyConfig(enabled, proxyConfig)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
// 前端可能在同一配置下重复触发同步(例如严格模式或状态回放),
// 这里做幂等日志,避免重复刷屏。
if !globalProxySnapshotEqual(before, snapshot) {
if snapshot.Enabled {
authState := ""
if strings.TrimSpace(snapshot.Proxy.User) != "" {
authState = "(认证:已配置)"
}
logger.Infof(
"全局代理已启用:%s://%s:%d%s",
strings.ToLower(strings.TrimSpace(snapshot.Proxy.Type)),
strings.TrimSpace(snapshot.Proxy.Host),
snapshot.Proxy.Port,
authState,
)
} else {
logger.Infof("全局代理已关闭")
}
}
return connection.QueryResult{
Success: true,
Message: a.appText("app.proxy.message.config_applied", nil),
Data: snapshot,
}
}
func globalProxySnapshotEqual(a, b globalProxySnapshot) bool {
if a.Enabled != b.Enabled {
return false
}
if !a.Enabled {
return true
}
return proxyConfigEqual(a.Proxy, b.Proxy)
}
func proxyConfigEqual(a, b connection.ProxyConfig) bool {
return strings.EqualFold(strings.TrimSpace(a.Type), strings.TrimSpace(b.Type)) &&
strings.TrimSpace(a.Host) == strings.TrimSpace(b.Host) &&
a.Port == b.Port &&
strings.TrimSpace(a.User) == strings.TrimSpace(b.User) &&
a.Password == b.Password
}
func currentGlobalProxyView() connection.GlobalProxyView {
snapshot := currentGlobalProxyConfig()
if !snapshot.Enabled {
return connection.GlobalProxyView{Enabled: false}
}
return connection.GlobalProxyView{
Enabled: true,
Type: snapshot.Proxy.Type,
Host: snapshot.Proxy.Host,
Port: snapshot.Proxy.Port,
User: snapshot.Proxy.User,
HasPassword: strings.TrimSpace(snapshot.Proxy.Password) != "",
}
}
func (a *App) GetGlobalProxyConfig() connection.QueryResult {
if strings.TrimSpace(a.configDir) == "" {
a.configDir = resolveAppConfigDir()
}
if view, err := a.loadStoredGlobalProxyView(); err == nil {
return connection.QueryResult{
Success: true,
Message: "OK",
Data: sanitizeGlobalProxyView(view),
}
} else if !os.IsNotExist(err) {
logger.Error(err, "加载全局代理元数据失败")
}
return connection.QueryResult{
Success: true,
Message: "OK",
Data: currentGlobalProxyView(),
}
}
func applyGlobalProxyToConnection(config connection.ConnectionConfig) connection.ConnectionConfig {
effective := config
if effective.UseProxy || effective.UseHTTPTunnel {
return effective
}
if isFileDatabaseType(effective.Type) {
effective.Proxy = connection.ProxyConfig{}
return effective
}
snapshot := currentGlobalProxyConfig()
if !snapshot.Enabled {
effective.Proxy = connection.ProxyConfig{}
return effective
}
effective.UseProxy = true
effective.Proxy = snapshot.Proxy
return effective
}
func isFileDatabaseType(driverType string) bool {
switch strings.ToLower(strings.TrimSpace(driverType)) {
case "sqlite", "duckdb":
return true
default:
return false
}
}
func newHTTPClientWithGlobalProxy(timeout time.Duration) *http.Client {
client := &http.Client{
Timeout: timeout,
}
if transport := buildHTTPTransportWithGlobalProxy(); transport != nil {
client.Transport = transport
}
return client
}
func (a *App) ensurePersistedGlobalProxyRuntime() {
if currentGlobalProxyConfig().Enabled {
return
}
if strings.TrimSpace(a.configDir) == "" {
a.configDir = resolveAppConfigDir()
}
view, err := a.loadStoredGlobalProxyView()
if err != nil {
if !os.IsNotExist(err) {
logger.Error(err, "加载全局代理元数据失败")
}
return
}
if !view.Enabled {
return
}
proxyConfig, err := a.resolveStoredGlobalProxyRuntimeConfig(view)
if err != nil {
logger.Error(err, "恢复全局代理运行时配置失败")
return
}
if _, err := setGlobalProxyConfig(true, proxyConfig); err != nil {
logger.Error(err, "恢复全局代理运行时配置失败")
}
}
func buildHTTPTransportWithGlobalProxy() http.RoundTripper {
baseTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok || baseTransport == nil {
return nil
}
transport := baseTransport.Clone()
snapshot := currentGlobalProxyConfig()
if !snapshot.Enabled {
transport.Proxy = http.ProxyFromEnvironment
return transport
}
transportWithProxy, err := buildHTTPTransportForProxyConfig(snapshot.Proxy)
if err != nil {
logger.Warnf("全局代理配置无效,回退系统代理:%v", err)
transport.Proxy = http.ProxyFromEnvironment
return transport
}
return transportWithProxy
}
func buildHTTPTransportForProxyConfig(proxyConfig connection.ProxyConfig) (http.RoundTripper, error) {
baseTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok || baseTransport == nil {
return nil, fmt.Errorf("default HTTP transport unavailable")
}
transport := baseTransport.Clone()
proxyURL, err := buildProxyURLFromConfig(proxyConfig)
if err != nil {
return nil, err
}
transport.Proxy = http.ProxyURL(proxyURL)
if !isLoopbackProxyHost(proxyConfig.Host) {
return transport, nil
}
fallbackTransport := transport.Clone()
fallbackTransport.TLSClientConfig = cloneTLSConfigWithInsecureSkipVerify(fallbackTransport.TLSClientConfig)
return &localProxyTLSFallbackTransport{
primary: transport,
fallback: fallbackTransport,
proxyEndpoint: proxyURL.Redacted(),
}, nil
}
func (t *localProxyTLSFallbackTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := t.primary.RoundTrip(req)
if err == nil {
return resp, nil
}
if !isTLSFallbackCandidate(req.Method, err) {
return nil, err
}
retryReq, cloneErr := cloneRequestForRetry(req)
if cloneErr != nil {
return nil, err
}
logger.Warnf("检测到本地代理 TLS 证书不受信任,启用兼容回退:代理=%s 目标=%s 错误=%v", t.proxyEndpoint, req.URL.String(), err)
return t.fallback.RoundTrip(retryReq)
}
func isTLSFallbackCandidate(method string, err error) bool {
if !isIdempotentRequestMethod(method) {
return false
}
return isUnknownAuthorityError(err)
}
func isIdempotentRequestMethod(method string) bool {
switch strings.ToUpper(strings.TrimSpace(method)) {
case http.MethodGet, http.MethodHead:
return true
default:
return false
}
}
func cloneRequestForRetry(req *http.Request) (*http.Request, error) {
cloned := req.Clone(req.Context())
if req.Body == nil || req.Body == http.NoBody {
return cloned, nil
}
if req.GetBody == nil {
return nil, fmt.Errorf("request body not replayable")
}
body, err := req.GetBody()
if err != nil {
return nil, err
}
cloned.Body = body
return cloned, nil
}
func isUnknownAuthorityError(err error) bool {
var unknownErr x509.UnknownAuthorityError
if errors.As(err, &unknownErr) {
return true
}
return strings.Contains(strings.ToLower(err.Error()), "x509: certificate signed by unknown authority")
}
func cloneTLSConfigWithInsecureSkipVerify(base *tls.Config) *tls.Config {
if base == nil {
return &tls.Config{InsecureSkipVerify: true}
}
cloned := base.Clone()
cloned.InsecureSkipVerify = true
return cloned
}
func isLoopbackProxyHost(host string) bool {
trimmed := strings.TrimSpace(host)
if trimmed == "" {
return false
}
if strings.EqualFold(trimmed, "localhost") {
return true
}
ip := net.ParseIP(trimmed)
if ip == nil {
return false
}
return ip.IsLoopback()
}
func buildProxyURLFromConfig(proxyConfig connection.ProxyConfig) (*url.URL, error) {
normalizedProxy, err := proxytunnel.NormalizeConfig(proxyConfig)
if err != nil {
return nil, err
}
proxyType := strings.ToLower(strings.TrimSpace(normalizedProxy.Type))
if proxyType != "http" && proxyType != "socks5" {
return nil, fmt.Errorf("unsupported proxy type: %s", normalizedProxy.Type)
}
if strings.TrimSpace(normalizedProxy.Host) == "" {
return nil, fmt.Errorf("proxy host is empty")
}
if normalizedProxy.Port <= 0 || normalizedProxy.Port > 65535 {
return nil, fmt.Errorf("invalid proxy port: %d", normalizedProxy.Port)
}
proxyURL := &url.URL{
Scheme: proxyType,
Host: net.JoinHostPort(strings.TrimSpace(normalizedProxy.Host), strconv.Itoa(normalizedProxy.Port)),
}
if strings.TrimSpace(normalizedProxy.User) != "" {
proxyURL.User = url.UserPassword(strings.TrimSpace(normalizedProxy.User), normalizedProxy.Password)
}
return proxyURL, nil
}
func (a *App) TestGlobalProxyConnection(input connection.TestGlobalProxyInput) connection.QueryResult {
started := time.Now()
targetURL, err := normalizeGlobalProxyTestURL(input.URL)
if err != nil {
return connection.QueryResult{Success: false, Message: a.localizedGlobalProxyTestError(err)}
}
timeoutSeconds := input.TimeoutSeconds
if timeoutSeconds <= 0 {
timeoutSeconds = 8
}
if timeoutSeconds > 30 {
timeoutSeconds = 30
}
client := &http.Client{Timeout: time.Duration(timeoutSeconds) * time.Second}
if input.Proxy.Enabled {
proxyPassword, err := a.resolveGlobalProxyPasswordForInput(input.Proxy)
if err != nil {
return connection.QueryResult{
Success: false,
Message: a.appText("app.proxy.backend.message.test_failed", map[string]any{
"url": targetURL,
"detail": err.Error(),
}),
}
}
transport, err := buildHTTPTransportForProxyConfig(connection.ProxyConfig{
Type: input.Proxy.Type,
Host: input.Proxy.Host,
Port: input.Proxy.Port,
User: input.Proxy.User,
Password: proxyPassword,
})
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
client.Transport = transport
}
req, err := http.NewRequest(http.MethodGet, targetURL, nil)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
req.Header.Set("User-Agent", "GoNavi-Proxy-Test")
req.Header.Set("Accept", "*/*")
req.Header.Set("Range", "bytes=0-0")
resp, err := client.Do(req)
durationMs := time.Since(started).Milliseconds()
if err != nil {
return connection.QueryResult{
Success: false,
Message: a.appText("app.proxy.backend.message.test_failed", map[string]any{
"url": targetURL,
"detail": err.Error(),
}),
Data: connection.GlobalProxyTestResult{
URL: targetURL,
DurationMs: durationMs,
ViaProxy: input.Proxy.Enabled,
},
}
}
defer resp.Body.Close()
_, _ = io.CopyN(io.Discard, resp.Body, 512)
result := connection.GlobalProxyTestResult{
URL: targetURL,
FinalURL: resp.Request.URL.String(),
StatusCode: resp.StatusCode,
Status: resp.Status,
DurationMs: durationMs,
ViaProxy: input.Proxy.Enabled,
}
messageKey := "app.proxy.backend.message.test_success"
if resp.StatusCode >= http.StatusBadRequest {
messageKey = "app.proxy.backend.message.test_http_status"
}
return connection.QueryResult{
Success: true,
Message: a.appText(messageKey, map[string]any{
"status": resp.StatusCode,
"duration": durationMs,
"url": targetURL,
}),
Data: result,
}
}
func (a *App) resolveGlobalProxyPasswordForInput(input connection.SaveGlobalProxyInput) (string, error) {
if strings.TrimSpace(input.Password) != "" {
return input.Password, nil
}
if input.ClearPassword {
return "", nil
}
if strings.TrimSpace(a.configDir) == "" {
a.configDir = resolveAppConfigDir()
}
existing, err := a.loadStoredGlobalProxyView()
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", err
}
if !existing.HasPassword {
return "", nil
}
bundle, err := a.loadGlobalProxySecretBundle(existing)
if err != nil {
return "", err
}
return bundle.Password, nil
}
func normalizeGlobalProxyTestURL(rawURL string) (string, error) {
trimmed := strings.TrimSpace(rawURL)
if trimmed == "" {
return "", localizedUpdateError{key: "app.proxy.backend.error.test_url_empty"}
}
if !strings.Contains(trimmed, "://") {
trimmed = "https://" + trimmed
}
parsed, err := url.Parse(trimmed)
if err != nil {
return "", localizedUpdateError{
key: "app.proxy.backend.error.test_url_invalid",
params: map[string]any{"detail": err.Error()},
}
}
switch strings.ToLower(parsed.Scheme) {
case "http", "https":
default:
return "", localizedUpdateError{
key: "app.proxy.backend.error.test_scheme_unsupported",
params: map[string]any{"scheme": parsed.Scheme},
}
}
if strings.TrimSpace(parsed.Host) == "" {
return "", localizedUpdateError{key: "app.proxy.backend.error.test_host_missing"}
}
return parsed.String(), nil
}
func (a *App) localizedGlobalProxyTestError(err error) string {
var localized localizedUpdateError
if errors.As(err, &localized) {
return a.appText(localized.key, localized.params)
}
return err.Error()
}