Skip to content

Configuration

gdsgate reads one TOML cluster configuration file given with --config. Every field is optional: missing fields fall back to a loopback development profile, then any GDSGATE_* environment variable overlays the result. With no --config and no environment overrides, the binary runs the all-in-one development defaults.

gdsgate --config /etc/gdsgate/gdsgate.toml <role>

The same file shape serves every role; each role reads the sections it needs:

  • Auth reads store_url, [endpoints], [policy], [oidc], [ca_rotation], [ha], [discovery], [approvals].
  • Proxy reads [endpoints], [enroll], [proxy].
  • Agent reads [endpoints], [enroll], [agent] (including every [[agent.backends]]).
  • Client reads [endpoints], [client], [oidc].

Layering

The effective config is built in three layers:

  1. Defaults. The loopback dev profile (every field has one).
  2. TOML file from --config. Any field present overwrites the default.
  3. Environment overlay. Any GDSGATE_* variable present overwrites the file. An unset variable changes nothing.

Use the layers to keep secrets and per-host values out of committed files — see Environment overrides.

Top level

Key Type Default Purpose
profile string "local" Free-form deployment name (logged).
store_url string "sqlite::memory:" State backend Auth consumes — audit chain, the transport CA's private key, the SSH CAs, registration tokens, resource catalog. SQLite (file or :memory:) or PostgreSQL.
profile = "prod-eu"
store_url = "postgres://gdsgate:gdsgate@store-db:5432/gdsgate"

In production use a persistent backend (file SQLite or PostgreSQL). sqlite::memory: loses everything on restart — only useful for tests.

[endpoints]

Bind targets (for the service that owns the listener) and dial targets (for peers that consume it), as host:port. Bind 0.0.0.0 in containers; dial by service name or DNS.

Key Default Purpose
auth 127.0.0.1:50051 Auth gRPC control plane (mutual TLS once the cluster is registered). The Proxy dials this; an agent dials it for two-layer SSH-forward decisions and for downstream-cert issuance.
auth_failover [] Additional Auth endpoints for HA. The Proxy's connection pool follows the audit write-leader across these (a follower answers with "unavailable"; the pool tries the next).
auth_enroll 127.0.0.1:50050 Auth's plaintext bootstrap listener. New nodes register here (the one-time token is the proof).
proxy_public 127.0.0.1:50061 Public, client-facing Proxy listener (gRPC over TLS in a registered cluster; plaintext gRPC in dev).
proxy_internal 127.0.0.1:50062 Agent-facing reverse-tunnel listener (mutual TLS).
proxy_ws 127.0.0.1:50063 Agent-facing WebSocket fallback listener.
[endpoints]
auth          = "auth:50051"
auth_failover = ["auth-2:50051", "auth-3:50051"]
auth_enroll   = "auth:50050"
proxy_public  = "0.0.0.0:50061"
proxy_internal = "0.0.0.0:50062"
proxy_ws      = "0.0.0.0:50063"

[enroll]

Set on a standalone Proxy or Agent so it obtains its transport identity and runs internal mutual TLS. When endpoint is set and a token is supplied (in the file or via GDSGATE_ENROLL_TOKEN), the node registers on first start; on subsequent starts it reuses the persisted identity in state_dir.

Key Type Purpose
endpoint string Auth registration URL, e.g. http://auth:50050. Without it the node runs plaintext (dev / loopback only).
token string One-time registration token. Prefer GDSGATE_ENROLL_TOKEN to keep the secret out of a long-lived file.
state_dir string Directory holding the persisted transport identity (transport.key, transport.crt, transport-ca.pem) and (for an agent serving SSH model A) the persistent SSH host key (ssh_host_ed25519_key). Defaults to a per-role directory under ~/.gdsgate/state.
[enroll]
endpoint  = "http://auth:50050"
state_dir = "/var/lib/gdsgate/proxy"
# token is supplied at boot via GDSGATE_ENROLL_TOKEN

The token is single-use

Auth consumes the registration token once. After registration the node runs off its persisted identity and re-registers near expiry — with a fresh token. Do not bake long-lived tokens into the file; supply them out of band on boot.

[client]

Trust anchor for the client commands (login, ssh, db, kube, tcp, mcp, ca).

Key Type Purpose
transport_ca string Path to the cluster transport CA anchor (PEM) used to verify the Proxy's public TLS. The same transport-ca.pem a registered Proxy / Agent writes into its state_dir. Without it the client connects to http://… plaintext (dev / loopback only).
[client]
transport_ca = "/etc/gdsgate/transport-ca.pem"

[proxy]

Proxy-only knobs. Off by default — the deployment shape uses separate listeners.

Key Type Purpose
single_port string When set, the Proxy multiplexes the client gRPC API and the agent WebSocket fallback onto one listen address. Each connection is peeked (TLS ClientHello / HTTP upgrade) and routed accordingly. The primary mutual-TLS reverse-tunnel stays on proxy_internal.
[proxy]
single_port = "0.0.0.0:443"

Use this when egress only allows HTTPS on :443 (corporate networks): clients and agent-WS share the port, the gRPC reverse tunnel stays on a separate internal port.

[policy]

Key Type Purpose
path string Path to a Cedar policy document Auth loads at startup. Without it Auth runs the deny-all bootstrap policy (denies every action — for dev / first start only).
[policy]
path = "/etc/gdsgate/policy.cedar"

The policy is strict-validated against gdsgate's Cedar schema at load (and may be validated independently with gdsgate auth policy validate). See Policy for the schema and patterns.

[oidc]

External OIDC identity provider. With issuer and client_id set, Auth verifies identity tokens against the provider's published signing keys (JWKS, RS256), and gdsgate login runs the device flow against it. Unset → the built-in development issuer (HS256, the client mints a local dev token — for development only).

Key Type Purpose
issuer string Issuer base URL, e.g. https://idp.example.com/realms/gdsgate. Discovery is fetched from <issuer>/.well-known/openid-configuration. The token's iss claim must equal this exactly.
client_id string OAuth client id registered for gdsgate at the identity provider.
audience string Expected aud claim. Defaults to client_id when unset (the common case for an OIDC id_token).
[oidc]
issuer    = "https://idp.example.com/realms/gdsgate"
client_id = "gdsgate"
# audience defaults to client_id when unset

Issuer must match exactly

The configured issuer, the issuer the provider advertises in its discovery document, and the iss claim of every token must all be byte-identical. Reach the provider by a single hostname from every node and every client.

[ca_rotation]

Scheduled rotation of the User SSH CA (and the same controller is reused for manual Onward SSH CA rotation). Off by default; manual rotations through gdsgate auth rotate-ca work regardless of enabled.

Key Type Default Purpose
enabled bool false Master switch for the scheduled rotation.
interval_secs u64 2592000 (30 days) Cadence. 0 disables the schedule.
proactive_secs u64 604800 (7 days) Also rotate when the active generation has less than this much validity left. 0 disables the safety net.
propagation_secs u64 300 (5 min) After publishing the candidate, wait this long for verifiers (Proxy, agents) to refresh their trust bundle before promoting it to the signer.
retire_secs u64 3600 (1 h) After promotion, wait this long before retiring the old CA. Set this ≥ the issued-certificate TTL so existing certificates expire naturally before their signer is removed.
check_secs u64 3600 How often the controller wakes up to check whether a rotation is due.
[ca_rotation]
enabled          = true
interval_secs    = 2592000   # 30 days
proactive_secs   = 604800    # 7 days
propagation_secs = 300
retire_secs      = 3600
check_secs       = 3600

[ha]

High availability for Auth: many instances behind one shared PostgreSQL store_url, with one audit write-leader at a time (the linear audit chain needs one writer). Off by default.

Key Type Default Purpose
enabled bool false Master switch.
owner string "" This instance's lease owner id. Empty → a per-process id is generated. Set it explicitly when you want stable, observable leadership.
lease_ttl_secs u64 15 Lease validity. A follower takes over this long after the leader stops renewing.
renew_secs u64 5 How often the leader renews the lease (and a follower retries acquiring). Keep well below lease_ttl_secs so transient hiccups do not drop the lease.
[ha]
enabled        = true
owner          = "auth-eu-1"
lease_ttl_secs = 15
renew_secs     = 5

HA here is failover, not write scaling. Followers refuse issuance and switch only when they win the lease.

[approvals]

Just-in-time access thresholds. The number of distinct approvers a gdsgate request-access needs is resolved by a cascade, narrowest wins:

  1. Per-resource ([[discovery.resources]].min_approvers),
  2. Per-environment ([approvals].per_environment),
  3. Global ([approvals].min_approvers).

The floor is 1.

Key Type Default Purpose
min_approvers usize 1 Global default.
per_environment table of string → usize {} Per-environment override, keyed by environment name (prod, staging, dev, …).
[approvals]
min_approvers = 1

[approvals.per_environment]
prod    = 2
staging = 1
dev     = 1

[discovery]

The resource catalog Auth seeds into the store on startup. Powers RBAC-filtered listing (gdsgate ls), attribute / label policies on the Cedar side (live resource metadata is read on every authorisation), and the per-resource JIT approval threshold cascade.

The catalog is not required for the data plane to work — agents declare what they serve at registration and the Proxy routes by that. But it is required for any policy that reads resource.environment or resource.getTag(…).

[[discovery.resources]] — declared resources

Key Type Default Purpose
id string Resource id. Matches what the agent registers and what clients request. Required.
kind string One of ssh, postgres, mysql, kubernetes, tcp, mcp. Selects the Cedar entity type (Server / Database / KubernetesCluster / TcpService / McpServer). Required.
project string "default" Owning project (Cedar parent: <entity> in Project::"…"). Use to partition by team / tenant.
min_approvers usize unset Resource-local JIT threshold — the narrowest level of the approvals cascade.

[[discovery.import_rules]] — label / environment rules

Applied in order to every declared resource whose id matches the glob. Environment defaults to dev; the last matching rule wins. Labels merge across matches (later wins per key).

Key Type Purpose
match string Glob over resource id (only * is special; matches any run, including empty). Required.
environment string Environment to set on a match.
labels table of string → string Labels merged on a match — exposed to Cedar as resource tags (resource.getTag("team")).

A full discovery example

[[discovery.resources]]
id            = "web-01"
kind          = "ssh"
project       = "frontend"

[[discovery.resources]]
id            = "web-02_prod"
kind          = "ssh"
project       = "frontend"
min_approvers = 2

[[discovery.resources]]
id            = "db-orders_prod"
kind          = "postgres"
project       = "backend"
min_approvers = 3

[[discovery.resources]]
id = "redis-cache_prod"
kind = "tcp"

[[discovery.resources]]
id = "tools-mcp"
kind = "mcp"

# Glob rules:
[[discovery.import_rules]]
match       = "*_prod"
environment = "prod"
labels      = { criticality = "high", on_call = "primary" }

[[discovery.import_rules]]
match       = "*_staging"
environment = "staging"
labels      = { criticality = "medium" }

[[discovery.import_rules]]
match  = "web-*"
labels = { team = "frontend", tier = "edge" }

[[discovery.import_rules]]
match  = "db-*"
labels = { team = "backend", owner_squad = "orders" }

The catalog is seeded once at Auth startup; changes need an Auth restart (there is no hot reload in v1).

[agent]

What an agent serves. The fields outside [[agent.backends]] are agent-wide; the backends list is per-resource.

Key Type Default Purpose
id string "local" Identifier this agent registers under at the Proxy. Two agents with the same id race for the same tunnel slot — pick distinct ids per agent.
backends array of tables [] The resources this agent serves. Empty → the agent registers nothing.

[[agent.backends]] — one resource per entry

Fields, with a per-kind legend below the table.

Key Type Used by Purpose
resource string every kind The id clients request and the Proxy routes on.
kind enum every kind One of ssh, tcp, postgres, mysql, mcp, kubernetes.
addr string tcp, postgres, mysql, mcp, ssh (model B) Local backend host:port. Required for every kind except ssh model A (the agent terminates SSH itself).
allowed_tools array of strings mcp The tool allow-list enforced on tools/call. Ignored otherwise.
api_url string kubernetes Cluster API base URL, e.g. https://k8s:6443.
ca_path string kubernetes PEM CA bundle verifying the cluster API's serving certificate.
token_path string kubernetes Service-account bearer token the agent presents to the API (the impersonator).
login_user string ssh (model B) POSIX user on the downstream host — becomes the principal of the OpenSSH certificate Auth signs per connection. Defaults to root.
host_key_fingerprints array of strings ssh (model B) Pinned SHA256:<base64> fingerprints of the downstream sshd's host key (the form ssh-keyscan prints). The agent refuses any other host key. Empty → trust-on-first-use (a warning is logged).
host_key_fingerprints_file string ssh (model B) Path to a file with one SHA256:… per line — appended to host_key_fingerprints at startup. Useful when an external script populates the file (e.g. gdsgate ca learn-host-key).
allow_local_forward array of patterns ssh (model A and B) Layer-1 SSRF border for ssh -L. See Forward gating. Empty → loopback only.
allow_remote_forward array of patterns ssh (model A and B) Layer-1 SSRF border for ssh -R. Empty → -R is disabled.
db_roles array of {name, db_user} postgres, mysql Session-role profiles: a logical role name Cedar can authorise via context.db_role, bound to a real pre-created DB user.
admin string reserved Future use — admin credential for the resource (resource-discovery design, decision #1). Not consumed by v1.

Per-kind reference

[[agent.backends]]
resource = "jump-host"
kind     = "ssh"
# No addr — the agent 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 agent 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 all work; -L and -R are subject to the two-layer forward gating.

The agent's SSH host key is persistent across sessions: at first start it is generated and stored under [enroll].state_dir as ssh_host_ed25519_key (mode 0600). Re-using the same key keeps a client's ~/.ssh/known_hosts entry valid.

[[agent.backends]]
resource              = "jump-fleet"
kind                  = "ssh"
addr                  = "sshd-target:22"
login_user            = "deploy"
host_key_fingerprints = ["SHA256:abcd…"]
# …or load fingerprints from a file populated by deploy-time scripts:
host_key_fingerprints_file = "/etc/gdsgate/sshd-target.fp"

The agent terminates the client's SSH (to record it), then opens its own SSH session to the downstream sshd and relays every channel. Authentication to the downstream sshd is by an OpenSSH user certificate Auth signs per connection with its Onward SSH CA, with principal = login_user. Pin the downstream's host key — trust-on-first-use is fine for dev only.

[[agent.backends]]
resource = "prod-db"
kind     = "postgres"
addr     = "10.0.0.5:5432"

[[agent.backends.db_roles]]
name    = "readonly"
db_user = "ro_login"

[[agent.backends.db_roles]]
name    = "writer"
db_user = "rw_login"

The agent forwards the database wire stream to addr and taps the client→server direction to emit a structured query log into the audit chain.

db_roles declares session-role profiles: a logical role name Cedar authorises via context.db_role, bound to a real pre-created DB user. The configurator that owns the database admin credential is the source of truth for these mappings.

[[agent.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 agent 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 — the user cannot pick who they impersonate.

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

Raw TCP byte forward to addr. Authorisation is tcpConnect. Used for Redis, internal HTTP, gRPC services, anything plaintext.

[[agent.backends]]
resource      = "tools-mcp"
kind          = "mcp"
addr          = "127.0.0.1:8765"
allowed_tools = ["search", "read_file"]

The agent forwards JSON-RPC verbatim, but each tools/call is gated against allowed_tools before it reaches the backend, and every tool call is audited. A denied tool returns a JSON-RPC error to the client.

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 / range
CIDR:port / CIDR:lo-hi 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"]

Defaults are conservative: with allow_local_forward = [], only loopback is reachable through -L; with allow_remote_forward = [], -R is disabled. Both lists are Layer 1; the Cedar policy (sshForwardLocal / sshForwardRemote) is Layer 2 (per user / per session). Both must permit the forward.

Environment overrides

Every GDSGATE_* variable overlays the file (an unset variable changes nothing). Use for secrets and per-host values.

Variable Overrides
GDSGATE_PROFILE profile
GDSGATE_STORE_URL store_url
GDSGATE_AUTH_ADDR endpoints.auth
GDSGATE_AUTH_ENROLL_ADDR endpoints.auth_enroll
GDSGATE_PROXY_PUBLIC_ADDR endpoints.proxy_public
GDSGATE_PROXY_INTERNAL_ADDR endpoints.proxy_internal
GDSGATE_PROXY_WS_ADDR endpoints.proxy_ws
GDSGATE_ENROLL_ENDPOINT enroll.endpoint
GDSGATE_ENROLL_TOKEN enroll.token
GDSGATE_ENROLL_STATE_DIR enroll.state_dir
GDSGATE_CLIENT_TRANSPORT_CA client.transport_ca
GDSGATE_OIDC_ISSUER oidc.issuer
GDSGATE_OIDC_CLIENT_ID oidc.client_id
GDSGATE_OIDC_AUDIENCE oidc.audience
GDSGATE_POLICY_PATH policy.path
GDSGATE_PROXY_SINGLE_PORT proxy.single_port
GDSGATE_AGENT_ID agent.id

A few client-only variables are not config overrides but switches:

Variable Purpose
GDSGATE_ID_TOKEN Identity token a client command should present (headless / CI).
GDSGATE_USER Local-dev principal name when no [oidc] is configured.
RUST_LOG Log filter (default info).

The [[agent.backends]] list is structured data; there is no environment override for it — it is file-only.

Per-role recipes

Minimum-viable configs for each role.

Auth-only (multi-node, control plane)

profile   = "prod"
store_url = "postgres://gdsgate:…@store-db:5432/gdsgate"

[endpoints]
auth        = "0.0.0.0:50051"
auth_enroll = "0.0.0.0:50050"

[policy]
path = "/etc/gdsgate/policy.cedar"

[oidc]
issuer    = "https://idp.example.com/realms/gdsgate"
client_id = "gdsgate"

[ca_rotation]
enabled       = true
interval_secs = 2592000

[approvals]
min_approvers = 1
[approvals.per_environment]
prod = 2

[[discovery.resources]]
id   = "prod-db"
kind = "postgres"

# (more catalog entries…)

Proxy-only

profile = "prod"

[endpoints]
auth          = "auth:50051"
auth_failover = ["auth-2:50051"]
proxy_public  = "0.0.0.0:50061"
proxy_internal = "0.0.0.0:50062"
proxy_ws      = "0.0.0.0:50063"

[enroll]
endpoint  = "http://auth:50050"
state_dir = "/var/lib/gdsgate/proxy"
# GDSGATE_ENROLL_TOKEN at boot

Agent-only

profile = "prod"

[endpoints]
auth          = "auth:50051"
proxy_internal = "proxy:50062"
proxy_ws      = "proxy:50063"

[enroll]
endpoint  = "http://auth:50050"
state_dir = "/var/lib/gdsgate/agent"

[agent]
id = "edge-1"

[[agent.backends]]
resource = "prod-db"
kind     = "postgres"
addr     = "10.0.0.5:5432"

[[agent.backends]]
resource = "jump-host"
kind     = "ssh"
allow_local_forward  = ["redis.internal:6379"]
allow_remote_forward = ["127.0.0.1:8000-9999"]

Client

profile = "prod"

[endpoints]
proxy_public = "gdsgate.example.com:50061"

[client]
transport_ca = "/etc/gdsgate/transport-ca.pem"

[oidc]
issuer    = "https://idp.example.com/realms/gdsgate"
client_id = "gdsgate"

All-in-one (single-node deployment)

profile   = "edge"
store_url = "sqlite:///var/lib/gdsgate/state.db?mode=rwc"

[endpoints]
auth           = "0.0.0.0:50051"
auth_enroll    = "0.0.0.0:50050"
proxy_public   = "0.0.0.0:50061"
proxy_internal = "0.0.0.0:50062"
proxy_ws       = "0.0.0.0:50063"

[policy]
path = "/etc/gdsgate/policy.cedar"

[oidc]
issuer    = "https://idp.example.com/realms/gdsgate"
client_id = "gdsgate"

[agent]
id = "embedded"

# (backends served by the embedded agent, if any)

External agents can still register against this all-in-one cluster as long as store_url is persistent.