Skip to main content

pb_mapper_client/sdk/
client.rs

1//! The session object: [`Client`], its request types, and the tunnel spawner.
2//!
3//! Everything a caller starts goes through here. `register` and `connect` are
4//! the same shape — resolve the local endpoint, open a status channel, spawn the
5//! transport's worker — so that lifecycle lives in one place (`TunnelWorker`)
6//! and each call site is left with only the worker it invokes.
7
8use crate::diagnostics::{Diagnostics, RecoveryPhase};
9use crate::endpoint::RelayEndpoint;
10use crate::relay::CarrierId;
11use std::sync::{Arc, RwLock};
12
13use pb_mapper_core::checksum::{Credential, parse_credential};
14use pb_mapper_core::config::{ResolvedAddrs, control_io_timeout, resolve_addrs_async};
15use pb_mapper_protocol::command::{PbConnStatusReq, PbConnStatusResp};
16use tokio::sync::watch;
17use tokio_util::sync::CancellationToken;
18use uni_stream::stream::{
19    TcpListenerProvider, TcpStreamProvider, UdpListenerProvider, UdpStreamProvider,
20};
21
22use snafu::ResultExt;
23
24use super::Error;
25use super::admin::Admin;
26use super::error::{AddressSnafu, Result, StatusSnafu};
27use super::handle::{Connection, LiveTunnel, Registration};
28use super::types::{RemoteId, ServiceConnection, Transport, TunnelStatus};
29use super::{ConnectOptions, PathPolicy, RegisterOptions};
30use crate::client::run_client_side_cli_recovering_with_path;
31use crate::client::status::get_status_with_credential;
32use crate::server::{ServerTunnelOptions, StatusCallback, run_server_side_cli_recovering};
33
34/// Configuration for a [`Client`] session.
35#[derive(Clone)]
36pub struct ClientConfig {
37    /// Relay address (`host:port`).
38    pub server: String,
39    /// Administrator key (32 printable bytes) or a `pbmt1_` temporary credential.
40    pub credential: String,
41    pub keep_alive: bool,
42    /// Administrator-only target namespace. Temporary credentials always use
43    /// their own key id when this is `None`.
44    pub namespace: Option<u64>,
45}
46
47impl std::fmt::Debug for ClientConfig {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.debug_struct("ClientConfig")
50            .field("server", &self.server)
51            .field("credential", &"[redacted]")
52            .field("keep_alive", &self.keep_alive)
53            .field("namespace", &self.namespace)
54            .finish()
55    }
56}
57
58/// Register a local TCP/UDP service with the relay.
59#[derive(Clone, Debug)]
60pub struct RegisterRequest {
61    pub key: String,
62    pub local_addr: String,
63    pub transport: Transport,
64    pub codec: bool,
65    pub force_namespace: bool,
66}
67
68/// Subscribe to a registered service and listen locally.
69#[derive(Clone, Debug)]
70pub struct ConnectRequest {
71    pub key: String,
72    pub local_addr: String,
73    pub transport: Transport,
74}
75
76pub(crate) struct ClientInner {
77    pub(crate) server: String,
78    pub(crate) endpoint: RelayEndpoint,
79    pub(crate) credential: RwLock<Credential>,
80    pub(crate) keep_alive: bool,
81    pub(crate) namespace: Option<u64>,
82}
83
84/// Session against one deployed relay.
85///
86/// The credential is per-client, not process-global. Two clients in the same
87/// process may use different keys.
88#[derive(Clone)]
89pub struct Client {
90    pub(crate) inner: Arc<ClientInner>,
91}
92
93impl Client {
94    pub fn new(config: ClientConfig) -> Result<Self> {
95        if config.server.trim().is_empty() {
96            return Err(Error::invalid_config("server address is required"));
97        }
98        if config.credential.trim().is_empty() {
99            return Err(Error::invalid_config("credential is required"));
100        }
101        let credential =
102            parse_credential(config.credential.trim()).map_err(Error::invalid_config)?;
103        Ok(Self::from_credential(
104            config.server,
105            credential,
106            config.keep_alive,
107            config.namespace,
108        ))
109    }
110
111    /// Build a client from an already-parsed credential.
112    pub fn from_credential(
113        server: impl Into<String>,
114        credential: Credential,
115        keep_alive: bool,
116        namespace: Option<u64>,
117    ) -> Self {
118        let server = server.into();
119        let endpoint = RelayEndpoint::shared(&server);
120        Self {
121            inner: Arc::new(ClientInner {
122                server,
123                endpoint,
124                credential: RwLock::new(credential),
125                keep_alive,
126                namespace,
127            }),
128        }
129    }
130
131    /// Select the relay carrier for subsequent work. Existing handles retain their carrier.
132    /// TCP remains the default. QUIC requires the relay-quic feature and a QUIC-enabled relay.
133    pub fn with_relay_transport(&self, transport: super::RelayTransport) -> Result<Self> {
134        if transport == self.relay_transport() {
135            return Ok(self.clone());
136        }
137        let endpoint = self
138            .inner
139            .endpoint
140            .with_transport(transport)
141            .map_err(|error| Error::invalid_config(error.to_string()))?;
142        Ok(Self {
143            inner: Arc::new(ClientInner {
144                server: self.inner.server.clone(),
145                endpoint,
146                credential: RwLock::new(self.credential()),
147                keep_alive: self.inner.keep_alive,
148                namespace: self.inner.namespace,
149            }),
150        })
151    }
152    pub fn relay_transport(&self) -> super::RelayTransport {
153        self.inner.endpoint.transport()
154    }
155    /// Sample current QUIC counters without a background observer or per-packet work.
156    /// Clients of the same relay and carrier in this process share one pool.
157    pub async fn relay_diagnostics(&self) -> super::RelayDiagnostics {
158        self.inner.endpoint.relay_diagnostics().await
159    }
160
161    /// The UDP port the relay offers QUIC on, or `None` when it offers none:
162    /// one started without a QUIC listener, or one from before QUIC. Asked
163    /// over TCP whatever this client's carrier, so a TCP client can learn
164    /// whether QUIC would work before switching to it.
165    pub async fn relay_quic_port(&self) -> Result<Option<u16>> {
166        let timeout = control_io_timeout().min(std::time::Duration::from_secs(5));
167        tokio::time::timeout(timeout, self.inner.endpoint.quic_port(self.credential()))
168            .await
169            .unwrap_or(Err(
170                std::io::Error::from(std::io::ErrorKind::TimedOut).into()
171            ))
172            .map_err(|error| Error::from_connect(error, self.inner.server.clone()))
173    }
174
175    /// Notify workers after a host network/resume event. This does not mark services healthy.
176    pub fn notify_network_change(&self) {
177        self.inner.endpoint.notify_network_change();
178    }
179
180    pub fn server(&self) -> &str {
181        &self.inner.server
182    }
183
184    pub fn namespace(&self) -> Option<u64> {
185        self.inner.namespace
186    }
187
188    pub(crate) fn credential(&self) -> Credential {
189        *self
190            .inner
191            .credential
192            .read()
193            .unwrap_or_else(|poisoned| poisoned.into_inner())
194    }
195
196    /// Administrator RPCs. Fails locally when the session is not an admin key.
197    pub fn admin(&self) -> Result<Admin> {
198        if !self.credential().is_admin() {
199            return Err(Error::NotAdministrator);
200        }
201        Ok(Admin {
202            inner: Arc::clone(&self.inner),
203        })
204    }
205
206    /// Publish a local service on the relay under `request.key`, with
207    /// [`RegisterOptions::default`]: direct sessions are allowed when the SDK
208    /// is built with `p2p`.
209    ///
210    /// Returns as soon as the worker is spawned; the handle's
211    /// [`Registration::wait_ready`] is what waits for the relay to accept it.
212    pub async fn register(&self, request: RegisterRequest) -> Result<Registration> {
213        self.register_with_options(request, RegisterOptions::default())
214            .await
215    }
216
217    /// Publish with an idle deadline for each forwarded TCP data connection.
218    ///
219    /// Traffic in either direction resets the deadline; control connections and
220    /// other registrations are unaffected. Zero disables the idle deadline.
221    /// UDP requests are rejected because datagram lifetime has a separate policy.
222    /// Durations that cannot be represented as a timer deadline are rejected.
223    pub async fn register_with_idle_timeout(
224        &self,
225        request: RegisterRequest,
226        idle_timeout: std::time::Duration,
227    ) -> Result<Registration> {
228        if request.transport.is_datagram() {
229            return Err(Error::invalid_config(
230                "a registration idle deadline requires TCP",
231            ));
232        }
233        if tokio::time::Instant::now()
234            .checked_add(idle_timeout)
235            .is_none()
236        {
237            return Err(Error::invalid_config("idle timeout exceeds timer range"));
238        }
239        self.register_inner(
240            request,
241            Some(idle_timeout),
242            RegisterOptions::default().allow_direct,
243        )
244        .await
245    }
246
247    /// Publish a service with explicit options, such as
248    /// `allow_direct: false` to keep every session on the relay.
249    pub async fn register_with_options(
250        &self,
251        request: RegisterRequest,
252        options: RegisterOptions,
253    ) -> Result<Registration> {
254        if options.allow_direct && !cfg!(feature = "p2p") {
255            return Err(Error::invalid_config("built without p2p support"));
256        }
257        if let Some(timeout) = options.idle_timeout
258            && (request.transport.is_datagram()
259                || tokio::time::Instant::now().checked_add(timeout).is_none())
260        {
261            return Err(Error::invalid_config(
262                "idle deadline requires TCP and a representable duration",
263            ));
264        }
265        self.register_inner(request, options.idle_timeout, options.allow_direct)
266            .await
267    }
268
269    async fn register_inner(
270        &self,
271        request: RegisterRequest,
272        idle_timeout: Option<std::time::Duration>,
273        allow_direct: bool,
274    ) -> Result<Registration> {
275        if request.key.trim().is_empty() {
276            return Err(Error::invalid_config("service key is required"));
277        }
278        let options = ServerTunnelOptions {
279            need_codec: request.codec,
280            is_datagram: request.transport.is_datagram(),
281            keep_alive: self.inner.keep_alive,
282            namespace: self.inner.namespace,
283            force_namespace: request.force_namespace,
284        };
285        let worker = self
286            .prepare_worker(&request.key, &request.local_addr)
287            .await?;
288        let credential = self.credential();
289        let handle = match request.transport {
290            Transport::Tcp => worker.spawn(move |context| {
291                run_server_side_cli_recovering::<TcpStreamProvider>(
292                    context.local_addr,
293                    context.remote_addr,
294                    context.key,
295                    options,
296                    Some(context.status_callback),
297                    Some(credential),
298                    context.shutdown,
299                    context.diagnostics,
300                    idle_timeout,
301                    allow_direct,
302                )
303            }),
304            Transport::Udp => worker.spawn(move |context| {
305                run_server_side_cli_recovering::<UdpStreamProvider>(
306                    context.local_addr,
307                    context.remote_addr,
308                    context.key,
309                    options,
310                    Some(context.status_callback),
311                    Some(credential),
312                    context.shutdown,
313                    context.diagnostics,
314                    idle_timeout,
315                    allow_direct,
316                )
317            }),
318        };
319        Ok(Registration::new(handle, request.key))
320    }
321
322    /// Subscribe to a registered service and forward it from a local listener,
323    /// with [`ConnectOptions::default`]: direct first with relay fallback when
324    /// the SDK is built with `p2p`.
325    ///
326    /// Returns as soon as the worker is spawned; the handle's
327    /// [`Connection::wait_ready`] is what waits for the local listener to bind
328    /// and the relay to confirm the service.
329    pub async fn connect(&self, request: ConnectRequest) -> Result<Connection> {
330        self.connect_with_options(request, ConnectOptions::default())
331            .await
332    }
333
334    /// Select direct-first or the relay-only protocol for this tunnel only.
335    pub async fn connect_with_options(
336        &self,
337        request: ConnectRequest,
338        options: ConnectOptions,
339    ) -> Result<Connection> {
340        if options.path == PathPolicy::Auto && !cfg!(feature = "p2p") {
341            return Err(Error::invalid_config("built without p2p support"));
342        }
343        let is_datagram = request.transport.is_datagram();
344        if request.key.trim().is_empty() {
345            return Err(Error::invalid_config("service key is required"));
346        }
347        let worker = self
348            .prepare_worker(&request.key, &request.local_addr)
349            .await?;
350        let credential = self.credential();
351        let keep_alive = self.inner.keep_alive;
352        let namespace = self.inner.namespace;
353        let handle = match request.transport {
354            Transport::Tcp => worker.spawn(move |context| {
355                run_client_side_cli_recovering_with_path::<TcpListenerProvider>(
356                    context.local_addr,
357                    context.remote_addr,
358                    context.key,
359                    keep_alive,
360                    namespace,
361                    Some(context.status_callback),
362                    Some(credential),
363                    context.shutdown,
364                    context.diagnostics,
365                    options.path,
366                    is_datagram,
367                )
368            }),
369            Transport::Udp => worker.spawn(move |context| {
370                run_client_side_cli_recovering_with_path::<UdpListenerProvider>(
371                    context.local_addr,
372                    context.remote_addr,
373                    context.key,
374                    keep_alive,
375                    namespace,
376                    Some(context.status_callback),
377                    Some(credential),
378                    context.shutdown,
379                    context.diagnostics,
380                    options.path,
381                    is_datagram,
382                )
383            }),
384        };
385        Ok(Connection::new(handle, request.key))
386    }
387
388    /// Service keys visible to this credential's namespace.
389    pub async fn list_keys(&self) -> Result<Vec<String>> {
390        match self.status_request(PbConnStatusReq::Keys).await? {
391            PbConnStatusResp::Keys(keys) => Ok(keys),
392            other => Err(Error::protocol(format!(
393                "expected keys status, got {other:?}"
394            ))),
395        }
396    }
397
398    /// Endpoint observations; stale or conflicting reports do not control sessions.
399    pub async fn sessions(&self) -> Result<Vec<pb_mapper_protocol::direct::SessionObservation>> {
400        match self.status_request(PbConnStatusReq::Sessions).await? {
401            PbConnStatusResp::Sessions(sessions) => Ok(sessions),
402            other => Err(Error::protocol(format!(
403                "expected sessions status, got {other:?}"
404            ))),
405        }
406    }
407
408    pub async fn service_status(&self, key: impl Into<String>) -> Result<Vec<ServiceConnection>> {
409        let key = key.into();
410        match self
411            .status_request(PbConnStatusReq::Service {
412                direct_query: None,
413                key,
414            })
415            .await?
416        {
417            PbConnStatusResp::Service { connections, .. } => Ok(connections
418                .into_iter()
419                .map(ServiceConnection::from)
420                .collect()),
421            other => Err(Error::protocol(format!(
422                "expected service status, got {other:?}"
423            ))),
424        }
425    }
426
427    pub async fn remote_id(&self) -> Result<RemoteId> {
428        RemoteId::from_status(self.status_request(PbConnStatusReq::RemoteId).await?)
429    }
430
431    /// Resolve the local endpoint and set up a recovering relay worker.
432    ///
433    /// Shared by `register` and `connect`: the two differ only in which worker
434    /// they hand the context to. Relay DNS remains retryable inside that worker.
435    async fn prepare_worker(&self, key: &str, local_addr: &str) -> Result<TunnelWorker> {
436        if !self.inner.endpoint.validate() {
437            return Err(Error::invalid_config(
438                "relay must be host:port or an IP socket address",
439            ));
440        }
441        let local_addr = resolve(local_addr).await?;
442        let remote_addr = self.inner.endpoint.clone();
443        let (status_tx, status_rx) = watch::channel(TunnelStatus::Starting);
444        Ok(TunnelWorker {
445            local_addr,
446            remote_addr,
447            key: Arc::from(key),
448            shutdown: CancellationToken::new(),
449            status_tx,
450            status_rx,
451            diagnostics: Diagnostics::default(),
452        })
453    }
454
455    async fn status_request(&self, request: PbConnStatusReq) -> Result<PbConnStatusResp> {
456        let credential = self.credential();
457        // Queries share admission with recovery. A stalled status consumer must
458        // not occupy that budget for the legacy 30-second control I/O timeout.
459        let io_timeout = control_io_timeout().min(std::time::Duration::from_secs(5));
460        let endpoint = &self.inner.endpoint;
461        let ask = async |carrier: &mut Option<CarrierId>| {
462            let mut stream = endpoint
463                .connect(credential, carrier)
464                .await
465                .map_err(|error| Error::from_connect(error, self.inner.server.clone()))?;
466            get_status_with_credential(
467                &mut stream,
468                request.clone(),
469                self.inner.namespace,
470                &credential,
471            )
472            .await
473            .context(StatusSnafu)
474        };
475        // Outside the deadline, so that a query it cuts short is charged to
476        // the connection the query went over too.
477        let mut carrier = None;
478        let result = tokio::time::timeout(io_timeout, async {
479            let _permit = endpoint.control_permit().await;
480            match ask(&mut carrier).await {
481                // A pooled QUIC connection whose credential the relay has
482                // since cancelled takes no new streams, so the refusal never
483                // arrives on it. A status query is a read: asked once more on
484                // a fresh connection, it gets the relay's verdict.
485                Err(error)
486                    if is_carrier_failure(&error)
487                        && endpoint.transport() == super::RelayTransport::Quic =>
488                {
489                    endpoint.carrier_failed(carrier);
490                    ask(&mut carrier).await
491                }
492                result => result,
493            }
494        })
495        .await
496        .unwrap_or(Err(Error::TimedOut {
497            timeout: io_timeout,
498        }));
499        match &result {
500            Ok(_) => endpoint.protocol_succeeded(),
501            // The relay answered: the carrier works, whatever it said.
502            Err(error) if is_carrier_failure(error) => endpoint.carrier_failed(carrier),
503            Err(_) => {}
504        }
505        result
506    }
507}
508
509/// Whether `error` says the carrier failed, rather than that the relay
510/// answered with a refusal.
511pub(crate) fn is_carrier_failure(error: &Error) -> bool {
512    match error {
513        Error::Remote { .. } => false,
514        Error::Status { source } => source.remote_retryable().is_none(),
515        _ => true,
516    }
517}
518
519/// Resolve one endpoint to every address it names.
520///
521/// The list, not just its first entry: a worker handed the whole list dials each
522/// candidate in turn, so a relay hostname with several records survives one of
523/// them being unreachable.
524async fn resolve(addr: &str) -> Result<ResolvedAddrs> {
525    resolve_addrs_async(addr)
526        .await
527        .context(AddressSnafu { addr })
528}
529
530/// Mark a finished worker as `Stopped`, unless it already reported why it will
531/// never come up. The status is a watch channel, so it keeps only the newest
532/// value: overwriting a `Failed(reason)` that the worker set on its way out
533/// would replace the only description of a permanent rejection the caller ever
534/// gets, leaving `wait_ready` to report a bare stop instead.
535fn settle_stopped(tx: &watch::Sender<TunnelStatus>) {
536    tx.send_if_modified(|status| match status {
537        TunnelStatus::Failed(_) => false,
538        _ => {
539            *status = TunnelStatus::Stopped;
540            true
541        }
542    });
543}
544
545/// Bridge the worker's string status callback onto the handle's watch channel.
546///
547/// `StatusCallback` and `ClientStatusCallback` are the same boxed closure type,
548/// so both tunnel directions share this.
549fn watch_callback(tx: watch::Sender<TunnelStatus>) -> StatusCallback {
550    Box::new(move |status: &str| {
551        let _ = tx.send(TunnelStatus::from_callback(status));
552    })
553}
554
555/// What a spawned tunnel worker is given: its resolved endpoints, its key, the
556/// callback that publishes its status, and the token that stops it.
557struct WorkerContext {
558    local_addr: ResolvedAddrs,
559    remote_addr: RelayEndpoint,
560    key: Arc<str>,
561    status_callback: StatusCallback,
562    shutdown: CancellationToken,
563    diagnostics: Diagnostics,
564}
565
566/// A tunnel resolved and wired up, waiting only for the transport-specific
567/// worker that will drive it.
568///
569/// Register and connect, over TCP and UDP, are four workers with four different
570/// signatures but one lifecycle: spawn, publish status, settle as `Stopped`.
571/// This owns that lifecycle so each call site is left with just its own call.
572struct TunnelWorker {
573    local_addr: ResolvedAddrs,
574    remote_addr: RelayEndpoint,
575    key: Arc<str>,
576    shutdown: CancellationToken,
577    status_tx: watch::Sender<TunnelStatus>,
578    status_rx: watch::Receiver<TunnelStatus>,
579    diagnostics: Diagnostics,
580}
581
582impl TunnelWorker {
583    fn spawn<F, Fut>(self, start: F) -> LiveTunnel
584    where
585        F: FnOnce(WorkerContext) -> Fut + Send + 'static,
586        Fut: std::future::Future<Output = ()> + Send,
587    {
588        let Self {
589            local_addr,
590            remote_addr,
591            key,
592            shutdown,
593            status_tx,
594            status_rx,
595            diagnostics,
596        } = self;
597        let worker_shutdown = shutdown.clone();
598        let worker_diagnostics = diagnostics.clone();
599        let endpoint = remote_addr.clone();
600        let join = tokio::spawn(async move {
601            start(WorkerContext {
602                local_addr,
603                remote_addr,
604                key,
605                status_callback: watch_callback(status_tx.clone()),
606                shutdown: worker_shutdown,
607                diagnostics: worker_diagnostics.clone(),
608            })
609            .await;
610            worker_diagnostics.phase(RecoveryPhase::Stopped);
611            settle_stopped(&status_tx);
612        });
613        LiveTunnel::new(shutdown, join, status_rx, endpoint, diagnostics)
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    #[test]
622    fn config_debug_redacts_credentials() {
623        let config = ClientConfig {
624            server: "localhost:7666".into(),
625            credential: "0123456789abcdefghijklmnopqrstuv".into(),
626            keep_alive: false,
627            namespace: None,
628        };
629        let debug = format!("{config:?}");
630        assert!(!debug.contains(&config.credential));
631        assert!(debug.contains("[redacted]"));
632    }
633
634    #[test]
635    fn admin_requires_administrator_credential() {
636        let admin = Client::from_credential(
637            "127.0.0.1:7666",
638            Credential::Admin(*b"0123456789abcdefghijklmnopqrstuv"),
639            false,
640            None,
641        );
642        assert!(admin.admin().is_ok());
643
644        let temporary = Client::from_credential(
645            "127.0.0.1:7666",
646            Credential::Temporary {
647                key_id: 1,
648                key: [0_u8; 32],
649            },
650            false,
651            None,
652        );
653        assert!(matches!(temporary.admin(), Err(Error::NotAdministrator)));
654    }
655}