Discovery configuration¶
Paths in the tables are relative to [discovery] or the nested array-table
heading. Check parsing, unknown keys, and cross-field rules with
gdsgate doctor --config <PATH> --json. See also the resource catalog
lifecycle.
[discovery]¶
Used by: Authority.
The resource catalog Authority writes to the store at start-up. It is used by
gdsgate resources list filtering, by Cedar policies on resource attributes and labels
(read on every authorization), and by per-resource JIT approval thresholds.
Authority checks catalog identity and publication admission before authorization. Automatic static backends and discovered targets use the enrolled node's assigned namespace and scope. Policies read the admitted resource's environment and labels. See publish and migrate resources.
The catalog can also be changed through Authority's catalog API. Each Authority
start upserts the rows declared here and removes nothing. Keep [discovery]
empty when the catalog is managed through the API. See
Admin guide → Resource catalog lifecycle.
| Key | Type | Default | Purpose |
|---|---|---|---|
publication |
manual or auto |
manual |
Initial enrollment policy for new Connector identities. authority init and setup prepare explicitly write auto for new installations. Omission in an existing configuration keeps manual. Used once when the shared policy is absent. An existing cluster policy wins on restart; existing nodes keep their assigned mode and namespace. |
lease_sweep_secs |
u64 | 60 |
Interval at which Authority deletes catalog rows with expired leases. The lease length is lease_secs on each auto-scope. |
[[discovery.resources]]: declared resources¶
| Key | Type | Default | Purpose |
|---|---|---|---|
id |
string | unset, required | Resource id. Matches what the connector registers and what clients request. |
kind |
string | unset, required | One of ssh, postgres, mysql, kubernetes, tcp, mcp, llm, web, web-egress, rdp. Selects the Cedar entity type (SshHost, Database, KubernetesCluster, TcpService, McpServer, LlmService, WebApp, WebEgress, RdpHost). The catalog has no cockroach resource kind. |
project |
string | "default" |
Owning project (Cedar parent: <entity> in Project::"<project>"). Use it to partition by team or tenant. |
min_approvers |
usize | unset | Resource-level JIT approval threshold, the narrowest level of the approvals cascade. |
hostname |
string | the resource id, when it is a legal name | Name clients can use for the resource. Relative DNS name: case-insensitive labels of at most 63 letters, digits or -, without empty labels or edge hyphens; at most 189 bytes in total. An id with _ or . has no name until hostname is set. |
aliases |
array of string | [] |
More names for the resource, same rules as hostname. |
port |
u16 | ssh 22, postgres 5432, mysql 3306, kubernetes 6443, web 80, rdp 3389 |
Port clients connect on. Required for tcp, mcp, llm and web-egress. Not related to the connector-side [[connector.backends]] addr, which is never published. |
native_name |
string | unset | web only. The application's own domain (app.example.com, at least two labels). A resident client answers to it locally only when its user lists it in [client].intercept_names. It is the name the connector sends in Host ([[connector.backends]] url). hostname is a relative DNS name inside the client zone, such as demo-ssh.connector-a (resolved as demo-ssh.connector-a.gds). See Resident client → An application's own name. |
pin_tool_descriptors |
bool | false |
mcp only. The first descriptor seen for a tool becomes its baseline; a tools/call on a tool whose descriptor changed is refused until the change is approved. See Pinning tool descriptors. On another kind, start-up is refused. |
A [[connector.backends]] entry with kind = "cockroach" (CockroachDB, port
26257) is published as kind = "postgres" with port = 26257. The catalog
has no cockroach kind.
Names are unique across the catalog, native_name included. A duplicate name,
declared or taken from an id, is refused at start-up, as is a tcp, mcp,
llm or web-egress resource without a port.
[[discovery.import_rules]]: label and environment rules¶
Rules apply in order to each declared resource whose id matches the glob. The
environment defaults to dev; the last matching rule sets it. Labels merge
across matches; a later rule wins per key.
| Key | Type | Default | Purpose |
|---|---|---|---|
match |
string | unset, required | Glob over resource id. Only * is special and it matches any run, including empty. |
environment |
optional string | unset, resolved environment dev |
Environment to set on a match. |
labels |
table of string to string | {} |
Labels merged on a match and exposed to Cedar as resource tags. |
Reserved labels¶
These labels also change gdsgate behavior. Like other labels, they merge across
rules and are visible to Cedar through resource.getTag(...).
| Key | Value | Effect |
|---|---|---|
pin_src_ip |
"true" |
Access certificates for this resource are bound to the client's TCP peer address at issue time. The connector rejects a session from another address. Any other value or no label: not pinned. |
Choose the publication mode¶
Discovery follows the enrolled Connector's assignment. Changing the cluster's
default affects future enrollments. Inspect its Registry UUID and the scope whose node matches it:
| Connector assignment | Scope and target identity |
|---|---|
| Automatic | Authority creates the scope, for example conn-k8s/. A configured probe's scope must match it. Kubernetes targets include the local source ID and object identity in their qualified resource ID. |
| Manual | The administrator declares a scope for that Registry UUID. Its prefix, such as kube-, is used by the legacy probe examples below. |
Do not create an unrelated scope for an automatic Connector. It publishes one
node-wide snapshot of static resources and discovered targets inside its assigned
scope. Change the assigned scope's limits with authority scopes set, retaining
its prefix and node. This setter replaces the declaration: include every intended
kind, onward mode, label and limit. A prefix change does not move the namespace.
Automatic database and MCP probes publish inventory under their parent resource;
they do not create separate catalog rows for each database or tool. Kubernetes
list_kube_targets publishes independently addressable protocol targets.
[[discovery.auto_scopes]]: rows a connector writes¶
An explicit auto-scope lets a manual Connector write catalog rows within limits set here. Each row has a lease and is removed when the connector stops renewing it. Use it for short-lived targets: test databases, pods, cluster services.
Rows in a scope are listed by gdsgate resources list, matched by Cedar policies and
reachable by clients like other rows. They also store the scope, the lease, and
that they were written by a connector.
| Key | Type | Default | Purpose |
|---|---|---|---|
id_prefix |
string | unset, required | Prefix every resource id inside the scope starts with. A scope whose prefix covers a declared resource, or overlaps another scope's prefix, is refused at startup and by authority scopes set. |
node |
string | unset, required | Enrolled node id (gdsgate authority nodes list) of the connector allowed to publish here. Authority matches the publishing peer's verified key fingerprint against this node. |
kinds |
array of string | unset, required | Resource kinds a target may be published as: ssh, postgres, mysql, kubernetes, tcp, mcp, llm, web, web-egress, rdp. A snapshot carrying any other kind is refused. |
project |
string | "default" |
Project written on every row in this scope. |
environment |
string | unset, required | Environment written on every row in this scope. Not taken from the source. |
labels |
table of string to string | {} |
Labels written on every row in this scope, overriding source labels with the same key. |
accept_label_keys |
array of string | [] |
Label keys accepted from the source. Keys outside this list are dropped. pin_src_ip is always dropped. On rows from an agent host, agent is set by Authority to the work that declares the face, whatever this list holds; a face no declaration names gets no agent. labels overrides both. |
max_live |
usize | unset, required | Maximum rows the scope holds at once, counting rows from earlier snapshots whose leases have not run out. A snapshot that would leave the scope over it is refused in full. See choosing max_live. |
lease_secs |
u64 | unset, required | How long a row stays after the snapshot that carried it. |
fallback |
parent | deny |
parent |
What an absent row means. parent: the parent resource's rules apply; a database is authorized through its host, and a target created between two probe passes is reachable. deny: a target with no row is not served. |
ssh_host_key |
annotation | tofu |
annotation |
Where an ssh target's host-key fingerprint comes from. annotation: the source states it, and a target that does not is not served. tofu: the first key seen is accepted for the lease. |
onward_auth |
array of local | certificate | client_key |
[] |
Onward modes an ssh target here may name. Required when kinds holds ssh, and refused when it does not. A snapshot naming any other mode is refused in full. |
enabled |
bool | true |
Whether the scope accepts publishes. Setting it to false deletes every row the scope holds. |
[[discovery.auto_scopes]]
id_prefix = "test-"
node = "kube-conn-1"
kinds = ["postgres", "ssh"]
project = "qa"
environment = "test"
labels = { origin = "k8s-auto" }
accept_label_keys = ["app", "pr"]
max_live = 2000
lease_secs = 900
onward_auth = ["certificate"]
A scope with ssh in kinds and no onward_auth is refused at start-up and by
authority scopes set. Each target picks its mode from the list.
gdsgate authority scopes set and authority scopes remove change scopes on a running
cluster; authority scopes list shows each scope's row count against
max_live. A scope declared in the file is rewritten from the file at each
Authority start; put authority scopes set changes into [[discovery.auto_scopes]] too.
Choosing max_live¶
A row missing from a new snapshot stays until its lease runs out. The scope
holds every target published during one lease window. With a probe interval of
interval_secs and a lease of lease_secs, a source that replaces all its
targets on every pass leaves up to
rows in the scope. For 300 targets, a 300s interval and a 900s lease that is
1200 rows; a lower max_live refuses snapshots until older leases run out.
Shorten lease_secs (a removed target then stays reachable for less time) or
raise max_live. gdsgate doctor warns at 90% of max_live.
A row can outlive its lease by up to [discovery].lease_sweep_secs.
gdsgate resources list shows each row's remaining lease; gdsgate resources list --leased lists only
rows written inside a scope.
Resource ids and names¶
The rules below describe explicit legacy auto-scopes. Connector identities assigned automatic mode use qualified node/local identities for static backends and discovery targets. The displayed namespace comes from Authority; short names are admitted only when the complete catalog is unambiguous.
A row's id is the scope prefix plus the name the source uses for the target:
a cluster service orders/orders-db inside scope test- becomes
test-orders-orders-db; a database orders on host prod-pg inside scope
prod-pg. becomes prod-pg.orders.
The row's name is derived from the id: characters not allowed in a DNS label
become -, repeated separators collapse, and the result is truncated to 63
bytes. prod-pg.orders is addressed as prod-pg-orders. If
another resource already answers to that name, the row is written without a
name and remains reachable by id; the connector logs which resource holds the
name.
Publishing from a probe¶
For a manual Connector, a probe publishes into an explicit scope when its [connector.backends.discovery] block
sets scope. A database or MCP probe then publishes inventory and writes a
catalog row per sub-resource. A kubernetes probe with
mode = "list_kube_targets" writes rows only.
| Key | Type | Default | Purpose |
|---|---|---|---|
scope |
string | unset | Scope prefix the probe publishes into. Required for mode = "list_kube_targets". |
label_selector |
string | unset | Label selector passed to the Kubernetes API. Required for mode = "list_kube_targets". |
sources |
array of string | ["service"] |
Objects a cluster probe enumerates: service (ClusterIP, port from the spec), pod (pod IP, port from the annotation), node (internal address, port 22 unless annotated). |
[[connector.backends]]
resource = "prod-k8s"
kind = "kubernetes"
api_url = "https://kubernetes.example:6443"
ca_path = "/etc/gdsgate/k8s-ca.pem"
token_path = "/var/run/secrets/gdsgate/token"
[connector.backends.discovery]
mode = "list_kube_targets"
label_selector = "gdsgate.io/expose=true"
sources = ["service", "node"]
scope = "test-"
interval_secs = 60
Database probe targets are named <host>.<db>, so the scope prefix is the host
id and a dot:
[connector.backends.discovery]
mode = "list_databases"
connection = "postgres://disc_ro@prod-pg/postgres"
password_file = "/etc/gdsgate/disc_ro.pass"
scope = "prod-pg."
interval_secs = 300
mode = "list_namespaces" enumerates the cluster's namespaces and publishes
them as inventory under the cluster's own resource id, which gdsgate kube list
reads. It needs no scope.
What the cluster objects must declare¶
| Annotation or label | Required | Purpose |
|---|---|---|
gdsgate.io/expose |
required | What the probe's label_selector matches on. The value is not read. |
gdsgate.io/kind |
required for service and pod |
Resource kind. A node defaults to ssh. An object without it is skipped. |
gdsgate.io/port |
required for pod |
Port to connect to. A service takes the first port of its spec; a node defaults to 22. |
gdsgate.io/ssh-host-key |
required for ssh under ssh_host_key = "annotation" |
Host-key fingerprint(s) of the target's sshd, comma-separated. |
apiVersion: v1
kind: Service
metadata:
name: orders-db
namespace: orders
labels:
gdsgate.io/expose: "true"
annotations:
gdsgate.io/kind: postgres
spec:
ports:
- port: 5432
A fingerprint is a SHA256:<base64> string as printed by ssh-keygen -lf or
gdsgate ca host-keys learn <host>, compared with the key the target presents.
List several when the host holds keys of more than one algorithm or is being
rotated:
annotations:
gdsgate.io/ssh-host-key: "SHA256:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU,SHA256:8sHc4rGZLmvKV8OoK1qN0KUwZ5Tz4EDx2H6pJXbYQ3o"
Objects skipped by the probe: one without gdsgate.io/kind (except nodes), a
headless service (clusterIP: None; publish the pods instead), a pod without
gdsgate.io/port.
The Connector serves discovered ssh, postgres, mysql, tcp and web
targets. A web target uses HTTP at the object's IP and selected port;
requests pass through the web lane and require connect and httpRequest
permission on its catalog resource. Add web to the scope's kinds before
setting gdsgate.io/kind: web on an object.
Discovered web targets use the default web policy: WebSocket channels, identity headers and forwarded headers are disabled; bodies are not recorded. HTTPS, a custom upstream CA and named routes require a configured web backend.
Service account permissions¶
A cluster probe uses the connector's service account. Grant list on the
collections it enumerates:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: gdsgate-discovery
rules:
- apiGroups: [""]
resources: ["services", "pods", "nodes", "namespaces"]
verbs: ["list"]
ClusterIP and pod addresses are reachable only inside the cluster. Run the connector that serves discovered cluster targets in the cluster.
Troubleshooting¶
The connector logs refused publishes (the cluster refused this scoped
publish, with the reason); Authority audits them as scope.refused
(CAT-0302). Accepted snapshots are audited as scope.published (CAT-0301),
one row per snapshot, with sha256 over the published targets and
held_sha256 over the ids the scope holds afterwards. They differ while rows
from earlier snapshots are still under lease.
These rows, lease expiry and set-scope / remove-scope changes are queued for
the audit chain. gdsgate_audit_outbox_depth,
gdsgate_audit_outbox_oldest_age_secs and the gdsgate doctor finding
state.audit.outbox show a queue that is not draining, which means no instance
holds the audit write leadership. state.audit.outbox_held reports a queued
row the leader refused to seal; see
queued rows.
| Symptom | Cause | Fix |
|---|---|---|
listing /api/v1/services answered 403 |
The service account lacks list on that collection. |
Apply the ClusterRole above. Rows already published keep their leases meanwhile. |
no such boundary |
The probe's scope names a prefix no scope declares. |
Declare it, or correct scope. authority scopes list shows what exists. |
this boundary is filled by another node |
The scope's node is not the publishing connector. |
Set node to the id from authority nodes list. |
boundary is disabled |
The scope has enabled = false. |
Re-enable it with authority scopes set without --disabled. |
target ... declares kind ..., which this boundary does not admit |
The object's gdsgate.io/kind is outside the scope's kinds. |
Add the kind to the scope, or correct the annotation. The whole snapshot is refused until then, so existing rows stop being renewed. |
the boundary holds N rows and this snapshot would leave M, over its ceiling of K |
The scope's live rows plus the snapshot's targets exceed max_live. Rows from earlier snapshots count until their leases run out. |
Raise max_live (in the file and with authority scopes set), shorten lease_secs, or narrow label_selector. See choosing max_live. |
target ... is outside the boundary |
An id the probe built does not start with the scope prefix. | Correct scope; for a database probe it must be <host>.. |
target ... is a resource somebody declared |
A declared resource has the same id. | Remove that resource, or move the scope. |
the boundary changed while this snapshot was being checked |
The scope was rewritten (disabled, moved to another node, narrowed) between the probe's pass and the write. | Expected once after authority scopes set; the next pass is judged against the scope as it now stands. Persisting means something rewrites the scope every interval. |
target ... appears twice in this snapshot |
The probe built the same id for two cluster objects. | Rename one of the two objects, or narrow label_selector. |
stale generation |
Authority holds a newer snapshot generation than the connector sent. | Expected briefly after a connector restart with a lost state directory; it resolves on the next pass. If it persists, check the connector's clock. |
| A target exists in the cluster but no row appears | The object is missing an annotation, or is skipped (headless service, pod without port), or the last snapshot was refused. | Check the connector log for the refusal, and the object against the table above. |
| A row remains after the target is gone | The lease has not expired, or the sweep has not run. | Wait up to lease_secs plus lease_sweep_secs. gdsgate resources list shows expired in between. |
| A row is listed without a name | The name its id proposes is held by another resource. | The connector log names the holder. Rename that resource, or reach this row by id. |
| A client is refused a target that exists in the cluster | Under fallback = "deny" the row is not published yet. |
Wait for the next probe pass, or set fallback = "parent". |
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
hostname = "orders-db" # the id is not a legal name (`_`)
aliases = ["orders"]
[[discovery.resources]]
id = "redis-cache_prod"
kind = "tcp"
port = 6379 # required: tcp has no well-known port
[[discovery.resources]]
id = "tools-mcp"
kind = "mcp"
port = 9000
# 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" }
# Bind issued certificates to the caller's TCP peer address.
[[discovery.import_rules]]
match = "*_prod"
labels = { pin_src_ip = "true" }
The catalog is read at Authority start-up. Restart Authority to apply changes.