Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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 后等待请求完成再退出 |
Expand Down
4 changes: 2 additions & 2 deletions configs/devbox.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion internal/alert/alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ func (w *WebhookProvider) Send(subject, body string) error {
if err != nil {
return fmt.Errorf("alert webhook: %w", err)
}
resp.Body.Close()
defer resp.Body.Close()
// Treat non-2xx responses as failures so misconfigured webhooks surface.
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("alert webhook returned %d", resp.StatusCode)
}
return nil
}

Expand Down
75 changes: 59 additions & 16 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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)

Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
}
}
}
}
Expand Down Expand Up @@ -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)
Expand All @@ -304,19 +345,21 @@ 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
}
}
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)
}
69 changes: 67 additions & 2 deletions internal/dashboard/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

"devbox/internal/alert"
"devbox/internal/config"
"devbox/internal/mirror"
"devbox/internal/store"
)
Expand All @@ -20,6 +21,7 @@ type Dashboard struct {
authToken string
publicURL string
rlConfig RateLimitConfigAccessor
gitProxyConfig GitProxyConfigAccessor
saveConfig func() error
alertEngine *alert.Engine
releaseHTTP *http.Client
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
15 changes: 12 additions & 3 deletions internal/dashboard/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,15 +312,18 @@ func (d *Dashboard) ReleaseDownloadHandler(w http.ResponseWriter, r *http.Reques
contentType = "application/octet-stream"
}
w.Header().Set("Content-Type", contentType)
if ticket.asset.Size > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(ticket.asset.Size, 10))
}
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": ticket.asset.Name}))
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Accept-Ranges", "bytes")

if r.Method == http.MethodHead {
// Only HEAD preflight can rely on the ticket metadata: the actual
// GET streams the upstream body and must not pre-announce a length
// that could mismatch the real content.
if ticket.asset.Size > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(ticket.asset.Size, 10))
}
w.WriteHeader(http.StatusOK)
return
}
Expand Down Expand Up @@ -354,6 +357,12 @@ func (d *Dashboard) ReleaseDownloadHandler(w http.ResponseWriter, r *http.Reques
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
slog.Warn("release download upstream status", "source_id", ticket.sourceID, "status", resp.StatusCode)
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
// Client-side errors (404 deleted asset, 403 expired URL, ...)
// are pass-through so the caller sees the real reason.
http.Error(w, resp.Status, resp.StatusCode)
return
}
http.Error(w, "release download upstream returned "+resp.Status, http.StatusBadGateway)
return
}
Expand Down
Loading
Loading