Skip to main content

signet_client/
client.rs

1//! Connection helpers for talking to a signet server.
2//!
3//! Mirrors `go/client.go`: [`dial_admin`] opens a bearer-token connection to
4//! the operator-facing `AdminService`/`GitOpsService` listener, applying the
5//! same loopback-defaults-to-plaintext logic (with the same `force_tls`/
6//! `plaintext` overrides) as the Go client and the `signet` CLI.
7//! [`dial_workload`] (behind the `spiffe-workload` feature) opens a
8//! SPIFFE-mTLS connection to the workload-facing `SecretsService` listener,
9//! retrying automatically — no opt-in required — if it loses the SPIRE
10//! identity-registration-propagation race described in its doc comment. Its
11//! `Channel` is also usable with [`gitops_client`] for the subset of
12//! `GitOpsService` reachable this way (`SyncBundle`, `PatchServiceConfig`,
13//! `GetSOPSPublicKey`) without ever holding an admin bearer token
14//! (bytepunx/signet#23, #38, #78) — see [`gitops_client`]'s doc comment and
15//! `examples/gitops_workload.rs`.
16
17use std::net::IpAddr;
18use std::path::Path;
19
20use tonic::codegen::{Body, Bytes, StdError};
21use tonic::service::interceptor::InterceptedService;
22use tonic::service::Interceptor;
23use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint};
24use tonic::{Request, Status};
25
26use crate::admin::v1::admin_service_client::AdminServiceClient;
27use crate::admin::v1::git_ops_service_client::GitOpsServiceClient;
28
29/// Errors returned by the connection helpers in this module.
30#[derive(Debug, thiserror::Error)]
31pub enum ClientError {
32    /// `dial_admin` was called with an empty (or whitespace-only) token.
33    #[error("token must not be empty")]
34    EmptyToken,
35
36    /// A CA PEM bundle was supplied but contained no parseable certificates.
37    #[error("invalid CA PEM bundle: no certificates found")]
38    InvalidCaPem,
39
40    /// `dial_admin` was called with both `force_tls` and `plaintext` set.
41    /// They request opposite overrides of the loopback heuristic, so
42    /// exactly one (or neither) may be set. See [`dial_admin`]'s doc
43    /// comment.
44    #[error("force_tls and plaintext are mutually exclusive")]
45    ForceTlsPlaintextConflict,
46
47    /// `dial_admin` was called with `plaintext` set and a non-empty
48    /// `ca_pem`. There is no meaningful CA to verify against when the
49    /// transport isn't TLS at all. See [`dial_admin`]'s doc comment.
50    #[error("plaintext and ca_pem are mutually exclusive")]
51    PlaintextCaPemConflict,
52
53    /// A CA PEM bundle was supplied but failed to parse.
54    #[error("invalid CA PEM bundle: {0}")]
55    CaPemParse(String),
56
57    /// The target address could not be turned into a valid gRPC endpoint URI.
58    #[error("invalid address {addr:?}: {source}")]
59    InvalidEndpoint {
60        addr: String,
61        #[source]
62        source: tonic::transport::Error,
63    },
64
65    /// Failed to establish the transport connection.
66    #[error("connect to {addr:?}: {source}")]
67    Connect {
68        addr: String,
69        #[source]
70        source: tonic::transport::Error,
71    },
72
73    /// Reading a CA bundle from disk (see [`read_ca_file`]) failed.
74    #[error("read CA file {path:?}: {source}")]
75    ReadCaFile {
76        path: String,
77        #[source]
78        source: std::io::Error,
79    },
80
81    /// Failed to connect to the SPIFFE Workload API.
82    #[cfg(feature = "spiffe-workload")]
83    #[error("connect to SPIFFE Workload API at {socket:?}: {source}")]
84    WorkloadApi {
85        socket: String,
86        #[source]
87        source: spiffe::x509_source::X509SourceError,
88    },
89
90    /// [`dial_workload`]'s identity-readiness probe kept seeing "no
91    /// identity issued" through its full retry budget (see
92    /// `dial_workload`'s doc comment for the schedule and why this retry
93    /// exists — bytepunx/signet-clients#33) without ever observing success
94    /// or a different failure.
95    #[cfg(feature = "spiffe-workload")]
96    #[error("connect to SPIFFE workload API at {socket:?}: no identity issued after {attempts} attempts: {source}")]
97    WorkloadNoIdentityIssued {
98        socket: String,
99        attempts: usize,
100        #[source]
101        source: spiffe::WorkloadApiError,
102    },
103
104    /// [`dial_workload`]'s identity-readiness probe failed for a reason
105    /// other than "no identity issued" (a bad `socket_path`, an expired or
106    /// canceled deadline, a malformed `trust_domain`, a genuine
107    /// authorization problem once identity issuance is actually broken
108    /// rather than merely delayed, ...) — surfaced immediately, unretried,
109    /// so this never masks a real misconfiguration as a transient blip.
110    #[cfg(feature = "spiffe-workload")]
111    #[error("connect to SPIFFE workload API at {socket:?}: {source}")]
112    WorkloadProbe {
113        socket: String,
114        #[source]
115        source: spiffe::WorkloadApiError,
116    },
117
118    /// The supplied trust domain string was not a valid SPIFFE trust domain.
119    #[cfg(feature = "spiffe-workload")]
120    #[error("invalid trust domain {0:?}: {1}")]
121    InvalidTrustDomain(String, String),
122
123    /// Failed to build the SPIFFE mTLS `rustls::ClientConfig`.
124    #[cfg(feature = "spiffe-workload")]
125    #[error("build SPIFFE mTLS client config: {0}")]
126    SpiffeTls(String),
127
128    /// The target address was not a valid `host:port` pair.
129    #[cfg(feature = "spiffe-workload")]
130    #[error("invalid workload address {0:?}: expected host:port")]
131    InvalidWorkloadAddress(String),
132
133    /// The underlying TCP connection or TLS handshake to the workload
134    /// listener failed.
135    #[cfg(feature = "spiffe-workload")]
136    #[error("connect to {addr:?}: {source}")]
137    WorkloadConnect {
138        addr: String,
139        #[source]
140        source: std::io::Error,
141    },
142}
143
144/// Injects `Authorization: Bearer <token>` into every outgoing RPC's
145/// metadata. Constructed internally by [`dial_admin`]; exposed so callers
146/// can see the concrete type of [`AdminChannel`].
147#[derive(Clone)]
148pub struct TokenInterceptor {
149    header_value: tonic::metadata::MetadataValue<tonic::metadata::Ascii>,
150}
151
152impl Interceptor for TokenInterceptor {
153    fn call(&mut self, mut req: Request<()>) -> Result<Request<()>, Status> {
154        req.metadata_mut()
155            .insert("authorization", self.header_value.clone());
156        Ok(req)
157    }
158}
159
160/// The transport type returned by [`dial_admin`]: a `tonic` [`Channel`] with
161/// a [`TokenInterceptor`] attached. Pass it to [`admin_client`] or
162/// [`gitops_client`] to get a typed RPC client.
163pub type AdminChannel = InterceptedService<Channel, TokenInterceptor>;
164
165/// Opens a gRPC connection to signet's admin listener, injecting `token`
166/// into every RPC as a bearer credential.
167///
168/// Transport security is chosen automatically from `addr`:
169/// - Loopback addresses (`localhost`, `127.0.0.1`, `::1` — the documented
170///   `kubectl port-forward` workflow) default to plaintext.
171/// - Every other address is upgraded to TLS automatically, using the
172///   system trust store, or the CA in `ca_pem` if provided.
173///
174/// `force_tls` and `plaintext` both override that heuristic, in opposite
175/// directions:
176/// - `force_tls` requests TLS even for a loopback address (e.g. testing a
177///   real TLS-terminating proxy locally).
178/// - `plaintext` forces insecure transport credentials even for a
179///   non-loopback address, bypassing the loopback heuristic entirely. This
180///   is required once signet exposes a real in-cluster admin listener
181///   (bytepunx/signet#19): dialing that Service by its cluster-DNS name is
182///   a non-loopback address, but the listener is intentionally still
183///   plaintext-behind-bearer-token, not TLS-terminated — without
184///   `plaintext`, the loopback heuristic would pick TLS and the handshake
185///   would fail immediately ("wrong version number") against a server that
186///   never speaks TLS on that listener. See bytepunx/signet-clients#32.
187///
188/// Per-RPC bearer-token authentication (the actual mechanism signet uses to
189/// authenticate the caller) is unaffected either way — `plaintext` only
190/// changes the transport, never who signet trusts the caller to be.
191///
192/// `force_tls` and `plaintext` are mutually exclusive with each other
193/// ([`ClientError::ForceTlsPlaintextConflict`]), and `plaintext` is
194/// mutually exclusive with a non-empty `ca_pem`
195/// ([`ClientError::PlaintextCaPemConflict`]) — there is no meaningful CA to
196/// verify against when the transport isn't TLS at all.
197///
198/// Returns [`ClientError::EmptyToken`] if `token` is empty or
199/// whitespace-only, before any connection attempt is made.
200pub async fn dial_admin(
201    addr: impl AsRef<str>,
202    token: impl AsRef<str>,
203    ca_pem: Option<&[u8]>,
204    force_tls: bool,
205    plaintext: bool,
206) -> Result<AdminChannel, ClientError> {
207    let addr = addr.as_ref();
208    let token = token.as_ref().trim();
209    if token.is_empty() {
210        return Err(ClientError::EmptyToken);
211    }
212
213    let decision = admin_transport_decision(addr, ca_pem, force_tls, plaintext)?;
214    let uri = format!(
215        "{}://{addr}",
216        if decision.requires_tls() { "https" } else { "http" }
217    );
218
219    let mut endpoint = Endpoint::from_shared(uri).map_err(|source| ClientError::InvalidEndpoint {
220        addr: addr.to_string(),
221        source,
222    })?;
223    if let TransportDecision::Tls(tls_config) = decision {
224        endpoint = endpoint
225            .tls_config(tls_config)
226            .map_err(|source| ClientError::InvalidEndpoint {
227                addr: addr.to_string(),
228                source,
229            })?;
230    }
231
232    let channel = endpoint
233        .connect()
234        .await
235        .map_err(|source| ClientError::Connect {
236            addr: addr.to_string(),
237            source,
238        })?;
239
240    let header_value = format!("Bearer {token}")
241        .parse()
242        .expect("Bearer <token> is always valid ASCII metadata once token is trimmed non-empty");
243
244    Ok(InterceptedService::new(
245        channel,
246        TokenInterceptor { header_value },
247    ))
248}
249
250/// Returns an `AdminService` client bound to `channel`.
251///
252/// Generic over the underlying `tonic` service type so that both
253/// [`AdminChannel`] (from [`dial_admin`], bearer-token auth) and a plain
254/// [`Channel`] (from `dial_workload`, behind the `spiffe-workload` feature)
255/// work as `channel` — the bound is exactly what the generated
256/// `AdminServiceClient<T>::new` requires. In practice signet's server only
257/// accepts admin-token auth for `AdminService` itself, so `dial_workload`'s
258/// channel is mainly useful with [`gitops_client`], not this function; the
259/// generic signature is kept the same as `gitops_client`'s for consistency.
260pub fn admin_client<T>(channel: T) -> AdminServiceClient<T>
261where
262    T: tonic::client::GrpcService<tonic::body::Body>,
263    T::Error: Into<StdError>,
264    T::ResponseBody: Body<Data = Bytes> + Send + 'static,
265    <T::ResponseBody as Body>::Error: Into<StdError> + Send,
266{
267    AdminServiceClient::new(channel)
268}
269
270/// Returns a `GitOpsService` client bound to `channel`.
271///
272/// `channel` may come from either [`dial_admin`] ([`AdminChannel`]:
273/// bearer-token auth, full access) or `dial_workload` (behind the
274/// `spiffe-workload` feature; a plain [`Channel`]: SPIFFE mTLS, the caller's
275/// own identity) — signet's server accepts both for `SyncBundle`,
276/// `PatchServiceConfig`, and `GetSOPSPublicKey`, letting a workload
277/// self-service its own bundle/config writes and fetch the active SOPS
278/// public key without ever holding an admin token (bytepunx/signet#23, #38,
279/// #78). Cross-namespace/service writes still require an explicit
280/// `CreatePolicy` grant from an operator. See `examples/gitops_workload.rs`
281/// for a runnable demonstration.
282///
283/// This function is generic over the underlying `tonic` service type,
284/// rather than fixed to [`AdminChannel`], purely so both channel kinds
285/// type-check here — the trait bound below is copied verbatim from the
286/// `where` clause `tonic-build` generates on
287/// `GitOpsServiceClient<T>::new`/`with_origin`/etc., not hand-picked.
288pub fn gitops_client<T>(channel: T) -> GitOpsServiceClient<T>
289where
290    T: tonic::client::GrpcService<tonic::body::Body>,
291    T::Error: Into<StdError>,
292    T::ResponseBody: Body<Data = Bytes> + Send + 'static,
293    <T::ResponseBody as Body>::Error: Into<StdError> + Send,
294{
295    GitOpsServiceClient::new(channel)
296}
297
298/// Reads a PEM CA bundle from `path` for use with [`dial_admin`].
299pub fn read_ca_file(path: impl AsRef<Path>) -> Result<Vec<u8>, ClientError> {
300    let path_ref = path.as_ref();
301    std::fs::read(path_ref).map_err(|source| ClientError::ReadCaFile {
302        path: path_ref.display().to_string(),
303        source,
304    })
305}
306
307#[derive(Debug)]
308pub(crate) enum TransportDecision {
309    Plaintext,
310    Tls(ClientTlsConfig),
311}
312
313impl TransportDecision {
314    pub(crate) fn requires_tls(&self) -> bool {
315        matches!(self, TransportDecision::Tls(_))
316    }
317}
318
319pub(crate) fn admin_transport_decision(
320    addr: &str,
321    ca_pem: Option<&[u8]>,
322    force_tls: bool,
323    plaintext: bool,
324) -> Result<TransportDecision, ClientError> {
325    let ca_pem_non_empty = ca_pem.map(|pem| !pem.is_empty()).unwrap_or(false);
326
327    if plaintext && force_tls {
328        return Err(ClientError::ForceTlsPlaintextConflict);
329    }
330    if plaintext && ca_pem_non_empty {
331        return Err(ClientError::PlaintextCaPemConflict);
332    }
333    if plaintext {
334        return Ok(TransportDecision::Plaintext);
335    }
336
337    let host = host_of(addr);
338    let use_tls = force_tls || ca_pem_non_empty || !is_loopback_host(&host);
339
340    if !use_tls {
341        return Ok(TransportDecision::Plaintext);
342    }
343
344    let mut tls = ClientTlsConfig::new();
345    if let Some(pem) = ca_pem {
346        if !pem.is_empty() {
347            validate_ca_pem(pem)?;
348            tls = tls.ca_certificate(Certificate::from_pem(pem));
349        }
350    }
351    Ok(TransportDecision::Tls(tls))
352}
353
354/// Extracts the host portion of a `host:port` (or bracketed IPv6
355/// `[::1]:port`) address, mirroring Go's `net.SplitHostPort` fallback
356/// behavior: if `addr` doesn't look like `host:port`, the whole string is
357/// treated as the host.
358pub(crate) fn host_of(addr: &str) -> String {
359    if let Ok(sock) = addr.parse::<std::net::SocketAddr>() {
360        return sock.ip().to_string();
361    }
362    if let Some(idx) = addr.rfind(':') {
363        let (host_part, port_part) = (&addr[..idx], &addr[idx + 1..]);
364        if !host_part.is_empty() && !port_part.is_empty() && port_part.bytes().all(|b| b.is_ascii_digit()) {
365            return host_part.trim_start_matches('[').trim_end_matches(']').to_string();
366        }
367    }
368    addr.to_string()
369}
370
371pub(crate) fn is_loopback_host(host: &str) -> bool {
372    if host.eq_ignore_ascii_case("localhost") {
373        return true;
374    }
375    host.parse::<IpAddr>()
376        .map(|ip| ip.is_loopback())
377        .unwrap_or(false)
378}
379
380fn validate_ca_pem(pem: &[u8]) -> Result<(), ClientError> {
381    let mut reader = std::io::BufReader::new(pem);
382    let mut count = 0usize;
383    for item in rustls_pemfile::certs(&mut reader) {
384        match item {
385            Ok(_) => count += 1,
386            Err(e) => return Err(ClientError::CaPemParse(e.to_string())),
387        }
388    }
389    if count == 0 {
390        return Err(ClientError::InvalidCaPem);
391    }
392    Ok(())
393}
394
395#[cfg(feature = "spiffe-workload")]
396mod workload {
397    use super::ClientError;
398    use std::future::Future;
399    use std::net::ToSocketAddrs;
400    use std::pin::Pin;
401    use std::sync::Arc;
402    use std::task::{Context, Poll};
403    use std::time::Duration;
404
405    use tokio::net::TcpStream;
406    use tonic::codegen::http::Uri;
407    use tonic::codegen::Service;
408    use tonic::transport::{Channel, Endpoint};
409
410    /// The wait before each retry [`dial_workload`]'s identity-readiness
411    /// probe makes after the Workload API reports "no identity issued" (see
412    /// `dial_workload`'s doc comment): 1s, 2s, 4s, 8s — exponential,
413    /// doubling each time and capped at 8s. This is the exact schedule
414    /// verified working in a real downstream consumer that hit this race
415    /// (bytepunx/signet-clients#33 — bytepunx/kluster's RabbitMQ
416    /// credential-provisioning Job, which retries its own dial 5 times
417    /// total with this identical backoff), so `WORKLOAD_DIAL_BACKOFF.len()`
418    /// retries are made beyond the initial attempt —
419    /// `WORKLOAD_DIAL_MAX_ATTEMPTS` total — with a worst case of roughly
420    /// 15s of sleeping before `dial_workload` gives up.
421    const WORKLOAD_DIAL_BACKOFF: [Duration; 4] = [
422        Duration::from_secs(1),
423        Duration::from_secs(2),
424        Duration::from_secs(4),
425        Duration::from_secs(8),
426    ];
427
428    /// The total number of identity-readiness probes [`dial_workload`]
429    /// makes (the initial attempt plus `WORKLOAD_DIAL_BACKOFF.len()`
430    /// retries) before giving up.
431    const WORKLOAD_DIAL_MAX_ATTEMPTS: usize = WORKLOAD_DIAL_BACKOFF.len() + 1;
432
433    /// Calls `probe` repeatedly, retrying per [`WORKLOAD_DIAL_BACKOFF`] (via
434    /// `tokio::time::sleep`, so tests can drive this deterministically
435    /// with a paused Tokio time source — see this module's tests) as long
436    /// as it keeps failing with `WorkloadApiError::NoIdentityIssued`.
437    /// Returns `Ok(())` as soon as `probe` succeeds, a
438    /// [`ClientError::WorkloadProbe`] on the first error that isn't "no
439    /// identity issued", or a [`ClientError::WorkloadNoIdentityIssued`]
440    /// naming the last such failure once `WORKLOAD_DIAL_MAX_ATTEMPTS` is
441    /// exhausted.
442    async fn retry_until_identity_issued<F, Fut>(
443        socket_path: &str,
444        mut probe: F,
445    ) -> Result<(), ClientError>
446    where
447        F: FnMut() -> Fut,
448        Fut: Future<Output = Result<(), spiffe::WorkloadApiError>>,
449    {
450        let mut last_err: Option<spiffe::WorkloadApiError> = None;
451        // The delay to sleep *before* each attempt: none before the first
452        // (probe immediately), then one entry per retry per
453        // WORKLOAD_DIAL_BACKOFF. This yields exactly
454        // WORKLOAD_DIAL_MAX_ATTEMPTS items, so the loop below never needs
455        // to index WORKLOAD_DIAL_BACKOFF by a separately tracked attempt
456        // counter.
457        let delays_before_each_attempt =
458            std::iter::once(None).chain(WORKLOAD_DIAL_BACKOFF.into_iter().map(Some));
459
460        for delay in delays_before_each_attempt {
461            if let Some(delay) = delay {
462                tokio::time::sleep(delay).await;
463            }
464            match probe().await {
465                Ok(()) => return Ok(()),
466                Err(e) => {
467                    if !matches!(e, spiffe::WorkloadApiError::NoIdentityIssued) {
468                        return Err(ClientError::WorkloadProbe {
469                            socket: socket_path.to_string(),
470                            source: e,
471                        });
472                    }
473                    last_err = Some(e);
474                }
475            }
476        }
477        Err(ClientError::WorkloadNoIdentityIssued {
478            socket: socket_path.to_string(),
479            attempts: WORKLOAD_DIAL_MAX_ATTEMPTS,
480            source: last_err
481                .expect("loop always records last_err before exhausting WORKLOAD_DIAL_MAX_ATTEMPTS"),
482        })
483    }
484
485    /// Probes the Workload API at `socket_path` with a single-shot,
486    /// non-watching fetch — see [`dial_workload`]'s doc comment for why a
487    /// plain `fetch_x509_context` call, and not `X509Source::builder()`, is
488    /// used here.
489    async fn probe_identity_issued(socket_path: &str) -> Result<(), spiffe::WorkloadApiError> {
490        let client = spiffe::WorkloadApiClient::connect_to(socket_path).await?;
491        client.fetch_x509_context().await?;
492        Ok(())
493    }
494
495    /// Opens a gRPC connection to signet's workload listener, authenticating
496    /// via SPIFFE mTLS.
497    ///
498    /// `socket_path` is the SPIFFE Workload API socket (e.g.
499    /// `unix:///run/spire/sockets/agent.sock`); `trust_domain` must match the
500    /// trust domain of the target signet instance and of this workload's own
501    /// SVID (federation across trust domains is not supported by this
502    /// helper). The server's presented SPIFFE ID is verified to be a member
503    /// of `trust_domain`, mirroring Go's
504    /// `tlsconfig.AuthorizeMemberOf` — connecting to a server whose identity
505    /// is outside that trust domain fails the handshake.
506    ///
507    /// The returned [`Channel`](tonic::transport::Channel) is also usable
508    /// with [`gitops_client`](super::gitops_client) for the subset of
509    /// `GitOpsService` reachable this way (`SyncBundle`,
510    /// `PatchServiceConfig`, `GetSOPSPublicKey`) without needing an admin
511    /// bearer token at all. See
512    /// [`gitops_client`](super::gitops_client)'s doc comment and
513    /// `examples/gitops_workload.rs`.
514    ///
515    /// `dial_workload` retries automatically — no opt-in required — if the
516    /// Workload API reports "no identity issued" before it can hand back a
517    /// connection. This is a real, verified-in-production race, not
518    /// speculative hardening: SPIRE's controller-manager reconciles a
519    /// brand-new pod's SPIFFE identity registration reactively, off the
520    /// pod's own creation event, and that registration takes a few seconds
521    /// to propagate from there to the node-local SPIRE agent this dials
522    /// over `socket_path`. A freshly-created pod's very first
523    /// `dial_workload` call — a Job's is the sharpest case, since a Job has
524    /// no prior pod that might have already won this race for the same
525    /// ServiceAccount — can lose it outright and see "no identity issued"
526    /// even though the identity shows up moments later. See
527    /// bytepunx/signet-clients#33 for the full writeup, including where
528    /// this was first hit (bytepunx/kluster's RabbitMQ
529    /// credential-provisioning Job).
530    ///
531    /// The retry makes up to `WORKLOAD_DIAL_MAX_ATTEMPTS` (5) attempts total
532    /// — the initial attempt plus up to 4 retries — backing off per
533    /// `WORKLOAD_DIAL_BACKOFF` (1s, 2s, 4s, 8s) between them. Only the "no
534    /// identity issued" failure is retried: every other error (a bad
535    /// `socket_path`, a malformed `trust_domain`, a genuine authorization
536    /// problem once identity issuance is actually broken rather than merely
537    /// delayed, ...) is returned to the caller immediately, unretried, so
538    /// this never masks a real misconfiguration as a transient blip. The
539    /// retry probes with a single-shot, non-watching
540    /// `WorkloadApiClient::fetch_x509_context` call rather than the
541    /// long-lived `X509Source` constructor used below, deliberately:
542    /// `X509Source`'s own initial sync already retries transient Workload
543    /// API failures forever with its own internal, unbounded, unobservable
544    /// backoff, which would silently absorb the exact "no identity issued"
545    /// signal this retry needs to see and pace itself against. Once the
546    /// probe confirms identity is issued, constructing the real
547    /// `X509Source` below establishes near-instantly in the common case;
548    /// its own internal retry remains as a safety net for any further
549    /// transient hiccup, but the race this function exists to close has
550    /// already been won by the probe.
551    ///
552    /// The returned [`Channel`] is backed by a live `spiffe::X509Source`
553    /// that keeps rotating its SVID/trust bundle in the background for the
554    /// life of the process (or until the channel and all its clones are
555    /// dropped) — there is no separate "closer" to call, unlike the Go
556    /// client's `DialWorkload`, because the underlying `spiffe-rustls`
557    /// crate takes ownership of the source once building the TLS config.
558    pub async fn dial_workload(
559        addr: impl AsRef<str>,
560        socket_path: impl AsRef<str>,
561        trust_domain: impl AsRef<str>,
562    ) -> Result<Channel, ClientError> {
563        let addr = addr.as_ref().to_string();
564        let socket_path = socket_path.as_ref().to_string();
565        let trust_domain = trust_domain.as_ref().to_string();
566
567        retry_until_identity_issued(&socket_path, || probe_identity_issued(&socket_path)).await?;
568
569        let source = spiffe::X509Source::builder()
570            .endpoint(&socket_path)
571            .build()
572            .await
573            .map_err(|source| ClientError::WorkloadApi {
574                socket: socket_path.clone(),
575                source,
576            })?;
577
578        let td = spiffe::TrustDomain::try_from(trust_domain.as_str())
579            .map_err(|e| ClientError::InvalidTrustDomain(trust_domain.clone(), e.to_string()))?;
580
581        let authorizer = spiffe_rustls::authorizer::trust_domains([td.clone()])
582            .map_err(|e| ClientError::SpiffeTls(e.to_string()))?;
583
584        let tls_config = spiffe_rustls::mtls_client(source)
585            .authorize(authorizer)
586            .trust_domain_policy(spiffe_rustls::TrustDomainPolicy::LocalOnly(td))
587            .with_alpn_protocols([b"h2".to_vec()])
588            .build()
589            .map_err(|e| ClientError::SpiffeTls(e.to_string()))?;
590
591        // The SPIFFE verifier authorizes purely on the SPIFFE ID URI SAN, not
592        // the TLS server_name (see spiffe-rustls's verifier docs), so any
593        // valid ServerName works here; we use the target host for clarity in
594        // logs/debugging even though it isn't cryptographically checked.
595        let host = super::host_of(&addr);
596        let server_name = rustls::pki_types::ServerName::try_from(host.clone())
597            .map_err(|_| ClientError::InvalidWorkloadAddress(addr.clone()))?;
598
599        let connector = SpiffeConnector {
600            target_addr: addr.clone(),
601            tls_config: Arc::new(tls_config),
602            server_name,
603        };
604
605        // "http://", not "https://": SpiffeConnector performs the TLS
606        // handshake itself (see its Service::call impl above) and hands
607        // tonic an already-secured stream. An "https://" endpoint URI here
608        // would make tonic expect its own .tls_config() to be set and it
609        // would reject the connection with HttpsUriWithoutTlsSupport before
610        // ever invoking the connector — the scheme only selects tonic's own
611        // request routing/validation, not what happens on the wire.
612        let endpoint =
613            Endpoint::from_shared(format!("http://{addr}")).map_err(|source| {
614                ClientError::InvalidEndpoint {
615                    addr: addr.clone(),
616                    source,
617                }
618            })?;
619
620        endpoint
621            .connect_with_connector(connector)
622            .await
623            .map_err(|source| ClientError::Connect { addr, source })
624    }
625
626    #[derive(Clone)]
627    struct SpiffeConnector {
628        target_addr: String,
629        tls_config: Arc<rustls::ClientConfig>,
630        server_name: rustls::pki_types::ServerName<'static>,
631    }
632
633    impl Service<Uri> for SpiffeConnector {
634        type Response = hyper_util::rt::TokioIo<tokio_rustls::client::TlsStream<TcpStream>>;
635        type Error = ClientError;
636        type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
637
638        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
639            Poll::Ready(Ok(()))
640        }
641
642        fn call(&mut self, _uri: Uri) -> Self::Future {
643            let target_addr = self.target_addr.clone();
644            let tls_config = self.tls_config.clone();
645            let server_name = self.server_name.clone();
646
647            Box::pin(async move {
648                let socket_addr = target_addr
649                    .to_socket_addrs()
650                    .map_err(|source| ClientError::WorkloadConnect {
651                        addr: target_addr.clone(),
652                        source,
653                    })?
654                    .next()
655                    .ok_or_else(|| ClientError::InvalidWorkloadAddress(target_addr.clone()))?;
656
657                let tcp = TcpStream::connect(socket_addr).await.map_err(|source| {
658                    ClientError::WorkloadConnect {
659                        addr: target_addr.clone(),
660                        source,
661                    }
662                })?;
663                let _ = tcp.set_nodelay(true);
664
665                let connector = tokio_rustls::TlsConnector::from(tls_config);
666                let tls_stream = connector
667                    .connect(server_name, tcp)
668                    .await
669                    .map_err(|source| ClientError::WorkloadConnect {
670                        addr: target_addr,
671                        source,
672                    })?;
673
674                Ok(hyper_util::rt::TokioIo::new(tls_stream))
675            })
676        }
677    }
678
679    #[cfg(test)]
680    mod tests {
681        use super::*;
682        use std::sync::atomic::{AtomicUsize, Ordering};
683
684        // Compile-time proof that dial_workload's return type — a plain
685        // `tonic::transport::Channel`, per its signature above — satisfies
686        // whatever bound gitops_client/admin_client require. This function
687        // is never called; if dial_workload's return type ever stopped
688        // matching that bound, `cargo test --all-features` (which compiles
689        // this module) would fail to build, catching the regression this
690        // whole change exists to fix without needing a live signet server
691        // or SPIRE agent.
692        #[allow(dead_code)]
693        fn _dial_workload_channel_satisfies_gitops_and_admin_client_bound(channel: Channel) {
694            let _ = super::super::gitops_client(channel.clone());
695            let _ = super::super::admin_client(channel);
696        }
697
698        fn no_identity_issued() -> spiffe::WorkloadApiError {
699            spiffe::WorkloadApiError::NoIdentityIssued
700        }
701
702        fn permission_denied(msg: &str) -> spiffe::WorkloadApiError {
703            spiffe::WorkloadApiError::PermissionDenied(msg.to_string())
704        }
705
706        #[test]
707        fn workload_dial_backoff_matches_verified_kluster_schedule() {
708            // The exact schedule kluster's hand-rolled retry loop verified
709            // working before this was moved into the library (see
710            // bytepunx/signet-clients#33).
711            assert_eq!(
712                WORKLOAD_DIAL_BACKOFF,
713                [
714                    Duration::from_secs(1),
715                    Duration::from_secs(2),
716                    Duration::from_secs(4),
717                    Duration::from_secs(8),
718                ]
719            );
720            assert_eq!(WORKLOAD_DIAL_MAX_ATTEMPTS, 5);
721        }
722
723        #[tokio::test]
724        async fn retry_until_identity_issued_succeeds_immediately() {
725            let calls = Arc::new(AtomicUsize::new(0));
726            let calls_probe = calls.clone();
727
728            let result = retry_until_identity_issued("unix:///test.sock", move || {
729                let calls = calls_probe.clone();
730                async move {
731                    calls.fetch_add(1, Ordering::SeqCst);
732                    Ok(())
733                }
734            })
735            .await;
736
737            assert!(result.is_ok());
738            assert_eq!(
739                calls.load(Ordering::SeqCst),
740                1,
741                "a successful first probe must not retry"
742            );
743        }
744
745        #[tokio::test(start_paused = true)]
746        async fn retry_until_identity_issued_retries_no_identity_issued_then_succeeds() {
747            let calls = Arc::new(AtomicUsize::new(0));
748            let calls_probe = calls.clone();
749            let start = tokio::time::Instant::now();
750
751            let result = retry_until_identity_issued("unix:///test.sock", move || {
752                let calls = calls_probe.clone();
753                async move {
754                    let attempt = calls.fetch_add(1, Ordering::SeqCst);
755                    if attempt < 2 {
756                        Err(no_identity_issued())
757                    } else {
758                        Ok(())
759                    }
760                }
761            })
762            .await;
763
764            assert!(result.is_ok());
765            assert_eq!(
766                calls.load(Ordering::SeqCst),
767                3,
768                "expected 2 failed probes then 1 succeeding probe"
769            );
770            // Backoff between attempts 0->1 is 1s and 1->2 is 2s: 3s total,
771            // no more (the loop must stop backing off once probe succeeds).
772            assert_eq!(start.elapsed(), Duration::from_secs(1 + 2));
773        }
774
775        #[tokio::test]
776        async fn retry_until_identity_issued_returns_other_error_immediately_unretried() {
777            let calls = Arc::new(AtomicUsize::new(0));
778            let calls_probe = calls.clone();
779
780            let err = retry_until_identity_issued("unix:///test.sock", move || {
781                let calls = calls_probe.clone();
782                async move {
783                    calls.fetch_add(1, Ordering::SeqCst);
784                    Err(permission_denied("selectors do not match"))
785                }
786            })
787            .await
788            .unwrap_err();
789
790            assert_eq!(
791                calls.load(Ordering::SeqCst),
792                1,
793                "a non-'no identity issued' failure must never be retried"
794            );
795            match err {
796                ClientError::WorkloadProbe { socket, source } => {
797                    assert_eq!(socket, "unix:///test.sock");
798                    assert!(matches!(
799                        source,
800                        spiffe::WorkloadApiError::PermissionDenied(_)
801                    ));
802                }
803                other => panic!("expected ClientError::WorkloadProbe, got {other:?}"),
804            }
805        }
806
807        #[tokio::test(start_paused = true)]
808        async fn retry_until_identity_issued_exhausts_after_five_attempts_with_full_backoff() {
809            let calls = Arc::new(AtomicUsize::new(0));
810            let calls_probe = calls.clone();
811            let start = tokio::time::Instant::now();
812
813            let err = retry_until_identity_issued("unix:///test.sock", move || {
814                let calls = calls_probe.clone();
815                async move {
816                    calls.fetch_add(1, Ordering::SeqCst);
817                    Err(no_identity_issued())
818                }
819            })
820            .await
821            .unwrap_err();
822
823            assert_eq!(
824                calls.load(Ordering::SeqCst),
825                WORKLOAD_DIAL_MAX_ATTEMPTS,
826                "expected exactly WORKLOAD_DIAL_MAX_ATTEMPTS probes when every one fails with no identity issued"
827            );
828            // Full backoff schedule elapses: 1 + 2 + 4 + 8 = 15s.
829            assert_eq!(start.elapsed(), Duration::from_secs(1 + 2 + 4 + 8));
830
831            match err {
832                ClientError::WorkloadNoIdentityIssued {
833                    socket,
834                    attempts,
835                    source,
836                } => {
837                    assert_eq!(socket, "unix:///test.sock");
838                    assert_eq!(attempts, WORKLOAD_DIAL_MAX_ATTEMPTS);
839                    assert!(matches!(source, spiffe::WorkloadApiError::NoIdentityIssued));
840                }
841                other => panic!("expected ClientError::WorkloadNoIdentityIssued, got {other:?}"),
842            }
843        }
844    }
845}
846
847#[cfg(feature = "spiffe-workload")]
848pub use workload::dial_workload;
849
850#[cfg(test)]
851mod tests {
852    use super::*;
853
854    #[test]
855    fn is_loopback_host_matches_go_client_table() {
856        let cases: &[(&str, bool)] = &[
857            ("localhost", true),
858            ("127.0.0.1", true),
859            ("::1", true),
860            ("10.0.0.5", false),
861            ("signet.internal", false),
862        ];
863        for (host, want) in cases {
864            assert_eq!(is_loopback_host(host), *want, "is_loopback_host({host:?})");
865        }
866    }
867
868    #[test]
869    fn admin_transport_decision_loopback_defaults_to_plaintext() {
870        let decision = admin_transport_decision("localhost:8444", None, false, false).unwrap();
871        assert!(!decision.requires_tls());
872    }
873
874    #[test]
875    fn admin_transport_decision_non_loopback_requires_tls() {
876        let decision =
877            admin_transport_decision("signet.internal:8444", None, false, false).unwrap();
878        assert!(decision.requires_tls());
879    }
880
881    #[test]
882    fn admin_transport_decision_force_tls_on_loopback() {
883        let decision = admin_transport_decision("localhost:8444", None, true, false).unwrap();
884        assert!(decision.requires_tls());
885    }
886
887    #[test]
888    fn admin_transport_decision_plaintext_overrides_non_loopback() {
889        // The whole point of issue #32: a non-loopback address (e.g. an
890        // in-cluster Service DNS name) that would otherwise be upgraded to
891        // TLS by the loopback heuristic stays plaintext when explicitly
892        // requested.
893        let decision =
894            admin_transport_decision("signet.internal:8444", None, false, true).unwrap();
895        assert!(!decision.requires_tls());
896    }
897
898    #[test]
899    fn admin_transport_decision_plaintext_leaves_loopback_unaffected() {
900        // plaintext on an address that would already default to plaintext
901        // is a no-op, not an error.
902        let decision = admin_transport_decision("localhost:8444", None, false, true).unwrap();
903        assert!(!decision.requires_tls());
904    }
905
906    #[test]
907    fn admin_transport_decision_rejects_force_tls_and_plaintext_together() {
908        let err = admin_transport_decision("localhost:8444", None, true, true).unwrap_err();
909        assert!(
910            matches!(err, ClientError::ForceTlsPlaintextConflict),
911            "expected ClientError::ForceTlsPlaintextConflict, got {err:?}"
912        );
913        assert_eq!(
914            err.to_string(),
915            "force_tls and plaintext are mutually exclusive"
916        );
917    }
918
919    #[test]
920    fn admin_transport_decision_rejects_plaintext_with_ca_pem() {
921        let pem = b"-----BEGIN CERTIFICATE-----\nnot validated at this layer\n-----END CERTIFICATE-----\n";
922        let err = admin_transport_decision("localhost:8444", Some(pem), false, true).unwrap_err();
923        assert!(
924            matches!(err, ClientError::PlaintextCaPemConflict),
925            "expected ClientError::PlaintextCaPemConflict, got {err:?}"
926        );
927        assert_eq!(
928            err.to_string(),
929            "plaintext and ca_pem are mutually exclusive"
930        );
931    }
932
933    #[test]
934    fn admin_transport_decision_plaintext_with_empty_ca_pem_is_not_a_conflict() {
935        // An empty (zero-length) CA slice is treated the same as `None`
936        // throughout this module (see `ca_pem_non_empty` in
937        // `admin_transport_decision`), so it doesn't trip the
938        // plaintext/ca_pem mutual-exclusion check.
939        let decision = admin_transport_decision("localhost:8444", Some(&[]), false, true).unwrap();
940        assert!(!decision.requires_tls());
941    }
942
943    #[test]
944    fn admin_transport_decision_ca_pem_forces_tls_even_on_loopback() {
945        // Providing a CA bundle is a signal the caller wants TLS, even
946        // against a loopback address (e.g. testing against a local TLS
947        // listener via port-forward). This is a throwaway self-signed test
948        // certificate (`openssl req -x509 -newkey rsa:2048 ...`), valid PEM
949        // framing/DER but not chained to any real CA.
950        let pem = b"-----BEGIN CERTIFICATE-----\n\
951MIICoDCCAYgCCQDLsJN6ayvwqTANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAd0\n\
952ZXN0LWNhMB4XDTI2MDcxMjIxMzM1MFoXDTI2MDcxMzIxMzM1MFowEjEQMA4GA1UE\n\
953AwwHdGVzdC1jYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALKQT/9e\n\
954HkJlnufQ8dCzc0JdZRO1gHDMY6stgfZljK1dEj2SaANpP3MDVIyDcmKq/6Gwbj4K\n\
955fexqB+1VGLn7CKmopYBvAIwiMDHsQ/R8xDOLwVJRCwnxzAbUUsBF9LvRkDqV4U0/\n\
956i7jizdwtxDHLoB9qEkDKWo3flgIGQtgJ6Vsj7YM9CPq369fby5ZBsPCR3itvEsiZ\n\
957BoM13D3A2RFywYWFpvAvzlzR6LoFd4OnH/8QMh9KTTtxNYw2K8C/a2Cv3GZRROhN\n\
958g5vcQbXLSyYVBUSwdEBT50/pl97KLStN54XEE2YQvoBZCU/kUBrOP888wn+ljafk\n\
959XEMVrZiAKRDnZokCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAQpRqAdDsxNm+1qFf\n\
9603IW8jJnfMrwdIUukE4c/ms7v3+n6QkdQYidfnZSXCrd0TAzXkRGonrFUDWAfRoGX\n\
961ty0EN/hiU/wmDEvmsNgg9PS5KW3qqoIFRGYdwxn97hjJ0GdgUrbBLg0BweeaP+WW\n\
9620Q7Jive55TT4W+Hwl5KETWOGi2FnvrlrDQGHWY1XKQKQn9J/tEQDMd+COyM9BHez\n\
963oWg4npa5Q/5SdfJs3i4GyGRU4NWYxGfgFi7JiHOZx8t2Nv0RJkYqQu1SMNq97IDo\n\
964ezQtmgLYbjPG41WWrdNT76h1mJgtlCzH0DfI7lQTBIi9AuE5poxPQiBoaC7flMsV\n\
965w8cAzA==\n\
966-----END CERTIFICATE-----\n";
967        let decision = admin_transport_decision("localhost:8444", Some(pem), false, false);
968        assert!(decision.is_ok());
969        assert!(decision.unwrap().requires_tls());
970    }
971
972    #[test]
973    fn admin_transport_decision_rejects_invalid_ca_pem() {
974        let err =
975            admin_transport_decision("signet.internal:8444", Some(b"not a cert"), false, false)
976                .unwrap_err();
977        assert!(
978            matches!(err, ClientError::InvalidCaPem),
979            "expected ClientError::InvalidCaPem, got {err:?}"
980        );
981        assert_eq!(err.to_string(), "invalid CA PEM bundle: no certificates found");
982    }
983
984    #[tokio::test]
985    async fn dial_admin_rejects_empty_token() {
986        let err = dial_admin("localhost:8444", "   ", None, false, false)
987            .await
988            .unwrap_err();
989        assert!(matches!(err, ClientError::EmptyToken));
990        assert_eq!(err.to_string(), "token must not be empty");
991    }
992
993    #[tokio::test]
994    async fn gitops_client_and_admin_client_accept_both_channel_kinds() {
995        // gitops_client/admin_client must accept both the AdminChannel
996        // dial_admin returns (Channel + TokenInterceptor) and the plain
997        // Channel dial_workload returns — that's the whole point of making
998        // them generic. `Endpoint::connect_lazy` builds a real Channel
999        // without touching the network (it only dials on first RPC), so
1000        // this actually constructs both client types over both channel
1001        // kinds rather than merely type-checking that it's possible.
1002        let plain_channel: Channel = Endpoint::from_static("http://localhost:1").connect_lazy();
1003        let _gitops_over_plain_channel = gitops_client(plain_channel.clone());
1004        let _admin_over_plain_channel = admin_client(plain_channel);
1005
1006        let header_value: tonic::metadata::MetadataValue<tonic::metadata::Ascii> =
1007            "Bearer test-token".parse().unwrap();
1008        let admin_channel: AdminChannel = InterceptedService::new(
1009            Endpoint::from_static("http://localhost:1").connect_lazy(),
1010            TokenInterceptor { header_value },
1011        );
1012        let _gitops_over_admin_channel = gitops_client(admin_channel.clone());
1013        let _admin_over_admin_channel = admin_client(admin_channel);
1014    }
1015
1016    #[test]
1017    fn host_of_handles_bracketed_ipv6_and_bare_hosts() {
1018        assert_eq!(host_of("localhost:8444"), "localhost");
1019        assert_eq!(host_of("127.0.0.1:8444"), "127.0.0.1");
1020        assert_eq!(host_of("[::1]:8444"), "::1");
1021        assert_eq!(host_of("signet.internal"), "signet.internal");
1022    }
1023}