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

382 lines
10 KiB
Go

package goproxy
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"strings"
"sync"
"time"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/format/packfile"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/protocol/packp"
"github.com/go-git/go-git/v5/plumbing/protocol/packp/capability"
"github.com/go-git/go-git/v5/plumbing/protocol/packp/sideband"
"github.com/go-git/go-git/v5/plumbing/transport"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/go-git/go-git/v5/storage/memory"
modzip "golang.org/x/mod/zip"
)
// gitSource reads a repository over the git smart-HTTP protocol, in
// process: refs come from the ref advertisement, and a commit's files from
// a depth-1 fetch of just that commit into memory. Over HTTP git servers
// accept wants for any commit reachable from an advertised ref, so
// pseudo-versions of older commits work too as long as their full hash is
// known (see resolve).
type gitSource struct {
tr transport.Transport
ep *transport.Endpoint
auth transport.AuthMethod
mu sync.Mutex
refs *packp.AdvRefs
refsAt time.Time
known map[string]string // abbreviated → full hash, of commits seen
fetched []fetchedCommit // most recent last
fetching map[string]*sync.Mutex
}
type fetchedCommit struct {
hash string
st *memory.Storage
}
const (
// refsTTL bounds how stale the ref advertisement may be; the
// GOPROXY answers built from it have their own cache TTLs on top.
refsTTL = 30 * time.Second
// fetchedKeep is how many fetched commits stay in memory, so that the
// .info → .mod → .zip sequence the go command sends fetches once.
fetchedKeep = 4
)
func newGitSource(c *http.Client, base, repo, username, password string) (*gitSource, error) {
ep, err := transport.NewEndpoint(strings.TrimSuffix(base, "/") + "/" + strings.TrimPrefix(repo, "/"))
if err != nil {
return nil, err
}
s := &gitSource{
tr: githttp.NewClient(c),
ep: ep,
known: map[string]string{},
fetching: map[string]*sync.Mutex{},
}
if password != "" {
if username == "" {
username = "git" // forges ignore it for token auth
}
s.auth = &githttp.BasicAuth{Username: username, Password: password}
}
return s, nil
}
func (s *gitSource) advRefs(ctx context.Context) (*packp.AdvRefs, error) {
s.mu.Lock()
if s.refs != nil && time.Since(s.refsAt) < refsTTL {
refs := s.refs
s.mu.Unlock()
return refs, nil
}
s.mu.Unlock()
sess, err := s.tr.NewUploadPackSession(s.ep, s.auth)
if err != nil {
return nil, err
}
defer sess.Close()
refs, err := sess.AdvertisedReferencesContext(ctx)
if err != nil {
return nil, gitErr(err)
}
s.mu.Lock()
s.refs, s.refsAt = refs, time.Now()
s.mu.Unlock()
return refs, nil
}
// gitErr maps go-git's "no such repository" errors to errNotFound.
func gitErr(err error) error {
switch {
case errors.Is(err, transport.ErrRepositoryNotFound),
errors.Is(err, transport.ErrEmptyRemoteRepository),
errors.Is(err, transport.ErrAuthenticationRequired),
errors.Is(err, transport.ErrAuthorizationFailed):
return fmt.Errorf("%w: %v", errNotFound, err)
}
return err
}
// peeled returns the commit a ref points at: the peeled target for an
// annotated tag, the ref's own hash otherwise.
func peeled(refs *packp.AdvRefs, name string) (plumbing.Hash, bool) {
if h, ok := refs.Peeled[name]; ok {
return h, true
}
h, ok := refs.References[name]
return h, ok
}
func (s *gitSource) Tags(ctx context.Context) ([]tag, error) {
refs, err := s.advRefs(ctx)
if err != nil {
return nil, err
}
var tags []tag
for name := range refs.References {
if t, ok := strings.CutPrefix(name, "refs/tags/"); ok {
h, _ := peeled(refs, name)
tags = append(tags, tag{Name: t, Hash: h.String()})
}
}
return tags, nil
}
// resolve turns rev into a full commit hash using the ref advertisement
// and the commits seen so far.
func (s *gitSource) resolve(ctx context.Context, rev string) (string, error) {
refs, err := s.advRefs(ctx)
if err != nil {
return "", err
}
if rev == "" {
if refs.Head != nil {
return refs.Head.String(), nil
}
return "", errNotFound
}
for _, name := range []string{"refs/heads/" + rev, "refs/tags/" + rev, rev} {
if h, ok := peeled(refs, name); ok {
return h.String(), nil
}
}
if !isHex(rev) || len(rev) < 7 || len(rev) > 40 {
return "", errNotFound
}
rev = strings.ToLower(rev)
if len(rev) == 40 {
return rev, nil
}
// An abbreviated hash: a ref tip, or a commit seen before (typically
// the one a pseudo-version the go command now asks about was built
// from).
for name := range refs.References {
if h, _ := peeled(refs, name); strings.HasPrefix(h.String(), rev) {
return h.String(), nil
}
}
s.mu.Lock()
full, ok := s.known[rev]
s.mu.Unlock()
if ok {
return full, nil
}
return "", errNotFound
}
func isHex(s string) bool {
for _, c := range s {
if !strings.ContainsRune("0123456789abcdefABCDEF", c) {
return false
}
}
return s != ""
}
func (s *gitSource) Commit(ctx context.Context, rev string) (commit, error) {
hash, err := s.resolve(ctx, rev)
if err != nil {
return commit{}, err
}
c, err := s.commitObject(ctx, hash)
if err != nil {
return commit{}, err
}
full := c.Hash.String()
s.mu.Lock()
s.known[full[:12]] = full
s.mu.Unlock()
return commit{Hash: full, Time: c.Committer.When}, nil
}
// commitObject fetches hash and returns its commit, peeling an annotated
// tag (servers that do not advertise peeled refs leave tag hashes in Tags).
func (s *gitSource) commitObject(ctx context.Context, hash string) (*object.Commit, error) {
st, err := s.fetch(ctx, hash)
if err != nil {
return nil, err
}
obj, err := object.GetObject(st, plumbing.NewHash(hash))
if err != nil {
return nil, err
}
switch o := obj.(type) {
case *object.Commit:
return o, nil
case *object.Tag:
return o.Commit()
}
return nil, fmt.Errorf("%w: %s is a %s, not a commit", errNotFound, hash, obj.Type())
}
func (s *gitSource) ReadFile(ctx context.Context, hash, name string) ([]byte, error) {
t, err := s.commitTree(ctx, hash)
if err != nil {
return nil, err
}
f, err := t.File(name)
if errors.Is(err, object.ErrFileNotFound) || errors.Is(err, object.ErrDirectoryNotFound) {
return nil, errNotFound
}
if err != nil {
return nil, err
}
if f.Size > apiLimit {
return nil, fmt.Errorf("%s: larger than %d bytes", name, apiLimit)
}
text, err := f.Contents()
return []byte(text), err
}
func (s *gitSource) Tree(ctx context.Context, hash string) (tree, error) {
t, err := s.commitTree(ctx, hash)
if err != nil {
return nil, err
}
var gt gitTree
err = t.Files().ForEach(func(f *object.File) error {
gt.files = append(gt.files, gitFile{f})
return nil
})
return gt, err
}
func (s *gitSource) commitTree(ctx context.Context, hash string) (*object.Tree, error) {
c, err := s.commitObject(ctx, hash)
if err != nil {
return nil, err
}
return c.Tree()
}
// fetch returns an in-memory store holding the commit and its tree,
// fetching it at depth 1 unless it is among the recently fetched.
func (s *gitSource) fetch(ctx context.Context, hash string) (*memory.Storage, error) {
s.mu.Lock()
lock, ok := s.fetching[hash]
if !ok {
lock = &sync.Mutex{}
s.fetching[hash] = lock
}
s.mu.Unlock()
lock.Lock()
defer func() {
s.mu.Lock()
delete(s.fetching, hash)
s.mu.Unlock()
lock.Unlock()
}()
if st := s.recent(hash); st != nil {
return st, nil
}
refs, err := s.advRefs(ctx)
if err != nil {
return nil, err
}
sess, err := s.tr.NewUploadPackSession(s.ep, s.auth)
if err != nil {
return nil, err
}
defer sess.Close()
// go-git's HTTP session must see the advertisement before a POST.
if _, err := sess.AdvertisedReferencesContext(ctx); err != nil {
return nil, gitErr(err)
}
req := packp.NewUploadPackRequestFromCapabilities(refs.Capabilities)
req.Wants = []plumbing.Hash{plumbing.NewHash(hash)}
if refs.Capabilities.Supports(capability.Shallow) {
if err := req.Capabilities.Set(capability.Shallow); err != nil {
return nil, err
}
req.Depth = packp.DepthCommits(1)
}
resp, err := sess.UploadPack(ctx, req)
if err != nil {
return nil, fmt.Errorf("fetch %s: %w", hash, gitErr(err))
}
defer resp.Close()
// The pack arrives multiplexed with progress messages when the
// request negotiated side-band (go-git's own fetch demuxes it too).
var pack io.Reader = resp
switch {
case req.Capabilities.Supports(capability.Sideband64k):
pack = sideband.NewDemuxer(sideband.Sideband64k, resp)
case req.Capabilities.Supports(capability.Sideband):
pack = sideband.NewDemuxer(sideband.Sideband, resp)
}
st := memory.NewStorage()
if err := packfile.UpdateObjectStorage(st, pack); err != nil {
return nil, fmt.Errorf("fetch %s: %w", hash, err)
}
s.mu.Lock()
s.fetched = append(s.fetched, fetchedCommit{hash: hash, st: st})
if len(s.fetched) > fetchedKeep {
s.fetched = s.fetched[1:]
}
s.mu.Unlock()
return st, nil
}
func (s *gitSource) recent(hash string) *memory.Storage {
s.mu.Lock()
defer s.mu.Unlock()
for _, f := range s.fetched {
if f.hash == hash {
return f.st
}
}
return nil
}
type gitTree struct{ files []modzip.File }
func (t gitTree) Files() []modzip.File { return t.files }
func (gitTree) Close() error { return nil }
type gitFile struct{ f *object.File }
func (g gitFile) Path() string { return g.f.Name }
func (g gitFile) Lstat() (fs.FileInfo, error) { return gitInfo{g.f}, nil }
func (g gitFile) Open() (io.ReadCloser, error) { return g.f.Reader() }
// gitInfo is the fs.FileInfo of a tree entry: symlinks and submodules are
// reported as such so module zips skip them, as the go command does.
type gitInfo struct{ f *object.File }
func (i gitInfo) Name() string { return i.f.Name[strings.LastIndex(i.f.Name, "/")+1:] }
func (i gitInfo) Size() int64 { return i.f.Size }
func (i gitInfo) ModTime() time.Time { return time.Time{} }
func (i gitInfo) IsDir() bool { return false }
func (i gitInfo) Sys() any { return nil }
func (i gitInfo) Mode() fs.FileMode {
switch i.f.Mode {
case filemode.Symlink:
return fs.ModeSymlink | 0o777
case filemode.Executable:
return 0o755
case filemode.Regular, filemode.Deprecated:
return 0o644
}
return fs.ModeIrregular
}