gitproxy

package
v0.0.6 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 16, 2026 License: MIT Imports: 25 Imported by: 0

Documentation

Overview

Package gitproxy is a git smart-HTTP interception proxy that runs outside the sandbox and decides, per ref, what a sandbox is allowed to push.

The hole it closes

pkg/executor already requires every write-back branch to live under "cloop/", and pkg/writeback re-verifies the branch after the fact. Both of those checks run in code the sandbox itself executes or in code that inspects the result after the push has already landed. Neither is a boundary. A workload that ignores the SDK and runs `git push --force origin HEAD:main` with the credential it was handed reaches main, and the first anyone hears of it is the force-push notification.

The credential is the reason. In push mode the hub leases a GitHub PAT and delivers it *into* the sandbox, because that is where git runs. Everything downstream of that delivery is advisory: the sandbox holds an authority scoped to whole repositories and is asked politely to use a fraction of it.

The inversion

This proxy moves git's authenticated leg out of the sandbox. The sandbox's remote is rewritten to point here, and it authenticates with an ephemeral session token that is worth nothing anywhere else. The proxy holds the real credential, parses the receive-pack command list before forwarding a byte of it, checks every ref update against the session's policy, and only then presents the credential upstream.

sandbox ──session token──▶ gitproxy ──PAT──▶ github.com
                             │
                        Policy.Decide per ref

So "push to whitelisted branches only" stops being a convention the sandbox is trusted to honour and becomes a property of the network path. A sandbox that tries refs/heads/main gets a refusal in git's own vocabulary and an audit row; a sandbox that leaks its token leaks the right to push to refs/heads/cloop/** for the rest of the task, and nothing else.

What it does not decide

Whether an update is a fast-forward. That needs the object graph, which the proxy does not have and would have to index a pack to get. Fast-forward enforcement belongs to the forge, where the graph already is — GitHub branch protection, or receive.denyNonFastForwards. This package is deliberately explicit about the boundary rather than implying a check it cannot make: what it enforces is *which refs* a sandbox may touch and *in which direction* (create, update, delete), which is exactly what stops a sandbox reaching a branch a human owns.

Signed pushes are refused outright. See ParseReceivePack.

Transport

Serve() takes a listener, so TLS is the caller's to configure through pkg/tlsconf. The base URL must be https: the sandbox presents its session token as an Authorization header on every request, and a sandbox is by construction something that may be sharing a host with whatever else is listening on loopback.

Index

Constants

View Source
const CredentialRefreshWindow = 10 * time.Minute

CredentialRefreshWindow is how much of an upstream credential's life may remain before a session with a refresher renews it. It matches the broker's window (secretbroker.AppTokenRefreshWindow), which this package does not import.

View Source
const DefaultListenAddr = "127.0.0.1:0"

DefaultListenAddr is where a hub binds the git proxy when listen_addr is unset: loopback, on a port chosen at startup.

View Source
const DefaultMaxCommands = 16

DefaultMaxCommands caps the ref updates one push may carry.

A write-back moves one branch. Anything with more than a handful of commands is either a mirror push or an attempt to find a ref the allowlist forgot, and neither is what a sandbox is for.

View Source
const DefaultMaxPackBytes int64 = 2 << 30 // 2 GiB

DefaultMaxPackBytes bounds one pushed pack.

The proxy streams the pack rather than buffering it, so this is not a memory bound — it is a bound on what one sandbox session can push through the hub's credential in a single request.

View Source
const DefaultSessionTTL = time.Hour

DefaultSessionTTL bounds one sandbox's access to the proxy.

It is the length of a task's git traffic, not the length of a task: the session is minted when a workload is dispatched and the clone and the write-back push both happen inside it. An hour covers a slow clone of a large repository over a slow link and still expires long before anything that leaked the token could be used at leisure.

View Source
const ForgeRouteSegment = "_forge"

ForgeRouteSegment is the path segment the proxy serves a forge host other than github.com beneath: /_forge/<host>/<owner>/<name>. No GitHub owner can be named it — an owner name may not begin with an underscore — and the route's four segments cannot be mistaken for a repository's two.

View Source
const MaxRefPatterns = 256

MaxRefPatterns bounds an allowlist. A policy with thousands of patterns is a configuration mistake, and matching every one of them against every ref in every push is work an attacker would otherwise get to choose the size of.

View Source
const MaxRepoPatterns = 64

MaxRepoPatterns bounds the allowlist a single session may carry.

Every pattern is tested on every request, so an unbounded list is a way to make each git operation arbitrarily expensive. The limit is far above any real grant — an allowlist is a handful of globs, not an inventory.

View Source
const MaxSessionTTL = 12 * time.Hour

MaxSessionTTL is the ceiling an operator cannot configure past. A git proxy session is a standing authority to push, and a long-lived one is indistinguishable from having handed the sandbox the credential itself.

Variables

View Source
var (
	// ErrSessionExists: the registry already holds a session with that id.
	ErrSessionExists = errors.New("gitproxy: a session with that id is already live")
	// ErrSessionLapsed: the session's TTL ran out before it could be restored.
	ErrSessionLapsed = errors.New("gitproxy: session lapsed before it could be restored")
)

Errors Restore distinguishes.

View Source
var (
	// ErrUnauthenticated covers every authentication failure — unknown
	// session, wrong token, expired, closed. They are one error on purpose: a
	// caller that could tell "no such session" from "wrong token" would be an
	// oracle for enumerating session IDs.
	ErrUnauthenticated = errors.New("gitproxy: unauthenticated")
	// ErrSessionNotFound is for management operations, where the caller is the
	// operator rather than the sandbox and precision is the point.
	ErrSessionNotFound = errors.New("gitproxy: session not found")
	// ErrWrongRepo means an authenticated session addressed a repository it
	// was not minted for.
	ErrWrongRepo = errors.New("gitproxy: session is not scoped to this repository")
)

Errors a caller distinguishes.

AllDenyReasons is every classification, for tests that assert the docs and dashboards cover the whole set.

View Source
var DefaultAllowedRef = "refs/heads/" + executor.WriteBackBranchPrefix + "**"

DefaultAllowedRef is the allowlist a policy gets when it names none.

It is the write-back namespace and nothing else. pkg/executor already requires every write-back branch to sit under this prefix so that the hub can force-update it without being able to overwrite a branch a human owns — but that rule is enforced by code running *inside* the sandbox, which makes it a convention rather than a boundary. Restating it here, outside, is what turns it into one.

View Source
var ErrCredentialRefused = errors.New("gitproxy: upstream credential will not be renewed")

ErrCredentialRefused is what a RefreshFunc wraps when the upstream credential will not be renewed, now or later. The session is closed.

View Source
var ErrRefDenied = errors.New("ref update denied by policy")

ErrRefDenied is the refusal. Every rejected command wraps it, so a caller can distinguish "the policy said no" — an expected, reportable outcome — from a malformed request or a broken upstream.

View Source
var ErrWrongForge = errors.New("gitproxy: session is for another forge host")

ErrWrongForge means an authenticated session addressed another forge host's route than the one its credential is for.

Functions

func AdvertisedBaseURL added in v0.0.5

func AdvertisedBaseURL(advertise string, bound net.Addr) (string, error)

AdvertisedBaseURL is the base a hub points sandboxes at for a proxy bound to bound: advertise_url when it is set, otherwise the bound address itself — with an unspecified bind named as 127.0.0.1, because 0.0.0.0 is a bind address and never a destination. The result is what NewRegistry is given, and so is still subject to NormalizeBaseURL.

The bound address is only correct when the sandbox shares the hub's network namespace, which is why an operator running containers, Pods or edge devices sets advertise_url. Falling back to it anyway — rather than refusing — keeps the single-host case working with no configuration, and a wrong choice here surfaces as a fetch that cannot connect rather than as a credential going somewhere it should not.

func ForgeRoute added in v0.0.6

func ForgeRoute(base, host string) (string, error)

ForgeRoute returns the URL beneath base that the sandbox's https://<host>/ is rewritten to: base itself for github.com, base/_forge/<host> for any other host. host is canonicalised; one that is not usable in a URL path — an IPv6 literal — is refused.

func NormalizeBaseURL added in v0.0.5

func NormalizeBaseURL(raw string) (string, error)

NormalizeBaseURL checks the public base a sandbox clones from and returns it as scheme://host[:port]. It is the rule NewRegistry applies, exported so that config validation and `cloop hub doctor` ask it rather than restate it (Task 20387): three copies of one rule were three chances to disagree about which advertise_url starts a proxy.

https is required for the same reason pkg/executor requires it of a workspace repo: the sandbox presents its session token as an Authorization header on every request, and over cleartext that token is published rather than delivered. A loopback proxy is not an exception — a sandbox is, by construction, something that might be sharing the host.

The host must name a host, not merely be non-empty: "https://:8443" has a Host of ":8443", and every sandbox handed it would dial nowhere. A port must be one a client can dial.

func NormalizeRepoPath added in v0.0.2

func NormalizeRepoPath(p string) string

NormalizeRepoPath renders an owner/name path in the form the matcher and the audit trail both use: no leading or trailing slash, no ".git" suffix, and lowercased, because GitHub treats repository paths case-insensitively and a matcher that did not would let "Acme/Tool" slip past an "acme/*" allowlist.

Normalisation only. It does not decide whether the result is usable — that is validRepoSegment's job, and the two are separate because the audit trail wants to record what was asked for even when the answer is no.

func RenewMargin added in v0.0.6

func RenewMargin(ttl time.Duration) time.Duration

RenewMargin is how close to its deadline a session is renewed: five minutes, or half its TTL for a shorter one.

func RenewTick added in v0.0.6

func RenewTick(ttl time.Duration) time.Duration

RenewTick is how often a renewer looks: a minute, or a quarter of the TTL for a shorter one — at least twice inside RenewMargin either way.

func UpstreamHostBase added in v0.0.2

func UpstreamHostBase(raw string) (string, error)

UpstreamHostBase checks the forge base a scoped session forwards to.

A scoped session's Upstream is a host, not a repository, so it is validated against the opposite requirement from UpstreamRepoPath: there must be *no* path, because every path segment on a scoped session comes from the request and a base with one would silently prefix them all.

func UpstreamRepoPath

func UpstreamRepoPath(upstream string) (string, error)

UpstreamRepoPath extracts the "owner/name" a proxy serves an upstream under.

The shape is not cosmetic. pkg/executor's Workspace.RepoPath parses exactly this out of a clone URL to match a GitHub grant's repository allowlist, so a proxy URL that did not preserve it would break grant matching for every workspace routed through the proxy.

func ValidateRefName

func ValidateRefName(ref string) error

ValidateRefName applies git's ref naming rules to a name arriving from a sandbox.

The proxy forwards this string to a real git server, so a name that git would refuse must be refused here rather than passed along: the rules below are what stop "refs/heads/../../x" and "refs/heads/ --upload-pack=sh" from being this proxy's problem to reason about.

func ValidateRepoPattern added in v0.0.2

func ValidateRepoPattern(pattern string) error

ValidateRepoPattern checks one owner/name glob.

The shape requirement — exactly one slash, or the bare "*" wildcards — is what keeps path.Match's semantics predictable here: "*" does not cross a separator, so "acme/*" matches acme/tool and not acme/group/tool. A pattern with a different shape could only ever match paths this proxy rejects anyway, so it is a mistake worth reporting at mint time rather than a silently dead entry in an allowlist someone is relying on.

Types

type Credential

type Credential struct {
	Username string
	Password string
	// GrantID and LeaseID are audit bookkeeping. Neither is secret.
	GrantID string
	LeaseID string
}

Credential is what the proxy presents upstream on the sandbox's behalf.

It is the whole point of the design. The sandbox never holds this — it holds a session token that works against this proxy, for this repository, for this TTL, under this policy, and nowhere else. A token that escapes a sandbox buys an attacker exactly the authority the policy already granted, which is "push to refs/heads/cloop/**", rather than everything the underlying PAT can reach.

func (Credential) Empty

func (c Credential) Empty() bool

Empty reports whether there is nothing to present upstream.

type Decision

type Decision struct {
	Update RefUpdate
	Err    error
}

Decision pairs one command with its verdict, for reporting and for audit.

func (Decision) Allowed

func (d Decision) Allowed() bool

Allowed reports whether the command passed.

func (Decision) Reason

func (d Decision) Reason() string

Reason renders the refusal as a single line fit for git's status report, which is newline-delimited and would otherwise be split by a multi-line message into a status line and garbage.

The ref itself is left out when the refusal carries it separately: every caller renders the reason beside the ref already.

type DenyReason

type DenyReason string

DenyReason classifies a refusal into a closed set.

The prose in a refusal names the ref and the allowlist that excluded it, so it is exactly as varied as the branch names a sandbox chooses to push. That makes it unusable as a metric label: a sandbox pushing refs/heads/1, refs/heads/2, ... would mint one Prometheus series per attempt, turning a probe of the allowlist into memory pressure on the hub watching it. The reason code is chosen by this package from the constants below, so the number of buckets is fixed no matter what a sandbox sends.

const (
	// DenyRefNotAllowed: the ref is outside the session's branch allowlist.
	// A sandbox hitting this repeatedly is enumerating the allowlist.
	DenyRefNotAllowed DenyReason = "ref_not_allowed"
	// DenyDelete: deleting refs is not permitted for this session.
	DenyDelete DenyReason = "delete_denied"
	// DenyCreate: creating refs is not permitted for this session.
	DenyCreate DenyReason = "create_denied"
	// DenyUpdate: updating existing refs is not permitted for this session.
	DenyUpdate DenyReason = "update_denied"
	// DenyNoWrite: the session's policy carries no write capability at all.
	DenyNoWrite DenyReason = "no_write"
	// DenyTooManyCommands: the push carries more ref updates than allowed.
	DenyTooManyCommands DenyReason = "too_many_commands"
	// DenyPushCert: signed pushes are not supported.
	DenyPushCert DenyReason = "push_cert"
)

func DenyReasonOf

func DenyReasonOf(err error) (DenyReason, bool)

DenyReasonOf extracts the classification from anything in an error's chain, falling back to DenyRefNotAllowed for a refusal that predates classification — the conservative choice, since that is the reason a security-minded operator most wants counted rather than dropped.

type Event

type Event struct {
	Kind      EventKind `json:"kind"`
	SessionID string    `json:"session_id,omitempty"`
	RepoPath  string    `json:"repo_path,omitempty"`
	ProjectID string    `json:"project_id,omitempty"`
	TaskID    string    `json:"task_id,omitempty"`
	Actor     string    `json:"actor,omitempty"`
	// Refs are the ref names the request touched.
	Refs []string `json:"refs,omitempty"`
	// Detail is a human-readable reason or summary.
	Detail string    `json:"detail,omitempty"`
	At     time.Time `json:"at"`
}

Event is one observation for the compliance trail.

It carries no credential and no object content: the fields are IDs, ref names and reasons, all of which are safe to write to a log an operator reads. The audit value is in the refusals — a push_denied naming refs/heads/main is a sandbox that tried, and nothing else in the system would have recorded it.

func (Event) String

func (e Event) String() string

String renders the event as one log line.

type EventKind

type EventKind string

EventKind names what happened.

const (
	// EventSessionMinted records a session coming into existence.
	EventSessionMinted EventKind = "session_minted"
	// EventSessionClosed records one going away, by revocation or expiry.
	EventSessionClosed EventKind = "session_closed"
	// EventSessionRenewed records a session's deadline moved by Renew.
	EventSessionRenewed EventKind = "session_renewed"
	// EventPushAllowed records a push whose every command passed policy. It is
	// emitted before forwarding, so it describes what was authorised, not what
	// the remote ultimately accepted.
	EventPushAllowed EventKind = "push_allowed"
	// EventPushDenied records a push refused by policy. This is the row that
	// matters: it is the evidence that the boundary held, and the only place a
	// sandbox's attempt to reach a protected branch is written down.
	EventPushDenied EventKind = "push_denied"
	// EventFetch records a read through the proxy.
	EventFetch EventKind = "fetch"
	// EventRejected records a request refused before policy ran at all —
	// unauthenticated, wrong repository, malformed, or a route this proxy does
	// not serve.
	EventRejected EventKind = "rejected"
)
const EventSessionRestored EventKind = "session_restored"

EventSessionRestored records a session taken over from another hub process and restored into this registry.

type MintRequest

type MintRequest struct {
	// Upstream is the real https repository URL the proxy will forward to —
	// or, when RepoPatterns is set, the forge host base (https://github.com)
	// beneath which the allowlisted repositories live.
	Upstream string
	// RepoPatterns, when non-empty, mints a *scoped* session: one that admits
	// every repository matching an owner/name glob, rather than the single
	// repository Upstream names. Upstream must then be a bare host base.
	//
	// This is the mode a user's GitHub PAT grant uses, because its allowlist
	// ("acme/*") names a set that only the forge can enumerate. See scope.go.
	RepoPatterns []string
	// ForgeHost is the forge host Credential is good against (Task 20423):
	// github.com, or the GitHub Enterprise Server host the grant names. The
	// session is served on that host's route alone, and a pinned session's
	// Upstream must be on it, as must a scoped one's for any host but
	// github.com. Empty is github.com for a scoped session and Upstream's own
	// host for a pinned one — what every request meant before hosts were
	// recorded.
	ForgeHost string
	// Credential authenticates the proxy upstream. Optional: a public
	// repository needs none, though pushing to one generally does.
	Credential Credential
	// CredentialExpiresAt is when the forge stops honouring Credential — a
	// GitHub App installation token's hour. Zero for a credential that does
	// not expire on a clock the hub knows (a PAT).
	CredentialExpiresAt time.Time
	// Refresh, when set, re-mints Credential before CredentialExpiresAt
	// (Task 20375). The session calls it from its request path once
	// CredentialRefreshWindow or less of the credential's life remains,
	// single-flight, presents the result to every later upstream request, and
	// retires the superseded credential once no request presents it. Requires
	// CredentialExpiresAt. See refresh.go.
	Refresh RefreshFunc
	// Policy is what the sandbox may do. Zero value gets WriteBackPolicy.
	Policy Policy
	// TTL bounds the session. 0 means DefaultSessionTTL; anything above
	// MaxSessionTTL is an error rather than a silent clamp, because a caller
	// that asked for a day and got twelve hours would discover it as a failed
	// push halfway through.
	TTL time.Duration

	ProjectID  string
	TaskID     string
	ExecutorID string
	Actor      string
	// RunID names the run the session is for, recorded with it.
	RunID string

	// Durable records the session in the registry's Store (Task 20383), so
	// that the hub process adopting its run after this one stops can restore
	// it under the same id and token. The lease path sets it: its sessions
	// stand on the run's lease, which the adopter takes over and re-derives
	// the upstream credential from. A session no lease is taken over for —
	// a workspace's, whose credential stands on a lease of its own — would
	// have nothing to be restored with, and is left out.
	Durable bool

	// OnEnd, when set, runs once after the session has left the registry —
	// closed, reaped after its TTL, or closed at shutdown — on the goroutine
	// that ended it and outside the registry's lock.
	//
	// It is for releasing what the upstream credential stands on. A GitHub
	// App installation token is destroyed at GitHub when the lease that
	// minted it is released, and a session presents that token upstream for
	// its whole life: releasing the lease when the credential was *delivered*
	// left the session holding a dead token (Task 20349).
	OnEnd func()
}

MintRequest describes the session to create.

type Minted

type Minted struct {
	Session *Session
	// Token is the plaintext session token. The registry keeps only its hash,
	// so this is the only copy that will ever exist.
	Token string
	// RepoURL is what the sandbox uses as its remote: the proxy's public base
	// with the session's repository path — or, for a scoped session, the route
	// its forge host's URLs are rewritten to (ForgeRoute). It carries no
	// credential.
	RepoURL string
}

Minted is a new session plus the one-time secret.

func (Minted) Credential

func (m Minted) Credential() Credential

Credential renders what a driver delivers into the sandbox: a basic credential whose username is the session ID and whose password is the token.

type Options

type Options struct {
	// Transport carries the proxy's requests to the forge. nil gets a
	// hardened default. It exists so an operator whose forge sits behind a
	// private CA can supply one — the same case pkg/tlsconf exists for — and
	// so a test can point the upstream leg at a self-signed fixture without
	// weakening the default.
	Transport http.RoundTripper

	// Fallback, when set, is offered every request whose credential names a
	// session this registry has never heard of, before it is refused
	// (Task 20354). Several hub processes may serve one control plane, each
	// with its own registry, and a sandbox's request reaches whichever of
	// them the network sends it to; the hub forwards it to the process that
	// minted the session. It reports whether it answered the request.
	// Credentials naming a session this registry knows — right or wrong
	// token, live or expired — are always decided here.
	Fallback func(w http.ResponseWriter, r *http.Request, sessionID string) bool
}

Options tunes the upstream leg.

type Policy

type Policy struct {
	// AllowedRefs are glob patterns over full ref names. A pattern without a
	// refs/ prefix is read as a branch name and gets refs/heads/ prepended, so
	// "cloop/*" and "refs/heads/cloop/*" mean the same thing.
	//
	// Matching follows path.Match: "*" does not cross a "/". A trailing "/**"
	// matches any depth below the prefix, which is what a per-task namespace
	// like cloop/task-42/fixup needs.
	AllowedRefs []string `json:"allowed_refs,omitempty"`

	// AllowCreate permits a push that brings a ref into existence. Creating a
	// new branch under an allowed namespace is the ordinary write-back case, so
	// this is the one authority a caller almost always wants.
	AllowCreate bool `json:"allow_create,omitempty"`

	// AllowUpdate permits moving an existing allowed ref to a new commit. A
	// retried task legitimately replaces its own predecessor.
	AllowUpdate bool `json:"allow_update,omitempty"`

	// AllowDelete permits removing an allowed ref. Off by default: deleting is
	// not something a write-back ever needs, and a name-only allowlist that
	// forgot about deletes would let a sandbox destroy the very branches it was
	// scoped to.
	AllowDelete bool `json:"allow_delete,omitempty"`

	// AllowFetch permits the read half of the protocol — git-upload-pack — over
	// the same session. A sandbox that clones through the proxy needs it; one
	// that only pushes back a tree it was handed does not.
	AllowFetch bool `json:"allow_fetch,omitempty"`

	// MaxCommands caps ref updates per push. 0 means DefaultMaxCommands.
	MaxCommands int `json:"max_commands,omitempty"`

	// MaxPackBytes caps the request body. 0 means DefaultMaxPackBytes.
	MaxPackBytes int64 `json:"max_pack_bytes,omitempty"`

	// RestrictRefs is a second allowlist that a ref must *also* match. Empty
	// means no second restriction, so only AllowedRefs applies.
	//
	// It exists so that one party can narrow another's allowlist without
	// either rewriting it. The hub's configured allowlist is AllowedRefs, the
	// ceiling; a grant that says "this project may push to feature/* only"
	// arrives here, and a ref has to clear both. The alternative, computing
	// one list that is the intersection of the two, is not possible in
	// general: the intersection of two sets of globs is not always a third
	// set of globs. Two lists that both have to match are always exact.
	//
	// Patterns take the same syntax and normalisation as AllowedRefs. Unlike
	// AllowedRefs, an empty list gets no default. Silence here means "the
	// grant added nothing", not "fall back to the write-back namespace".
	RestrictRefs []string `json:"restrict_refs,omitempty"`
}

Policy is what a sandbox is allowed to do to a repository's refs.

It is deny-by-default in every dimension. An empty AllowedRefs is not "no restriction", it is the built-in write-back namespace; an unset AllowDelete is a refusal, not an omission. The failure mode of a permissive default here is a sandbox force-pushing over main, which is exactly the outcome the subsystem exists to make impossible, so there is no default that trades safety for convenience.

func WriteBackPolicy

func WriteBackPolicy() Policy

WriteBackPolicy returns the policy the executor write-back path needs: create and update inside the cloop/ namespace, no deletes, no fetch.

It is a constructor rather than a documented recipe because every caller that hand-assembled these four booleans would be a caller that could get one wrong, and three of the four failures are silent.

func (Policy) AllowsRef

func (p Policy) AllowsRef(ref string) bool

AllowsRef reports whether the ref name clears both allowlists: some pattern in AllowedRefs admits it and, when RestrictRefs names anything, some pattern there admits it too.

func (Policy) Decide

func (p Policy) Decide(u RefUpdate) error

Decide returns nil if the update is permitted, or an error wrapping ErrRefDenied whose text is shown to the person running git push.

The two checks are ordered name-first deliberately. A sandbox probing for a ref it is not allowed to touch learns the same thing either way, but an operator reading the audit trail wants "main is not in the allowlist" rather than "delete is not permitted", because the first names the real problem.

The name refusals carry the ref separately from their prose (RefDenial.Ref), so the reason git shows next to the ref does not repeat it. That reason is cut to one short line for git's status report (see elide), and a long branch name restated inside it pushed the one part the reader needs — the refs that would have been accepted — past the cut (found in Task 20340's live run).

func (Policy) DecideAll

func (p Policy) DecideAll(cmds []RefUpdate) ([]Decision, bool)

DecideAll evaluates every command.

A push is atomic here whatever the client asked for: if any command is refused, none is forwarded. Partially applying a push would mean the sandbox discovers the allowlist by watching which half of its commands landed, and leaves the repository in a state neither side asked for.

func (Policy) IsZero

func (p Policy) IsZero() bool

IsZero reports whether the policy grants nothing and names nothing.

It distinguishes "the caller never filled this in" from "the caller deliberately wrote a policy that permits nothing", which Validate refuses. Only the first is safe to replace with a default; treating the second as an invitation to substitute WriteBackPolicy would silently widen a policy somebody wrote to be narrow.

func (*Policy) Normalize

func (p *Policy) Normalize()

Normalize fills defaults and canonicalises patterns. It is idempotent.

func (Policy) RefSummary added in v0.0.3

func (p Policy) RefSummary() string

RefSummary renders the allowlist as one line for audit rows and the summaries shown to an operator: the ceiling, then the narrowing when there is one.

func (Policy) Restricted added in v0.0.3

func (p Policy) Restricted() bool

Restricted reports whether a second allowlist narrows this policy.

func (Policy) Validate

func (p Policy) Validate() error

Validate reports whether the policy is usable. Call Normalize first.

type Proxy

type Proxy struct {
	// contains filtered or unexported fields
}

Proxy serves the git smart-HTTP protocol for the sessions in a Registry.

The zero value is not usable; call New.

func New

func New(reg *Registry, opts Options) (*Proxy, error)

New returns a proxy over reg.

func (*Proxy) Addr

func (p *Proxy) Addr() string

Addr returns the address the proxy is listening on, or "".

func (*Proxy) Close

func (p *Proxy) Close() error

Close stops serving and waits briefly for in-flight requests.

func (*Proxy) Registry

func (p *Proxy) Registry() *Registry

Registry returns the session registry the proxy serves.

func (*Proxy) Serve

func (p *Proxy) Serve(ln net.Listener) error

Serve accepts connections on ln until Close. The caller supplies the listener, so a TLS listener from pkg/tlsconf drops straight in.

func (*Proxy) ServeHTTP

func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements the git smart-HTTP routes.

Everything outside those routes is a 404. The dumb-HTTP protocol in particular is not served: it reads objects and packs straight out of the repository, which would let a sandbox fetch by path and bypass every check below.

type ReceivePack

type ReceivePack struct {
	// Commands are the ref updates, in wire order.
	Commands []RefUpdate
	// Capabilities are the tokens the client advertised after the NUL on the
	// first command line — "report-status", "side-band-64k", "agent=git/2.43"
	// and so on.
	Capabilities []string
	// Shallow lists the object names from any shallow lines. They are carried
	// so a caller can see them; nothing here decides on them.
	Shallow []string
}

ReceivePack is the head of a push: everything before the pack file.

func ParseReceivePack

func ParseReceivePack(body io.Reader) (*ReceivePack, io.Reader, error)

ParseReceivePack reads the command section of a git-receive-pack request.

It returns the parsed head and a reader that replays the entire body — the consumed command bytes followed by the untouched pack — so a caller that decides to allow the push forwards exactly what the client sent. See pktReader for why replay rather than re-encode.

A push carrying a push certificate is refused. With push-cert the command list lives inside a signed block and the remote applies *that* copy, so enforcing a branch allowlist against the unsigned lines would be enforcing it against bytes nothing acts on. Refusing is the only honest answer available without full certificate parsing, and a proxy that silently passed one through would be a branch allowlist with a documented bypass.

func (*ReceivePack) HasCapability

func (r *ReceivePack) HasCapability(name string) bool

HasCapability reports whether the client advertised a bare capability token.

func (*ReceivePack) WantsReportStatus

func (r *ReceivePack) WantsReportStatus() bool

WantsReportStatus reports whether the client will read a status report.

This decides how a refusal is delivered. A client that asked for a report can be told which ref was refused and why, in git's own vocabulary, and will exit non-zero having printed it. A client that did not ask has no channel for that and must be refused at the HTTP layer instead — a silent 200 to a client that cannot read a report is a push that reports success and moved nothing.

func (*ReceivePack) WantsSideband

func (r *ReceivePack) WantsSideband() bool

WantsSideband reports whether the status report must be side-band framed.

type RefDenial

type RefDenial struct {
	Reason  DenyReason
	Message string
	// Ref, when set, is the ref the refusal is about. Error names it, so the
	// error is self-contained wherever it is logged; Decision.Reason leaves it
	// out, because git's status line already prints it and the reason is cut
	// to one short line (see Decide).
	Ref string
}

RefDenial is a policy refusal carrying both its prose and its class.

Error renders exactly as the fmt.Errorf it replaced — the sentinel's text, a colon, then the message — because Decision.Reason strips that prefix to build git's status line, and the sandbox-facing wording of a refusal is covered by tests.

func (*RefDenial) Error

func (e *RefDenial) Error() string

func (*RefDenial) Unwrap

func (e *RefDenial) Unwrap() error

Unwrap keeps errors.Is(err, ErrRefDenied) true for every existing caller.

type RefUpdate

type RefUpdate struct {
	Old string
	New string
	Ref string
}

RefUpdate is one line of a push: move Ref from Old to New.

The SHAs are the client's claim, not a verified fact. Old in particular is what the client believes the remote currently holds; the remote checks it, this proxy does not, and no decision here may depend on it being true. What the proxy does depend on is the *shape* — whether either side is the zero SHA — because that is what distinguishes a create from a delete from an update, and those are three different authorities.

func (RefUpdate) Action

func (u RefUpdate) Action() string

Action names the authority this update needs, for policy and for audit rows.

func (RefUpdate) IsCreate

func (u RefUpdate) IsCreate() bool

IsCreate reports whether the ref does not exist yet.

func (RefUpdate) IsDelete

func (u RefUpdate) IsDelete() bool

IsDelete reports whether the push removes the ref.

func (RefUpdate) IsUpdate

func (u RefUpdate) IsUpdate() bool

IsUpdate reports whether an existing ref moves to a new commit.

func (RefUpdate) String

func (u RefUpdate) String() string

String renders the update for a log line. Object names are not secret.

type RefreshFunc added in v0.0.5

type RefreshFunc func(ctx context.Context, held string) (Refreshed, error)

RefreshFunc renews a session's upstream credential. held is the password the session presents now. An error wrapping ErrCredentialRefused is final; any other is retried.

type Refreshed added in v0.0.5

type Refreshed struct {
	// Credential is presented upstream from now on.
	Credential Credential
	// ExpiresAt is when the forge stops honouring it.
	ExpiresAt time.Time
	// Retire ends the credential this one superseded — destroys a GitHub App
	// token at GitHub. The session calls it once, after the last request
	// presenting the old credential has finished. Nil when there is nothing to
	// retire, which is also what a refresher returns when another caller had
	// already renewed the credential.
	Retire func()
}

Refreshed is a renewed upstream credential.

type Registry

type Registry struct {
	// BaseURL is the https base the sandbox reaches the proxy at, e.g.
	// "https://hub.internal:8443". Sessions render their RepoURL beneath it.
	BaseURL string
	// Now is the clock, injectable for tests. nil means time.Now.
	Now func() time.Time
	// OnEvent, if set, receives every authorisation decision. It runs on the
	// request goroutine, so an implementation that blocks blocks a push.
	OnEvent func(Event)
	// Store, if set, records every Durable session and its end (durable.go),
	// so another hub process can restore it. Nil keeps sessions in memory
	// only, as every registry did before Task 20383.
	Store SessionStore
	// contains filtered or unexported fields
}

Registry holds live sessions.

It is the hub-side half of the proxy and has no HTTP in it, so the policy decisions it gates can be tested without sockets — the same split pkg/egressbroker draws between Broker and Proxy, for the same reason.

func NewRegistry

func NewRegistry(baseURL string) (*Registry, error)

NewRegistry returns a registry serving sessions beneath baseURL.

func (*Registry) Authenticate

func (r *Registry) Authenticate(id, token string) (*Session, error)

Authenticate resolves a session from the basic credential a sandbox presented. Every failure returns ErrUnauthenticated.

func (*Registry) CheckRestore added in v0.0.5

func (r *Registry) CheckRestore(rec SessionRecord) error

CheckRestore reports why Restore would refuse rec, or nil: everything it checks except the credential. A caller that must spend something to obtain the credential — mint a GitHub App token — asks first.

func (*Registry) Close

func (r *Registry) Close(id, reason string)

Close revokes a session. It is idempotent, so a driver can call it from a defer without checking whether the run already ended.

func (*Registry) CloseForGrant added in v0.0.5

func (r *Registry) CloseForGrant(leaseID, grantID, reason string) int

CloseForGrant closes the sessions grantID's credential feeds in leaseID — one grant of a lease revoked while the lease's other grants keep working (Task 20403) — and reports how many it closed.

func (*Registry) CloseForLease added in v0.0.5

func (r *Registry) CloseForLease(leaseID, reason string) int

CloseForLease closes every session whose upstream credential stands on leaseID, and reports how many it closed. It is how releasing a lease ends the sessions it fed when the lease no longer names them in its materials — one taken over from another process carries only grant ids.

func (*Registry) Known added in v0.0.5

func (r *Registry) Known(id string) bool

Known reports whether id names a session this registry holds, live or not. It does not authenticate anything; see Options.Fallback for its one use.

func (*Registry) LeaseSessions added in v0.0.6

func (r *Registry) LeaseSessions(leaseID string) []*Session

LeaseSessions returns the live sessions whose upstream credential stands on leaseID.

func (*Registry) Mint

func (r *Registry) Mint(req MintRequest) (*Minted, error)

Mint creates a session and returns its one-time token.

func (*Registry) ReapExpired

func (r *Registry) ReapExpired() int

ReapExpired drops sessions past their TTL and returns how many went.

Authenticate already refuses an expired session, so this is hygiene rather than enforcement: without it the map grows for the life of the process.

func (*Registry) Renew added in v0.0.6

func (r *Registry) Renew(id string, ttl time.Duration) (time.Time, error)

Renew moves a live session's deadline to ttl from now — 0 is DefaultSessionTTL, more than MaxSessionTTL is refused as Mint refuses it — and never backwards, recording the renewal in the Store when the session is durable. It returns the deadline afterwards.

It is how a workspace's session outlives session_minutes while its run does (Task 20422): the session is minted when the run is dispatched and has to authenticate the run's write-back push at its end, however long the run took. Whoever calls it decides the run is alive; the registry decides only that the session is. A session that is closed or unknown is ErrSessionNotFound, one already past its deadline ErrSessionLapsed — reviving it would race the reaper into serving a session it had written off.

func (*Registry) RenewDue added in v0.0.6

func (r *Registry) RenewDue(id string, ttl time.Duration) (bool, time.Time, error)

RenewDue renews id when RenewMargin(ttl) or less of its life remains, on the registry's clock, and reports whether it did and the deadline afterwards. A session that is closed or unknown is ErrSessionNotFound, one past its deadline ErrSessionLapsed, as for Renew.

func (*Registry) RenewalDue added in v0.0.6

func (r *Registry) RenewalDue(id string, ttl time.Duration) bool

RenewalDue reports whether id is open with RenewMargin(ttl) or less of its life left, on the registry's clock: what a renewer asks before anything costlier, such as whether the run is still alive.

func (*Registry) Restore added in v0.0.5

func (r *Registry) Restore(req RestoreRequest) (*Session, error)

Restore brings back a session another hub process minted, under the same id, token hash, scope and expiry, presenting the credential the caller re-derived. Its end is recorded in the Store like a minted durable session's.

It refuses a record it could not have minted — an unusable upstream, an allowlist or policy Mint would refuse — and a session already past its TTL, already live here, or whose token hash is not a SHA-256: a restored session is checked as strictly as a new one, because the record it comes from was read back from a database rather than produced by this process.

func (*Registry) Session

func (r *Registry) Session(id string) (*Session, error)

Session returns a session by ID, for management and for tests.

func (*Registry) Sessions

func (r *Registry) Sessions() []*Session

Sessions returns every live session.

func (*Registry) Suspend added in v0.0.5

func (r *Registry) Suspend(id, reason string) bool

Suspend stops serving a session without ending it: it leaves the registry, but its record stays open and its OnEnd does not run, so the hub process that adopts its run can restore it. A hub stopping gracefully suspends its durable sessions rather than closing them, and a process whose lease another one took over suspends the sessions that lease feeds — the new holder serves them now. It reports whether the session was here.

func (*Registry) SuspendForLease added in v0.0.5

func (r *Registry) SuspendForLease(leaseID, reason string) []string

SuspendForLease suspends every durable session leaseID feeds and returns their ids, sorted. A session its Store holds no record of is closed instead, with reason: nothing could restore it, and a session that simply stopped being served would leave its audit trail without an end.

type RestoreRequest added in v0.0.5

type RestoreRequest struct {
	// Record is the session as its Store recorded it.
	Record SessionRecord
	// Credential is presented upstream on the session's behalf: re-derived by
	// the caller from the grant the session stands on, never read from the
	// record, which has none. CredentialExpiresAt and Refresh are as on
	// MintRequest.
	Credential          Credential
	CredentialExpiresAt time.Time
	Refresh             RefreshFunc
	// OnEnd is as on MintRequest.
	OnEnd func()
	// From names the hub process the session was taken over from, for the
	// restored row.
	From string
}

RestoreRequest is a session to bring back.

type Session

type Session struct {
	// ID identifies the session and is the username half of the basic
	// credential the sandbox presents. Not secret.
	ID string
	// RepoPath is the "owner/name" the sandbox addresses, and the path the
	// proxy serves this session under. Pinning it means a session minted for
	// one repository cannot be replayed against another the same proxy happens
	// to be serving.
	//
	// Empty on a scoped session, where RepoPatterns takes over. See scope.go.
	RepoPath string
	// RepoPatterns is an owner/name glob allowlist. When non-empty the session
	// is *scoped* rather than pinned: it admits every repository the allowlist
	// matches, which is what lets one session stand in for a user's PAT grant
	// whose allowlist the hub cannot enumerate. See scope.go.
	RepoPatterns []string
	// Upstream is the real https clone URL — or, for a scoped session, the
	// forge host base beneath which repository paths are resolved. Either way
	// it never reaches the sandbox, and it is the only source of the host the
	// credential is ever presented to.
	Upstream string
	// ForgeHost is the forge host the session's credential is good against,
	// canonical: github.com, or a GitHub Enterprise Server host (Task 20423).
	// The proxy serves the session on that host's route only (forge.go).
	// Upstream is on this host, except for a scoped github.com session's,
	// which is the hub's to point at github.com or, in a test, a stand-in.
	ForgeHost string
	// Policy is what this session may do.
	Policy Policy

	// Labels for the audit trail.
	ProjectID  string
	TaskID     string
	ExecutorID string
	Actor      string
	// RunID, GrantID and LeaseID say which run the session serves and which
	// grant of which lease its upstream credential stands on (Task 20383): the
	// lease it is closed with, and the record a restore finds it by. None is
	// secret.
	RunID   string
	GrantID string
	LeaseID string

	IssuedAt time.Time
	// contains filtered or unexported fields
}

Session is one sandbox's brokered access to one repository.

func (*Session) AllowsRepo added in v0.0.2

func (s *Session) AllowsRepo(repoPath string) bool

AllowsRepo reports whether this session may address the given owner/name.

Deny-by-default in both modes: a pinned session admits its one repository, a scoped session admits only what its allowlist matches, and a session with neither admits nothing.

func (*Session) CloseReason

func (s *Session) CloseReason() string

CloseReason returns why the session was revoked, or "".

func (*Session) Closed

func (s *Session) Closed() bool

Closed reports whether the session was revoked.

func (*Session) CredentialExpiresAt added in v0.0.5

func (s *Session) CredentialExpiresAt() time.Time

CredentialExpiresAt reports when the forge stops honouring the credential the session presents now. Zero when it does not expire on a clock the hub knows.

func (*Session) Durable added in v0.0.5

func (s *Session) Durable() bool

Durable reports whether a Store holds a record of the session.

func (*Session) Expired

func (s *Session) Expired(now time.Time) bool

Expired reports whether the session's TTL has run out.

func (*Session) ExpiresAt

func (s *Session) ExpiresAt() time.Time

ExpiresAt returns when the session stops authenticating.

func (*Session) Refreshable added in v0.0.5

func (s *Session) Refreshable() bool

Refreshable reports whether the session renews its upstream credential.

func (*Session) RenewedAt added in v0.0.6

func (s *Session) RenewedAt() time.Time

RenewedAt returns when Renew last moved the deadline, or zero.

func (*Session) Scoped added in v0.0.2

func (s *Session) Scoped() bool

Scoped reports whether the session serves an allowlist rather than a single pinned repository.

func (*Session) Stats

func (s *Session) Stats() Stats

Stats returns the session's counters.

func (*Session) Used added in v0.0.5

func (s *Session) Used() bool

Used reports whether the session ever authenticated a request.

type SessionRecord added in v0.0.5

type SessionRecord struct {
	ID string
	// TokenSHA256 is the hex SHA-256 of the session token.
	TokenSHA256  string
	RepoPath     string
	RepoPatterns []string
	// Upstream is the forge URL or host base; it carries no credential (Mint
	// refuses one with userinfo).
	Upstream string
	// ForgeHost is the forge host the session's credential is good against
	// (Task 20423). A record written before hosts were recorded has none, and
	// reads as github.com for a scoped session and its upstream's host for a
	// pinned one — what it was minted for.
	ForgeHost  string
	Policy     Policy
	ProjectID  string
	TaskID     string
	ExecutorID string
	Actor      string
	RunID      string
	GrantID    string
	LeaseID    string
	IssuedAt   time.Time
	ExpiresAt  time.Time
	// RenewedAt is when Renew last moved ExpiresAt, zero for a session never
	// renewed: what a restore bounds the recorded deadline from (Task 20422).
	RenewedAt time.Time
}

SessionRecord is what a Store keeps of a session: everything Restore needs except the upstream credential, which whoever restores re-derives from the grant the session stands on.

type SessionStore added in v0.0.5

type SessionStore interface {
	// SaveSession records a session this process now serves.
	SaveSession(rec SessionRecord) error
	// CloseSession records that a session this process served has ended,
	// and why. A session another process took over is not this process's to
	// close; an implementation fences the write on its own holder.
	CloseSession(id, reason string, at time.Time) error
	// RenewSession records a session's deadline moved to expiresAt by a
	// renewal at renewedAt, fenced on the holder as CloseSession is.
	RenewSession(id string, expiresAt, renewedAt time.Time) error
}

SessionStore persists the record of every Durable session and its end.

Errors are the implementation's to report: the registry cannot do anything useful with one beyond what it does — a session whose record could not be written is served all the same, and noted on its minted row.

type Stats

type Stats struct {
	Pushes    int64 `json:"pushes"`
	Fetches   int64 `json:"fetches"`
	Denied    int64 `json:"denied"`
	BytesUp   int64 `json:"bytes_up"`
	BytesDown int64 `json:"bytes_down"`
}

Stats is a snapshot of one session's traffic, for the UI and audit rows.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL