diff --git a/README.md b/README.md index cefeede..065cc31 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Each type's paths, Terraform examples and local/virtual behaviour are in `docs/< - `deb`: [remote](docs/deb/remote.md), [remote-github](docs/deb/remote-github.md) - `docker`: [remote](docs/docker/remote.md), [local](docs/docker/local.md) - `generic`: [remote](docs/generic/remote.md) +- `gems`: [remote](docs/gems/remote.md) - `goproxy`: [remote](docs/goproxy/remote.md) - `helm`: [remote](docs/helm/remote.md), [local](docs/helm/local.md), [virtual](docs/helm/virtual.md) - `npm`: [remote](docs/npm/remote.md), [local](docs/npm/local.md), [virtual](docs/npm/virtual.md) diff --git a/docs/common/caching.md b/docs/common/caching.md index 40d70e3..20fd733 100644 --- a/docs/common/caching.md +++ b/docs/common/caching.md @@ -12,8 +12,8 @@ Every proxied request on a remote is classified as **denied**, **immutable** or | `immutable_patterns` | Regexes forcing a path immutable. | | `mutable_patterns` | Regexes forcing a path mutable. | | `immutable_ttl` | Seconds an immutable path stays fresh. `0` = forever. | -| `mutable_ttl` | Seconds a mutable path stays fresh. `0` also means never expires, and the API stores `0` when the field is omitted. | -| `check_mutable` | Stores the upstream ETag and tries a conditional `HEAD` (`If-None-Match`) when a mutable path is fetched. The ETag expires with the `mutable_ttl` freshness key, so an expired mutable path is refetched in full, not revalidated. | +| `mutable_ttl` | Seconds a mutable path stays fresh. `0` also means never expires. Omitted on create, it defaults to `3600`; omitted on update, the stored value is kept. | +| `check_mutable` | When a mutable path's `mutable_ttl` expires, sends a conditional `HEAD` (`If-None-Match`) with the stored upstream ETag. A `304` re-arms the TTL and serves the stored copy; anything else refetches in full. The ETag is kept for `max(10 × mutable_ttl, 7 days)`, re-armed on each `304`, so idle paths age out. It is only stored when `check_mutable` is set and `mutable_ttl` is non-zero, and never for immutable paths. | | `stale_on_error` | On an upstream network error, serve the stored copy instead of failing. | | `quarantine_enabled` / `quarantine_days` | Stored on the remote but not enforced by the proxy yet. | | `mirrorlist` / `mirror_strategy` | rpm/deb/alpine only. Upstream pool is `[base_url] + mirrorlist`; `round_robin` (default) rotates the starting mirror, `least_conn` starts at the one with the fewest in-flight requests. Network errors and 5xx fail over to the next mirror; other statuses return as-is. | @@ -46,7 +46,7 @@ So `blocklist` beats the allowlist, and `immutable_patterns` beats |---|---|---| | Stored as | content-addressed blob (sha256), streamed | per-path index object, buffered and rewritten by the provider | | Fresh for | `immutable_ttl` (`0` = forever) | `mutable_ttl` | -| After expiry | refetched from upstream | refetched from upstream (the ETag expires with the TTL, so `check_mutable` does not revalidate it) | +| After expiry | refetched from upstream | revalidated with `If-None-Match` when `check_mutable` is set and an ETag is stored, else refetched | | Upstream down | `stale_on_error` serves the stored copy and re-arms the TTL; an open circuit breaker always serves stale if a copy exists, else 503 | same | Concurrent misses on one path share a fetch lock. Waiters poll the store for @@ -60,6 +60,7 @@ curl -X DELETE https://artifactapi.example.com/api/v2/remotes//objects//*` and takes no lock. - Returns 204, or 404 for an unknown remote. @@ -94,7 +95,7 @@ Force a moving path mutable, e.g. branch tarballs on `generic`: Each type's page lists its auto-detected mutable and immutable paths: [alpine](../alpine/remote.md), [alpine github](../alpine/remote-github.md), [cargo](../cargo/remote.md), [deb](../deb/remote.md), [deb github](../deb/remote-github.md), -[docker](../docker/remote.md), [generic](../generic/remote.md), [goproxy](../goproxy/remote.md), +[docker](../docker/remote.md), [generic](../generic/remote.md), [gems](../gems/remote.md), [goproxy](../goproxy/remote.md), [helm](../helm/remote.md), [npm](../npm/remote.md), [nuget](../nuget/remote.md), [puppet](../puppet/remote.md), [pypi](../pypi/remote.md), [rpm](../rpm/remote.md), [rpm github](../rpm/remote-github.md), [terraform](../terraform/remote.md). diff --git a/docs/gems/remote.md b/docs/gems/remote.md new file mode 100644 index 0000000..9f4707b --- /dev/null +++ b/docs/gems/remote.md @@ -0,0 +1,7 @@ +# `gems` remote + +Shared classification, TTL and eviction rules: [caching](../common/caching.md). + +| Type | Mutable (auto-detected) | Immutable (auto-detected) | +|---|---|---| +| `gems` | compact index (`versions`, `info/*`, `names`), `*specs.4.8.gz` | `gems/*.gem`, `quick/Marshal.4.8/*.gemspec.rz` | diff --git a/docs/nuget/remote.md b/docs/nuget/remote.md index ba0109a..7e6ed99 100644 --- a/docs/nuget/remote.md +++ b/docs/nuget/remote.md @@ -4,4 +4,4 @@ Shared classification, TTL and eviction rules: [caching](../common/caching.md). | Type | Mutable (auto-detected) | Immutable (auto-detected) | |---|---|---| -| `nuget` | `index.json` (service index), registration and flat container indexes | `.nupkg`, `.nuspec` | +| `nuget` | `index.json` (service index), registration and flat container indexes | `.nupkg`, `.snupkg`, `.nuspec`, `readme`, `icon` | diff --git a/e2e-docker/caching_gems_test.go b/e2e-docker/caching_gems_test.go new file mode 100644 index 0000000..9ef2c8b --- /dev/null +++ b/e2e-docker/caching_gems_test.go @@ -0,0 +1,7 @@ +//go:build dockere2e + +package e2edocker + +func init() { + cachingCases = append(cachingCases, cachingCase{"gems", "gems/mygem-1.0.0.gem", "gems/mygem-1.0.0.gem"}) +} diff --git a/e2e-docker/fixtures/gems/mygem-1.0.0.gem b/e2e-docker/fixtures/gems/mygem-1.0.0.gem new file mode 100644 index 0000000..666781d --- /dev/null +++ b/e2e-docker/fixtures/gems/mygem-1.0.0.gem @@ -0,0 +1 @@ +mygem-1.0.0 e2e fixture diff --git a/e2e/head_test.go b/e2e/head_test.go new file mode 100644 index 0000000..e222551 --- /dev/null +++ b/e2e/head_test.go @@ -0,0 +1,182 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "git.unkin.net/unkin/artifactapi/internal/testsupport" +) + +// assertHeadMatchesGet checks HEAD returns GET's status, type and length with no body. +func assertHeadMatchesGet(t *testing.T, url string) { + t.Helper() + get, err := http.Get(url) + if err != nil { + t.Fatalf("GET: %v", err) + } + body, _ := io.ReadAll(get.Body) + get.Body.Close() + if get.StatusCode != http.StatusOK { + t.Fatalf("GET %s: %d %s", url, get.StatusCode, body) + } + + head, err := http.Head(url) + if err != nil { + t.Fatalf("HEAD: %v", err) + } + hb, _ := io.ReadAll(head.Body) + head.Body.Close() + if head.StatusCode != http.StatusOK { + t.Fatalf("HEAD %s: got %d, want 200", url, head.StatusCode) + } + if len(hb) != 0 { + t.Errorf("HEAD returned a %d-byte body", len(hb)) + } + for _, h := range []string{"Content-Type", "Cache-Control", "Vary"} { + if head.Header.Get(h) != get.Header.Get(h) { + t.Errorf("HEAD %s %q, GET %q", h, head.Header.Get(h), get.Header.Get(h)) + } + } + if head.ContentLength != int64(len(body)) { + t.Errorf("HEAD Content-Length %d, GET body %d bytes", head.ContentLength, len(body)) + } +} + +func TestProxyHeadNuGetRewritten(t *testing.T) { + var upstream *httptest.Server + upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"version": "3.0.0", "resources": []map[string]string{ + {"@id": upstream.URL + "/v3-flatcontainer/", "@type": "PackageBaseAddress/3.0.0"}, + }}) + })) + defer upstream.Close() + + createRemote(t, mustFormat(`{"name": "head-nuget", "package_type": "nuget", "base_url": %q}`, upstream.URL+"/v3/index.json")) + defer deleteRemote(t, "head-nuget") + + url := apiURL("/api/v1/remote/head-nuget/index.json") + assertHeadMatchesGet(t, url) + + req, _ := http.NewRequest(http.MethodHead, url, nil) + req.Host = "evil.example:" + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("HEAD with invalid Host: got %d, want 400", resp.StatusCode) + } +} + +func TestProxyHeadGitHubRPMRepodata(t *testing.T) { + rpm := testsupport.MinimalRPM("demo", "1.2", "3", "x86_64") + var gh *httptest.Server + mux := http.NewServeMux() + mux.HandleFunc("/repos/acme/tools/releases", func(w http.ResponseWriter, r *http.Request) { + if p := r.URL.Query().Get("page"); p != "" && p != "1" { + w.Write([]byte("[]")) + return + } + json.NewEncoder(w).Encode([]map[string]any{{"tag_name": "v1.2-3", "assets": []map[string]any{{ + "name": "demo-1.2-3.x86_64.rpm", + "size": len(rpm), + "browser_download_url": gh.URL + "/acme/tools/releases/download/v1.2-3/demo-1.2-3.x86_64.rpm", + }}}}) + }) + mux.HandleFunc("/acme/tools/releases/download/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", strconv.Itoa(len(rpm))) + w.Write(rpm) + }) + gh = httptest.NewServer(mux) + defer gh.Close() + + createRemote(t, mustFormat(`{"name": "head-ghrpm", "package_type": "github_rpm", "base_url": %q, "releases_remote": "github"}`, gh.URL+"/repos/acme/tools")) + defer deleteRemote(t, "head-ghrpm") + + url := apiURL("/api/v1/remote/head-ghrpm/repodata/repomd.xml") + deadline := time.Now().Add(30 * time.Second) + for { + if _, status := getBody(t, url); status == http.StatusOK || time.Now().After(deadline) { + break + } + time.Sleep(500 * time.Millisecond) + } + assertHeadMatchesGet(t, url) +} + +func TestProxyHeadNpmUncachedPackument(t *testing.T) { + const abbreviated = "application/vnd.npm.install-v1+json" + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{"name": "left-pad", "versions": map[string]any{"1.3.0": map[string]any{}}} + if r.Header.Get("Accept") != abbreviated { + doc["readme"] = strings.Repeat("full packument ", 200) + } + json.NewEncoder(w).Encode(doc) + })) + defer upstream.Close() + + createRemote(t, mustFormat(`{"name": "head-npm", "package_type": "npm", "base_url": %q}`, upstream.URL)) + defer deleteRemote(t, "head-npm") + + url := apiURL("/api/v1/remote/head-npm/left-pad") + do := func(method string) (*http.Response, []byte) { + req, _ := http.NewRequest(method, url, nil) + req.Header.Set("Accept", abbreviated) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s: %v", method, err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp, b + } + head, _ := do(http.MethodHead) + get, body := do(http.MethodGet) + if head.StatusCode != http.StatusOK || get.StatusCode != http.StatusOK { + t.Fatalf("HEAD %d, GET %d", head.StatusCode, get.StatusCode) + } + if head.ContentLength != int64(len(body)) { + t.Errorf("uncached HEAD Content-Length %d, GET body %d bytes", head.ContentLength, len(body)) + } +} + +func TestProxyHeadNonRewritingUsesUpstreamHead(t *testing.T) { + methods := make(chan string, 4) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + methods <- r.Method + w.Header().Set("Content-Type", "text/xml") + w.Write([]byte("")) + })) + defer upstream.Close() + + createRemote(t, mustFormat(`{"name": "head-rpm", "package_type": "rpm", "base_url": %q}`, upstream.URL)) + defer deleteRemote(t, "head-rpm") + + resp, err := http.Head(apiURL("/api/v1/remote/head-rpm/repodata/repomd.xml")) + if err != nil { + t.Fatalf("HEAD: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK || resp.Header.Get("X-Artifact-Source") != "remote" { + t.Fatalf("HEAD %d source %q", resp.StatusCode, resp.Header.Get("X-Artifact-Source")) + } + close(methods) + var seen []string + for m := range methods { + seen = append(seen, m) + } + if len(seen) != 1 || seen[0] != http.MethodHead { + t.Errorf("upstream saw %v, want a single HEAD", seen) + } +} diff --git a/e2e/scheme_test.go b/e2e/scheme_test.go new file mode 100644 index 0000000..4ab636f --- /dev/null +++ b/e2e/scheme_test.go @@ -0,0 +1,59 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "net/http" + "strings" + "testing" +) + +func getForwardedHTTPS(t *testing.T, url string) *http.Response { + t.Helper() + req, _ := http.NewRequest(http.MethodGet, url, nil) + req.Header.Set("X-Forwarded-Proto", "https") + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + return resp +} + +func TestForwardedProtoCargoConfig(t *testing.T) { + createRemote(t, `{"name": "scheme-cargo", "package_type": "cargo", "base_url": "https://index.crates.io"}`) + defer deleteRemote(t, "scheme-cargo") + + resp := getForwardedHTTPS(t, apiURL("/api/v1/remote/scheme-cargo/config.json")) + defer resp.Body.Close() + var cfg map[string]string + if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil { + t.Fatal(err) + } + if want := "https://" + strings.TrimPrefix(baseURL, "http://") + "/api/v1/remote/scheme-cargo/"; !strings.HasPrefix(cfg["dl"], want) { + t.Errorf("dl = %q, want prefix %q", cfg["dl"], want) + } +} + +func TestForwardedProtoVirtualRedirect(t *testing.T) { + createRemote(t, `{"name": "scheme-rpm", "package_type": "rpm", "base_url": "https://rpm.example.com"}`) + defer deleteRemote(t, "scheme-rpm") + resp, err := http.Post(apiURL("/api/v2/virtuals"), "application/json", + strings.NewReader(`{"name": "scheme-virt", "package_type": "rpm", "members": ["scheme-rpm"]}`)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create virtual: status %d", resp.StatusCode) + } + defer deleteRequest(t, apiURL("/api/v2/virtuals/scheme-virt")) + + resp = getForwardedHTTPS(t, apiURL("/api/v1/virtual/scheme-virt/scheme-rpm/Packages/a.rpm")) + resp.Body.Close() + loc := resp.Header.Get("Location") + if want := "https://" + strings.TrimPrefix(baseURL, "http://") + "/api/v1/remote/scheme-rpm/Packages/a.rpm"; resp.StatusCode != http.StatusFound || loc != want { + t.Errorf("redirect = %d %q, want 302 %q", resp.StatusCode, loc, want) + } +} diff --git a/internal/api/terraform/registry.go b/internal/api/terraform/registry.go index 7aee9de..cd69aca 100644 --- a/internal/api/terraform/registry.go +++ b/internal/api/terraform/registry.go @@ -13,6 +13,7 @@ import ( "github.com/go-chi/chi/v5" + v1 "git.unkin.net/unkin/artifactapi/internal/api/v1" "git.unkin.net/unkin/artifactapi/internal/database" tfprov "git.unkin.net/unkin/artifactapi/internal/provider/terraform" "git.unkin.net/unkin/artifactapi/internal/tfsign" @@ -290,12 +291,5 @@ func writeJSON(w http.ResponseWriter, v any) { } func baseURL(r *http.Request) string { - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" { - scheme = fwd - } - return scheme + "://" + r.Host + return v1.Scheme(r) + "://" + r.Host } diff --git a/internal/api/v1/npm.go b/internal/api/v1/npm.go index 18604cf..e5caf25 100644 --- a/internal/api/v1/npm.go +++ b/internal/api/v1/npm.go @@ -254,7 +254,7 @@ func (h *ProxyHandler) serveNPMPackument(w http.ResponseWriter, r *http.Request, return } read := func(hash string) ([]byte, error) { return h.readLocalBlob(r.Context(), hash) } - tarballBase := fmt.Sprintf("%s://%s/api/v1/local/%s/", scheme(r), r.Host, remote.Name) + tarballBase := fmt.Sprintf("%s://%s/api/v1/local/%s/", Scheme(r), r.Host, remote.Name) doc, err := buildNPMPackument(pkg, files, read, tarballBase) if err != nil { npmError(w, http.StatusInternalServerError, err.Error()) diff --git a/internal/api/v1/proxy.go b/internal/api/v1/proxy.go index 2ff961b..2d6f3b7 100644 --- a/internal/api/v1/proxy.go +++ b/internal/api/v1/proxy.go @@ -6,7 +6,9 @@ import ( "fmt" "io" "log/slog" + "net" "net/http" + "strconv" "strings" "github.com/go-chi/chi/v5" @@ -43,6 +45,7 @@ func NewProxyHandler(engine *proxy.Engine, virtualEngine *virtual.Engine, db *da func (h *ProxyHandler) Routes() chi.Router { r := chi.NewRouter() r.Get("/remote/{remoteName}/*", h.handleProxy) + r.Head("/remote/{remoteName}/*", h.handleProxyHead) r.Get("/local/{localName}/*", h.handleLocal) r.Put("/local/{localName}/*", h.handleLocalPut) r.Get("/virtual/{virtualName}/*", h.handleVirtual) @@ -71,31 +74,43 @@ func (h *ProxyHandler) handleDockerPing(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusOK) } -func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) { +// remoteFor resolves the remote and provider named in the request, writing the +// error response itself when either is missing. +func (h *ProxyHandler) remoteFor(w http.ResponseWriter, r *http.Request) (*models.Remote, provider.Provider, bool) { remoteName := chi.URLParam(r, "remoteName") - path := chi.URLParam(r, "*") - remote, err := h.db.GetRemote(r.Context(), remoteName) if err != nil { http.Error(w, fmt.Sprintf("remote %q not found", remoteName), http.StatusNotFound) - return + return nil, nil, false } - prov, err := provider.Get(remote.PackageType) if err != nil { http.Error(w, fmt.Sprintf("no provider for %q", remote.PackageType), http.StatusInternalServerError) + return nil, nil, false + } + return remote, prov, true +} + +// serveRemote lets metadata-only remotes (e.g. github_rpm) synthesize their own +// responses and redirect package downloads instead of proxying bytes. +func (h *ProxyHandler) serveRemote(w http.ResponseWriter, r *http.Request, remote *models.Remote, prov provider.Provider, path string) bool { + rs, ok := prov.(provider.RemoteServer) + return ok && rs.ServeRemote(w, r, *remote, path, fmt.Sprintf("%s://%s", Scheme(r), r.Host), h.db) +} + +func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) { + remote, prov, ok := h.remoteFor(w, r) + if !ok { return } - - // Metadata-only remotes (e.g. github_rpm) synthesize their own responses and - // redirect package downloads to a backend remote instead of proxying bytes. - if rs, ok := prov.(provider.RemoteServer); ok { - proxyBaseURL := fmt.Sprintf("%s://%s", scheme(r), r.Host) - if rs.ServeRemote(w, r, *remote, path, proxyBaseURL, h.db) { - return - } + path := chi.URLParam(r, "*") + if h.serveRemote(w, r, remote, prov, path) { + return } + h.serveFetch(w, r, remote, prov, path) +} +func (h *ProxyHandler) serveFetch(w http.ResponseWriter, r *http.Request, remote *models.Remote, prov provider.Provider, path string) { result, err := h.engine.Fetch(r.Context(), *remote, path, prov, r.Header) if err != nil { var proxyErr *proxy.ProxyError @@ -103,7 +118,7 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) { http.Error(w, proxyErr.Message, proxyErr.Status) return } - slog.Error("proxy fetch failed", "remote", remoteName, "path", path, "error", err) + slog.Error("proxy fetch failed", "remote", remote.Name, "path", path, "error", err) http.Error(w, "bad gateway", http.StatusBadGateway) return } @@ -111,37 +126,43 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) { var body io.Reader = result.Reader if sr, ok := prov.(provider.ServeRewriter); ok && prov.Classify(path) == provider.Mutable { - raw, err := io.ReadAll(result.Reader) - if err != nil { - http.Error(w, "bad gateway", http.StatusBadGateway) + raw, ok := rewriteServed(w, r, sr, *remote, result.Reader) + if !ok { return } - raw = sr.RewriteServed(raw, *remote, fmt.Sprintf("%s://%s", scheme(r), r.Host)) body, result.Size = bytes.NewReader(raw), int64(len(raw)) } w.Header().Set("Content-Type", result.ContentType) w.Header().Set("X-Artifact-Source", result.Source) if result.Size > 0 { + w.Header().Set("Content-Length", fmt.Sprintf("%d", result.Size)) w.Header().Set("X-Artifact-Size", fmt.Sprintf("%d", result.Size)) } w.WriteHeader(http.StatusOK) io.Copy(w, body) } -func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) { - remoteName := chi.URLParam(r, "remoteName") - path := chi.URLParam(r, "*") +func rewritesMutable(prov provider.Provider, path string) bool { + _, sr := prov.(provider.ServeRewriter) + _, rr := prov.(provider.ResponseRewriter) + return (sr || rr) && prov.Classify(path) == provider.Mutable +} - remote, err := h.db.GetRemote(r.Context(), remoteName) - if err != nil { - http.Error(w, fmt.Sprintf("remote %q not found", remoteName), http.StatusNotFound) +// handleProxyHead answers HEAD like GET without a body. Responses a provider +// synthesizes or rewrites per request go through the GET path so their headers +// match; everything else is answered from store metadata or an upstream HEAD. +func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) { + remote, prov, ok := h.remoteFor(w, r) + if !ok { return } - - prov, err := provider.Get(remote.PackageType) - if err != nil { - http.Error(w, fmt.Sprintf("no provider for %q", remote.PackageType), http.StatusInternalServerError) + path := chi.URLParam(r, "*") + if h.serveRemote(w, r, remote, prov, path) { + return + } + if rewritesMutable(prov, path) { + h.serveFetch(w, r, remote, prov, path) return } @@ -152,7 +173,7 @@ func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) { http.Error(w, proxyErr.Message, proxyErr.Status) return } - slog.Error("proxy head failed", "remote", remoteName, "path", path, "error", err) + slog.Error("proxy head failed", "remote", remote.Name, "path", path, "error", err) http.Error(w, "bad gateway", http.StatusBadGateway) return } @@ -175,7 +196,7 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) { return } - proxyBaseURL := fmt.Sprintf("%s://%s", scheme(r), r.Host) + proxyBaseURL := fmt.Sprintf("%s://%s", Scheme(r), r.Host) loc, ok, err := h.virtualEngine.MemberRedirect(r.Context(), *virt, path, r.URL.RawQuery, proxyBaseURL) if err != nil { @@ -266,12 +287,68 @@ func (h *ProxyHandler) serveLocalFile(w http.ResponseWriter, r *http.Request, re io.Copy(w, reader) } -func scheme(r *http.Request) string { +// Scheme returns the client-facing scheme: TLS, else the first +// X-Forwarded-Proto value when it is http or https, else http. +func Scheme(r *http.Request) string { if r.TLS != nil { return "https" } - if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" { + fwd, _, _ := strings.Cut(r.Header.Get("X-Forwarded-Proto"), ",") + if fwd = strings.ToLower(strings.TrimSpace(fwd)); fwd == "https" { return fwd } return "http" } + +// rewriteServed rewrites a cached body for the requesting host and marks the +// response uncacheable by shared caches, since it varies per Host. +func rewriteServed(w http.ResponseWriter, r *http.Request, sr provider.ServeRewriter, remote models.Remote, src io.Reader) ([]byte, bool) { + if !validHost(r.Host) { + http.Error(w, "invalid host header", http.StatusBadRequest) + return nil, false + } + raw, err := io.ReadAll(src) + if err != nil { + http.Error(w, "bad gateway", http.StatusBadGateway) + return nil, false + } + raw = sr.RewriteServed(raw, remote, fmt.Sprintf("%s://%s", Scheme(r), r.Host)) + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Vary", "Host, X-Forwarded-Proto") + w.Header().Set("Content-Length", strconv.Itoa(len(raw))) + return raw, true +} + +// validHost accepts host[:port] where host is a DNS name or IP literal. +func validHost(hostport string) bool { + host, port := hostport, "" + if h, p, err := net.SplitHostPort(hostport); err == nil { + if p == "" { + return false + } + host, port = h, p + } else if strings.HasPrefix(hostport, "[") { + return strings.HasSuffix(hostport, "]") && net.ParseIP(hostport[1:len(hostport)-1]) != nil + } + if port != "" { + if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 { + return false + } + } + if net.ParseIP(host) != nil { + return true + } + if strings.HasPrefix(hostport, "[") { + return false + } + if host == "" || len(host) > 253 { + return false + } + for _, label := range strings.Split(strings.TrimSuffix(host, "."), ".") { + if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' || + strings.Trim(strings.ToLower(label), "abcdefghijklmnopqrstuvwxyz0123456789-_") != "" { + return false + } + } + return true +} diff --git a/internal/api/v1/rewrite_test.go b/internal/api/v1/rewrite_test.go new file mode 100644 index 0000000..24fd732 --- /dev/null +++ b/internal/api/v1/rewrite_test.go @@ -0,0 +1,74 @@ +package v1 + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.unkin.net/unkin/artifactapi/internal/provider/nuget" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func TestValidHost(t *testing.T) { + for _, h := range []string{"aa.example", "aa.example:8443", "artifactapi.k8s.syd1.au.unkin.net", "10.0.0.1:80", "[::1]:8080", "[::1]", "::1", "localhost", "my_host.local"} { + if !validHost(h) { + t.Errorf("validHost(%q) = false, want true", h) + } + } + for _, h := range []string{"", "evil\".example", "a b", "aa.example:0", "aa.example:99999", "aa.example:x", "aa.example:", ":8080", "-bad.example", "a..b", "[::1", "[nope]:80", "a/b", "a