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.
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:
- Defaults. The loopback dev profile (every field has one).
- TOML file from
--config. Any field present overwrites the default. - 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. |
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). |
[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. |
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). |
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 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:
- Per-resource (
[[discovery.resources]].min_approvers), - Per-environment (
[approvals].per_environment), - 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, …). |
[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.
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.