Connector configuration¶
Field reference for [connector] and its nested tables. The
configuration hub links every family.
Keys in the tables are relative to [connector], [[connector.backends]], or
the nested heading. Keep credentials and host-key material in protected files.
Check the file with gdsgate doctor --config <PATH> --json, then follow the
connector deployment guide.
[connector]¶
Used by: Connector.
What a connector serves. The fields outside [[connector.backends]] are
connector-wide; the backends list is per-resource.
| Key | Type | Default | Applies to | Purpose |
|---|---|---|---|---|
id |
string | "local" |
Connector | Identifier this connector registers under at the Proxy. Give each connector a distinct id; two connectors with one id compete for the same tunnel slot. |
proxy_connection_count |
usize | 1 |
Connector | Desired distinct Proxy connections, 1–16. Enrolled nodes verify Proxy identities and accept authenticated peer opens at every count. Set 2 or more to retain a tunnel when a Proxy fails; multiple tunnels require enrollment. |
backends |
array of tables | [] |
Connector | Static resources this connector serves. |
discovery_sources |
array of tables | [] |
Connector | Discovery-only cluster connections; their APIs are never registered or routed. See discovery sources. |
authority_transport |
enum | "direct" |
Connector | Route the connector's Authority control-plane calls take. See below. |
git.git_upload_pack |
string | "/usr/bin/git-upload-pack" |
Connector | Absolute path to the program that serves a fetch on a backend with ssh_profile = "git". |
git.git_receive_pack |
string | "/usr/bin/git-receive-pack" |
Connector | Absolute path to the program that serves a push. |
git.git_upload_archive |
string | "/usr/bin/git-upload-archive" |
Connector | Absolute path to the program that serves an archive. |
git.lfs_batch_body_max |
bytes | 0 (one mebibyte) |
Connector | The largest LFS Batch API body a web backend reads before deciding a request. The body is read, decided on, and forwarded from memory. A larger body is refused. Separate from a backend's max_request_bytes, which limits what passes through. |
git.git_lfs_authenticate |
string | unset | Connector | Absolute path to the handler for lfs-download and lfs-upload, usually the git server's own handler. Unset: the two LFS operations are refused on this node. |
The four paths are absolute; PATH is not searched. The connector checks them at
start-up and opens each one. An operation whose program is missing or not
executable is refused, and the log names it. A program replaced after start-up
is not picked up until the connector restarts.
A #! script works through its interpreter. The operation is refused when the
interpreter cannot run on this node or /proc is not mounted; the log names the
reason at start-up.
[connector.git]
git_upload_pack = "/usr/bin/git-upload-pack"
git_receive_pack = "/usr/bin/git-receive-pack"
git_upload_archive = "/usr/bin/git-upload-archive"
git_lfs_authenticate = ""
authority_transport picks the address the connector's Authority calls go to.
It changes only the connector's egress. Authority authenticates the connector by
its certificate in every mode; the reverse tunnel to the Proxy is unchanged.
In Authority's file the key sets the route the cluster's nodes use. Authority advertises it to every node it issues an identity to and does not use it.
| Value | Egress | Authority address |
|---|---|---|
direct |
two connections, one to the Proxy and one to Authority | endpoints.authority, or its default when unset |
fronted |
one address, shared with the Proxy | endpoints.authority when set, otherwise endpoints.proxy_internal verbatim |
tunneled |
the Proxy only | from the Proxy's configuration, not the connector's |
fronted is for Authority and the Proxy behind one network front: the connector
host needs one outbound address. The derived address is used as-is, port
included; the front delivers it to Authority.
gdsgate join --route fronted writes the route,
endpoints.authority and [enroll].renew_endpoint as the registration address.
tunneled removes the connector's Authority egress. Control-plane calls ride
the reverse tunnel; the Proxy copies the bytes without terminating TLS, and
Authority authenticates the connector's certificate. Registration and token-less
renewal happen before the tunnel exists and go to
[endpoints].proxy_join:
| Call | Address in tunneled |
Overridden by |
|---|---|---|
| registration (first start) | http://<endpoints.proxy_join> |
[enroll].endpoint |
| token-less renewal | https://<endpoints.proxy_join> |
[enroll].renew_endpoint |
| everything else | the reverse tunnel | unset |
An explicit [enroll] address wins over the derivation; a derived address is
logged. With [proxy].public_join, both addresses are the
Proxy's client-facing port, Authority advertises them, and
gdsgate join writes them into the file. With neither
proxy_join nor [enroll].endpoint, a tunnelled connector refuses to start.
In tunneled, Authority calls fail while the tunnel is down, and a failed call
is a denial. The control-plane stream is not counted against
[proxy].max_streams_per_tunnel; a connector at its stream cap
still reaches Authority.
In fronted the connector sends a role-specific TLS server name, for a front
that routes by server name: auth.gdsgate.internal on the Authority
control-plane channel, tunnel.gdsgate.internal on both legs of the reverse
tunnel (gRPC and the WebSocket fallback). direct and tunneled use the shared
name.
Four names exist in total:
| Name | Belongs to |
|---|---|
gdsgate.internal |
every certificate: the shared name, always present |
auth.gdsgate.internal |
the Authority control plane |
proxy.gdsgate.internal |
the Proxy's client-facing listener |
tunnel.gdsgate.internal |
the Proxy's reverse-tunnel listeners |
The front also needs a route for the shared name. Token-less renewal
([enroll].renew_endpoint) uses gdsgate.internal. A
front configured for the three role names alone passes registration and fails
renewal about a day later. Route gdsgate.internal to the Authority control
plane, next to auth.gdsgate.internal.
A certificate is reissued on enrollment or renewal, not on CA rotation. A node
serves its old certificate, with the shared name only, until its 24-hour identity
refreshes. Switch a client to a role name after the node it dials has renewed.
gdsgate doctor on the node reports when its transport identity expires.
GDSGATE_CONNECTOR_AUTH_TRANSPORT overrides the value; an unrecognised one is
ignored and leaves the configured mode in place.
Tunnelled connectors and Authority failover¶
With authority_failover configured, the Proxy picks
the Authority instance each time a relay stream opens: it asks the instances
which one leads and relays to that one.
During a failover:
- Leadership moves. The connector's stream still ends at the old leader.
- The connector's next call there is refused with "unavailable". The connector drops that stream and opens a new one for its next call.
- The Proxy asks the instances which one leads and relays the new stream to the new leader.
Calls on the old stream are refused. Audit shipping and recording upload retry with backoff; a session gate denies, as for any unreachable Authority. While no instance holds the lease, the Proxy asks again every 250 ms, doubling up to every 5 s, and is on the new leader within 5 s of the takeover. This needs no client traffic at the Proxy.
A connector on direct or fronted does not use the relay and is not affected.
[[connector.discovery_sources]]: discovery-only clusters¶
Use a discovery source when the Connector must find targets inside a cluster without offering access to its API. The source has no catalog resource and accepts no impersonation or session settings. Authority's auto scope admits the discovered targets; each target uses its own protocol backend and access checks.
A tunnelled Connector keeps its authenticated control relay while discovered targets are absent or withheld. Successful publication retries withheld routes, including after a catalog lease expires. This relay grants no resource access; Authority still validates the node, scope and each target.
| Key | Type | Default | Meaning |
|---|---|---|---|
id |
string | required | Stable local source name; 1–128 letters, digits, dots, underscores or hyphens. Unique among discovery sources. |
kind |
enum | required | "kubernetes". |
api_url |
string | required | Cluster API server base URL. |
ca_path |
optional string | unset | Public CA bundle verifying the API; unset uses system roots. |
token_path |
string | required | Service-account token file or secret reference, reread on each discovery pass. |
discovery |
table | required | The probe settings; mode must be "list_kube_targets", with a non-empty scope and label_selector. |
[[connector.discovery_sources]]
id = "discovery-cluster"
kind = "kubernetes"
api_url = "https://kubernetes.default.svc:6443"
ca_path = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token"
[connector.discovery_sources.discovery]
mode = "list_kube_targets"
scope = "kube-"
label_selector = "gdsgate.io/expose=true"
sources = ["service"]
interval_secs = 30
The ServiceAccount needs list access to the selected object kinds. It needs no
impersonation permission. Mount its projected token in the Connector pod;
automountServiceAccountToken: true supplies the standard paths above. Configure
Authority's auto scope for the enrolled Connector and the kube- prefix before
starting discovery. Several sources retain independent target sets and generations.
[[connector.backends]]: one resource per entry¶
cockroach kubernetes llm mcp mysql postgres rdp ssh tcp web web-egress
unset means the key is absent; it differs from an explicit empty list or
false. required means startup fails without the key for the named backend.
Each row says whether a non-default value on another backend is refused,
warned about, or ignored.
| Key | Type | Default / unset | Applies to | Purpose |
|---|---|---|---|---|
resource |
string | unset; required | every backend entry | The id clients request and the Proxy routes on. |
kind |
enum | unset; required | every backend entry | One of the ten connector backend kinds. The catalog resource vocabulary is narrower; see the discovery boundary. |
addr |
optional string | unset; required except SSH model A and MCP alternate forms | SSH model B, TCP, RDP, Postgres, Cockroach, MySQL, MCP socket, LLM | Backend host:port or LLM base URL. Required for the applicable backend kinds, except SSH model A and MCP forms using command or url. |
command |
array of strings | []; required when MCP has neither addr nor url |
MCP only | The MCP server to run as a child process, argv-style: the program by absolute path, then its arguments. Exactly one of addr, url, command is required; two or none is refused at startup. The connector speaks newline-delimited JSON-RPC over the child's stdin and stdout; every gate below applies. See Running the MCP server as a child process. |
command_env |
map of strings | {} |
MCP with command |
Environment variables the command server is given, on top of the small set every process needs (PATH, HOME, LANG, LC_ALL, LC_CTYPE, TZ, USER). The child does not inherit the connector's environment. Refused at startup without command. |
url |
optional string | unset; required for Web | MCP or Web | MCP: the remote server's Streamable HTTP endpoint; each message is an HTTP POST to this URL. Mutually exclusive with addr and command. See A remote MCP server over HTTP. Web: the application's base URL, required. Every request goes to it; the client's Host is neither routed on nor forwarded. No query or fragment in either form; the caller's request path is appended. |
ca_path |
optional string | unset; platform roots when optional | Kubernetes (required), LLM, MCP with url, Web |
PEM CA bundle verifying the upstream's serving certificate. Required for kubernetes; elsewhere unset means the platform roots. Refused on an mcp backend without url. |
allow |
array of strings | []; WebEgress requires nonempty |
WebEgress only | The host names a web-egress backend may reach; nothing else is reachable. Required; an empty list is refused at startup. A leading *. covers one label, as in a TLS wildcard: *.githubusercontent.com admits raw.githubusercontent.com, not githubusercontent.com or a.b.githubusercontent.com. A * elsewhere, a bare *, a URL or a host:port is refused at startup. See the web-egress tab below. |
inspect |
bool | false |
WebEgress only | false: a CONNECT is a tunnel, decided once on host name and port, carried unread, costing web:tunnel. true: TLS of the programs behind this egress ends at their own endpoint under a per-run root; each request is decided by method and path and costs web:read / web:write. See Inspecting egress traffic. |
routed_ranges |
array of strings | [] |
WebEgress only | Non-public address ranges this egress may dial. Default empty: only global unicast addresses outside the IANA special-purpose ranges are dialled; loopback, private ranges, carrier-grade NAT, 169.254.0.0/16, documentation and benchmarking space, fec0::/10, 2001:db8::/32 and unallocated space are refused in both families. An entry is address/prefix (10.0.0.0/8, fec0::/10) routed by this connector's network. Bits set below the prefix, or a range covering loopback, the unspecified address or multicast, is refused at startup. allow is checked first. See the web-egress tab below. |
forwarded_headers |
bool | false |
Web only | false: the application sees an ordinary client on its own domain (own Host, links, cookies). true: X-Forwarded-For / -Proto / -Host carry this connector's observations. Client-sent values of these headers are dropped in both modes. |
identity |
enum | none |
Web only | What a web application is told about the caller: none (default; the caller's Authorization passes unchanged), signed (a short-lived Gdsgate-Identity assertion signed by Authority, naming the caller, their groups and the resource), or headers (X-Remote-User / X-Remote-Groups, unsigned). signed and headers drop the caller's Authorization. See Forwarding caller identity. |
identity_headers_trust_the_network |
bool | false |
Web only | Required for identity = "headers": states that the application is reachable only through this gateway. The two headers are not verified. identity = "headers" without it, or this key without identity = "headers", refuses to start. |
allow_encoded_separator |
bool | false |
Web only | false: a path segment with a percent-encoded / is refused. true: such a segment is forwarded raw, for APIs that use it (GitLab's group%2Fproject). A decoded form with a dot segment (%2e%2e%2f) is refused in both modes. |
websocket |
bool | false |
Web only | Whether a WebSocket channel may be opened. true: the handshake is decided under httpRequest with context.upgrade set; the open channel is not decided again and runs until a peer closes it or the session certificate expires. |
max_request_bytes |
u64 (bytes) | 0; no ceiling |
Web and WebEgress | The largest request body this backend carries. 0 (default): no ceiling; bodies stream through. Set: a request with a declared length above it gets 413 before the far end is dialled; a chunked upload is cut off at the ceiling, and the far end receives a truncated request. Refused at startup on any other kind. |
routes |
array of tables | [] |
Web only | The named path families a request is matched into before it is decided; see [[connector.backends.routes]]. Empty means every request carries context.route == "", and only a rule written without naming a route can permit anything. |
audit |
optional enum | unset; derived sampled on Web and full on WebEgress |
Web and WebEgress | How much of this backend's traffic reaches the audit chain per request: sampled or full. Default sampled on web, full on web-egress. On web a route may override it for its own family. See The web lane. |
decision_cache_ttl_secs |
optional u64 (seconds) | unset; derived 5 for Web |
Web only | How long a per-request permit is reused before the gate is asked again. Unset: five seconds; 0: every request is decided. Refusals are not cached. Revoked access takes effect up to this many seconds late. Refused on web-egress, which caches nothing. |
credential_file |
optional string | unset | LLM; MCP with url |
Protected file with an upstream credential, read by the connector. LLM: the organisation's provider key, sent in place of the caller's credential. Remote MCP: the token the server expects. Unset: LLM pass-through; remote MCP sends no credential. |
credential_header |
optional string | unset; derived authorization when credential_file is set |
LLM; MCP with url |
The header the credential travels in. A header the lane sets is refused at startup. Refused without credential_file. |
credential_format |
optional string | unset; derived Bearer {credential} when credential_file is set |
LLM; MCP with url |
The value template, which must contain {credential} once. A header the lane sets is refused at startup. Refused without credential_file. |
credential |
optional string | unset; any value refused | every backend entry; reserved refusal | An inline provider key. Always refused at startup. Write the key to a protected file and name it in credential_file. |
allowed_request_headers |
array of strings | [] |
LLM substitution; requires credential_file |
Caller request headers passed on under substitution, in addition to accept, accept-encoding, content-type. Name provider headers a service needs here (anthropic-version). ["*"], alone in the list, passes every header except those stripped on every call and credential_header; the connector logs a warning at start-up, and a caller credential under another header name then reaches the provider. Requires credential_file. |
allowed_query_params |
array of strings | [] |
LLM substitution; requires credential_file |
Query parameters a caller may send under substitution. Empty: a call with any query string is refused. ["*"] passes every parameter, with the same start-up warning; a caller key in the query then reaches the provider. Requires credential_file. |
allowed_models |
array of strings | [] |
LLM only | Models this backend serves; a per-resource ceiling under the Cedar decision. Empty: policy alone decides, and llmCall is deny-by-default. |
record_request |
optional string (record level) | unset; derived digest |
LLM, Web, WebEgress | How much of what a caller sends is written into the session recording: off, digest (default), redacted, full. Set independently of record_response. On llm this is what the model received; on web and web-egress it is the HTTP request. Neither controls token accounting on llm, nor the audit chain's own request rows on the web lane. |
record_response |
optional string (record level) | unset; derived digest |
LLM, Web, WebEgress | How much of what the backend answers with is written into the session recording: off, digest (default), redacted, full. Set independently of record_request. On llm this is what the model answered; on web and web-egress it is the HTTP response, including a channel's frames. Neither controls token accounting on llm, nor the audit chain's own response rows on the web lane. See The model lane and Recording the exchange. |
allowed_tools |
array of strings | [] |
MCP only | The tool allow-list enforced on tools/call. Ignored otherwise, with a warning when nonempty. |
enforce_tool_policy |
bool | false |
MCP only | false uses allowed_tools only. true also authorizes each tools/call with the Cedar mcpCallTool action and filters tools/list with viewTools. Both the allow-list and Cedar must permit; a deny or an unreachable Authority refuses the call. |
allowed_input_requests |
array of strings | [] |
MCP only | Server-initiated asks this backend may make of the client: sampling/createMessage, elicitation/create, roots/list. Empty, the default, carries none of them. Every ask is audited either way. A name outside the three is refused at startup. |
allow_elicitation_url |
bool | false |
MCP only | false refuses an elicitation/create with mode = "url" (a server-chosen link shown to the person), even where elicitation/create is permitted. Setting it without naming elicitation/create in allowed_input_requests is refused at startup. |
pin_tool_descriptors |
bool | false; true refused on connector backends |
every backend entry; catalog owns the active setting | Descriptor pinning moved to the catalog resource row. A true backend value is refused at startup; set it on the matching [[discovery.resources]] entry instead. |
refuse_unfiltered_tool_listing |
bool | false |
MCP only | What happens to a tools/list result when the viewTools decision cannot be obtained (Authority unreachable). false (default): the listing is forwarded unfiltered and recorded as mcp.tool_listing_undecided. true: the caller gets a JSON-RPC error. tools/call is refused in both modes. Requires enforce_tool_policy; set without it, the connector refuses to start. See Behavior when Authority is unreachable. |
decider_url |
optional string | unset | MCP only | An external decider asked synchronously before a tools/call, resources/read or prompts/get reaches the server: one POST per call to this URL. It answers allow or block and is asked only about calls the other gates permitted. Mutually exclusive with decider_command. Independent of enforce_tool_policy. See The external decider. |
decider_command |
array of strings | [] |
MCP only | The decider as a program on this node, argv-style, program by absolute path: one process per call, request as one JSON line on stdin. Slower than decider_url; runs as the connector's user. See The external decider. |
decider_command_env |
map of strings | {} |
MCP with decider_command |
Environment variables a decider_command is given, on top of the same small set a command server gets. Refused at startup without decider_command. |
decider_timeout_ms |
optional u64 | unset; derived 2000 when a decider is configured |
MCP with a decider | Decider timeout in milliseconds. Default 2000. 0 and values over 60000 are refused at startup. A timeout counts as no answer. |
decider_fail_closed |
optional bool | unset; derived true when a decider is configured |
MCP with a decider | Result when the decider does not answer. true (default): the call is refused. false: the call proceeds and is recorded as undecided. Refused at startup without a decider. |
cage |
optional table | unset | MCP with command or decider_command |
Confinement for the command server and the decider_command on this node; one table for both. Absent: no cage. Refused at startup on a backend that runs neither. See The cage. |
decider_ca_path |
optional string | unset; platform roots | MCP with decider_url |
PEM CA bundle verifying an https decider_url, separate from ca_path. Unset: platform roots. Refused at startup without decider_url. |
api_url |
optional string | unset; required | Kubernetes only | Cluster API base URL, for example https://k8s:6443. |
token_path |
optional string | unset; required | Kubernetes only | Service-account bearer token the connector presents to the API, as the impersonator. |
allow_port_forward |
bool | false |
Kubernetes only | false refuses kubectl port-forward. true enables it; port-forward traffic is a TCP tunnel into the pod network and is not recorded. |
allowed_backend_hosts |
array of patterns | []; empty means unrestricted |
TCP, RDP, Postgres, Cockroach, MySQL, MCP, LLM, Web | Allowed backend addresses. addr, or the host of url with the scheme's default port, must match a host:port / host:lo-hi / *:port / CIDR:port pattern, or the connector refuses to start. Empty: no restriction. Not applied to an mcp backend with command, nor to SSH, Kubernetes, WebEgress. |
agent_auth_attempts_max |
integer | 5 |
SSH model B, onward_auth = "client_key" |
Maximum signatures per authentication to the downstream host. 0 is refused at start-up. |
agent_identities_max |
integer | 5 |
SSH model B, onward_auth = "client_key" |
Identities from the forwarded agent offered to the downstream host. The default stays below a stock sshd's MaxAuthTries (6). 0 is refused at start-up. |
allow_unpinned_upstream |
bool | false |
SSH model B only | false refuses to start a jump-host session when no host-key fingerprint is pinned. Set it to true only when per-host-key pinning is impossible. |
login_mode |
enum | "connector" |
SSH model A only | Account a session runs as on the connector's host. connector: the connector's own account. host_user: a host account, started through the host's login program (su -l or runuser -l). See Model A under a host account. Refused at startup on any other backend kind, including SSH model B. host_user is refused at startup on Windows; there a session runs as the connector's account. |
shell |
optional absolute path | the connector's own: $SHELL or /bin/sh; %ComSpec% on Windows |
SSH model A with login_mode = "connector" |
Shell for a session's shell and exec. A command is passed with /c for cmd.exe, -Command for powershell.exe and pwsh.exe, -c otherwise. Refused at startup when relative, and under login_mode = "host_user" (the account's login shell runs). |
login_user |
optional string | unset; derived root on model B |
SSH model A and B | Model B: user on the downstream host; default root. With onward_auth = "certificate" it is the principal of the OpenSSH certificate Authority signs per connection; the login_users markers are refused at startup. With onward_auth = "client_key" the markers are allowed; the account is the SSH user name of the connection the caller's key opens. Model A with login_mode = "host_user": the single account this resource offers. Refused at startup together with login_users. |
login_users |
array of strings | [] |
SSH model A with login_mode = "host_user"; SSH model B with onward_auth = "client_key" |
Several accounts this resource offers. Markers: "$subject" is the caller's gateway identity, "*" is any host account. See Model A under a host account and The account on the host beyond. Refused at startup together with login_user, with "*" beside other names, and on model B without onward_auth = "client_key". |
ssh_profile |
enum | "general" |
SSH model A only | How this node runs a recognised git command. general: like any other exec, through the login. git: the program runs directly, with no login shell, login profile, shell functions or shell PATH. What may run is set by the catalog's ssh_profile on the resource; gdsgate doctor reports a mismatch between file and catalog. git is refused at startup on Windows; serve git repositories from a Linux connector. |
onward_auth |
optional enum | unset | SSH model A and B | Expected onward authentication: local, certificate or client_key. The catalog value is authoritative. When file and catalog differ, the node stops serving the resource. Unset: follows the catalog. |
repository_root |
optional string | unset | SSH model A with ssh_profile = "git" |
Directory holding the repositories. A repository argument resolves beneath it; a path leaving it is refused. Unset: every recognised command is refused. |
audit_command |
enum | "off" |
SSH model A and B | Whether the exec request string reaches the audit chain. off: classification only (kind, operation, program name, canonical repository). text: the request string too, capped at 2 KiB. fingerprint: a digest of the string. The request string can contain tokens and signed URLs. Never used in policy decisions. |
host_key_fingerprints |
array of strings | [] |
SSH model B only | Pinned SHA256:<base64> fingerprints of the downstream sshd host key, as ssh-keyscan prints them. Any other host key is refused. Empty: the session is refused, unless allow_unpinned_upstream = true (trust-on-first-use, with a warning). |
host_key_fingerprints_file |
optional string | unset | SSH model B only | File with one SHA256:<base64> per line, appended to host_key_fingerprints at startup. Can be filled by gdsgate ca learn-host-key. |
allow_local_forward |
array of patterns | []; empty means loopback only |
SSH model A and B | Layer-1 SSRF border for ssh -L. See Forward gating. Empty means loopback only. |
allow_remote_forward |
array of patterns | []; empty disables |
SSH model A and B | Layer-1 SSRF border for ssh -R. See Forward gating. Empty disables -R. |
allow_agent_forward |
bool | false |
SSH model A | Layer-1 switch for ssh -A. false refuses agent forwarding before Authority is asked; the Connector creates no socket and the session's programs see no SSH_AUTH_SOCK. true: the Cedar action sshForwardAgent decides per user and session. While the session lives, anyone who can reach the forwarded socket, including root on the Connector host, can authenticate with the client's keys. Model B serves no agent request. |
db_roles |
array of tables | [] |
Postgres and Cockroach in session_role |
Session-role profiles: a logical role name Cedar can authorise through context.db_role, bound to a real pre-created DB user. read_only = true declares that the DB role holds no write grant, and gdsgate doctor checks the database against the declaration and fails if it does. |
auth_mode |
enum | passthrough |
Postgres and Cockroach; MySQL must remain passthrough |
passthrough (default), where the client brings its own DB credentials and the connector byte-relays. session_role, where the connector connects as service_account and issues SET ROLE to the policy-chosen role. |
enforce_query_categories |
bool | false |
Postgres and Cockroach | false keeps the query log observe-only. true classifies each statement and refuses any whose category the policy (dbQuery) does not permit. Always on for session_role; this flag adds it to passthrough. Ignored for MySQL and other kinds. |
statement_timeout_ms |
optional u64 | unset; backend default | Postgres and Cockroach in session_role |
Server-side statement_timeout the connector sets on the session, in milliseconds. The client cannot raise it. Unset uses the backend default. |
settable_parameters |
array of strings | [] |
Postgres and Cockroach in session_role |
Extra session parameters a client may SET, on top of the built-in list of driver parameters (client_encoding, search_path, TimeZone). A restricted session may set those and nothing else. Parameters the connector manages (role, application_name, statement_timeout, default_transaction_read_only) are refused even if named here. |
allow_fastpath_function |
bool | false |
Postgres and Cockroach restricted sessions; MySQL carries the field but has no restricted gate | false (default) refuses the fast-path FunctionCall message in a restricted session; it calls a function by OID outside SQL and has no query category. true allows it for a legacy client; the call is audited, not gated. No effect on observe-only passthrough. |
service_account |
optional string | unset; required for session_role |
Postgres and Cockroach in session_role |
Service-account connection string, no password, for example postgres://gdsgate_sa@host/db. Required for session_role. |
service_account_password_file |
optional string | unset; required for session_role |
Postgres and Cockroach in session_role |
Path to a file holding the service-account password (vault or secret mount). Required for session_role. |
service_account_tls_ca_file |
optional string | unset; system trust store | Postgres and Cockroach in session_role |
PEM CA bundle verifying the backend's TLS certificate when the service-account DSN sets sslmode=verify-full, for example an RDS or CloudSQL root bundle. Unset uses the system trust store. |
discovery |
optional table | unset | Postgres and MCP | Live-discovery probe; see [connector.backends.discovery]. |
admin |
optional string | unset; reserved | every backend entry; no current consumer | Future use: admin credential for the resource. Not consumed in this release. |
credential_file, token_path, and service_account_password_file each
accept the secret-reference notation in
place of a bare path. Kubernetes token_path is read at startup and before each
API request or discovery pass. Projected token replacement takes effect without
restarting the Connector. An unreadable, empty, malformed or expired token
refuses the operation with no fallback to the previous value. The cluster API
authenticates tokens and enforces revocation. The other credentials are read
at startup.
[[connector.backends.routes]]: named path families¶
One table per named family of paths on a kind = "web" backend. Routes are
matched in declaration order; the first match wins. A catch-all declared first
makes the routes after it unreachable. Overlapping patterns are allowed.
| Key | Type | Default | Applies to | Purpose |
|---|---|---|---|---|
name |
string | unset, required | Web backend route | The word context.route carries to policy. It must be non-empty and unique within the backend. |
match |
array of strings | unset, required | Web backend route | Path patterns claiming this family. Each begins with /; * matches any run, including none. |
audit |
optional enum | unset; inherits backend audit |
Web backend route | How much of this route's traffic reaches the chain. Unset inherits the backend value. |
Only the path is matched; the query string is ignored. A request no route
matches carries context.route == "" into the decision, and deny-by-default
refuses it unless a rule permits it:
[[connector.backends.routes]]
name = "admin"
match = ["/admin/*", "/api/admin/*"]
[[connector.backends.routes]]
name = "dashboards"
match = ["/d/*"]
Per-kind reference¶
[[connector.backends]]
resource = "jump-host"
kind = "ssh"
# No addr: the connector is the SSH server.
allow_local_forward = ["redis.internal:6379", "10.0.0.0/8:5432"]
allow_remote_forward = ["127.0.0.1:8000-9999"]
The connector terminates the SSH session, spawns a PTY under its own OS
user, and records the terminal stream in asciicast v2. SFTP, PTY, exec,
agent forwarding (-A), -L and -R work. The three forwarding
capabilities go through forward gating; -A needs
allow_agent_forward = true.
The connector's SSH host key is generated at first start and stored under
[enroll].state_dir as ssh_host_ed25519_key, mode 0600. It persists
across restarts, so client ~/.ssh/known_hosts entries stay valid. Both
models present it.
Both SSH models honor record = "off", "best_effort" (default), or
"required". off retains no terminal events, does not consult the sink,
and advertises the session as unrecorded. required needs a streaming sink
whose header can be written before SSH starts. A write or flush failure
ends the session; the close audit row reports Failure with reason
unrecorded. Any retained partial recording remains evidence of an
incomplete session. best_effort can continue after a sink failure.
[[connector.backends]]
resource = "jump-fleet"
kind = "ssh"
addr = "sshd-target:22"
login_user = "deploy"
host_key_fingerprints = ["SHA256:<base64-fingerprint>"]
# Or load fingerprints from a file populated by deploy-time scripts:
host_key_fingerprints_file = "/etc/gdsgate/sshd-target.fp"
The connector terminates and records the client's SSH, opens its own SSH
session to the downstream sshd, and relays every channel. It
authenticates downstream with an OpenSSH user certificate Authority signs
per connection with its Onward SSH CA, principal = login_user. With
onward_auth = "client_key" on the catalog resource it uses the caller's
key through the forwarded agent, under the account
chosen at session start. Pin the downstream host key;
allow_unpinned_upstream = true is for development only.
[[connector.backends]]
resource = "prod-db"
kind = "postgres"
addr = "10.0.0.5:5432"
[[connector.backends.db_roles]]
name = "readonly"
db_user = "ro_login"
[[connector.backends.db_roles]]
name = "writer"
db_user = "rw_login"
In the default passthrough mode the connector forwards the database wire
stream to addr and writes a structured query log of client statements
into the audit chain. The client authenticates to the database with its
own credentials.
db_roles declares session-role profiles: a logical role name Cedar
authorises through context.db_role, bound to an existing DB user.
With auth_mode = "session_role" the connector connects as one service
account and issues SET ROLE to the role policy chose. No per-user
database accounts are needed; the audit log records the principal and the
role:
[[connector.backends]]
resource = "prod-db"
kind = "postgres"
addr = "10.0.0.5:5432"
auth_mode = "session_role"
service_account = "postgres://gdsgate_sa@10.0.0.5:5432/appdb"
service_account_password_file = "/run/secrets/gdsgate_sa_pw"
[[connector.backends.db_roles]]
name = "analyst_ro" # the logical name Cedar authorises
db_user = "analyst" # the real role SET ROLE switches to
Grant the service account membership in each db_user it may assume.
gdsgate_sa should own nothing else and must not be a superuser: a
superuser bypasses every grant. A client's own SET ROLE or
SET application_name is refused. The DBA-side correlation key is
application_name = gdsgate:<principal>:<session_uid>.
CockroachDB: kind = "cockroach", default port 26257. Everything on this
page applies, session_role and enforce_query_categories included.
gdsgate doctor's catalog grant probe is
PostgreSQL-only and reports a cockroach backend as unchecked.
A read-only profile needs a role without write grants. For a capability
without write, ddl or dcl the connector also sets
default_transaction_read_only = on; a session can clear that parameter,
so the role's grants are what enforce read-only. Declare it and let
gdsgate doctor check the database:
[[connector.backends.db_roles]]
name = "analyst_ro"
db_user = "analyst"
read_only = true # doctor fails if `analyst` can write
doctor reports three cases where a read-only role can write:
| Cause | Effect |
|---|---|
A write privilege granted TO PUBLIC on a table |
Every role can write that table. |
| The role owns a table | Full rights with no ACL entry. |
The role may execute a SECURITY DEFINER function that writes |
The function writes with its owner's rights; only the read-only transaction default stops it. |
For a managed database, set sslmode=verify-full in the service_account
DSN and point service_account_tls_ca_file at the provider's root bundle.
To gate each statement by category, set enforce_query_categories = true
or use session_role, which always enforces. See
Policy → Per-statement database authorization
and the database session roles pattern.
Live discovery (Postgres): with a [connector.backends.discovery] block
the connector periodically lists the host's databases under a read-only
account and publishes them to Authority; gdsgate ls then shows
prod-db.<database> sub-resources. Listing grants no access; connect
still decides each connection.
[connector.backends.discovery]
mode = "list_databases"
connection = "postgres://disc_ro@10.0.0.5:5432/postgres"
password_file = "/run/secrets/disc_ro_pw"
interval_secs = 300
allowlist = ["app_*", "analytics"] # optional globs
denylist = ["template_*"]
Use a separate, least-privilege account (disc_ro, only SELECT on
pg_database); do not reuse the SET ROLE service account. A failed probe
keeps the previous inventory. Field reference:
[connector.backends.discovery].
[[connector.backends]]
resource = "prod-cluster"
kind = "kubernetes"
api_url = "https://kubernetes.default.svc:6443"
ca_path = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token"
The connector reverse-proxies the cluster API, authenticating with the
service-account token and CA bundle, and impersonates the resolved
identity on every request. Client Authorization and Impersonate-*
headers are stripped and replaced. A person is impersonated under their identity-provider
name; an agent or another workload under its SPIFFE ID, for example
spiffe://gdsgate/agent/default/page-bot. A RoleBinding names that
ID as the subject. Every request is authorized on its own (kubeRequest,
carrying context.read_only for the verbs that only read); exec and
attach are recorded. kubectl port-forward is refused unless
allow_port_forward = true; port-forward traffic is not recorded.
Raw TCP byte forward to addr. Authorisation is connect over
TcpService::"<resource>". Used for Redis, internal HTTP, gRPC services,
anything plaintext.
[[connector.backends]]
resource = "win11"
kind = "rdp"
addr = "10.0.0.20:3389"
# Terminating: the connector opens the session and logs in itself.
windows_user = "WORKSTATION\\operator"
credential_file = "/etc/gdsgate/win11.pw"
A Windows desktop. Authorisation is connect over RdpHost::"<resource>".
With windows_user set, the connector opens the session on the host,
logging in with the configured password, and renders the desktop to the
client. Without it, the stream is forwarded as with tcp, and the
caller's client authenticates to the host.
DOMAIN\name is a domain account; a bare name is local to the host.
windows_user and credential_file are set together; either alone is
refused at startup.
Without an account, record, record_max_bytes, the channel keys,
graphics and the three pinning keys are refused at startup. Name an
account or remove the key.
credential_file takes a path or a
secret reference. Trailing whitespace is
trimmed.
Where the account depends on who is asking, declare several instead:
[[connector.backends]]
resource = "win11"
kind = "rdp"
addr = "10.0.0.20:3389"
[[connector.backends.windows_users]]
account = "WORKSTATION\\operator"
credential_file = "/etc/gdsgate/win11-operator.pw"
[[connector.backends.windows_users]]
account = "WORKSTATION\\Administrator"
credential_file = "/etc/gdsgate/win11-admin.pw"
Each account has its own credential_file. windows_users and
windows_user are mutually exclusive; a duplicate account is refused at
startup.
The caller picks an account (gdsgate rdp proxy win11 --windows-user
'WORKSTATION\operator'), policy decides over context.windows_user, and
the certificate carries the result. Naming none when several are offered is
refused with the list of accounts this caller may use. The session's audit
row names the account: login_account=WORKSTATION\operator.
Rotation. The password is read once, at startup. To rotate, write the new secret, then restart the connector. Open sessions keep running; new sessions use the new secret.
Host certificate pinning. A Windows host presents a self-signed TLS certificate. Pin it; an unpinned session is refused.
host_cert_fingerprints = ["sha256:d27ae465b20ef990063fb3d8bd8a2f3ab18635e96cd6477c4454e1fd15b461eb"]
gdsgate ca learn-host-cert <host> prints that line. It connects to the
address, reads the certificate, and sends no credentials. Run it from a
trusted network position. Several values cover a rotation window;
host_cert_fingerprints_file adds one per line from a file. Case and :
separators are ignored, so fingerprints from openssl or a certificate
viewer work as printed.
With no fingerprint set, the session is refused before the password is
sent; the error names the address and the command above.
allow_unpinned_upstream = true disables the check and is logged every
session, as on an SSH jump host.
A terminated desktop is recorded when [recording] names a sink: the
stream the Windows host sends the gateway, stored as <session>.rdp. It is
not an asciicast and asciinema does not play it.
Under continuous full-screen motion a recording grows at 1.77 MiB/s
(6.4 GB an hour); a still desktop writes about a megabyte an hour.
record_max_bytes caps one session's recording, 4 GiB by default.
record sets what happens when a session cannot be recorded. Accepted on
kind = "ssh" and kind = "rdp", refused elsewhere.
record |
The session is | A sink that will not open | The ceiling |
|---|---|---|---|
off |
not recorded | not consulted | does not apply |
best_effort (default) |
recorded | logged, session continues | stops the recording |
required |
recorded | refuses the session | ends the session |
Under required, a session ended because it could no longer be recorded
has a close row with reason=unrecorded and outcome Success.
Channels. A terminating desktop carries the clipboard, sound and the caller's devices; each has a key:
| Key | Default | What it carries |
|---|---|---|
clipboard |
both |
off, in (paste into the desktop), out (copy from it), both |
clipboard_files |
off |
the same four values, for files |
audio |
true |
sound from the desktop |
drives |
false |
the caller's disks, as \\tsclient\<name> |
printers |
false |
the caller's printers |
ports |
false |
the caller's serial and parallel ports |
smart_cards |
false |
the caller's card readers |
A channel that nothing may use is not opened; a client asking for it is told it does not exist. For a closed clipboard direction the far side is offered an empty clipboard. A device type that is not carried is removed from the client's device announcement; the other devices still arrive. An announcement the connector cannot parse is dropped whole.
clipboard_files is limited by clipboard: a direction clipboard does
not carry carries no files. clipboard = "out" with
clipboard_files = "both" moves files out of the desktop only.
The keys are refused at startup on any other kind. They control which
channels and directions are carried; the content is not inspected.
Policy. These keys set what the backend carries. Cedar's
desktopChannel decides, per channel and direction, what this caller may
use, when the access certificate is issued. A session carries what both
allow. Cedar is deny-by-default: with no policy for the action, no channels
are carried. See A desktop's channels.
The session's open row lists the result in channels.
Graphics. graphics sets how the host's screen reaches the caller:
graphics |
The gateway | Cost, measured |
|---|---|---|
redraw (default) |
decodes the host's picture and draws it again for the client, with the codec those two agreed on | ~0.7 of a core per active session, 343 MB to the caller |
passthrough |
asks Windows for its graphics pipeline and carries that channel to the caller unread | ~0.03 of a core, 57 MB to the caller |
Measured over fifty seconds of continuous full-screen motion, both legs counted.
passthrough relays the MS-RDPEGFX graphics pipeline channel byte for
byte; the caller and the host negotiate the codec. It needs a client that
supports the pipeline: mstsc and FreeRDP do. A client that never opens the
channel gets a desktop with no picture, and the connector logs it. Pointers
and palettes are still decoded and redrawn.
Recording by mode: a redraw recording plays back frame by frame. A
passthrough recording holds the same host stream, but the screen is in the
pipeline codecs, which the shipped player (--example play) does not draw;
it reports what the pipeline carried. Use redraw where recordings must be
watchable.
Compatibility. The lane serves a full desktop on one monitor over TCP,
with no RemoteApp and no UDP transport. Verified against:
| Verified | Not tried | |
|---|---|---|
| clients | FreeRDP 3.31 (xfreerdp3, and Remmina, which is the same engine); mstsc on Windows 11, both in both modes |
tablet clients; macOS Windows App |
| hosts | Windows 11 | Windows Server; gnome-remote-desktop (stops responding after ClientInfo) |
| codecs, gateway → client | RemoteFX RLGR under redraw; whatever the caller settles with the host under passthrough |
NSCodec |
| codecs, host → gateway | bitmap under redraw. Under passthrough the caller and the host settle it between themselves and the gateway reads none of it; measured against FreeRDP, they chose RFX Progressive and ClearCodec |
what another client would settle on, H.264 included |
| channels | cliprdr, rdpsnd, rdpdr, each in both modes; the graphics pipeline on drdynvc |
every other dynamic channel; the host offers several and each is refused |
With drives = false and drive redirection enabled in mstsc, the session
connects and the drives do not appear.
Kill and reconnect. gdsgate session kill closes the connection and the
recording. The Windows session stays as a disconnected session with its
applications running; the host's Set time limit for disconnected sessions
policy (Remote Desktop Session Host) decides when it ends.
A reconnect is a new gateway session: new id, new policy decision, new
recording. When Windows reattaches the same desktop, the close row carries
windows_session (the host's session number) and resumed (the previous
gateway session).
A killed session's close row reads reason=client_closed. The
session.kill row carries the same session_uid and names who killed it.
| Symptom | Cause | What to do |
|---|---|---|
Close row reason=never_opened with the address |
The host did not answer within eight seconds | Check the address, port 3389 and the host firewall. |
Close row reason=queue_overrun, outcome Success |
More than 16 MiB queued between host and client | Check the client's network and performance. |
A caller who fails the listener's sign-in never reaches Windows; a wrong password does not disconnect the user already in that desktop.
[[connector.backends]]
resource = "tools-mcp"
kind = "mcp"
addr = "127.0.0.1:8765"
allowed_tools = ["search", "read_file"]
enforce_tool_policy = true # also gate each call with Cedar mcpCallTool
# What the server may ask of the client. Empty (the default) = nothing.
allowed_input_requests = ["elicitation/create"]
allow_elicitation_url = false # no server-chosen link in front of the user
# Optional: discover the server's tools to filter `tools/list` per identity.
# (Discovered tools are not added to `gdsgate ls`; only the server is.)
[connector.backends.discovery]
mode = "list_tools"
The connector forwards JSON-RPC unchanged. Each tools/call is checked
against allowed_tools before it reaches the backend and is audited; a
denied tool returns a JSON-RPC error. With enforce_tool_policy = true the
call is also authorized with the Cedar mcpCallTool action and
tools/list is filtered with viewTools; see
Policy → MCP per-tool policy.
Server-initiated asks are gated too: sampling/createMessage,
elicitation/create and roots/list, both as a server-to-client request
(before revision 2026-07-28) and as an inputRequests field on a result
(from that revision). Both forms are audited; an ask is carried only when
allowed_input_requests names it. A refused ask gets a JSON-RPC error: to
the server for a request, to the client for a result.
Regardless of allowed_input_requests, an ask is accepted only on the
answer to prompts/get, resources/read or tools/call, and only for a
capability the client declared on that request. A session keeps the
protocol revision the first request names; session.close records it.
The instructions a server returns at its handshake are relayed unchanged;
their size is recorded as mcp.instructions.
[[connector.backends]]
resource = "docs-tools"
kind = "mcp"
# Replaces `addr`; never both.
command = ["/usr/local/bin/mcp-filesystem", "--root", "/srv/docs"]
command_env = { MCP_LOG_LEVEL = "warn" }
allowed_tools = ["search", "read_file"]
enforce_tool_policy = true
command runs a stdio MCP server: newline-delimited JSON-RPC over stdin
and stdout. Everything above applies: the per-call Cedar decision,
tools/list filtering, descriptor pinning, the ask gate, the pinned
protocol revision. session.close records transport=stdio or
transport=socket.
Each session starts its own server process and stops it on close. A
[connector.backends.discovery] probe starts its own process on each
interval.
[[connector.backends]]
resource = "models-prod"
kind = "llm"
addr = "https://api.example.com" # base URL, not host:port
ca_path = "/etc/gdsgate/upstream-ca.pem" # optional; platform roots if unset
# Substitution: the organisation's key replaces the caller's.
# Omit credential_file for pass-through (the caller's own key travels).
credential_file = "/run/secrets/models-prod"
credential_header = "authorization" # default
credential_format = "Bearer {credential}" # default
allowed_request_headers = ["anthropic-version"]
allowed_query_params = ["api-version"]
allowed_models = ["gpt-4o", "gpt-4o-mini"] # empty = policy is the boundary
# Prompts and answers are content. digest/digest is the default.
record_request = "digest"
record_response = "digest"
A model service, reverse-proxied with a policy decision on every call. The
connector terminates HTTP, reads the model name from the request body,
evaluates the Cedar llmCall action, then opens TLS to addr. See
The model lane for the two modes and
what is recorded.
addr can be a provider API or a model gateway in front of several.
gdsgate has no provider adapters.
[[connector.backends]]
resource = "grafana"
kind = "web"
url = "https://grafana.internal:3000"
# Optional: named path families, matched before anything is decided.
[[connector.backends.routes]]
name = "admin"
match = ["/admin/*", "/api/admin/*"]
[[connector.backends.routes]]
name = "dashboards"
match = ["/d/*"]
The connector terminates HTTP and reverse-proxies each request to url.
The client's Content-Length and Transfer-Encoding are not copied. The
outgoing Host is the base URL's; forwarded_headers adds the connector's
X-Forwarded-* headers.
connect opens the session over the backend's WebApp; httpRequest
decides each request, with context.route set to the matched route name or
"". See Policy → HTTP(S) application routes.
| Header or response | Handling |
|---|---|
cookie |
Passed unchanged. |
authorization |
Passed unchanged under identity = "none"; dropped under signed and headers. See Forwarding caller identity. |
set-cookie |
Domain removed; Secure, HttpOnly, SameSite, Path unchanged. |
3xx |
Passed to the client. A Location on the application's own host is rewritten relative to the browser's request; other Location values are unchanged. The connector does not follow redirects. |
| Response | Cause |
|---|---|
400 |
A path segment percent-encodes / and allow_encoded_separator is false. |
501 |
A WebSocket handshake and websocket is false. |
504 |
The application sent no response head within 30 seconds. |
With websocket = true the handshake is decided under httpRequest with
context.upgrade set; nothing after the 101 is decided. The channel runs
until a peer closes it or the session certificate expires. Request bodies
stream through, unbounded unless max_request_bytes is set. The 30-second
deadline covers the response head only; slow streams and large downloads
are not cut off. See The web lane.
[[connector.backends]]
resource = "outbound"
kind = "web-egress"
allow = ["github.com", "*.githubusercontent.com", "pypi.org"]
A web-egress resource is a forward proxy out of the protected zone: each
request names its target, and allow limits the targets. It is authorized
with connect over its WebEgress entity, audited on open and close, and
each request inside the session is decided on httpRequest, as on a web
application.
Two request forms are accepted: an absolute target
(GET http://host/path) and CONNECT host:port. Origin form
(GET /path) is refused 400; the usual cause is a program given this
endpoint as a base URL where it should use it as a proxy (see
CLI → Endpoints: what the program is given).
allow is checked before Authority is asked. Entries *, *.com, a *
other than the whole leftmost label, a URL, a host:port or a blank entry
are refused at startup.
The name is resolved once, and the connection uses the resolved address.
Each resolved address must be global unicast and outside every
special-purpose range; anything else is refused. Refused in IPv4:
0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8,
169.254.0.0/16 (where the cloud metadata service answers),
172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24,
192.168.0.0/16, 198.18.0.0/15 (the resident client's own pool),
198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4 and
255.255.255.255. Refused in IPv6: ::, ::1, 64:ff9b:1::/48,
100::/64, 2001::/23, 2001:db8::/32, 3fff::/20, 5f00::/16,
fc00::/7, fe80::/10, fec0::/10, ff00::/8, and everything outside
2000::/3. The four IPv4-in-IPv6 forms (v4-mapped, v4-compatible, 6to4,
NAT64) are judged as the IPv4 address they carry: 2002:a9fe:a9fe:: is
refused as 169.254.169.254. To dial a range the connector's network
routes, name it in routed_ranges.
If any resolved address is refused, the whole name is refused. A
split-horizon name on allow that resolves to an internal address from the
connector's network is refused.
context.route is always "" on web-egress. context.inspected is
true for the absolute form (path read and canonicalised) and false for
a CONNECT (decided on host name and port, then carried unread). A rule
that needs the path can require context.inspected. See
Policy → HTTP(S) application routes.
No permit is cached on web-egress; every request asks Authority.
For a delegated program, CONNECT costs web:tunnel, which web:write
does not include. The absolute form costs web:read / web:write by
method.
Each request is audited as http.request (audit defaults to full). A
CONNECT adds an http.tunnel row when the channel ends, with
tunnel = "connect" (a WebSocket's row has websocket).
TLS to the far end verifies against the platform roots only; ca_path is
not read on this kind. The 30-second response-head deadline applies to the
absolute form, not to CONNECT.
Inspecting egress traffic¶
[[connector.backends]]
resource = "outbound"
kind = "web-egress"
allow = ["github.com", "pypi.org"]
inspect = true
inspect = true makes each request inside a CONNECT a separate decision,
as for the absolute form. Default false: the TLS the delegated program opens
is carried unread.
gdsgate delegate --exec sends OPTIONS * to the egress before the program
starts and reads Gdsgate-Way-Out: inspect or tunnel; no answer counts as
tunnel. On each CONNECT it then sends Gdsgate-Terminated: tls.
| Symptom | Cause | What to do |
|---|---|---|
501 naming inspect on a CONNECT with Gdsgate-Terminated: tls |
The backend has inspect = false. |
Set inspect = true or run the program without inspection. |
501 naming inspect on a plain CONNECT |
The backend has inspect = true; only gdsgate delegate --exec can use it. gdsgate web proxy and other relays cannot. |
Use a separate web-egress resource without inspect for those clients. |
A CONNECT to a non-TLS port is refused |
Inspection carries only HTTP inside TLS. | Use a resource with inspect = false. |
A WebSocket upgrade inside the CONNECT is refused |
Not offered under inspection. | Use a resource with inspect = false. |
| The run reports at start-up that the program cannot use inspection | The program pins its certificate or ignores the trust variables (the JVM, .NET). | Use a resource with inspect = false. |
TLS is terminated by the gdsgate delegate --exec run, not by the connector.
The run generates its own root and a leaf for each CONNECT host name. The key
stays in the run's memory, is trusted only by the program the run started, and
is discarded when the run ends. Ed25519, server authentication only, path
length zero; valid for the delegation grant's window plus five minutes either
side.
The connector receives the requests and decides them under httpRequest with
context.inspected = true and context.route == "". They cost web:read /
web:write by method; web:tunnel is not charged. Each row carries
inspected = true, the method and the path. TLS to the far end still
verifies against the platform roots and the requested name.
The trust variables given to the program are listed in CLI → Endpoints: what the program is given.
Carrying git-lfs objects¶
LFS objects do not travel on the git connection. The server returns an object
store address (git-lfs-authenticate over SSH, the Batch API href over
HTTPS), and a .lfsconfig in the repository can name another. Objects need a
web-egress backend whose allow names the store:
[[connector.backends]]
resource = "lfs-objects"
kind = "web-egress"
allow = ["lfs-objects.example", "*.objects.example"]
The two operations reach policy as lfs-download and lfs-upload on the SSH
lane and on the Batch API request; the object transfer is an ordinary
httpRequest on the egress, with context.git_operation absent.
Enforced for any store address:
| Enforced | Refused as |
|---|---|
The name in the received address is on allow. |
403 on the request, reason = not_allowed. |
Every address that name resolves to is a host on the public internet, or in routed_ranges. |
403 on the request, reason = address_refused. |
The name in CONNECT, the name in the TLS SNI and the name in Host are one name. |
The SNI at the run's own endpoint, which drops the connection and names both hosts in the run's output; the Host at the connector, 403 with reason = name_disagreement. |
| No header crosses a redirect this gateway handled. | Nothing is refused: a 3xx is handed to the client as written, and this gateway follows none. The client's own next request meets allow under the name it names. |
Not enforced: which host an Authorization header from a
git-lfs-authenticate or Batch API answer is sent to. The git-lfs client
decides. git-lfs 3.4.1 sends no Authorization to an href or .lfsconfig
host other than the remote.
| Symptom | Cause | What to do |
|---|---|---|
git lfs pull or git lfs push fails, and the row is 403 with reason = not_allowed. |
The address the server chose for the object is not on allow. |
Add the store's name to allow, or point the git server at a store that is already there. |
The same failure, with reason = address_refused. |
The store's name resolves to a non-public address. | Name the range in routed_ranges if this network routes it. |
| LFS reaches a host the operator never listed for this repository. | A .lfsconfig in the repository names it. |
The request is refused by allow like any other; read hostname on the row to see which name the repository asked for. |
The row is 403 with reason = name_disagreement. |
A request inside a terminated CONNECT named a host other than the one the channel was opened to. |
Open a channel to that name. This does not arise from git-lfs itself. |
The web lane¶
A kind = "web" backend is one application, reverse-proxied with a policy
decision on every request. connect opens the session once; httpRequest
decides each request inside it. web-egress shares the request decision and
audit shape, caches nothing and has no routes; see the web-egress tab above.
Permit cache. A permit is keyed on caller, resource, route and method and
reused for decision_cache_ttl_secs (default five seconds) without asking
Authority. Refusals are not cached. Revoked access takes effect up to the TTL
late.
Audit sampling. A successful GET on a route without audit = "full" is
folded into the session's close row. Other methods, non-2xx answers, and
anything under audit = "full" (backend or route) get their own
http.request row. The close row carries requests, bytes_up, bytes_down,
per-class counts status_2xx to status_5xx, and rows_folded.
WebSocket. Without websocket = true a handshake is refused 501 naming
the setting. With it, the handshake is an httpRequest decision with
context.upgrade set, so a rule can permit reading a route and refuse a
channel on it. The handshake is always decided fresh: it neither uses nor
fills the permit cache. After it opens, the channel is not decided again and
runs until a peer closes it or the session certificate expires. The close row
carries tunnels, tunnel_bytes_up, tunnel_bytes_down. When the channel
ends an http.tunnel row records why: peer close, transport failure or
certificate expiry, with tunnel = "websocket" ("connect" on web-egress).
For a delegated program a channel costs web:tunnel, which web:write does
not include.
Headers. Handling of cookie, authorization, set-cookie and 3xx is
in the web tab above. X-Forwarded-For / -Proto / -Host are added only
with forwarded_headers = true and carry the connector's observations.
Body framing. The client's Content-Length and Transfer-Encoding are not
copied; the connector writes its own framing. A request that arrived with a
length leaves with that length; a chunked request leaves chunked; a request
with both leaves chunked. The body streams through at the rate the
application reads it. A client that sends less than it declared gets 400;
the application sees an unfinished request.
Links inside page bodies (HTML, CSS, scripts) are not rewritten; only the
set-cookie Domain and the 3xx Location are. Identity is forwarded only
as identity sets; see below.
Forwarding caller identity to the application¶
By default (identity = "none") the application sees no gateway identity; the
caller's Authorization, if any, passes unchanged. identity = "signed" adds
a signed statement of the caller and drops the caller's credential.
The assertion is a compact JWS (EdDSA over Ed25519) in the
Gdsgate-Identity request header. Claims:
| Claim | Value |
|---|---|
iss |
gdsgate |
sub |
The access certificate's subject. |
groups |
The caller's groups. |
owner |
Whom a delegated program or workload acts for; absent for a person. |
aud |
The catalog resource id, taken from the certificate's scope. An assertion for one application is not valid at another. |
iat |
Issue time. |
exp |
iat plus one minute. |
Authority signs with a separate CA, CaKind::JwtSigning, not with the access
certificate key. Rotate it with gdsgate authority rotate-jwt-ca (manual
only); set the rotation's propagation_secs to cover how long your
applications cache the key set. See [ca_rotation].
The Proxy publishes the key set at GET /.well-known/gdsgate/jwks.json on its
browser origin ([proxy].web_addr). It lists every trusted JwtSigning
generation: the candidate during rotation and the previous one until its last
assertion expires. Cache-Control: public, max-age=300; the Proxy caches its
own copy for the same time.
The assertion comes with the AuthorizeHttpRequest answer and is cached with
the permit. It expires at the earlier of decision_cache_ttl_secs and the
certificate's not_after.
If no assertion can be made (Authority does not support it, or none is
configured), the request is refused 502; it is not forwarded anonymously.
identity = "headers" sends X-Remote-User and X-Remote-Groups
(comma-separated), unsigned, for software that cannot verify a JWS.
X-Remote-Groups carries the groups Authority returned with the permit; a
change to a delegated program's declaration reaches it within
decision_cache_ttl_secs. An Authority older than v0.9.0-rc.9 returns no
groups; the certificate's groups are sent instead.
The headers are not verified, so the application must be reachable only
through this gateway. identity = "headers" requires
identity_headers_trust_the_network = true; either without the other refuses
to start.
Under signed and headers the caller's Authorization is always dropped.
Inbound Gdsgate-Identity and all x-remote- headers are stripped in every
mode, including none.
Recording the exchange¶
The audit row http.request carries method, route, status and size. The
session recording carries headers, bodies and channel frames, sealed with a
SHA-256 on the session's close row. record_request and record_response
(the same keys and levels as kind = "llm", default digest) set the level
per direction on web and web-egress:
| Level | What lands in the recording |
|---|---|
off |
Nothing for that direction. |
digest (default) |
Sizes and a SHA-256 fingerprint of the body. |
redacted |
Bodies through the secret redactor. |
full |
Bodies verbatim, written as they stream. |
The format tag is gdsgate.http.exchange. It has the same levels, raw-byte
fingerprint, JSON-lines framing and redactor as the model lane's
gdsgate.llm.exchange; its fields are method, route and status.
A request with a body is written as a head line, chunk lines (at full),
and an end line with bytes and sha256. end carries cut_off when the
body did not finish: the application abandoned it, the client sent less than
it declared, or max_request_bytes stopped it. A request without a body
(GET, HEAD, a WebSocket handshake) is one line. This is format version 2;
version 1 wrote every request on one line. recordings show renders both.
authorization, proxy-authorization, cookie and set-cookie are recorded
as name, size and value fingerprint at every level, full included. Other
header values always go through the redactor.
At redacted and full, the connector sends accept-encoding: identity
upstream and does not offer WebSocket permessage-deflate. At off and
digest negotiation is unchanged.
WebSocket frames are recorded one JSON line per frame: direction, opcode, length, payload. Recording does not delay or drop frames. The fingerprint is over the unmasked payload.
Both web and web-egress record. On web-egress, requests are recorded
only with inspect = true (see
Inspecting egress traffic).
A CONNECT without inspection records host name, port and 200 only. A
refusal is recorded on both, naming the host.
gdsgate authority recordings show <session_uid> renders both exchange
formats (request, response, what was withheld) and returns other recordings,
asciicast included, as bytes; --raw always returns bytes. See
CLI → gdsgate authority recordings show.
Running the MCP server as a child process¶
A command backend runs the program on this node as the connector's user. That
user can read this node's Kubernetes service-account token, the model lane's
credential_file, the connector's key and certificate, and its configuration.
The child's environment is cleared and rebuilt from PATH, HOME, LANG,
LC_ALL, LC_CTYPE, TZ, USER and command_env. File access is not
limited by this.
To confine the child, declare [connector.backends.cage]
(The cage). Without it the child runs with the
connector's filesystem, and the connector logs this at every start.
To keep the server off the connector's node, run it under its own account or on
another host and use addr or url. Gating is the same for all three forms.
The program and arguments come from the connector's configuration; descriptor
pinning comes from Authority's catalog row. Pinning detects a server that
changes a descriptor after approval, not a changed command.
The program is named by absolute path; a relative or bare name is refused at
startup. command is an argv list, not a shell command line; arguments with
spaces need no quoting.
When a session ends, the server's stdin is closed first.
| Platform | Without a cage | With a cage |
|---|---|---|
| Linux | The server's process group is signalled. A descendant that calls setsid survives, and so does the tree when the connector is killed with SIGKILL. |
The server runs in its own PID namespace with PDEATHSIG set; the whole tree ends with the session or the connector. |
| Windows | Only the server process is ended. | The job object ends the whole tree. |
The server's stderr is logged at debug level only, truncated per line and
capped per session.
The cage¶
[connector.backends.cage] confines the programs a backend runs on this node:
the command server and the decider_command, both under one table. Absent:
no cage.
The keys follow systemd.exec(5) naming. Unlike the gdsgate delegate
--sandbox profiles, the network stays open by default.
The filesystem is always an allow-list. A caged program sees the system
runtime, the /etc files a process needs to start, the character devices, its
own installation prefix, its private $HOME and $TMPDIR, and the paths in
read_only_paths and read_write_paths. /proc is not visible, so other
processes' /proc/<pid>/environ is not readable.
| Setting | Type | Default | Applies to | What it does |
|---|---|---|---|---|
private_network |
bool | false |
MCP command or decider program | An empty network namespace: no interfaces, no route, no DNS. No per-destination allow-list. |
protect_home |
bool | true |
MCP command or decider program | $HOME is a private directory of this backend; the connector user's home is not visible. false exposes the real home read-write, for a server that keeps state there. |
private_tmp |
bool | true |
MCP command or decider program | $TMPDIR is that same private directory and /tmp is not in the cage. false allows /tmp and leaves $TMPDIR alone. |
read_only_paths |
list of strings | [] |
MCP command or decider program | Extra paths the program may read and execute, recursively. A path missing on this host is skipped. |
read_write_paths |
list of strings | [] |
MCP command or decider program | The same, writable. For the server's own data. |
restrict_address_families |
list of strings | [] |
MCP command or decider program | The families the program may still open a socket in: AF_INET, AF_INET6, AF_NETLINK, AF_UNIX, and so on. Empty means no restriction; a name this build does not know is refused at startup. |
memory_max |
optional string | unset | MCP command or decider program | memory.max for the run's cgroup: a byte count with an optional K, M or G. Applies to the whole process tree. |
tasks_max |
optional integer | unset | MCP command or decider program | pids.max for the same cgroup. |
cpu_quota |
optional string | unset | MCP command or decider program | cpu.max as a percentage of one core: "100%" is one, "400%" is four. |
report_denied_paths |
bool | false |
MCP command or decider program | Log every path the program opens that the cage does not allow. See below. Refused at startup on Windows: see The cage on Windows. |
allow_degraded |
bool | false |
MCP command or decider program | Run even when this host cannot provide something the table asks for. |
On Linux, only restrict_address_families blocks connecting to a unix-domain
socket by path; neither the filesystem allow-list nor private_network does.
Without it, a caged program can reach a container runtime socket or the session
bus. ["AF_INET", "AF_INET6", "AF_NETLINK"] closes unix sockets.
socketpair is always allowed, as with RestrictAddressFamilies=.
report_denied_paths shows which paths a server needs. With it on, the
connector checks every open and logs a warning for each path outside the
cage:
WARN a caged program asked for a path this cage does not allow; add it to
read_only_paths or read_write_paths if the server needs it
program=/usr/local/bin/mcp-filesystem path=/srv/docs/index.db
Turn it on, run the server, add the reported paths, turn it off. It adds a
round trip to the connector on every open. It only reports; Landlock
enforces the allow-list in both modes.
If the host cannot provide something the table asks for, the program is killed
and the session fails; for a decider, the call counts as unanswered and
decider_fail_closed applies. With allow_degraded = true the program runs
with what was achieved, and the shortfall is logged and recorded on the
session. The check runs inside the program's process before exec.
The session's session.close row records the measured cage, next to the
transport:
On Linux the cage uses Landlock, namespaces, seccomp and cgroup v2. It does not
contain a kernel exploit; for that, use a virtual machine or run the server
elsewhere and reach it over addr or url.
The cage on Windows¶
On Windows the same table is built from an AppContainer and a job object. The
connector starts the program through gdsgate.exe __contain, which places it
in both before it runs and checks the result.
| Setting | On Windows |
|---|---|
| filesystem | Granted for the run: the program's directory, read_only_paths, read_write_paths, the backend's private directory. Windows system directories and Program Files are readable by every AppContainer. |
protect_home, private_tmp |
Always on: HOME, USERPROFILE, TEMP, TMP, APPDATA and LOCALAPPDATA point to the private directory. |
private_network |
No network capability; loopback is closed too. Needs the Base Filtering Engine service running; without it the cage reports netns=no. |
restrict_address_families |
A list without AF_INET and AF_INET6 closes the network. Unix-domain sockets are files and follow the filesystem grants. |
memory_max, tasks_max, cpu_quota |
Job object limits; cpu_quota is converted to a share of the machine. |
report_denied_paths |
Refused at startup. |
A grant on a read_write_paths tree is written to every file under it and
removed the same way when the program ends.
allow_degraded and the session.close record work as on Linux.
A remote MCP server over HTTP¶
url names a remote server over Streamable HTTP. Each message is an HTTP POST
to the URL; the answer is a JSON object or an event stream with progress
notifications and then the response. Relay, Cedar decisions, audit rows and
descriptor pinning are the same as for addr and command.
addr speaks newline-delimited JSON-RPC over a socket; remote MCP servers do
not serve it. Use url for them.
https is verified against the platform roots plus ca_path; verification
cannot be turned off. A server credential is read from credential_file and
shaped by credential_header / credential_format, with the same checks as the
model lane.
A 3xx from the server is not followed; the caller gets a refusal.
A tool must be listed before it is called: the listing tells the connector
which arguments to mirror into Mcp-Param-* headers. A call to a tool never
listed is refused with a message saying so. Listings are remembered per
backend, so a client may list on one connection and call on another.
The external decider¶
decider_url / decider_command connects an external program that judges call
content (prompt injection, argument paths, exfiltration patterns). Before a
data-path call leaves for the server, the decider is asked synchronously and
the call waits.
The answer is allow or block:
A decider cannot modify a call. The allow-list, the Cedar decision and descriptor pinning run first; the decider is asked only about calls they permitted.
The decider receives what the audit chain records for the call: caller and owner, resource, session, method, name, and arguments after secret redaction and the size cap. It does not receive the raw line, the JSON-RPC id or the caller's certificate.
{
"gdsgate": "mcp.decide/1",
"session_uid": "<session-uid>",
"resource": "ai-tools",
"actor": "Agent::\"builder\"",
"owner": "User::\"alice\"",
"method": "tools/call",
"name": "read_file",
"arguments": "{\"path\":\"/srv/docs/changelog\"}",
"arguments_truncated": false
}
Every failure to answer is handled by decider_fail_closed (default true:
refuse):
| Failure | Recorded as |
|---|---|
| the decider could not be reached, or the program would not start | unreachable |
| the deadline passed | timed_out |
a decider_command exited non-zero, or was killed |
exit_status |
a decider_url answered outside 2xx, including a redirect (not followed) |
http_status |
| nothing came back | no_answer |
| the answer does not follow the format | malformed |
| the answer exceeded the size cap | too_large |
A decider_command that exits non-zero is a failure, whatever it printed.
With the default, a decider that is down stops every data-path call on the
backend; run it redundantly. decider_fail_closed = false lets the call
through, and mcp.decider_hook records it as undecided.
decider_command starts a process per call: about 1.9 ms against about
0.35 ms for a POST to a decider on loopback. Use decider_url for real
traffic; decider_command suits a short script.
A decider_command runs on this node as the connector's user, like a
command server,
and the backend's cage confines it too. If the
cage cannot be established, the call counts as unanswered.
Every consultation writes one mcp.decider_hook row next to the
mcp.tool_call / mcp.resource_read / mcp.prompt_get row for the call:
| What happened | On the chain |
|---|---|
| policy refused the call | the call's row with Failure, and no mcp.decider_hook row, since the decider was not asked |
| the decider refused it | the call's row with Failure, and mcp.decider_hook with verdict=block and the decider's own reason |
| the decider could not answer | mcp.decider_hook with verdict=unanswered, the failure named, and refused saying whether it cost the call |
The decider's reason is written to the audit chain only. The caller is told
that the external decider refused the call.
Pinning tool descriptors¶
A tool descriptor is its name, description, input schema and annotations. A
server can change a descriptor after the tool was approved.
pin_tool_descriptors = true records and gates such changes. It is set on the
resource in Authority's catalog:
# Authority's configuration: the catalog
[[discovery.resources]]
id = "tools-mcp"
kind = "mcp"
port = 8765
pin_tool_descriptors = true
# The connector's configuration: no pinning switch of its own
[[connector.backends]]
resource = "tools-mcp"
kind = "mcp"
addr = "127.0.0.1:8765"
enforce_tool_policy = true # carries the round-trips the pin travels on
The same flag on a running cluster:
$ gdsgate authority set-resource tools-mcp --kind mcp --port 8765 --pin-tool-descriptors
created tools-mcp mcp project=default env=dev hostname=tools-mcp port=8765 pin_tool_descriptors=on
The catalog row is written by Authority's [discovery] or by
Authority.UpsertResource under the control-plane certificate and Cedar
manageCatalog; an enrolled connector is refused. pin_tool_descriptors under
[[connector.backends]] is refused at startup, naming the catalog as its
place.
| Event | Result |
|---|---|
First tools/list with a tool |
The descriptor digest becomes the baseline: mcp.descriptor_pinned. |
| A later descriptor differs | mcp.descriptor_changed; the listing is not refused. |
tools/call on a changed tool |
Refused. The caller runs gdsgate request-access for the tool; an approver runs gdsgate approve, which accepts the new descriptor: mcp.descriptor_repinned. |
| The server returns to the pinned descriptor | The refusal clears. |
| The connector reports no descriptors on a pinned resource | mcp.descriptors_unreported on every such listing. Watch for it after enabling pinning. |
The server's instructions change |
mcp.descriptor_changed against the server; nothing is refused. |
notifications/tools/list_changed |
Recorded as mcp.tools_changed and relayed; the next listing detects the change. |
The baseline is stored in the database, sealed with the inventory key of the
Authority's control keyring
([security.integrity]). Without that key, a
session with pinning refuses every call.
The records use the ResourceIntegrity class:
gdsgate authority audit list --class resource-integrity. Older records are
under Authorization; --code MCP-0301 and neighbouring codes match both. See
Operations → What lands in audit.
The first descriptor seen is trusted. A server that revises its tools triggers a refusal each time; enable pinning for servers with a stable tool set.
Behavior when Authority is unreachable¶
With enforce_tool_policy on, tools/list is filtered per caller by the Cedar
viewTools decision. When Authority is unreachable or returns an error, the
listing cannot be filtered.
By default the listing is forwarded as served, and
mcp.tool_listing_undecided (outcome = Failure) is recorded with reason
(unreachable or unconfigured), posture (refused or
forwarded_unfiltered) and tools (the number of tool names). tools/call
is refused while Authority is unreachable.
refuse_unfiltered_tool_listing = true answers the caller with a JSON-RPC
error and no listing.
[[connector.backends]]
resource = "tools-mcp"
kind = "mcp"
addr = "127.0.0.1:8765"
enforce_tool_policy = true # required: there is no filter without it
refuse_unfiltered_tool_listing = true
refuse_unfiltered_tool_listing |
During an Authority outage |
|---|---|
false (default) |
Sessions keep working; the caller sees names of tools it may not use, without descriptors or access. |
true |
The caller gets an error; MCP clients that cannot list tools stop working. |
A listing whose tool names cannot be parsed is forwarded unchanged in both
modes. A session with enforce_tool_policy on but no Authority client or
access certificate is refused at startup; if reached, it is recorded with
reason = unconfigured.
The model lane¶
A kind = "llm" backend is a model service in the catalog: delegated as
llm:call@<resource>, authorized by Cedar, audited, revocable, listed by
gdsgate ls.
Every call is decided before anything is sent to the provider. An llm
backend that cannot reach Authority refuses to start.
| Mode | What the provider sees |
|---|---|
Substitution, credential_file set |
The organisation's key from credential_file, plus only the headers in allowed_request_headers and content negotiation. |
Pass-through, credential_file unset |
The caller's own credential. |
Under substitution, a header the provider requires (anthropic-version) must
be listed in allowed_request_headers; otherwise the provider refuses the call.
A query string is refused except for parameters in allowed_query_params.
credential_file is read once, at startup; restart the connector after
rotating the key.
Input and output token counters are written to the audit chain on every call;
this cannot be turned off. The row carries tokens=absent when the provider
sent no counters and tokens=unscannable when they could not be read.
Prompts and answers go to the recording store, streamed, with a SHA-256 sealed
into the chain and a recording://<uid> reference, readable under
viewRecordings.
| Level | What lands in the recording |
|---|---|
off |
Nothing; the counters still reach the chain |
digest (default) |
Sizes, the model's name, fingerprints of the bodies |
redacted |
Bodies through the secret redactor |
full |
Bodies whole, written chunk by chunk as they stream |
record_request and record_response are independent; any combination is
valid (off / full, redacted / off). The connector logs each resource's
levels at startup, as a warning where bodies are stored; gdsgate doctor
reports them per resource.
The lane carries POST only, and a body must be JSON with a top-level
model; anything else is refused. GET /v1/models gets 405, so an SDK that
starts by listing models does not work through it.
[connector.backends.discovery]¶
A live-discovery probe on a backend. The connector periodically enumerates the
backend's sub-resources under a read-only account. For a PostgreSQL host the
discovered databases show up in gdsgate ls as connectable <host>.<db>
resources; for an MCP server the discovered tools feed the per-identity
tools/list filter (viewTools) and are not added to gdsgate ls. Discovery
grants no access.
| Key | Type | Default | Applies to | Purpose |
|---|---|---|---|---|
mode |
string | unset, required | Postgres or MCP backend | What to enumerate: list_databases (a Postgres host's databases, surfaced in gdsgate ls) or list_tools (an MCP server's tools, used to filter tools/list, not surfaced in gdsgate ls). |
connection |
string | "" |
Postgres backend | Read-only connection string for the probe account (user and database), for example postgres://disc_ro@host/postgres. Postgres only; omit for list_tools. |
password_file |
string | "" |
Postgres backend | Path to a file holding the probe account's password. Postgres only. |
interval_secs |
u64 | 300 |
Postgres or MCP backend | How often to probe. |
allowlist |
array of patterns | [] |
Postgres or MCP backend | Globs applied before publishing. Empty allows all, subject to denylist. |
denylist |
array of patterns | [] |
Postgres or MCP backend | Globs applied after allowlist. |
freshness_secs |
optional u64 | unset; derived 4 × interval_secs |
Postgres or MCP backend | Validity window of a published snapshot. Past it, the entries drop out of listings until the next probe refreshes them. |
password_file accepts the secret-reference notation
in place of a bare path; it is read once, at startup.
For Postgres, use a separate, least-privilege account, with only SELECT on
pg_database; do not reuse the session_role service account. Name the
Authority's control keyring in
[security.integrity] to seal each published
snapshot with an integrity receipt.
[connector.backends.session]¶
Linux only. Every model A session gets its own cgroup under the connector's cgroup; this table adds ceilings and sets what happens to leftover processes on close. On Windows the table is refused at startup; see Model A on Windows. Also refused on any other backend kind and when empty.
| Key | Type | Default | Applies to | Purpose |
|---|---|---|---|---|
memory_max |
optional string | unset; no ceiling | SSH model A | Memory ceiling for the session and everything it starts, as a byte count with an optional K/M/G suffix ("2G"). Written to memory.max. |
tasks_max |
optional u64 | unset; no ceiling | SSH model A | How many processes the session may hold at once. Written to pids.max. 0 is refused. |
cpu_quota |
optional string | unset; no ceiling | SSH model A | CPU ceiling as a percentage of one core: "100%" is one core, "200%" two. Written to cpu.max. |
kill_on_close |
optional bool | unset ⇒ false |
SSH model A | true kills every process the session left running when it closes. false leaves them running, as sshd does (tmux, nohup, detached jobs). |
The session cgroup needs a delegated ancestor cgroup. On a systemd host set
Delegate=yes and DelegateSubgroup=supervisor (systemd 254+) on the
connector's unit and remove ProtectControlGroups=, or start it with
systemd-run --scope. Delegate=yes alone is not enough. Ceilings on a node
without a session cgroup refuse startup; without ceilings the session runs
without one, logged at debug level. gdsgate doctor reports the state on
state.session.frame.
Under login_mode = "host_user" the ceilings do not bind on a host whose login
stack loads pam_systemd; see Model A under a host account.
Model A under a host account¶
Linux only. login_mode = "host_user" starts the session as a host account
through the host's login program. Files, sudo and the login environment are
that account's.
Five requirements are checked at startup and reported by gdsgate doctor under
state.session.host-user.*. If any is missing the backend refuses to start and
lists every shortfall.
| Requirement | What is checked |
|---|---|
| uid 0 | the connector process is root |
| capabilities | CAP_SETUID, CAP_SETGID, CAP_DAC_OVERRIDE, CAP_AUDIT_WRITE in the effective set |
| user database | getent is on the node |
| login program | su or runuser is on the node |
| frame | a session can be given a cgroup of its own |
Every login_users name other than a marker is resolved in the node's user
database at startup; an unknown name refuses startup. gdsgate creates no
accounts. "$subject" is resolved at session start; the session is refused if
the host has no such account.
What the login brings, and what a git-only backend gives up¶
With ssh_profile = "general" the session starts through su -l: the
account's shell and home, pam_env PATH, pam_umask umask, pam_limits
limits, and the login shell reads the account's profile and shell functions
before the command runs.
With ssh_profile = "git" the recognised program runs directly: the account,
its groups and the repository directory are resolved before fork, and the child
drops privileges and enters the directory. No login environment applies.
Choosing the account¶
The client names the account as gdsgate ssh <login>@<resource>. Authority
evaluates connect once per candidate with context.login_user set to that
name, and the answer decides:
| Offer | Client names no login | Client names one |
|---|---|---|
| one account, and policy allows it | that account | must match the offer |
| several, and policy allows more than one | refused: name one, and the refusal lists what is open to this caller | must be one of them |
"$subject" |
the caller's own identity | must match it |
"*" |
refused: name one; the refusal lists nothing | any account the host holds, subject to policy |
none (login_mode = "connector") |
the connector's own account | refused |
The decided login is signed into the access certificate; the connector checks it against the session account. A certificate naming an account this backend does not offer is refused.
With "*", policy alone limits the account: a permit on connect that does
not read context.login_user permits every host account, root included. The
connector warns at startup and gdsgate doctor reports it. Pair a permit by
group with a forbid on accounts nobody may use:
forbid(principal, action == Action::"connect", resource is SshHost)
when { context has login_user && context.login_user == "root" };
What the login brings, and what it does not¶
The session runs the host's PAM stack for su-l or runuser-l. On Ubuntu
24.04: the account's login shell and home, pam_env login PATH, pam_umask
umask, and pam_limits. A limits.conf entry for the account applies; a
connector-mode session gets the connector service's limits.
On stock Debian and Ubuntu, loginuid stays unset (4294967295):
pam_loginuid is in the login and sshd stacks, not in su's. The host's
auditd then cannot attribute the session's actions. Add to
/etc/pam.d/su-l:
The session's loginuid is then the account's uid, which gdsgate records as
login_uid. gdsgate doctor reports it on state.session.host-user.loginuid.
pam_systemd, present in that stack, moves the session's processes to a
logind scope under user-<uid>.slice, out of the connector's session cgroup;
runuser does the same.
| Setting | Under host_user with pam_systemd |
|---|---|
kill_on_close |
Works: the connector empties the scope the session moved to, after checking it holds only this session. |
memory_max, tasks_max, cpu_quota |
Not applied. The connector warns at startup; gdsgate doctor reports it on state.session.host-user.login-stack. |
Limit such sessions with limits.conf for the account or properties on its
user-<uid>.slice. Without pam_systemd, the ceilings apply as written.
ssh -A is refused under this mode, at startup and on request.
File transfer¶
Under host_user, sftp and scp are served by the host's sftp-server,
started through the same login as the shell, with the account's permissions.
Under login_mode = "connector" file transfer runs inside the connector
process with the connector account's permissions. A model A connector running
as root serves sftp as root: a caller allowed connect can read
/etc/shadow and write under /root. Run a connector-terminated backend
under an ordinary account unless it uses host_user.
The connector looks for sftp-server in:
/usr/lib/openssh/sftp-server /usr/libexec/openssh/sftp-server
/usr/lib/ssh/sftp-server /usr/libexec/sftp-server
/usr/lib/sftp-server
With none of them, the SFTP subsystem is refused: ssh works, sftp and
scp do not (current OpenSSH scp uses SFTP). gdsgate doctor reports this
on state.session.host-user.file-transfer.
Audit rows are the same as in other modes: sftp.open, sftp.remove,
sftp.rename, sftp.mkdir, sftp.rmdir, and sftp.close with bytes read and
written. One extra sftp.served row names the account and the serving
program. A refused subsystem writes only that row, with Failure and the
reason.
The audit rows¶
session.open and session.close carry login_mode on every lane, including
connector; a row without it predates the key. Under host_user they also
carry login_account and login_uid, resolved per session. A session whose
account no longer exists is refused, and session.open records the failure.
A jump host's rows carry login_account (the downstream account requested)
with login_mode = "connector", and no login_uid.
Model A on Windows¶
A connector on Windows serves model A sessions as its own account: on a pseudoconsole (Windows 10 1809, Server 2019 and later) when the client asks for a terminal, on pipes otherwise.
| On Windows | |
|---|---|
| shell | %ComSpec% (cmd.exe) unless shell is set. The command after cmd.exe /c is passed as the client wrote it. |
| process tree | Every program a session starts is in a job object; ending the session ends all of them. |
| exit status | The program's exit code, including NTSTATUS values such as 0xC0000005. |
ssh -A |
SSH_AUTH_SOCK is a named pipe accessible to the connector's account. |
| SFTP | Paths are /C:/Users/...; / is the root of the home directory's drive. Modes are derived from the ACL and read-only; mode changes and symbolic links are refused. |
login_mode = "host_user", ssh_profile = "git" |
Refused at startup. |
[connector.backends.session] |
Not available; a backend with ceilings is refused at startup. |
The account on the host beyond¶
A model B session runs on the downstream sshd under an account of that host.
login_user names it and defaults to root; login_users names several, on a
backend that proposes onward_auth = "client_key".
Under onward_auth = "certificate", or with no mode in the file, the account is
one literal name, written into the onward certificate as its principal per
connection. "$subject" and "*" are refused at startup.
Under onward_auth = "client_key" no certificate is issued. The connection
uses the caller's key through the forwarded agent, and the account is the SSH
user name, taken from the session certificate at session start. "$subject"
is the caller's gateway identity. Policy decides which account a caller may use
through context.login_user, as in Choosing the account.
[[connector.backends]]
resource = "jump-fleet"
kind = "ssh"
addr = "sshd-target:22"
onward_auth = "client_key"
login_users = ["$subject", "deploy"]
The downstream host accepts a key for the account whose authorized_keys holds
it. Policy can narrow that set, not widen it.
| Symptom | Cause | What to do |
|---|---|---|
the host beyond this one accepted none of your keys for "alice@corp.example" |
the identity gdsgate knows the caller by is not what the account is called on that host | name the account, gdsgate ssh <login>@<resource>, or add the key to that account's authorized_keys there |
the session is refused: this backend names the account on the host beyond as "$subject" |
the catalog does not set client_key for this resource; a marker cannot be a certificate principal |
gdsgate authority set-resource <id> --onward-auth client_key |
the connector will not start: Name the downstream login, or declare onward_auth = "client_key" |
a model B backend names a marker and its own file proposes another mode | add onward_auth = "client_key" to the backend, or name one account |
the session is refused: the certificate settled on the account "root" beyond this host |
the catalog offers accounts this backend's file does not | make the two agree, on the resource or in the file |
Forward gating¶
For ssh -L (allow_local_forward) and ssh -R (allow_remote_forward), each
list entry is a pattern:
| Pattern | Meaning |
|---|---|
host:port |
exact host (case-insensitive), exact port |
host:lo-hi |
exact host, inclusive port range |
host:* |
exact host, any port |
*:port / *:lo-hi |
any host on this port or range |
CIDR:port / CIDR:lo-hi |
the request host must be a literal IP in the CIDR (IPv4 or IPv6) |
[ipv6]:port |
IPv6 host in square brackets, to avoid colon ambiguity |
Examples:
allow_local_forward = [
"redis.internal:6379",
"10.0.0.0/8:5432",
"[fd00::/64]:6379",
"metrics.internal:*",
"*:443",
]
allow_remote_forward = ["127.0.0.1:8000-9999"]
With allow_local_forward = [] only loopback is reachable through -L; with
allow_remote_forward = [], -R is disabled. The lists are Layer 1; the Cedar
actions sshForwardLocal / sshForwardRemote are Layer 2, per user and
session. Both must permit the forward.
For ssh -A, Layer 1 is the switch allow_agent_forward (default false) and
Layer 2 is the Cedar action sshForwardAgent:
While the session lives, any process that reaches the forwarded socket (the
shell, its children, root on the Connector host) can authenticate with the
client's keys. When either layer refuses, no socket is created and
SSH_AUTH_SOCK is not set. Both outcomes are audited as sshForwardAgent
decisions naming the resource and the session.
Complete backend examples¶
Each block is a complete TOML document for one connector backend. Hostnames
use the reserved .invalid suffix; file paths are placeholders. Replace them,
then run gdsgate doctor --config <file> before serving traffic.
SSH, model A (connector-terminated)¶
[connector]
id = "edge-ssh-local"
[[connector.backends]]
resource = "jump-shell"
kind = "ssh"
allow_local_forward = ["127.0.0.1:8000-8100"]
allow_remote_forward = []
SSH, model A under a host account¶
The connector must run as root on this node; see Model A under a host account.
[connector]
id = "edge-ssh-local"
[[connector.backends]]
resource = "build-box"
kind = "ssh"
login_mode = "host_user"
login_users = ["alice", "deploy"]
[connector.backends.session]
kill_on_close = true
SSH, model B (jump host)¶
[connector]
id = "edge-ssh-jump"
[[connector.backends]]
resource = "jump-fleet"
kind = "ssh"
addr = "sshd.example.invalid:22"
login_user = "deploy"
# Development only: pin host_key_fingerprints_file before real traffic.
allow_unpinned_upstream = true
PostgreSQL¶
[connector]
id = "edge-postgres"
[[connector.backends]]
resource = "orders-db"
kind = "postgres"
addr = "postgres.example.invalid:5432"
allowed_backend_hosts = ["postgres.example.invalid:5432"]
CockroachDB¶
The connector backend uses kind = "cockroach"; the catalog resource uses
kind = "postgres".
[connector]
id = "edge-cockroach"
[[connector.backends]]
resource = "orders-roach"
kind = "cockroach"
addr = "roach.example.invalid:26257"
allowed_backend_hosts = ["roach.example.invalid:26257"]
[[discovery.resources]]
id = "orders-roach"
kind = "postgres"
port = 26257
MySQL¶
[connector]
id = "edge-mysql"
[[connector.backends]]
resource = "legacy-db"
kind = "mysql"
addr = "mysql.example.invalid:3306"
allowed_backend_hosts = ["mysql.example.invalid:3306"]
Kubernetes¶
[connector]
id = "edge-kubernetes"
[[connector.backends]]
resource = "prod-cluster"
kind = "kubernetes"
api_url = "https://kubernetes.example.invalid:6443"
ca_path = "secrets/kubernetes-ca.pem"
token_path = "secrets/kubernetes-token"
allow_port_forward = false
TCP¶
[connector]
id = "edge-tcp"
[[connector.backends]]
resource = "metrics"
kind = "tcp"
addr = "metrics.example.invalid:9090"
allowed_backend_hosts = ["metrics.example.invalid:9090"]
MCP over a socket¶
[connector]
id = "edge-mcp"
[[connector.backends]]
resource = "tools"
kind = "mcp"
addr = "mcp.example.invalid:8765"
allowed_backend_hosts = ["mcp.example.invalid:8765"]
allowed_tools = ["search", "read"]
LLM¶
[connector]
id = "edge-llm"
[[connector.backends]]
resource = "model-service"
kind = "llm"
addr = "https://model.example.invalid/v1"
allowed_backend_hosts = ["model.example.invalid:443"]
credential_file = "secrets/model-api-key"
allowed_models = ["safe-model"]
Web application¶
[connector]
id = "edge-web"
[[connector.backends]]
resource = "portal"
kind = "web"
url = "https://portal.example.invalid"
allowed_backend_hosts = ["portal.example.invalid:443"]
Web egress¶
[connector]
id = "edge-web-egress"
[[connector.backends]]
resource = "outbound"
kind = "web-egress"
allow = ["api.example.invalid", "packages.example.invalid"]
inspect = false
# Only for non-public ranges this network routes; default: none.
# routed_ranges = ["fec0::/10"]
These examples cover the connector side only. Add the catalog entry to
Authority's [[discovery.resources]], and the [endpoints] and [enroll]
settings from their pages.