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
- type ActivityRecorder
- type AuditWriter
- type Authenticator
- type Cache
- type Counters
- type Metrics
- type PortListeners
- type Proxy
- type Resolver
- type RuntimeUpstreams
- type SessionResolver
- type StateResolver
- func (r *StateResolver) ByHostname(ctx context.Context, hostname string) (state.App, *spec.AppSpec, bool, error)
- func (r *StateResolver) ByPath(ctx context.Context, path string) (state.App, *spec.AppSpec, string, bool, error)
- func (r *StateResolver) ByPort(ctx context.Context, port int) (state.App, *spec.AppSpec, bool, error)
- func (r *StateResolver) BySlug(ctx context.Context, slug string) (state.App, *spec.AppSpec, bool, error)
- func (r *StateResolver) IsAppHostname(ctx context.Context, hostname string) (bool, error)
- type Upstreams
- type UsePolicy
Constants ¶
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.
const AnonymousUsePerMinute = 120
AnonymousUsePerMinute caps new anonymous visits recorded per app per minute.
const CacheChannel = "pando_proxy_cache"
CacheChannel is the channel migration 68's triggers notify on.
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.
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.
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.
const UseWindow = 12 * time.Hour
UseWindow is how long a visit is remembered once recorded.
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 ¶
AuditWriter is where the proxy records what happened. *audit.Writer is one.
type Authenticator ¶
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.
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
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.
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 a path-mode app from the first path segment. An app
// addressed by hostname or port is never found this way (R-166).
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).
type SessionResolver ¶
type SessionResolver interface {
SessionID(r *http.Request) (string, error)
SessionPrincipal(ctx context.Context, cookie 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 a path-mode app by its slug. An app on its own hostname or port is not found this way (R-166, state.Apps.ByRouting).
func (*StateResolver) IsAppHostname ¶
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.