Skip to content

Connector configuration

Apply supported changes with gdsgate reload --role connector. See the reload matrix for settings that require restart and the treatment of existing sessions.

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.

[connector]
id             = "edge-1"
authority_transport = "fronted"

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:

  1. Leadership moves. The connector's stream still ends at the old leader.
  2. The connector's next call there is refused with "unavailable". The connector drops that stream and opens a new one for its next call.
  3. 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 an explicit Authority scope for a manual Connector and the kube- prefix before starting discovery. For an automatic Connector, replace kube- with its assigned prefix from authority scopes list, such as conn-k8s/; do not allocate a second scope. Sources retain independent target sets; automatic publication combines them with static backends under one node-wide generation. See publication modes.

[[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 Local backend ID; id is an accepted alias. Automatic publication qualifies it with the assigned namespace; explicit manual bindings can map it to another catalog ID. Legacy manual nodes use the catalog ID directly. Clients request the admitted catalog ID.
kind enum unset; required every backend entry One of the ten connector backend kinds. The catalog resource vocabulary is narrower; see the discovery boundary.
port optional u16 protocol standard port automatic catalog publication Client-facing port, 1–65535. Required for TCP, MCP, LLM and web-egress. Independent of the upstream addr or url.
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 host-keys learn.
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 resources list 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.

[[connector.backends]]
resource = "redis-cache"
kind     = "tcp"
port = 9090
addr     = "127.0.0.1:32768"

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 host-certs learn <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 sessions 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"
port = 8765
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 resources list`; 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"
port = 8765
# 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"
port = 443
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"
port = 3128
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"
port = 3128
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 delegations create --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 delegations create --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 delegations create --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"
port = 3128
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 ca rotate jwt (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 delegations create --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:

"transport":"stdio","cage":"landlock=FullyEnforced,fs=enabled,netns=enabled"

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:

{"decision": "allow"}
{"decision": "block", "reason": "path outside the project root"}

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"
port = 8765
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 resources set 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 requests create for the tool; an approver runs gdsgate requests 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"
port = 8765
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 resources list.

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 resources list 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 resources list. 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 resources list) or list_tools (an MCP server's tools, used to filter tools/list, not surfaced in gdsgate resources list).
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 connect <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:

session    required     pam_loginuid.so

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 connect <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 resources set <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:

[[connector.backends]]
resource = "jump-host"
kind     = "ssh"
allow_agent_forward = true

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"
host_key_fingerprints_file = "/etc/gdsgate/sshd-host-keys"

Supply the downstream host fingerprints through a trusted channel. See learning host keys. The default automatic scope requires pins; allow_unpinned_upstream = true cannot bypass it.

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

Use setup kubernetes configure to configure API access, discovery only, or both. It generates the existing backend/source declarations and retains overrides on repeated runs.

[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"
port = 9090
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"
port = 8765
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"
port = 443
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"
port = 3128
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. Add [endpoints] and [enroll] settings from their pages. An automatic Connector publishes these local backend IDs under its assigned namespace; no manual catalog declaration is needed. A manual Connector needs matching Authority catalog entries and serving assignments. See publication and migration.