mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
feat: add optional proxy for online updates
This commit is contained in:
1 parent
e94a7d472b
commit
6a3d39e969
13 files changed
+612
-47
No files matched your search
@@ -161,7 +161,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 发布在线更新
|
||||
|
||||
|
||||
+42
-11
@@ -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()
|
||||
|
||||
|
||||
@@ -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:[email protected]: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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+23
-11
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
-11
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<UpdateSettings | null>(null);
|
||||
const [savedNetwork, setSavedNetwork] = React.useState<UpdateSettings | null>(null);
|
||||
const [savingNetwork, setSavingNetwork] = React.useState(false);
|
||||
const [networkError, setNetworkError] = React.useState("");
|
||||
const streamRef = React.useRef<ReturnType<typeof openSSE> | 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() {
|
||||
</CardTitle>
|
||||
<CardDescription>从 GitHub 检查并安装新版本。更新会重启程序,正在运行的任务会被中断。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!networkError} data-disabled={busy || restarting || savingNetwork || !network}>
|
||||
<FieldLabel htmlFor="update-proxy" className="text-sm font-normal text-muted-foreground">
|
||||
更新代理(可选)
|
||||
</FieldLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="update-proxy"
|
||||
autoComplete="off"
|
||||
placeholder="http://host:port 或 socks5://host:port(留空=直连)"
|
||||
value={network?.proxy ?? ""}
|
||||
disabled={busy || restarting || savingNetwork || !network}
|
||||
aria-invalid={!!networkError}
|
||||
onChange={(e) =>
|
||||
network &&
|
||||
setNetwork({
|
||||
proxy: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void saveNetwork()}
|
||||
disabled={!network || savingNetwork || checking || busy || restarting}
|
||||
>
|
||||
{savingNetwork ? "保存中…" : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
<FieldDescription className="text-xs">
|
||||
独立出口代理,仅用于检查版本和下载更新,支持 HTTP / HTTPS / SOCKS5。留空保存后直连;不影响任务、网络搜索或
|
||||
LLM 的代理。
|
||||
</FieldDescription>
|
||||
{networkDirty && <p className="text-xs text-muted-foreground">修改尚未保存,保存后将自动检查更新。</p>}
|
||||
{networkError && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{networkError}
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">当前版本</span>
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
@@ -238,7 +331,7 @@ export function UpdateCard() {
|
||||
<p className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-2 text-xs text-destructive">
|
||||
<TriangleAlertIcon className="mt-0.5 size-3.5 shrink-0" />
|
||||
无法连接 GitHub:{info.error}
|
||||
{" "}可在上方配置全局代理后重试。
|
||||
{" "}可配置更新代理或切换为直连后重试。
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -274,6 +367,12 @@ export function UpdateCard() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{progress?.phase === "failed" && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
更新失败:{progress.error || progress.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showProgress && (
|
||||
<div className="space-y-1.5">
|
||||
<Progress value={pct} className={downloading ? undefined : "animate-pulse"} />
|
||||
@@ -284,14 +383,29 @@ export function UpdateCard() {
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => check(false)} disabled={checking || busy || restarting}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => check(false)}
|
||||
disabled={checking || busy || restarting || networkDirty || savingNetwork || !network}
|
||||
>
|
||||
<RefreshCwIcon className={checking ? "size-4 animate-spin" : "size-4"} />
|
||||
检查更新
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={doUpdate}
|
||||
disabled={busy || restarting || !info?.has_update || info?.asset_available === false}
|
||||
disabled={
|
||||
checking ||
|
||||
networkDirty ||
|
||||
savingNetwork ||
|
||||
!network ||
|
||||
busy ||
|
||||
restarting ||
|
||||
!!info?.error ||
|
||||
!info?.has_update ||
|
||||
info?.asset_available === false
|
||||
}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
{info?.has_update ? `更新到 ${info.latest}` : "立即更新"}
|
||||
|
||||
@@ -106,6 +106,7 @@ import type {
|
||||
TrafficHost,
|
||||
TrafficResp,
|
||||
UpdateCheck,
|
||||
UpdateSettings,
|
||||
UsageStats,
|
||||
WorkspaceFile,
|
||||
WorkspaceListing,
|
||||
@@ -1410,6 +1411,8 @@ export const api = {
|
||||
// 后端对 GitHub 的查询结果有 30 分钟缓存(未认证的 GitHub API 是 60 次/小时/IP,
|
||||
// 顶栏每次整页加载都会查一次,不缓存会很快耗光配额)。force=true 强制回源,
|
||||
// 留给用户显式点「检查更新」时用。
|
||||
getUpdateSettings: () => get<UpdateSettings>("/update/settings"),
|
||||
saveUpdateSettings: (settings: UpdateSettings) => put<UpdateSettings>("/update/settings", settings),
|
||||
checkUpdate: (force = false) => get<UpdateCheck>(`/update/check${force ? "?force=1" : ""}`),
|
||||
// 202 即返回,实际下载在后台跑,进度走 /api/update/stream。
|
||||
applyUpdate: () => post<{ ok: boolean; target: string }>(`/update/apply`),
|
||||
|
||||
@@ -1613,6 +1613,10 @@ export interface FindingTrafficDetail {
|
||||
response: EvidenceBodyPreview;
|
||||
}
|
||||
|
||||
export interface UpdateSettings {
|
||||
proxy: string;
|
||||
}
|
||||
|
||||
/** GET /api/update/check —— 当前版本与 GitHub 最新正式版的比较结果。 */
|
||||
export interface UpdateCheck {
|
||||
/** 当前运行的版本;开发构建为 "dev" 或 git describe 的带后缀形式。 */
|
||||
|
||||
Reference in new issue
Block a user