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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ replace-with = "vouch"

Vouch rewrites the sparse-index `config.json` `dl` field so cargo downloads `.crate` files through the proxy.

**Go modules**:

```sh
GOPROXY=http://127.0.0.1:18080/go/ go mod download golang.org/x/mod@v0.35.0
```

The Go adapter mirrors the [`GOPROXY` protocol](https://go.dev/ref/mod#goproxy-protocol) used by `https://proxy.golang.org`: `/@v/list`, `.info`, `.mod`, `.zip`, and `/@latest` paths are passed through using Go's case-encoded module and version path elements. Vouch uses the `.info` timestamp as the publish time for cooldown checks on `.zip` downloads.

### Try the cooldown

Pick a package version published in the last week and watch the proxy reject it:
Expand Down
4 changes: 4 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ ecosystems:
enabled: false
upstream: "https://index.crates.io/"
path_prefix: "/crates/"
go:
enabled: false
upstream: "https://proxy.golang.org/"
path_prefix: "/go/"

policy:
cooldown_days: 7
Expand Down
4 changes: 4 additions & 0 deletions deploy/fly/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ ecosystems:
enabled: true
upstream: "https://index.crates.io/"
path_prefix: "/crates/"
go:
enabled: true
upstream: "https://proxy.golang.org/"
path_prefix: "/go/"

policy:
cooldown_days: 7
Expand Down
4 changes: 4 additions & 0 deletions deploy/helm/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ ecosystems:
enabled: false
upstream: "https://index.crates.io/"
pathPrefix: "/crates/"
go:
enabled: false
upstream: "https://proxy.golang.org/"
pathPrefix: "/go/"

# -- OpenTelemetry tracing. Disabled by default. When enabled and the
# OTLP/gRPC endpoint is unreachable, vouch still starts cleanly and spans
Expand Down
5 changes: 5 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,11 @@ func Default() Config {
Upstream: "https://pypi.org/",
PathPrefix: "/pypi/",
},
"go": {
Enabled: false,
Upstream: "https://proxy.golang.org/",
PathPrefix: "/go/",
},
},
Policy: PolicyConfig{
CooldownDays: 7,
Expand Down
224 changes: 224 additions & 0 deletions internal/ecosystem/gomod/adapter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package gomod

import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"

"golang.org/x/mod/module"

"github.com/timreynolds/vouch/internal/core"
"github.com/timreynolds/vouch/internal/ecosystem"
)

const DefaultPathPrefix = "/go/"

type Adapter struct {
pathPrefix string
}

func NewAdapter(pathPrefix string) *Adapter {
if pathPrefix == "" {
pathPrefix = DefaultPathPrefix
}
if !strings.HasSuffix(pathPrefix, "/") {
pathPrefix += "/"
}
return &Adapter{pathPrefix: pathPrefix}
}

func (a *Adapter) Name() string {
return "go"
}

func (a *Adapter) ParseRequest(r *http.Request) (*core.PackageRequest, error) {
requestPath := r.URL.Path
if !strings.HasPrefix(requestPath, a.pathPrefix) {
return nil, nil
}
if r.URL.RawQuery != "" {
return nil, fmt.Errorf("invalid Go module proxy request: query parameters are not supported")
}

tail := strings.TrimLeft(strings.TrimPrefix(requestPath, a.pathPrefix), "/")
if tail == "" {
return nil, nil
}
if len(tail) > 1024 {
return nil, fmt.Errorf("invalid Go module proxy path (length %d > 1024)", len(tail))
}
for _, r := range tail {
if r < 0x20 || r == 0x7f {
return nil, fmt.Errorf("invalid Go module proxy path %q (control character)", requestPath)
}
}

encodedModule, endpoint, ok := splitEndpoint(tail)
if !ok {
return nil, nil
}
name, err := module.UnescapePath(encodedModule)
if err != nil {
return nil, fmt.Errorf("invalid Go module path %q: %w", encodedModule, err)
}

kind := core.RequestKindMetadata
version := ""
if strings.HasSuffix(endpoint, ".zip") {
kind = core.RequestKindBlob
}
if endpoint != "list" && endpoint != "@latest" {
encodedVersion, ok := strings.CutSuffix(endpoint, ".info")
if !ok {
encodedVersion, ok = strings.CutSuffix(endpoint, ".mod")
}
if !ok {
encodedVersion, ok = strings.CutSuffix(endpoint, ".zip")
}
if !ok || encodedVersion == "" {
return nil, nil
}
version, err = module.UnescapeVersion(encodedVersion)
if err != nil {
return nil, fmt.Errorf("invalid Go module version %q: %w", encodedVersion, err)
}
}

return &core.PackageRequest{
Ecosystem: a.Name(),
Name: name,
Version: version,
Kind: kind,
Method: r.Method,
Path: requestPath,
OriginalURL: r.URL.String(),
}, nil
}

func (a *Adapter) UpstreamPath(req core.PackageRequest) (string, error) {
return strings.TrimLeft(strings.TrimPrefix(req.Path, a.pathPrefix), "/"), nil
}

func (a *Adapter) MetadataRequest(req core.PackageRequest) (*core.PackageRequest, error) {
if req.Kind != core.RequestKindBlob || req.Name == "" || req.Version == "" {
return nil, nil
}
encodedModule, err := module.EscapePath(req.Name)
if err != nil {
return nil, fmt.Errorf("escape Go module path: %w", err)
}
encodedVersion, err := module.EscapeVersion(req.Version)
if err != nil {
return nil, fmt.Errorf("escape Go module version: %w", err)
}
metadataPath := a.pathPrefix + encodedModule + "/@v/" + encodedVersion + ".info"
return &core.PackageRequest{
Ecosystem: a.Name(),
Name: req.Name,
Version: req.Version,
Kind: core.RequestKindMetadata,
Method: http.MethodGet,
Path: metadataPath,
OriginalURL: metadataPath,
}, nil
}

func (a *Adapter) DistributionMetadata(metadataBody []byte, req core.PackageRequest) (ecosystem.DistributionMetadata, error) {
if req.Kind != core.RequestKindBlob {
return ecosystem.DistributionMetadata{}, nil
}
var info struct {
Version string `json:"Version"`
Time time.Time `json:"Time"`
}
if err := json.Unmarshal(metadataBody, &info); err != nil {
return ecosystem.DistributionMetadata{}, fmt.Errorf("parse Go module info: %w", err)
}
if info.Time.IsZero() {
return ecosystem.DistributionMetadata{}, nil
}
published := info.Time.UTC()
return ecosystem.DistributionMetadata{PublishedAt: &published}, nil
}

func (a *Adapter) RewriteMetadata(artifact *core.Artifact, _ string) error {
if artifact == nil {
return nil
}
artifact.Headers.Del("Content-Length")
artifact.Headers.Del("Etag")
artifact.Headers.Del("Content-Encoding")
artifact.ContentType = contentTypeForPath(artifact.Request.Path)
return nil
}

func (a *Adapter) SerializeResponse(w http.ResponseWriter, r *http.Request, artifact *core.Artifact) error {
for name, values := range artifact.Headers {
if shouldForwardHeader(name) {
for _, value := range values {
w.Header().Add(name, value)
}
}
}
if artifact.ContentType != "" {
w.Header().Set("Content-Type", artifact.ContentType)
}

status := artifact.StatusCode
if status == 0 {
status = http.StatusOK
}
if status < 200 || status >= 300 {
w.Header().Set("Content-Length", fmt.Sprintf("%d", artifact.BodySize()))
w.WriteHeader(status)
if r != nil && r.Method == http.MethodHead {
return nil
}
_, err := artifact.WriteBodyTo(w)
return err
}
return core.ServeArtifactBody(w, r, artifact)
}

func (a *Adapter) SerializeError(w http.ResponseWriter, _ *core.PackageRequest, status int, reason string) error {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(status)
_, err := fmt.Fprintln(w, reason)
return err
}

func splitEndpoint(tail string) (encodedModule, endpoint string, ok bool) {
if encodedModule, _, ok = strings.Cut(tail, "/@v/"); ok {
endpoint = strings.TrimPrefix(tail[len(encodedModule)+len("/@v/"):], "/")
return encodedModule, endpoint, encodedModule != "" && endpoint != ""
}
encodedModule, ok = strings.CutSuffix(tail, "/@latest")
if !ok || encodedModule == "" {
return "", "", false
}
return encodedModule, "@latest", true
}

func contentTypeForPath(reqPath string) string {
switch {
case strings.HasSuffix(reqPath, ".info"), strings.HasSuffix(reqPath, "/@latest"):
return "application/json"
case strings.HasSuffix(reqPath, ".zip"):
return "application/zip"
default:
return "text/plain; charset=utf-8"
}
}

func shouldForwardHeader(name string) bool {
switch strings.ToLower(name) {
case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "content-length", "content-encoding",
"x-content-type-options",
"set-cookie":
return false
default:
return true
}
}
Loading
Loading