Skip to main content

citadel_sdk/builder/
node_builder.rs

1//! Node Builder API for Citadel Protocol
2//!
3//! This module provides a builder pattern interface for constructing and configuring Citadel network nodes.
4//! The builder supports creating both peer and server nodes with customizable settings for security,
5//! networking, and backend storage.
6//!
7//! # Features
8//! - Flexible node type configuration (Peer/Server)
9//! - Multiple backend storage options (In-memory, Filesystem, SQL with enterprise feature)
10//! - Customizable security settings (TLS, certificates)
11//! - Google services integration (optional)
12//! - STUN server configuration for NAT traversal
13//! - Server authentication via pre-shared keys
14//!
15//! # Example
16//! ```rust
17//! use citadel_sdk::prelude::*;
18//! use std::net::SocketAddr;
19//! use std::str::FromStr;
20//!
21//! // Create a basic server node
22//! let builder = DefaultNodeBuilder::default()
23//!     .with_node_type(NodeType::Server(SocketAddr::from_str("0.0.0.0:25021").unwrap()))
24//!     .with_backend(BackendType::InMemory);
25//! ```
26//!
27//! # Important Notes
28//! - Server nodes require a valid bind address
29//! - Default backend is filesystem-based when the "filesystem" feature is enabled
30//! - TLS is enabled by default with self-signed certificates
31//! - When using Google services, both services JSON and database config must be set
32//!
33//! # Related Components
34//! - [`NetKernel`]: Core networking kernel that processes node operations
35//! - [`KernelExecutor`]: Executor for running the network kernel
36//! - [`BackendType`]: Storage backend configurations
37
38use citadel_proto::prelude::*;
39
40use citadel_io::ServerMode;
41use citadel_proto::kernel::KernelExecutorArguments;
42use citadel_proto::macros::{ContextRequirements, LocalContextRequirements};
43use citadel_types::crypto::{HeaderObfuscatorSettings, PreSharedKey};
44use futures::Future;
45use std::fmt::{Debug, Formatter};
46use std::marker::PhantomData;
47use std::pin::Pin;
48use std::task::{Context, Poll};
49
50/// Used to construct a running client/peer or server instance
51pub struct NodeBuilder<R: Ratchet = StackedRatchet, T: PlatformOps = DefaultTransport> {
52    hypernode_type: Option<NodeType>,
53    underlying_protocol: Option<ServerMode<T>>,
54    backend_type: Option<BackendType>,
55    server_argon_settings: Option<ArgonDefaultServerSettings>,
56    #[cfg(feature = "google-services")]
57    services: Option<ServicesConfig>,
58    server_misc_settings: Option<ServerMiscSettings>,
59    client_tls_config: Option<T::ClientConfig>,
60    kernel_executor_settings: Option<KernelExecutorSettings>,
61    stun_servers: Option<Vec<String>>,
62    turn_servers: Option<Vec<TurnServerConfig>>,
63    local_only_server_settings: Option<ServerOnlySessionInitSettings>,
64    websocket_listener: Option<citadel_io::WebSocketListen>,
65    injected_listener: Option<T::Listener>,
66    #[cfg(target_family = "wasm")]
67    serverless_config: Option<ServerlessConfig>,
68    _ratchet: PhantomData<R>,
69    _transport: PhantomData<T>,
70}
71
72/// Default node builder type using platform-appropriate transport
73pub type DefaultNodeBuilder = NodeBuilder<StackedRatchet, DefaultTransport>;
74/// Lightweight node builder using `MonoRatchet` for reduced cryptographic overhead
75pub type LightweightNodeBuilder = NodeBuilder<MonoRatchet, DefaultTransport>;
76
77impl<R: Ratchet, T: PlatformOps> Default for NodeBuilder<R, T> {
78    fn default() -> Self {
79        Self {
80            hypernode_type: None,
81            underlying_protocol: None,
82            backend_type: None,
83            server_argon_settings: None,
84            #[cfg(feature = "google-services")]
85            services: None,
86            server_misc_settings: None,
87            client_tls_config: None,
88            kernel_executor_settings: None,
89            stun_servers: None,
90            turn_servers: None,
91            local_only_server_settings: None,
92            websocket_listener: None,
93            injected_listener: None,
94            #[cfg(target_family = "wasm")]
95            serverless_config: None,
96            _ratchet: Default::default(),
97            _transport: Default::default(),
98        }
99    }
100}
101
102/// An awaitable future whose return value propagates any internal protocol or kernel-level errors
103pub struct NodeFuture<'a, K> {
104    inner: Pin<Box<dyn FutureContextRequirements<'a, Result<K, NetworkError>>>>,
105    _pd: PhantomData<fn() -> K>,
106}
107
108#[cfg(feature = "multi-threaded")]
109trait FutureContextRequirements<'a, Output>:
110    Future<Output = Output> + Send + LocalContextRequirements<'a>
111{
112}
113#[cfg(feature = "multi-threaded")]
114impl<'a, T: Future<Output = Output> + Send + LocalContextRequirements<'a>, Output>
115    FutureContextRequirements<'a, Output> for T
116{
117}
118
119#[cfg(not(feature = "multi-threaded"))]
120trait FutureContextRequirements<'a, Output>:
121    Future<Output = Output> + LocalContextRequirements<'a>
122{
123}
124#[cfg(not(feature = "multi-threaded"))]
125impl<'a, T: Future<Output = Output> + LocalContextRequirements<'a>, Output>
126    crate::builder::node_builder::FutureContextRequirements<'a, Output> for T
127{
128}
129
130impl<K> Debug for NodeFuture<'_, K> {
131    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
132        write!(f, "NodeFuture")
133    }
134}
135
136impl<K> Future for NodeFuture<'_, K> {
137    type Output = Result<K, NetworkError>;
138
139    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
140        self.inner.as_mut().poll(cx)
141    }
142}
143
144impl<R: Ratchet + ContextRequirements, T: PlatformOps> NodeBuilder<R, T> {
145    /// Returns a future that represents the both the protocol and kernel execution
146    pub fn build<'a, 'b: 'a, K: NetKernel<R> + 'b>(
147        &'a mut self,
148        kernel: K,
149    ) -> anyhow::Result<NodeFuture<'b, K>> {
150        self.check()?;
151        let hypernode_type = self.hypernode_type.take().unwrap_or_default();
152        let backend_type = self.backend_type.take().unwrap_or_default();
153        let server_argon_settings = self.server_argon_settings.take();
154        #[cfg(feature = "google-services")]
155        let server_services_cfg = self.services.take();
156        #[cfg(not(feature = "google-services"))]
157        let server_services_cfg = None;
158        let server_misc_settings = self.server_misc_settings.take();
159        let client_config = self.client_tls_config.take();
160        let kernel_executor_settings = self.kernel_executor_settings.take().unwrap_or_default();
161        let stun_servers = self.stun_servers.take();
162        let turn_servers = self.turn_servers.take();
163        let underlying_proto = self.underlying_protocol.take();
164        let server_only_session_init_settings = self.local_only_server_settings.take();
165        let websocket_listener = self.websocket_listener.take();
166        let injected_listener = self.injected_listener.take();
167        #[cfg(target_family = "wasm")]
168        let serverless_config = self.serverless_config.take();
169
170        Ok(NodeFuture {
171            _pd: Default::default(),
172            inner: Box::pin(async move {
173                let underlying_proto = match underlying_proto {
174                    Some(proto) => proto,
175                    None => T::default_server_config().await.map_err(|err| {
176                        citadel_io::error!(
177                            citadel_io::ErrorCode::NodeDefaultServerConfigFailed,
178                            err.to_string()
179                        )
180                    })?,
181                };
182
183                T::config_warnings(&underlying_proto);
184
185                // Serverless mode: perform signaling and override node
186                // type, listener, and client config based on assigned role.
187                #[cfg(target_family = "wasm")]
188                let (pre_built_listener, client_config, hypernode_type) = if let Some(sl_config) =
189                    serverless_config
190                {
191                    let conn = establish_serverless_connection(
192                        sl_config.signaling.as_ref(),
193                        &sl_config.room_token,
194                        &sl_config.ice_servers,
195                        sl_config.poll_interval_ms,
196                        sl_config.timeout_ms,
197                    )
198                    .await
199                    .map_err(|e: std::io::Error| NetworkError::generic(e.to_string()))?;
200
201                    T::setup_serverless_transport(conn.stream, conn.is_server_role, client_config)
202                } else {
203                    (injected_listener, client_config, hypernode_type)
204                };
205
206                #[cfg(not(target_family = "wasm"))]
207                let pre_built_listener = injected_listener;
208
209                log::trace!(target: "citadel", "[NodeBuilder] Checking Tokio runtime ...");
210                let rt = citadel_io::try_current_runtime().map_err(NetworkError::generic)?;
211                log::trace!(target: "citadel", "[NodeBuilder] Creating account manager ...");
212                let account_manager = AccountManager::new(
213                    backend_type,
214                    server_argon_settings,
215                    server_services_cfg,
216                    server_misc_settings,
217                )
218                .await?;
219
220                let args: KernelExecutorArguments<_, _, T> = KernelExecutorArguments {
221                    rt,
222                    hypernode_type,
223                    account_manager,
224                    kernel,
225                    underlying_proto,
226                    client_config,
227                    kernel_executor_settings,
228                    stun_servers,
229                    turn_servers,
230                    server_only_session_init_settings,
231                    websocket_listener,
232                    pre_built_listener,
233                };
234
235                log::trace!(target: "citadel", "[NodeBuilder] Creating KernelExecutor ...");
236                let kernel_executor = KernelExecutor::<_, R>::new(args).await?;
237                log::trace!(target: "citadel", "[NodeBuilder] Executing kernel");
238                kernel_executor.execute().await
239            }),
240        })
241    }
242
243    /// Defines the node type. By default, Peer is used. If a server is desired, a bind address is expected
244    /// ```
245    /// use citadel_sdk::prelude::DefaultNodeBuilder;
246    /// use citadel_proto::prelude::NodeType;
247    ///
248    /// DefaultNodeBuilder::default().with_node_type(NodeType::server("0.0.0.0:25021").unwrap());
249    /// ```
250    pub fn with_node_type(&mut self, node_type: NodeType) -> &mut Self {
251        self.hypernode_type = Some(node_type);
252        self
253    }
254
255    /// Sets the backend used to synchronize client account information. By default, uses the filesystem.
256    /// When the enterprise feature is set, a SQL database (MySQL, PostgreSQL, SQLite) is available. Using a single SQL cluster can be used in combination with
257    /// a cluster of load-balancing running ['NetKernel']'s on different IPs to construct scaled applications
258    pub fn with_backend(&mut self, backend_type: BackendType) -> &mut Self {
259        self.backend_type = Some(backend_type);
260        self
261    }
262
263    /// Sets the desired settings for the [`KernelExecutor`]
264    pub fn with_kernel_executor_settings(
265        &mut self,
266        kernel_executor_settings: KernelExecutorSettings,
267    ) -> &mut Self {
268        self.kernel_executor_settings = Some(kernel_executor_settings);
269        self
270    }
271
272    /// Attaches custom Argon settings for password hashing at the server
273    pub fn with_server_argon_settings(
274        &mut self,
275        settings: ArgonDefaultServerSettings,
276    ) -> &mut Self {
277        self.server_argon_settings = Some(settings);
278        self
279    }
280
281    /// Attaches a google services json path, allowing the use of Google Auth and other dependent services like Realtime Database for android/IOS messaging. Required when using [`Self::with_google_realtime_database_config`]
282    #[cfg(feature = "google-services")]
283    pub fn with_google_services_json_path<V: Into<String>>(&mut self, path: V) -> &mut Self {
284        let cfg = self.get_or_create_services();
285        cfg.google_services_json_path = Some(path.into());
286        self
287    }
288
289    /// Attaches miscellaneous server settings (e.g., transient mode, credential requirements)
290    pub fn with_server_misc_settings(&mut self, misc_settings: ServerMiscSettings) -> &mut Self {
291        self.server_misc_settings = Some(misc_settings);
292        self
293    }
294
295    /// Creates a Google Realtime Database configuration given the project URL and API Key. Requires the use of [`Self::with_google_services_json_path`] to allow minting of JsonWebTokens
296    /// at the central server
297    #[cfg(feature = "google-services")]
298    pub fn with_google_realtime_database_config<V: Into<String>, W: Into<String>>(
299        &mut self,
300        url: V,
301        api_key: W,
302    ) -> &mut Self {
303        let cfg = self.get_or_create_services();
304        cfg.google_rtdb = Some(RtdbConfig {
305            url: url.into(),
306            api_key: api_key.into(),
307        });
308        self
309    }
310
311    /// Sets the underlying protocol / server configuration for the transport.
312    /// Default: TLS transport w/ self-signed cert (for NativeIO)
313    pub fn with_underlying_protocol(&mut self, proto: ServerMode<T>) -> &mut Self {
314        self.underlying_protocol = Some(proto);
315        self
316    }
317
318    /// Sets the client-side transport configuration.
319    pub fn with_client_config(&mut self, config: T::ClientConfig) -> &mut Self {
320        self.client_tls_config = Some(config);
321        self
322    }
323
324    #[cfg(feature = "google-services")]
325    fn get_or_create_services(&mut self) -> &mut ServicesConfig {
326        if self.services.is_some() {
327            self.services.as_mut().unwrap()
328        } else {
329            let cfg = ServicesConfig::default();
330            self.services = Some(cfg);
331            self.services.as_mut().unwrap()
332        }
333    }
334
335    /// Specifies custom STUN servers. If left unspecified, will use the defaults (twilio and Google STUN servers)
336    pub fn with_stun_servers<V: Into<String>, S: Into<Vec<V>>>(&mut self, servers: S) -> &mut Self {
337        self.stun_servers = Some(servers.into().into_iter().map(|t| t.into()).collect());
338        self
339    }
340
341    /// Specifies TURN servers for relay-based NAT traversal.
342    ///
343    /// TURN servers relay traffic when direct peer-to-peer connections fail
344    /// due to restrictive NAT configurations. On WASM targets, these are
345    /// passed as ICE servers to the WebRTC peer connection.
346    pub fn with_turn_servers<S: Into<Vec<TurnServerConfig>>>(&mut self, servers: S) -> &mut Self {
347        self.turn_servers = Some(servers.into());
348        self
349    }
350
351    /// Adds an optional WebSocket listener to a server node.
352    ///
353    /// When set, the server will accept both its primary transport (TCP/TLS/QUIC)
354    /// AND WebSocket connections on the specified address. This enables browser
355    /// clients (via WASM) to connect to the same server.
356    ///
357    /// Has no effect on client/peer nodes.
358    pub fn with_websocket_listener(&mut self, addr: std::net::SocketAddr) -> &mut Self {
359        self.websocket_listener = Some(citadel_io::WebSocketListen::Addr(addr));
360        self
361    }
362
363    /// Like [`Self::with_websocket_listener`], but accepts on a socket the caller already bound.
364    /// The port is held from the caller's bind onward, so nothing else can take it before the
365    /// node starts.
366    pub fn with_bound_websocket_listener(&mut self, listener: std::net::TcpListener) -> &mut Self {
367        self.websocket_listener = Some(citadel_io::WebSocketListen::Bound(listener));
368        self
369    }
370
371    /// Serves a server node from a listener the host feeds, instead of binding one. The node
372    /// accepts whatever connections the listener yields and never opens a socket of its own; the
373    /// bind address given to [`NodeType::Server`] is only recorded. On WASM this is how a server
374    /// runs where the host accepts sockets itself — see `WasmListener::injected`.
375    ///
376    /// Only meaningful for [`NodeType::Server`]; `build` refuses it on a peer.
377    pub fn with_injected_listener(&mut self, listener: T::Listener) -> &mut Self {
378        self.injected_listener = Some(listener);
379        self
380    }
381
382    /// Enables serverless browser-to-browser mode. Both peers run identical code;
383    /// one is automatically assigned the server role and the other the client role
384    /// via deterministic signaling through the provided [`ServerlessConfig`].
385    ///
386    /// When set, `hypernode_type`, `underlying_protocol`, and `client_config` are
387    /// determined automatically after signaling completes.
388    #[cfg(target_family = "wasm")]
389    pub fn with_no_central_server(&mut self, config: ServerlessConfig) -> &mut Self {
390        self.serverless_config = Some(config);
391        self
392    }
393
394    /// Sets the pre-shared key for the server. Only a server should set this value
395    /// If no value is set, any client can connect to the server. If a pre-shared key
396    /// is specified, the client must have the matching pre-shared key in order to
397    /// register and connect with the server.
398    pub fn with_server_password<V: Into<PreSharedKey>>(&mut self, password: V) -> &mut Self {
399        let mut server_only_settings = self.local_only_server_settings.clone().unwrap_or_default();
400        server_only_settings.declared_pre_shared_key = Some(password.into());
401        self.local_only_server_settings = Some(server_only_settings);
402        self
403    }
404
405    /// Sets the header obfuscator settings for the server
406    pub fn with_server_declared_header_obfuscation<V: Into<HeaderObfuscatorSettings>>(
407        &mut self,
408        header_obfuscator_settings: V,
409    ) -> &mut Self {
410        let mut server_only_settings = self.local_only_server_settings.clone().unwrap_or_default();
411        server_only_settings.declared_header_obfuscation_setting =
412            header_obfuscator_settings.into();
413        self.local_only_server_settings = Some(server_only_settings);
414        self
415    }
416
417    fn check(&self) -> anyhow::Result<()> {
418        if self.injected_listener.is_some()
419            && !matches!(self.hypernode_type, Some(NodeType::Server(_)))
420        {
421            return Err(anyhow::Error::msg(
422                "An injected listener requires NodeType::Server",
423            ));
424        }
425
426        #[cfg(feature = "google-services")]
427        if let Some(svc) = self.services.as_ref() {
428            if svc.google_rtdb.is_some() && svc.google_services_json_path.is_none() {
429                return Err(anyhow::Error::msg(
430                    "Google realtime database is enabled, yet, a services path is not provided",
431                ));
432            }
433        }
434
435        if let Some(stun_servers) = self.stun_servers.as_ref() {
436            if stun_servers.len() != 3 {
437                return Err(anyhow::Error::msg(
438                    "There must be exactly 3 specified STUN servers",
439                ));
440            }
441        }
442
443        Ok(())
444    }
445}
446
447/// NativeIO-specific builder methods for TLS certificate configuration
448#[cfg(not(target_family = "wasm"))]
449impl<R: Ratchet + ContextRequirements> NodeBuilder<R, NativeIO> {
450    /// Wraps a rustls client config, preserving any previously-set `require_cert_verification`
451    /// policy so that the ordering of `with_*_certs` and `with_require_cert_verification` does not
452    /// matter.
453    fn set_client_rustls_config(
454        &mut self,
455        config: std::sync::Arc<citadel_proto::re_imports::RustlsClientConfig>,
456    ) {
457        let require_cert_verification = self
458            .client_tls_config
459            .as_ref()
460            .map(|cfg| cfg.require_cert_verification)
461            .unwrap_or(false);
462        self.client_tls_config = Some(citadel_proto::re_imports::NativeClientConfig {
463            config,
464            require_cert_verification,
465        });
466    }
467
468    /// Loads the accepted cert chain stored by the local operating system
469    /// If a custom set of certs is required, run [`Self::with_custom_certs`]
470    /// This is the default if no client TLS config is specified
471    pub async fn with_native_certs(&mut self) -> anyhow::Result<&mut Self> {
472        let certs = citadel_proto::re_imports::load_native_certs_async().await?;
473        let cfg = citadel_proto::re_imports::cert_vec_to_secure_client_config(&certs)?;
474        self.set_client_rustls_config(std::sync::Arc::new(cfg));
475        Ok(self)
476    }
477
478    /// The client will skip unconditionally server certificate verification
479    /// This is not recommended
480    pub fn with_insecure_skip_cert_verification(&mut self) -> &mut Self {
481        // Explicit opt-out: this clears any require-verification policy by design.
482        self.client_tls_config = Some(citadel_proto::re_imports::NativeClientConfig::new(
483            std::sync::Arc::new(citadel_proto::re_imports::insecure::rustls_client_config()),
484        ));
485        self
486    }
487
488    /// Requires server certificate verification, refusing to fall back to the accept-any-cert path
489    /// even if the peer advertises a self-signed server (i.e. an attacker cannot downgrade the
490    /// transport by flipping the `is_self_signed`/`domain` signal). If no client TLS config has been
491    /// set yet, the OS-native cert roots are loaded first. Order-independent with respect to the
492    /// `with_*_certs` methods.
493    pub async fn with_require_cert_verification(&mut self) -> anyhow::Result<&mut Self> {
494        if self.client_tls_config.is_none() {
495            let _ = self.with_native_certs().await?;
496        }
497        if let Some(cfg) = self.client_tls_config.as_mut() {
498            cfg.require_cert_verification = true;
499        }
500        Ok(self)
501    }
502
503    /// Loads a custom list of certs into the acceptable certificate list. Connections that present server certificates
504    /// that are outside of this list during the handshake process are refused
505    pub fn with_custom_certs<V: AsRef<[u8]>>(
506        &mut self,
507        custom_certs: &[V],
508    ) -> anyhow::Result<&mut Self> {
509        let cfg = citadel_proto::re_imports::create_rustls_client_config(custom_certs)?;
510        self.set_client_rustls_config(std::sync::Arc::new(cfg));
511        Ok(self)
512    }
513
514    /// The file should be a PEM formatted certificate
515    #[cfg(feature = "std")]
516    pub async fn with_pem_file<P: AsRef<std::path::Path>>(
517        &mut self,
518        path: P,
519    ) -> anyhow::Result<&mut Self> {
520        use citadel_wire::exports::{Certificate, PemObject};
521        let mut der = std::io::Cursor::new(citadel_io::tokio::fs::read(path).await?);
522        let certs: Vec<Certificate<'static>> =
523            Certificate::pem_reader_iter(&mut der).collect::<Result<Vec<_>, _>>()?;
524        let cfg = citadel_proto::re_imports::create_rustls_client_config(&certs)?;
525        self.set_client_rustls_config(std::sync::Arc::new(cfg));
526        Ok(self)
527    }
528}
529
530#[cfg(all(test, not(target_family = "wasm")))]
531mod tests {
532    use crate::builder::node_builder::DefaultNodeBuilder;
533    use crate::prefabs::server::empty::EmptyKernel;
534    use crate::prelude::{BackendType, NodeType};
535    use citadel_io::tokio;
536    use citadel_proto::prelude::{
537        KernelExecutorSettings, NativeIO, NativeP2PConfig, NativeSecureConfig, ServerMode,
538    };
539    use rstest::rstest;
540    use std::str::FromStr;
541
542    #[test]
543    #[cfg(feature = "google-services")]
544    fn okay_config() {
545        let _ = DefaultNodeBuilder::default()
546            .with_google_realtime_database_config("123", "456")
547            .with_google_services_json_path("abc")
548            .build(EmptyKernel::default())
549            .unwrap();
550    }
551
552    #[test]
553    #[cfg(feature = "google-services")]
554    fn bad_config() {
555        assert!(DefaultNodeBuilder::default()
556            .with_google_realtime_database_config("123", "456")
557            .build(EmptyKernel::default())
558            .is_err());
559    }
560
561    #[test]
562    fn bad_config2() {
563        assert!(DefaultNodeBuilder::default()
564            .with_stun_servers(["dummy1", "dummy2"])
565            .build(EmptyKernel::default())
566            .is_err());
567    }
568
569    #[rstest]
570    #[tokio::test]
571    #[timeout(std::time::Duration::from_secs(60))]
572    #[allow(clippy::let_underscore_must_use)]
573    async fn test_options(
574        #[values(ServerMode::P2P(NativeP2PConfig::self_signed()), ServerMode::OrderedReliableSecure(NativeSecureConfig::self_signed().unwrap())
575        )]
576        underlying_protocol: ServerMode<NativeIO>,
577        #[values(NodeType::Peer, NodeType::Server(std::net::SocketAddr::from_str("127.0.0.1:9999").unwrap()
578        ))]
579        node_type: NodeType,
580        #[values(KernelExecutorSettings::default(), KernelExecutorSettings::default().with_max_concurrency(2)
581        )]
582        kernel_settings: KernelExecutorSettings,
583        #[values(BackendType::InMemory, BackendType::new("file:/hello_world/path/").unwrap())]
584        backend_type: BackendType,
585    ) {
586        let mut builder = DefaultNodeBuilder::default();
587        let _ = builder
588            .with_underlying_protocol(underlying_protocol.clone())
589            .with_backend(backend_type.clone())
590            .with_node_type(node_type)
591            .with_kernel_executor_settings(kernel_settings.clone())
592            .with_insecure_skip_cert_verification()
593            .with_stun_servers(["dummy1", "dummy1", "dummy3"])
594            .with_native_certs()
595            .await
596            .unwrap();
597
598        assert!(builder.underlying_protocol.is_some());
599        assert_eq!(backend_type, builder.backend_type.clone().unwrap());
600        assert_eq!(node_type, builder.hypernode_type.unwrap());
601        assert_eq!(
602            kernel_settings,
603            builder.kernel_executor_settings.clone().unwrap()
604        );
605
606        drop(builder.build(EmptyKernel::default()).unwrap());
607    }
608}