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
29 changes: 19 additions & 10 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
# Stage 1: Build frontend
FROM node:20-alpine AS frontend
# Stage 1: Build frontend (architecture-independent static assets)
# $BUILDPLATFORM ensures this stage only ever runs on the native builder,
# since the same dist/ works for both amd64 and arm64 images.
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend
RUN corepack enable && corepack prepare pnpm@10 --activate
WORKDIR /app/web
COPY web/pnpm-lock.yaml web/package.json ./
RUN pnpm install --frozen-lockfile
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY web/ .
RUN pnpm run build
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm run build

# Stage 2: Build Go binary (CGO_ENABLED=0 cross-compile to Linux)
FROM golang:1.25-alpine AS backend
# Stage 2: Cross-compile Go binary.
# CGO_ENABLED=0 lets us compile for the target arch on the native builder,
# avoiding slow QEMU emulation entirely.
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS backend
WORKDIR /app
ARG TARGETOS TARGETARCH
COPY go.mod go.sum ./
RUN go mod download
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o devbox ./cmd/devbox/
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-s -w" -o devbox ./cmd/devbox/

# Stage 3: Minimal runtime image
FROM alpine:3.21
# Stage 3: Minimal runtime image (per-target-architecture base)
FROM --platform=$TARGETPLATFORM alpine:3.21

Check warning on line 28 in Dockerfile

View workflow job for this annotation

GitHub Actions / build-and-test

Setting platform to predefined $TARGETPLATFORM in FROM is redundant as this is the default behavior

RedundantTargetPlatform: Setting platform to predefined $TARGETPLATFORM in FROM is redundant as this is the default behavior More info: https://docs.docker.com/go/dockerfile/rule/redundant-target-platform/
RUN apk add --no-cache ca-certificates git curl
COPY --from=backend /app/devbox /usr/local/bin/devbox
COPY --from=frontend /app/web/dist /usr/share/devbox/frontend
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ docker run -d \
-p 127.0.0.1:8080:8080 \
-v devbox-data:/data \
-e DEVBOX_PUBLIC_URL=https://dev.example.com \
ghcr.io/ksbbs/devbox:latest
ghcr.io/wha7ev9r/devbox:latest
```

## 配置
Expand Down Expand Up @@ -198,6 +198,10 @@ logging:
retention_days: 30 # 流量日志保留天数
```

> 限流仅信任来自 `rate_limit.trusted_proxies` 内代理网段的 `X-Real-IP` / `X-Forwarded-For`
> (默认仅本机回环);若端口直接暴露公网,伪造这两个头不会生效,将按真实连接 IP 限流。
> Docker 部署 + 宿主机 nginx 反代时,需把 docker 网桥网段(如 `172.16.0.0/12`)加入 `trusted_proxies`。

### 环境变量覆盖

所有配置项都可通过环境变量覆盖,格式 `DEVBOX_<层级>_<键>`:
Expand Down Expand Up @@ -280,7 +284,7 @@ Docker Hub 使用 `registry-mirrors` 配置(Docker 原生支持):
docker pull dev.example.com/ghcr/owner/image:tag

# 例如拉取 DevBox 自身
docker pull dev.example.com/ghcr/ksbbs/devbox:latest
docker pull dev.example.com/ghcr/wha7ev9r/devbox:latest
```

> 不需要加 `https://`,Docker 客户端会自动走 HTTPS。如果未配置 SSL,需加 `http://` 前缀并设置 Docker insecure registry。
Expand Down
2 changes: 0 additions & 2 deletions cmd/devbox/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ import (
"devbox/internal/config"
"devbox/internal/mirror"
"devbox/internal/server"

_ "devbox/internal/mirror"
)

func main() {
Expand Down
1 change: 1 addition & 0 deletions configs/devbox.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,4 @@ rate_limit:
interval: "3h" # 滚动时间窗口长度
whitelist: [] # 白名单 IP/CIDR(免限速)
blacklist: [] # 黑名单 IP/CIDR(永远禁止)
trusted_proxies: ["127.0.0.1/8", "::1/128"] # 信任转发头(如 X-Real-IP)的代理网段,反代场景按需加 docker 网桥等
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
devbox:
image: ghcr.io/ksbbs/devbox:latest
image: ghcr.io/wha7ev9r/devbox:latest
container_name: devbox
restart: always
ports:
Expand Down
21 changes: 15 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,13 @@ type LoggingConfig struct {
}

type RateLimitConfig struct {
Enabled bool `yaml:"enabled"`
Rate int `yaml:"rate"` // max requests per rolling window
Interval string `yaml:"interval"` // rolling window length, e.g. "3h"
IntervalDur time.Duration `yaml:"-"` // parsed
Whitelist []string `yaml:"whitelist"` // IPs exempt from rate limiting
Blacklist []string `yaml:"blacklist"` // IPs always blocked
Enabled bool `yaml:"enabled"`
Rate int `yaml:"rate"` // max requests per rolling window
Interval string `yaml:"interval"` // rolling window length, e.g. "3h"
IntervalDur time.Duration `yaml:"-"` // parsed
Whitelist []string `yaml:"whitelist"` // IPs exempt from rate limiting
Blacklist []string `yaml:"blacklist"` // IPs always blocked
TrustedProxies []string `yaml:"trusted_proxies"` // CIDRs whose X-Real-IP / X-Forwarded-For are trusted
}

func (cfg *Config) Save(path string) error {
Expand Down Expand Up @@ -133,6 +134,11 @@ func applyDefaults(cfg *Config) {
if cfg.RateLimit.Rate == 0 {
cfg.RateLimit.Rate = 500
}
if len(cfg.RateLimit.TrustedProxies) == 0 {
// Loopback only by default: proxy headers are trusted solely from a
// reverse proxy running on the same host (e.g. nginx + 127.0.0.1 bind).
cfg.RateLimit.TrustedProxies = []string{"127.0.0.1/8", "::1/128"}
}

defaultMirrors := map[string]MirrorConfig{
"npm": {Enabled: true, Upstream: "https://registry.npmjs.org", CacheTTL: "7d"},
Expand Down Expand Up @@ -205,6 +211,9 @@ func applyEnvOverrides(cfg *Config) {
if v := os.Getenv("DEVBOX_RATE_LIMIT_INTERVAL"); v != "" {
cfg.RateLimit.Interval = v
}
if v := os.Getenv("DEVBOX_RATE_LIMIT_TRUSTED_PROXIES"); v != "" {
cfg.RateLimit.TrustedProxies = strings.Split(v, ",")
}

for name := range cfg.Mirrors {
upstream := os.Getenv(fmt.Sprintf("DEVBOX_MIRROR_%s_UPSTREAM", strings.ToUpper(name)))
Expand Down
12 changes: 11 additions & 1 deletion internal/dashboard/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,10 @@ func (d *Dashboard) MirrorConfigHandler(w http.ResponseWriter, r *http.Request)
}

func (d *Dashboard) PublicConfigHandler(w http.ResponseWriter, r *http.Request) {
if !d.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
writeJSON(w, map[string]string{"publicUrl": d.publicURL})
}

Expand Down Expand Up @@ -403,13 +407,19 @@ func (d *Dashboard) LoginHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if req.Token != d.authToken {
if subtle.ConstantTimeCompare([]byte(req.Token), []byte(d.authToken)) != 1 {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
writeJSON(w, map[string]string{"status": "ok", "token": req.Token})
}

// CheckAuth exposes the token check to other packages (e.g. server routes
// that need the same auth policy as dashboard endpoints).
func (d *Dashboard) CheckAuth(r *http.Request) bool {
return d.checkAuth(r)
}

func (d *Dashboard) RateLimitConfigHandler(w http.ResponseWriter, r *http.Request) {
if !d.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
Expand Down
2 changes: 1 addition & 1 deletion internal/dashboard/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@ func readJSON(r *http.Request, v interface{}) bool {
return false
}
return json.Unmarshal(body, v) == nil
}
}
19 changes: 12 additions & 7 deletions internal/dashboard/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ import (
"net/http"
"net/url"
"strconv"
"time"
)

// searchClient bounds upstream registry API calls so a hung upstream cannot
// stall the request (or leak goroutines) forever.
var searchClient = &http.Client{Timeout: 10 * time.Second}

type SearchHandler struct{}

func NewSearchHandler() *SearchHandler {
Expand Down Expand Up @@ -98,7 +103,7 @@ func (sh *SearchHandler) Search(w http.ResponseWriter, r *http.Request) {
}

func searchNpm(query string, page, perPage int) ([]SearchResult, bool) {
resp, err := http.Get(fmt.Sprintf("https://registry.npmjs.org/-/v1/search?text=%s&size=%d&from=%d", url.QueryEscape(query), perPage, (page-1)*perPage))
resp, err := searchClient.Get(fmt.Sprintf("https://registry.npmjs.org/-/v1/search?text=%s&size=%d&from=%d", url.QueryEscape(query), perPage, (page-1)*perPage))
if err != nil {
return nil, false
}
Expand Down Expand Up @@ -131,7 +136,7 @@ func searchNpm(query string, page, perPage int) ([]SearchResult, bool) {
}

func searchDocker(query string, page, perPage int) ([]SearchResult, bool) {
resp, err := http.Get(fmt.Sprintf("https://registry.hub.docker.com/v2/search/repositories/?query=%s&page_size=%d&page=%d", url.QueryEscape(query), perPage, page))
resp, err := searchClient.Get(fmt.Sprintf("https://registry.hub.docker.com/v2/search/repositories/?query=%s&page_size=%d&page=%d", url.QueryEscape(query), perPage, page))
if err != nil {
return nil, false
}
Expand Down Expand Up @@ -164,7 +169,7 @@ func searchDocker(query string, page, perPage int) ([]SearchResult, bool) {
}

func searchPyPI(query string) []SearchResult {
resp, err := http.Get(fmt.Sprintf("https://pypi.org/pypi/%s/json", url.QueryEscape(query)))
resp, err := searchClient.Get(fmt.Sprintf("https://pypi.org/pypi/%s/json", url.QueryEscape(query)))
if err != nil {
return nil
}
Expand All @@ -191,7 +196,7 @@ func searchPyPI(query string) []SearchResult {
}

func searchConda(query string) []SearchResult {
resp, err := http.Get(fmt.Sprintf("https://api.anaconda.org/package/%s", url.QueryEscape(query)))
resp, err := searchClient.Get(fmt.Sprintf("https://api.anaconda.org/package/%s", url.QueryEscape(query)))
if err != nil {
return nil
}
Expand Down Expand Up @@ -221,7 +226,7 @@ func searchConda(query string) []SearchResult {
}

func searchRubyGems(query string, page, perPage int) ([]SearchResult, bool) {
resp, err := http.Get(fmt.Sprintf("https://rubygems.org/api/v1/search.json?query=%s&page=%d", url.QueryEscape(query), page))
resp, err := searchClient.Get(fmt.Sprintf("https://rubygems.org/api/v1/search.json?query=%s&page=%d", url.QueryEscape(query), page))
if err != nil {
return nil, false
}
Expand Down Expand Up @@ -255,7 +260,7 @@ func searchRubyGems(query string, page, perPage int) ([]SearchResult, bool) {
func searchCargo(query string, page, perPage int) ([]SearchResult, bool) {
req, _ := http.NewRequest("GET", fmt.Sprintf("https://crates.io/api/v1/crates?q=%s&page=%d&per_page=%d", url.QueryEscape(query), page, perPage), nil)
req.Header.Set("User-Agent", "devbox/1.0")
resp, err := http.DefaultClient.Do(req)
resp, err := searchClient.Do(req)
if err != nil {
return nil, false
}
Expand Down Expand Up @@ -288,7 +293,7 @@ func searchCargo(query string, page, perPage int) ([]SearchResult, bool) {

func searchNuGet(query string, page, perPage int) ([]SearchResult, bool) {
skip := (page - 1) * perPage
resp, err := http.Get(fmt.Sprintf("https://azuresearch-usnc.nuget.org/query?q=%s&skip=%d&take=%d", url.QueryEscape(query), skip, perPage))
resp, err := searchClient.Get(fmt.Sprintf("https://azuresearch-usnc.nuget.org/query?q=%s&skip=%d&take=%d", url.QueryEscape(query), skip, perPage))
if err != nil {
return nil, false
}
Expand Down
17 changes: 16 additions & 1 deletion internal/gitproxy/gitproxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,16 @@ func (gp *GitProxy) Handler(w http.ResponseWriter, r *http.Request) {

func (gp *GitProxy) proxyGitHub(w http.ResponseWriter, r *http.Request, path string) {
if isBlobRequest(path) {
path = strings.Replace(path, "/blob/", "/raw/", 1)
// /user/repo/blob/branch/file → raw.githubusercontent.com/user/repo/branch/file
path = strings.Replace(path, "/blob/", "/", 1)
gp.proxyRaw(w, r, path)
return
}
if isArchiveRequest(path) {
gp.proxyArchive(w, r, gp.githubUpstream, path)
} else if isRawRequest(path) {
// /user/repo/raw/branch/file → raw.githubusercontent.com/user/repo/branch/file
path = strings.Replace(path, "/raw/", "/", 1)
gp.proxyRaw(w, r, path)
} else {
gp.proxySmartHTTP(w, r, gp.githubUpstream, path)
Expand Down Expand Up @@ -193,12 +196,24 @@ func copyResponseHeaders(w http.ResponseWriter, resp *http.Response) {

func copyRequestHeaders(newReq *http.Request, orig *http.Request) {
for k, vv := range orig.Header {
if isHopByHopHeader(k) {
continue
}
for _, v := range vv {
newReq.Header.Add(k, v)
}
}
}

func isHopByHopHeader(k string) bool {
switch http.CanonicalHeaderKey(k) {
case "Host", "Connection", "Keep-Alive", "Proxy-Connection",
"Proxy-Authorization", "Transfer-Encoding", "Upgrade", "TE", "Trailer":
return true
}
return false
}

func isHTMLResponse(resp *http.Response) bool {
ct := resp.Header.Get("Content-Type")
return strings.HasPrefix(ct, "text/html")
Expand Down
6 changes: 6 additions & 0 deletions internal/gitproxy/gitproxy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ func TestGitProxyGitHubClone(t *testing.T) {

func TestGitProxyGitHubRaw(t *testing.T) {
raw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/user/repo/branch/file.go" {
t.Fatalf("expected path /user/repo/branch/file.go, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("raw-file-content"))
Expand All @@ -53,6 +56,9 @@ func TestGitProxyGitHubRaw(t *testing.T) {

func TestGitProxyGitHubBlobRedirect(t *testing.T) {
raw := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/user/repo/branch/file.go" {
t.Fatalf("expected path /user/repo/branch/file.go, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("blob-content"))
Expand Down
6 changes: 3 additions & 3 deletions internal/mirror/alpine.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,10 @@ func (a *AlpineMirror) ApplyConfig(cfg config.MirrorConfig) {
}

func (a *AlpineMirror) ProxyHandler(cache *Cache) http.HandlerFunc {
a.mu.RLock()
upstream := a.upstream
a.mu.RUnlock()
return func(w http.ResponseWriter, r *http.Request) {
a.mu.RLock()
upstream := a.upstream
a.mu.RUnlock()
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/alpine")
cache.ProxyStream(w, r, upstream)
}
Expand Down
6 changes: 3 additions & 3 deletions internal/mirror/apt.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,10 @@ func (a *AptMirror) ApplyConfig(cfg config.MirrorConfig) {
}

func (a *AptMirror) ProxyHandler(cache *Cache) http.HandlerFunc {
a.mu.RLock()
upstream := a.upstream
a.mu.RUnlock()
return func(w http.ResponseWriter, r *http.Request) {
a.mu.RLock()
upstream := a.upstream
a.mu.RUnlock()
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/apt")
cache.ProxyStream(w, r, upstream)
}
Expand Down
Loading
Loading