diff --git a/README.md b/README.md index 7bcb2b7..af125c6 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,8 @@ ARTEX_TARGETS=linux/amd64,windows/amd64 ./build.sh --release - **更新会中断正在运行的任务**——更新即重启,请在空闲时进行。 - **开发构建不给更新**:版本号是 `dev` 或 `git describe` 带后缀时禁用,避免正式版覆盖掉本地调试的二进制。 - **Docker 下只换程序、不换镜像**:镜像里的 playwright / nmap 等工具链不会跟着升级,且 `docker compose up -d` 重建容器后会退回镜像自带的版本。要连镜像一起升级仍请用 `docker compose pull artex && docker compose up -d artex`。 -- 访问 GitHub 需要代理时,在同一页面配置**全局代理**即可,更新链路会走它。更新只从 GitHub 域名下载并强制 HTTPS。 +- **更新代理(可选)**与其他代理设置使用相同的「地址输入框 + 保存」方式。填写独立更新代理后保存,例如 `http://127.0.0.1:7897`;支持 `http://`、`https://`、`socks5://`、`socks5h://` 及用户名密码认证。清空并保存即为直连,不受全局代理或 `HTTP_PROXY` / `HTTPS_PROXY` 环境变量影响。保存后自动重新检查,检查版本、校验和下载和发布包下载使用同一个出口。更新只从 GitHub 域名下载并强制 HTTPS。 +- 代理地址由 **ARTEX 服务端**访问。Docker 内的 `127.0.0.1` 指容器自身;代理运行在宿主机时,应使用容器可达地址(Docker Desktop 通常为 `http://host.docker.internal:7897`),并允许容器访问代理端口。 ### 维护者:通过 GitHub Actions 发布在线更新 diff --git a/selfupdate/github.go b/selfupdate/github.go index e89fd09..00503d5 100644 --- a/selfupdate/github.go +++ b/selfupdate/github.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "strconv" "strings" "time" ) @@ -47,20 +48,50 @@ type Asset struct { Size int64 `json:"size"` } -// NewClient 构造一个只认 GitHub 域名的 HTTP 客户端。proxy 为空则直连。 -// -// 刻意不复用默认 Transport:升级链路必须强制走 TLS 且校验证书,不能被别处 -// 设置的 InsecureSkipVerify 之类影响到。 -func NewClient(proxy string) *http.Client { +// ParseProxy validates an optional update proxy without including credentials in errors. +func ParseProxy(raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("代理地址格式无效") + } + switch u.Scheme { + case "http", "https", "socks5", "socks5h": + default: + return nil, fmt.Errorf("代理协议须为 http、https、socks5 或 socks5h") + } + if u.Hostname() == "" || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { + return nil, fmt.Errorf("代理地址须包含主机,且不能包含路径、查询或片段") + } + if port := u.Port(); port != "" { + n, err := strconv.Atoi(port) + if err != nil || n < 1 || n > 65535 { + return nil, fmt.Errorf("代理端口须在 1–65535 之间") + } + } else if strings.HasSuffix(u.Host, ":") { + return nil, fmt.Errorf("代理端口不能为空") + } + return u, nil +} + +// NewClient validates the proxy before opening any connection. Empty means direct, +// regardless of HTTP_PROXY / HTTPS_PROXY in the process environment. +func NewClient(proxy string) (*http.Client, error) { + pu, err := ParseProxy(proxy) + if err != nil { + return nil, err + } tr := &http.Transport{ ForceAttemptHTTP2: true, TLSHandshakeTimeout: 15 * time.Second, } - if p := strings.TrimSpace(proxy); p != "" { - if pu, err := url.Parse(p); err == nil { - tr.Proxy = http.ProxyURL(pu) - } + if pu != nil { + tr.Proxy = http.ProxyURL(pu) } + return &http.Client{ Transport: tr, Timeout: 30 * time.Minute, // 下载整包,不能按请求级超时卡死 @@ -70,7 +101,7 @@ func NewClient(proxy string) *http.Client { } return checkURL(req.URL) }, - } + }, nil } // checkURL 强制 https + 域名白名单。 @@ -98,7 +129,7 @@ func FetchLatest(ctx context.Context, c *http.Client) (*Release, error) { resp, err := c.Do(req) if err != nil { - return nil, fmt.Errorf("访问 GitHub 失败(可在系统设置里配置全局代理): %w", err) + return nil, fmt.Errorf("访问 GitHub 失败(可在版本与更新中配置更新代理): %w", err) } defer resp.Body.Close() diff --git a/selfupdate/proxy_test.go b/selfupdate/proxy_test.go new file mode 100644 index 0000000..2739f22 --- /dev/null +++ b/selfupdate/proxy_test.go @@ -0,0 +1,90 @@ +package selfupdate + +import ( + "crypto/tls" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestProxyValidation(t *testing.T) { + for _, raw := range []string{"", " ", "http://127.0.0.1:7897", "https://user:secret@proxy.example:443", "socks5://localhost:1080", "socks5h://[::1]:1080"} { + if _, err := NewClient(raw); err != nil { + t.Errorf("valid proxy rejected: %v", err) + } + } + for _, raw := range []string{"127.0.0.1:7897", "ftp://proxy", "http://", "http://:7897", "http://proxy:0", "http://proxy:65536", "http://proxy:", "http://proxy/path", "http://proxy?x=1", "http://proxy#fragment", "http://user:secret@proxy:bad"} { + if _, err := NewClient(raw); err == nil { + t.Error("invalid proxy accepted") + } else if strings.Contains(err.Error(), "secret") { + t.Fatal("proxy validation leaked password") + } + } +} + +func TestDirectClientIgnoresEnvironmentProxy(t *testing.T) { + t.Setenv("HTTP_PROXY", "http://127.0.0.1:1") + t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1") + c, err := NewClient("") + if err != nil { + t.Fatal(err) + } + defer c.CloseIdleConnections() + transport := c.Transport.(*http.Transport) + if transport.Proxy != nil { + t.Fatal("direct client must not consult environment proxies") + } + if transport.TLSClientConfig != nil && transport.TLSClientConfig.InsecureSkipVerify { + t.Fatal("TLS verification disabled") + } +} + +// Exercise real HTTP CONNECT routing for metadata, checksum and archive requests. +// The proxy returns a sentinel error; none of these requests may fall back direct. +func TestUpdateRequestsUseProxy(t *testing.T) { + var hosts []string + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect { + t.Errorf("expected CONNECT, got %s", r.Method) + } + hosts = append(hosts, r.Host) + http.Error(w, "test proxy refuses tunnel", http.StatusBadGateway) + })) + defer proxy.Close() + c, err := NewClient(proxy.URL) + if err != nil { + t.Fatal(err) + } + defer c.CloseIdleConnections() + if _, err := FetchLatest(t.Context(), c); err == nil { + t.Fatal("expected proxy error") + } + for _, name := range []string{"SHA256SUMS", "artex-0.3.21-linux-arm64.zip"} { + if _, err := get(t.Context(), c, "https://github.com/"+Repo+"/releases/download/v0.3.21/"+name); err == nil { + t.Fatal("expected proxy error") + } + } + if len(hosts) != 3 || hosts[0] != "api.github.com:443" || hosts[1] != "github.com:443" || hosts[2] != "github.com:443" { + t.Fatalf("unexpected proxy requests: %v", hosts) + } +} + +func TestClientRejectsUnsafeRedirect(t *testing.T) { + target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "https://example.com/untrusted", http.StatusFound) + })) + defer target.Close() + c, _ := NewClient("") + defer c.CloseIdleConnections() + c.Transport.(*http.Transport).TLSClientConfig = &tls.Config{RootCAs: target.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs} + resp, err := c.Get(target.URL) + if resp != nil { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + if err == nil || !strings.Contains(err.Error(), "拒绝非 GitHub") { + t.Fatalf("unsafe redirect accepted: %v", err) + } +} diff --git a/selfupdate/stage.go b/selfupdate/stage.go index fe41aaf..63a689c 100644 --- a/selfupdate/stage.go +++ b/selfupdate/stage.go @@ -52,6 +52,11 @@ func Stage(ctx context.Context, c *http.Client, rel *Release, currentVersion str if err != nil { return err } + return stageAt(ctx, c, rel, currentVersion, prog, p) +} + +// stageAt keeps downloads and executable replacement testable in an isolated directory. +func stageAt(ctx context.Context, c *http.Client, rel *Release, currentVersion string, prog Progress, p Paths) error { if err := checkWritable(p.Dir); err != nil { return err } diff --git a/selfupdate/stage_download_test.go b/selfupdate/stage_download_test.go new file mode 100644 index 0000000..0538d14 --- /dev/null +++ b/selfupdate/stage_download_test.go @@ -0,0 +1,76 @@ +package selfupdate + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "fmt" + "io" + "net/http" + "os" + "runtime" + "strings" + "testing" +) + +func TestStageDownloadAndVerify(t *testing.T) { + requireUnix(t) + var archive bytes.Buffer + zw := zip.NewWriter(&archive) + f, err := zw.Create("artex") + if err != nil { + t.Fatal(err) + } + f.Write([]byte("#!/bin/sh\necho new-version\nexit 0\n")) + if err := zw.Close(); err != nil { + t.Fatal(err) + } + for _, corrupt := range []bool{false, true} { + t.Run(fmt.Sprintf("corrupt=%v", corrupt), func(t *testing.T) { + p := testPaths(t) + fakeBin(t, p.Current, "old-version", 0) + name := AssetName("v0.3.21", runtime.GOOS, runtime.GOARCH) + rel := &Release{TagName: "v0.3.21", Assets: []Asset{ + {Name: name, URL: "https://github.com/" + name, Size: int64(archive.Len())}, + {Name: sumsAsset, URL: "https://github.com/SHA256SUMS"}, + }} + sum := fmt.Sprintf("%x", sha256.Sum256(archive.Bytes())) + if corrupt { + sum = strings.Repeat("0", 64) + } + client := &http.Client{Transport: releaseTransport(func(r *http.Request) (*http.Response, error) { + body := archive.Bytes() + if strings.HasSuffix(r.URL.Path, sumsAsset) { + body = []byte(sum + " " + name + "\n") + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(body))}, nil + })} + var phases []Phase + err := stageAt(t.Context(), client, rel, "0.3.20", func(p Phase, _ int, _ string) { phases = append(phases, p) }, p) + if !strings.Contains(readAll(t, p.Current), "old-version") { + t.Fatal("running binary changed before bootstrap") + } + if corrupt { + if err == nil || !strings.Contains(err.Error(), "SHA256") { + t.Fatalf("corrupt archive accepted: %v", err) + } + if _, err := os.Stat(p.New); !os.IsNotExist(err) { + t.Fatal("corrupt binary staged") + } + return + } + if err != nil { + t.Fatal(err) + } + if len(phases) == 0 || phases[len(phases)-1] != PhaseStaged { + t.Fatalf("phases: %v", phases) + } + if !strings.Contains(readAll(t, p.New), "new-version") { + t.Fatal("new binary not staged") + } + if m, ok := readMarker(p.Marker); !ok || m.From != "0.3.20" || m.To != "0.3.21" { + t.Fatalf("invalid rollback marker: %+v", m) + } + }) + } +} diff --git a/server/server.go b/server/server.go index 17b916b..aad2fce 100644 --- a/server/server.go +++ b/server/server.go @@ -704,6 +704,8 @@ func (s *Server) Handler() http.Handler { // 页面一键更新。走的是默认的 JWT 鉴权(auth.go 只放行 /api/auth/* 和 // /api/health),所以这几个改动程序自身的接口天然需要登录。 + mux.HandleFunc("GET /api/update/settings", s.getUpdateSettings) + mux.HandleFunc("PUT /api/update/settings", s.putUpdateSettings) mux.HandleFunc("GET /api/update/check", s.updateCheck) mux.HandleFunc("POST /api/update/apply", s.updateApply) mux.HandleFunc("POST /api/update/rollback", s.updateRollback) diff --git a/server/update.go b/server/update.go index 4e42dc1..c1c96e4 100644 --- a/server/update.go +++ b/server/update.go @@ -57,10 +57,11 @@ func bootUpdateState() selfupdate.State { // 每 IP 每小时 60 次——不缓存的话,多开几个标签页或刷几次页面就把配额耗光了, // 之后真想更新时反而查不动。用户显式点"检查更新"时可以 force 绕过缓存。 type releaseCache struct { - mu sync.Mutex - rel *selfupdate.Release - err error - at time.Time + mu sync.Mutex + rel *selfupdate.Release + err error + at time.Time + network string // fetch 是取数函数,仅为测试留的注入点;为 nil 时走真正的 GitHub 查询。 fetch func(context.Context, *http.Client) (*selfupdate.Release, error) } @@ -80,11 +81,11 @@ var relCache = &releaseCache{} // // 取数期间一直持有锁:并发请求会排队等同一次查询的结果,而不是各自去打 GitHub // (页面刚加载时多个标签页同时来查,正是最容易触发限流的时刻)。 -func (c *releaseCache) get(ctx context.Context, client *http.Client, force bool) (*selfupdate.Release, error) { +func (c *releaseCache) get(ctx context.Context, client *http.Client, force bool, network string) (*selfupdate.Release, error) { c.mu.Lock() defer c.mu.Unlock() - if !force { + if !force && c.network == network { ttl := releaseTTL if c.err != nil { ttl = releaseErrTTL @@ -106,7 +107,7 @@ func (c *releaseCache) get(ctx context.Context, client *http.Client, force bool) if err != nil && ctx.Err() != nil && errors.Is(ctx.Err(), context.Canceled) { return c.rel, err } - c.rel, c.err, c.at = rel, err, time.Now() + c.rel, c.err, c.at, c.network = rel, err, time.Now(), network return rel, err } @@ -227,8 +228,13 @@ func (s *Server) updateCheck(w http.ResponseWriter, r *http.Request) { // 顶栏提示走缓存(默认);用户点"检查更新"时带 force=1 强制回源。 force := r.URL.Query().Get("force") != "" - client := selfupdate.NewClient(s.m.GlobalProxy()) - rel, err := relCache.get(r.Context(), client, force) + client, network, err := s.updateClient() + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + defer client.CloseIdleConnections() + rel, err := relCache.get(r.Context(), client, force, network) if err != nil { out["error"] = err.Error() writeJSON(w, 200, out) @@ -271,8 +277,13 @@ func (s *Server) updateApply(w http.ResponseWriter, r *http.Request) { current := BuildVersion // 走缓存:确保装上的就是用户在界面上看到并确认的那个版本。 - client := selfupdate.NewClient(s.m.GlobalProxy()) - rel, err := relCache.get(r.Context(), client, false) + client, network, err := s.updateClient() + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + defer client.CloseIdleConnections() + rel, err := relCache.get(r.Context(), client, false, network) if err != nil { writeErr(w, 502, err.Error()) return @@ -294,6 +305,7 @@ func (s *Server) updateApply(w http.ResponseWriter, r *http.Request) { go func() { // 刻意用 s.ctx 而不是请求的 ctx:HTTP 响应一返回请求就结束了, // 挂在它上面下载会立刻被取消。 + defer client.CloseIdleConnections() err := selfupdate.Stage(s.ctx, client, rel, current, func(ph selfupdate.Phase, pct int, msg string) { updHub.publish(ph, pct, msg) }) diff --git a/server/update_settings.go b/server/update_settings.go new file mode 100644 index 0000000..56f1c05 --- /dev/null +++ b/server/update_settings.go @@ -0,0 +1,83 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/Autumn-27/artex/selfupdate" +) + +const updateSettingsKey = "update.network" + +type updateSettings struct { + Proxy string `json:"proxy"` +} + +func (cfg *updateSettings) validate() error { + cfg.Proxy = strings.TrimSpace(cfg.Proxy) + if _, err := selfupdate.ParseProxy(cfg.Proxy); err != nil { + return err + } + return nil +} + +func (s *Server) loadUpdateSettings() (updateSettings, error) { + cfg := updateSettings{} + raw, ok, err := s.m.pg.GetSetting(updateSettingsKey) + if err != nil { + return cfg, fmt.Errorf("读取更新网络设置失败: %w", err) + } + if ok { + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return cfg, fmt.Errorf("更新网络设置格式无效") + } + } + if err := cfg.validate(); err != nil { + return cfg, err + } + return cfg, nil +} + +func (s *Server) getUpdateSettings(w http.ResponseWriter, r *http.Request) { + cfg, err := s.loadUpdateSettings() + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, cfg) +} + +func (s *Server) putUpdateSettings(w http.ResponseWriter, r *http.Request) { + var cfg updateSettings + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&cfg); err != nil { + writeErr(w, 400, "更新网络设置格式无效") + return + } + if err := cfg.validate(); err != nil { + writeErr(w, 400, err.Error()) + return + } + raw, err := json.Marshal(cfg) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if err := s.m.pg.SetSettingContext(r.Context(), updateSettingsKey, string(raw)); err != nil { + writeErr(w, 500, "保存更新网络设置失败") + return + } + writeJSON(w, 200, cfg) +} + +// Capture one effective proxy for both release discovery and all asset downloads. +// A different proxy bypasses cached success/error results from the previous route. +func (s *Server) updateClient() (*http.Client, string, error) { + cfg, err := s.loadUpdateSettings() + if err != nil { + return nil, "", err + } + client, err := selfupdate.NewClient(cfg.Proxy) + return client, cfg.Proxy, err +} diff --git a/server/update_settings_test.go b/server/update_settings_test.go new file mode 100644 index 0000000..15f0849 --- /dev/null +++ b/server/update_settings_test.go @@ -0,0 +1,101 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestUpdateSettingsHTTPPersistence(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable: %v", err) + } + defer m.Close() + old, existed, err := m.pg.GetSetting(updateSettingsKey) + if err != nil { + t.Fatal(err) + } + defer func() { + if existed { + m.pg.SetSetting(updateSettingsKey, old) + } else { + m.pg.Exec("DELETE FROM settings WHERE key=$1", updateSettingsKey) + } + }() + m.pg.Exec("DELETE FROM settings WHERE key=$1", updateSettingsKey) + dir := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s := New(ctx, m, dir, dir, dir) + token, err := signJWT(s.jwtKey) + if err != nil { + t.Fatal(err) + } + handler := s.Handler() + request := func(method, body string, authenticated bool) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, "/api/update/settings", bytes.NewBufferString(body)) + if authenticated { + req.Header.Set("Authorization", "Bearer "+token) + } + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + return res + } + for _, method := range []string{"GET", "PUT"} { + if r := request(method, `{}`, false); r.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated %s: %d", method, r.Code) + } + } + if cfg, err := s.loadUpdateSettings(); err != nil || cfg.Proxy != "" { + t.Fatalf("default: %+v %v", cfg, err) + } + for _, body := range []string{`{"proxy":"127.0.0.1:7897"}`, `{"proxy":"http://proxy:65536"}`, `{`} { + if r := request("PUT", body, true); r.Code != 400 { + t.Fatalf("invalid setting accepted: %d %s", r.Code, r.Body.String()) + } + } + m.mu.Lock() + m.globalProxy = "http://127.0.0.1:1" + m.mu.Unlock() + originalGlobal := m.GlobalProxy() + for _, proxy := range []string{" http://127.0.0.1:7897 ", ""} { + body, _ := json.Marshal(updateSettings{Proxy: proxy}) + if r := request("PUT", string(body), true); r.Code != 200 { + t.Fatalf("save: %d %s", r.Code, r.Body.String()) + } + // Read through a fresh Server to prove the setting comes from the DB. + fresh := &Server{m: m} + cfg, err := fresh.loadUpdateSettings() + if err != nil || cfg.Proxy != strings.TrimSpace(proxy) { + t.Fatalf("reload: %+v %v", cfg, err) + } + r := request("GET", "", true) + if r.Code != 200 { + t.Fatalf("GET settings: %d", r.Code) + } + want := cfg + var got updateSettings + if err := json.Unmarshal(r.Body.Bytes(), &got); err != nil || got != want { + t.Fatalf("GET: %+v %v", got, err) + } + client, effective, err := fresh.updateClient() + if err != nil { + t.Fatal(err) + } + defer client.CloseIdleConnections() + if proxy != "" && effective != "http://127.0.0.1:7897" { + t.Fatalf("proxy not normalized: %q", effective) + } + if proxy == "" && client.Transport.(*http.Transport).Proxy != nil { + t.Fatal("direct inherited proxy") + } + if m.GlobalProxy() != originalGlobal { + t.Fatal("update setting changed global proxy") + } + } +} diff --git a/server/update_test.go b/server/update_test.go index 8a2e088..12d0944 100644 --- a/server/update_test.go +++ b/server/update_test.go @@ -26,7 +26,7 @@ func TestReleaseCacheServesFromCache(t *testing.T) { }) for range 5 { - rel, err := c.get(t.Context(), nil, false) + rel, err := c.get(t.Context(), nil, false, "") if err != nil { t.Fatalf("get: %v", err) } @@ -46,11 +46,11 @@ func TestReleaseCacheForceBypasses(t *testing.T) { return &selfupdate.Release{TagName: "v0.3.8"}, nil }) - if _, err := c.get(t.Context(), nil, false); err != nil { + if _, err := c.get(t.Context(), nil, false, ""); err != nil { t.Fatal(err) } // 用户点「检查更新」必须拿到实时结果,否则刚发布的版本要等缓存过期才看得见。 - if _, err := c.get(t.Context(), nil, true); err != nil { + if _, err := c.get(t.Context(), nil, true, ""); err != nil { t.Fatal(err) } if calls != 2 { @@ -65,12 +65,12 @@ func TestReleaseCacheExpiresAfterTTL(t *testing.T) { return &selfupdate.Release{TagName: "v0.3.8"}, nil }) - if _, err := c.get(t.Context(), nil, false); err != nil { + if _, err := c.get(t.Context(), nil, false, ""); err != nil { t.Fatal(err) } // 把落库时间往前拨到刚过期,模拟 TTL 到点。 c.at = time.Now().Add(-releaseTTL - time.Second) - if _, err := c.get(t.Context(), nil, false); err != nil { + if _, err := c.get(t.Context(), nil, false, ""); err != nil { t.Fatal(err) } if calls != 2 { @@ -85,11 +85,11 @@ func TestReleaseCacheUsesShorterTTLForErrors(t *testing.T) { return nil, errors.New("github 不可达") }) - if _, err := c.get(t.Context(), nil, false); err == nil { + if _, err := c.get(t.Context(), nil, false, ""); err == nil { t.Fatal("期望返回错误") } // 失败结果也要缓存一会儿,否则 GitHub 不可达时每次页面加载都白等一次超时。 - if _, err := c.get(t.Context(), nil, false); err == nil { + if _, err := c.get(t.Context(), nil, false, ""); err == nil { t.Fatal("期望返回错误") } if calls != 1 { @@ -101,7 +101,7 @@ func TestReleaseCacheUsesShorterTTLForErrors(t *testing.T) { t.Fatalf("错误 TTL(%v) 必须短于成功 TTL(%v)", releaseErrTTL, releaseTTL) } c.at = time.Now().Add(-releaseErrTTL - time.Second) - if _, err := c.get(t.Context(), nil, false); err == nil { + if _, err := c.get(t.Context(), nil, false, ""); err == nil { t.Fatal("期望返回错误") } if calls != 2 { @@ -114,7 +114,7 @@ func TestReleaseCacheDoesNotPoisonOnCallerCancel(t *testing.T) { c := newTestCache(func(ctx context.Context, _ *http.Client) (*selfupdate.Release, error) { return good, nil }) - if _, err := c.get(t.Context(), nil, false); err != nil { + if _, err := c.get(t.Context(), nil, false, ""); err != nil { t.Fatal(err) } @@ -127,7 +127,7 @@ func TestReleaseCacheDoesNotPoisonOnCallerCancel(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - if _, err := c.get(ctx, nil, false); err == nil { + if _, err := c.get(ctx, nil, false, ""); err == nil { t.Fatal("调用方已取消时应把错误透传给它") } @@ -145,7 +145,7 @@ func TestReleaseCacheDoesNotPoisonOnCallerCancel(t *testing.T) { c.fetch = func(context.Context, *http.Client) (*selfupdate.Release, error) { return good, nil } - rel, err := c.get(t.Context(), nil, false) + rel, err := c.get(t.Context(), nil, false, "") if err != nil { t.Fatalf("取消之后的正常请求不应报错: %v", err) } @@ -153,3 +153,46 @@ func TestReleaseCacheDoesNotPoisonOnCallerCancel(t *testing.T) { t.Fatalf("应拿到正常结果,得到 %+v", rel) } } + +func TestReleaseCacheChangesWithNetwork(t *testing.T) { + calls := 0 + c := newTestCache(func(context.Context, *http.Client) (*selfupdate.Release, error) { + calls++ + if calls == 1 { + return nil, errors.New("direct unavailable") + } + return &selfupdate.Release{TagName: "v0.3.21"}, nil + }) + if _, err := c.get(t.Context(), nil, false, ""); err == nil { + t.Fatal("expected direct error") + } + if _, err := c.get(t.Context(), nil, false, "http://localhost:7897"); err != nil { + t.Fatal(err) + } + if _, err := c.get(t.Context(), nil, false, "http://localhost:7897"); err != nil { + t.Fatal(err) + } + if calls != 2 { + t.Fatalf("same proxy should reuse cache; calls=%d", calls) + } + if _, err := c.get(t.Context(), nil, false, ""); err != nil { + t.Fatal(err) + } + if calls != 3 { + t.Fatalf("switching back to direct must refetch; calls=%d", calls) + } +} + +func TestUpdateSettingsValidation(t *testing.T) { + for _, tc := range []struct { + proxy string + valid bool + }{ + {"", true}, {" http://127.0.0.1:7897 ", true}, {"localhost:7897", false}, + } { + cfg := updateSettings{Proxy: tc.proxy} + if err := cfg.validate(); (err == nil) != tc.valid { + t.Errorf("validation: %v", err) + } + } +} diff --git a/web/src/app/(main)/system/settings/_components/update-card.tsx b/web/src/app/(main)/system/settings/_components/update-card.tsx index e4ef334..dc0068b 100644 --- a/web/src/app/(main)/system/settings/_components/update-card.tsx +++ b/web/src/app/(main)/system/settings/_components/update-card.tsx @@ -15,9 +15,11 @@ import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; import { api, openSSE } from "@/lib/api"; -import type { UpdateCheck, UpdateProgress } from "@/lib/types"; +import type { UpdateCheck, UpdateProgress, UpdateSettings } from "@/lib/types"; /** 等待新版本上线的最长时间。一次升级要经过三次进程启动(暂存 → 换装 → 新版), * 每次都是秒级,三分钟足够覆盖慢磁盘和 Docker 容器重建。 */ @@ -45,6 +47,32 @@ export function UpdateCard() { const [restarting, setRestarting] = React.useState(false); const [busy, setBusy] = React.useState(false); + const [network, setNetwork] = React.useState(null); + const [savedNetwork, setSavedNetwork] = React.useState(null); + const [savingNetwork, setSavingNetwork] = React.useState(false); + const [networkError, setNetworkError] = React.useState(""); + const streamRef = React.useRef | null>(null); + const networkDirty = network?.proxy !== savedNetwork?.proxy; + + React.useEffect(() => { + let active = true; + api + .getUpdateSettings() + .then((settings) => { + if (!active) return; + setNetwork(settings); + setSavedNetwork(settings); + }) + .catch((e) => { + if (active) setNetworkError("读取更新网络设置失败:" + (e as Error).message); + }); + return () => { + active = false; + }; + }, []); + + React.useEffect(() => () => streamRef.current?.close(), []); + // quiet 同时决定要不要绕过后端缓存:进页面时的自动检查用缓存(顶栏刚查过), // 用户手动点「检查更新」则强制回源,否则刚发布的版本要等缓存过期才看得到。 const check = React.useCallback((quiet = false) => { @@ -69,6 +97,25 @@ export function UpdateCard() { check(true); }, [check]); + const saveNetwork = async () => { + if (!network) return; + setSavingNetwork(true); + setNetworkError(""); + try { + const settings = await api.saveUpdateSettings({ + proxy: network.proxy, + }); + setNetwork(settings); + setSavedNetwork(settings); + toast.success("更新网络设置已保存"); + check(); + } catch (e) { + setNetworkError((e as Error).message); + } finally { + setSavingNetwork(false); + } + }; + // 轮询 /api/health 直到版本号变化。 // // 判据必须是"版本变了"而不是"能连上了":换装过程中旧版本会短暂地重新起来一次 @@ -94,12 +141,14 @@ export function UpdateCard() { } } setRestarting(false); + setBusy(false); toast.error("等待服务重启超时。请检查后端日志,或确认 artex 是通过 start.sh / start.bat 启动的。"); }, []); // 订阅更新进度。SSE 不走 Next 的 /api 重写(那层会缓冲,事件推不出来)。 const openStream = React.useCallback( (fromVersion: string) => { + streamRef.current?.close(); const es = openSSE("/api/update/stream"); es.onmessage = (ev) => { let p: UpdateProgress; @@ -120,10 +169,11 @@ export function UpdateCard() { void waitForNewVersion(fromVersion); } }; + streamRef.current = es; es.onerror = () => { // 进程退出时 SSE 必然断开。如果已经进入等待重启,这属于正常现象, // 交给 /api/health 轮询继续判定即可。 - es.close(); + // EventSource 自动重连,恢复服务端保存的当前进度。 }; return es; }, @@ -145,13 +195,16 @@ export function UpdateCard() { setBusy(true); setProgress({ phase: "downloading", percent: 0, message: "准备中…" }); - const es = openStream(from); - api.applyUpdate().catch((e) => { - es.close(); - setBusy(false); - setProgress(null); - toast.error("启动更新失败:" + (e as Error).message); - }); + api + .applyUpdate() + .then(() => { + openStream(from); + }) + .catch((e) => { + setBusy(false); + setProgress(null); + toast.error("启动更新失败:" + (e as Error).message); + }); }; const doRollback = () => { @@ -193,7 +246,47 @@ export function UpdateCard() { 从 GitHub 检查并安装新版本。更新会重启程序,正在运行的任务会被中断。 - + + + + + 更新代理(可选) + +
+ + network && + setNetwork({ + proxy: e.target.value, + }) + } + /> + +
+ + 独立出口代理,仅用于检查版本和下载更新,支持 HTTP / HTTPS / SOCKS5。留空保存后直连;不影响任务、网络搜索或 + LLM 的代理。 + + {networkDirty &&

修改尚未保存,保存后将自动检查更新。

} + {networkError && ( +

+ {networkError} +

+ )} +
+
当前版本 @@ -238,7 +331,7 @@ export function UpdateCard() {

无法连接 GitHub:{info.error} - {" "}可在上方配置全局代理后重试。 + {" "}可配置更新代理或切换为直连后重试。

)} @@ -274,6 +367,12 @@ export function UpdateCard() {

)} + {progress?.phase === "failed" && ( +

+ 更新失败:{progress.error || progress.message} +

+ )} + {showProgress && (
@@ -284,14 +383,29 @@ export function UpdateCard() { )}
-