Proxy configuration¶
The flat configuration hub remains the compatibility route. This page is the canonical source-checked field reference for its family, including nested tables and list rows.
Paths in the tables are relative to [proxy] and its nested headings. Each row
gives the serde type and source default or unset meaning. Check parsing and
listener/admission cross-field rules with gdsgate doctor --config <PATH>
--json, then follow the network zoning guide.
[proxy]¶
Used by: Proxy.
Listener mode, admission control, and what the client-facing listener presents.
Every admission limit defaults to 0, which means unlimited; set the ones you
need. The join_*, public_join_*, public_renewal_max_connections and
public_admin_max_connections keys are the exception: each carries a built-in
figure, explained under the table.
| Key | Type | Default | Purpose |
|---|---|---|---|
single_port |
string | unset | Multiplex the client gRPC API and the connector WebSocket fallback onto one listen address. Each connection is peeked (TLS ClientHello or HTTP upgrade) and routed accordingly; the mutual-TLS reverse tunnel stays on proxy_internal, and the join relay, when configured, keeps its own proxy_join address. Use it when egress only allows HTTPS on :443. |
max_sessions |
usize | 0 |
Cap on concurrent relayed client sessions. A session over the cap is refused cleanly at open. Bounds backend connections, relay tasks, and file descriptors. |
max_streams_per_tunnel |
usize | 0 |
Cap on concurrent backend streams a single connector tunnel may hold. Bounds per-connector fan-out and the blast radius of one noisy connector. The control-plane streams of a tunnelled connector (authority_transport = "tunneled") have a small ceiling of their own and do not spend this budget, so data-plane load can never leave a connector unable to reach Authority. |
max_public_connections |
usize | 0 |
Cap on concurrent connections to the public client-facing listener. A connection over the cap is shed at accept, bounding pre-auth pile-up. Does not affect connector-facing listeners. |
internal_accept_rate_per_ip |
u32 | 0 |
Per-source-IP accept rate (tokens/second) on the connector-facing internal and WebSocket listeners. Over-budget source IPs are dropped at accept. Paired with internal_accept_burst. |
internal_accept_burst |
u32 | 0 |
Token-bucket burst size for internal_accept_rate_per_ip. Either value being 0 disables the limiter. |
public_anonymous_rate_per_ip |
u32 | 0 |
Per-source-IP call rate (calls/second) for the two RPCs on the public listener that answer before the caller has been authenticated: the settings bundle gdsgate connect fetches, and the liveness-and-version probe. Over-budget calls are refused (RESOURCE_EXHAUSTED); the authenticated RPCs beside them are never charged. Paired with public_anonymous_burst. |
public_anonymous_burst |
u32 | 0 |
Token-bucket burst size for public_anonymous_rate_per_ip. Either value being 0 disables the limiter. One gdsgate connect spends two bundle calls, so a burst of 1 refuses first contact rather than pacing it. |
max_bytes_per_second_per_session |
u64 | 0 |
Sustained bytes/second a single session may push, combined across both directions. Over-budget traffic is paced rather than dropped. Paired with session_rate_burst_bytes. |
session_rate_burst_bytes |
u64 | 0 |
Token-bucket burst (bytes) for max_bytes_per_second_per_session. Set it to roughly one second of expected traffic so normal bursts are not throttled. Either value being 0 disables the throttle. |
public_origin |
string | unset | The gateway's public browser origin (for example https://gate.example.com), the base of the WebAuthn step-up confirmation URL. Should match an entry in [mfa.webauthn].origins. |
web_addr |
string | unset | Listen address for the browser-facing WebAuthn web-origin (HTTPS). Set with web_cert_file and web_key_file; unset, the web-origin is off. |
web_cert_file |
string | unset | PEM certificate chain for the web-origin's TLS. Must be browser-trusted (a public or corporate CA), separate from the internal mTLS CA. gdsgate obtains nothing: whatever writes the pair replaces it on renewal, and the listener re-reads the files while it runs, so a renewal is not a restart. |
web_key_file |
string | unset | PEM private key for web_cert_file. Naming one half of the pair and not the other is refused at start-up. |
webapps_zone |
string | unset | Zone the clientless browser entry publishes applications under, as <application>.<webapps_zone>, with the label gdsgate reserved inside it for signing in. Unset, the entry is off, and the web lane is reached only the three ways that need gdsgate on the device. Naming some but not all four of this key and the three below is refused at start-up. See The clientless browser entry. |
webapps_addr |
string | unset | Listen address for the clientless browser entry (HTTPS). |
webapps_cert_file |
string | unset | PEM certificate chain the clientless browser entry presents: a browser-trusted wildcard for *.<webapps_zone>, separate from web_cert_file because it is a different name. That one is this gateway's own origin; this one is the zone the applications live in. gdsgate obtains nothing: whatever writes the pair replaces it on renewal, and the listener re-reads the files while it runs. |
webapps_key_file |
string | unset | PEM private key for webapps_cert_file. |
webapps_session_ttl_secs |
u64 | 28800 (8h) |
How long a browser session lasts, and with it how long the ticket minted for it is valid, and the longest a connection already relaying keeps relaying after the person signs out or the session expires on an instance that never saw it. Authority clamps it to a ceiling of its own (12h); a figure above that is silently the ceiling. It is not how long access survives a policy change: every request inside the session is decided again at the connector. |
webapps_proxy_protocol_from |
array | [] |
Addresses the clientless browser entry accepts a PROXY protocol header from, being the front the entry is published through, as single addresses or blocks (["10.4.0.0/24", "2001:db8::7"]). Empty, the header is never read and a browser's address is the address of the socket, which is the truth for a listener a browser reaches directly and a falsehood for one behind an SNI router, a load balancer or an nginx. Set, and the entry serves exactly one shape of connection: from a listed address, carrying a header. See Behind a front. |
public_proxy_protocol_from |
array | [] |
Addresses the public, client-facing listener accepts a PROXY protocol header from, being the front it is published through, as single addresses or blocks (["10.4.0.0/24", "2001:db8::7"]). The same setting as webapps_proxy_protocol_from for the other listener, and read apart from it, since one may be behind a front while the other is not. Empty, the header is never read and a caller's address is the address of the socket, which is the truth for a client that dials the gateway directly. Behind a front it is that front's address for every client at once, which decides context.src_ip, the src_ip of the audit row, and the pin a resource labelled pin_src_ip = "true" carries. Set, and the listener serves exactly one shape of connection: from a listed address, carrying a header. Covers single_port too. See Behind a front. |
public_cert_file |
string | unset | PEM certificate chain the client-facing listener presents, for a gateway clients reach by its own domain name. With it set, a client needs no trust anchor of the cluster's, since its own trust store already holds the authority that signed this, and gdsgate connect writes a configuration without one. gdsgate obtains nothing: an ACME client or a corporate issuer writes the pair and replaces it on renewal, and the listener re-reads the files while it runs, so a renewal is not a restart. Unset, the listener presents the node's enrolled transport identity, which is what an on-premise cluster distributing its own anchor wants. Set with public_key_file; naming one half is refused at start-up. |
public_key_file |
string | unset | PEM private key for public_cert_file. |
public_join |
bool | false |
Carry a node's join on the public, client-facing listener as well, so a node needs one address for its whole life. See the note below. |
public_join_rate_per_ip |
u32 | built-in 5 |
Per-source-IP call rate (calls/second) for the registration served under public_join. Its own bucket, so a burst of registrations cannot refuse the bundle or probe calls beside it. Paired with public_join_burst. |
public_join_burst |
u32 | built-in 10 |
Token-bucket burst for public_join_rate_per_ip. Either being 0 disables the limiter. |
public_renewal_max_connections |
usize | built-in 64 |
Ceiling on concurrent node renewals relayed off the public listener, the fleet's lane of the relayed branch. Raise it for a rollout that renews more nodes at once. |
public_admin_max_connections |
usize | built-in 8 |
Ceiling on concurrent administrative connections relayed off the same listener, told apart by the server name. Its own lane, so the two cannot spend each other's slots. See the two lanes. |
join_max_connections |
usize | built-in 64 |
Ceiling on concurrent relayed joins on the join relay. Raise it for a rollout that brings up more connectors at once than the built-in figure admits. |
join_accept_rate_per_ip |
u32 | built-in 5 |
Per-source-IP accept rate (tokens/second) on the join relay. Paired with join_accept_burst. |
join_accept_burst |
u32 | built-in 10 |
Token-bucket burst for join_accept_rate_per_ip. Either being 0 disables the limiter. |
join_peek_timeout_secs |
u64 | built-in 5 |
How long a connection to the join relay may stay silent before it is classified. |
join_idle_timeout_secs |
u64 | built-in 30 |
Idle ceiling on an established relay. |
web_key_file, webapps_key_file, and public_key_file each accept the
secret-reference notation in place of a bare
path. A helper or variable named there is re-run on each certificate rotation,
not only at start-up.
public_anonymous_rate_per_ip is counted per call
Everything else in this table counts sockets, sessions or bytes. Sockets are
the wrong unit here: HTTP/2 multiplexes, so a single connection admitted by
max_public_connections carries as many bundle calls as the peer cares to
send, and a bundle is an amplifier, an empty request answered with a
certificate.
It is charged on the two RPCs that answer before there is a caller to know,
and on nothing else. The client's working path, requesting access and
opening a session, is never charged, so an anonymous flood cannot throttle
real work. The two hold separate buckets under the one pair of keys, because
a flood of the bundle must not spend what the next gdsgate ssh needs from
the probe.
Size it for your client population, not for a flood. The probe opens every
gdsgate login, gdsgate ssh and gdsgate db login, so the figure has to
clear what one source address runs, and an office behind one NAT address, or
this listener behind an L4 front that does not preserve the client address,
is one source address for the whole population. Anything outside gdsgate
that polls the bundle in a loop spends from its own address's budget, so
count it in too. Refusals are counted in
gdsgate_public_anonymous_refused_total and not logged per call: a log line
per refused call would be an amplifier of its own.
Defaults for the join_* and public_join_* keys
Everywhere else in [proxy], a key left out means unlimited. In these it
means the built-in figure, because what they guard answers before any
authentication. A stated 0 is unlimited, exactly as it is above, so a site
that means "no ceiling" has to write it.
public_join: the join on the client-facing port¶
A node's one address can be the gateway's own. With public_join = true the
client-facing listener carries both halves of a join as well, and a node that was
given https://proxy.<domain> and a token needs nothing else for the rest of its
life: no second port, and no address derived from a convention only an installer
knows.
The two halves are served differently:
| Half | On the public port | Destination |
|---|---|---|
| registration | terminated here and forwarded as a call | [endpoints].authority_enroll |
| token-less renewal | not terminated; relayed byte for byte | [endpoints].authority |
Renewal presents the node's own certificate and Authority authenticates that certificate, resolving the node's role and registry row from the verified leaf, so a gateway that terminated it would hand Authority its own identity and the node would be refused. Registration presents no certificate at all: the one-time token in the request is the whole proof, so terminating takes nothing from it.
They are told apart by the TLS server name. A renewal asks for the internal
transport name (gdsgate.internal), which only this cluster's own certificates
carry; a client, and a registering node, which dials the same URL a client does,
asks for the gateway's public name. A connection that names nothing is a client
dialling by address and is served as one. Nothing a caller sends chooses a
destination: both addresses come from the Proxy's own configuration.
This does not move the join relay's per-source-IP accept rate. On a port that
multiplexes, an accept rate bounds nothing, since one connection carries any
number of calls, and on a port a whole office reaches through one NAT address it
would be a limit on the office. It becomes the call budget
public_join_rate_per_ip instead, in a bucket of its own; the connection ceiling
(max_public_connections) keeps covering a relayed renewal for as long
as it is open.
Relayed lanes and their ceilings¶
The relayed half carries more than renewal. The names it takes off the port are
the whole internal family, and one of them, auth.gdsgate.internal, is what a
caller holding a control-plane certificate asks for when it means to reach
Authority's administrative surface instead of renewing. Both are the same
unterminated branch and neither may be terminated, so they are one listener; they
are two different populations, so they are two lanes with a ceiling each:
| Lane | Server name | Ceiling |
|---|---|---|
| a node renewing | gdsgate.internal (and the rest of the family) |
public_renewal_max_connections |
| a control-plane certificate administering | auth.gdsgate.internal |
public_admin_max_connections |
A fleet rolling out cannot refuse an operator, and an operator's tooling cannot
refuse a node whose identity is expiring. Both sit under
max_public_connections, which counts every connection this listener accepts, so
size that above the two. The figures are for two different populations: renewal
is one call a day per node plus retries, while administration is serial per
caller, one connection per command.
The lane grants nothing. Authority completes the handshake itself, so a certificate it did not issue is refused there, and one it did issue is gated on the role in its subject: a connector's certificate reaches the administrative surface and is refused by it. What the lane decides is which ceiling a connection is counted against.
Reaching Authority this way is what [admin].transport = "fronted" is
for, and it has one cost: the address in an administrative audit row is the
gateway's rather than the operator's. The gateway relays opaque bytes and never
sees the request, so there is nothing for it to annotate, which means a policy
rule that admits administration only from a corporate network does not work on
this path. Administration on a direct dial to Authority is unaffected.
Off by default. It is a surface that answers before the caller has been
authenticated, and an upgrade must not hand one to a gateway whose operator did
not ask for it; proxy_join remains the way to put the same two
destinations on an address of their own. The two can run side by side.
On the tunnelled route this is also what Authority advertises as the renewal address, so it belongs in Authority's configuration too; see what Authority tells a joining node.
[proxy]
# Share :443 with the connector WebSocket fallback (corporate egress).
single_port = "0.0.0.0:443"
# Browser WebAuthn web-origin: the passkey ceremony runs against the gateway
# domain over a browser-trusted cert (not the internal mTLS CA).
public_origin = "https://gate.example.com"
web_addr = "0.0.0.0:8443"
web_cert_file = "/etc/gdsgate/web-cert.pem"
web_key_file = "/etc/gdsgate/web-key.pem"
# Admission control (all default to 0 = unlimited).
max_sessions = 2000
max_streams_per_tunnel = 256
max_public_connections = 5000
internal_accept_rate_per_ip = 20
internal_accept_burst = 40
# What one source address may ask of the two unauthenticated RPCs. Ten calls a
# second is far above what a client population runs and far below what a sweep
# wants; the burst of 30 absorbs a room starting up at once.
public_anonymous_rate_per_ip = 10
public_anonymous_burst = 30
# A publicly trusted certificate for the client-facing listener, renewed by
# something outside gdsgate. Clients then need no anchor of the cluster's.
public_cert_file = "/etc/gdsgate/public-fullchain.pem"
public_key_file = "/etc/gdsgate/public-key.pem"
# Join relay: a rollout larger than the built-in 64 concurrent joins.
join_max_connections = 512
join_accept_rate_per_ip = 50
join_accept_burst = 100
# Carry a node's join on the client-facing port as well, so a node needs one
# address for its whole life. Off unless stated; the figures are the built-in
# ones, shown here for visibility.
public_join = true
public_join_rate_per_ip = 5
public_join_burst = 10
# The two lanes of the relayed half, sized for two different populations.
public_renewal_max_connections = 64
public_admin_max_connections = 8
Three certificates the Proxy presents outward, public_cert_file,
web_cert_file and webapps_cert_file, are issued and renewed by something
other than gdsgate, and every one of their listeners re-reads its files while the
process runs. A renewal is a file replacement; the next connection is handed the
new certificate and the ones already established are not disturbed. That matters
most here, because these listeners share their process with the data plane, and
restarting the Proxy to pick up a certificate would end every session it carries.
See
Renewing a listener certificate
for what happens to a pair caught half-written, and for the SIGHUP that makes a
renewal visible immediately instead of within the second.
web_addr also serves the key set that verifies the identity assertions the web
lane hands applications: GET /.well-known/gdsgate/jwks.json on this gateway's
own origin, not the customary bare /.well-known/jwks.json, since other
things live under .well-known on this origin too. It renders the same trust
bundle Authority keeps for CA rotation, filtered to the JwtSigning kind, with
Cache-Control: public, max-age=300, one figure that is both how long the Proxy
holds its own copy and how long an application is told it may hold its. See
Telling the application who came.
The clientless browser entry¶
A person on a device with no gdsgate on it opens
https://<application>.<webapps_zone>, signs in at the identity provider, and
reaches the application. It is the one way into the web lane that needs nothing
installed; the other three (a local listener, an address inside a delegated
program's cage, a name the resident client publishes) all need it.
webapps_zone, webapps_addr, webapps_cert_file and webapps_key_file have
to be set together or not at all: a zone with no listener answers nothing, and a
listener with no zone does not know which application a name means.
One certificate, one listener, one reserved name. Every application is published
on its own subdomain of the zone (grafana.<webapps_zone>). The certificate is
an ordinary public wildcard, and a wildcard covers one label, so
grafana.team.<webapps_zone> is not served. The label gdsgate inside the same
zone is reserved for signing in: the identity provider is registered with one
exact redirect URI, which a wildcard cannot be, and putting sign-in on this
gateway's own public origin instead would give the sign-in cookie a Domain
covering every application behind it instead of this one zone. An application
catalogued under the reserved name is unreachable through this entry; the surface
says so once, in the log; it does not serve a page that resolves, answers, and is
the wrong one.
A browser is admitted over a certificate, as every other client is. There is no
key on a borrowed device to bind one to, so Authority signs a ticket instead of
the usual access certificate: the same connect decision, over the same
WebApp, with the certificate's own fields put to a browser's use. The
public-key field carries a SHA-256 digest of the session cookie rather than a
key; pinned_ip is always the observed address, since a cookie is portable and
the address is the other half of what proves this browser is the one it was
issued to; and its window is the browser session's own,
webapps_session_ttl_secs clamped to Authority's ceiling of 12 hours. The
connector checks a ticket exactly as it checks every other certificate, with the
same trust root, scope and pin, and knows nothing about browsers to do so. A
ticket copied off this gateway is useless anywhere else: redeeming one means
signing a nonce with the key named in it, and no key was ever named. Authority
accepts a ticket request only over the Proxy's own mTLS identity.
Behind a front, the address has to be handed over. The address the entry observes
on a connection is used three times: it binds the browser session, it is what the
ticket above is pinned to, and it is context.src_ip in the decision. Published
through an SNI router, a load balancer or an nginx, that address is the front's,
which makes the pin bind every ticket to one address, so a session cookie lifted
off one machine works from another, and makes context.src_ip.isInRange(...) a
statement about the front. webapps_proxy_protocol_from names the addresses a
PROXY protocol header (versions 1 and 2) is accepted from; the front has to send
one (proxy_protocol on in an nginx stream block). It is a list rather than a
switch, because the header carries no signature: it is true because of who sent
it. With it set, this listener admits one shape of connection and closes the rest
without answering. A header from an address not on the list is somebody choosing
their own source address, and a connection with no header is a front that was
half configured, which would otherwise be indistinguishable from a working
deployment until somebody read a pinned address. Empty is the default and means
the header is never read.
The identity token is spent once and kept nowhere. The id_token a sign-in
exchanges at the provider is used to ask for one ticket and then dropped, so this
listener never holds a reusable identity for anybody signed in, only a set of
narrow, pinned, expiring tickets. The visible cost is that a second application,
inside the same browser session, is a second trip through the identity provider:
a pair of redirects where the provider's own session is still live, an ordinary
sign-in where it is not.
The session lives in this process's memory and nowhere else. The cookie
(Secure, HttpOnly, SameSite=Lax, scoped to webapps_zone) is an opaque
handle; who is signed in, since when, which applications, and until when are this
process's own state, never the cookie's, so a session this process does not hold
is a session that does not exist. Sign-out, posted from the surface's own page
with the surface's own token, removes the entry and cuts every relay it opened;
expiry ends a session at the ticket's not_after, the same certificate boundary
every other lane enforces, and a relay already open is cut there too. Neither is
a promise about the next instant policy makes on its own: every request inside a
session is decided again at the connector.
Limitations of this surface:
- A browser session is not in
ListSessions/KillSession. The kill registry is built inside the gRPC service this listener sits in front of, and does not see it. - The session table is in-memory and per-instance. A Proxy restart signs every browser out, and behind more than one Proxy instance a session answers only on the instance that opened it, since the ticket is pinned to the browser's own address rather than to an instance.
single_portis refused at start-up together with this entry. The shared port is multiplexed by peeking the TLSClientHello; this listener's handshake ends in a certificate for the application zone's own wildcard, and there is nothing left to peek at that would tell the two apart.