Files
MRLokopandClaude Opus 5.5 42abe48da1 Add a Go module proxy with per-module routing rules
A go vrepo serves the GOPROXY protocol at /go/<vrepo>/. Its upstreams
are ordered rules matched by module path (GOPRIVATE-style patterns);
each one relays to another module proxy, or builds .info/.mod/.zip,
@v/list and @latest itself from a git repository: through the Gitea
or GitLab REST API, or over git smart-HTTP (go-git) for any other
host, without a git binary. Module layout follows the go command:
<dir>/vX.Y.Z tags, /vN major subdirectories, +incompatible, nested
modules and vendor/ left out of zips. go.mod is served as committed,
so forks keeping their upstream module path work through replace.

Repo templates map captured pattern elements to repository paths
({1}, {2}...). vanity_hosts answers ?go-get=1 with a go-import meta
tag pointing at this proxy or at the repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:53:25 +03:00

156 lines
4.2 KiB
Go

package goproxy
import (
"archive/zip"
"context"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"strings"
"time"
modzip "golang.org/x/mod/zip"
)
// errNotFound means the repository, revision or file does not exist (or is
// not visible with the configured credentials): the next rule may still
// serve the module. Any other source error is an outage.
var errNotFound = errors.New("not found")
type tag struct {
Name string // without refs/tags/
Hash string // commit the tag points at, peeled
}
type commit struct {
Hash string // full hash
Time time.Time
}
// source reads one git repository. Git-backed rules build every GOPROXY
// answer from these four operations.
type source interface {
// Tags lists the repository's tags.
Tags(ctx context.Context) ([]tag, error)
// Commit resolves rev — a full or abbreviated commit hash, a branch or
// a tag name, or "" for the default branch — to a commit.
Commit(ctx context.Context, rev string) (commit, error)
// ReadFile returns the contents of name at commit hash, or errNotFound.
ReadFile(ctx context.Context, hash, name string) ([]byte, error)
// Tree returns every file of the commit, paths relative to the repo
// root. The caller closes it.
Tree(ctx context.Context, hash string) (tree, error)
}
// tree is the file listing of one commit.
type tree interface {
Files() []modzip.File
io.Closer
}
// archiveTree is a tree read from a forge's zip download of a commit,
// spooled to a temp file so large repos do not sit in memory. Forges put
// everything under one top-level directory, which is stripped.
type archiveTree struct {
f *os.File
zr *zip.Reader
files []modzip.File
}
func (t *archiveTree) Files() []modzip.File { return t.files }
func (t *archiveTree) Close() error {
t.f.Close()
return os.Remove(t.f.Name())
}
// downloadArchive GETs a zip archive into a temp file and lists it.
func downloadArchive(ctx context.Context, c *http.Client, url string, hdr http.Header) (*archiveTree, error) {
resp, err := get(ctx, c, url, hdr)
if err != nil {
return nil, err
}
defer resp.Body.Close()
f, err := os.CreateTemp("", "tiny-proxy-go-*.zip")
if err != nil {
return nil, err
}
t := &archiveTree{f: f}
size, err := io.Copy(f, resp.Body)
if err != nil {
t.Close()
return nil, fmt.Errorf("download archive: %w", err)
}
t.zr, err = zip.NewReader(f, size)
if err != nil {
t.Close()
return nil, fmt.Errorf("read archive: %w", err)
}
for _, zf := range t.zr.File {
_, name, ok := strings.Cut(zf.Name, "/")
if !ok || name == "" || strings.HasSuffix(name, "/") {
continue
}
t.files = append(t.files, zipEntry{name: name, f: zf})
}
return t, nil
}
type zipEntry struct {
name string
f *zip.File
}
func (e zipEntry) Path() string { return e.name }
func (e zipEntry) Lstat() (fs.FileInfo, error) { return e.f.FileInfo(), nil }
func (e zipEntry) Open() (io.ReadCloser, error) { return e.f.Open() }
// get performs a GET with extra headers, mapping 404 (and the 400/422 some
// forges answer for unknown refs) to errNotFound and other non-2xx
// statuses to errors.
func get(ctx context.Context, c *http.Client, url string, hdr http.Header) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
for k, v := range hdr {
req.Header[k] = v
}
resp, err := c.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode/100 == 2 {
return resp, nil
}
resp.Body.Close()
switch resp.StatusCode {
case http.StatusNotFound, http.StatusBadRequest, http.StatusUnprocessableEntity:
return nil, errNotFound
}
return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
}
// getBytes is get plus reading the body, capped at limit bytes.
func getBytes(ctx context.Context, c *http.Client, url string, hdr http.Header, limit int64) ([]byte, error) {
resp, err := get(ctx, c, url, hdr)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return nil, err
}
if int64(len(body)) > limit {
return nil, fmt.Errorf("GET %s: response larger than %d bytes", url, limit)
}
return body, nil
}
// apiLimit caps API responses and go.mod files read into memory.
const apiLimit = 16 << 20