Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 07:39:19 -05:00
pkg-proxy/internal/gitcache/relay.go
Simon Chrzanowski 06aef28125 Refuse a repository whose fetch is redirected
The relay turned a redirect into a failed fetch, which counted as the
upstream failing rather than refusing, so the cached copy was still
served and no refusal was recorded. Report it as a refusal, as for a
redirected ref listing.
2026-11-08 10:10:39 +01:00

206 lines
5.9 KiB
Go

package gitcache
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"io"
"log/slog"
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
)
const (
relayIDBytes = 16
relayReadHeaderTimeout = 30 * time.Second
)
// relay is a loopback HTTP server that every git fetch the cache runs goes
// through. git never reaches the network itself: each fetch gets a route to
// one upstream repository, and the relay forwards its requests with the
// proxy's upstream client, so address checks, upstream.auth, the access log
// and the no-redirect rule apply as they do to the proxy's own requests, and
// git never sees a credential.
type relay struct {
client *http.Client
logger *slog.Logger
listener net.Listener
server *http.Server
base string
mu sync.Mutex
routes map[string]*relayRoute
}
type relayRoute struct {
repoURL string
// ctx ends when the fetch does, which stops its relayed requests, so a
// git helper left behind by a killed git does not keep downloading.
ctx context.Context
// limit caps the response bytes the route relays, 0 for no cap, and
// budget, when set, holds space within MaxSize for them. exceeded and
// full record which of them stopped the route.
limit int64
budget *reservation
relayed atomic.Int64
exceeded atomic.Bool
full atomic.Bool
// answered records that the upstream answered a git-upload-pack
// request, so a failed fetch was not an outage.
answered atomic.Bool
// redirected records the status of a redirect the upstream answered
// with, 0 for none.
redirected atomic.Int32
}
func newRelay(client *http.Client, logger *slog.Logger) (*relay, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
if listener, err = net.Listen("tcp", "[::1]:0"); err != nil {
return nil, err
}
}
rl := &relay{
client: client,
logger: logger,
listener: listener,
base: "http://" + listener.Addr().String(),
routes: make(map[string]*relayRoute),
}
rl.server = &http.Server{Handler: rl, ReadHeaderTimeout: relayReadHeaderTimeout}
go func() {
if err := rl.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("git relay stopped", "error", err)
}
}()
return rl, nil
}
func (rl *relay) close() error {
return rl.server.Close()
}
// register adds a route to repoURL for a fetch running with ctx and returns
// the URL git fetches from and a function that removes the route and stops
// its requests.
func (rl *relay) register(ctx context.Context, repoURL string, limit int64, budget *reservation) (string, *relayRoute, func()) {
var b [relayIDBytes]byte
_, _ = rand.Read(b[:])
id := hex.EncodeToString(b[:])
ctx, cancel := context.WithCancel(ctx)
route := &relayRoute{repoURL: repoURL, ctx: ctx, limit: limit, budget: budget}
rl.mu.Lock()
rl.routes[id] = route
rl.mu.Unlock()
return rl.base + "/" + id, route, func() {
rl.mu.Lock()
delete(rl.routes, id)
rl.mu.Unlock()
cancel()
}
}
func (rl *relay) ServeHTTP(w http.ResponseWriter, r *http.Request) {
id, suffix, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
rl.mu.Lock()
route := rl.routes[id]
rl.mu.Unlock()
if route == nil {
http.NotFound(w, r)
return
}
var target string
switch {
case suffix == "info/refs" && r.Method == http.MethodGet && r.URL.RawQuery == "service=git-upload-pack":
target = route.repoURL + "/info/refs?service=git-upload-pack"
case suffix == "git-upload-pack" && r.Method == http.MethodPost:
target = route.repoURL + "/git-upload-pack"
default:
http.NotFound(w, r)
return
}
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
defer context.AfterFunc(route.ctx, cancel)()
out, err := http.NewRequestWithContext(ctx, r.Method, target, r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
out.ContentLength = r.ContentLength
for _, name := range []string{"Git-Protocol", "Content-Type", "Content-Encoding", "Accept", "User-Agent"} {
if value := r.Header.Get(name); value != "" {
out.Header.Set(name, value)
}
}
resp, err := rl.client.Do(out)
if err != nil {
if ctx.Err() == nil {
rl.logger.Warn("git relay: upstream request failed", "upstream", route.repoURL, "error", err)
}
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
// A redirect passed on would send git to the Location itself.
if resp.StatusCode >= http.StatusMultipleChoices && resp.StatusCode < http.StatusBadRequest {
route.redirected.Store(int32(resp.StatusCode))
http.Error(w, "upstream redirect not followed", http.StatusBadGateway)
return
}
if r.Method == http.MethodPost && resp.StatusCode == http.StatusOK {
route.answered.Store(true)
}
if ct := resp.Header.Get("Content-Type"); ct != "" {
w.Header().Set("Content-Type", ct)
}
w.WriteHeader(resp.StatusCode)
if _, err := io.Copy(w, &routeReader{ctx: ctx, r: resp.Body, route: route}); err != nil {
if r.Context().Err() != nil {
// git went away; there is no one to tell.
return
}
if ctx.Err() == nil && !route.exceeded.Load() && !route.full.Load() {
rl.logger.Warn("git relay: copying upstream response", "upstream", route.repoURL, "error", err)
}
// Cut the connection so git fails rather than taking a response
// that ended early for one that is complete.
panic(http.ErrAbortHandler)
}
}
// routeReader counts relayed bytes against the route's limit and budget.
type routeReader struct {
ctx context.Context
r io.Reader
route *relayRoute
}
func (rr *routeReader) Read(p []byte) (int, error) {
n, err := rr.r.Read(p)
total := rr.route.relayed.Add(int64(n))
if rr.route.limit > 0 && total > rr.route.limit {
rr.route.exceeded.Store(true)
return 0, ErrTooLarge
}
if rr.route.budget != nil {
if err := rr.route.budget.cover(rr.ctx, total); err != nil {
if errors.Is(err, ErrCacheFull) {
rr.route.full.Store(true)
}
return 0, err
}
}
return n, err
}