Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 841b9f1547 | |||
| ed6216d78b | |||
| eca686b7bb | |||
| ceae176089 | |||
| bc68eb75fc | |||
| 78d24f7752 | |||
| 9161558334 |
@@ -1,6 +1,6 @@
|
|||||||
# ArtifactAPI
|
# ArtifactAPI
|
||||||
|
|
||||||
Caching proxy for package repositories. Single Go binary, 11 package types, content-addressable storage, managed by Terraform.
|
Caching proxy for package repositories. Single Go binary, 12 package types, content-addressable storage, managed by Terraform.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ API: `http://localhost:8000` | Frontend: `http://localhost:5173`
|
|||||||
| `terraform` | `*/versions` | `*/download/*/*` |
|
| `terraform` | `*/versions` | `*/download/*/*` |
|
||||||
| `goproxy` | `@v/list`, `@latest` | `.info`, `.mod`, `.zip` |
|
| `goproxy` | `@v/list`, `@latest` | `.info`, `.mod`, `.zip` |
|
||||||
| `cargo` | sparse index files (`config.json` synthesized) | `crates/*/*.crate` |
|
| `cargo` | sparse index files (`config.json` synthesized) | `crates/*/*.crate` |
|
||||||
|
| `nuget` | `index.json` (service index), registration and flat container indexes | `.nupkg`, `.nuspec` |
|
||||||
| `github_rpm` | `repodata/*` (synthesized) | `.rpm` (redirected) |
|
| `github_rpm` | `repodata/*` (synthesized) | `.rpm` (redirected) |
|
||||||
|
|
||||||
Providers classify paths automatically. Users only configure what to proxy and TTLs.
|
Providers classify paths automatically. Users only configure what to proxy and TTLs.
|
||||||
@@ -371,6 +372,8 @@ additive and idempotent for the same reason; a test enforces it.
|
|||||||
| `GITHUB_APP_INSTALLATION_ID` | | GitHub App installation id |
|
| `GITHUB_APP_INSTALLATION_ID` | | GitHub App installation id |
|
||||||
| `GITHUB_APP_PRIVATE_KEY` | | GitHub App private key, inline PEM |
|
| `GITHUB_APP_PRIVATE_KEY` | | GitHub App private key, inline PEM |
|
||||||
| `GITHUB_APP_PRIVATE_KEY_PATH` | | GitHub App private key, file path (alternative to inline PEM) |
|
| `GITHUB_APP_PRIVATE_KEY_PATH` | | GitHub App private key, file path (alternative to inline PEM) |
|
||||||
|
| `ARTIFACTAPI_OIDC_ISSUER` | | OIDC issuer URL (e.g. Vault `https://vault.service.consul:8200/v1/identity/oidc`). When set, `POST /api/v2/remotes`, `POST /api/v2/virtuals` and `PUT`/`DELETE` on `/api/v2/{remotes,virtuals}/{name}` require `Authorization: Bearer <jwt>` verified against the issuer's JWKS. Uploads, object/file deletes, probe, reads and `/v2` stay open. Unset = no auth |
|
||||||
|
| `ARTIFACTAPI_OIDC_AUDIENCE` | | Comma-separated allowed `aud` values (e.g. the Vault OIDC role's client_id). Required when `ARTIFACTAPI_OIDC_ISSUER` is set |
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ already-running stack.
|
|||||||
|
|
||||||
- **Repository lifecycle** — add / change / delete for remote, local and virtual repos.
|
- **Repository lifecycle** — add / change / delete for remote, local and virtual repos.
|
||||||
- **Caching** — one immutable artifact per remote package type (generic, docker,
|
- **Caching** — one immutable artifact per remote package type (generic, docker,
|
||||||
helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo) proxied through the
|
helm, pypi, npm, rpm, alpine, puppet, terraform, goproxy, cargo, nuget) proxied through the
|
||||||
mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
|
mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
|
||||||
verified against the origin fixture.
|
verified against the origin fixture.
|
||||||
- **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/`
|
- **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/`
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ func TestCachingPerProvider(t *testing.T) {
|
|||||||
{"puppet", "puppet-releases/author-mod-1.0.0.tar.gz", "puppet-releases/author-mod-1.0.0.tar.gz"},
|
{"puppet", "puppet-releases/author-mod-1.0.0.tar.gz", "puppet-releases/author-mod-1.0.0.tar.gz"},
|
||||||
{"goproxy", "goproxy/example.com/mod/@v/v1.0.0.zip", "goproxy/example.com/mod/@v/v1.0.0.zip"},
|
{"goproxy", "goproxy/example.com/mod/@v/v1.0.0.zip", "goproxy/example.com/mod/@v/v1.0.0.zip"},
|
||||||
{"cargo", "crates/mycrate/mycrate-1.0.0.crate", "crates/mycrate/mycrate-1.0.0.crate"},
|
{"cargo", "crates/mycrate/mycrate-1.0.0.crate", "crates/mycrate/mycrate-1.0.0.crate"},
|
||||||
|
{"nuget", "nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg", "nuget/e2epkg/1.0.0/e2epkg.1.0.0.nupkg"},
|
||||||
{"terraform", "hashicorp/aws/download/pkg.zip", "v1/providers/hashicorp/aws/download/pkg.zip"},
|
{"terraform", "hashicorp/aws/download/pkg.zip", "v1/providers/hashicorp/aws/download/pkg.zip"},
|
||||||
{"docker", "library/testimg/blobs/blobdata", "v2/library/testimg/blobs/blobdata"},
|
{"docker", "library/testimg/blobs/blobdata", "v2/library/testimg/blobs/blobdata"},
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -14,6 +14,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/testsupport"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestVirtualPyPIMerge uploads different packages to two pypi locals and
|
// TestVirtualPyPIMerge uploads different packages to two pypi locals and
|
||||||
@@ -136,3 +138,104 @@ func TestVirtualRPMMerge(t *testing.T) {
|
|||||||
t.Fatalf("real dnf against rpm virtual failed: %v\n%s", err, out)
|
t.Fatalf("real dnf against rpm virtual failed: %v\n%s", err, out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestVirtualDebMerge merges two local deb repos carrying the same package: the
|
||||||
|
// first member wins the duplicate, and a real apt sees both members' packages
|
||||||
|
// and installs through the virtual.
|
||||||
|
func TestVirtualDebMerge(t *testing.T) {
|
||||||
|
createRepo(t, `{"name":"vdeb-a","package_type":"deb","repo_type":"local"}`)
|
||||||
|
createRepo(t, `{"name":"vdeb-b","package_type":"deb","repo_type":"local"}`)
|
||||||
|
defer deleteRepo(t, "vdeb-a")
|
||||||
|
defer deleteRepo(t, "vdeb-b")
|
||||||
|
|
||||||
|
uploadFile(t, "vdeb-a", "vdeb-one_1.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-one", "1.0.0", "amd64"), "application/vnd.debian.binary-package")
|
||||||
|
uploadFile(t, "vdeb-b", "vdeb-one_1.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-one", "1.0.0", "amd64"), "application/vnd.debian.binary-package")
|
||||||
|
uploadFile(t, "vdeb-b", "vdeb-two_2.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-two", "2.0.0", "amd64"), "application/vnd.debian.binary-package")
|
||||||
|
|
||||||
|
// Local indexes regenerate asynchronously; merge only once vdeb-b lists both.
|
||||||
|
deadline := time.Now().Add(15 * time.Second)
|
||||||
|
for {
|
||||||
|
resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/vdeb-b/Packages"), nil, "")
|
||||||
|
if resp.StatusCode == http.StatusOK && strings.Contains(string(body), "vdeb-two") || time.Now().After(deadline) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
createVirtual(t, `{"name":"vdeb","package_type":"deb","members":["vdeb-a","vdeb-b"]}`)
|
||||||
|
defer deleteVirtual(t, "vdeb")
|
||||||
|
|
||||||
|
_, body := doRequest(t, http.MethodGet, api("/api/v1/virtual/vdeb/Packages"), nil, "")
|
||||||
|
pk := string(body)
|
||||||
|
if strings.Count(pk, "Package: vdeb-one\n") != 1 || !strings.Contains(pk, "Filename: vdeb-a/pool/vdeb-one_1.0.0_amd64.deb") ||
|
||||||
|
!strings.Contains(pk, "Filename: vdeb-b/pool/vdeb-two_2.0.0_amd64.deb") {
|
||||||
|
t.Fatalf("merged Packages should hold vdeb-one from the first member and vdeb-two:\n%s", pk)
|
||||||
|
}
|
||||||
|
|
||||||
|
network := os.Getenv("COMPOSE_NETWORK")
|
||||||
|
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
||||||
|
if network == "" || internal == "" {
|
||||||
|
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; skipping real apt")
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("docker"); err != nil {
|
||||||
|
t.Skip("docker not available on the test host")
|
||||||
|
}
|
||||||
|
script := "set -euo pipefail; " +
|
||||||
|
"rm -f /etc/apt/sources.list.d/*; " +
|
||||||
|
"echo \"deb [trusted=yes] $URL ./\" > /etc/apt/sources.list.d/vdeb.list; " +
|
||||||
|
"apt-get update; " +
|
||||||
|
"apt-cache policy vdeb-one vdeb-two | tee /dev/stderr | grep -c virtual/vdeb | grep -qx 2; " +
|
||||||
|
"apt-get install -y vdeb-one vdeb-two; " +
|
||||||
|
"dpkg -s vdeb-one vdeb-two"
|
||||||
|
out, err := exec.Command("docker", "run", "--rm", "--network", network,
|
||||||
|
"-e", "URL="+strings.TrimRight(internal, "/")+"/api/v1/virtual/vdeb/",
|
||||||
|
"debian:13", "bash", "-c", script).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("real apt against deb virtual failed: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVirtualDebSuite merges two proxied deb.debian.org remotes and drives a
|
||||||
|
// real apt through the virtual's dists/<suite> layout (by-hash indexes).
|
||||||
|
func TestVirtualDebSuite(t *testing.T) {
|
||||||
|
network := os.Getenv("COMPOSE_NETWORK")
|
||||||
|
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
||||||
|
if network == "" || internal == "" {
|
||||||
|
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; skipping real apt")
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("docker"); err != nil {
|
||||||
|
t.Skip("docker not available on the test host")
|
||||||
|
}
|
||||||
|
for _, n := range []string{"vdebs-a", "vdebs-b"} {
|
||||||
|
createRepo(t, `{"name":"`+n+`","package_type":"deb","repo_type":"remote","base_url":"http://deb.debian.org/debian"}`)
|
||||||
|
defer deleteRepo(t, n)
|
||||||
|
}
|
||||||
|
createVirtual(t, `{"name":"vdebs","package_type":"deb","members":["vdebs-a","vdebs-b"]}`)
|
||||||
|
defer deleteVirtual(t, "vdebs")
|
||||||
|
|
||||||
|
resp, rel := doRequest(t, http.MethodGet, api("/api/v1/virtual/vdebs/dists/trixie-updates/Release"), nil, "")
|
||||||
|
if resp.StatusCode != http.StatusOK || !strings.Contains(string(rel), "Acquire-By-Hash: yes\n") ||
|
||||||
|
!strings.Contains(string(rel), " main/binary-amd64/Packages.gz\n") {
|
||||||
|
t.Fatalf("merged suite Release: status %d:\n%s", resp.StatusCode, rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
script := "set -euo pipefail; " +
|
||||||
|
"rm -f /etc/apt/sources.list.d/*; " +
|
||||||
|
"echo \"deb [trusted=yes] $URL trixie-updates main\" > /etc/apt/sources.list.d/vdebs.list; " +
|
||||||
|
"apt-get update; " +
|
||||||
|
"idx=/tmp/avail; apt-cache dumpavail > $idx; " +
|
||||||
|
"grep -q '^Filename: vdebs-a/pool/' $idx; if grep -q '^Filename: vdebs-b/' $idx; then echo 'vdebs-b not deduped'; exit 1; fi; " +
|
||||||
|
"pkg=$(awk '/^Package:/{p=$2} /^Size:/{if(!m||$2<m){m=$2;b=p}} END{print b}' $idx); " +
|
||||||
|
"cd /tmp && apt-get download $pkg && ls -l /tmp/*.deb"
|
||||||
|
out, err := exec.Command("docker", "run", "--rm", "--network", network,
|
||||||
|
"-e", "URL="+strings.TrimRight(internal, "/")+"/api/v1/virtual/vdebs/",
|
||||||
|
"debian:13", "bash", "-c", script).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("real apt against deb virtual suite failed: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
t.Logf("%s", out)
|
||||||
|
resp, _ = doRequest(t, http.MethodGet, api("/api/v1/remote/vdebs-a/dists/trixie-updates/main/binary-amd64/Packages.xz"), nil, "")
|
||||||
|
if src := resp.Header.Get("X-Artifact-Source"); resp.StatusCode != http.StatusOK || src != "cache" {
|
||||||
|
t.Fatalf("member index the virtual merged should be cached: status %d source %q", resp.StatusCode, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ require (
|
|||||||
github.com/cavaliergopher/rpm v1.3.0
|
github.com/cavaliergopher/rpm v1.3.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/coreos/go-oidc/v3 v3.21.0
|
||||||
github.com/go-chi/chi/v5 v5.3.0
|
github.com/go-chi/chi/v5 v5.3.0
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/klauspost/compress v1.19.2
|
github.com/klauspost/compress v1.19.2
|
||||||
@@ -102,6 +104,7 @@ require (
|
|||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/net v0.56.0 // indirect
|
golang.org/x/net v0.56.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.40.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.2 // indirect
|
gopkg.in/ini.v1 v1.67.2 // indirect
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
|||||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
@@ -67,6 +69,8 @@ github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeO
|
|||||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@@ -225,6 +229,8 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM
|
|||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -105,13 +106,24 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer result.Reader.Close()
|
defer result.Reader.Close()
|
||||||
|
|
||||||
|
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)
|
||||||
|
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("Content-Type", result.ContentType)
|
||||||
w.Header().Set("X-Artifact-Source", result.Source)
|
w.Header().Set("X-Artifact-Source", result.Source)
|
||||||
if result.Size > 0 {
|
if result.Size > 0 {
|
||||||
w.Header().Set("X-Artifact-Size", fmt.Sprintf("%d", result.Size))
|
w.Header().Set("X-Artifact-Size", fmt.Sprintf("%d", result.Size))
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
io.Copy(w, result.Reader)
|
io.Copy(w, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) {
|
func (h *ProxyHandler) handleProxyHead(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BearerOIDC verifies `Authorization: Bearer <jwt>` against an OIDC issuer's
|
||||||
|
// JWKS. Discovery is lazy so startup never depends on the issuer being
|
||||||
|
// reachable; a failed discovery is not retried until the backoff elapses.
|
||||||
|
type BearerOIDC struct {
|
||||||
|
issuer string
|
||||||
|
audiences []string
|
||||||
|
timeout time.Duration
|
||||||
|
backoff time.Duration
|
||||||
|
discover func(context.Context, string) (*oidc.Provider, error)
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
verifier *oidc.IDTokenVerifier
|
||||||
|
inflight chan struct{}
|
||||||
|
failedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var errIssuerUnavailable = errors.New("oidc issuer unavailable")
|
||||||
|
|
||||||
|
// NewBearerOIDC returns nil when issuer is empty, meaning auth is disabled.
|
||||||
|
func NewBearerOIDC(issuer string, audiences []string) *BearerOIDC {
|
||||||
|
if issuer == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &BearerOIDC{issuer: issuer, audiences: audiences, timeout: 10 * time.Second, backoff: 15 * time.Second, discover: oidc.NewProvider}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getVerifier runs at most one discovery at a time without holding the lock
|
||||||
|
// across network I/O; concurrent callers wait for it, and only callers within
|
||||||
|
// the post-failure backoff fail fast.
|
||||||
|
func (b *BearerOIDC) getVerifier(ctx context.Context) (*oidc.IDTokenVerifier, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
if v := b.verifier; v != nil {
|
||||||
|
b.mu.Unlock()
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
if done := b.inflight; done != nil {
|
||||||
|
b.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if b.verifier == nil {
|
||||||
|
return nil, errIssuerUnavailable
|
||||||
|
}
|
||||||
|
return b.verifier, nil
|
||||||
|
}
|
||||||
|
if time.Since(b.failedAt) < b.backoff {
|
||||||
|
b.mu.Unlock()
|
||||||
|
return nil, errIssuerUnavailable
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
b.inflight = done
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
v, err := b.discoverOnce(done)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("oidc discovery failed", "issuer", b.issuer, "error", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverOnce releases inflight and waiters on every exit, a panic included,
|
||||||
|
// recording failedAt unless discovery succeeded.
|
||||||
|
func (b *BearerOIDC) discoverOnce(done chan struct{}) (v *oidc.IDTokenVerifier, err error) {
|
||||||
|
defer func() {
|
||||||
|
b.mu.Lock()
|
||||||
|
b.inflight = nil
|
||||||
|
if v == nil {
|
||||||
|
b.failedAt = time.Now()
|
||||||
|
}
|
||||||
|
b.verifier = v
|
||||||
|
b.mu.Unlock()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
// The provider keeps this context and client for JWKS refreshes, so the
|
||||||
|
// deadline lives on the client rather than the context.
|
||||||
|
client := &http.Client{Timeout: b.timeout}
|
||||||
|
p, err := b.discover(oidc.ClientContext(context.Background(), client), b.issuer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p.Verifier(&oidc.Config{SkipClientIDCheck: true}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require gates every request through next on a valid token. A nil receiver
|
||||||
|
// passes everything through.
|
||||||
|
func (b *BearerOIDC) Require(next http.Handler) http.Handler {
|
||||||
|
if b == nil {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
scheme, raw, _ := strings.Cut(strings.TrimSpace(r.Header.Get("Authorization")), " ")
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if !strings.EqualFold(scheme, "Bearer") || raw == "" {
|
||||||
|
unauthorized(w, "missing bearer token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, err := b.getVerifier(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"token issuer unavailable"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tok, err := v.Verify(r.Context(), raw)
|
||||||
|
if err != nil {
|
||||||
|
slog.Info("bearer token rejected", "error", err)
|
||||||
|
unauthorized(w, "invalid token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(b.audiences) > 0 && !slices.ContainsFunc(tok.Audience, func(a string) bool { return slices.Contains(b.audiences, a) }) {
|
||||||
|
slog.Info("bearer token rejected", "error", "audience not allowed", "aud", tok.Audience)
|
||||||
|
unauthorized(w, "invalid token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("bearer token accepted", "sub", tok.Subject, "method", r.Method, "path", r.URL.Path)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireWrites gates only mutating methods.
|
||||||
|
func (b *BearerOIDC) RequireWrites(next http.Handler) http.Handler {
|
||||||
|
gated := b.Require(next)
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
default:
|
||||||
|
gated.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func unauthorized(w http.ResponseWriter, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("WWW-Authenticate", "Bearer")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"` + msg + `"}`))
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/base64"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"github.com/go-jose/go-jose/v4"
|
||||||
|
"github.com/go-jose/go-jose/v4/jwt"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/testsupport"
|
||||||
|
)
|
||||||
|
|
||||||
|
func serve(h http.Handler, method, token string) int {
|
||||||
|
if token == "" {
|
||||||
|
return serveAuth(h, method, "")
|
||||||
|
}
|
||||||
|
return serveAuth(h, method, "Bearer "+token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveAuth(h http.Handler, method, authz string) int {
|
||||||
|
r := httptest.NewRequest(method, "/x", nil)
|
||||||
|
if authz != "" {
|
||||||
|
r.Header.Set("Authorization", authz)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
return w.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
func claims(iss string) jwt.Claims {
|
||||||
|
now := time.Now()
|
||||||
|
return jwt.Claims{
|
||||||
|
Issuer: iss,
|
||||||
|
Subject: "ci",
|
||||||
|
Audience: jwt.Audience{"artifactapi"},
|
||||||
|
IssuedAt: jwt.NewNumericDate(now.Add(-time.Minute)),
|
||||||
|
Expiry: jwt.NewNumericDate(now.Add(time.Hour)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hs256Token(t *testing.T, c jwt.Claims) string {
|
||||||
|
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: []byte("0123456789abcdef0123456789abcdef")},
|
||||||
|
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "k1"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tok, err := jwt.Signed(sig).Claims(c).Serialize()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return tok
|
||||||
|
}
|
||||||
|
|
||||||
|
func noneToken(iss string) string {
|
||||||
|
enc := base64.RawURLEncoding.EncodeToString
|
||||||
|
return enc([]byte(`{"alg":"none","typ":"JWT","kid":"k1"}`)) + "." +
|
||||||
|
enc([]byte(`{"iss":"`+iss+`","sub":"ci","aud":"artifactapi","exp":`+strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)+`}`)) + "."
|
||||||
|
}
|
||||||
|
|
||||||
|
var ok = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||||
|
|
||||||
|
func TestBearerOIDC(t *testing.T) {
|
||||||
|
iss := testsupport.NewOIDCIssuer()
|
||||||
|
defer iss.Close()
|
||||||
|
h := NewBearerOIDC(iss.URL, []string{"other", "artifactapi"}).Require(ok)
|
||||||
|
|
||||||
|
otherKey, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"valid", iss.Token(nil, "artifactapi", "ci", time.Hour), http.StatusNoContent},
|
||||||
|
{"missing", "", http.StatusUnauthorized},
|
||||||
|
{"garbage", "not.a.jwt", http.StatusUnauthorized},
|
||||||
|
{"bad signature", iss.Token(otherKey, "artifactapi", "ci", time.Hour), http.StatusUnauthorized},
|
||||||
|
{"wrong aud", iss.Token(nil, "nope", "ci", time.Hour), http.StatusUnauthorized},
|
||||||
|
{"expired", iss.Token(nil, "artifactapi", "ci", -time.Hour), http.StatusUnauthorized},
|
||||||
|
{"wrong iss", iss.Sign(nil, claims("https://evil.example")), http.StatusUnauthorized},
|
||||||
|
{"hs256", hs256Token(t, claims(iss.URL)), http.StatusUnauthorized},
|
||||||
|
{"alg none", noneToken(iss.URL), http.StatusUnauthorized},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := serve(h, http.MethodPost, c.token); got != c.want {
|
||||||
|
t.Errorf("%s: status %d, want %d", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
valid := iss.Token(nil, "artifactapi", "ci", time.Hour)
|
||||||
|
headers := []struct {
|
||||||
|
name string
|
||||||
|
authz string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"lowercase scheme", "bearer " + valid, http.StatusNoContent},
|
||||||
|
{"extra whitespace", " Bearer " + valid + " ", http.StatusNoContent},
|
||||||
|
{"empty value", "Bearer ", http.StatusUnauthorized},
|
||||||
|
{"scheme only", "Bearer", http.StatusUnauthorized},
|
||||||
|
{"basic scheme", "Basic " + valid, http.StatusUnauthorized},
|
||||||
|
}
|
||||||
|
for _, c := range headers {
|
||||||
|
if got := serveAuth(h, http.MethodPost, c.authz); got != c.want {
|
||||||
|
t.Errorf("%s: status %d, want %d", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerOIDCHangingIssuerBacksOff(t *testing.T) {
|
||||||
|
var hits atomic.Int32
|
||||||
|
release := make(chan struct{})
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
case <-release:
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
defer close(release)
|
||||||
|
|
||||||
|
b := NewBearerOIDC(srv.URL, nil)
|
||||||
|
b.timeout = 100 * time.Millisecond
|
||||||
|
b.backoff = time.Hour
|
||||||
|
h := b.Require(ok)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
if got := serve(h, http.MethodPost, "x.y.z"); got != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("hanging issuer: status %d, want 503", got)
|
||||||
|
}
|
||||||
|
if d := time.Since(start); d > 2*time.Second {
|
||||||
|
t.Fatalf("hanging issuer: took %s, timeout not applied", d)
|
||||||
|
}
|
||||||
|
if got := serve(h, http.MethodPost, "x.y.z"); got != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("during backoff: status %d, want 503", got)
|
||||||
|
}
|
||||||
|
if n := hits.Load(); n != 1 {
|
||||||
|
t.Fatalf("issuer hit %d times, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerOIDCPanickingDiscoveryBacksOff(t *testing.T) {
|
||||||
|
b := NewBearerOIDC("http://issuer.invalid", nil)
|
||||||
|
b.backoff = time.Hour
|
||||||
|
b.discover = func(context.Context, string) (*oidc.Provider, error) { panic("boom") }
|
||||||
|
h := b.Require(ok)
|
||||||
|
|
||||||
|
func() {
|
||||||
|
defer func() { _ = recover() }()
|
||||||
|
serve(h, http.MethodPost, "x.y.z")
|
||||||
|
}()
|
||||||
|
|
||||||
|
got := make(chan int, 1)
|
||||||
|
go func() { got <- serve(h, http.MethodPost, "x.y.z") }()
|
||||||
|
select {
|
||||||
|
case code := <-got:
|
||||||
|
if code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("after panic: status %d, want 503", code)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("after panic: request hung on stale inflight discovery")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerOIDCConcurrentFirstRequestsWaitForDiscovery(t *testing.T) {
|
||||||
|
iss := testsupport.NewOIDCIssuer()
|
||||||
|
defer iss.Close()
|
||||||
|
var hits atomic.Int32
|
||||||
|
inner := iss.Config.Handler
|
||||||
|
iss.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/.well-known/openid-configuration" {
|
||||||
|
hits.Add(1)
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
inner.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
|
||||||
|
h := NewBearerOIDC(iss.URL, nil).Require(ok)
|
||||||
|
tok := iss.Token(nil, "artifactapi", "ci", time.Hour)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
codes := make([]int, 20)
|
||||||
|
for i := range codes {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
codes[i] = serve(h, http.MethodPost, tok)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
for i, c := range codes {
|
||||||
|
if c != http.StatusNoContent {
|
||||||
|
t.Errorf("request %d: status %d, want 204", i, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := hits.Load(); n != 1 {
|
||||||
|
t.Fatalf("discovery hit %d times, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerOIDCRetriesAfterBackoff(t *testing.T) {
|
||||||
|
iss := testsupport.NewOIDCIssuer()
|
||||||
|
defer iss.Close()
|
||||||
|
b := NewBearerOIDC(iss.URL, nil)
|
||||||
|
b.failedAt = time.Now()
|
||||||
|
b.backoff = 50 * time.Millisecond
|
||||||
|
tok := iss.Token(nil, "artifactapi", "ci", time.Hour)
|
||||||
|
if got := serve(b.Require(ok), http.MethodPost, tok); got != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("within backoff: status %d, want 503", got)
|
||||||
|
}
|
||||||
|
time.Sleep(60 * time.Millisecond)
|
||||||
|
if got := serve(b.Require(ok), http.MethodPost, tok); got != http.StatusNoContent {
|
||||||
|
t.Fatalf("after backoff: status %d, want 204", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerOIDCIssuerDown(t *testing.T) {
|
||||||
|
iss := testsupport.NewOIDCIssuer()
|
||||||
|
b := NewBearerOIDC(iss.URL, []string{"artifactapi"})
|
||||||
|
tok := iss.Token(nil, "artifactapi", "ci", time.Hour)
|
||||||
|
iss.Close()
|
||||||
|
if got := serve(b.Require(ok), http.MethodPost, tok); got != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("issuer down: status %d, want 503", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerOIDCDisabled(t *testing.T) {
|
||||||
|
if got := serve(NewBearerOIDC("", nil).RequireWrites(ok), http.MethodDelete, ""); got != http.StatusNoContent {
|
||||||
|
t.Errorf("disabled: status %d, want 204", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireWritesLeavesReadsOpen(t *testing.T) {
|
||||||
|
iss := testsupport.NewOIDCIssuer()
|
||||||
|
defer iss.Close()
|
||||||
|
h := NewBearerOIDC(iss.URL, []string{"artifactapi"}).RequireWrites(ok)
|
||||||
|
for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodOptions} {
|
||||||
|
if got := serve(h, m, ""); got != http.StatusNoContent {
|
||||||
|
t.Errorf("%s: status %d, want 204", m, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
|
||||||
|
if got := serve(h, m, ""); got != http.StatusUnauthorized {
|
||||||
|
t.Errorf("%s: status %d, want 401", m, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -56,6 +57,10 @@ type Config struct {
|
|||||||
GitHubAppInstallationID string
|
GitHubAppInstallationID string
|
||||||
GitHubAppPrivateKey string
|
GitHubAppPrivateKey string
|
||||||
GitHubAppPrivateKeyPath string
|
GitHubAppPrivateKeyPath string
|
||||||
|
|
||||||
|
// Bearer-JWT auth on configuration writes; empty issuer disables it.
|
||||||
|
OIDCIssuer string
|
||||||
|
OIDCAudiences []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) DatabaseDSN() string {
|
func (c *Config) DatabaseDSN() string {
|
||||||
@@ -90,6 +95,17 @@ func Load() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("invalid GITHUB_SYNC_POLL_INTERVAL: %w", err)
|
return nil, fmt.Errorf("invalid GITHUB_SYNC_POLL_INTERVAL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oidcIssuer := getenv("ARTIFACTAPI_OIDC_ISSUER", "")
|
||||||
|
var oidcAudiences []string
|
||||||
|
for _, a := range strings.Split(getenv("ARTIFACTAPI_OIDC_AUDIENCE", ""), ",") {
|
||||||
|
if a = strings.TrimSpace(a); a != "" {
|
||||||
|
oidcAudiences = append(oidcAudiences, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oidcIssuer != "" && len(oidcAudiences) == 0 {
|
||||||
|
return nil, fmt.Errorf("ARTIFACTAPI_OIDC_AUDIENCE is required when ARTIFACTAPI_OIDC_ISSUER is set")
|
||||||
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
ListenAddr: getenv("LISTEN_ADDR", ":8000"),
|
ListenAddr: getenv("LISTEN_ADDR", ":8000"),
|
||||||
|
|
||||||
@@ -123,6 +139,9 @@ func Load() (*Config, error) {
|
|||||||
GitHubAppInstallationID: getenv("GITHUB_APP_INSTALLATION_ID", ""),
|
GitHubAppInstallationID: getenv("GITHUB_APP_INSTALLATION_ID", ""),
|
||||||
GitHubAppPrivateKey: getenv("GITHUB_APP_PRIVATE_KEY", ""),
|
GitHubAppPrivateKey: getenv("GITHUB_APP_PRIVATE_KEY", ""),
|
||||||
GitHubAppPrivateKeyPath: getenv("GITHUB_APP_PRIVATE_KEY_PATH", ""),
|
GitHubAppPrivateKeyPath: getenv("GITHUB_APP_PRIVATE_KEY_PATH", ""),
|
||||||
|
|
||||||
|
OIDCIssuer: oidcIssuer,
|
||||||
|
OIDCAudiences: oidcAudiences,
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
|
|||||||
@@ -64,3 +64,20 @@ func TestLoadInvalidPort(t *testing.T) {
|
|||||||
t.Error("expected error for invalid DBPORT")
|
t.Error("expected error for invalid DBPORT")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadOIDC(t *testing.T) {
|
||||||
|
t.Setenv("ARTIFACTAPI_OIDC_ISSUER", "https://vault.example/v1/identity/oidc")
|
||||||
|
t.Setenv("ARTIFACTAPI_OIDC_AUDIENCE", " a, ,b ")
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if len(cfg.OIDCAudiences) != 2 || cfg.OIDCAudiences[0] != "a" || cfg.OIDCAudiences[1] != "b" {
|
||||||
|
t.Errorf("audiences = %q", cfg.OIDCAudiences)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("ARTIFACTAPI_OIDC_AUDIENCE", "")
|
||||||
|
if _, err := Load(); err == nil {
|
||||||
|
t.Error("expected error for issuer without audience")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Package nuget proxies a NuGet v3 feed: the service index, registration pages
|
||||||
|
// and flat container indexes are mutable, .nupkg/.nuspec downloads immutable.
|
||||||
|
package nuget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/auth"
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
provider.Register(&Provider{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceIndex is the path clients use as their source URL; it maps to base_url.
|
||||||
|
const serviceIndex = "index.json"
|
||||||
|
|
||||||
|
type Provider struct{}
|
||||||
|
|
||||||
|
func (p *Provider) Type() models.PackageType { return models.PackageNuGet }
|
||||||
|
|
||||||
|
func (p *Provider) Classify(path string) provider.Mutability {
|
||||||
|
if isPackage(path) {
|
||||||
|
return provider.Immutable
|
||||||
|
}
|
||||||
|
return provider.Mutable
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) ContentType(path string) string {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(path, ".nupkg"):
|
||||||
|
return "application/octet-stream"
|
||||||
|
case strings.HasSuffix(path, ".nuspec"):
|
||||||
|
return "application/xml"
|
||||||
|
}
|
||||||
|
return "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpstreamURL maps index.json to base_url (the upstream service index) and any
|
||||||
|
// other path to the same path on the base_url's origin, where nuget.org serves
|
||||||
|
// v3-flatcontainer/ and v3/registration*/.
|
||||||
|
func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
|
||||||
|
path = strings.TrimLeft(path, "/")
|
||||||
|
if path == serviceIndex {
|
||||||
|
return remote.BaseURL
|
||||||
|
}
|
||||||
|
return origin(remote.BaseURL) + "/" + path
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) RewriteResponse(_ []byte, _ models.Remote, _ string) ([]byte, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RewriteServed points upstream-origin URLs in cached JSON back at this remote.
|
||||||
|
// It runs per request because the URLs must name the requesting host.
|
||||||
|
func (p *Provider) RewriteServed(body []byte, remote models.Remote, proxyBaseURL string) []byte {
|
||||||
|
from := origin(remote.BaseURL)
|
||||||
|
if from == "" || !json.Valid(body) {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
to := strings.TrimRight(proxyBaseURL, "/") + "/api/v1/remote/" + remote.Name
|
||||||
|
return []byte(strings.ReplaceAll(string(body), from, to))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
|
||||||
|
return auth.BasicHeaders(remote), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func origin(baseURL string) string {
|
||||||
|
u, err := url.Parse(baseURL)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return u.Scheme + "://" + u.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPackage(path string) bool {
|
||||||
|
return strings.HasSuffix(path, ".nupkg") || strings.HasSuffix(path, ".nuspec")
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package nuget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ provider.ServeRewriter = (*Provider)(nil)
|
||||||
|
|
||||||
|
var nugetOrg = models.Remote{Name: "nuget-org", BaseURL: "https://api.nuget.org/v3/index.json"}
|
||||||
|
|
||||||
|
func TestClassify(t *testing.T) {
|
||||||
|
p := &Provider{}
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
want provider.Mutability
|
||||||
|
}{
|
||||||
|
{"v3-flatcontainer/newtonsoft.json/13.0.3/newtonsoft.json.13.0.3.nupkg", provider.Immutable},
|
||||||
|
{"v3-flatcontainer/newtonsoft.json/13.0.3/newtonsoft.json.nuspec", provider.Immutable},
|
||||||
|
{"v3-flatcontainer/newtonsoft.json/index.json", provider.Mutable},
|
||||||
|
{"v3/registration5-gz-semver2/newtonsoft.json/index.json", provider.Mutable},
|
||||||
|
{"index.json", provider.Mutable},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := p.Classify(tt.path); got != tt.want {
|
||||||
|
t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpstreamURL(t *testing.T) {
|
||||||
|
p := &Provider{}
|
||||||
|
tests := []struct {
|
||||||
|
path, want string
|
||||||
|
}{
|
||||||
|
{"index.json", "https://api.nuget.org/v3/index.json"},
|
||||||
|
{"/index.json", "https://api.nuget.org/v3/index.json"},
|
||||||
|
{"v3-flatcontainer/serilog/index.json", "https://api.nuget.org/v3-flatcontainer/serilog/index.json"},
|
||||||
|
{"v3/registration5-gz-semver2/serilog/index.json", "https://api.nuget.org/v3/registration5-gz-semver2/serilog/index.json"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := p.UpstreamURL(nugetOrg, tt.path); got != tt.want {
|
||||||
|
t.Errorf("UpstreamURL(%q) = %q, want %q", tt.path, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteServed(t *testing.T) {
|
||||||
|
p := &Provider{}
|
||||||
|
body := `{"resources":[` +
|
||||||
|
`{"@id":"https://api.nuget.org/v3-flatcontainer/","@type":"PackageBaseAddress/3.0.0"},` +
|
||||||
|
`{"@id":"https://azuresearch-usnc.nuget.org/query","@type":"SearchQueryService"}]}`
|
||||||
|
want := `{"resources":[` +
|
||||||
|
`{"@id":"http://aa.example:8000/api/v1/remote/nuget-org/v3-flatcontainer/","@type":"PackageBaseAddress/3.0.0"},` +
|
||||||
|
`{"@id":"https://azuresearch-usnc.nuget.org/query","@type":"SearchQueryService"}]}`
|
||||||
|
if got := string(p.RewriteServed([]byte(body), nugetOrg, "http://aa.example:8000/")); got != want {
|
||||||
|
t.Errorf("RewriteServed =\n%s\nwant\n%s", got, want)
|
||||||
|
}
|
||||||
|
if got := string(p.RewriteServed([]byte(body), models.Remote{Name: "x"}, "http://aa.example")); got != body {
|
||||||
|
t.Error("remote without a parseable base_url must pass the body through")
|
||||||
|
}
|
||||||
|
cert := "\x30\x82https://api.nuget.org"
|
||||||
|
if got := string(p.RewriteServed([]byte(cert), nugetOrg, "http://aa.example")); got != cert {
|
||||||
|
t.Error("non-JSON bodies must pass through untouched")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,13 @@ type RemoteServer interface {
|
|||||||
ServeRemote(w http.ResponseWriter, r *http.Request, remote models.Remote, path, proxyBaseURL string, store RemoteMetadataStore) bool
|
ServeRemote(w http.ResponseWriter, r *http.Request, remote models.Remote, path, proxyBaseURL string, store RemoteMetadataStore) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServeRewriter rewrites a cached mutable remote response per request, for
|
||||||
|
// indexes whose URLs must name the requesting host and so cannot be rewritten
|
||||||
|
// before caching.
|
||||||
|
type ServeRewriter interface {
|
||||||
|
RewriteServed(body []byte, remote models.Remote, proxyBaseURL string) []byte
|
||||||
|
}
|
||||||
|
|
||||||
// RemoteMetadataStore is the persistence surface a RemoteServer needs to cache
|
// RemoteMetadataStore is the persistence surface a RemoteServer needs to cache
|
||||||
// and read the metadata it derives per upstream asset. *database.DB satisfies it.
|
// and read the metadata it derives per upstream asset. *database.DB satisfies it.
|
||||||
type RemoteMetadataStore interface {
|
type RemoteMetadataStore interface {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func cors(next http.Handler) http.Handler {
|
|||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/testsupport"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServerOIDCWriteAuth(t *testing.T) {
|
||||||
|
requireStack(t)
|
||||||
|
iss := testsupport.NewOIDCIssuer()
|
||||||
|
defer iss.Close()
|
||||||
|
|
||||||
|
cfg := *testSrv.cfg
|
||||||
|
cfg.OIDCIssuer = iss.URL
|
||||||
|
cfg.OIDCAudiences = []string{"artifactapi"}
|
||||||
|
s := *testSrv
|
||||||
|
s.cfg = &cfg
|
||||||
|
ts := httptest.NewServer(s.routes())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
good := iss.Token(nil, "artifactapi", "ci", time.Hour)
|
||||||
|
do := func(method, path, body, token string) int {
|
||||||
|
t.Helper()
|
||||||
|
rq, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
|
||||||
|
rq.Header.Set("Content-Type", "application/json")
|
||||||
|
if token != "" {
|
||||||
|
rq.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(rq)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %s: %v", method, path, err)
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
expect := func(want int, method, path, body, token string) {
|
||||||
|
t.Helper()
|
||||||
|
if got := do(method, path, body, token); got != want {
|
||||||
|
t.Errorf("%s %s: status %d, want %d", method, path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remote := `{"name":"srv-oidc","package_type":"generic","repo_type":"local"}`
|
||||||
|
virtual := `{"name":"srv-oidc-v","package_type":"generic","members":["srv-oidc"]}`
|
||||||
|
|
||||||
|
expect(401, "POST", "/api/v2/remotes", remote, "")
|
||||||
|
expect(401, "POST", "/api/v2/remotes", remote, iss.Token(nil, "other", "ci", time.Hour))
|
||||||
|
expect(201, "POST", "/api/v2/remotes", remote, good)
|
||||||
|
expect(401, "PUT", "/api/v2/remotes/srv-oidc", `{"package_type":"generic"}`, "")
|
||||||
|
expect(401, "DELETE", "/api/v2/remotes/srv-oidc", "", "")
|
||||||
|
expect(401, "POST", "/api/v2/virtuals", virtual, "")
|
||||||
|
expect(201, "POST", "/api/v2/virtuals", virtual, good)
|
||||||
|
expect(401, "PUT", "/api/v2/virtuals/srv-oidc-v", virtual, "")
|
||||||
|
expect(401, "DELETE", "/api/v2/virtuals/srv-oidc-v", "", "")
|
||||||
|
|
||||||
|
expect(200, "GET", "/api/v2/remotes", "", "")
|
||||||
|
expect(200, "GET", "/api/v2/remotes/srv-oidc", "", "")
|
||||||
|
expect(200, "GET", "/api/v2/virtuals/srv-oidc-v", "", "")
|
||||||
|
expect(201, "PUT", "/api/v2/remotes/srv-oidc/files/a.bin", "payload", "")
|
||||||
|
expect(200, "GET", "/api/v2/remotes/srv-oidc/files/a.bin", "", "")
|
||||||
|
if got := do("DELETE", "/api/v2/remotes/srv-oidc/files/a.bin", "", ""); got >= 400 {
|
||||||
|
t.Errorf("delete file: status %d", got)
|
||||||
|
}
|
||||||
|
if got := do("POST", "/api/v2/probe", `{}`, ""); got == 401 {
|
||||||
|
t.Error("probe gated")
|
||||||
|
}
|
||||||
|
if got := do("GET", "/v2/", "", ""); got == 401 {
|
||||||
|
t.Error("docker /v2 gated")
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(204, "DELETE", "/api/v2/virtuals/srv-oidc-v", "", good)
|
||||||
|
expect(204, "DELETE", "/api/v2/remotes/srv-oidc", "", good)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
tfregistry "git.unkin.net/unkin/artifactapi/internal/api/terraform"
|
tfregistry "git.unkin.net/unkin/artifactapi/internal/api/terraform"
|
||||||
v1 "git.unkin.net/unkin/artifactapi/internal/api/v1"
|
v1 "git.unkin.net/unkin/artifactapi/internal/api/v1"
|
||||||
v2 "git.unkin.net/unkin/artifactapi/internal/api/v2"
|
v2 "git.unkin.net/unkin/artifactapi/internal/api/v2"
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/auth"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/cache"
|
"git.unkin.net/unkin/artifactapi/internal/cache"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/config"
|
"git.unkin.net/unkin/artifactapi/internal/config"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/database"
|
"git.unkin.net/unkin/artifactapi/internal/database"
|
||||||
@@ -28,6 +29,7 @@ import (
|
|||||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/goproxy"
|
_ "git.unkin.net/unkin/artifactapi/internal/provider/goproxy"
|
||||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/helm"
|
_ "git.unkin.net/unkin/artifactapi/internal/provider/helm"
|
||||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/npm"
|
_ "git.unkin.net/unkin/artifactapi/internal/provider/npm"
|
||||||
|
_ "git.unkin.net/unkin/artifactapi/internal/provider/nuget"
|
||||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/puppet"
|
_ "git.unkin.net/unkin/artifactapi/internal/provider/puppet"
|
||||||
_ "git.unkin.net/unkin/artifactapi/internal/provider/pypi"
|
_ "git.unkin.net/unkin/artifactapi/internal/provider/pypi"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/provider/rpm"
|
"git.unkin.net/unkin/artifactapi/internal/provider/rpm"
|
||||||
@@ -186,10 +188,11 @@ func (s *Server) routes() chi.Router {
|
|||||||
statsHandler := v2.NewStatsHandler(s.db)
|
statsHandler := v2.NewStatsHandler(s.db)
|
||||||
eventsHandler := v2.NewEventsHandler()
|
eventsHandler := v2.NewEventsHandler()
|
||||||
probeHandler := v2.NewProbeHandler(s.engine, s.db)
|
probeHandler := v2.NewProbeHandler(s.engine, s.db)
|
||||||
|
writeAuth := auth.NewBearerOIDC(s.cfg.OIDCIssuer, s.cfg.OIDCAudiences)
|
||||||
|
|
||||||
r.Route("/api/v2", func(r chi.Router) {
|
r.Route("/api/v2", func(r chi.Router) {
|
||||||
r.Mount("/remotes", remotesHandler.Routes())
|
r.Mount("/remotes", writeAuth.RequireWrites(remotesHandler.Routes()))
|
||||||
r.Mount("/virtuals", virtualsHandler.Routes())
|
r.Mount("/virtuals", writeAuth.RequireWrites(virtualsHandler.Routes()))
|
||||||
r.Mount("/health", healthHandler.Routes())
|
r.Mount("/health", healthHandler.Routes())
|
||||||
r.Mount("/stats", statsHandler.Routes())
|
r.Mount("/stats", statsHandler.Routes())
|
||||||
r.Mount("/events", eventsHandler.Routes())
|
r.Mount("/events", eventsHandler.Routes())
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package testsupport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-jose/go-jose/v4"
|
||||||
|
"github.com/go-jose/go-jose/v4/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OIDCIssuer is a minimal OIDC issuer serving discovery and JWKS, signing
|
||||||
|
// RS256 tokens with a generated key.
|
||||||
|
type OIDCIssuer struct {
|
||||||
|
*httptest.Server
|
||||||
|
key *rsa.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOIDCIssuer() *OIDCIssuer {
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
iss := &OIDCIssuer{key: key}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"issuer": iss.URL,
|
||||||
|
"jwks_uri": iss.URL + "/keys",
|
||||||
|
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/keys", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{
|
||||||
|
{Key: &key.PublicKey, KeyID: "k1", Algorithm: "RS256", Use: "sig"},
|
||||||
|
}})
|
||||||
|
})
|
||||||
|
iss.Server = httptest.NewServer(mux)
|
||||||
|
return iss
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token signs a token for aud and sub. Pass a nil key to use the issuer's own;
|
||||||
|
// expiry is relative to now.
|
||||||
|
func (o *OIDCIssuer) Token(key *rsa.PrivateKey, aud, sub string, expiry time.Duration) string {
|
||||||
|
now := time.Now()
|
||||||
|
return o.Sign(key, jwt.Claims{
|
||||||
|
Issuer: o.URL,
|
||||||
|
Subject: sub,
|
||||||
|
Audience: jwt.Audience{aud},
|
||||||
|
IssuedAt: jwt.NewNumericDate(now.Add(-time.Minute)),
|
||||||
|
Expiry: jwt.NewNumericDate(now.Add(expiry)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign signs arbitrary claims with RS256. Pass a nil key to use the issuer's own.
|
||||||
|
func (o *OIDCIssuer) Sign(key *rsa.PrivateKey, claims jwt.Claims) string {
|
||||||
|
if key == nil {
|
||||||
|
key = o.key
|
||||||
|
}
|
||||||
|
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key},
|
||||||
|
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "k1"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
tok, err := jwt.Signed(sig).Claims(claims).Serialize()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return tok
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package virtual
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// debIndexRe matches the binary Packages indexes a virtual merges: the flat
|
||||||
|
// repo's root Packages and a suite's <component>/binary-<arch>/Packages.
|
||||||
|
var debIndexRe = regexp.MustCompile(`^([^/]+/binary-[^/]+/)?Packages(\.[a-z0-9]+)?$`)
|
||||||
|
|
||||||
|
// debByHashRe matches an index requested by its SHA256 (Acquire-By-Hash).
|
||||||
|
var debByHashRe = regexp.MustCompile(`^([^/]+/binary-[^/]+/)?by-hash/SHA256/[0-9a-f]{64}$`)
|
||||||
|
|
||||||
|
// debCompressions is the order a member's index variants are fetched in.
|
||||||
|
var debCompressions = []string{".xz", ".gz", ".zst", ".bz2", ""}
|
||||||
|
|
||||||
|
// DebMember is one member's Release plus its decompressed Packages indexes,
|
||||||
|
// keyed by index directory relative to the Release ("" for a flat repo).
|
||||||
|
type DebMember struct {
|
||||||
|
RemoteName string
|
||||||
|
Release []byte
|
||||||
|
Packages map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebRepo is a merged apt repo: Release plus the indexes it lists, keyed by
|
||||||
|
// their path relative to the Release.
|
||||||
|
type DebRepo struct {
|
||||||
|
Release []byte
|
||||||
|
Files map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type debRelease struct {
|
||||||
|
fields map[string]string
|
||||||
|
files []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDebRelease reads a Release file's single-line fields and the names
|
||||||
|
// listed in its checksum sections.
|
||||||
|
func parseDebRelease(body []byte) debRelease {
|
||||||
|
rel := debRelease{fields: map[string]string{}}
|
||||||
|
inSums := false
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for line := range strings.SplitSeq(string(body), "\n") {
|
||||||
|
line = strings.TrimRight(line, "\r")
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line[0] == ' ' || line[0] == '\t' {
|
||||||
|
if f := strings.Fields(line); inSums && len(f) == 3 && !seen[f[2]] {
|
||||||
|
seen[f[2]] = true
|
||||||
|
rel.files = append(rel.files, f[2])
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, v, _ := strings.Cut(line, ":")
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
inSums = v == "" && (k == "MD5Sum" || strings.HasPrefix(k, "SHA"))
|
||||||
|
if _, ok := rel.fields[k]; !ok {
|
||||||
|
rel.fields[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rel
|
||||||
|
}
|
||||||
|
|
||||||
|
// debIndexFiles returns, per index directory, the variant of its Packages file
|
||||||
|
// to fetch from a member's Release listing.
|
||||||
|
func debIndexFiles(rel debRelease) map[string]string {
|
||||||
|
listed := map[string]bool{}
|
||||||
|
for _, f := range rel.files {
|
||||||
|
listed[f] = true
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, f := range rel.files {
|
||||||
|
m := debIndexRe.FindStringSubmatch(f)
|
||||||
|
if m == nil || out[m[1]] != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, c := range debCompressions {
|
||||||
|
if listed[m[1]+"Packages"+c] {
|
||||||
|
out[m[1]] = m[1] + "Packages" + c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitStanzas(body []byte) []string {
|
||||||
|
var out []string
|
||||||
|
for s := range strings.SplitSeq(strings.ReplaceAll(string(body), "\r\n", "\n"), "\n\n") {
|
||||||
|
if s = strings.Trim(s, "\n"); s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func stanzaField(stanza, key string) string {
|
||||||
|
for line := range strings.SplitSeq(stanza, "\n") {
|
||||||
|
if k, v, ok := strings.Cut(line, ":"); ok && k == key {
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func unionFields(dst []string, field string) []string {
|
||||||
|
for f := range strings.FieldsSeq(field) {
|
||||||
|
if !slices.Contains(dst, f) {
|
||||||
|
dst = append(dst, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDebDate(s string) (time.Time, bool) {
|
||||||
|
for _, layout := range []string{time.RFC1123Z, time.RFC1123} {
|
||||||
|
if t, err := time.Parse(layout, s); err == nil {
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeDeb merges members into one apt repo. Members are in priority order: the
|
||||||
|
// first member to carry a (Package, Version, Architecture) in an index wins;
|
||||||
|
// stanzas missing any of those are always kept. Filename is prefixed with the
|
||||||
|
// owning member's name so the virtual can route downloads back to it.
|
||||||
|
func MergeDeb(members []DebMember) *DebRepo {
|
||||||
|
var suite, codename string
|
||||||
|
var arches, comps, dirs []string
|
||||||
|
date := time.Unix(0, 0)
|
||||||
|
for _, m := range members {
|
||||||
|
rel := parseDebRelease(m.Release)
|
||||||
|
if suite == "" {
|
||||||
|
suite, codename = rel.fields["Suite"], rel.fields["Codename"]
|
||||||
|
}
|
||||||
|
arches = unionFields(arches, rel.fields["Architectures"])
|
||||||
|
comps = unionFields(comps, rel.fields["Components"])
|
||||||
|
if t, ok := parseDebDate(rel.fields["Date"]); ok && t.After(date) {
|
||||||
|
date = t
|
||||||
|
}
|
||||||
|
for d := range m.Packages {
|
||||||
|
if !slices.Contains(dirs, d) {
|
||||||
|
dirs = append(dirs, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(dirs)
|
||||||
|
|
||||||
|
repo := &DebRepo{Files: map[string][]byte{}}
|
||||||
|
for _, d := range dirs {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var b bytes.Buffer
|
||||||
|
for _, m := range members {
|
||||||
|
for _, s := range splitStanzas(m.Packages[d]) {
|
||||||
|
pkg, ver, arch := stanzaField(s, "Package"), stanzaField(s, "Version"), stanzaField(s, "Architecture")
|
||||||
|
if pkg != "" && ver != "" && arch != "" {
|
||||||
|
key := pkg + "\x00" + ver + "\x00" + arch
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
}
|
||||||
|
if fn := stanzaField(s, "Filename"); fn != "" {
|
||||||
|
s = rewriteFilename(s, m.RemoteName+"/"+strings.TrimPrefix(path.Clean("/"+fn), "/"))
|
||||||
|
}
|
||||||
|
b.WriteString(s)
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gz := gzipDeterministic(b.Bytes())
|
||||||
|
repo.Files[d+"Packages"] = b.Bytes()
|
||||||
|
repo.Files[d+"Packages.gz"] = gz
|
||||||
|
repo.Files[d+"by-hash/SHA256/"+sha256Hex(b.Bytes())] = b.Bytes()
|
||||||
|
repo.Files[d+"by-hash/SHA256/"+sha256Hex(gz)] = gz
|
||||||
|
}
|
||||||
|
|
||||||
|
var r bytes.Buffer
|
||||||
|
if suite != "" {
|
||||||
|
fmt.Fprintf(&r, "Suite: %s\n", suite)
|
||||||
|
}
|
||||||
|
if codename != "" {
|
||||||
|
fmt.Fprintf(&r, "Codename: %s\n", codename)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&r, "Date: %s\n", date.UTC().Format(time.RFC1123Z))
|
||||||
|
fmt.Fprintf(&r, "Architectures: %s\n", strings.Join(arches, " "))
|
||||||
|
if len(comps) > 0 {
|
||||||
|
fmt.Fprintf(&r, "Components: %s\n", strings.Join(comps, " "))
|
||||||
|
}
|
||||||
|
r.WriteString("Acquire-By-Hash: yes\n")
|
||||||
|
for _, sec := range []struct {
|
||||||
|
name string
|
||||||
|
sum func([]byte) string
|
||||||
|
}{{"MD5Sum", md5Hex}, {"SHA256", sha256Hex}} {
|
||||||
|
r.WriteString(sec.name + ":\n")
|
||||||
|
for _, d := range dirs {
|
||||||
|
for _, n := range []string{d + "Packages", d + "Packages.gz"} {
|
||||||
|
fmt.Fprintf(&r, " %s %d %s\n", sec.sum(repo.Files[n]), len(repo.Files[n]), n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repo.Release = r.Bytes()
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func rewriteFilename(stanza, fn string) string {
|
||||||
|
lines := strings.Split(stanza, "\n")
|
||||||
|
for i, l := range lines {
|
||||||
|
if strings.HasPrefix(l, "Filename:") {
|
||||||
|
lines[i] = "Filename: " + fn
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// debIndexPath splits an index request into its Release directory and the file
|
||||||
|
// under it. ok is false for anything a deb virtual does not merge.
|
||||||
|
func debIndexPath(p string) (base, name string, ok bool) {
|
||||||
|
if rest, found := strings.CutPrefix(p, "dists/"); found {
|
||||||
|
suite, n, found := strings.Cut(rest, "/")
|
||||||
|
if !found || suite == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
base, p = "dists/"+suite+"/", n
|
||||||
|
}
|
||||||
|
if p != "Release" && !debIndexRe.MatchString(p) && !debByHashRe.MatchString(p) {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return base, p, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func md5Hex(data []byte) string {
|
||||||
|
h := md5.Sum(data)
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package virtual
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"path"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func debStanza(pkg, ver, arch, filename string) string {
|
||||||
|
return fmt.Sprintf("Package: %s\nVersion: %s\nArchitecture: %s\nDescription: test\n multi-line body\nFilename: %s\nSize: 1\n", pkg, ver, arch, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
func flatDebMember(name, date string, stanzas ...string) DebMember {
|
||||||
|
rel := "Date: " + date + "\nArchitectures: amd64\nAcquire-By-Hash: no\nSHA256:\n x 1 Packages\n x 1 Packages.gz\n"
|
||||||
|
return DebMember{RemoteName: name, Release: []byte(rel), Packages: map[string][]byte{"": []byte(strings.Join(stanzas, "\n"))}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeDebDedupesByMemberOrder(t *testing.T) {
|
||||||
|
repo := MergeDeb([]DebMember{
|
||||||
|
flatDebMember("a", "Mon, 05 Oct 2026 10:00:00 +0000",
|
||||||
|
debStanza("foo", "1.0", "amd64", "pool/foo_1.0_amd64.deb"),
|
||||||
|
debStanza("bar", "2.0", "amd64", "/pool/bar.deb"),
|
||||||
|
debStanza("dot", "1.0", "amd64", "./dot.deb"),
|
||||||
|
"Package: noversion\nFilename: nv1.deb\n",
|
||||||
|
"Package: noversion\nFilename: nv2.deb\n"),
|
||||||
|
flatDebMember("b", "Tue, 06 Oct 2026 10:00:00 UTC",
|
||||||
|
debStanza("foo", "1.0", "amd64", "pool/foo-from-b.deb"),
|
||||||
|
debStanza("foo", "1.0", "arm64", "pool/foo_arm64.deb"),
|
||||||
|
debStanza("foo", "1.1", "amd64", "o/r/releases/download/v1/foo.deb")),
|
||||||
|
})
|
||||||
|
pk := string(repo.Files["Packages"])
|
||||||
|
stanzas := splitStanzas(repo.Files["Packages"])
|
||||||
|
if len(stanzas) != 7 {
|
||||||
|
t.Fatalf("want 7 stanzas (one duplicate dropped), got %d:\n%s", len(stanzas), pk)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"Filename: a/pool/foo_1.0_amd64.deb",
|
||||||
|
"Filename: a/pool/bar.deb",
|
||||||
|
"Filename: a/dot.deb",
|
||||||
|
"Filename: a/nv1.deb",
|
||||||
|
"Filename: a/nv2.deb",
|
||||||
|
"Filename: b/pool/foo_arm64.deb",
|
||||||
|
"Filename: b/o/r/releases/download/v1/foo.deb",
|
||||||
|
" multi-line body",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(pk, want) {
|
||||||
|
t.Errorf("merged Packages missing %q:\n%s", want, pk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(pk, "foo-from-b") {
|
||||||
|
t.Fatalf("second member's duplicate must lose:\n%s", pk)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(gunzip(t, repo.Files["Packages.gz"]), repo.Files["Packages"]) {
|
||||||
|
t.Fatal("Packages.gz does not match Packages")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(repo.Release), "Date: Tue, 06 Oct 2026 10:00:00 +0000\n") {
|
||||||
|
t.Fatalf("Date should be the newest member date:\n%s", repo.Release)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeDebReleaseHashesFiles(t *testing.T) {
|
||||||
|
repo := MergeDeb([]DebMember{flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo.deb"))})
|
||||||
|
rel := parseDebRelease(repo.Release)
|
||||||
|
if got := slices.Sorted(slices.Values(rel.files)); !slices.Equal(got, []string{"Packages", "Packages.gz"}) {
|
||||||
|
t.Fatalf("Release lists %v", got)
|
||||||
|
}
|
||||||
|
inSHA := false
|
||||||
|
checked := 0
|
||||||
|
for line := range strings.SplitSeq(string(repo.Release), "\n") {
|
||||||
|
if !strings.HasPrefix(line, " ") {
|
||||||
|
inSHA = line == "SHA256:"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var sum, name string
|
||||||
|
var size int
|
||||||
|
if _, err := fmt.Sscan(line, &sum, &size, &name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := repo.Files[name]
|
||||||
|
want := sha256Hex(body)
|
||||||
|
if !inSHA {
|
||||||
|
h := md5.Sum(body)
|
||||||
|
want = hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
if sum != want || size != len(body) {
|
||||||
|
t.Errorf("%s: Release has %s %d, file is %s %d", name, sum, size, want, len(body))
|
||||||
|
}
|
||||||
|
checked++
|
||||||
|
}
|
||||||
|
if checked != 4 {
|
||||||
|
t.Fatalf("checked %d Release entries, want 4:\n%s", checked, repo.Release)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(MergeDeb([]DebMember{flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo.deb"))}).Release, repo.Release) {
|
||||||
|
t.Fatal("merge must be deterministic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeDebSuiteLayout(t *testing.T) {
|
||||||
|
rel := func(comps string) []byte {
|
||||||
|
return []byte("Suite: stable\nCodename: trixie\nArchitectures: amd64 arm64\nComponents: " + comps + "\nSHA256:\n x 1 main/binary-amd64/Packages.xz\n")
|
||||||
|
}
|
||||||
|
repo := MergeDeb([]DebMember{
|
||||||
|
{RemoteName: "deb1", Release: rel("main"), Packages: map[string][]byte{"main/binary-amd64/": []byte(debStanza("foo", "1", "amd64", "pool/main/f/foo.deb"))}},
|
||||||
|
{RemoteName: "deb2", Release: rel("main contrib"), Packages: map[string][]byte{
|
||||||
|
"main/binary-amd64/": []byte(debStanza("bar", "1", "amd64", "pool/main/b/bar.deb")),
|
||||||
|
"contrib/binary-amd64/": []byte(debStanza("baz", "1", "amd64", "pool/contrib/b/baz.deb")),
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
for _, want := range []string{"Suite: stable\n", "Codename: trixie\n", "Components: main contrib\n", "Architectures: amd64 arm64\n"} {
|
||||||
|
if !strings.Contains(string(repo.Release), want) {
|
||||||
|
t.Errorf("Release missing %q:\n%s", want, repo.Release)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := []string{"contrib/binary-amd64/Packages", "contrib/binary-amd64/Packages.gz", "main/binary-amd64/Packages", "main/binary-amd64/Packages.gz"}
|
||||||
|
if got := slices.Sorted(slices.Values(parseDebRelease(repo.Release).files)); !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("Release lists %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(repo.Release), "Acquire-By-Hash: yes\n") {
|
||||||
|
t.Fatalf("Release must advertise by-hash:\n%s", repo.Release)
|
||||||
|
}
|
||||||
|
for _, n := range want {
|
||||||
|
byHash := path.Dir(n) + "/by-hash/SHA256/" + sha256Hex(repo.Files[n])
|
||||||
|
if !bytes.Equal(repo.Files[byHash], repo.Files[n]) {
|
||||||
|
t.Errorf("%s not served at %s", n, byHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(repo.Files) != 2*len(want) {
|
||||||
|
t.Fatalf("files %v, want each index plus its by-hash copy", slices.Sorted(maps.Keys(repo.Files)))
|
||||||
|
}
|
||||||
|
if n := len(splitStanzas(repo.Files["main/binary-amd64/Packages"])); n != 2 {
|
||||||
|
t.Fatalf("main/binary-amd64 should merge both members, got %d stanzas", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebIndexFiles(t *testing.T) {
|
||||||
|
rel := parseDebRelease([]byte("Origin: Debian\nMD5Sum:\n a 1 main/binary-amd64/Packages\n a 1 main/binary-amd64/Packages.gz\nSHA256:\n" +
|
||||||
|
" a 1 main/binary-amd64/Packages\n a 1 main/binary-amd64/Packages.xz\n a 1 main/binary-amd64/Packages.gz\n" +
|
||||||
|
" a 1 main/debian-installer/binary-amd64/Packages.xz\n a 1 main/i18n/Translation-en.xz\n a 1 contrib/binary-all/Packages\n"))
|
||||||
|
got := debIndexFiles(rel)
|
||||||
|
want := map[string]string{"main/binary-amd64/": "main/binary-amd64/Packages.xz", "contrib/binary-all/": "contrib/binary-all/Packages"}
|
||||||
|
if !maps.Equal(got, want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if flat := debIndexFiles(parseDebRelease([]byte("SHA256:\n a 1 Packages\n a 1 Packages.gz\n"))); flat[""] != "Packages.gz" {
|
||||||
|
t.Fatalf("flat repo should fetch Packages.gz, got %v", flat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebIndexPath(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
in, base, name string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"Release", "", "Release", true},
|
||||||
|
{"Packages.gz", "", "Packages.gz", true},
|
||||||
|
{"dists/trixie/Release", "dists/trixie/", "Release", true},
|
||||||
|
{"dists/trixie/main/binary-amd64/Packages", "dists/trixie/", "main/binary-amd64/Packages", true},
|
||||||
|
{"by-hash/SHA256/abababababababababababababababababababababababababababababababab", "", "by-hash/SHA256/abababababababababababababababababababababababababababababababab", true},
|
||||||
|
{"dists/trixie/main/binary-amd64/by-hash/SHA256/abababababababababababababababababababababababababababababababab", "dists/trixie/", "main/binary-amd64/by-hash/SHA256/abababababababababababababababababababababababababababababababab", true},
|
||||||
|
{"dists/trixie/main/binary-amd64/by-hash/MD5Sum/abababababababababababababababab", "", "", false},
|
||||||
|
{"InRelease", "", "", false},
|
||||||
|
{"Release.gpg", "", "", false},
|
||||||
|
{"dists/trixie/InRelease", "", "", false},
|
||||||
|
{"dists/trixie/main/i18n/Translation-en", "", "", false},
|
||||||
|
{"dists/Release", "", "", false},
|
||||||
|
{"pool/foo.deb", "", "", false},
|
||||||
|
} {
|
||||||
|
base, name, ok := debIndexPath(tc.in)
|
||||||
|
if base != tc.base || name != tc.name || ok != tc.ok {
|
||||||
|
t.Errorf("%s: got (%q, %q, %v), want (%q, %q, %v)", tc.in, base, name, ok, tc.base, tc.name, tc.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+166
-43
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -26,32 +27,51 @@ type Engine struct {
|
|||||||
proxyEngine *proxy.Engine
|
proxyEngine *proxy.Engine
|
||||||
getRemote func(context.Context, string) (*models.Remote, error)
|
getRemote func(context.Context, string) (*models.Remote, error)
|
||||||
rpmMember func(context.Context, string) (*RPMMember, error)
|
rpmMember func(context.Context, string) (*RPMMember, error)
|
||||||
|
debMember func(ctx context.Context, name, base string) (*DebMember, error)
|
||||||
|
|
||||||
mergeTTL time.Duration
|
mergeTTL time.Duration
|
||||||
|
|
||||||
sf singleflight.Group
|
sf singleflight.Group
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
rpm map[string]*rpmGen
|
gens map[string]*repoGen
|
||||||
}
|
}
|
||||||
|
|
||||||
// rpmGen holds a virtual's current merge plus the previous one, so a client
|
// mergedRepo is a merged index document plus the files it references.
|
||||||
// holding the previous repomd can still fetch its content-hashed data files.
|
type mergedRepo interface {
|
||||||
type rpmGen struct {
|
index() []byte
|
||||||
cur, prev *RPMRepo
|
files() map[string][]byte
|
||||||
at time.Time
|
}
|
||||||
|
|
||||||
|
func (r *RPMRepo) index() []byte { return r.Repomd }
|
||||||
|
func (r *RPMRepo) files() map[string][]byte { return r.Files }
|
||||||
|
func (r *DebRepo) index() []byte { return r.Release }
|
||||||
|
func (r *DebRepo) files() map[string][]byte { return r.Files }
|
||||||
|
|
||||||
|
// repoGen holds a virtual's current merge plus the previous one, so a client
|
||||||
|
// holding the previous index can still fetch the files it references.
|
||||||
|
type repoGen struct {
|
||||||
|
cur, prev mergedRepo
|
||||||
|
at, used time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
const rpmMergeTTL = 60 * time.Second
|
const rpmMergeTTL = 60 * time.Second
|
||||||
|
|
||||||
|
// maxDebSuites bounds the client-chosen suites cached per deb virtual.
|
||||||
|
const maxDebSuites = 32
|
||||||
|
|
||||||
func NewEngine(db *database.DB, proxyEngine *proxy.Engine) *Engine {
|
func NewEngine(db *database.DB, proxyEngine *proxy.Engine) *Engine {
|
||||||
e := &Engine{db: db, proxyEngine: proxyEngine, getRemote: db.GetRemote, mergeTTL: rpmMergeTTL}
|
e := &Engine{db: db, proxyEngine: proxyEngine, getRemote: db.GetRemote, mergeTTL: rpmMergeTTL}
|
||||||
e.rpmMember = e.fetchRPMMember
|
e.rpmMember = e.fetchRPMMember
|
||||||
|
e.debMember = e.fetchDebMember
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) Fetch(ctx context.Context, virt models.Virtual, path string, proxyBaseURL string) ([]byte, string, error) {
|
func (e *Engine) Fetch(ctx context.Context, virt models.Virtual, path string, proxyBaseURL string) ([]byte, string, error) {
|
||||||
if virt.PackageType == models.PackageRPM {
|
switch virt.PackageType {
|
||||||
|
case models.PackageRPM:
|
||||||
return e.fetchRPM(ctx, virt, path)
|
return e.fetchRPM(ctx, virt, path)
|
||||||
|
case models.PackageDeb:
|
||||||
|
return e.fetchDeb(ctx, virt, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
merger, err := GetMerger(virt.PackageType)
|
merger, err := GetMerger(virt.PackageType)
|
||||||
@@ -170,11 +190,22 @@ var (
|
|||||||
ErrBadPath = errors.New("invalid path")
|
ErrBadPath = errors.New("invalid path")
|
||||||
)
|
)
|
||||||
|
|
||||||
// MemberRedirect maps an rpm virtual package path (<member>/<href>, as written
|
// MemberRedirect maps an rpm or deb virtual package path (<member>/<href>, as
|
||||||
// by MergeRPM) to the owning member's route. ok is false for any other path;
|
// written by MergeRPM/MergeDeb) to the owning member's route. ok is false for
|
||||||
// ErrBadPath means the path is absolute or escapes the member.
|
// any other path; ErrBadPath means the path is absolute or escapes the member.
|
||||||
func (e *Engine) MemberRedirect(ctx context.Context, virt models.Virtual, path, rawQuery, proxyBaseURL string) (string, bool, error) {
|
func (e *Engine) MemberRedirect(ctx context.Context, virt models.Virtual, path, rawQuery, proxyBaseURL string) (string, bool, error) {
|
||||||
if virt.PackageType != models.PackageRPM || strings.HasPrefix(path, "repodata/") {
|
switch virt.PackageType {
|
||||||
|
case models.PackageRPM:
|
||||||
|
if strings.HasPrefix(path, "repodata/") {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
case models.PackageDeb:
|
||||||
|
// apt requests a flat repo's files under its "./" dist verbatim.
|
||||||
|
path = strings.TrimPrefix(path, "./")
|
||||||
|
if strings.HasPrefix(path, "dists/") {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
return "", false, nil
|
return "", false, nil
|
||||||
}
|
}
|
||||||
name, rest, found := strings.Cut(path, "/")
|
name, rest, found := strings.Cut(path, "/")
|
||||||
@@ -217,76 +248,93 @@ func (e *Engine) fetchRPM(ctx context.Context, virt models.Virtual, path string)
|
|||||||
if !strings.HasPrefix(path, "repodata/") {
|
if !strings.HasPrefix(path, "repodata/") {
|
||||||
return nil, "", ErrNotFound
|
return nil, "", ErrNotFound
|
||||||
}
|
}
|
||||||
|
key := "rpm\x00" + virt.Name
|
||||||
if path != "repodata/repomd.xml" {
|
if path != "repodata/repomd.xml" {
|
||||||
if body, ok := e.cachedRPMFile(virt.Name, path); ok {
|
if body, ok := e.cachedFile(key, path); ok {
|
||||||
return body, "application/gzip", nil
|
return body, "application/gzip", nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
repo, err := e.mergedRPM(ctx, virt)
|
repo, err := e.merged(ctx, key, func(ctx context.Context) (mergedRepo, error) {
|
||||||
|
return e.mergeRPM(ctx, virt)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if path == "repodata/repomd.xml" {
|
if path == "repodata/repomd.xml" {
|
||||||
return repo.Repomd, "application/xml", nil
|
return repo.index(), "application/xml", nil
|
||||||
}
|
}
|
||||||
if body, ok := repo.Files[path]; ok {
|
if body, ok := repo.files()[path]; ok {
|
||||||
return body, "application/gzip", nil
|
return body, "application/gzip", nil
|
||||||
}
|
}
|
||||||
return nil, "", ErrNotFound
|
return nil, "", ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergedRPM returns the virtual's merge, reusing it for mergeTTL and
|
// merged returns the merge cached under key, reusing it for mergeTTL and
|
||||||
// collapsing concurrent merges of the same virtual into one.
|
// collapsing concurrent merges of the same key into one.
|
||||||
// ponytail: per-replica cache; behind a non-sticky LB a data request landing on
|
// ponytail: per-replica cache; behind a non-sticky LB a data request landing on
|
||||||
// another replica re-merges and 404s if a member changed in between. Move to the
|
// another replica re-merges and 404s if a member changed in between. Move to the
|
||||||
// shared redis cache if that shows up.
|
// shared redis cache if that shows up.
|
||||||
func (e *Engine) mergedRPM(ctx context.Context, virt models.Virtual) (*RPMRepo, error) {
|
func (e *Engine) merged(ctx context.Context, key string, merge func(context.Context) (mergedRepo, error)) (mergedRepo, error) {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
g := e.rpm[virt.Name]
|
g := e.gens[key]
|
||||||
if g != nil && time.Since(g.at) < e.mergeTTL {
|
if g != nil && time.Since(g.at) < e.mergeTTL {
|
||||||
|
g.used = time.Now()
|
||||||
e.mu.Unlock()
|
e.mu.Unlock()
|
||||||
return g.cur, nil
|
return g.cur, nil
|
||||||
}
|
}
|
||||||
e.mu.Unlock()
|
e.mu.Unlock()
|
||||||
|
|
||||||
v, err, _ := e.sf.Do(virt.Name, func() (any, error) {
|
v, err, _ := e.sf.Do(key, func() (any, error) {
|
||||||
repo, err := e.mergeRPM(context.WithoutCancel(ctx), virt)
|
repo, err := merge(context.WithoutCancel(ctx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
if e.rpm == nil {
|
if e.gens == nil {
|
||||||
e.rpm = map[string]*rpmGen{}
|
e.gens = map[string]*repoGen{}
|
||||||
}
|
}
|
||||||
g := e.rpm[virt.Name]
|
g := e.gens[key]
|
||||||
switch {
|
switch {
|
||||||
case g == nil:
|
case g == nil:
|
||||||
e.rpm[virt.Name] = &rpmGen{cur: repo, at: time.Now()}
|
e.gens[key] = &repoGen{cur: repo, at: time.Now(), used: time.Now()}
|
||||||
case string(g.cur.Repomd) == string(repo.Repomd):
|
case string(g.cur.index()) == string(repo.index()):
|
||||||
g.at = time.Now()
|
g.at, g.used = time.Now(), time.Now()
|
||||||
repo = g.cur
|
repo = g.cur
|
||||||
default:
|
default:
|
||||||
g.prev, g.cur, g.at = g.cur, repo, time.Now()
|
g.prev, g.cur, g.at, g.used = g.cur, repo, time.Now(), time.Now()
|
||||||
}
|
}
|
||||||
return repo, nil
|
return repo, nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return v.(*RPMRepo), nil
|
return v.(mergedRepo), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) mergeRPM(ctx context.Context, virt models.Virtual) (*RPMRepo, error) {
|
func (e *Engine) mergeRPM(ctx context.Context, virt models.Virtual) (*RPMRepo, error) {
|
||||||
members := make([]RPMMember, len(virt.Members))
|
members, err := fetchMembers(virt, func(name string) (*RPMMember, error) { return e.rpmMember(ctx, name) })
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
repo, err := MergeRPM(members)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("merge rpm repodata: %w", err)
|
||||||
|
}
|
||||||
|
return repo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchMembers reads every member in parallel, failing if any member fails.
|
||||||
|
func fetchMembers[T any](virt models.Virtual, fetch func(name string) (*T, error)) ([]T, error) {
|
||||||
|
members := make([]T, len(virt.Members))
|
||||||
errs := make([]error, len(virt.Members))
|
errs := make([]error, len(virt.Members))
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i, name := range virt.Members {
|
for i, name := range virt.Members {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
m, err := e.rpmMember(ctx, name)
|
m, err := fetch(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs[i] = fmt.Errorf("member %q: %w", name, err)
|
errs[i] = fmt.Errorf("member %q: %w", name, err)
|
||||||
return
|
return
|
||||||
@@ -299,26 +347,101 @@ func (e *Engine) mergeRPM(ctx context.Context, virt models.Virtual) (*RPMRepo, e
|
|||||||
if err := errors.Join(errs...); err != nil {
|
if err := errors.Join(errs...); err != nil {
|
||||||
return nil, fmt.Errorf("virtual %q: %v", virt.Name, err)
|
return nil, fmt.Errorf("virtual %q: %v", virt.Name, err)
|
||||||
}
|
}
|
||||||
|
return members, nil
|
||||||
repo, err := MergeRPM(members)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("merge rpm repodata: %w", err)
|
|
||||||
}
|
|
||||||
return repo, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) cachedRPMFile(virt, path string) ([]byte, bool) {
|
func (e *Engine) fetchDeb(ctx context.Context, virt models.Virtual, reqPath string) ([]byte, string, error) {
|
||||||
|
base, name, ok := debIndexPath(strings.TrimPrefix(path.Clean("/"+reqPath), "/"))
|
||||||
|
if !ok {
|
||||||
|
return nil, "", ErrNotFound
|
||||||
|
}
|
||||||
|
key := "deb\x00" + virt.Name + "\x00" + base
|
||||||
|
if name != "Release" {
|
||||||
|
if body, ok := e.cachedFile(key, name); ok {
|
||||||
|
return body, debContentType(name), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, err := e.merged(ctx, key, func(ctx context.Context) (mergedRepo, error) {
|
||||||
|
members, err := fetchMembers(virt, func(name string) (*DebMember, error) { return e.debMember(ctx, name, base) })
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return MergeDeb(members), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
e.evictLRU("deb\x00"+virt.Name+"\x00", maxDebSuites)
|
||||||
|
if name == "Release" {
|
||||||
|
return repo.index(), "text/plain", nil
|
||||||
|
}
|
||||||
|
if body, ok := repo.files()[name]; ok {
|
||||||
|
return body, debContentType(name), nil
|
||||||
|
}
|
||||||
|
return nil, "", ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func debContentType(name string) string {
|
||||||
|
if strings.HasSuffix(name, ".gz") {
|
||||||
|
return "application/gzip"
|
||||||
|
}
|
||||||
|
return "text/plain"
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchDebMember reads a member's Release under base and each binary Packages
|
||||||
|
// index it lists.
|
||||||
|
func (e *Engine) fetchDebMember(ctx context.Context, name, base string) (*DebMember, error) {
|
||||||
|
remote, err := e.getRemote(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("remote %q: %w", name, err)
|
||||||
|
}
|
||||||
|
rel, err := e.fetchMemberPath(ctx, *remote, base+"Release")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m := &DebMember{RemoteName: name, Release: rel, Packages: map[string][]byte{}}
|
||||||
|
for dir, file := range debIndexFiles(parseDebRelease(rel)) {
|
||||||
|
raw, err := e.fetchMemberPath(ctx, *remote, base+file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if m.Packages[dir], err = decompress(file, raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("decompress %s: %w", file, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictLRU drops the least recently used merges under prefix beyond keep.
|
||||||
|
func (e *Engine) evictLRU(prefix string, keep int) {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
g := e.rpm[virt]
|
var keys []string
|
||||||
|
for k := range e.gens {
|
||||||
|
if strings.HasPrefix(k, prefix) {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortFunc(keys, func(a, b string) int { return e.gens[b].used.Compare(e.gens[a].used) })
|
||||||
|
for _, k := range keys[min(len(keys), keep):] {
|
||||||
|
delete(e.gens, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) cachedFile(key, path string) ([]byte, bool) {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
g := e.gens[key]
|
||||||
if g == nil {
|
if g == nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
for _, r := range []*RPMRepo{g.cur, g.prev} {
|
for _, r := range []mergedRepo{g.cur, g.prev} {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if body, ok := r.Files[path]; ok {
|
if body, ok := r.files()[path]; ok {
|
||||||
|
g.used = time.Now()
|
||||||
return body, true
|
return body, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -156,3 +157,213 @@ func TestMemberRedirect(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func debEngine(members map[string]*DebMember) *Engine {
|
||||||
|
e := fakeEngine(nil)
|
||||||
|
e.debMember = func(_ context.Context, name, base string) (*DebMember, error) {
|
||||||
|
if m := members[name]; m != nil && base == "" {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("upstream down")
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func debVirt(members ...string) models.Virtual {
|
||||||
|
return models.Virtual{Name: "v", PackageType: models.PackageDeb, Members: members}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchDebFailsClosed(t *testing.T) {
|
||||||
|
a := flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo.deb"))
|
||||||
|
e := debEngine(map[string]*DebMember{"a": &a})
|
||||||
|
for _, p := range []string{"Release", "./Packages.gz", "dists/trixie/Release"} {
|
||||||
|
if _, _, err := e.Fetch(context.Background(), debVirt("a", "b"), p, ""); err == nil || errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("%s: want upstream error with a member down, got %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range []string{"InRelease", "./Release.gpg", "Packages.xz"} {
|
||||||
|
if _, _, err := e.Fetch(context.Background(), debVirt("a"), p, ""); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("%s: want not found, got %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchDebRemergesAfterTTL(t *testing.T) {
|
||||||
|
a := flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo_1.deb"))
|
||||||
|
e := debEngine(map[string]*DebMember{"a": &a})
|
||||||
|
virt := debVirt("a")
|
||||||
|
|
||||||
|
rel, _, err := e.Fetch(context.Background(), virt, "./Release", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a.Packages[""] = []byte(debStanza("foo", "2", "amd64", "pool/foo_2.deb"))
|
||||||
|
e.mergeTTL = 0
|
||||||
|
newRel, _, err := e.Fetch(context.Background(), virt, "Release", "")
|
||||||
|
if err != nil || string(newRel) == string(rel) {
|
||||||
|
t.Fatalf("Release should reflect the member change (err %v)", err)
|
||||||
|
}
|
||||||
|
pk, ct, err := e.Fetch(context.Background(), virt, "./Packages", "")
|
||||||
|
if err != nil || ct != "text/plain" || !strings.Contains(string(pk), "Version: 2") {
|
||||||
|
t.Fatalf("Packages should be the current merge (ct %q, err %v):\n%s", ct, err, pk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchDebMergeIsSharedPerSuite(t *testing.T) {
|
||||||
|
var calls atomic.Int32
|
||||||
|
e := fakeEngine(nil)
|
||||||
|
e.mergeTTL = time.Minute
|
||||||
|
e.debMember = func(_ context.Context, name, base string) (*DebMember, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
m := flatDebMember(name, "", debStanza("foo", "1", "amd64", "pool/foo.deb"))
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
for _, p := range []string{"Release", "Packages", "Packages.gz", "./Release", "dists/a/Release", "dists/b/Release"} {
|
||||||
|
if _, _, err := e.Fetch(context.Background(), debVirt("a"), p, ""); err != nil && !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := calls.Load(); n != 3 {
|
||||||
|
t.Fatalf("member fetched %d times, want once per Release dir (3)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemberRedirectDeb(t *testing.T) {
|
||||||
|
e := fakeEngine(nil)
|
||||||
|
virt := debVirt("gh", "loc")
|
||||||
|
for _, tc := range []struct {
|
||||||
|
path, want string
|
||||||
|
ok bool
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{path: "gh/o/r/releases/download/v1/foo_1.0_amd64.deb", ok: true, want: "http://h/api/v1/remote/gh/o/r/releases/download/v1/foo_1.0_amd64.deb"},
|
||||||
|
{path: "./gh/pool/a+b.deb", ok: true, want: "http://h/api/v1/remote/gh/pool/a+b.deb"},
|
||||||
|
{path: "gh/../loc/x.deb", err: ErrBadPath},
|
||||||
|
{path: "dists/trixie/Release"},
|
||||||
|
{path: "Packages"},
|
||||||
|
} {
|
||||||
|
got, ok, err := e.MemberRedirect(context.Background(), virt, tc.path, "", "http://h/")
|
||||||
|
if got != tc.want || ok != tc.ok || !errors.Is(err, tc.err) {
|
||||||
|
t.Errorf("%s: got (%q, %v, %v), want (%q, %v, %v)", tc.path, got, ok, err, tc.want, tc.ok, tc.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeRepo struct {
|
||||||
|
idx string
|
||||||
|
fs map[string][]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeRepo) index() []byte { return []byte(r.idx) }
|
||||||
|
func (r *fakeRepo) files() map[string][]byte { return r.fs }
|
||||||
|
|
||||||
|
func genRepo(gen string) func(context.Context) (mergedRepo, error) {
|
||||||
|
return func(context.Context) (mergedRepo, error) {
|
||||||
|
return &fakeRepo{idx: gen, fs: map[string][]byte{gen + ".gz": []byte(gen), "fixed": []byte(gen)}}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergedRetainsPreviousGeneration(t *testing.T) {
|
||||||
|
e := fakeEngine(nil)
|
||||||
|
for _, gen := range []string{"g1", "g2", "g3"} {
|
||||||
|
if _, err := e.merged(context.Background(), "k", genRepo(gen)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for path, want := range map[string]string{"g3.gz": "g3", "g2.gz": "g2", "fixed": "g3"} {
|
||||||
|
if body, ok := e.cachedFile("k", path); !ok || string(body) != want {
|
||||||
|
t.Errorf("%s: got (%q, %v), want %q", path, body, ok, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := e.cachedFile("k", "g1.gz"); ok {
|
||||||
|
t.Error("generation two merges back must be dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergedReusesWithinTTLAndIsolatesKeys(t *testing.T) {
|
||||||
|
e := fakeEngine(nil)
|
||||||
|
e.mergeTTL = time.Minute
|
||||||
|
for _, step := range []struct{ key, gen, want string }{
|
||||||
|
{"a", "a1", "a1"}, {"b", "b1", "b1"}, {"a", "a2", "a1"},
|
||||||
|
} {
|
||||||
|
repo, err := e.merged(context.Background(), step.key, genRepo(step.gen))
|
||||||
|
if err != nil || string(repo.index()) != step.want {
|
||||||
|
t.Fatalf("%s: got %v (err %v), want %s", step.key, repo, err, step.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := e.cachedFile("b", "a1.gz"); ok {
|
||||||
|
t.Error("key b served key a's file")
|
||||||
|
}
|
||||||
|
if _, ok := e.cachedFile("c", "a1.gz"); ok {
|
||||||
|
t.Error("unknown key served a file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func suiteDebMember(name, version string) *DebMember {
|
||||||
|
return &DebMember{RemoteName: name, Release: []byte("Suite: s\nSHA256:\n x 1 main/binary-amd64/Packages.gz\n"),
|
||||||
|
Packages: map[string][]byte{"main/binary-amd64/": []byte(debStanza("foo", version, "amd64", "pool/foo_"+version+".deb"))}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchDebByHashSurvivesRemerge(t *testing.T) {
|
||||||
|
version := "1"
|
||||||
|
e := fakeEngine(nil)
|
||||||
|
e.debMember = func(_ context.Context, name, _ string) (*DebMember, error) { return suiteDebMember(name, version), nil }
|
||||||
|
virt := debVirt("a")
|
||||||
|
fetch := func(p string) []byte {
|
||||||
|
body, _, err := e.Fetch(context.Background(), virt, p, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", p, err)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
byHash := func(rel []byte) string {
|
||||||
|
m := regexp.MustCompile(`(?m)^SHA256:\n(?: .*\n)*? ([0-9a-f]{64}) \d+ main/binary-amd64/Packages.gz$`).FindSubmatch(rel)
|
||||||
|
if m == nil {
|
||||||
|
t.Fatalf("no Packages.gz SHA256 in Release:\n%s", rel)
|
||||||
|
}
|
||||||
|
return "dists/trixie/main/binary-amd64/by-hash/SHA256/" + string(m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
oldRel := fetch("dists/trixie/Release")
|
||||||
|
version, e.mergeTTL = "2", 0
|
||||||
|
if newRel := fetch("dists/trixie/Release"); string(newRel) == string(oldRel) {
|
||||||
|
t.Fatal("Release should reflect the member change")
|
||||||
|
}
|
||||||
|
if got := string(gunzip(t, fetch(byHash(oldRel)))); !strings.Contains(got, "Version: 1\n") {
|
||||||
|
t.Fatalf("by-hash from the previous Release served the wrong index:\n%s", got)
|
||||||
|
}
|
||||||
|
if got := string(fetch("dists/trixie/main/binary-amd64/Packages")); !strings.Contains(got, "Version: 2\n") {
|
||||||
|
t.Fatalf("fixed-name Packages should be the current merge:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchDebBoundsSuitesPerVirtual(t *testing.T) {
|
||||||
|
e := fakeEngine(nil)
|
||||||
|
e.mergeTTL = time.Minute
|
||||||
|
e.debMember = func(_ context.Context, name, _ string) (*DebMember, error) { return suiteDebMember(name, "1"), nil }
|
||||||
|
other := models.Virtual{Name: "other", PackageType: models.PackageDeb, Members: []string{"a"}}
|
||||||
|
if _, _, err := e.Fetch(context.Background(), other, "dists/keep/Release", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := range maxDebSuites + 5 {
|
||||||
|
if _, _, err := e.Fetch(context.Background(), debVirt("a"), fmt.Sprintf("dists/s%d/Release", i), ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Microsecond)
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for k := range e.gens {
|
||||||
|
if strings.HasPrefix(k, "deb\x00v\x00") {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n != maxDebSuites {
|
||||||
|
t.Fatalf("virtual v caches %d suites, want %d", n, maxDebSuites)
|
||||||
|
}
|
||||||
|
if e.gens["deb\x00v\x00dists/s0/"] != nil || e.gens["deb\x00v\x00dists/s4/"] != nil {
|
||||||
|
t.Fatal("least recently used suites should be evicted first")
|
||||||
|
}
|
||||||
|
if e.gens["deb\x00other\x00dists/keep/"] == nil {
|
||||||
|
t.Fatal("another virtual's suites must not be evicted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const (
|
|||||||
PackageTerraform PackageType = "terraform"
|
PackageTerraform PackageType = "terraform"
|
||||||
PackageGoProxy PackageType = "goproxy"
|
PackageGoProxy PackageType = "goproxy"
|
||||||
PackageCargo PackageType = "cargo"
|
PackageCargo PackageType = "cargo"
|
||||||
|
PackageNuGet PackageType = "nuget"
|
||||||
PackageGitHubRPM PackageType = "github_rpm"
|
PackageGitHubRPM PackageType = "github_rpm"
|
||||||
PackageGitHubDeb PackageType = "github_deb"
|
PackageGitHubDeb PackageType = "github_deb"
|
||||||
PackageGitHubAlpine PackageType = "github_alpine"
|
PackageGitHubAlpine PackageType = "github_alpine"
|
||||||
@@ -35,6 +36,7 @@ var validPackageTypes = map[PackageType]bool{
|
|||||||
PackageTerraform: true,
|
PackageTerraform: true,
|
||||||
PackageGoProxy: true,
|
PackageGoProxy: true,
|
||||||
PackageCargo: true,
|
PackageCargo: true,
|
||||||
|
PackageNuGet: true,
|
||||||
PackageGitHubRPM: true,
|
PackageGitHubRPM: true,
|
||||||
PackageGitHubDeb: true,
|
PackageGitHubDeb: true,
|
||||||
PackageGitHubAlpine: true,
|
PackageGitHubAlpine: true,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ func TestPackageTypeValid(t *testing.T) {
|
|||||||
models.PackageTerraform,
|
models.PackageTerraform,
|
||||||
models.PackageGoProxy,
|
models.PackageGoProxy,
|
||||||
models.PackageCargo,
|
models.PackageCargo,
|
||||||
|
models.PackageNuGet,
|
||||||
models.PackageGitHubRPM,
|
models.PackageGitHubRPM,
|
||||||
models.PackageGitHubDeb,
|
models.PackageGitHubDeb,
|
||||||
models.PackageGitHubAlpine,
|
models.PackageGitHubAlpine,
|
||||||
|
|||||||
@@ -243,6 +243,22 @@ index = "sparse+${proxy}/"`,
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
case 'nuget':
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Use this feed instead of nuget.org',
|
||||||
|
language: 'xml',
|
||||||
|
code: `<!-- nuget.config -->
|
||||||
|
<configuration>
|
||||||
|
<packageSources>
|
||||||
|
<clear />
|
||||||
|
<add key="${name}" value="${proxy}/index.json" protocolVersion="3" />
|
||||||
|
</packageSources>
|
||||||
|
</configuration>`,
|
||||||
|
note: 'Serve artifactapi over HTTPS: NuGet rejects the repository signatures resource over plain HTTP.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
case 'puppet':
|
case 'puppet':
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const typeColors: Record<string, 'blue' | 'green' | 'yellow' | 'red' | 'default'
|
|||||||
terraform: 'blue',
|
terraform: 'blue',
|
||||||
goproxy: 'green',
|
goproxy: 'green',
|
||||||
cargo: 'yellow',
|
cargo: 'yellow',
|
||||||
|
nuget: 'blue',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Locals() {
|
export function Locals() {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const typeColors: Record<string, 'blue' | 'green' | 'yellow' | 'red' | 'default'
|
|||||||
terraform: 'blue',
|
terraform: 'blue',
|
||||||
goproxy: 'green',
|
goproxy: 'green',
|
||||||
cargo: 'yellow',
|
cargo: 'yellow',
|
||||||
|
nuget: 'blue',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Remotes() {
|
export function Remotes() {
|
||||||
|
|||||||
Reference in New Issue
Block a user