Help us shape the future of Go! Take the 2026 Go Developer Survey

proxy

package
v0.13.0 Latest Latest
Warning

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

Go to latest
Published: Oct 21, 2026 License: AGPL-3.0 Imports: 30 Imported by: 0

Documentation

Overview

Package proxy is the identity-aware reverse proxy: the single enforcement point for every request to every app (R-023).

There is no bypass — not for public apps, not for performance, not for websockets. Inbound X-Pando-* headers are stripped unconditionally before assertion headers are set; without that, a client sets X-Pando-User and any app trusting the convenience headers is trivially spoofed (R-053). Read the package CLAUDE.md before changing anything here.

Index

Constants

View Source
const (
	HeaderUser   = "X-Pando-User"
	HeaderEmail  = "X-Pando-Email"
	HeaderGroups = "X-Pando-Groups"
)

Convenience headers, sent alongside the assertion and documented as UNVERIFIED (R-053). An app that trusts them is trusting the network boundary, which is a legitimate choice a developer must know they are making.

View Source
const AnonymousUsePerMinute = 120

AnonymousUsePerMinute caps new anonymous visits recorded per app per minute.

View Source
const CacheChannel = "pando_proxy_cache"

CacheChannel is the channel migration 68's triggers notify on.

View Source
const CookiePrefix = "pando_"

CookiePrefix is Pando's cookie namespace.

Every cookie starting with this is removed from the request before it is forwarded to an app (see stripCookies), and so is one starting with it after a browser's __Host- or __Secure- prefix: the session cookie is __Host-pando_session on an HTTPS install. A prefix rather than one name, for the same reason HeaderPrefix is: the rule has to cover the cookie nobody has added yet.

View Source
const HeaderPrefix = "X-Pando-"

HeaderPrefix is Pando's header namespace.

Every inbound header starting with this is stripped unconditionally before anything is set (R-053). See stripInbound — that loop is the single most security-critical piece of code in the system.

View Source
const PasscodeCookiePrefix = CookiePrefix + "pass_"

PasscodeCookiePrefix names the cookie that proves a visitor entered an app's passcode (R-075a): PasscodeCookiePrefix + the app's ID. In Pando's namespace, so stripCookies removes it before anything reaches the app — it is a credential for that app, and the app is not entitled to hold it.

View Source
const UseWindow = 12 * time.Hour

UseWindow is how long a visit is remembered once recorded.

View Source
const VisitCookie = CookiePrefix + "visit_"

VisitCookie names the cookie that marks a browser's visit to an app: VisitCookie + the app's ID. In Pando's namespace, so an app never sees it.

Variables

This section is empty.

Functions

This section is empty.

Types

type ActivityRecorder

type ActivityRecorder interface {
	Touch(appID string)
}

ActivityRecorder hears that a request was let through to an app, which keeps the app from being stopped or deleted for being idle (R-394). *idle.Recorder is one: in memory, written to the database once a minute, so the request never waits for it.

type AuditWriter

type AuditWriter interface {
	Write(ctx context.Context, e audit.Event) error
}

AuditWriter is where the proxy records what happened. *audit.Writer is one.

type Authenticator

type Authenticator interface {
	Authenticate(r *http.Request) (authz.Principal, error)
}

Authenticator resolves a request's credentials to a principal.

type Cache

type Cache struct {
	// TTL is the longest an entry lives while the cache is listening. Thirty
	// seconds when zero, a quarter of the revocation window, which is the
	// most a lost notification can add to it.
	TTL time.Duration

	// Limit is how many entries of each kind are kept; past it, that kind is
	// emptied. 4096 when zero. A hostname or cookie is whatever a client sends,
	// and must not be a way to grow this without bound.
	Limit int
	// contains filtered or unexported fields
}

Cache keeps, for a few seconds, what a proxied request reads from the state store: who a session cookie belongs to, which app a hostname, slug or port names, and the facts CheckData decides on (issue #93). Every request to every app reads these, a page is dozens of requests, and they rarely change.

Rarely, but at once when they do: a revoked session or grant must stop working now, not when an entry ages out (design 06 §3.1). So the cache is emptied on every replica whenever anything it may hold changes, by a NOTIFY the database sends from triggers on the tables involved (migration 68), and it holds nothing at all while it is not listening for those — a copy kept only because nothing said it changed is good only while something could have. TTL bounds an entry anyway, for a notification lost in flight.

What it does not keep: a verdict. CheckData still runs on every request, over cached facts, so every denial is audited as it happens (design 06 §6). Nor a bearer token, nor a session that did not resolve: those are read every time.

func (*Cache) Clear

func (c *Cache) Clear()

Clear forgets everything.

func (*Cache) Listen

func (c *Cache) Listen(ctx context.Context, db *state.DB)

Listen keeps the cache in step with the database until ctx ends: every notification empties it, and while the connection is down it keeps nothing.

func (*Cache) Store

func (c *Cache) Store(s authz.Store) authz.Store

Store wraps an authorization store so the DataFacts the proxy's authorizer reads are kept. Everything else goes to the store underneath, and a store that cannot read DataFacts in one query is returned as it is.

type Counters

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

Counters is an in-memory request count, per app.

Its purpose is evidential: R-023 says there is no bypass, and a counter that every request increments — allowed or denied, authenticated or anonymous — is how that claim is checked rather than asserted.

func NewCounters

func NewCounters() *Counters

func (*Counters) Request

func (c *Counters) Request(appID string, kind authz.PrincipalKind, allowed bool)

func (*Counters) Snapshot

func (c *Counters) Snapshot(appID string) (total, allowed, denied, anonymous int64)

Snapshot returns the counts for one app.

type Metrics

type Metrics interface {
	Request(appID string, kind authz.PrincipalKind, allowed bool)
}

Metrics counts what passed through, so "there is no bypass" is observable rather than merely intended.

type PortListeners

type PortListeners struct {
	// Ports reports which host ports are currently allocated to apps.
	Ports interface {
		InUse(ctx context.Context) ([]int, error)
	}

	// Handler is the proxy. Taken as an interface so a test can assert on what
	// reached it without standing up the whole request path.
	Handler http.Handler

	// Interval is how often the set of listeners is reconciled against the
	// allocations. Allocation happens during detection, which is neither
	// frequent nor latency-sensitive.
	Interval time.Duration

	Logger *zap.Logger
	// contains filtered or unexported fields
}

PortListeners serves each port-mode app at the root of its own host port.

R-166 prefers subdomain and falls back to path, and the loopback adapter that ships as the laptop default supports neither — it is port mode only, which is why a port is allocated at all (design 03 §4.2, O-15). The allocation was being made and written into the spec, and the console was showing the address it produced, but nothing ever listened on it: every laptop install told its users about an address that refused the connection, and the apps were in fact reachable only under the path prefix.

That is not a cosmetic gap. Under a path prefix an app that writes absolute addresses into its own HTML — which is every frontend built with a default configuration — comes up blank, and Pando will not rewrite the response to paper over it (R-167, R-028). Port mode is the answer to exactly that, and it only exists if something accepts connections.

Each listener is an ordinary http.Server whose handler is the same Proxy as every other request path. There is no second enforcement point and no fast path: the only thing the listener contributes is the port the request arrived on, which resolve() turns into an app (R-023).

func (*PortListeners) Run

func (l *PortListeners) Run(ctx context.Context)

Run reconciles listeners until ctx is canceled, then closes all of them.

type Proxy

type Proxy struct {
	Resolver      Resolver
	Authenticator Authenticator

	// Cache, when set, keeps what a request reads about its session for a
	// few seconds, emptied on every change (cache.go, issue #93). Give the
	// resolver the same one, and build Authz over Cache.Store.
	Cache     *Cache
	Authz     *authz.Authorizer
	Minter    *assertion.Minter
	Upstreams Upstreams
	Auditor   AuditWriter
	Metrics   Metrics

	// Activity, when set, hears of every request let through (R-394).
	Activity ActivityRecorder
	Logger   *zap.Logger

	// UsePolicy says whether anonymous use is recorded (R-227). Nil records
	// it, which is the default.
	UsePolicy UsePolicy

	// ExternalURL is how a browser reaches this installation, which decides
	// whether the visit cookie is marked Secure — the same rule as the session
	// cookie (O-19). Nil falls back to whether the request arrived over TLS.
	ExternalURL *url.URL

	// LoginPath is where an unauthenticated caller is sent.
	//
	// It has to be a path Pando answers on *every* hostname, not only its own.
	// On an app's own hostname the console does not serve "/login" — the proxy
	// does, because every path there belongs to the app — so sending somebody
	// to "/login" there redirected them to the page that had just redirected
	// them, forever. See httpapi.LoginPath.
	LoginPath string

	// Mode is the install's default shape, for building URLs to show people
	// (design 03 §4.1). It is NOT how a request is resolved: resolution tries
	// both, because an install can mix the two and a request does not care what
	// the default was.
	Mode spec.RoutingMode
	// contains filtered or unexported fields
}

Proxy is the single enforcement point for every request to every app (R-023).

There is no bypass — not for public apps, not for performance, not for websockets. If you are adding a fast path, you are adding a security hole.

func (*Proxy) ServeHTTP

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

ServeHTTP runs the request path from design 06 §4.

type Resolver

type Resolver interface {
	// ByHostname resolves an app from the Host header.
	ByHostname(ctx context.Context, hostname string) (state.App, *spec.AppSpec, bool, error)
	// ByPath resolves the path-mode app whose path is the longest whole-segment
	// prefix of the request's path, and returns that path to strip (R-167).
	ByPath(ctx context.Context, path string) (state.App, *spec.AppSpec, string, bool, error)
	// BySlug resolves an app from the first path segment, for proxy mode.
	BySlug(ctx context.Context, slug string) (state.App, *spec.AppSpec, bool, error)
	// ByPort resolves an app from the port the request arrived on, for port
	// mode (design 03 §4.2).
	ByPort(ctx context.Context, port int) (state.App, *spec.AppSpec, bool, error)
}

Resolver finds which app a request is for.

type RuntimeUpstreams

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

RuntimeUpstreams asks the runtime adapter where an app's primary workload is.

The address comes from the adapter rather than being assembled here, because how a workload is addressed is exactly the provider vocabulary core must not learn (R-251).

func NewRuntimeUpstreams

func NewRuntimeUpstreams(registry *api.Registry) *RuntimeUpstreams

func (*RuntimeUpstreams) Primary

func (u *RuntimeUpstreams) Primary(ctx context.Context, app state.App, s *spec.AppSpec) (api.Upstream, error)

Primary returns how to reach the app's primary workload: its URL, and on a runtime whose workloads are on another host, the Dial that reaches it through that host's agent (O-45).

func (*RuntimeUpstreams) PrimaryAddress

func (u *RuntimeUpstreams) PrimaryAddress(ctx context.Context, app state.App, s *spec.AppSpec) (string, error)

PrimaryAddress returns the URL of the app's primary workload.

type SessionResolver

type SessionResolver interface {
	SessionID(r *http.Request) (string, error)
	SessionPrincipal(ctx context.Context, sessionID string) (authz.Principal, time.Time, error)
}

SessionResolver is an Authenticator that can resolve a session cookie on its own and say when the session ends, which is what lets the cache keep it. SessionID reads the cookie the same way the Authenticator does, so the cache cannot key on a cookie the Authenticator would have refused or ignored.

type StateResolver

type StateResolver struct {

	// Cache, when set, keeps lookups by hostname, slug and port (issue #93).
	// A path lookup is not kept: its key would be every path anyone asks
	// for, and it is one query (state.Apps.ByPath).
	Cache *Cache
	// contains filtered or unexported fields
}

StateResolver resolves apps from the state store.

func NewStateResolver

func NewStateResolver(apps *state.Apps) *StateResolver

func (*StateResolver) ByHostname

func (r *StateResolver) ByHostname(ctx context.Context, hostname string) (state.App, *spec.AppSpec, bool, error)

ByHostname resolves an app by the hostname in its pinned spec.

func (*StateResolver) ByPath

func (r *StateResolver) ByPath(ctx context.Context, path string) (state.App, *spec.AppSpec, string, bool, error)

ByPath resolves a path-mode app by the longest path it holds that the request's path begins with.

func (*StateResolver) ByPort

func (r *StateResolver) ByPort(ctx context.Context, port int) (state.App, *spec.AppSpec, bool, error)

ByPort resolves an app by the host port it was given, for port mode.

func (*StateResolver) BySlug

func (r *StateResolver) BySlug(ctx context.Context, slug string) (state.App, *spec.AppSpec, bool, error)

BySlug resolves an app by its slug, for path mode.

func (*StateResolver) IsAppHostname

func (r *StateResolver) IsAppHostname(ctx context.Context, hostname string) (bool, error)

IsAppHostname reports whether a hostname belongs to an app.

For the HTTP router, which has to decide between the console and the proxy before either runs. Deliberately only a yes or no: the caller uses it to pick a handler, and the proxy still resolves and authorizes the request itself.

type Upstreams

type Upstreams interface {
	Primary(ctx context.Context, app state.App, s *spec.AppSpec) (api.Upstream, error)
}

Upstreams says how to reach an app's primary workload.

type UsePolicy

type UsePolicy interface {
	RecordsAnonymousUse(ctx context.Context) bool
}

UsePolicy says whether anonymous use is recorded. The policy evaluator is one; nil records it.

Jump to

Keyboard shortcuts

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