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;
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, PortMapping, 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            RegisterOptions {
242                idle_timeout: Some(idle_timeout),
243                ..Default::default()
244            },
245        )
246        .await
247    }
248
249    /// Publish a service with explicit options, such as
250    /// `allow_direct: false` to keep every session on the relay.
251    pub async fn register_with_options(
252        &self,
253        request: RegisterRequest,
254        options: RegisterOptions,
255    ) -> Result<Registration> {
256        if options.allow_direct && !cfg!(feature = "p2p") {
257            return Err(Error::invalid_config("built without p2p support"));
258        }
259        check_port_mapping(options.port_mapping)?;
260        if let Some(timeout) = options.idle_timeout
261            && (request.transport.is_datagram()
262                || tokio::time::Instant::now().checked_add(timeout).is_none())
263        {
264            return Err(Error::invalid_config(
265                "idle deadline requires TCP and a representable duration",
266            ));
267        }
268        self.register_inner(request, options).await
269    }
270
271    async fn register_inner(
272        &self,
273        request: RegisterRequest,
274        options: RegisterOptions,
275    ) -> Result<Registration> {
276        let RegisterOptions {
277            allow_direct,
278            idle_timeout,
279            port_mapping,
280        } = options;
281        if request.key.trim().is_empty() {
282            return Err(Error::invalid_config("service key is required"));
283        }
284        let tunnel = ServerTunnelOptions {
285            need_codec: request.codec,
286            is_datagram: request.transport.is_datagram(),
287            keep_alive: self.inner.keep_alive,
288            namespace: self.inner.namespace,
289            force_namespace: request.force_namespace,
290        };
291        let worker = self
292            .prepare_worker(&request.key, &request.local_addr)
293            .await?;
294        let credential = self.credential();
295        let port_mapping = worker.port_mapping(if allow_direct {
296            port_mapping
297        } else {
298            PortMapping::Off
299        });
300        let handle = match request.transport {
301            Transport::Tcp => worker.spawn(move |context| {
302                run_server_side_cli_recovering::<TcpStreamProvider>(
303                    context.local_addr,
304                    context.remote_addr,
305                    context.key,
306                    tunnel,
307                    Some(context.status_callback),
308                    Some(credential),
309                    context.shutdown,
310                    context.diagnostics,
311                    idle_timeout,
312                    allow_direct,
313                    port_mapping,
314                )
315            }),
316            Transport::Udp => worker.spawn(move |context| {
317                run_server_side_cli_recovering::<UdpStreamProvider>(
318                    context.local_addr,
319                    context.remote_addr,
320                    context.key,
321                    tunnel,
322                    Some(context.status_callback),
323                    Some(credential),
324                    context.shutdown,
325                    context.diagnostics,
326                    idle_timeout,
327                    allow_direct,
328                    port_mapping,
329                )
330            }),
331        };
332        Ok(Registration::new(handle, request.key))
333    }
334
335    /// Subscribe to a registered service and forward it from a local listener,
336    /// with [`ConnectOptions::default`]: direct first with relay fallback when
337    /// the SDK is built with `p2p`.
338    ///
339    /// Returns as soon as the worker is spawned; the handle's
340    /// [`Connection::wait_ready`] is what waits for the local listener to bind
341    /// and the relay to confirm the service.
342    pub async fn connect(&self, request: ConnectRequest) -> Result<Connection> {
343        self.connect_with_options(request, ConnectOptions::default())
344            .await
345    }
346
347    /// Select direct-first or the relay-only protocol for this tunnel only.
348    pub async fn connect_with_options(
349        &self,
350        request: ConnectRequest,
351        options: ConnectOptions,
352    ) -> Result<Connection> {
353        if options.path == PathPolicy::Auto && !cfg!(feature = "p2p") {
354            return Err(Error::invalid_config("built without p2p support"));
355        }
356        check_port_mapping(options.port_mapping)?;
357        let is_datagram = request.transport.is_datagram();
358        if request.key.trim().is_empty() {
359            return Err(Error::invalid_config("service key is required"));
360        }
361        let worker = self
362            .prepare_worker(&request.key, &request.local_addr)
363            .await?;
364        let credential = self.credential();
365        let keep_alive = self.inner.keep_alive;
366        let namespace = self.inner.namespace;
367        let port_mapping = worker.port_mapping(match options.path {
368            PathPolicy::Auto => options.port_mapping,
369            PathPolicy::RelayOnly => PortMapping::Off,
370        });
371        let handle = match request.transport {
372            Transport::Tcp => worker.spawn(move |context| {
373                run_client_side_cli_recovering_with_path::<TcpListenerProvider>(
374                    context.local_addr,
375                    context.remote_addr,
376                    context.key,
377                    keep_alive,
378                    namespace,
379                    Some(context.status_callback),
380                    Some(credential),
381                    context.shutdown,
382                    context.diagnostics,
383                    options.path,
384                    is_datagram,
385                    port_mapping,
386                )
387            }),
388            Transport::Udp => worker.spawn(move |context| {
389                run_client_side_cli_recovering_with_path::<UdpListenerProvider>(
390                    context.local_addr,
391                    context.remote_addr,
392                    context.key,
393                    keep_alive,
394                    namespace,
395                    Some(context.status_callback),
396                    Some(credential),
397                    context.shutdown,
398                    context.diagnostics,
399                    options.path,
400                    is_datagram,
401                    port_mapping,
402                )
403            }),
404        };
405        Ok(Connection::new(handle, request.key))
406    }
407
408    /// Service keys visible to this credential's namespace.
409    pub async fn list_keys(&self) -> Result<Vec<String>> {
410        match self.status_request(PbConnStatusReq::Keys).await? {
411            PbConnStatusResp::Keys(keys) => Ok(keys),
412            other => Err(Error::protocol(format!(
413                "expected keys status, got {other:?}"
414            ))),
415        }
416    }
417
418    /// Endpoint observations; stale or conflicting reports do not control sessions.
419    pub async fn sessions(&self) -> Result<Vec<pb_mapper_protocol::direct::SessionObservation>> {
420        match self.status_request(PbConnStatusReq::Sessions).await? {
421            PbConnStatusResp::Sessions(sessions) => Ok(sessions),
422            other => Err(Error::protocol(format!(
423                "expected sessions status, got {other:?}"
424            ))),
425        }
426    }
427
428    pub async fn service_status(&self, key: impl Into<String>) -> Result<Vec<ServiceConnection>> {
429        let key = key.into();
430        match self
431            .status_request(PbConnStatusReq::Service {
432                direct_query: None,
433                key,
434            })
435            .await?
436        {
437            PbConnStatusResp::Service { connections, .. } => Ok(connections
438                .into_iter()
439                .map(ServiceConnection::from)
440                .collect()),
441            other => Err(Error::protocol(format!(
442                "expected service status, got {other:?}"
443            ))),
444        }
445    }
446
447    pub async fn remote_id(&self) -> Result<RemoteId> {
448        RemoteId::from_status(self.status_request(PbConnStatusReq::RemoteId).await?)
449    }
450
451    /// Resolve the local endpoint and set up a recovering relay worker.
452    ///
453    /// Shared by `register` and `connect`: the two differ only in which worker
454    /// they hand the context to. Relay DNS remains retryable inside that worker.
455    async fn prepare_worker(&self, key: &str, local_addr: &str) -> Result<TunnelWorker> {
456        if !self.inner.endpoint.validate() {
457            return Err(Error::invalid_config(
458                "relay must be host:port or an IP socket address",
459            ));
460        }
461        let local_addr = resolve(local_addr).await?;
462        let remote_addr = self.inner.endpoint.clone();
463        let (status_tx, status_rx) = watch::channel(TunnelStatus::Starting);
464        Ok(TunnelWorker {
465            local_addr,
466            remote_addr,
467            key: Arc::from(key),
468            shutdown: CancellationToken::new(),
469            status_tx,
470            status_rx,
471            diagnostics: Diagnostics::default(),
472        })
473    }
474
475    async fn status_request(&self, request: PbConnStatusReq) -> Result<PbConnStatusResp> {
476        let credential = self.credential();
477        // Queries share admission with recovery. A stalled status consumer must
478        // not occupy that budget for the legacy 30-second control I/O timeout.
479        let io_timeout = control_io_timeout().min(std::time::Duration::from_secs(5));
480        let endpoint = &self.inner.endpoint;
481        let ask = async |carrier: &mut Option<CarrierId>| {
482            let mut stream = endpoint
483                .connect(credential, carrier)
484                .await
485                .map_err(|error| Error::from_connect(error, self.inner.server.clone()))?;
486            get_status_with_credential(
487                &mut stream,
488                request.clone(),
489                self.inner.namespace,
490                &credential,
491            )
492            .await
493            .context(StatusSnafu)
494        };
495        // Outside the deadline, so that a query it cuts short is charged to
496        // the connection the query went over too.
497        let mut carrier = None;
498        let result = tokio::time::timeout(io_timeout, async {
499            let _permit = endpoint.control_permit().await;
500            match ask(&mut carrier).await {
501                // A pooled QUIC connection whose credential the relay has
502                // since cancelled takes no new streams, so the refusal never
503                // arrives on it. A status query is a read: asked once more on
504                // a fresh connection, it gets the relay's verdict.
505                Err(error)
506                    if is_carrier_failure(&error)
507                        && endpoint.transport() == super::RelayTransport::Quic =>
508                {
509                    endpoint.carrier_failed(carrier);
510                    ask(&mut carrier).await
511                }
512                result => result,
513            }
514        })
515        .await
516        .unwrap_or(Err(Error::TimedOut {
517            timeout: io_timeout,
518        }));
519        match &result {
520            Ok(_) => endpoint.protocol_succeeded(),
521            // The relay answered: the carrier works, whatever it said.
522            Err(error) if is_carrier_failure(error) => endpoint.carrier_failed(carrier),
523            Err(_) => {}
524        }
525        result
526    }
527}
528
529/// Whether `error` says the carrier failed, rather than that the relay
530/// answered with a refusal.
531pub(crate) fn is_carrier_failure(error: &Error) -> bool {
532    match error {
533        Error::Remote { .. } => false,
534        Error::Status { source } => source.remote_retryable().is_none(),
535        _ => true,
536    }
537}
538
539/// Refuse a mapping from the start that this build cannot make, rather than
540/// ignore it. One when needed is best effort, and the default: without the
541/// feature it never starts.
542fn check_port_mapping(requested: PortMapping) -> Result<()> {
543    if requested == PortMapping::Always && !cfg!(feature = "port-mapping") {
544        return Err(Error::invalid_config("built without port-mapping support"));
545    }
546    Ok(())
547}
548
549/// Resolve one endpoint to every address it names.
550///
551/// The list, not just its first entry: a worker handed the whole list dials each
552/// candidate in turn, so a relay hostname with several records survives one of
553/// them being unreachable.
554async fn resolve(addr: &str) -> Result<ResolvedAddrs> {
555    resolve_addrs_async(addr)
556        .await
557        .context(AddressSnafu { addr })
558}
559
560/// Mark a finished worker as `Stopped`, unless it already reported why it will
561/// never come up. The status is a watch channel, so it keeps only the newest
562/// value: overwriting a `Failed(reason)` that the worker set on its way out
563/// would replace the only description of a permanent rejection the caller ever
564/// gets, leaving `wait_ready` to report a bare stop instead.
565fn settle_stopped(tx: &watch::Sender<TunnelStatus>) {
566    tx.send_if_modified(|status| match status {
567        TunnelStatus::Failed(_) => false,
568        _ => {
569            *status = TunnelStatus::Stopped;
570            true
571        }
572    });
573}
574
575/// Bridge the worker's string status callback onto the handle's watch channel.
576///
577/// `StatusCallback` and `ClientStatusCallback` are the same boxed closure type,
578/// so both tunnel directions share this.
579fn watch_callback(tx: watch::Sender<TunnelStatus>) -> StatusCallback {
580    Box::new(move |status: &str| {
581        let _ = tx.send(TunnelStatus::from_callback(status));
582    })
583}
584
585/// What a spawned tunnel worker is given: its resolved endpoints, its key, the
586/// callback that publishes its status, and the token that stops it.
587struct WorkerContext {
588    local_addr: ResolvedAddrs,
589    remote_addr: RelayEndpoint,
590    key: Arc<str>,
591    status_callback: StatusCallback,
592    shutdown: CancellationToken,
593    diagnostics: Diagnostics,
594}
595
596/// A tunnel resolved and wired up, waiting only for the transport-specific
597/// worker that will drive it.
598///
599/// Register and connect, over TCP and UDP, are four workers with four different
600/// signatures but one lifecycle: spawn, publish status, settle as `Stopped`.
601/// This owns that lifecycle so each call site is left with just its own call.
602struct TunnelWorker {
603    local_addr: ResolvedAddrs,
604    remote_addr: RelayEndpoint,
605    key: Arc<str>,
606    shutdown: CancellationToken,
607    status_tx: watch::Sender<TunnelStatus>,
608    status_rx: watch::Receiver<TunnelStatus>,
609    diagnostics: Diagnostics,
610}
611
612impl TunnelWorker {
613    /// How this tunnel maps its direct endpoint's port, noted in its
614    /// diagnostics from the start when it may.
615    fn port_mapping(&self, mode: PortMapping) -> PortMapping {
616        let mode = if cfg!(feature = "port-mapping") {
617            mode
618        } else {
619            PortMapping::Off
620        };
621        if mode != PortMapping::Off {
622            self.diagnostics
623                .port_mapping_requested(mode, crate::network::NetworkHints::shared().generation());
624        }
625        mode
626    }
627
628    fn spawn<F, Fut>(self, start: F) -> LiveTunnel
629    where
630        F: FnOnce(WorkerContext) -> Fut + Send + 'static,
631        Fut: std::future::Future<Output = ()> + Send,
632    {
633        let Self {
634            local_addr,
635            remote_addr,
636            key,
637            shutdown,
638            status_tx,
639            status_rx,
640            diagnostics,
641        } = self;
642        let worker_shutdown = shutdown.clone();
643        let worker_diagnostics = diagnostics.clone();
644        let endpoint = remote_addr.clone();
645        let join = tokio::spawn(async move {
646            start(WorkerContext {
647                local_addr,
648                remote_addr,
649                key,
650                status_callback: watch_callback(status_tx.clone()),
651                shutdown: worker_shutdown,
652                diagnostics: worker_diagnostics.clone(),
653            })
654            .await;
655            worker_diagnostics.stop();
656            settle_stopped(&status_tx);
657        });
658        LiveTunnel::new(shutdown, join, status_rx, endpoint, diagnostics)
659    }
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    #[test]
667    fn config_debug_redacts_credentials() {
668        let config = ClientConfig {
669            server: "localhost:7666".into(),
670            credential: "0123456789abcdefghijklmnopqrstuv".into(),
671            keep_alive: false,
672            namespace: None,
673        };
674        let debug = format!("{config:?}");
675        assert!(!debug.contains(&config.credential));
676        assert!(debug.contains("[redacted]"));
677    }
678
679    #[test]
680    fn admin_requires_administrator_credential() {
681        let admin = Client::from_credential(
682            "127.0.0.1:7666",
683            Credential::Admin(*b"0123456789abcdefghijklmnopqrstuv"),
684            false,
685            None,
686        );
687        assert!(admin.admin().is_ok());
688
689        let temporary = Client::from_credential(
690            "127.0.0.1:7666",
691            Credential::Temporary {
692                key_id: 1,
693                key: [0_u8; 32],
694            },
695            false,
696            None,
697        );
698        assert!(matches!(temporary.admin(), Err(Error::NotAdministrator)));
699    }
700}