From b94168957e574e91ce636930b7e110677254ebd4 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 8 Jul 2026 17:36:42 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(update/proxy):=20=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E6=9B=B4=E6=96=B0=E6=A3=80=E6=9F=A5=E4=B8=8E=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E8=BF=90=E8=A1=8C=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新检查前恢复持久化全局代理,避免重启后代理状态未进入运行时 - 将 DNS、EOF 和临时网络异常转为本地化错误,并对可重试网络错误做短重试 - 优先读取 GitHub asset digest,缺失时再回退 SHA256SUMS 校验文件 - 新增全局代理连接测试后端接口,支持草稿代理、已保存密码复用和清除密码意图 - 更新 Wails 绑定、浏览器 mock 与代理草稿序列化测试 --- frontend/src/main.tsx | 19 +- frontend/src/utils/globalProxyDraft.test.ts | 16 +- frontend/src/utils/globalProxyDraft.ts | 7 +- frontend/wailsjs/go/app/App.d.ts | 2 + frontend/wailsjs/go/app/App.js | 4 + frontend/wailsjs/go/models.ts | 37 +++- internal/app/global_proxy.go | 215 +++++++++++++++++++- internal/app/global_proxy_persistence.go | 67 +++--- internal/app/global_proxy_secret_test.go | 202 ++++++++++++++++++ internal/app/methods_update.go | 110 +++++++++- internal/app/methods_update_test.go | 132 +++++++++++- internal/connection/saved_types.go | 28 ++- 12 files changed, 783 insertions(+), 56 deletions(-) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b07e0235..8292a9b0 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -212,11 +212,13 @@ if ( const saveMockGlobalProxy = (input: any) => { const nextPassword = String(input?.password ?? ''); + const clearPassword = input?.clearPassword === true; mockGlobalProxy = { ...mockGlobalProxy, ...input, password: '', - hasPassword: nextPassword !== '' ? true : !!mockGlobalProxy.hasPassword, + hasPassword: clearPassword ? false : (nextPassword !== '' ? true : !!mockGlobalProxy.hasPassword), + clearPassword: undefined, }; return cloneBrowserMockValue(mockGlobalProxy); }; @@ -432,6 +434,21 @@ if ( }, SaveGlobalProxy: async (input: any) => saveMockGlobalProxy(input), ImportLegacyGlobalProxy: async (input: any) => saveMockGlobalProxy(input), + TestGlobalProxyConnection: async (input: any) => { + const url = String(input?.url || 'https://api.github.com/').trim(); + return { + success: true, + message: t('app.proxy.backend.message.test_success', { status: 200, duration: 18, url }), + data: { + url, + finalUrl: url, + statusCode: 200, + status: '200 OK', + durationMs: 18, + viaProxy: input?.proxy?.enabled === true, + }, + }; + }, SelectDataRootDirectory: async (currentPath: string) => ({ success: true, data: { ...mockDataRootInfo, path: currentPath || mockDataRootInfo.path } }), ApplyDataRootDirectory: async (path: string) => { const nextPath = String(path || mockDataRootInfo.defaultPath); diff --git a/frontend/src/utils/globalProxyDraft.test.ts b/frontend/src/utils/globalProxyDraft.test.ts index 7354ca77..bf056fde 100644 --- a/frontend/src/utils/globalProxyDraft.test.ts +++ b/frontend/src/utils/globalProxyDraft.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { createGlobalProxyDraft, toPersistedGlobalProxy } from './globalProxyDraft'; +import { createGlobalProxyDraft, toPersistedGlobalProxy, toSaveGlobalProxyInput } from './globalProxyDraft'; describe('global proxy draft', () => { it('hydrates a secretless draft from backend metadata while keeping password input blank', () => { @@ -32,4 +32,18 @@ describe('global proxy draft', () => { expect('password' in persisted).toBe(false); expect(persisted.hasPassword).toBe(true); }); + + it('passes explicit saved password clearing intent to the backend save payload', () => { + const input = toSaveGlobalProxyInput({ + enabled: true, + type: 'socks5', + host: '127.0.0.1', + port: 7890, + hasPassword: true, + clearPassword: true, + }); + + expect(input.password).toBe(''); + expect(input.clearPassword).toBe(true); + }); }); diff --git a/frontend/src/utils/globalProxyDraft.ts b/frontend/src/utils/globalProxyDraft.ts index 408635ce..34dc28d5 100644 --- a/frontend/src/utils/globalProxyDraft.ts +++ b/frontend/src/utils/globalProxyDraft.ts @@ -1,5 +1,9 @@ import { GlobalProxyConfig } from '../types'; +type SaveGlobalProxyDraft = Partial & { + clearPassword?: boolean; +}; + const toTrimmedString = (value: unknown): string => { if (typeof value === 'string') { return value.trim(); @@ -53,10 +57,11 @@ export function toPersistedGlobalProxy(value: Partial = {}): }; } -export function toSaveGlobalProxyInput(value: Partial = {}): GlobalProxyConfig { +export function toSaveGlobalProxyInput(value: SaveGlobalProxyDraft = {}): GlobalProxyConfig & { clearPassword?: boolean } { const draft = createGlobalProxyDraft(value); return { ...draft, password: typeof value.password === 'string' ? value.password : '', + clearPassword: value.clearPassword === true || undefined, }; } diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index 7bd17344..44f0a6a3 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -372,6 +372,8 @@ export function StartSecurityUpdate(arg1:app.StartSecurityUpdateRequest):Promise export function TestConnection(arg1:connection.ConnectionConfig):Promise; +export function TestGlobalProxyConnection(arg1:connection.TestGlobalProxyInput):Promise; + export function TestJVMConnection(arg1:connection.ConnectionConfig):Promise; export function TruncateTables(arg1:connection.ConnectionConfig,arg2:string,arg3:Array):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 49d7e794..d3af08d1 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -734,6 +734,10 @@ export function TestConnection(arg1) { return window['go']['app']['App']['TestConnection'](arg1); } +export function TestGlobalProxyConnection(arg1) { + return window['go']['app']['App']['TestGlobalProxyConnection'](arg1); +} + export function TestJVMConnection(arg1) { return window['go']['app']['App']['TestJVMConnection'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index c8918d7d..7ecb7afd 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1161,6 +1161,7 @@ export namespace connection { port: number; user?: string; password?: string; + clearPassword?: boolean; static createFrom(source: any = {}) { return new SaveGlobalProxyInput(source); @@ -1174,6 +1175,7 @@ export namespace connection { this.port = source["port"]; this.user = source["user"]; this.password = source["password"]; + this.clearPassword = source["clearPassword"]; } } export class SavedConnectionInput { @@ -1360,6 +1362,40 @@ export namespace connection { return a; } } + export class TestGlobalProxyInput { + proxy: SaveGlobalProxyInput; + url: string; + timeoutSeconds?: number; + + static createFrom(source: any = {}) { + return new TestGlobalProxyInput(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.proxy = this.convertValues(source["proxy"], SaveGlobalProxyInput); + this.url = source["url"]; + this.timeoutSeconds = source["timeoutSeconds"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } } @@ -1555,4 +1591,3 @@ export namespace sync { } } - diff --git a/internal/app/global_proxy.go b/internal/app/global_proxy.go index 875ef163..936c5448 100644 --- a/internal/app/global_proxy.go +++ b/internal/app/global_proxy.go @@ -5,9 +5,11 @@ import ( "crypto/x509" "errors" "fmt" + "io" "net" "net/http" "net/url" + "os" "strconv" "strings" "sync" @@ -139,6 +141,18 @@ func currentGlobalProxyView() connection.GlobalProxyView { } 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", @@ -186,6 +200,33 @@ func newHTTPClientWithGlobalProxy(timeout time.Duration) *http.Client { 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 { @@ -199,16 +240,29 @@ func buildHTTPTransportWithGlobalProxy() http.RoundTripper { return transport } - proxyURL, err := buildProxyURLFromConfig(snapshot.Proxy) + 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(snapshot.Proxy.Host) { - return transport + if !isLoopbackProxyHost(proxyConfig.Host) { + return transport, nil } fallbackTransport := transport.Clone() @@ -217,7 +271,7 @@ func buildHTTPTransportWithGlobalProxy() http.RoundTripper { primary: transport, fallback: fallbackTransport, proxyEndpoint: proxyURL.Redacted(), - } + }, nil } func (t *localProxyTLSFallbackTransport) RoundTrip(req *http.Request) (*http.Response, error) { @@ -328,3 +382,156 @@ func buildProxyURLFromConfig(proxyConfig connection.ProxyConfig) (*url.URL, erro 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() +} diff --git a/internal/app/global_proxy_persistence.go b/internal/app/global_proxy_persistence.go index 963644fe..72f3393c 100644 --- a/internal/app/global_proxy_persistence.go +++ b/internal/app/global_proxy_persistence.go @@ -47,7 +47,7 @@ func (a *App) saveGlobalProxy(input connection.SaveGlobalProxyInput) (connection bundle := globalProxySecretBundle{} if strings.TrimSpace(input.Password) != "" { bundle.Password = input.Password - } else if existing.HasPassword { + } else if existing.HasPassword && !input.ClearPassword { existingBundle, loadErr := a.loadGlobalProxySecretBundle(existing) if loadErr != nil { return connection.GlobalProxyView{}, loadErr @@ -55,20 +55,6 @@ func (a *App) saveGlobalProxy(input connection.SaveGlobalProxyInput) (connection bundle = existingBundle } - if !view.Enabled { - if deleteErr := a.dailySecretStore().DeleteGlobalProxy(); deleteErr != nil { - return connection.GlobalProxyView{}, deleteErr - } - view = connection.GlobalProxyView{Enabled: false} - if err := a.persistGlobalProxyView(view); err != nil { - return connection.GlobalProxyView{}, err - } - if _, err := setGlobalProxyConfig(false, connection.ProxyConfig{}); err != nil { - return connection.GlobalProxyView{}, err - } - return view, nil - } - if strings.TrimSpace(bundle.Password) != "" { if storeErr := a.dailySecretStore().PutGlobalProxy(toDailyGlobalProxyBundle(bundle)); storeErr != nil { return connection.GlobalProxyView{}, storeErr @@ -86,6 +72,12 @@ func (a *App) saveGlobalProxy(input connection.SaveGlobalProxyInput) (connection if err := a.persistGlobalProxyView(view); err != nil { return connection.GlobalProxyView{}, err } + if !view.Enabled { + if _, err := setGlobalProxyConfig(false, connection.ProxyConfig{}); err != nil { + return connection.GlobalProxyView{}, err + } + return sanitizeGlobalProxyView(view), nil + } if _, err := setGlobalProxyConfig(true, connection.ProxyConfig{ Type: view.Type, Host: view.Host, @@ -139,6 +131,28 @@ func (a *App) loadGlobalProxySecretBundle(view connection.GlobalProxyView) (glob return globalProxySecretBundle{}, os.ErrNotExist } +func (a *App) resolveStoredGlobalProxyRuntimeConfig(view connection.GlobalProxyView) (connection.ProxyConfig, error) { + proxyConfig := connection.ProxyConfig{ + Type: view.Type, + Host: view.Host, + Port: view.Port, + User: view.User, + } + if !view.HasPassword { + return proxyConfig, nil + } + bundle, err := a.loadGlobalProxySecretBundle(view) + if err != nil { + if os.IsNotExist(err) { + logger.Warnf("全局代理标记了已保存密码,但密文不存在,将尝试按无认证代理恢复") + return proxyConfig, nil + } + return connection.ProxyConfig{}, err + } + proxyConfig.Password = bundle.Password + return proxyConfig, nil +} + func (a *App) loadGlobalProxySecretBundleFromStore(view connection.GlobalProxyView) (globalProxySecretBundle, error) { if a.secretStore == nil { return globalProxySecretBundle{}, fmt.Errorf("secret store unavailable") @@ -195,20 +209,17 @@ func (a *App) loadPersistedGlobalProxy() { } return } - - proxyConfig := connection.ProxyConfig{ - Type: view.Type, - Host: view.Host, - Port: view.Port, - User: view.User, - } - if view.HasPassword { - bundle, loadErr := a.loadGlobalProxySecretBundle(view) - if loadErr != nil { - logger.Error(loadErr, "加载全局代理密码失败") - return + if !view.Enabled { + if _, err := setGlobalProxyConfig(false, connection.ProxyConfig{}); err != nil { + logger.Error(err, "恢复全局代理关闭状态失败") } - proxyConfig.Password = bundle.Password + return + } + + proxyConfig, err := a.resolveStoredGlobalProxyRuntimeConfig(view) + if err != nil { + logger.Error(err, "加载全局代理密码失败") + return } if _, err := setGlobalProxyConfig(view.Enabled, proxyConfig); err != nil { logger.Error(err, "恢复全局代理配置失败") diff --git a/internal/app/global_proxy_secret_test.go b/internal/app/global_proxy_secret_test.go index 6615cd03..c114907e 100644 --- a/internal/app/global_proxy_secret_test.go +++ b/internal/app/global_proxy_secret_test.go @@ -1,7 +1,13 @@ package app import ( + "encoding/base64" + "net" + "net/http" + "net/http/httptest" + "net/url" "os" + "strconv" "strings" "testing" @@ -67,6 +73,202 @@ func TestGetGlobalProxyConfigReturnsSecretlessView(t *testing.T) { } } +func TestSaveGlobalProxyClearPasswordRemovesStoredSecret(t *testing.T) { + store := newFakeAppSecretStore() + app := NewAppWithSecretStore(store) + app.configDir = t.TempDir() + + if _, err := app.saveGlobalProxy(connection.SaveGlobalProxyInput{ + Enabled: true, + Type: "http", + Host: "127.0.0.1", + Port: 8080, + User: "ops", + Password: "proxy-secret", + }); err != nil { + t.Fatalf("saveGlobalProxy with password returned error: %v", err) + } + + view, err := app.saveGlobalProxy(connection.SaveGlobalProxyInput{ + Enabled: true, + Type: "http", + Host: "127.0.0.1", + Port: 8080, + User: "ops", + ClearPassword: true, + }) + if err != nil { + t.Fatalf("saveGlobalProxy clear password returned error: %v", err) + } + if view.HasPassword { + t.Fatal("expected global proxy password marker to be cleared") + } + if secret, ok, err := app.dailySecretStore().GetGlobalProxy(); err != nil { + t.Fatalf("GetGlobalProxy returned error: %v", err) + } else if ok || secret.Password != "" { + t.Fatalf("expected global proxy secret to be deleted, got %#v ok=%v", secret, ok) + } + snapshot := currentGlobalProxyConfig() + if snapshot.Proxy.Password != "" { + t.Fatalf("expected runtime proxy password to be cleared, got %q", snapshot.Proxy.Password) + } +} + +func TestSaveGlobalProxyDisabledKeepsDraftAndStoredPassword(t *testing.T) { + store := newFakeAppSecretStore() + app := NewAppWithSecretStore(store) + app.configDir = t.TempDir() + + if _, err := app.saveGlobalProxy(connection.SaveGlobalProxyInput{ + Enabled: true, + Type: "http", + Host: "127.0.0.1", + Port: 8080, + User: "ops", + Password: "proxy-secret", + }); err != nil { + t.Fatalf("saveGlobalProxy with password returned error: %v", err) + } + + view, err := app.saveGlobalProxy(connection.SaveGlobalProxyInput{ + Enabled: false, + Type: "http", + Host: "127.0.0.1", + Port: 8080, + User: "ops", + }) + if err != nil { + t.Fatalf("saveGlobalProxy disabled draft returned error: %v", err) + } + if view.Enabled { + t.Fatal("expected saved proxy view to stay disabled") + } + if view.Host != "127.0.0.1" || view.Port != 8080 || view.User != "ops" { + t.Fatalf("expected disabled draft fields to be retained, got %#v", view) + } + if !view.HasPassword { + t.Fatal("expected disabled draft to keep saved password marker") + } + if snapshot := currentGlobalProxyConfig(); snapshot.Enabled { + t.Fatalf("expected runtime global proxy to be disabled, got %#v", snapshot) + } + if secret, ok, err := app.dailySecretStore().GetGlobalProxy(); err != nil { + t.Fatalf("GetGlobalProxy returned error: %v", err) + } else if !ok || secret.Password != "proxy-secret" { + t.Fatalf("expected saved proxy password to be retained, got %#v ok=%v", secret, ok) + } + + result := app.GetGlobalProxyConfig() + stored, ok := result.Data.(connection.GlobalProxyView) + if !ok { + t.Fatalf("expected GlobalProxyView, got %T", result.Data) + } + if stored.Enabled || stored.Host != "127.0.0.1" || !stored.HasPassword { + t.Fatalf("expected GetGlobalProxyConfig to return disabled persisted draft, got %#v", stored) + } +} + +func TestTestGlobalProxyConnectionUsesDraftHTTPProxy(t *testing.T) { + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + + proxyCalled := false + proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxyCalled = true + if !r.URL.IsAbs() { + t.Fatalf("expected proxy request URL to be absolute, got %q", r.URL.String()) + } + if r.URL.String() != "http://example.com/probe" { + t.Fatalf("unexpected target URL through proxy: %s", r.URL.String()) + } + w.WriteHeader(http.StatusNoContent) + })) + defer proxyServer.Close() + + host, port := parseTestServerHostPort(t, proxyServer.URL) + result := app.TestGlobalProxyConnection(connection.TestGlobalProxyInput{ + Proxy: connection.SaveGlobalProxyInput{ + Enabled: true, + Type: "http", + Host: host, + Port: port, + }, + URL: "http://example.com/probe", + TimeoutSeconds: 2, + }) + if !result.Success { + t.Fatalf("expected proxy test success, got %#v", result) + } + if !proxyCalled { + t.Fatal("expected draft proxy to receive the test request") + } + data, ok := result.Data.(connection.GlobalProxyTestResult) + if !ok { + t.Fatalf("expected GlobalProxyTestResult, got %T", result.Data) + } + if data.StatusCode != http.StatusNoContent || !data.ViaProxy { + t.Fatalf("unexpected proxy test data: %#v", data) + } +} + +func TestTestGlobalProxyConnectionReusesSavedPasswordWhenDraftPasswordBlank(t *testing.T) { + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + + if _, err := app.saveGlobalProxy(connection.SaveGlobalProxyInput{ + Enabled: true, + Type: "http", + Host: "127.0.0.1", + Port: 8080, + User: "ops", + Password: "proxy-secret", + }); err != nil { + t.Fatalf("saveGlobalProxy returned error: %v", err) + } + + wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("ops:proxy-secret")) + proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Proxy-Authorization"); got != wantAuth { + t.Fatalf("expected saved proxy password auth %q, got %q", wantAuth, got) + } + w.WriteHeader(http.StatusOK) + })) + defer proxyServer.Close() + + host, port := parseTestServerHostPort(t, proxyServer.URL) + result := app.TestGlobalProxyConnection(connection.TestGlobalProxyInput{ + Proxy: connection.SaveGlobalProxyInput{ + Enabled: true, + Type: "http", + Host: host, + Port: port, + User: "ops", + }, + URL: "http://example.com/probe", + TimeoutSeconds: 2, + }) + if !result.Success { + t.Fatalf("expected proxy test to reuse saved password, got %#v", result) + } +} + +func parseTestServerHostPort(t *testing.T, rawURL string) (string, int) { + t.Helper() + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("url.Parse returned error: %v", err) + } + host, portText, err := net.SplitHostPort(parsed.Host) + if err != nil { + t.Fatalf("SplitHostPort returned error: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("Atoi returned error: %v", err) + } + return host, port +} + func TestLoadPersistedGlobalProxyOnDarwinUsesInlinePassword(t *testing.T) { if _, err := setGlobalProxyConfig(false, connection.ProxyConfig{}); err != nil { t.Fatalf("setGlobalProxyConfig returned error: %v", err) diff --git a/internal/app/methods_update.go b/internal/app/methods_update.go index 6b5e9bb3..c2c2668d 100644 --- a/internal/app/methods_update.go +++ b/internal/app/methods_update.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "math" + "net" "net/http" urlpkg "net/url" "os" @@ -31,6 +32,7 @@ const ( updateDevAPIURL = "https://api.github.com/repos/" + updateRepo + "/releases/tags/" + updateDevReleaseTag updateChecksumAsset = "SHA256SUMS" updateDownloadProgressEvent = "update:download-progress" + updateNetworkRetryDelay = 250 * time.Millisecond ) var ( @@ -150,6 +152,7 @@ func (a *App) CheckForUpdatesSilently() connection.QueryResult { } func (a *App) checkForUpdates(logFailure bool) connection.QueryResult { + a.ensurePersistedGlobalProxyRuntime() channel := a.currentUpdateChannel() info, err := fetchLatestUpdateInfo(channel) if err != nil { @@ -477,11 +480,14 @@ func fetchLatestUpdateInfo(channel updateChannel) (UpdateInfo, error) { return UpdateInfo{}, err } - hashMap, err := updateFetchReleaseSHA256(release.Assets) - if err != nil { - return UpdateInfo{}, err + sha256Value := normalizeGitHubAssetSHA256(asset.Digest) + if sha256Value == "" { + hashMap, err := updateFetchReleaseSHA256(release.Assets) + if err != nil { + return UpdateInfo{}, err + } + sha256Value = strings.TrimSpace(hashMap[assetName]) } - sha256Value := strings.TrimSpace(hashMap[assetName]) if sha256Value == "" { return UpdateInfo{}, localizedUpdateError{key: "app.update.backend.error.sha256_missing_current_package"} } @@ -566,7 +572,7 @@ func fetchReleaseByURL(apiURL string) (*githubRelease, error) { req.Header.Set("User-Agent", "GoNavi-Updater") req.Header.Set("Accept", "application/vnd.github+json") - resp, err := client.Do(req) + resp, err := doUpdateRequest(client, req) if err != nil { return nil, err } @@ -581,7 +587,7 @@ func fetchReleaseByURL(apiURL string) (*githubRelease, error) { var release githubRelease if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { - return nil, err + return nil, wrapUpdateNetworkError(err) } return &release, nil } @@ -662,6 +668,20 @@ func findReleaseAsset(assets []githubAsset, name string) (*githubAsset, error) { } } +func normalizeGitHubAssetSHA256(digest string) string { + digest = strings.TrimSpace(digest) + if digest == "" { + return "" + } + if algorithm, value, ok := strings.Cut(digest, ":"); ok { + if !strings.EqualFold(strings.TrimSpace(algorithm), "sha256") { + return "" + } + digest = strings.TrimSpace(value) + } + return strings.ToLower(digest) +} + func fetchReleaseSHA256(assets []githubAsset) (map[string]string, error) { var checksumURL string for _, asset := range assets { @@ -680,7 +700,7 @@ func fetchReleaseSHA256(assets []githubAsset) (map[string]string, error) { return nil, err } req.Header.Set("User-Agent", "GoNavi-Updater") - resp, err := client.Do(req) + resp, err := doUpdateRequest(client, req) if err != nil { return nil, err } @@ -695,7 +715,7 @@ func fetchReleaseSHA256(assets []githubAsset) (map[string]string, error) { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, err + return nil, wrapUpdateNetworkError(err) } return parseSHA256Sums(string(body)), nil @@ -765,7 +785,7 @@ func downloadFileWithHashWithTimeout(url, filePath string, onProgress func(downl req.Header.Set("Accept", "application/octet-stream") } - resp, err := client.Do(req) + resp, err := doUpdateRequest(client, req) if err != nil { return "", err } @@ -810,7 +830,7 @@ func downloadFileWithHashWithTimeout(url, filePath string, onProgress func(downl } if _, err := io.Copy(io.MultiWriter(writers...), resp.Body); err != nil { out.Close() - return "", err + return "", wrapUpdateNetworkError(err) } if onProgress != nil { onProgress(progressWriter.written, total) @@ -828,6 +848,76 @@ func downloadFileWithHashWithTimeout(url, filePath string, onProgress func(downl return hex.EncodeToString(hasher.Sum(nil)), nil } +func doUpdateRequest(client *http.Client, req *http.Request) (*http.Response, error) { + resp, err := client.Do(req) + if err == nil { + return resp, nil + } + if !shouldRetryUpdateNetworkError(err) { + return nil, wrapUpdateNetworkError(err) + } + time.Sleep(updateNetworkRetryDelay) + retryReq := req.Clone(req.Context()) + resp, err = client.Do(retryReq) + if err != nil { + return nil, wrapUpdateNetworkError(err) + } + return resp, nil +} + +func shouldRetryUpdateNetworkError(err error) bool { + if err == nil { + return false + } + if isUpdateEOFError(err) { + return true + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return true + } + lower := strings.ToLower(err.Error()) + return strings.Contains(lower, "connection reset by peer") || + strings.Contains(lower, "connection refused") || + strings.Contains(lower, "server closed idle connection") +} + +func wrapUpdateNetworkError(err error) error { + if err == nil { + return nil + } + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + host := strings.TrimSpace(dnsErr.Name) + if host == "" { + host = "api.github.com" + } + return localizedUpdateError{ + key: "app.update.backend.error.network_dns", + params: map[string]any{ + "host": host, + "detail": err.Error(), + }, + } + } + if isUpdateEOFError(err) { + return localizedUpdateError{ + key: "app.update.backend.error.network_eof", + params: map[string]any{"detail": err.Error()}, + } + } + return localizedUpdateError{ + key: "app.update.backend.error.network_failed", + params: map[string]any{"detail": err.Error()}, + } +} + +func isUpdateEOFError(err error) bool { + return errors.Is(err, io.EOF) || + errors.Is(err, io.ErrUnexpectedEOF) || + strings.Contains(strings.ToLower(err.Error()), "eof") +} + func isGitHubReleaseAssetAPIURL(urlText string) bool { parsed, err := urlpkg.Parse(strings.TrimSpace(urlText)) if err != nil { diff --git a/internal/app/methods_update_test.go b/internal/app/methods_update_test.go index 20725a07..d40ec7a0 100644 --- a/internal/app/methods_update_test.go +++ b/internal/app/methods_update_test.go @@ -1,12 +1,17 @@ package app import ( + "encoding/json" "errors" + "net/http" + "net/http/httptest" "os" "path/filepath" stdRuntime "runtime" "strings" "testing" + + "GoNavi-Wails/internal/connection" ) func TestFetchLatestUpdateInfoSkipsChecksumWhenCurrentVersionIsAlreadyLatest(t *testing.T) { @@ -64,7 +69,59 @@ func TestFetchLatestUpdateInfoSkipsChecksumWhenCurrentVersionIsAlreadyLatest(t * } } -func TestFetchLatestUpdateInfoFetchesChecksumWhenUpdateIsAvailable(t *testing.T) { +func TestFetchLatestUpdateInfoUsesAssetDigestWhenUpdateIsAvailable(t *testing.T) { + assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH, "v0.6.5") + if err != nil { + t.Fatalf("expectedAssetName returned error: %v", err) + } + digest := strings.Repeat("A", 64) + + originalVersion := AppVersion + AppVersion = "0.6.4" + defer func() { + AppVersion = originalVersion + }() + + restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) { + return &githubRelease{ + TagName: "v0.6.5", + Name: "v0.6.5", + HTMLURL: "https://github.com/Syngnat/GoNavi/releases/tag/v0.6.5", + Assets: []githubAsset{ + { + Name: assetName, + BrowserDownloadURL: "https://example.com/" + assetName, + Digest: "sha256:" + digest, + Size: 4096, + }, + }, + }, nil + }) + defer restoreRelease() + + checksumCalled := false + restoreChecksum := swapUpdateFetchReleaseSHA256(func([]githubAsset) (map[string]string, error) { + checksumCalled = true + return nil, errors.New("checksum should not be fetched when asset digest is available") + }) + defer restoreChecksum() + + info, err := fetchLatestUpdateInfo(updateChannelLatest) + if err != nil { + t.Fatalf("fetchLatestUpdateInfo returned error: %v", err) + } + if checksumCalled { + t.Fatal("expected SHA256SUMS fetch to be skipped when asset digest is available") + } + if !info.HasUpdate { + t.Fatalf("expected HasUpdate=true, got %#v", info) + } + if info.SHA256 != strings.ToLower(digest) || info.AssetName != assetName { + t.Fatalf("unexpected update info: %#v", info) + } +} + +func TestFetchLatestUpdateInfoFallsBackToChecksumFileWhenAssetDigestMissing(t *testing.T) { assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH, "v0.6.5") if err != nil { t.Fatalf("expectedAssetName returned error: %v", err) @@ -106,7 +163,7 @@ func TestFetchLatestUpdateInfoFetchesChecksumWhenUpdateIsAvailable(t *testing.T) t.Fatalf("fetchLatestUpdateInfo returned error: %v", err) } if !checksumCalled { - t.Fatal("expected SHA256SUMS fetch when update is available") + t.Fatal("expected SHA256SUMS fetch when asset digest is missing") } if !info.HasUpdate { t.Fatalf("expected HasUpdate=true, got %#v", info) @@ -117,7 +174,7 @@ func TestFetchLatestUpdateInfoFetchesChecksumWhenUpdateIsAvailable(t *testing.T) } func TestCheckForUpdatesLogsFailuresForManualChecks(t *testing.T) { - app := &App{} + app := &App{configDir: t.TempDir()} restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) { return nil, errors.New("request timed out") @@ -140,7 +197,7 @@ func TestCheckForUpdatesLogsFailuresForManualChecks(t *testing.T) { } func TestCheckForUpdatesSilentlySkipsFailureLogs(t *testing.T) { - app := &App{} + app := &App{configDir: t.TempDir()} restoreRelease := swapUpdateFetchLatestRelease(func() (*githubRelease, error) { return nil, errors.New("request timed out") @@ -162,6 +219,73 @@ func TestCheckForUpdatesSilentlySkipsFailureLogs(t *testing.T) { } } +func TestCheckForUpdatesRestoresPersistedGlobalProxyRuntime(t *testing.T) { + previousProxy := currentGlobalProxyConfig() + t.Cleanup(func() { + _, _ = setGlobalProxyConfig(previousProxy.Enabled, previousProxy.Proxy) + }) + + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + + proxyCalled := false + proxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxyCalled = true + if !r.URL.IsAbs() { + t.Fatalf("expected update request through HTTP proxy to use absolute URL, got %q", r.URL.String()) + } + if r.URL.Host != "api.github.invalid" { + t.Fatalf("expected proxied GitHub API host api.github.invalid, got %q", r.URL.Host) + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(githubRelease{ + TagName: updateDevReleaseTag, + Name: "Dev Build (dev-proxy123)", + HTMLURL: "https://github.com/Syngnat/GoNavi/releases/tag/dev-latest", + }); err != nil { + t.Fatalf("Encode returned error: %v", err) + } + })) + defer proxyServer.Close() + + host, port := parseTestServerHostPort(t, proxyServer.URL) + if _, err := app.saveGlobalProxy(connection.SaveGlobalProxyInput{ + Enabled: true, + Type: "http", + Host: host, + Port: port, + }); err != nil { + t.Fatalf("saveGlobalProxy returned error: %v", err) + } + if _, err := setGlobalProxyConfig(false, connection.ProxyConfig{}); err != nil { + t.Fatalf("setGlobalProxyConfig reset returned error: %v", err) + } + + originalVersion := AppVersion + AppVersion = "dev-proxy123" + defer func() { + AppVersion = originalVersion + }() + + restoreRelease := swapUpdateFetchDevRelease(func() (*githubRelease, error) { + return fetchReleaseByURL("http://api.github.invalid/repos/Syngnat/GoNavi/releases/tags/dev-latest") + }) + defer restoreRelease() + + setChannelResult := app.SetUpdateChannel(string(updateChannelDev)) + if !setChannelResult.Success { + t.Fatalf("SetUpdateChannel returned failure: %#v", setChannelResult) + } + + result := app.CheckForUpdates() + if !result.Success { + t.Fatalf("expected update check through restored proxy to succeed, got %#v", result) + } + if !proxyCalled { + t.Fatal("expected persisted global proxy to receive the update check request") + } +} + func TestFetchLatestUpdateInfoForDevChannelUsesReleaseBuildVersion(t *testing.T) { assetName, err := expectedAssetName(stdRuntime.GOOS, stdRuntime.GOARCH, "dev-a1b2c3d") if err != nil { diff --git a/internal/connection/saved_types.go b/internal/connection/saved_types.go index 2a5f8759..2686999b 100644 --- a/internal/connection/saved_types.go +++ b/internal/connection/saved_types.go @@ -42,12 +42,28 @@ type SavedConnectionView struct { type LegacySavedConnection = SavedConnectionInput type SaveGlobalProxyInput struct { - Enabled bool `json:"enabled"` - Type string `json:"type"` - Host string `json:"host"` - Port int `json:"port"` - User string `json:"user,omitempty"` - Password string `json:"password,omitempty"` + Enabled bool `json:"enabled"` + Type string `json:"type"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user,omitempty"` + Password string `json:"password,omitempty"` + ClearPassword bool `json:"clearPassword,omitempty"` +} + +type TestGlobalProxyInput struct { + Proxy SaveGlobalProxyInput `json:"proxy"` + URL string `json:"url"` + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` +} + +type GlobalProxyTestResult struct { + URL string `json:"url"` + FinalURL string `json:"finalUrl,omitempty"` + StatusCode int `json:"statusCode,omitempty"` + Status string `json:"status,omitempty"` + DurationMs int64 `json:"durationMs"` + ViaProxy bool `json:"viaProxy"` } type GlobalProxyView struct {