Skip to content

Add ProxyAdminService with member-aware responses - #250

Draft
liam-lowe wants to merge 8 commits into
mainfrom
liam-lowe/proxyadmin
Draft

Add ProxyAdminService with member-aware responses#250
liam-lowe wants to merge 8 commits into
mainfrom
liam-lowe/proxyadmin

Conversation

@liam-lowe

@liam-lowe liam-lowe commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

ProxyAdminService answers DescribeClusterConnections for a whole proxy deployment rather than for whichever pod received the call, and a cross-cluster query asks the far proxy to do the same for its own.

A proxy runs as a Deployment with N pods, so a one-pod answer is incomplete. It is also non-deterministic: MultiClientConn load balances across mux sessions (transport/grpcutil/grpc.go:21), so a cross-cluster query lands on an arbitrary far pod and two identical calls can disagree.

Eight commits, meant to be read in order. The merge squashes them, so they are for review rather than for main's history. The first five are independent units (a shared helper, the proto, config, the engine); the sixth is the proxy wiring; the last two are metrics and the chart. Every commit builds and passes make lint and make test on its own, verified with git rebase --exec.

Routing lives in metadata, not request fields

DescribeClusterConnectionsRequest is empty. Two headers control how far a call travels:

  • s2s-proxy-scope: member or group. Absent means group.
  • s2s-proxy-target: a cluster connection name, forwarded once to that connection's peer proxy.

An interceptor cannot read a request field without reflection. With per-message fields, each listener's limits have to be re-checked inside every handler, and any RPC added later is exposed until someone remembers. One interceptor now covers every RPC the service will ever have.

Three listeners, three roles

Listener Role Behavior
loopback :6061 operator any scope, forwarding allowed, nothing withheld
peer 0.0.0.0:9234 peer member scope forced, forwarding refused
inbound mux counterparty answers only for the connection the call arrived on, serves only listed methods

The peer listener refusing to forward is what bounds a group call to a single round of fan-out. The mux interceptor is installed server-wide and also sees replication traffic, so anything outside the admin service passes through untouched; there is a test for exactly that.

Access control

A compile-time table keyed by the generated method constants carries two independent booleans per RPC: whether another organization may call it on us, and whether we may put it to them. Both default to false, so a new RPC is neither served across an organizational boundary nor sent to one until someone decides it should be.

Operators can narrow the counterparty side further with aclPolicy.allowedMethods.proxyAdmin. Absent means the compile-time ceiling; an empty list serves nothing, which is how you decline to answer one counterparty. A name outside the ceiling is a startup error, so configuration can only narrow. Note this differs from the sibling adminService list, whose empty value means allow everything: that fail-open default is a compatibility promise the replication ACL depends on, and repeating it here would make the natural spelling of "off" the widest possible setting.

Two bugs this fixes in the earlier draft of the branch

  • A single-pod install reported responding: 0. The chart default leaves the peer block off, which left Dial nil, and fanOut took its first early return with the count never set. Four early-return paths shared it.
  • The counterparty view trimmed the connection list but left proxy.member_id, per-pod versions and sibling pod addresses in place, and kept each connection's local_cluster, whose HistoryShardCount and FailoverVersionIncrement are read raw from the frontend. Those are the values proxy/adminservice.go:148-162 rewrites before answering that same counterparty's DescribeCluster, so the admin endpoint served the untranslated numbers around the existing translation. The response is now built field by field, so a field added to the proto later is not exposed until someone adds it there.

Known gap, not addressed here

Counterparty controls are defence in depth rather than access control, because the mux does not authenticate the caller. encryption.GetServerTLSConfig sets ClientAuth = tls.RequireAnyClientCert and replaces VerifyPeerCertificate with a function that logs the subject and returns nil, so a counterparty is authenticated only by presenting some certificate and remoteCAPath is decorative on that listener. Fixing it touches the replication transport and needs a coordinated rollout, so it is filed separately. The peer listener does verify, and there is a test that a certificate from another CA is refused.

Also deferred: authentication on the operator listener (loopback by documentation today), a per-connection outbound ACL, and a configured session count on ClusterConnectionMember so the RPC reports the same denominator as cluster_connection_mux_sessions_target.

Metrics

The session state breakdown is the gap nothing else fills: mux_connection_active is set to 1 on every observer tick until the session's lifetime ends and num_muxes_active is the size of the session map, so a session failing its ping but still in the map reads as healthy in both, and their label sets do not join in PromQL.

  • cluster_connection_mux_sessions{config_name,state} and ..._target (the denominator; a session that never established was never added, so sessions held says nothing about how many were meant to exist)
  • cluster_connection_local_cluster_reachable{config_name}
  • build_info{version}
  • gRPC server metrics on the admin listeners, which had none

The sampler reads mux.CountSessions, the same function the admin API reads, so the metric and the endpoint cannot disagree. Also registers GRPCIntraProxyClientMetrics, which was constructed and used but never registered, so those metrics were collected and never scraped.

Testing

make lint (0 issues), make test, make bins, make helm-test (9/9), buf lint with no generated-code drift, example.yaml regenerated, and go test -race over the prober and fan-out. Each of the eight commits was verified independently with git rebase --exec.

New tests, and the behaviour each pins:

  • adminplane/serve_test.go: fan-out against a fake discovery and dialer, no network. The roster invariant across all four early-return paths, budget exhaustion, the concurrency cap, self-dedupe including when the ID function is absent, and the fail-closed guard on a missing View.
  • adminplane/role_test.go: the listener role matrix, the compile-time ceiling, operator narrowing including the empty-list off switch, and that a non-admin method passes through a counterparty listener untouched.
  • proxy/proxyadmin_tls_test.go: a peer certificate signed by another CA is refused, which is the claim peerServerTLSConfig exists to make. Uses an in-process CA and leaf, because the committed fixtures are two self-signed leaves that each trust the other and cannot express "wrong CA".
  • proxy/local_cluster_probe_test.go, transport/mux/state_test.go, proxy/connection_metrics_test.go: previously uncovered.

Four of the security-relevant tests were checked by reverting the code they guard and confirming they fail.

@liam-lowe
liam-lowe requested a review from a team as a code owner July 30, 2026 07:10
@liam-lowe liam-lowe closed this Jul 30, 2026
@liam-lowe liam-lowe reopened this Jul 30, 2026
@liam-lowe
liam-lowe marked this pull request as draft July 30, 2026 16:57
@liam-lowe liam-lowe changed the title Add ProxyAdminService, the proxy's own control-plane API Add ProxyAdminService Jul 30, 2026
@liam-lowe
liam-lowe force-pushed the liam-lowe/proxyadmin branch 8 times, most recently from 7ae10e4 to 27537ff Compare August 10, 2026 15:59
Comment thread .github/workflows/proto.yml Outdated
name: lint, breaking and generation drift - pull request
steps:
- name: Checkout
uses: actions/checkout@v5

@github-actions github-actions Bot Aug 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opengrepsecurity.gha.unpinned-action (WARNING)

Unpinned action reference actions/checkout@v5: this uses: resolves a mutable ref (tag or branch), so the code that runs in CI can change without this line changing. A compromised upstream can repoint the tag and execute arbitrary code with access to this repository's secrets and GITHUB_TOKEN (tj-actions/changed-files, March 2025). Pin to the full 40-character commit SHA with the resolved version in a trailing comment, e.g. uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2. Prefer deputy pin --ecosystems github-actions, which resolves the ref, writes a version comment that reflects the most specific ref actually pointing at that commit, and verifies the SHA is reachable from a real branch upstream. That last check matters: pinning alone does not detect imposter or dangling commits, and this rule only sees the shape of the ref, never its provenance. Reusable workflow calls (owner/repo/.github/workflows/x.yml@ref) run with the same trust as actions and are pinned the same way. Not reported, by campaign policy: temporalio/* refs (first-party, pinned by internal process), local ./ actions, self-repository $/ refs (resolve to the running commit, so they are already pin-equivalent), and docker:// images (pinned by digest as a separate ecosystem).

Fixed in 3b226c3

Fixed in c50f001

Fixed in 8c28d08

Fixed in 79ec5ce

Comment thread .github/workflows/proto.yml Outdated
fetch-depth: 0

- name: Install buf
uses: bufbuild/buf-setup-action@v1

@github-actions github-actions Bot Aug 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opengrepsecurity.gha.unpinned-action (WARNING)

Unpinned action reference bufbuild/buf-setup-action@v1: this uses: resolves a mutable ref (tag or branch), so the code that runs in CI can change without this line changing. A compromised upstream can repoint the tag and execute arbitrary code with access to this repository's secrets and GITHUB_TOKEN (tj-actions/changed-files, March 2025). Pin to the full 40-character commit SHA with the resolved version in a trailing comment, e.g. uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2. Prefer deputy pin --ecosystems github-actions, which resolves the ref, writes a version comment that reflects the most specific ref actually pointing at that commit, and verifies the SHA is reachable from a real branch upstream. That last check matters: pinning alone does not detect imposter or dangling commits, and this rule only sees the shape of the ref, never its provenance. Reusable workflow calls (owner/repo/.github/workflows/x.yml@ref) run with the same trust as actions and are pinned the same way. Not reported, by campaign policy: temporalio/* refs (first-party, pinned by internal process), local ./ actions, self-repository $/ refs (resolve to the running commit, so they are already pin-equivalent), and docker:// images (pinned by digest as a separate ecosystem).

Fixed in 3b226c3

Fixed in c50f001

Fixed in 8c28d08

Fixed in 79ec5ce

@liam-lowe
liam-lowe force-pushed the liam-lowe/proxyadmin branch 2 times, most recently from 3b226c3 to c50f001 Compare August 11, 2026 23:11
@liam-lowe liam-lowe changed the title Add ProxyAdminService Add ProxyAdminService with member-aware responses Aug 11, 2026
s2s-proxy.mergedConfig only wrote clusterConnections back into the merged
result, so every other top-level configOverride key was silently discarded.
Setting configOverride.metrics or configOverride.profiling rendered the
default from files/default.yaml with no error.

Merge the remaining top-level keys with mergeOverwrite. The source is
deepCopy'd because sprig mutates its first argument and the clusterConnections
loop above already writes defaults into .Values in place.

The deployment test worked around this by nesting metrics under
clusterConnections[0], but ClusterConnConfig has no metrics field and config
loading uses KnownFields(true), so that config would be rejected by the
binary. Move it to the top level and drop the dead per-connection lookup in
parsedPorts that allowed it.
@liam-lowe
liam-lowe force-pushed the liam-lowe/proxyadmin branch from c50f001 to 8c28d08 Compare August 12, 2026 19:10
proxy/debug.go mapped session states to strings inline. The admin API and a
metrics gauge both need the same mapping, and three copies of a switch over
MuxSessionState is three places to miss when a fourth state appears.

CountSessions also exposes the breakdown itself, which nothing did before:
num_muxes_active is the size of the session map and mux_connection_active is
per session, so a session failing its health check but still in the map reads
as healthy in both.

DesiredMuxCount is exported alongside it because the sessions a manager holds
mean nothing without the count it was configured to hold, and a caller
reporting that ratio should not carry its own copy of the default.
The schema, the generated stubs, the make target and the drift workflow are
one unit: CI regenerates and diffs api/, so a change to any of them without
the others fails the gate.

Requests carry no routing fields. Scope and target travel as gRPC metadata,
because an interceptor cannot read a request field without reflection, so
with fields every listener's limits would have to be re-checked inside every
handler and any RPC added later would be exposed until someone remembered.
Each discovery provider gets its own typed block, selected by name. Every
layered configuration tool in this stack deep-merges and cannot delete keys,
so switching provider through a Helm override leaves the previous provider's
block behind and it has to be inert. A single flat options map could not be
switched at all under strict decoding.

ProxyAdmin is a value rather than a pointer because nil and an empty listen
address already mean the same thing.

The counterparty method list is deliberately not resolved through
auth.AccessControl, whose IsAllowed returns true for an empty list. That
fail-open default is a compatibility promise the replication ACL depends on,
and repeating it here would make the natural spelling of "off" the widest
possible setting.
Serve answers one RPC at whatever scope the caller asked for and the listener
allows: this process, this deployment, or once across a mux to another
organization. Endpoints supply four functions and inherit deadline budgeting,
discovery, concurrency capping, dial lifecycle and the unreachable roster.

Three properties are structural rather than conventions each handler has to
remember. Serve applies the narrowing View last and refuses to answer a
counterparty that has none. The peer listener refuses to forward, which is
what bounds a group call to a single round of fan-out. And a method outside
the admin service passes through untouched, because on a mux this interceptor
is installed server-wide and also sees every replication call.

Merge takes members as a slice rather than a pre-folded value, so an endpoint
whose aggregate is not a sum can still be expressed: shard ownership is
disjoint across pods, and a configuration check wants to know whether the
members agree rather than what they add up to.

Self is recognized after the call, not before it. A DNS record carries no
identity, so every discovered address is dialed, including this pod's own,
and a reply bearing our own id is discarded.
Wires the admin plane into the proxy: each cluster connection describes its
own mux and local cluster state, one instance of the service answers on every
listener, and the merge folds the members' answers into one.

NewProxy gains an identity and an error return. The identity is supplied
rather than read from config, because a value in the shared config file would
be identical on every replica and deduplication by id would collapse the whole
deployment into a single member. The error return replaces a Fatal-then-
continue that silently dropped a cluster connection when the log component was
disabled.

Three listeners, three roles. The loopback operator listener is trusted local
access. The peer listener serves sibling pods and refuses to forward. The mux
listener is reached by another organization, so it serves only listed methods
and its answer is built field by field rather than by deleting fields, so a
field added to the proto later is not served across an organizational boundary
until someone adds it there. That withholds this deployment's shape, and it
withholds LocalCluster, whose shard count and failover version increment are
the raw values adminservice.DescribeCluster rewrites for that same caller.

Peer TLS does not reuse encryption.GetServerTLSConfig, which sets
RequireAnyClientCert and replaces VerifyPeerCertificate with a function that
logs the subject and returns nil, so it never checks the chain. On a listener
bound to the pod network the certificate is the only thing distinguishing a
sibling from anything else that can reach it.

A cached probe answers whether this proxy can reach the Temporal cluster it
fronts, which no mux state can. It calls the frontend directly rather than
through the proxy's own DescribeCluster handler, and it runs on a timer
because a two second dial inside a two second member budget would turn a slow
frontend into a member that looks unreachable.
The admin API answers an operator who asks. Alerting needs the same facts
without one, and the chart leaves that API's listener on loopback.

The session state breakdown is what nothing exported before. mux_connection_active
is set to 1 on every observer tick until the session's lifetime ends and
num_muxes_active is the size of the session map, so a session failing its ping
but still in the map reads as healthy in both, and their label sets do not join
in PromQL. The target gauge is the denominator that makes the breakdown
alertable: a session that never established was never added to the manager, so
the sessions held say nothing on their own about how many were meant to exist.

The sampler reads mux.CountSessions, the same function the admin API reads, so
the metric and the endpoint cannot disagree. It samples before waiting rather
than after, unlike the mux observer, so a connection that lives less than one
interval is not invisible. Sampling faster would not detect a failure faster:
a session is marked errored by its own health check, which pings about once a
minute.

The admin listeners had no gRPC metrics at all. They reuse the registered
server metrics rather than declaring a second collector, because a second
grpcprom.ServerMetrics under the same namespace and subsystem panics at
process start whether or not its label list matches. Metrics wrap the admin
interceptor so its rejections land in grpc_code.

Also registers GRPCIntraProxyClientMetrics, which was constructed and used but
never registered, so intra-proxy client metrics were collected and never
scraped.
The operator listener ships enabled on loopback, so a query needs only an exec
or a port-forward rather than a config change.

The peer listener stays commented out. It is the part that lets one pod answer
for the whole deployment, and it binds the pod network, so it should be a
decision rather than a default.

When dns discovery is selected the name defaults to this release's headless
Service. That Service already publishes one A record per endpoint, so nothing
about it changes; without the default every install would hand-write a
cluster-internal DNS name, which is the kind of configuration alignment this
endpoint exists to avoid.

POD_NAME comes through the downward API so each pod names itself in an
aggregated response. Two members reporting the same id are indistinguishable
from one member answering twice.

The configmap tests cover the case the config shape was designed around:
switching discovery provider through an override leaves the previous
provider's block behind, and the binary must still accept it under strict
decoding.
@liam-lowe
liam-lowe force-pushed the liam-lowe/proxyadmin branch from 8c28d08 to 79ec5ce Compare August 12, 2026 19:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant