From f767b88f7f63db47ea08cf57f528c5f0ac5c4e8a Mon Sep 17 00:00:00 2001 From: wha7ev9r Date: Fri, 31 Jul 2026 20:59:45 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20TTL=20=E9=9D=A2=E6=9D=BF=E5=8F=AF?= =?UTF-8?q?=E8=B0=83=E4=B8=8E=E9=85=8D=E7=BD=AE=E6=9C=8D=E5=8A=A1=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 镜像缓存 TTL 与 Git 代理缓存 TTL 可在面板配置(0=永久/禁用) - GitProxy 增加 CacheTTL 读写接口与 /api/config/gitproxy 端点 - config 保存防 env token 落盘(AuthTokenOrig)、原子写入、mtime 失败重试 - token 缓存不缓存临期 token;限流中间件始终装配且 search/release 纳入限流 - 静态资源 SPA fallback 直读 index.html,修复深链接被 301 到根页的问题 - 路径遍历防护、CIDR/时长/大小溢出校验、webhook 非 2xx 视为失败 - 前端:搜索竞态守卫、表单校验、登录回跳、日期守卫、日志容错 --- README.md | 3 +- configs/devbox.yaml | 4 +- internal/config/config.go | 75 +++++++++++++---- internal/dashboard/dashboard.go | 69 +++++++++++++++- internal/gitproxy/gitproxy.go | 65 +++++++++++++-- internal/server/server.go | 97 +++++++++++++++++----- web/src/api/client.ts | 12 ++- web/src/router/index.ts | 3 +- web/src/views/Dashboard.vue | 6 +- web/src/views/Login.vue | 4 +- web/src/views/Mirrors.vue | 142 ++++++++++++++++++++++++++++---- web/src/views/Releases.vue | 4 +- web/src/views/Search.vue | 8 +- web/src/views/Settings.vue | 11 +++ 14 files changed, 436 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 615a545..59d415a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ | Conda 镜像 | 代理 `https://repo.anaconda.com` | | RubyGems 镜像 | 代理 `https://rubygems.org` | | Cargo 镜像 | 代理 `https://static.crates.io/crates` | -| NuGet 镜像 | 代理 `https://api.nuget.org/v3/index.json` | +| NuGet 镜像 | 代理 `https://api.nuget.org/v3` | | APT 镜像 | 代理 `https://deb.debian.org/debian` | | Alpine 镜像 | 代理 `https://dl-cdn.alpinelinux.org/alpine` | | Homebrew 镜像 | 代理 `https://ghcr.io/v2/homebrew/core` | @@ -30,6 +30,7 @@ | Docker v2 Auth | Token 认证代理,让 `docker pull` 不依赖直接访问上游 | | 镜像搜索 | Dashboard 搜索 npm、Docker Hub、PyPI、Conda、RubyGems、Cargo、NuGet 包 | | IP 限流 | 滚动时间窗口限流防滥用,白名单免限速,黑名单直接拒绝 | +| 缓存 TTL 可调 | 面板可逐镜像配置缓存 TTL(0=永久缓存),Git 代理缓存 TTL 独立可调 | | 健康检查端点 | `/health` 端点供 Kubernetes/Docker probe 使用 | | Prometheus 指标 | `/metrics` 端点暴露缓存命中率等指标 | | 优雅关闭 | 收到 SIGTERM 后等待请求完成再退出 | diff --git a/configs/devbox.yaml b/configs/devbox.yaml index a54bd8e..9aca4ae 100644 --- a/configs/devbox.yaml +++ b/configs/devbox.yaml @@ -43,7 +43,7 @@ mirrors: hf: enabled: true upstream: "https://huggingface.co" - cache_ttl: "7d" + cache_ttl: "0" conda: enabled: true upstream: "https://repo.anaconda.com" @@ -58,7 +58,7 @@ mirrors: cache_ttl: "7d" nuget: enabled: true - upstream: "https://api.nuget.org/v3/index.json" + upstream: "https://api.nuget.org/v3" cache_ttl: "7d" apt: enabled: true diff --git a/internal/config/config.go b/internal/config/config.go index 9ac36a7..3892d83 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,10 @@ type Config struct { Logging LoggingConfig `yaml:"logging"` RateLimit RateLimitConfig `yaml:"rate_limit"` Alerts AlertConfig `yaml:"alerts"` + + // AuthTokenOrig holds the auth_token value from the yaml file so that + // Save() can avoid persisting an env-injected DEVBOX_AUTH_TOKEN. + AuthTokenOrig string `yaml:"-"` } type AlertConfig struct { @@ -74,11 +78,21 @@ type RateLimitConfig struct { } func (cfg *Config) Save(path string) error { + // Never persist an env-injected auth token: DEVBOX_AUTH_TOKEN must stay + // out of the config file. Save the file value (or empty) instead. + orig := cfg.Server.AuthToken + cfg.Server.AuthToken = cfg.AuthTokenOrig data, err := yaml.Marshal(cfg) + cfg.Server.AuthToken = orig if err != nil { return fmt.Errorf("marshal config: %w", err) } - return os.WriteFile(path, data, 0600) + + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0600); err != nil { + return fmt.Errorf("write config: %w", err) + } + return os.Rename(tmp, path) } func Load(path string) (*Config, error) { @@ -92,6 +106,9 @@ func Load(path string) (*Config, error) { return nil, fmt.Errorf("parse config: %w", err) } + // Remember the file value before env overrides replace it. + cfg.AuthTokenOrig = cfg.Server.AuthToken + applyDefaults(cfg) applyEnvOverrides(cfg) @@ -150,11 +167,11 @@ func applyDefaults(cfg *Config) { "quay": {Enabled: true, Upstream: "https://quay.io", CacheTTL: "0"}, "mcr": {Enabled: true, Upstream: "https://mcr.microsoft.com", CacheTTL: "0"}, "ghapi": {Enabled: true, Upstream: "https://api.github.com", CacheTTL: "0"}, - "hf": {Enabled: true, Upstream: "https://huggingface.co", CacheTTL: "7d"}, + "hf": {Enabled: true, Upstream: "https://huggingface.co", CacheTTL: "0"}, "conda": {Enabled: true, Upstream: "https://repo.anaconda.com", CacheTTL: "30d"}, "rubygems": {Enabled: true, Upstream: "https://rubygems.org", CacheTTL: "7d"}, "cargo": {Enabled: true, Upstream: "https://static.crates.io/crates", CacheTTL: "7d"}, - "nuget": {Enabled: true, Upstream: "https://api.nuget.org/v3/index.json", CacheTTL: "7d"}, + "nuget": {Enabled: true, Upstream: "https://api.nuget.org/v3", CacheTTL: "7d"}, "apt": {Enabled: true, Upstream: "https://deb.debian.org/debian", CacheTTL: "0"}, "alpine": {Enabled: true, Upstream: "https://dl-cdn.alpinelinux.org/alpine", CacheTTL: "0"}, "homebrew": {Enabled: true, Upstream: "https://ghcr.io/v2/homebrew/core", CacheTTL: "0"}, @@ -185,7 +202,11 @@ func applyDefaults(cfg *Config) { func applyEnvOverrides(cfg *Config) { if v := os.Getenv("DEVBOX_SERVER_PORT"); v != "" { - cfg.Server.Port = mustInt(v) + if n, err := mustInt(v); err == nil { + cfg.Server.Port = n + } else { + slog.Error("invalid env DEVBOX_SERVER_PORT, keeping file value", "value", v) + } } if v := os.Getenv("DEVBOX_AUTH_TOKEN"); v != "" { cfg.Server.AuthToken = v @@ -200,13 +221,25 @@ func applyEnvOverrides(cfg *Config) { cfg.Cache.MaxSize = v } if v := os.Getenv("DEVBOX_LOGGING_RETENTION_DAYS"); v != "" { - cfg.Logging.RetentionDays = mustInt(v) + if n, err := mustInt(v); err == nil { + cfg.Logging.RetentionDays = n + } else { + slog.Error("invalid env DEVBOX_LOGGING_RETENTION_DAYS, keeping file value", "value", v) + } } if v := os.Getenv("DEVBOX_RATE_LIMIT_ENABLED"); v != "" { - cfg.RateLimit.Enabled = mustBool(v) + if b, err := mustBool(v); err == nil { + cfg.RateLimit.Enabled = b + } else { + slog.Error("invalid env DEVBOX_RATE_LIMIT_ENABLED, keeping file value", "value", v) + } } if v := os.Getenv("DEVBOX_RATE_LIMIT_RATE"); v != "" { - cfg.RateLimit.Rate = mustInt(v) + if n, err := mustInt(v); err == nil { + cfg.RateLimit.Rate = n + } else { + slog.Error("invalid env DEVBOX_RATE_LIMIT_RATE, keeping file value", "value", v) + } } if v := os.Getenv("DEVBOX_RATE_LIMIT_INTERVAL"); v != "" { cfg.RateLimit.Interval = v @@ -224,9 +257,13 @@ func applyEnvOverrides(cfg *Config) { } enabled := os.Getenv(fmt.Sprintf("DEVBOX_MIRROR_%s_ENABLED", strings.ToUpper(name))) if enabled != "" { - m := cfg.Mirrors[name] - m.Enabled = mustBool(enabled) - cfg.Mirrors[name] = m + if b, err := mustBool(enabled); err == nil { + m := cfg.Mirrors[name] + m.Enabled = b + cfg.Mirrors[name] = m + } else { + slog.Error("invalid env DEVBOX_MIRROR_"+strings.ToUpper(name)+"_ENABLED, keeping file value", "value", enabled) + } } } } @@ -284,6 +321,10 @@ func ParseDuration(s string) (time.Duration, error) { if err != nil { return 0, fmt.Errorf("invalid days: %s", s) } + // Guard against int overflow when converting days to nanoseconds. + if days > int((1<<63-1)/(24*int(time.Hour))) { + return 0, fmt.Errorf("duration too large: %s", s) + } return time.Duration(days) * 24 * time.Hour, nil } return time.ParseDuration(s) @@ -304,6 +345,10 @@ func parseCacheMaxSize(cfg *Config) error { if err != nil { return fmt.Errorf("invalid cache max_size: %s", s) } + // Guard against multiplication overflow (e.g. huge values in GB). + if sf.mul != 1 && val > (1<<63-1)/sf.mul { + return fmt.Errorf("cache max_size too large: %s", s) + } cfg.Cache.MaxSizeBytes = val * sf.mul return nil } @@ -311,12 +356,10 @@ func parseCacheMaxSize(cfg *Config) error { return fmt.Errorf("invalid cache max_size suffix: %s", s) } -func mustInt(s string) int { - v, _ := strconv.Atoi(s) - return v +func mustInt(s string) (int, error) { + return strconv.Atoi(s) } -func mustBool(s string) bool { - v, _ := strconv.ParseBool(s) - return v +func mustBool(s string) (bool, error) { + return strconv.ParseBool(s) } diff --git a/internal/dashboard/dashboard.go b/internal/dashboard/dashboard.go index 9496036..678635b 100644 --- a/internal/dashboard/dashboard.go +++ b/internal/dashboard/dashboard.go @@ -11,6 +11,7 @@ import ( "time" "devbox/internal/alert" + "devbox/internal/config" "devbox/internal/mirror" "devbox/internal/store" ) @@ -20,6 +21,7 @@ type Dashboard struct { authToken string publicURL string rlConfig RateLimitConfigAccessor + gitProxyConfig GitProxyConfigAccessor saveConfig func() error alertEngine *alert.Engine releaseHTTP *http.Client @@ -51,6 +53,11 @@ type RateLimitConfigAccessor interface { SetRateLimitBlacklist(list []string) } +type GitProxyConfigAccessor interface { + GetCacheTTL() string + SetCacheTTL(ttl string) error +} + type RateLimitConfigView struct { Enabled bool `json:"enabled"` Rate int `json:"rate"` @@ -83,6 +90,10 @@ func (d *Dashboard) SetRateLimitConfigAccessor(rl RateLimitConfigAccessor) { d.rlConfig = rl } +func (d *Dashboard) SetGitProxyConfigAccessor(gp GitProxyConfigAccessor) { + d.gitProxyConfig = gp +} + func (d *Dashboard) GetRateLimitConfig() RateLimitConfigView { if d.rlConfig == nil { return RateLimitConfigView{} @@ -364,7 +375,7 @@ func (d *Dashboard) MirrorConfigHandler(w http.ResponseWriter, r *http.Request) if d.saveConfig != nil { if err := d.saveConfig(); err != nil { slog.Error("failed to persist config", "error", err) - writeJSON(w, map[string]string{"status": "persist_failed", "error": err.Error()}) + http.Error(w, "persist_failed", http.StatusInternalServerError) return } } @@ -442,6 +453,16 @@ func (d *Dashboard) RateLimitConfigHandler(w http.ResponseWriter, r *http.Reques http.Error(w, "invalid request", http.StatusBadRequest) return } + // Validate before mutating anything: a bad interval or rate must + // not silently keep the old values while reporting success. + if _, err := config.ParseDuration(req.Interval); err != nil { + http.Error(w, "invalid interval", http.StatusBadRequest) + return + } + if req.Rate <= 0 { + http.Error(w, "rate must be positive", http.StatusBadRequest) + return + } d.rlConfig.SetRateLimitEnabled(req.Enabled) d.rlConfig.SetRateLimitRate(req.Rate) d.rlConfig.SetRateLimitInterval(req.Interval) @@ -461,11 +482,55 @@ func (d *Dashboard) RateLimitConfigHandler(w http.ResponseWriter, r *http.Reques http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } +func (d *Dashboard) GitProxyConfigHandler(w http.ResponseWriter, r *http.Request) { + if !d.checkAuth(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + if d.gitProxyConfig == nil { + http.Error(w, "git proxy not available", http.StatusNotFound) + return + } + + if r.Method == http.MethodGet { + writeJSON(w, map[string]string{"cacheTTL": d.gitProxyConfig.GetCacheTTL()}) + return + } + + if r.Method == http.MethodPut { + var req struct { + CacheTTL string `json:"cacheTTL"` + } + if !readJSON(r, &req) { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + if err := d.gitProxyConfig.SetCacheTTL(req.CacheTTL); err != nil { + http.Error(w, "invalid cacheTTL: "+err.Error(), http.StatusBadRequest) + return + } + if d.saveConfig != nil { + if err := d.saveConfig(); err != nil { + slog.Error("failed to persist git proxy config", "error", err) + http.Error(w, "persist_failed", http.StatusInternalServerError) + return + } + } + writeJSON(w, map[string]string{"cacheTTL": d.gitProxyConfig.GetCacheTTL()}) + return + } + + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) +} + func (d *Dashboard) checkAuth(r *http.Request) bool { if d.authToken == "" { return true // no auth required } token := r.Header.Get("Authorization") - token = strings.TrimPrefix(token, "Bearer ") + if len(token) > 7 && strings.EqualFold(token[:7], "Bearer ") { + token = token[7:] + } return subtle.ConstantTimeCompare([]byte(token), []byte(d.authToken)) == 1 } diff --git a/internal/gitproxy/gitproxy.go b/internal/gitproxy/gitproxy.go index af3dbc3..8895792 100644 --- a/internal/gitproxy/gitproxy.go +++ b/internal/gitproxy/gitproxy.go @@ -1,12 +1,15 @@ package gitproxy import ( + "fmt" "io" "log/slog" "net/http" "strings" + "sync" "time" + "devbox/internal/config" "devbox/internal/mirror" ) @@ -25,6 +28,7 @@ type GitProxy struct { rawUpstream string cacheTTL time.Duration cache *mirror.Cache + mu sync.RWMutex } func New(githubUpstream, gitlabUpstream, rawUpstream string, cacheTTL time.Duration, cache *mirror.Cache) *GitProxy { @@ -37,6 +41,37 @@ func New(githubUpstream, gitlabUpstream, rawUpstream string, cacheTTL time.Durat } } +func (gp *GitProxy) CacheTTL() string { + gp.mu.RLock() + defer gp.mu.RUnlock() + d := gp.cacheTTL + if d == 0 { + return "0" + } + secs := d / time.Second + if secs%86400 == 0 { + return fmt.Sprintf("%dd", secs/86400) + } + if secs%3600 == 0 { + return fmt.Sprintf("%dh", secs/3600) + } + if secs%60 == 0 { + return fmt.Sprintf("%dm", secs/60) + } + return fmt.Sprintf("%ds", secs) +} + +func (gp *GitProxy) SetCacheTTL(ttl string) error { + d, err := config.ParseDuration(ttl) + if err != nil { + return err + } + gp.mu.Lock() + defer gp.mu.Unlock() + gp.cacheTTL = d + return nil +} + func (gp *GitProxy) Handler(w http.ResponseWriter, r *http.Request) { path := r.URL.Path @@ -86,9 +121,17 @@ func isRawRequest(path string) bool { } func (gp *GitProxy) proxyArchive(w http.ResponseWriter, r *http.Request, upstream, path string) { - if gp.cache != nil && gp.cacheTTL > 0 { + gp.mu.RLock() + cacheTTL := gp.cacheTTL + gp.mu.RUnlock() + if gp.cache != nil && cacheTTL > 0 { target := upstream + path if resp, err := client.Head(target); err == nil { + if resp.StatusCode == http.StatusNotFound { + resp.Body.Close() + http.Error(w, "not found", http.StatusNotFound) + return + } if isHTMLResponse(resp) { resp.Body.Close() slog.Warn("gitproxy blocking HTML response (cache)", "path", path) @@ -121,9 +164,17 @@ func (gp *GitProxy) proxyArchive(w http.ResponseWriter, r *http.Request, upstrea } func (gp *GitProxy) proxyRaw(w http.ResponseWriter, r *http.Request, path string) { - if gp.cache != nil && gp.cacheTTL > 0 { + gp.mu.RLock() + cacheTTL := gp.cacheTTL + gp.mu.RUnlock() + if gp.cache != nil && cacheTTL > 0 { target := gp.rawUpstream + path if resp, err := client.Head(target); err == nil { + if resp.StatusCode == http.StatusNotFound { + resp.Body.Close() + http.Error(w, "not found", http.StatusNotFound) + return + } if isHTMLResponse(resp) { resp.Body.Close() slog.Warn("gitproxy blocking HTML response (cache)", "path", path) @@ -134,7 +185,7 @@ func (gp *GitProxy) proxyRaw(w http.ResponseWriter, r *http.Request, path string } orig := r.URL.Path r.URL.Path = path - gp.cache.ProxyHTTP(w, r, gp.rawUpstream, gp.cacheTTL) + gp.cache.ProxyHTTP(w, r, gp.rawUpstream, cacheTTL) r.URL.Path = orig return } @@ -156,10 +207,13 @@ func (gp *GitProxy) proxyRaw(w http.ResponseWriter, r *http.Request, path string } func (gp *GitProxy) proxySmartHTTP(w http.ResponseWriter, r *http.Request, upstream, path string) { - if gp.cache != nil && gp.cacheTTL > 0 && r.Method == "GET" { + gp.mu.RLock() + cacheTTL := gp.cacheTTL + gp.mu.RUnlock() + if gp.cache != nil && cacheTTL > 0 && r.Method == "GET" { orig := r.URL.Path r.URL.Path = path - gp.cache.ProxyHTTP(w, r, upstream, gp.cacheTTL) + gp.cache.ProxyHTTP(w, r, upstream, cacheTTL) r.URL.Path = orig return } @@ -173,6 +227,7 @@ func (gp *GitProxy) proxySmartHTTP(w http.ResponseWriter, r *http.Request, upstr http.Error(w, "request error", http.StatusInternalServerError) return } + newReq.ContentLength = r.ContentLength copyRequestHeaders(newReq, r) resp, err := client.Do(newReq) diff --git a/internal/server/server.go b/internal/server/server.go index 034e5a1..1f523cd 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -138,6 +138,7 @@ func New(cfg *config.Config, configPath string, frontDir string) (*Server, error } dash.SetRateLimitConfigAccessor(s) + dash.SetGitProxyConfigAccessor(s) dash.SetSaveConfig(s.saveConfig) return s, nil @@ -165,6 +166,7 @@ func (s *Server) Start() error { mux.HandleFunc("/api/stats/logs", s.dash.LogHandler) mux.HandleFunc("/api/config/mirrors", s.dash.MirrorConfigHandler) mux.HandleFunc("/api/config/ratelimit", s.dash.RateLimitConfigHandler) + mux.HandleFunc("/api/config/gitproxy", s.dash.GitProxyConfigHandler) mux.HandleFunc("/api/config/public", s.dash.PublicConfigHandler) mux.HandleFunc("/api/auth/login", s.dash.LoginHandler) mux.HandleFunc("/api/auth/check", s.dash.AuthCheckHandler) @@ -186,13 +188,31 @@ func (s *Server) Start() error { if s.frontDir != "" { if _, err := os.Stat(s.frontDir); err == nil { fileServer := http.FileServer(http.Dir(s.frontDir)) + frontDir := filepath.Clean(s.frontDir) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if path == "/" { path = "/index.html" } - if _, err := os.Stat(s.frontDir + path); err != nil { - r.URL.Path = "/index.html" + full := filepath.Join(frontDir, filepath.FromSlash(path)) + if full != frontDir && !strings.HasPrefix(full, frontDir+string(filepath.Separator)) { + // Path escapes the frontend dir (".." traversal): fall back. + full = filepath.Join(frontDir, "index.html") + } + if _, err := os.Stat(full); err != nil { + // SPA fallback. Serve index.html directly instead of + // rewriting r.URL.Path: http.FileServer 301-redirects a + // rewritten "/index.html" path to "./" (breaking deep + // links like /mirrors). + data, rerr := os.ReadFile(filepath.Join(frontDir, "index.html")) + if rerr != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) + return } fileServer.ServeHTTP(w, r) }) @@ -208,16 +228,15 @@ func (s *Server) Start() error { go s.tokenCacheCleanup() port, accessLog := s.serverConfigSnapshot() - rl := s.rateLimitConfigSnapshot() addr := fmt.Sprintf(":%d", port) slog.Info("DevBox starting", "addr", addr) handler := logMiddleware(mux, accessLog) handler = s.bodyLimitMiddleware(handler) - if s.getLimiter() != nil { - handler = s.rateLimitMiddleware(handler) - slog.Info("rate limiting enabled", "rate", rl.Rate, "interval", rl.Interval) - } + // Always install the rate limit middleware; it internally no-ops when + // rate limiting is disabled, so enabling it at runtime works without a + // server restart. + handler = s.rateLimitMiddleware(handler) srv := &http.Server{ Addr: addr, @@ -340,6 +359,7 @@ func (s *Server) proxyRegistryRequest(w http.ResponseWriter, r *http.Request, ta http.Error(w, "request error", http.StatusInternalServerError) return } + upstreamReq.ContentLength = r.ContentLength // Copy client headers (except Host) for k, vv := range r.Header { @@ -381,10 +401,14 @@ func (s *Server) proxyRegistryRequest(w http.ResponseWriter, r *http.Request, ta } resp.Body.Close() - // Retry without token — let upstream give fresh 401 - slog.Warn("registry token rejected, retrying without token") - s.proxyRegistryRequest(w, r, target, "", depth+1) - return + // Retry without token — let upstream give fresh 401. + // Only replay body-less requests: http.Client.Do closed r.Body + // on the first attempt, so uploads cannot be safely retried. + if r.Method == http.MethodGet || r.ContentLength == 0 { + slog.Warn("registry token rejected, retrying without token") + s.proxyRegistryRequest(w, r, target, "", depth+1) + return + } } // If 401 without token, get a new token with scope and retry @@ -403,8 +427,10 @@ func (s *Server) proxyRegistryRequest(w http.ResponseWriter, r *http.Request, ta if err == nil && newToken != "" { resp.Body.Close() slog.Info("registry got new token, retrying", "scope", scope) - s.proxyRegistryRequest(w, r, target, newToken, depth+1) - return + if r.Method == http.MethodGet || r.ContentLength == 0 { + s.proxyRegistryRequest(w, r, target, newToken, depth+1) + return + } } } @@ -484,14 +510,16 @@ func (s *Server) getRegistryToken(regInfo registryInfo, scope string) (string, e return "", fmt.Errorf("empty token") } - // Cache token (use expires_in minus 5 min safety margin, min 2 min) + // Cache token with a 5 min safety margin; never cache a token for + // longer than it is actually valid (clamping to a minimum would serve + // expired tokens and cause 401 retry storms). ttl := time.Duration(tokenResp.ExpiresIn) * time.Second if ttl == 0 { ttl = 5 * time.Minute } ttl = ttl - 5*time.Minute - if ttl < 2*time.Minute { - ttl = 2 * time.Minute + if ttl <= 0 { + return tokenResp.Token, nil } s.tokenCache.mu.Lock() @@ -648,13 +676,17 @@ func (s *Server) configWatcher() { if fi.ModTime().Equal(lastMtime) { continue } - lastMtime = fi.ModTime() + newMtime := fi.ModTime() cfg, err := config.Load(s.configPath) if err != nil { + // Do not advance lastMtime on failure: keep polling this file + // state so a transient write (e.g. atomic rename in progress) + // retries until the config becomes valid again. slog.Error("config hot-reload failed to load, keeping old config", "error", err) continue } + lastMtime = newMtime s.applyRuntimeConfig(cfg) slog.Info("config hot-reloaded", "path", s.configPath) @@ -722,6 +754,8 @@ func (s *Server) saveConfig() error { s.cfg.Mirrors[m.Name()] = existing } + s.cfg.GitProxy.CacheTTL = s.gitProxy.CacheTTL() + if err := s.cfg.Save(s.configPath); err != nil { slog.Error("config failed to persist", "error", err) return err @@ -779,6 +813,17 @@ func (s *Server) SetRateLimitBlacklist(list []string) { s.rebuildLimiter() } +func (s *Server) GetCacheTTL() string { + s.cfgMu.RLock() + gp := s.gitProxy + s.cfgMu.RUnlock() + return gp.CacheTTL() +} + +func (s *Server) SetCacheTTL(ttl string) error { + return s.gitProxy.SetCacheTTL(ttl) +} + func (s *Server) rebuildLimiter() { rl := s.rateLimitConfigSnapshot() s.limiterMu.Lock() @@ -834,7 +879,11 @@ func (s *Server) wrapWithDynamicStats(nameFor func(*http.Request) string, handle start := time.Now() handler(sw, r) name := nameFor(r) - if err := s.store.RecordTraffic(name, r.Method, r.URL.Path, 0, sw.bytesWritten, sw.status); err != nil { + bytesIn := int64(0) + if r.ContentLength > 0 { + bytesIn = r.ContentLength + } + if err := s.store.RecordTraffic(name, r.Method, r.URL.Path, int(bytesIn), sw.bytesWritten, sw.status); err != nil { slog.Warn("record traffic failed", "name", name, "error", err) } slog.Info("request stats", @@ -884,7 +933,9 @@ type statusWriter struct { } func (sw *statusWriter) WriteHeader(code int) { - sw.status = code + if sw.status == 0 { + sw.status = code + } sw.ResponseWriter.WriteHeader(code) } @@ -919,8 +970,12 @@ func (s *Server) rateLimitMiddleware(next http.Handler) http.Handler { // force attempts are throttled like everything else. if path == "/api/auth/login" { // no-op: fall through to rate limiting - } else if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/v2/") || - strings.HasPrefix(path, "/token") || path == "/" || path == "/health" || + } else if (strings.HasPrefix(path, "/api/") && + path != "/api/search" && + !strings.HasPrefix(path, "/api/release-sources") && + !strings.HasPrefix(path, "/api/release-download")) || + strings.HasPrefix(path, "/v2/") || strings.HasPrefix(path, "/token") || + path == "/" || path == "/health" || strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") || strings.HasSuffix(path, ".ico") { next.ServeHTTP(w, r) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 413a742..d5b2853 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -14,7 +14,9 @@ api.interceptors.response.use( localStorage.removeItem("devbox_token"); setToken(""); if (window.location.pathname !== "/login") { - window.location.href = "/login"; + window.location.href = + "/login?redirect=" + + encodeURIComponent(window.location.pathname + window.location.search); } } return Promise.reject(error); @@ -97,6 +99,14 @@ export async function getPublicConfig() { return api.get("/config/public").then((r) => r.data); } +export async function getGitProxyConfig() { + return api.get("/config/gitproxy").then((r) => r.data); +} + +export async function updateGitProxyConfig(cacheTTL: string) { + return api.put("/config/gitproxy", { cacheTTL }).then((r) => r.data); +} + export async function searchMirrors( q: string, registry?: string, diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 7cf58a6..ef85aa7 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -39,7 +39,8 @@ router.beforeEach(async (to) => { const required = await checkAuthRequired() if (!required) return true - return '/login' + // Remember where the user was headed so the login page can bounce back. + return { path: '/login', query: { redirect: to.fullPath } } }) export default router \ No newline at end of file diff --git a/web/src/views/Dashboard.vue b/web/src/views/Dashboard.vue index d0b8f16..5a29fe4 100644 --- a/web/src/views/Dashboard.vue +++ b/web/src/views/Dashboard.vue @@ -209,7 +209,11 @@ async function switchGranularity(level: 'hourly' | 'daily' | 'weekly') { } async function refreshLogs() { - logs.value = await getRecentLogs(50) + try { + logs.value = await getRecentLogs(50) + } catch (e: any) { + errorMsg.value = e.response?.statusText || '日志加载失败' + } } function formatBytes(b: number) { diff --git a/web/src/views/Login.vue b/web/src/views/Login.vue index 0a10258..88b4807 100644 --- a/web/src/views/Login.vue +++ b/web/src/views/Login.vue @@ -15,7 +15,9 @@ async function submit() { error.value = '' try { await login(password.value) - window.location.href = '/' + // Only allow relative-path redirects to avoid open-redirect abuse. + const redirect = new URLSearchParams(window.location.search).get('redirect') + window.location.href = redirect && redirect.startsWith('/') ? redirect : '/' } catch { error.value = '认证被拒绝,请检查 AUTH_TOKEN' } diff --git a/web/src/views/Mirrors.vue b/web/src/views/Mirrors.vue index 1f3b10e..1b56824 100644 --- a/web/src/views/Mirrors.vue +++ b/web/src/views/Mirrors.vue @@ -1,6 +1,7 @@ 该镜像不缓存 diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue index 8b7bd27..7569240 100644 --- a/web/src/views/Settings.vue +++ b/web/src/views/Settings.vue @@ -43,7 +43,8 @@ async function saveRateLimit() { rlSaving.value = false return } - if (!/^\d+(ms|s|m|h|d)$/.test(rlInterval.value.trim())) { + const interval = rlInterval.value.trim() + if (!/^\d+(ms|s|m|h|d)$/.test(interval)) { rlMsg.value = '时间窗口格式无效(如 3h、30m、1d)' rlSaving.value = false return @@ -51,7 +52,7 @@ async function saveRateLimit() { try { const wl = rlWhitelist.value.split(',').map(s => s.trim()).filter(Boolean) const bl = rlBlacklist.value.split(',').map(s => s.trim()).filter(Boolean) - const res = await updateRateLimitConfig({ enabled: rlEnabled.value, rate: rlRate.value, interval: rlInterval.value, whitelist: wl, blacklist: bl }) + const res = await updateRateLimitConfig({ enabled: rlEnabled.value, rate: rlRate.value, interval, whitelist: wl, blacklist: bl }) rlEnabled.value = res.enabled rlRate.value = res.rate rlInterval.value = res.interval || rlInterval.value