From 78a90b573e83df2a8f523ec626aaf268a7f5366e Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Thu, 26 Mar 2026 14:57:51 +0100 Subject: [PATCH 01/15] Implement custom roots for LAN clients in communication. --- Cargo.lock | 4 +- Cargo.toml | 1 + s2energy-connection/Cargo.toml | 1 + .../examples/communication-client.rs | 4 + .../examples/pairing-client.rs | 4 +- .../examples/pairing-server.rs | 2 +- .../src/communication/client.rs | 49 +++- s2energy-connection/src/communication/mod.rs | 9 +- .../src/communication/transport.rs | 223 ++++++++++++++++++ s2energy-connection/src/lib.rs | 33 +++ s2energy-connection/src/pairing/client.rs | 39 ++- s2energy-connection/src/pairing/mod.rs | 25 +- s2energy-connection/src/pairing/server.rs | 10 +- s2energy-connection/src/pairing/transport.rs | 34 ++- s2energy-connection/src/pairing/wire.rs | 36 ++- 15 files changed, 431 insertions(+), 43 deletions(-) create mode 100644 s2energy-connection/src/communication/transport.rs diff --git a/Cargo.lock b/Cargo.lock index 16c382b..a834e4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -716,6 +716,7 @@ version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ + "serde", "typenum", "version_check", ] @@ -1273,7 +1274,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1680,6 +1681,7 @@ dependencies = [ "axum-server", "base64", "futures-util", + "generic-array", "hmac", "http", "http-body-util", diff --git a/Cargo.toml b/Cargo.toml index 356c093..8c686bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ base64 = "0.22.1" bon = "3.8.0" chrono = { version = "0.4.42", features = ["serde"] } futures-util = "0.3.31" +generic-array = { version = "=0.14.7", features = ["serde"] } hmac = "0.12.1" http = "1.4.0" hyper = "1.8.1" diff --git a/s2energy-connection/Cargo.toml b/s2energy-connection/Cargo.toml index d2f9c95..ea1de77 100644 --- a/s2energy-connection/Cargo.toml +++ b/s2energy-connection/Cargo.toml @@ -8,6 +8,7 @@ axum.workspace = true axum-extra.workspace = true base64.workspace = true futures-util.workspace = true +generic-array.workspace = true hmac.workspace = true http.workspace = true hyper.workspace = true diff --git a/s2energy-connection/examples/communication-client.rs b/s2energy-connection/examples/communication-client.rs index 6d42f5d..2b1e3ce 100644 --- a/s2energy-connection/examples/communication-client.rs +++ b/s2energy-connection/examples/communication-client.rs @@ -35,6 +35,10 @@ impl ClientPairing for &mut MemoryPairing { &self.communication_url } + fn certificate_hash(&self) -> Option { + None + } + async fn set_access_tokens(&mut self, tokens: Vec) -> Result<(), Self::Error> { self.tokens = tokens; Ok(()) diff --git a/s2energy-connection/examples/pairing-client.rs b/s2energy-connection/examples/pairing-client.rs index 3748f17..ae2ecfc 100644 --- a/s2energy-connection/examples/pairing-client.rs +++ b/s2energy-connection/examples/pairing-client.rs @@ -29,7 +29,7 @@ async fn main() { }, vec![MessageVersion("v1".into())], ) - .with_connection_initiate_url("client.example.com".into()) + .with_connection_initiate_url("https://client.example.com".into()) .build() .unwrap(); @@ -61,7 +61,7 @@ async fn main() { let pair_result = rx.await.unwrap(); match pair_result.role { - s2energy_connection::pairing::PairingRole::CommunicationClient { initiate_url } => { + s2energy_connection::pairing::PairingRole::CommunicationClient { initiate_url, .. } => { println!("Paired as client, url: {initiate_url}, token: {}", pair_result.token.0) } s2energy_connection::pairing::PairingRole::CommunicationServer => println!("Paired as server, token: {}", pair_result.token.0), diff --git a/s2energy-connection/examples/pairing-server.rs b/s2energy-connection/examples/pairing-server.rs index a9f8380..5d3c694 100644 --- a/s2energy-connection/examples/pairing-server.rs +++ b/s2energy-connection/examples/pairing-server.rs @@ -35,7 +35,7 @@ async fn main() { }, vec![MessageVersion("v1".into())], ) - .with_connection_initiate_url("test.example.com".into()) + .with_connection_initiate_url("https://test.example.com".into()) .build() .unwrap(); let app = server.get_router(); diff --git a/s2energy-connection/src/communication/client.rs b/s2energy-connection/src/communication/client.rs index e02563a..7edcf11 100644 --- a/s2energy-connection/src/communication/client.rs +++ b/s2energy-connection/src/communication/client.rs @@ -7,10 +7,11 @@ use tokio_tungstenite::{Connector, connect_async_tls_with_config, tungstenite::C use tracing::{debug, trace}; use crate::{ - AccessToken, CommunicationProtocol, EndpointDescription, NodeId, + AccessToken, CertificateHash, CommunicationProtocol, EndpointDescription, NodeId, common::negotiate_version, communication::{ CommunicationResult, ConnectionInfo, Error, ErrorKind, NodeConfig, WebSocketTransport, + transport::hash_checking_http_client, wire::{ CommunicationDetails, CommunicationDetailsErrorMessage, InitiateConnectionRequest, InitiateConnectionResponse, UnpairRequest, }, @@ -52,6 +53,8 @@ pub trait ClientPairing: Send { fn access_tokens(&self) -> impl AsRef<[AccessToken]>; /// The communication url the client can use to contact the server. fn communication_url(&self) -> impl AsRef; + /// Hash of the root certificate the server uses. + fn certificate_hash(&self) -> Option; /// Store a new set of access tokens for the pairing. fn set_access_tokens(&mut self, tokens: Vec) -> impl Future> + Send; @@ -71,14 +74,17 @@ impl Client { /// upon success. #[tracing::instrument(skip_all, fields(client = %pairing.client_id(), server = %pairing.server_id()), level = tracing::Level::ERROR)] pub async fn unpair(&self, pairing: impl ClientPairing) -> CommunicationResult<()> { - let client = reqwest::Client::builder() - .tls_certs_merge( - self.additional_certificates - .iter() - .filter_map(|v| reqwest::Certificate::from_der(v).ok()), - ) - .build() - .map_err(|e| Error::new(ErrorKind::TransportFailed, e))?; + let client = match pairing.certificate_hash() { + Some(hash) => hash_checking_http_client(hash)?, + None => reqwest::Client::builder() + .tls_certs_merge( + self.additional_certificates + .iter() + .filter_map(|v| reqwest::Certificate::from_der(v).ok()), + ) + .build() + .map_err(|e| Error::new(ErrorKind::TransportFailed, e))?, + }; let communication_url = Url::parse(pairing.communication_url().as_ref()).map_err(|e| Error::new(ErrorKind::InvalidUrl, e))?; @@ -301,7 +307,7 @@ mod tests { use tokio::net::TcpListener; use crate::{ - AccessToken, CommunicationProtocol, EndpointDescription, MessageVersion, NodeId, Role, + AccessToken, CertificateHash, CommunicationProtocol, EndpointDescription, MessageVersion, NodeId, Role, common::wire::test::{UUID_A, UUID_B, basic_node_description}, communication::{ self, Client, ClientConfig, ClientPairing, ErrorKind, NodeConfig, PairingLookup, Server, ServerConfig, ServerPairing, @@ -370,6 +376,7 @@ mod tests { server: NodeId, tokens: Arc>>, url: String, + certificate_hash: Option, } impl ClientPairing for &TestPairing { @@ -391,6 +398,10 @@ mod tests { &self.url } + fn certificate_hash(&self) -> Option { + self.certificate_hash.clone() + } + async fn set_access_tokens(&mut self, tokens: Vec) -> Result<(), Self::Error> { *self.tokens.lock().unwrap() = tokens; Ok(()) @@ -451,6 +462,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; assert!(client.unpair(&pairing).await.is_ok()); @@ -483,6 +495,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("invalidtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; let error = client.unpair(&pairing).await.unwrap_err(); @@ -511,6 +524,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; let mut client_connection = client.connect(&pairing).await.unwrap(); @@ -577,6 +591,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; let mut client_connection = client.connect(&pairing).await.unwrap(); @@ -637,6 +652,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; let mut client_connection = client.connect(&pairing).await.unwrap(); @@ -692,6 +708,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; let mut client_connection = client.connect(&pairing).await.unwrap(); @@ -740,6 +757,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; let mut client_connection = client.connect(&pairing).await.unwrap(); @@ -792,6 +810,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; assert_eq!(client.connect(&pairing).await.unwrap_err().kind(), ErrorKind::NoSupportedVersion); @@ -830,6 +849,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; assert_eq!(client.connect(&pairing).await.unwrap_err().kind(), ErrorKind::NoSupportedVersion); @@ -876,6 +896,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; assert_eq!(client.connect(&pairing).await.unwrap_err().kind(), ErrorKind::NoSupportedVersion); @@ -922,6 +943,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; assert_eq!(client.connect(&pairing).await.unwrap_err().kind(), ErrorKind::NoSupportedVersion); @@ -973,6 +995,10 @@ mod tests { &self.url } + fn certificate_hash(&self) -> Option { + None + } + async fn set_access_tokens(&mut self, _tokens: Vec) -> Result<(), Self::Error> { Err(std::io::ErrorKind::Other.into()) } @@ -995,6 +1021,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; let mut client_connection = client.connect(&pairing).await.unwrap(); @@ -1042,6 +1069,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; assert_eq!(client.connect(&pairing).await.unwrap_err().kind(), ErrorKind::ProtocolError); @@ -1076,6 +1104,7 @@ mod tests { server: UUID_B.into(), tokens: Arc::new(Mutex::new(vec![AccessToken("testtoken".into())])), url: format!("https://localhost:{}/", addr.port()), + certificate_hash: None, }; assert_eq!(client.connect(&pairing).await.unwrap_err().kind(), ErrorKind::ProtocolError); diff --git a/s2energy-connection/src/communication/mod.rs b/s2energy-connection/src/communication/mod.rs index ad84fa5..6336e44 100644 --- a/s2energy-connection/src/communication/mod.rs +++ b/s2energy-connection/src/communication/mod.rs @@ -44,12 +44,13 @@ //! # use std::sync::Arc; //! # use std::convert::Infallible; //! # use s2energy_connection::communication::{NodeConfig, Client, ClientConfig, ClientPairing}; -//! # use s2energy_connection::{MessageVersion, AccessToken, NodeId}; +//! # use s2energy_connection::{MessageVersion, AccessToken, NodeId, CertificateHash}; //! struct MemoryClientPairing { //! client_id: NodeId, //! server_id: NodeId, //! communication_url: String, //! access_tokens: Vec, +//! certificate_hash: Option, //! } //! //! impl ClientPairing for MemoryClientPairing { @@ -71,6 +72,10 @@ //! &self.access_tokens //! } //! +//! fn certificate_hash(&self) -> Option { +//! self.certificate_hash.clone() +//! } +//! //! async fn set_access_tokens(&mut self, tokens: Vec) -> Result<(), Infallible> { //! self.access_tokens = tokens; //! Ok(()) @@ -84,6 +89,7 @@ //! server_id: NodeId::try_from("67e55044-10b1-426f-9247-bb680e5fe0c6").unwrap(), //! communication_url: "https://example.com".into(), //! access_tokens: vec![AccessToken("some-token-value".into())], +//! certificate_hash: None, //! }); //! ``` //! @@ -210,6 +216,7 @@ use crate::{EndpointDescription, MessageVersion, NodeDescription}; mod client; mod error; mod server; +mod transport; mod websocket; mod wire; diff --git a/s2energy-connection/src/communication/transport.rs b/s2energy-connection/src/communication/transport.rs new file mode 100644 index 0000000..b85d59a --- /dev/null +++ b/s2energy-connection/src/communication/transport.rs @@ -0,0 +1,223 @@ +use std::sync::{Arc, OnceLock}; + +use rustls::{ + RootCertStore, + client::{WebPkiServerVerifier, danger::ServerCertVerifier}, + pki_types::CertificateDer, +}; + +use crate::CertificateHash; + +use super::{CommunicationResult, Error, ErrorKind}; + +#[derive(Debug)] +struct HashedCertificateVerifier { + inner: rustls_platform_verifier::Verifier, + self_signed_state: OnceLock, + root_hash: CertificateHash, +} + +#[derive(Debug)] +struct SelfSignedState { + hash: CertificateHash, + verifier: SelfVerifier, +} + +#[derive(Debug)] +enum SelfVerifier { + WebPki(WebPkiServerVerifier), + None, +} + +impl ServerCertVerifier for SelfVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &rustls::pki_types::ServerName<'_>, + ocsp_response: &[u8], + now: rustls::pki_types::UnixTime, + ) -> Result { + match self { + SelfVerifier::WebPki(web_pki_server_verifier) => { + web_pki_server_verifier.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) + } + SelfVerifier::None => Err(rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer)), + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + match self { + SelfVerifier::WebPki(web_pki_server_verifier) => web_pki_server_verifier.verify_tls12_signature(message, cert, dss), + SelfVerifier::None => Err(rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer)), + } + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + match self { + SelfVerifier::WebPki(web_pki_server_verifier) => web_pki_server_verifier.verify_tls13_signature(message, cert, dss), + SelfVerifier::None => Err(rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer)), + } + } + + fn supported_verify_schemes(&self) -> Vec { + match self { + SelfVerifier::WebPki(web_pki_server_verifier) => web_pki_server_verifier.supported_verify_schemes(), + SelfVerifier::None => vec![], + } + } +} + +impl ServerCertVerifier for HashedCertificateVerifier { + fn verify_server_cert( + &self, + end_entity: &rustls::pki_types::CertificateDer<'_>, + intermediates: &[rustls::pki_types::CertificateDer<'_>], + server_name: &rustls::pki_types::ServerName<'_>, + ocsp_response: &[u8], + now: rustls::pki_types::UnixTime, + ) -> Result { + let state = self.self_signed_state.get_or_init(|| { + let fallback = CertificateDer::from_slice(&[]); + let root_cert = intermediates.last().unwrap_or(&fallback); + let hash = CertificateHash::sha256(root_cert); + let mut root_store = RootCertStore::empty(); + // conciously ignore errors here, we just want to initialize + root_store.add(root_cert.clone()).ok(); + let verifier = match WebPkiServerVerifier::builder(Arc::new(root_store)).build() { + Ok(verifier) => SelfVerifier::WebPki(Arc::try_unwrap(verifier).unwrap()), + Err(_) => SelfVerifier::None, + }; + + SelfSignedState { hash, verifier } + }); + if state.hash != self.root_hash { + return Err(rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer)); + } + state + .verifier + .verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } +} + +pub(crate) fn hash_checking_http_client(root_hash: CertificateHash) -> CommunicationResult { + let rustls_config_builder = rustls::ClientConfig::builder(); + let crypto_provider = rustls_config_builder.crypto_provider().clone(); + let verifier = HashedCertificateVerifier { + inner: rustls_platform_verifier::Verifier::new(crypto_provider).map_err(|e| Error::new(ErrorKind::TransportFailed, e))?, + self_signed_state: OnceLock::new(), + root_hash, + }; + let client_config = rustls_config_builder + .dangerous() + .with_custom_certificate_verifier(Arc::new(verifier)) + .with_no_client_auth(); + + let client = reqwest::Client::builder() + .use_preconfigured_tls(client_config) + .build() + .map_err(|e| Error::new(ErrorKind::TransportFailed, e))?; + + Ok(client) +} + +#[cfg(test)] +mod tests { + use std::net::{Ipv4Addr, SocketAddr}; + + use axum::{Router, routing::get}; + use axum_server::tls_rustls::RustlsConfig; + use rustls::pki_types::{CertificateDer, pem::PemObject}; + + use crate::{CertificateHash, communication::transport::hash_checking_http_client}; + + #[tokio::test] + async fn matching_certificates() { + let rustls_config = RustlsConfig::from_pem( + include_bytes!("../../testdata/localhost.chain.pem").into(), + include_bytes!("../../testdata/localhost.key").into(), + ) + .await + .unwrap(); + let router = Router::new().route("/", get(|| async { "Hello world" })); + let https_server_handle = axum_server::Handle::new(); + let https_server_handle_clone = https_server_handle.clone(); + tokio::spawn(async move { + axum_server::bind_rustls(SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 0), rustls_config) + .handle(https_server_handle_clone) + .serve(router.into_make_service()) + .await + .unwrap(); + }); + let addr = https_server_handle.listening().await.unwrap(); + + let client = hash_checking_http_client(CertificateHash::sha256( + &CertificateDer::from_pem_slice(include_bytes!("../../testdata/root.pem")).unwrap(), + )) + .unwrap(); + assert!(client.get(format!("https://localhost:{}/", addr.port())).send().await.is_ok()); + + https_server_handle.shutdown(); + } + + #[tokio::test] + async fn mismatching_certificates() { + let rustls_config = RustlsConfig::from_pem( + include_bytes!("../../testdata/localhost.chain.pem").into(), + include_bytes!("../../testdata/localhost.key").into(), + ) + .await + .unwrap(); + let router = Router::new().route("/", get(|| async { "Hello world" })); + let https_server_handle = axum_server::Handle::new(); + let https_server_handle_clone = https_server_handle.clone(); + tokio::spawn(async move { + axum_server::bind_rustls(SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 0), rustls_config) + .handle(https_server_handle_clone) + .serve(router.into_make_service()) + .await + .unwrap(); + }); + let addr = https_server_handle.listening().await.unwrap(); + + let client = hash_checking_http_client(CertificateHash::sha256( + &CertificateDer::from_pem_slice(include_bytes!("../../testdata/altroot.pem")).unwrap(), + )) + .unwrap(); + assert!(client.get(format!("https://localhost:{}/", addr.port())).send().await.is_err()); + + https_server_handle.shutdown(); + } +} diff --git a/s2energy-connection/src/lib.rs b/s2energy-connection/src/lib.rs index b38c993..3b4d71c 100644 --- a/s2energy-connection/src/lib.rs +++ b/s2energy-connection/src/lib.rs @@ -18,3 +18,36 @@ pub mod pairing; pub use common::wire::{ AccessToken, CommunicationProtocol, Deployment, EndpointDescription, InvalidNodeId, MessageVersion, NodeDescription, NodeId, Role, }; +use serde::{Deserialize, Serialize}; +use sha2::Digest; + +/// Hash of a TLS certificate. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] +pub struct CertificateHash(CertificateHashInner); + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)] +enum CertificateHashInner { + Sha256(sha2::digest::generic_array::GenericArray::OutputSize>), +} + +impl CertificateHash { + pub(crate) fn sha256(data: &[u8]) -> Self { + Self(CertificateHashInner::Sha256(sha2::Sha256::digest(data))) + } +} + +impl AsRef for CertificateHash { + fn as_ref(&self) -> &CertificateHash { + self + } +} + +impl std::ops::Deref for CertificateHash { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + match &self.0 { + CertificateHashInner::Sha256(generic_array) => generic_array, + } + } +} diff --git a/s2energy-connection/src/pairing/client.rs b/s2energy-connection/src/pairing/client.rs index b05999f..e3cf5fa 100644 --- a/s2energy-connection/src/pairing/client.rs +++ b/s2energy-connection/src/pairing/client.rs @@ -8,7 +8,7 @@ use crate::common::negotiate_version; use crate::common::wire::{AccessToken, Deployment, PairingVersion, Role}; use crate::pairing::transport::{HashProvider, hash_providing_https_client}; use crate::pairing::{ConfigError, Error, Pairing, PairingRole}; -use crate::{EndpointDescription, NodeDescription, NodeId}; +use crate::{CertificateHash, EndpointDescription, NodeDescription, NodeId}; use super::NodeConfig; use super::wire::*; @@ -392,7 +392,9 @@ impl Client { } fn prepare_reqwest_client(&self, url: &Url) -> Result<(reqwest::Client, Option), Error> { - let (client, certhash) = if url.domain().map(|v| v.ends_with(".local")).unwrap_or_default() { + let (client, certhash) = if url.domain().map(|v| v.ends_with(".local")).unwrap_or_default() + || url.domain().map(|v| v.ends_with(".local.")).unwrap_or_default() + { let (client, certhash) = hash_providing_https_client()?; (client, Some(certhash)) } else { @@ -476,11 +478,11 @@ impl<'a> V1Session<'a> { let our_deployment = self.endpoint_description.deployment.unwrap_or(local_deployment); let our_role = self.config.node_description.role; - let network = if self.base_url.domain().map(|v| v.ends_with(".local")).unwrap_or_default() { - if let Some(hash) = certhash.as_ref().and_then(HashProvider::hash) { - Network::Lan { - fingerprint: hash.try_into().unwrap(), - } + let network = if self.base_url.domain().map(|v| v.ends_with(".local")).unwrap_or_default() + || self.base_url.domain().map(|v| v.ends_with(".local.")).unwrap_or_default() + { + if let Some(hash) = certhash.as_ref().and_then(HashProvider::leaf_hash) { + Network::Lan { fingerprint: hash.clone() } } else { return Err(ErrorKind::ProtocolError.into()); } @@ -552,6 +554,7 @@ impl<'a> V1Session<'a> { server_hmac_challenge_response, initiate_connection_url.clone(), access_token.clone(), + self.config.root_certificate.as_deref().map(CertificateHash::sha256), ) .await { @@ -573,12 +576,26 @@ impl<'a> V1Session<'a> { return Err(e); } }; + + let initiate_url = + Url::parse(&connection_details.initiate_connection_url).map_err(|e| Error::new(ErrorKind::ProtocolError, e))?; + let root_hash = if initiate_url.domain().map(|v| v.ends_with(".local")).unwrap_or_default() + || initiate_url.domain().map(|v| v.ends_with(".local.")).unwrap_or_default() + { + connection_details + .certificate_fingerprint + .or(certhash.as_ref().and_then(HashProvider::root_hash).cloned()) + } else { + None + }; + Pairing { remote_endpoint_description: request_pairing_response.server_endpoint_description, remote_node_description: request_pairing_response.server_node_description, token: connection_details.access_token, role: PairingRole::CommunicationClient { initiate_url: connection_details.initiate_connection_url, + root_hash, }, } } @@ -644,12 +661,14 @@ impl<'a> V1Session<'a> { server_hmac_challenge_response: HmacChallengeResponse, initiate_connection_url: String, access_token: AccessToken, + certificate_fingerprint: Option, ) -> PairingResult<()> { let request = PostConnectionDetailsRequest { server_hmac_challenge_response, connection_details: ConnectionDetails { initiate_connection_url, access_token, + certificate_fingerprint, }, }; let response = self @@ -888,12 +907,12 @@ mod tests { #[tokio::test] async fn pairing_ok_rm_initiates() { let server_config = NodeConfig::builder(basic_node_description(UUID_A, Role::Cem), vec![MessageVersion("v1".into())]) - .with_connection_initiate_url("test.example.com".into()) + .with_connection_initiate_url("https://test.example.com".into()) .build() .unwrap(); let client_config = NodeConfig::builder(basic_node_description(UUID_B, Role::Rm), vec![MessageVersion("v1".into())]) - .with_connection_initiate_url("client.example.com".into()) + .with_connection_initiate_url("https://client.example.com".into()) .build() .unwrap(); @@ -1502,7 +1521,7 @@ mod tests { #[tokio::test] async fn longpolling() { let server_config = NodeConfig::builder(basic_node_description(UUID_A, Role::Cem), vec![MessageVersion("v1".into())]) - .with_connection_initiate_url("test.example.com".into()) + .with_connection_initiate_url("https://test.example.com".into()) .build() .unwrap(); diff --git a/s2energy-connection/src/pairing/mod.rs b/s2energy-connection/src/pairing/mod.rs index 58ff60a..1a9491d 100644 --- a/s2energy-connection/src/pairing/mod.rs +++ b/s2energy-connection/src/pairing/mod.rs @@ -210,6 +210,7 @@ mod wire; use rand::CryptoRng; +use rustls::pki_types::CertificateDer; use wire::{HmacChallenge, HmacChallengeResponse}; pub use client::{Client, ClientConfig, LongpollHandler, Longpoller, PairingRemote, PrePairing}; @@ -219,7 +220,10 @@ pub use server::{ }; pub use wire::NodeIdAlias; -use crate::{CommunicationProtocol, Deployment, EndpointDescription, MessageVersion, NodeDescription, Role, common::wire::AccessToken}; +use crate::{ + CertificateHash, CommunicationProtocol, Deployment, EndpointDescription, MessageVersion, NodeDescription, Role, + common::wire::AccessToken, +}; /// Full description of an S2 node. #[derive(Debug, Clone)] @@ -228,6 +232,7 @@ pub struct NodeConfig { supported_message_versions: Vec, supported_communication_protocols: Vec, connection_initiate_url: Option, + root_certificate: Option>, } impl NodeConfig { @@ -251,6 +256,11 @@ impl NodeConfig { self.connection_initiate_url.as_deref() } + /// Root certificate used by the node in communication, if known. + pub fn root_certificate(&self) -> Option<&CertificateDer<'static>> { + self.root_certificate.as_ref() + } + /// Create a builder for a new [`NodeConfig`]. /// /// All node configurations must at least contain description of the node and supported message versions. Additional @@ -261,6 +271,7 @@ impl NodeConfig { supported_message_versions, supported_communication_protocols: vec![CommunicationProtocol("WebSocket".into())], connection_initiate_url: None, + root_certificate: None, } } } @@ -271,6 +282,7 @@ pub struct ConfigBuilder { supported_message_versions: Vec, supported_communication_protocols: Vec, connection_initiate_url: Option, + root_certificate: Option>, } impl ConfigBuilder { @@ -288,6 +300,12 @@ impl ConfigBuilder { self } + /// Set the root certificate used in communication by this node. + pub fn with_root_certificate(mut self, root_certificate: CertificateDer<'static>) -> Self { + self.root_certificate = Some(root_certificate); + self + } + /// Create the actual [`NodeConfig`], validating that it is reasonable. pub fn build(self) -> Result { if self.node_description.role == Role::Cem && self.connection_initiate_url.is_none() { @@ -298,6 +316,7 @@ impl ConfigBuilder { supported_message_versions: self.supported_message_versions, supported_communication_protocols: self.supported_communication_protocols, connection_initiate_url: self.connection_initiate_url, + root_certificate: self.root_certificate, }) } } @@ -309,6 +328,8 @@ pub enum PairingRole { CommunicationClient { /// URL to be used for initiating the connection. initiate_url: String, + /// Hash of the root certificate of the communication server + root_hash: Option, }, /// This node gets contacted by the other node to initiate a connection. CommunicationServer, @@ -400,7 +421,7 @@ pub type PairingResult = Result; #[derive(Debug)] enum Network { Wan, - Lan { fingerprint: [u8; 32] }, + Lan { fingerprint: CertificateHash }, } impl Network { diff --git a/s2energy-connection/src/pairing/server.rs b/s2energy-connection/src/pairing/server.rs index 3efeaa0..264a5dc 100644 --- a/s2energy-connection/src/pairing/server.rs +++ b/s2energy-connection/src/pairing/server.rs @@ -28,6 +28,7 @@ use tokio::{ use tracing::{Instrument, info, trace}; use crate::{ + CertificateHash, common::{ AbortingJoinHandle, root, wire::{AccessToken, EndpointDescription, NodeDescription, NodeId, PairingVersion}, @@ -133,7 +134,7 @@ impl Clone for Server { /// Configuration for the S2 pairing server. pub struct ServerConfig { - /// The root certificate of the server, if we are using a self-signed root. + /// The leaf certificate of the server, if we are using a self-signed root. /// Presence of this field indicates we are deployed on LAN. pub leaf_certificate: Option>, /// Endpoint description of the server @@ -212,7 +213,7 @@ impl Server { network: server_config .leaf_certificate .map(|v| Network::Lan { - fingerprint: sha2::Sha256::digest(v).into(), + fingerprint: CertificateHash::sha256(&v), }) .unwrap_or(Network::Wan), advertised_nodes: Mutex::new(server_config.advertised_nodes), @@ -1021,6 +1022,7 @@ async fn v1_request_connection_details( None => return (Err(StatusCode::BAD_REQUEST), None), }, access_token: AccessToken::new(&mut rng), + certificate_fingerprint: state.config.root_certificate.as_deref().map(CertificateHash::sha256), }; trace!("Generated connection details"); @@ -1094,6 +1096,7 @@ async fn v1_post_connection_details( access_token: req.connection_details.access_token, role: PairingRole::CommunicationClient { initiate_url: req.connection_details.initiate_connection_url, + root_hash: req.connection_details.certificate_fingerprint, }, }); @@ -2018,6 +2021,7 @@ mod tests { connection_details: ConnectionDetails { initiate_connection_url: "https://example.com/".into(), access_token: AccessToken::new(&mut rand::rng()), + certificate_fingerprint: None, }, }) .unwrap(), @@ -2073,6 +2077,7 @@ mod tests { connection_details: ConnectionDetails { initiate_connection_url: "https://example.com/".into(), access_token: AccessToken::new(&mut rand::rng()), + certificate_fingerprint: None, }, }) .unwrap(), @@ -2130,6 +2135,7 @@ mod tests { connection_details: ConnectionDetails { initiate_connection_url: "https://example.com/".into(), access_token: AccessToken::new(&mut rand::rng()), + certificate_fingerprint: None, }, }) .unwrap(), diff --git a/s2energy-connection/src/pairing/transport.rs b/s2energy-connection/src/pairing/transport.rs index 06b40cc..2fc122d 100644 --- a/s2energy-connection/src/pairing/transport.rs +++ b/s2energy-connection/src/pairing/transport.rs @@ -5,9 +5,8 @@ use rustls::{ client::{WebPkiServerVerifier, danger::ServerCertVerifier}, pki_types::CertificateDer, }; -use sha2::Digest; -use crate::pairing::Error; +use crate::{CertificateHash, pairing::Error}; use super::{ErrorKind, PairingResult}; @@ -19,7 +18,8 @@ struct HashingCertificateVerifier { #[derive(Debug)] struct SelfSignedState { - hash: CertificateHash, + root_hash: CertificateHash, + leaf_hash: CertificateHash, verifier: SelfVerifier, } @@ -78,8 +78,6 @@ impl ServerCertVerifier for SelfVerifier { } } -type CertificateHash = sha2::digest::generic_array::GenericArray::OutputSize>; - impl ServerCertVerifier for HashingCertificateVerifier { fn verify_server_cert( &self, @@ -98,7 +96,8 @@ impl ServerCertVerifier for HashingCertificateVerifier { let state = self.self_signed_state.get_or_init(|| { let fallback = CertificateDer::from_slice(&[]); let root_cert = intermediates.last().unwrap_or(&fallback); - let hash = sha2::Sha256::digest(end_entity); + let root_hash = CertificateHash::sha256(root_cert); + let leaf_hash = CertificateHash::sha256(end_entity); let mut root_store = RootCertStore::empty(); // conciously ignore errors here, we just want to initialize root_store.add(root_cert.clone()).ok(); @@ -107,7 +106,11 @@ impl ServerCertVerifier for HashingCertificateVerifier { Err(_) => SelfVerifier::None, }; - SelfSignedState { hash, verifier } + SelfSignedState { + root_hash, + leaf_hash, + verifier, + } }); state .verifier @@ -144,9 +147,16 @@ pub(crate) struct HashProvider { } impl HashProvider { - pub(crate) fn hash(&self) -> Option<&[u8]> { + pub(crate) fn leaf_hash(&self) -> Option<&CertificateHash> { + match self.state.get() { + Some(state) => Some(&state.leaf_hash), + None => None, + } + } + + pub(crate) fn root_hash(&self) -> Option<&CertificateHash> { match self.state.get() { - Some(state) => Some(&state.hash), + Some(state) => Some(&state.root_hash), None => None, } } @@ -205,7 +215,7 @@ mod tests { let (client, hash_provider) = hash_providing_https_client().unwrap(); assert!(client.get(format!("https://localhost:{}/", addr.port())).send().await.is_ok()); - assert!(hash_provider.hash().is_some()); + assert!(hash_provider.leaf_hash().is_some()); assert!(client.get(format!("https://localhost:{}/", addr.port())).send().await.is_ok()); https_server_handle.shutdown(); @@ -233,7 +243,7 @@ mod tests { let (client, hash_provider) = hash_providing_https_client().unwrap(); assert!(client.get(format!("https://localhost:{}/", addr.port())).send().await.is_ok()); - assert!(hash_provider.hash().is_some()); + assert!(hash_provider.leaf_hash().is_some()); https_server_handle.shutdown(); @@ -280,7 +290,7 @@ mod tests { let (client, hash_provider) = hash_providing_https_client().unwrap(); assert!(client.get(format!("https://localhost:{}/", addr.port())).send().await.is_ok()); - assert!(hash_provider.hash().is_some()); + assert!(hash_provider.leaf_hash().is_some()); https_server_handle.shutdown(); diff --git a/s2energy-connection/src/pairing/wire.rs b/s2energy-connection/src/pairing/wire.rs index f5bd5f2..73d5d91 100644 --- a/s2energy-connection/src/pairing/wire.rs +++ b/s2energy-connection/src/pairing/wire.rs @@ -2,13 +2,13 @@ use axum::{Json, extract::FromRequestParts, response::IntoResponse}; use axum_extra::{TypedHeader, headers}; use http::StatusCode; use rand::distr::{Alphanumeric, SampleString}; -use serde::*; +use serde::{ser::SerializeMap, *}; use subtle::ConstantTimeEq; use thiserror::Error; use tracing::info; use crate::{ - NodeId, + CertificateHash, CertificateHashInner, NodeId, common::wire::{AccessToken, CommunicationProtocol, EndpointDescription, MessageVersion, NodeDescription}, }; @@ -234,6 +234,38 @@ pub(crate) struct CancelPrePairingRequest { pub(crate) struct ConnectionDetails { pub initiate_connection_url: String, pub access_token: AccessToken, + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_fingerprint", + deserialize_with = "deserialize_fingerprint" + )] + pub certificate_fingerprint: Option, +} + +pub(crate) fn serialize_fingerprint(value: &Option, serializer: S) -> Result { + use base64::{Engine, engine::general_purpose::STANDARD}; + // Unwrap is ok here as we serialize only when not none. + let encoded = STANDARD.encode(value.as_deref().unwrap() as &[u8]); + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("SHA256", &encoded)?; + map.end() +} + +pub(crate) fn deserialize_fingerprint<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + use base64::{Engine, engine::general_purpose::STANDARD}; + use std::{borrow::Cow, collections::HashMap}; + let data = HashMap::, Cow<'de, str>>::deserialize(deserializer)?; + if let Some(hash) = data.get("SHA256") { + let decoded = STANDARD.decode(hash.as_ref()).map_err(de::Error::custom)?; + Ok(Some(CertificateHash(CertificateHashInner::Sha256( + <[u8; 32]>::try_from(decoded) + .map_err(|_| de::Error::custom("Hash is wrong length"))? + .into(), + )))) + } else { + Err(de::Error::custom("Missing SHA256 hash")) + } } #[derive(Serialize, Deserialize)] From 518ef59847cbb28c097fbe7bf8b4840eb2fe9150 Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Mon, 30 Mar 2026 09:34:53 +0200 Subject: [PATCH 02/15] Add node id to message format. --- s2energy-connection/src/pairing/client.rs | 3 ++- s2energy-connection/src/pairing/server.rs | 23 +++++++++++++++-------- s2energy-connection/src/pairing/wire.rs | 5 ++++- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/s2energy-connection/src/pairing/client.rs b/s2energy-connection/src/pairing/client.rs index e3cf5fa..85e08dd 100644 --- a/s2energy-connection/src/pairing/client.rs +++ b/s2energy-connection/src/pairing/client.rs @@ -695,7 +695,8 @@ impl<'a> V1Session<'a> { let request = RequestPairing { node_description: self.config.node_description.clone(), endpoint_description: self.endpoint_description.clone(), - id, + id: None, + id_alias: id, supported_protocols: self.config.supported_communication_protocols.clone(), supported_versions: self.config.supported_message_versions.clone(), supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], diff --git a/s2energy-connection/src/pairing/server.rs b/s2energy-connection/src/pairing/server.rs index 264a5dc..91c13ea 100644 --- a/s2energy-connection/src/pairing/server.rs +++ b/s2energy-connection/src/pairing/server.rs @@ -871,7 +871,7 @@ async fn v1_request_pairing( let server_hmac_challenge = HmacChallenge::new(&mut rand::rng(), HMAC_CHALLENGE_BYTES); - let pairing_s2_node_id = request_pairing.id.unwrap_or(NodeIdAlias(String::default())); + let pairing_s2_node_id = request_pairing.id_alias.unwrap_or(NodeIdAlias(String::default())); let open_pairing = { let mut open_pairings = state.open_pairings.lock().unwrap(); if let Some((_, request)) = open_pairings.remove_entry(&pairing_s2_node_id) { @@ -1557,7 +1557,8 @@ mod tests { serde_json::to_vec(&RequestPairing { node_description: basic_node_description(UUID_B, Role::Cem), endpoint_description: EndpointDescription::default(), - id: Some(pairing_s2_node_id()), + id: None, + id_alias: Some(pairing_s2_node_id()), supported_protocols: vec![CommunicationProtocol("WebSocket".into())], supported_versions: vec![MessageVersion("v1".into())], supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], @@ -1609,7 +1610,8 @@ mod tests { serde_json::to_vec(&RequestPairing { node_description: basic_node_description(UUID_B, Role::Cem), endpoint_description: EndpointDescription::default(), - id: Some(pairing_s2_node_id()), + id: None, + id_alias: Some(pairing_s2_node_id()), supported_protocols: vec![CommunicationProtocol("HTTP/3".into())], supported_versions: vec![MessageVersion("v1".into())], supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], @@ -1660,7 +1662,8 @@ mod tests { serde_json::to_vec(&RequestPairing { node_description: basic_node_description(UUID_B, Role::Cem), endpoint_description: EndpointDescription::default(), - id: Some(pairing_s2_node_id()), + id: None, + id_alias: Some(pairing_s2_node_id()), supported_protocols: vec![CommunicationProtocol("WebSocket".into())], supported_versions: vec![MessageVersion("v0".into())], supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], @@ -1711,7 +1714,8 @@ mod tests { serde_json::to_vec(&RequestPairing { node_description: basic_node_description(UUID_B, Role::Cem), endpoint_description: EndpointDescription::default(), - id: Some(pairing_s2_node_id()), + id: None, + id_alias: Some(pairing_s2_node_id()), supported_protocols: vec![CommunicationProtocol("HTTP/3".into())], supported_versions: vec![MessageVersion("v0".into())], supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], @@ -1749,7 +1753,8 @@ mod tests { serde_json::to_vec(&RequestPairing { node_description: basic_node_description(UUID_A, Role::Cem), endpoint_description: EndpointDescription::default(), - id: Some(pairing_s2_node_id()), + id: None, + id_alias: Some(pairing_s2_node_id()), supported_protocols: vec![CommunicationProtocol("WebSocket".into())], supported_versions: vec![MessageVersion("v1".into())], supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], @@ -1800,7 +1805,8 @@ mod tests { serde_json::to_vec(&RequestPairing { node_description: basic_node_description(UUID_B, Role::Rm), endpoint_description: EndpointDescription::default(), - id: Some(pairing_s2_node_id()), + id: None, + id_alias: Some(pairing_s2_node_id()), supported_protocols: vec![CommunicationProtocol("WebSocket".into())], supported_versions: vec![MessageVersion("v1".into())], supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], @@ -2683,7 +2689,8 @@ mod tests { serde_json::to_vec(&RequestPairing { node_description: basic_node_description(UUID_A, Role::Cem), endpoint_description: EndpointDescription::default(), - id: Some(pairing_s2_node_id()), + id: None, + id_alias: Some(pairing_s2_node_id()), supported_protocols: vec![CommunicationProtocol("WebSocket".into())], supported_versions: vec![MessageVersion("v1".into())], supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], diff --git a/s2energy-connection/src/pairing/wire.rs b/s2energy-connection/src/pairing/wire.rs index 73d5d91..f261e9c 100644 --- a/s2energy-connection/src/pairing/wire.rs +++ b/s2energy-connection/src/pairing/wire.rs @@ -132,7 +132,10 @@ pub(crate) struct RequestPairing { /// A server-assigned identifier of the S2Node that this server represents. #[serde(rename = "nodeIdAlias")] #[serde(default)] - pub id: Option, + pub id_alias: Option, + #[serde(rename = "nodeId")] + #[serde(default)] + pub id: Option, #[serde(rename = "supportedCommunicationProtocols")] pub supported_protocols: Vec, /// The versions of the S2 JSON message schemas this S2Node implementation currently supports. From 869e4af39f741e70d7a7b1c0587779d738ca75f0 Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Mon, 30 Mar 2026 11:03:10 +0200 Subject: [PATCH 03/15] Modified server to allow pairing requests using node ids. --- s2energy-connection/src/pairing/error.rs | 3 + s2energy-connection/src/pairing/server.rs | 263 +++++++++++++++++++--- 2 files changed, 233 insertions(+), 33 deletions(-) diff --git a/s2energy-connection/src/pairing/error.rs b/s2energy-connection/src/pairing/error.rs index e7e9f4d..e016c48 100644 --- a/s2energy-connection/src/pairing/error.rs +++ b/s2energy-connection/src/pairing/error.rs @@ -153,6 +153,8 @@ pub enum ErrorKind { AlreadyPending, /// Provided token was invalid. InvalidToken, + /// Provided node alias was invalid. + InvalidNodeAlias, /// Remote permanently rejects longpolling or querying of node information. Rejected, /// The pairing or longpolling session was cancelled. @@ -176,6 +178,7 @@ impl std::fmt::Display for ErrorKind { Self::Timeout => f.write_str("Timed out"), Self::AlreadyPending => f.write_str("A pairing or longpolling session for this node is already pending"), Self::InvalidToken => f.write_str("The token used does not match with that of the remote"), + Self::InvalidNodeAlias => f.write_str("The node alias provided is not valid"), Self::Rejected => f.write_str("Longpolling was permanently rejected by remote"), Self::Cancelled => f.write_str("Pairing or longpolling was cancelled by remote"), Self::RemoteOfSameType => f.write_str("Remote is of same type of us"), diff --git a/s2energy-connection/src/pairing/server.rs b/s2energy-connection/src/pairing/server.rs index 91c13ea..6f3ac16 100644 --- a/s2energy-connection/src/pairing/server.rs +++ b/s2energy-connection/src/pairing/server.rs @@ -218,8 +218,7 @@ impl Server { .unwrap_or(Network::Wan), advertised_nodes: Mutex::new(server_config.advertised_nodes), endpoint_description: server_config.endpoint_description, - permanent_pairings: Mutex::new(HashMap::new()), - open_pairings: Mutex::new(HashMap::new()), + pending_pairings: Mutex::new(PendingPairings::default()), attempts: Mutex::new(HashMap::default()), prepairing_handler: Arc::new(handler), longpolling_enabled: longpolling_enabled_receiver, @@ -304,27 +303,33 @@ impl Server { pairing_node_id: Option, pairing_token: PairingToken, callback: impl (FnOnce(PairingResult) -> F) + Send + 'static, - ) -> Result<(), ErrorKind> { + ) -> Result<(), Error> { if config.connection_initiate_url.is_none() { - return Err(ErrorKind::InvalidConfig(super::ConfigError::MissingInitiateUrl)); + return Err(ErrorKind::InvalidConfig(super::ConfigError::MissingInitiateUrl).into()); + } + + if pairing_node_id.as_ref().map(|v| v.0.is_empty()).unwrap_or(false) { + return Err(ErrorKind::InvalidNodeAlias.into()); } let pairing_node_id = pairing_node_id.unwrap_or(NodeIdAlias(String::default())); + let node_id = config.node_description.id; - // We hit issues here, because the node node_description only has S2NodeId with no - // efficient way of mapping that back. - let mut open_pairings = self.state.open_pairings.lock().unwrap(); - let mut permanent_pairings = self.state.permanent_pairings.lock().unwrap(); - if open_pairings.contains_key(&pairing_node_id) || permanent_pairings.contains_key(&pairing_node_id) { - return Err(ErrorKind::AlreadyPending); + let mut pending_pairings = self.state.pending_pairings.lock().unwrap(); + if pending_pairings.alias_mappings.contains_key(&pairing_node_id) + || pending_pairings.open_pairings.contains_key(&node_id) + || pending_pairings.permanent_pairings.contains_key(&node_id) + { + return Err(ErrorKind::AlreadyPending.into()); } - drop(permanent_pairings); - open_pairings.insert( - pairing_node_id, + pending_pairings.alias_mappings.insert(pairing_node_id.clone(), node_id); + pending_pairings.open_pairings.insert( + node_id, PairingRequest { config, handler: ResultHandler::Oneshot(Box::new(|result| Box::pin(async move { callback(result).await.map_err(|_| ()) }))), token: pairing_token, + alias: pairing_node_id, age: Instant::now(), }, ); @@ -345,22 +350,25 @@ impl Server { pairing_node_id: Option, pairing_token: PairingToken, callback: impl (Fn(PairingResult) -> F) + Send + Sync + 'static, - ) -> Result<(), ErrorKind> { + ) -> Result<(), Error> { if config.connection_initiate_url.is_none() { - return Err(ErrorKind::InvalidConfig(super::ConfigError::MissingInitiateUrl)); + return Err(ErrorKind::InvalidConfig(super::ConfigError::MissingInitiateUrl).into()); } let pairing_node_id = pairing_node_id.unwrap_or(NodeIdAlias(String::default())); + let node_id = config.node_description.id; - let mut open_pairings = self.state.open_pairings.lock().unwrap(); - let mut permanent_pairings = self.state.permanent_pairings.lock().unwrap(); - if open_pairings.contains_key(&pairing_node_id) || permanent_pairings.contains_key(&pairing_node_id) { - return Err(ErrorKind::AlreadyPending); + let mut pending_pairings = self.state.pending_pairings.lock().unwrap(); + if pending_pairings.alias_mappings.contains_key(&pairing_node_id) + || pending_pairings.open_pairings.contains_key(&node_id) + || pending_pairings.permanent_pairings.contains_key(&node_id) + { + return Err(ErrorKind::AlreadyPending.into()); } - drop(open_pairings); let callback = Arc::new(callback); - permanent_pairings.insert( - pairing_node_id, + pending_pairings.alias_mappings.insert(pairing_node_id.clone(), node_id); + pending_pairings.permanent_pairings.insert( + node_id, PermanentPairingRequest { config, handler: Arc::new(move |result| { @@ -534,6 +542,7 @@ struct PairingRequest { config: Arc, handler: ResultHandler, token: PairingToken, + alias: NodeIdAlias, age: Instant, } @@ -600,8 +609,7 @@ struct AppStateInner { network: Network, endpoint_description: EndpointDescription, advertised_nodes: Mutex>, - permanent_pairings: Mutex>, - open_pairings: Mutex>, + pending_pairings: Mutex, attempts: Mutex>, prepairing_handler: Arc, longpolling_enabled: tokio::sync::watch::Receiver, @@ -615,6 +623,13 @@ struct AppStateInner { cleanup_task: OnceLock>, } +#[derive(Default)] +struct PendingPairings { + alias_mappings: HashMap, + permanent_pairings: HashMap, + open_pairings: HashMap, +} + // Holder for longpolling sessions during a session. Returns // them to the appstate on drop. struct LocalLongpollingCache { @@ -689,8 +704,15 @@ async fn periodic_cleanup(state: Weak>) { // Open pairing sessions { - let mut open_pairings = state.open_pairings.lock().unwrap(); - open_pairings.retain(|_, value| now.duration_since(value.age) < ONCEPAIR_TIMEOUT + TIMEOUT_SLACK); + let mut pending_pairings = state.pending_pairings.lock().unwrap(); + let PendingPairings { + alias_mappings, + open_pairings, + .. + } = &mut *pending_pairings; + for (_, pairing) in open_pairings.extract_if(|_, value| now.duration_since(value.age) >= ONCEPAIR_TIMEOUT + TIMEOUT_SLACK) { + alias_mappings.remove(&pairing.alias); + } } drop(state); @@ -871,21 +893,35 @@ async fn v1_request_pairing( let server_hmac_challenge = HmacChallenge::new(&mut rand::rng(), HMAC_CHALLENGE_BYTES); - let pairing_s2_node_id = request_pairing.id_alias.unwrap_or(NodeIdAlias(String::default())); let open_pairing = { - let mut open_pairings = state.open_pairings.lock().unwrap(); - if let Some((_, request)) = open_pairings.remove_entry(&pairing_s2_node_id) { + let mut pending_pairings = state.pending_pairings.lock().unwrap(); + + let node_id = request_pairing.id.map(Ok).unwrap_or_else(|| { + let pairing_s2_node_id = request_pairing.id_alias.clone().unwrap_or(NodeIdAlias(String::default())); + pending_pairings + .alias_mappings + .get(&pairing_s2_node_id) + .copied() + .ok_or(if request_pairing.id_alias.is_some() { + PairingResponseErrorMessage::S2NodeNotFound + } else { + PairingResponseErrorMessage::S2NodeNotProvided + }) + })?; + + if let Some((_, request)) = pending_pairings.open_pairings.remove_entry(&node_id) { + pending_pairings.alias_mappings.remove(&request.alias); request } else { - drop(open_pairings); - let permanent_pairings = state.permanent_pairings.lock().unwrap(); - let entry = permanent_pairings - .get(&pairing_s2_node_id) + let entry = pending_pairings + .permanent_pairings + .get(&node_id) .ok_or(PairingResponseErrorMessage::S2NodeNotFound)?; PairingRequest { config: entry.config.clone(), handler: ResultHandler::Multi(entry.handler.clone()), token: PairingToken(entry.token.0.clone()), + alias: NodeIdAlias(String::default()), age: Instant::now(), } } @@ -1577,6 +1613,167 @@ mod tests { let response_data: RequestPairingResponse = serde_json::from_slice(&body).unwrap(); let expected_response = challenge.sha256(&Network::Wan, b"testtoken"); assert_eq!(expected_response, response_data.client_hmac_challenge_response); + assert!(server.state.pending_pairings.lock().unwrap().alias_mappings.is_empty()); + } + + #[tokio::test] + async fn pair_attempt_node_id() { + let server = Server::new(ServerConfig { + leaf_certificate: None, + endpoint_description: EndpointDescription::default(), + advertised_nodes: vec![], + }); + server + .allow_pair_repeated( + Arc::new( + NodeConfig::builder(basic_node_description(UUID_A, Role::Rm), vec![MessageVersion("v1".into())]) + .with_connection_initiate_url("https://example.com/".into()) + .build() + .unwrap(), + ), + Some(pairing_s2_node_id()), + PairingToken(b"testtoken".as_slice().into()), + async |_| Ok::<_, std::io::Error>(()), + ) + .unwrap(); + + let challenge = HmacChallenge::new(&mut rand::rng(), 64); + let response = server + .get_router() + .oneshot( + http::Request::post("/v1/requestPairing") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&RequestPairing { + node_description: basic_node_description(UUID_B, Role::Cem), + endpoint_description: EndpointDescription::default(), + id: Some(UUID_A.into()), + id_alias: None, + supported_protocols: vec![CommunicationProtocol("WebSocket".into())], + supported_versions: vec![MessageVersion("v1".into())], + supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], + client_hmac_challenge: challenge.clone(), + force_pairing: false, + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let response_data: RequestPairingResponse = serde_json::from_slice(&body).unwrap(); + let expected_response = challenge.sha256(&Network::Wan, b"testtoken"); + assert_eq!(expected_response, response_data.client_hmac_challenge_response); + assert!(!server.state.pending_pairings.lock().unwrap().alias_mappings.is_empty()); + } + + #[tokio::test] + async fn pair_attempt_no_node_identifier() { + let server = Server::new(ServerConfig { + leaf_certificate: None, + endpoint_description: EndpointDescription::default(), + advertised_nodes: vec![], + }); + server + .allow_pair_once( + Arc::new( + NodeConfig::builder(basic_node_description(UUID_A, Role::Rm), vec![MessageVersion("v1".into())]) + .with_connection_initiate_url("https://example.com/".into()) + .build() + .unwrap(), + ), + None, + PairingToken(b"testtoken".as_slice().into()), + async |_| Ok::<_, std::io::Error>(()), + ) + .unwrap(); + + let challenge = HmacChallenge::new(&mut rand::rng(), 64); + let response = server + .get_router() + .oneshot( + http::Request::post("/v1/requestPairing") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&RequestPairing { + node_description: basic_node_description(UUID_B, Role::Cem), + endpoint_description: EndpointDescription::default(), + id: None, + id_alias: None, + supported_protocols: vec![CommunicationProtocol("WebSocket".into())], + supported_versions: vec![MessageVersion("v1".into())], + supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], + client_hmac_challenge: challenge.clone(), + force_pairing: false, + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let response_data: RequestPairingResponse = serde_json::from_slice(&body).unwrap(); + let expected_response = challenge.sha256(&Network::Wan, b"testtoken"); + assert_eq!(expected_response, response_data.client_hmac_challenge_response); + assert!(server.state.pending_pairings.lock().unwrap().alias_mappings.is_empty()); + } + + #[tokio::test] + async fn pair_attempt_need_node_identifier() { + let server = Server::new(ServerConfig { + leaf_certificate: None, + endpoint_description: EndpointDescription::default(), + advertised_nodes: vec![], + }); + server + .allow_pair_once( + Arc::new( + NodeConfig::builder(basic_node_description(UUID_A, Role::Rm), vec![MessageVersion("v1".into())]) + .with_connection_initiate_url("https://example.com/".into()) + .build() + .unwrap(), + ), + Some(pairing_s2_node_id()), + PairingToken(b"testtoken".as_slice().into()), + async |_| Ok::<_, std::io::Error>(()), + ) + .unwrap(); + + let challenge = HmacChallenge::new(&mut rand::rng(), 64); + let response = server + .get_router() + .oneshot( + http::Request::post("/v1/requestPairing") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&RequestPairing { + node_description: basic_node_description(UUID_B, Role::Cem), + endpoint_description: EndpointDescription::default(), + id: None, + id_alias: None, + supported_protocols: vec![CommunicationProtocol("WebSocket".into())], + supported_versions: vec![MessageVersion("v1".into())], + supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], + client_hmac_challenge: challenge.clone(), + force_pairing: false, + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let error: PairingResponseErrorMessage = serde_json::from_slice(&body).unwrap(); + assert_eq!(error, PairingResponseErrorMessage::S2NodeNotProvided); } #[tokio::test] From 5d7be91a6e918e683a16250e404b61fefd004ff4 Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Mon, 30 Mar 2026 11:32:57 +0200 Subject: [PATCH 04/15] Modified client to enable use of node ids in pairing. --- .../examples/pairing-client.rs | 4 +- s2energy-connection/src/pairing/client.rs | 63 +++++++++++++------ s2energy-connection/src/pairing/mod.rs | 6 +- 3 files changed, 50 insertions(+), 23 deletions(-) diff --git a/s2energy-connection/examples/pairing-client.rs b/s2energy-connection/examples/pairing-client.rs index ae2ecfc..f3a01ab 100644 --- a/s2energy-connection/examples/pairing-client.rs +++ b/s2energy-connection/examples/pairing-client.rs @@ -4,7 +4,7 @@ use uuid::uuid; use s2energy_connection::{ Deployment, EndpointDescription, MessageVersion, NodeDescription, Role, - pairing::{Client, ClientConfig, NodeConfig, NodeIdAlias, PairingRemote}, + pairing::{Client, ClientConfig, NodeConfig, NodeIdAlias, PairingRemote, RemoteNodeIdentifier}, }; use tracing_subscriber::{EnvFilter, fmt, prelude::*}; @@ -48,7 +48,7 @@ async fn main() { &config, PairingRemote { url: "https://localhost:8005".into(), - id: Some(NodeIdAlias("ninechars".into())), + id: RemoteNodeIdentifier::Alias(NodeIdAlias("ninechars".into())), }, PAIRING_TOKEN, async |pairing| { diff --git a/s2energy-connection/src/pairing/client.rs b/s2energy-connection/src/pairing/client.rs index 85e08dd..a283cc4 100644 --- a/s2energy-connection/src/pairing/client.rs +++ b/s2energy-connection/src/pairing/client.rs @@ -14,13 +14,24 @@ use super::NodeConfig; use super::wire::*; use super::{ErrorKind, Network, PairingResult}; +/// Optional node identifier of a remote node +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum RemoteNodeIdentifier { + /// A full UUID node identifier + Id(NodeId), + /// A human-entered alias for an S2 Node + Alias(NodeIdAlias), + /// No identifier + None, +} + /// Remote node to pair with. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct PairingRemote { /// URL at which the remote node can be reached pub url: String, /// S2 node id of the remote node. - pub id: Option, + pub id: RemoteNodeIdentifier, } /// Remote node to pair with. @@ -88,14 +99,19 @@ impl PrePairing<'_> { /// When the callback returns an error, the client will be notified of the error. pub async fn pair( self, - remote_id: Option, pairing_token: &[u8], callback: impl AsyncFnOnce(Pairing) -> Result<(), E>, ) -> PairingResult<()> { async move { trace!("Start pairing after pre-pairing."); self.session - .pair(self.certhash, self.local_deployment, remote_id, pairing_token, callback) + .pair( + self.certhash, + self.local_deployment, + RemoteNodeIdentifier::Id(self.remote_id), + pairing_token, + callback, + ) .await } .instrument(self.span) @@ -471,7 +487,7 @@ impl<'a> V1Session<'a> { self, certhash: Option, local_deployment: Deployment, - id: Option, + id: RemoteNodeIdentifier, pairing_token: &[u8], callback: impl AsyncFnOnce(Pairing) -> Result<(), E>, ) -> PairingResult<()> { @@ -689,20 +705,31 @@ impl<'a> V1Session<'a> { async fn request_pairing( &self, - id: Option, + id: RemoteNodeIdentifier, client_hmac_challenge: &HmacChallenge, ) -> PairingResult { - let request = RequestPairing { + let bare_request = RequestPairing { node_description: self.config.node_description.clone(), endpoint_description: self.endpoint_description.clone(), id: None, - id_alias: id, + id_alias: None, supported_protocols: self.config.supported_communication_protocols.clone(), supported_versions: self.config.supported_message_versions.clone(), supported_hashing_algorithms: vec![HmacHashingAlgorithm::Sha256], client_hmac_challenge: client_hmac_challenge.clone(), force_pairing: false, }; + let request = match id { + RemoteNodeIdentifier::Id(node_id) => RequestPairing { + id: Some(node_id), + ..bare_request + }, + RemoteNodeIdentifier::Alias(node_id_alias) => RequestPairing { + id_alias: Some(node_id_alias), + ..bare_request + }, + RemoteNodeIdentifier::None => bare_request, + }; let response = self .client .post(self.base_url.join("requestPairing").unwrap()) @@ -758,7 +785,7 @@ mod tests { common::wire::test::{UUID_A, UUID_B, basic_node_description, pairing_s2_node_id}, pairing::{ Client, ClientConfig, ErrorKind, LongpollHandler, Longpoller, Network, NodeConfig, NoopPrePairingHandler, Pairing, - PairingRemote, PairingRole, PairingToken, PrePairingHandler, PrePairingResponse, Server, ServerConfig, + PairingRemote, PairingRole, PairingToken, PrePairingHandler, PrePairingResponse, RemoteNodeIdentifier, Server, ServerConfig, client::PrePairingRemote, wire::{ FinalizePairingRequest, HmacChallenge, HmacChallengeResponse, PairingAttemptId, PairingResponseErrorMessage, @@ -922,7 +949,7 @@ mod tests { let addr = server_handle.listening().await.unwrap(); let remote = PairingRemote { url: format!("https://localhost:{}/", addr.port()), - id: Some(pairing_s2_node_id()), + id: RemoteNodeIdentifier::Alias(pairing_s2_node_id()), }; let client = Client::new(ClientConfig { @@ -966,7 +993,7 @@ mod tests { let addr = server_handle.listening().await.unwrap(); let remote = PairingRemote { url: format!("https://localhost:{}/", addr.port()), - id: Some(pairing_s2_node_id()), + id: RemoteNodeIdentifier::Id(UUID_A.into()), }; let client = Client::new(ClientConfig { @@ -1064,7 +1091,7 @@ mod tests { let client_prepair = client.prepair(&client_config, remote).await.unwrap(); let (tx, rx) = tokio::sync::oneshot::channel(); client_prepair - .pair(Some(pairing_s2_node_id()), b"testtoken", async |pairing| { + .pair(b"testtoken", async |pairing| { tx.send(pairing).unwrap(); Ok::<_, std::io::Error>(()) }) @@ -1213,7 +1240,7 @@ mod tests { let addr = server_handle.listening().await.unwrap(); let remote = PairingRemote { url: format!("https://localhost:{}/", addr.port()), - id: Some(pairing_s2_node_id()), + id: RemoteNodeIdentifier::Alias(pairing_s2_node_id()), }; let client = Client::new(ClientConfig { @@ -1280,7 +1307,7 @@ mod tests { let addr = server_handle.listening().await.unwrap(); let remote = PairingRemote { url: format!("https://localhost:{}/", addr.port()), - id: Some(pairing_s2_node_id()), + id: RemoteNodeIdentifier::Alias(pairing_s2_node_id()), }; let client = Client::new(ClientConfig { @@ -1343,7 +1370,7 @@ mod tests { let addr = server_handle.listening().await.unwrap(); let remote = PairingRemote { url: format!("https://localhost:{}/", addr.port()), - id: Some(pairing_s2_node_id()), + id: RemoteNodeIdentifier::Alias(pairing_s2_node_id()), }; let client = Client::new(ClientConfig { @@ -1395,7 +1422,7 @@ mod tests { let addr = server_handle.listening().await.unwrap(); let remote = PairingRemote { url: format!("https://localhost:{}/", addr.port()), - id: Some(pairing_s2_node_id()), + id: RemoteNodeIdentifier::Alias(pairing_s2_node_id()), }; let client = Client::new(ClientConfig { @@ -1447,7 +1474,7 @@ mod tests { let addr = server_handle.listening().await.unwrap(); let remote = PairingRemote { url: format!("https://localhost:{}/", addr.port()), - id: Some(pairing_s2_node_id()), + id: RemoteNodeIdentifier::Alias(pairing_s2_node_id()), }; let client = Client::new(ClientConfig { @@ -1497,7 +1524,7 @@ mod tests { let addr = server_handle.listening().await.unwrap(); let remote = PairingRemote { url: format!("https://localhost:{}/", addr.port()), - id: Some(pairing_s2_node_id()), + id: RemoteNodeIdentifier::Alias(pairing_s2_node_id()), }; let client = Client::new(ClientConfig { @@ -1586,7 +1613,7 @@ mod tests { let remote = PairingRemote { url: format!("https://localhost:{}/", addr.port()), - id: Some(pairing_s2_node_id()), + id: RemoteNodeIdentifier::Alias(pairing_s2_node_id()), }; let (tx, rx) = tokio::sync::oneshot::channel(); diff --git a/s2energy-connection/src/pairing/mod.rs b/s2energy-connection/src/pairing/mod.rs index 1a9491d..255e767 100644 --- a/s2energy-connection/src/pairing/mod.rs +++ b/s2energy-connection/src/pairing/mod.rs @@ -46,7 +46,7 @@ //! server. For this, you will also need to know the id of the node, and the URL on which its pairing server is reachable. //! ```rust //! # use std::sync::Arc; -//! # use s2energy_connection::pairing::{Client, ClientConfig, NodeConfig, PairingRemote, NodeIdAlias}; +//! # use s2energy_connection::pairing::{Client, ClientConfig, NodeConfig, PairingRemote, NodeIdAlias, RemoteNodeIdentifier}; //! # use s2energy_connection::{Deployment, MessageVersion, NodeDescription, EndpointDescription, NodeId, Role}; //! # let local_node = NodeConfig::builder(NodeDescription { //! # id: NodeId::new(), @@ -69,7 +69,7 @@ //! //! let pairing_result = client.pair(&local_node, PairingRemote { //! url: "https://remote.example.com".into(), -//! id: Some(NodeIdAlias("test_pairing_id".into())), +//! id: RemoteNodeIdentifier::Alias(NodeIdAlias("test_pairing_id".into())), //! }, b"ABCDEF0123456", async |pairing| { /* do something with pairing */ Ok::<_, std::convert::Infallible>(())}); //! ``` //! @@ -213,7 +213,7 @@ use rand::CryptoRng; use rustls::pki_types::CertificateDer; use wire::{HmacChallenge, HmacChallengeResponse}; -pub use client::{Client, ClientConfig, LongpollHandler, Longpoller, PairingRemote, PrePairing}; +pub use client::{Client, ClientConfig, LongpollHandler, Longpoller, PairingRemote, PrePairing, RemoteNodeIdentifier}; pub use error::{ConfigError, Error, ErrorKind}; pub use server::{ LongpollingHandle, NoopPrePairingHandler, PairingToken, PairingTokenError, PrePairingHandler, PrePairingResponse, Server, ServerConfig, From 32d1e5c412b5fa2cba4da69a10e49296fb7c9faa Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Mon, 13 Apr 2026 08:49:00 +0200 Subject: [PATCH 05/15] Fix missing handling of local certificates in communication client. --- .../src/communication/client.rs | 39 ++++++++++++------- .../src/communication/transport.rs | 10 +++++ 2 files changed, 34 insertions(+), 15 deletions(-) diff --git a/s2energy-connection/src/communication/client.rs b/s2energy-connection/src/communication/client.rs index 7edcf11..aa4b66b 100644 --- a/s2energy-connection/src/communication/client.rs +++ b/s2energy-connection/src/communication/client.rs @@ -11,7 +11,7 @@ use crate::{ common::negotiate_version, communication::{ CommunicationResult, ConnectionInfo, Error, ErrorKind, NodeConfig, WebSocketTransport, - transport::hash_checking_http_client, + transport::{hash_checking_http_client, hash_checking_verifier}, wire::{ CommunicationDetails, CommunicationDetailsErrorMessage, InitiateConnectionRequest, InitiateConnectionResponse, UnpairRequest, }, @@ -130,14 +130,17 @@ impl Client { #[tracing::instrument(skip_all, fields(client = %pairing.client_id(), server = %pairing.server_id()), level = tracing::Level::ERROR)] pub async fn connect(&self, mut pairing: impl ClientPairing) -> CommunicationResult { trace!("Establishing new communication connection."); - let client = reqwest::Client::builder() - .tls_certs_merge( - self.additional_certificates - .iter() - .filter_map(|v| reqwest::Certificate::from_der(v).ok()), - ) - .build() - .map_err(|e| Error::new(ErrorKind::TransportFailed, e))?; + let client = match pairing.certificate_hash() { + Some(hash) => hash_checking_http_client(hash)?, + None => reqwest::Client::builder() + .tls_certs_merge( + self.additional_certificates + .iter() + .filter_map(|v| reqwest::Certificate::from_der(v).ok()), + ) + .build() + .map_err(|e| Error::new(ErrorKind::TransportFailed, e))?, + }; trace!("Prepared reqwest client."); @@ -243,7 +246,7 @@ impl Client { trace!("Confirmed new access token to server."); pairing - .set_access_tokens(dbg!(vec![initiate_response.access_token])) + .set_access_tokens(vec![initiate_response.access_token]) .await .map_err(|e| Error::new(ErrorKind::Storage, Box::new(e) as Box<_>))?; @@ -257,10 +260,16 @@ impl Client { tls_config_builder.crypto_provider().clone(), ) .map_err(|e| Error::new(ErrorKind::TransportFailed, e))?; - let tls_config = tls_config_builder - .dangerous() - .with_custom_certificate_verifier(Arc::new(cert_verifier)) - .with_no_client_auth(); + let tls_config = match pairing.certificate_hash() { + Some(root_hash) => tls_config_builder + .dangerous() + .with_custom_certificate_verifier(Arc::new(hash_checking_verifier(root_hash)?)) + .with_no_client_auth(), + None => tls_config_builder + .dangerous() + .with_custom_certificate_verifier(Arc::new(cert_verifier)) + .with_no_client_auth(), + }; let request = ClientRequestBuilder::new( web_socket_communication_details @@ -361,7 +370,7 @@ mod tests { } async fn set_access_token(&mut self, token: crate::AccessToken) -> Result<(), Self::Error> { - *self.token.lock().unwrap() = dbg!(token); + *self.token.lock().unwrap() = token; Ok(()) } diff --git a/s2energy-connection/src/communication/transport.rs b/s2energy-connection/src/communication/transport.rs index b85d59a..c1bac1c 100644 --- a/s2energy-connection/src/communication/transport.rs +++ b/s2energy-connection/src/communication/transport.rs @@ -153,6 +153,16 @@ pub(crate) fn hash_checking_http_client(root_hash: CertificateHash) -> Communica Ok(client) } +pub(crate) fn hash_checking_verifier(root_hash: CertificateHash) -> CommunicationResult { + let rustls_config_builder = rustls::ClientConfig::builder(); + let crypto_provider = rustls_config_builder.crypto_provider().clone(); + Ok(HashedCertificateVerifier { + inner: rustls_platform_verifier::Verifier::new(crypto_provider).map_err(|e| Error::new(ErrorKind::TransportFailed, e))?, + self_signed_state: OnceLock::new(), + root_hash, + }) +} + #[cfg(test)] mod tests { use std::net::{Ipv4Addr, SocketAddr}; From 9eabf845b93f5b00df15fca3930f905a21b4647b Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Mon, 13 Apr 2026 08:54:32 +0200 Subject: [PATCH 06/15] Fix incorrect assignment in discovery with_longpolling_url. --- s2energy-connection/src/discovery/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/s2energy-connection/src/discovery/mod.rs b/s2energy-connection/src/discovery/mod.rs index 4a57a4b..ffa5dbe 100644 --- a/s2energy-connection/src/discovery/mod.rs +++ b/s2energy-connection/src/discovery/mod.rs @@ -220,7 +220,7 @@ impl DiscoverableS2EndpointBuilder { Host::Domain(domain) if (domain.ends_with(".local") || domain.ends_with(".local.")) == matches!(self.deployment, Deployment::Lan) => { - self.pairing_url = Some(longpolling_url); + self.longpolling_url = Some(longpolling_url); Ok(self) } _ => Err(BuilderError::InvalidUrl), From 01c5429ef05b95741c8de4b567307160c4f02c19 Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Mon, 13 Apr 2026 08:55:50 +0200 Subject: [PATCH 07/15] Always use hash certificate verifier when pairing local. --- s2energy-connection/src/pairing/transport.rs | 50 ++++++++------------ 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/s2energy-connection/src/pairing/transport.rs b/s2energy-connection/src/pairing/transport.rs index 2fc122d..082ed91 100644 --- a/s2energy-connection/src/pairing/transport.rs +++ b/s2energy-connection/src/pairing/transport.rs @@ -87,36 +87,28 @@ impl ServerCertVerifier for HashingCertificateVerifier { ocsp_response: &[u8], now: rustls::pki_types::UnixTime, ) -> Result { - match self - .inner - .verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) - { - Ok(v) => Ok(v), - Err(_) => { - let state = self.self_signed_state.get_or_init(|| { - let fallback = CertificateDer::from_slice(&[]); - let root_cert = intermediates.last().unwrap_or(&fallback); - let root_hash = CertificateHash::sha256(root_cert); - let leaf_hash = CertificateHash::sha256(end_entity); - let mut root_store = RootCertStore::empty(); - // conciously ignore errors here, we just want to initialize - root_store.add(root_cert.clone()).ok(); - let verifier = match WebPkiServerVerifier::builder(Arc::new(root_store)).build() { - Ok(verifier) => SelfVerifier::WebPki(Arc::try_unwrap(verifier).unwrap()), - Err(_) => SelfVerifier::None, - }; - - SelfSignedState { - root_hash, - leaf_hash, - verifier, - } - }); - state - .verifier - .verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) + let state = self.self_signed_state.get_or_init(|| { + let fallback = CertificateDer::from_slice(&[]); + let root_cert = intermediates.last().unwrap_or(&fallback); + let root_hash = CertificateHash::sha256(root_cert); + let leaf_hash = CertificateHash::sha256(end_entity); + let mut root_store = RootCertStore::empty(); + // conciously ignore errors here, we just want to initialize + root_store.add(root_cert.clone()).ok(); + let verifier = match WebPkiServerVerifier::builder(Arc::new(root_store)).build() { + Ok(verifier) => SelfVerifier::WebPki(Arc::try_unwrap(verifier).unwrap()), + Err(_) => SelfVerifier::None, + }; + + SelfSignedState { + root_hash, + leaf_hash, + verifier, } - } + }); + state + .verifier + .verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) } fn verify_tls12_signature( From 01faf077d24a5dbeb1aa7dcde2df3de1913a29ff Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Mon, 13 Apr 2026 09:00:01 +0200 Subject: [PATCH 08/15] Properly report error in any failing pairing scenario. --- s2energy-connection/src/pairing/server.rs | 294 +++++++++++++--------- 1 file changed, 181 insertions(+), 113 deletions(-) diff --git a/s2energy-connection/src/pairing/server.rs b/s2energy-connection/src/pairing/server.rs index 6f3ac16..7d60e81 100644 --- a/s2energy-connection/src/pairing/server.rs +++ b/s2energy-connection/src/pairing/server.rs @@ -593,14 +593,6 @@ impl ExpiringPairingState { Some(&mut self.state) } } - - fn into_state(self) -> Option { - if self.start_time.elapsed() > Duration::from_secs(15) { - None - } else { - Some(self.state) - } - } } type AppState = Arc>; @@ -697,22 +689,39 @@ async fn periodic_cleanup(state: Weak>) { } // Active pairing sessions - { + let handlers: Vec<_> = { let mut attempts = state.attempts.lock().unwrap(); - attempts.retain(|_, value| now.duration_since(value.start_time) < SESSION_TIMEOUT + TIMEOUT_SLACK); + attempts + .extract_if(|_, value| now.duration_since(value.start_time) >= SESSION_TIMEOUT + TIMEOUT_SLACK) + .filter_map(|(_, state)| match state.state { + PairingState::Empty => None, + PairingState::Initial(InitialPairingState { sender, .. }) + | PairingState::Complete(CompletePairingState { sender, .. }) => Some(sender), + }) + .collect() + }; + for handler in handlers { + handler.handle(Err(ErrorKind::Timeout.into())).await; } // Open pairing sessions - { + let handlers: Vec<_> = { let mut pending_pairings = state.pending_pairings.lock().unwrap(); let PendingPairings { alias_mappings, open_pairings, .. } = &mut *pending_pairings; - for (_, pairing) in open_pairings.extract_if(|_, value| now.duration_since(value.age) >= ONCEPAIR_TIMEOUT + TIMEOUT_SLACK) { - alias_mappings.remove(&pairing.alias); - } + open_pairings + .extract_if(|_, value| now.duration_since(value.age) >= ONCEPAIR_TIMEOUT + TIMEOUT_SLACK) + .map(|(_, request)| { + alias_mappings.remove(&request.alias); + request.handler + }) + .collect() + }; + for handler in handlers { + handler.handle(Err(ErrorKind::Timeout.into())).await; } drop(state); @@ -940,6 +949,7 @@ async fn v1_request_pairing( } if open_pairing.config.node_description.role == request_pairing.node_description.role { + open_pairing.handler.handle(Err(ErrorKind::RemoteOfSameType.into())).await; return Err(PairingResponseErrorMessage::InvalidCombinationOfRoles); } @@ -952,6 +962,7 @@ async fn v1_request_pairing( } } if !communication_overlap { + open_pairing.handler.handle(Err(ErrorKind::NoSupportedVersion.into())).await; return Err(PairingResponseErrorMessage::IncompatibleCommunicationProtocols); } let mut connection_overlap = false; @@ -962,6 +973,7 @@ async fn v1_request_pairing( } } if !connection_overlap { + open_pairing.handler.handle(Err(ErrorKind::NoSupportedVersion.into())).await; return Err(PairingResponseErrorMessage::IncompatibleS2MessageVersions); } } @@ -1031,52 +1043,73 @@ async fn v1_request_connection_details( return (Err(StatusCode::UNAUTHORIZED), None); }; - if let Some(state_entry) = state.get_state() - && let PairingState::Initial(state) = std::mem::replace(state_entry, PairingState::Empty) - { - // It is ok to manually enter the span here as this closure is not async. - let session_span_clone = state.session_span.clone(); - let _entered_span = session_span_clone.enter(); - - trace!("Found pairing session."); - - let expected = state.challenge.sha256(&app_state.network, &state.token.0); - if expected != req.server_hmac_challenge_response { - attempts.remove(&pairing_attempt_id); - return ( - Err(StatusCode::FORBIDDEN), - Some(state.sender.handle(Err(ErrorKind::InvalidToken.into()))), - ); - } - - trace!("Validated remote's response to pairing token challenge."); - - let mut rng = rand::rng(); - let connection_details = ConnectionDetails { - initiate_connection_url: match &state.config.connection_initiate_url { - Some(url) => url.clone(), - None => return (Err(StatusCode::BAD_REQUEST), None), - }, - access_token: AccessToken::new(&mut rng), - certificate_fingerprint: state.config.root_certificate.as_deref().map(CertificateHash::sha256), - }; + if let Some(state_entry) = state.get_state() { + match std::mem::replace(state_entry, PairingState::Empty) { + PairingState::Empty => { + info!("Pairing session was in unexpected state for requesting connection details (A)."); + attempts.remove(&pairing_attempt_id); + (Err(StatusCode::UNAUTHORIZED), None) + } + PairingState::Complete(complete_pairing_state) => { + info!("Pairing session was in unexpected state for requesting connection details (B)."); + attempts.remove(&pairing_attempt_id); + ( + Err(StatusCode::UNAUTHORIZED), + Some(complete_pairing_state.sender.handle(Err(ErrorKind::ProtocolError.into()))), + ) + } + PairingState::Initial(state) => { + // It is ok to manually enter the span here as this closure is not async. + let session_span_clone = state.session_span.clone(); + let _entered_span = session_span_clone.enter(); + + trace!("Found pairing session."); + + let expected = state.challenge.sha256(&app_state.network, &state.token.0); + if expected != req.server_hmac_challenge_response { + attempts.remove(&pairing_attempt_id); + return ( + Err(StatusCode::FORBIDDEN), + Some(state.sender.handle(Err(ErrorKind::InvalidToken.into()))), + ); + } - trace!("Generated connection details"); + trace!("Validated remote's response to pairing token challenge."); - *state_entry = PairingState::Complete(CompletePairingState { - session_span: state.session_span, - sender: state.sender, - remote_node_description: state.remote_node_description, - remote_endpoint_description: state.remote_endpoint_description, - access_token: connection_details.access_token.clone(), - role: PairingRole::CommunicationServer, - }); + let mut rng = rand::rng(); + let connection_details = ConnectionDetails { + initiate_connection_url: match &state.config.connection_initiate_url { + Some(url) => url.clone(), + None => return (Err(StatusCode::BAD_REQUEST), None), + }, + access_token: AccessToken::new(&mut rng), + certificate_fingerprint: state.config.root_certificate.as_deref().map(CertificateHash::sha256), + }; + + trace!("Generated connection details"); + + *state_entry = PairingState::Complete(CompletePairingState { + session_span: state.session_span, + sender: state.sender, + remote_node_description: state.remote_node_description, + remote_endpoint_description: state.remote_endpoint_description, + access_token: connection_details.access_token.clone(), + role: PairingRole::CommunicationServer, + }); - (Ok(Json(connection_details)), None) + (Ok(Json(connection_details)), None) + } + } } else { - info!("Pairing session was expired, or in unexpected state for requesting connection details."); + info!("Pairing session was expired."); + let action = match std::mem::replace(&mut state.state, PairingState::Empty) { + PairingState::Empty => None, + PairingState::Initial(InitialPairingState { sender, .. }) | PairingState::Complete(CompletePairingState { sender, .. }) => { + Some(sender.handle(Err(ErrorKind::Timeout.into()))) + } + }; attempts.remove(&pairing_attempt_id); - (Err(StatusCode::UNAUTHORIZED), None) + (Err(StatusCode::UNAUTHORIZED), action) } })(); @@ -1103,46 +1136,67 @@ async fn v1_post_connection_details( return (StatusCode::UNAUTHORIZED, None); }; - if let Some(state_entry) = state.get_state() - && let PairingState::Initial(state) = std::mem::replace(state_entry, PairingState::Empty) - { - // It is ok to manually enter the span here as this closure is not async. - let session_span_clone = state.session_span.clone(); - let _entered_span = session_span_clone.enter(); - - trace!("Found pairing session."); - - let expected = state.challenge.sha256(&app_state.network, &state.token.0); - if expected != req.server_hmac_challenge_response { - attempts.remove(&pairing_attempt_id); - return ( - StatusCode::FORBIDDEN, - Some(state.sender.handle(Err(ErrorKind::InvalidToken.into()))), - ); - } + if let Some(state_entry) = state.get_state() { + match std::mem::replace(state_entry, PairingState::Empty) { + PairingState::Empty => { + info!("Pairing session was in unexpected state for posting connection details."); + attempts.remove(&pairing_attempt_id); + (StatusCode::UNAUTHORIZED, None) + } + PairingState::Complete(complete_pairing_state) => { + info!("Pairing session was in unexpected state for posting connection details."); + attempts.remove(&pairing_attempt_id); + ( + StatusCode::UNAUTHORIZED, + Some(complete_pairing_state.sender.handle(Err(ErrorKind::ProtocolError.into()))), + ) + } + PairingState::Initial(state) => { + // It is ok to manually enter the span here as this closure is not async. + let session_span_clone = state.session_span.clone(); + let _entered_span = session_span_clone.enter(); + + trace!("Found pairing session."); + + let expected = state.challenge.sha256(&app_state.network, &state.token.0); + if expected != req.server_hmac_challenge_response { + attempts.remove(&pairing_attempt_id); + return ( + StatusCode::FORBIDDEN, + Some(state.sender.handle(Err(ErrorKind::InvalidToken.into()))), + ); + } - trace!("Validated remote's response to pairing token challenge."); - - // Do better error handling here than unwrap - *state_entry = PairingState::Complete(CompletePairingState { - session_span: state.session_span, - sender: state.sender, - remote_node_description: state.remote_node_description, - remote_endpoint_description: state.remote_endpoint_description, - access_token: req.connection_details.access_token, - role: PairingRole::CommunicationClient { - initiate_url: req.connection_details.initiate_connection_url, - root_hash: req.connection_details.certificate_fingerprint, - }, - }); + trace!("Validated remote's response to pairing token challenge."); + + // Do better error handling here than unwrap + *state_entry = PairingState::Complete(CompletePairingState { + session_span: state.session_span, + sender: state.sender, + remote_node_description: state.remote_node_description, + remote_endpoint_description: state.remote_endpoint_description, + access_token: req.connection_details.access_token, + role: PairingRole::CommunicationClient { + initiate_url: req.connection_details.initiate_connection_url, + root_hash: req.connection_details.certificate_fingerprint, + }, + }); - trace!("Stored received connection details in session state."); + trace!("Stored received connection details in session state."); - (StatusCode::NO_CONTENT, None) + (StatusCode::NO_CONTENT, None) + } + } } else { - info!("Pairing session was expired, or in unexpected state for posting connection details."); + info!("Pairing session was expired."); + let action = match std::mem::replace(&mut state.state, PairingState::Empty) { + PairingState::Empty => None, + PairingState::Initial(InitialPairingState { sender, .. }) | PairingState::Complete(CompletePairingState { sender, .. }) => { + Some(sender.handle(Err(ErrorKind::Timeout.into()))) + } + }; attempts.remove(&pairing_attempt_id); - (StatusCode::UNAUTHORIZED, None) + (StatusCode::UNAUTHORIZED, action) } })(); @@ -1161,7 +1215,7 @@ async fn v1_finalize_pairing( ) -> StatusCode { trace!("Received request to finalize pairing session."); - let Some(state) = ({ + let Some(mut state) = ({ let mut attempts = state.attempts.lock().unwrap(); attempts.remove(&pairing_attempt_id) }) else { @@ -1169,33 +1223,41 @@ async fn v1_finalize_pairing( return StatusCode::UNAUTHORIZED; }; - if let Some(state) = state.into_state() { + if let Some(state) = state.get_state() { let session_span_clone = state.get_session_span().cloned(); let completion = async move { if success { - if let PairingState::Complete(state) = state { - let result = state - .sender - .handle(Ok(Pairing { - remote_endpoint_description: state.remote_endpoint_description, - remote_node_description: state.remote_node_description, - token: state.access_token, - role: state.role, - })) - .await; - - trace!("Finalized pairing session."); - if result.is_ok() { - StatusCode::NO_CONTENT - } else { - StatusCode::INTERNAL_SERVER_ERROR + match std::mem::replace(state, PairingState::Empty) { + PairingState::Empty => { + info!("Remote tried to finalize pairing session in invalid state."); + StatusCode::BAD_REQUEST + } + PairingState::Initial(state) => { + info!("Remote tried to finalize pairing session that did not yet have all the data exchanged."); + state.sender.handle(Err(ErrorKind::ProtocolError.into())).await; + StatusCode::BAD_REQUEST + } + PairingState::Complete(state) => { + let result = state + .sender + .handle(Ok(Pairing { + remote_endpoint_description: state.remote_endpoint_description, + remote_node_description: state.remote_node_description, + token: state.access_token, + role: state.role, + })) + .await; + + trace!("Finalized pairing session."); + if result.is_ok() { + StatusCode::NO_CONTENT + } else { + StatusCode::INTERNAL_SERVER_ERROR + } } - } else { - info!("Remote tried to finalize pairing session that did not yet have all the data exchanged."); - StatusCode::BAD_REQUEST } } else { - match state { + match std::mem::replace(state, PairingState::Empty) { PairingState::Empty => { /* should never happen, but fine to ignore */ } PairingState::Initial(InitialPairingState { sender, .. }) | PairingState::Complete(CompletePairingState { sender, .. }) => { @@ -1215,6 +1277,12 @@ async fn v1_finalize_pairing( } } else { info!("Pairing session was expired during finalization."); + match std::mem::replace(&mut state.state, PairingState::Empty) { + PairingState::Empty => { /* should never happen, but fine to ignore */ } + PairingState::Initial(InitialPairingState { sender, .. }) | PairingState::Complete(CompletePairingState { sender, .. }) => { + sender.handle(Err(ErrorKind::Cancelled.into())).await; + } + } StatusCode::UNAUTHORIZED } } @@ -2018,7 +2086,7 @@ mod tests { .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let body = dbg!(response.into_body().collect().await.unwrap().to_bytes()); + let body = response.into_body().collect().await.unwrap().to_bytes(); let error: PairingResponseErrorMessage = serde_json::from_slice(&body).unwrap(); assert_eq!(error, PairingResponseErrorMessage::InvalidCombinationOfRoles); } From eb03d500ba1e9df875bf048a88bd090f9aa7b12e Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Mon, 13 Apr 2026 09:02:10 +0200 Subject: [PATCH 09/15] Allow explicit waiting for longpolling remote to drop the session. --- s2energy-connection/src/pairing/server.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/s2energy-connection/src/pairing/server.rs b/s2energy-connection/src/pairing/server.rs index 7d60e81..09f6b30 100644 --- a/s2energy-connection/src/pairing/server.rs +++ b/s2energy-connection/src/pairing/server.rs @@ -389,6 +389,7 @@ pub struct LongpollingHandle { node: tokio::sync::watch::Receiver>, last_pairing_response: tokio::sync::watch::Receiver>>, client_id: NodeId, + active: tokio::sync::oneshot::Receiver<()>, } impl LongpollingHandle { @@ -465,6 +466,11 @@ impl LongpollingHandle { .await .map_err(|_| ErrorKind::Cancelled.into()) } + + /// Wait for the session to be dropped by the remote. + pub fn wait_dropped(&mut self) -> impl Future { + poll_fn(|cx| Pin::new(&mut self.active).poll(cx).map(|_| ())) + } } enum LongpollingState { @@ -512,6 +518,7 @@ struct LongpollingStateInner { endpoint: tokio::sync::watch::Sender>, node: tokio::sync::watch::Sender>, last_pairing_response: tokio::sync::watch::Sender>>, + _active_sender: tokio::sync::oneshot::Sender<()>, } /// These don't keep the original errors in the results to limit the amount of boxing needed here. @@ -773,12 +780,14 @@ async fn v1_wait_for_pairing( let (endpoint_sender, endpoint_receiver) = tokio::sync::watch::channel(None); let (node_sender, node_receiver) = tokio::sync::watch::channel(None); let (last_pairing_sender, last_pairing_receiver) = tokio::sync::watch::channel(None); + let (active_tx, active_rx) = tokio::sync::oneshot::channel(); pending_handles.push(LongpollingHandle { commands: commands_sender, endpoint: endpoint_receiver, node: node_receiver, last_pairing_response: last_pairing_receiver, client_id: request.client_node_id, + active: active_rx, }); vacant_entry.insert(LongpollingState::Running { since: Instant::now(), @@ -789,6 +798,7 @@ async fn v1_wait_for_pairing( endpoint: endpoint_sender, node: node_sender, last_pairing_response: last_pairing_sender, + _active_sender: active_tx, } } }; From 135fc47b5c518ee837e9781e89a74617543fe265 Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Mon, 13 Apr 2026 09:04:33 +0200 Subject: [PATCH 10/15] Add convenience function for getting reference to slice from pairingId. --- s2energy-connection/src/pairing/server.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/s2energy-connection/src/pairing/server.rs b/s2energy-connection/src/pairing/server.rs index 09f6b30..1eb4024 100644 --- a/s2energy-connection/src/pairing/server.rs +++ b/s2energy-connection/src/pairing/server.rs @@ -54,6 +54,13 @@ const TIMEOUT_SLACK: Duration = Duration::from_secs(5); #[derive(Debug, Clone)] pub struct PairingToken(pub Box<[u8]>); +impl PairingToken { + /// Get the raw token bytes. + pub fn as_slice(&self) -> &[u8] { + &self.0 + } +} + impl std::fmt::Display for PairingToken { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { Base64Display::new(&self.0, &BASE64_STANDARD).fmt(f) From 642808fb01488fef087e101c31a1cb025ff9b369 Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Mon, 13 Apr 2026 09:11:03 +0200 Subject: [PATCH 11/15] Make communication and pairing server usable with non-mutable references. --- .../examples/communication-server.rs | 2 +- .../src/communication/client.rs | 12 ++++++------ .../src/communication/server.rs | 19 ++++++++++++++----- s2energy-connection/src/pairing/client.rs | 2 +- s2energy-connection/src/pairing/server.rs | 4 ++-- 5 files changed, 24 insertions(+), 15 deletions(-) diff --git a/s2energy-connection/examples/communication-server.rs b/s2energy-connection/examples/communication-server.rs index ec8a9b9..e9f9e2b 100644 --- a/s2energy-connection/examples/communication-server.rs +++ b/s2energy-connection/examples/communication-server.rs @@ -92,7 +92,7 @@ async fn main() { .with(EnvFilter::from_default_env()) .init(); - let mut server = Server::new( + let server = Server::new( ServerConfig { base_url: "localhost:8005".into(), endpoint_description: None, diff --git a/s2energy-connection/src/communication/client.rs b/s2energy-connection/src/communication/client.rs index aa4b66b..7941754 100644 --- a/s2energy-connection/src/communication/client.rs +++ b/s2energy-connection/src/communication/client.rs @@ -517,7 +517,7 @@ mod tests { AccessToken("testtoken".into()), NodeConfig::builder(vec![MessageVersion("v1".into())]).build(), ); - let (handle, mut server) = setup_server(store.clone(), None, Router::new()).await; + let (handle, server) = setup_server(store.clone(), None, Router::new()).await; let addr = handle.listening().await.unwrap(); let client = Client::new( @@ -580,7 +580,7 @@ mod tests { AccessToken("testtoken".into()), NodeConfig::builder(vec![MessageVersion("v1".into())]).build(), ); - let (handle, mut server) = setup_server(store.clone(), None, Router::new()).await; + let (handle, server) = setup_server(store.clone(), None, Router::new()).await; let addr = handle.listening().await.unwrap(); let client = Client::new( @@ -636,7 +636,7 @@ mod tests { AccessToken("testtoken".into()), NodeConfig::builder(vec![MessageVersion("v1".into())]).build(), ); - let (handle, mut server) = setup_server( + let (handle, server) = setup_server( store.clone(), Some(EndpointDescription { name: Some("a".into()), @@ -697,7 +697,7 @@ mod tests { AccessToken("testtoken".into()), NodeConfig::builder(vec![MessageVersion("v1".into())]).build(), ); - let (handle, mut server) = setup_server(store.clone(), None, Router::new()).await; + let (handle, server) = setup_server(store.clone(), None, Router::new()).await; let addr = handle.listening().await.unwrap(); let client = Client::new( @@ -746,7 +746,7 @@ mod tests { AccessToken("testtoken".into()), NodeConfig::builder(vec![MessageVersion("v1".into())]).build(), ); - let (handle, mut server) = setup_server(store.clone(), None, Router::new()).await; + let (handle, server) = setup_server(store.clone(), None, Router::new()).await; let addr = handle.listening().await.unwrap(); let client = Client::new( @@ -967,7 +967,7 @@ mod tests { AccessToken("testtoken".into()), NodeConfig::builder(vec![MessageVersion("v1".into())]).build(), ); - let (handle, mut server) = setup_server(store.clone(), None, Router::new()).await; + let (handle, server) = setup_server(store.clone(), None, Router::new()).await; let addr = handle.listening().await.unwrap(); let client = Client::new( diff --git a/s2energy-connection/src/communication/server.rs b/s2energy-connection/src/communication/server.rs index 4fd23d1..4a5dcc5 100644 --- a/s2energy-connection/src/communication/server.rs +++ b/s2energy-connection/src/communication/server.rs @@ -96,7 +96,16 @@ pub struct ServerConfig { /// Server for handling the S2 Communication establishment subprotocol. pub struct Server { app_state: AppState, - connection_receiver: tokio::sync::mpsc::Receiver<(PairingLookup, ConnectionInfo)>, + connection_receiver: Arc>>, +} + +impl Clone for Server { + fn clone(&self) -> Self { + Self { + app_state: self.app_state.clone(), + connection_receiver: self.connection_receiver.clone(), + } + } } type AppState = Arc>; @@ -166,7 +175,7 @@ impl Server { Server { app_state, - connection_receiver, + connection_receiver: Arc::new(tokio::sync::Mutex::new(connection_receiver)), } } @@ -181,9 +190,9 @@ impl Server { } /// Get the next connection which has been established with the server. - pub async fn next_connection(&mut self) -> (PairingLookup, ConnectionInfo) { + pub async fn next_connection(&self) -> (PairingLookup, ConnectionInfo) { // The other end will always exist. - self.connection_receiver.recv().await.unwrap() + self.connection_receiver.lock().await.recv().await.unwrap() } } @@ -1137,7 +1146,7 @@ mod tests { async fn websocket_handling() { let listener = TcpListener::bind(SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 0)).await.unwrap(); let port = listener.local_addr().unwrap().port(); - let mut server = Server::new( + let server = Server::new( ServerConfig { base_url: format!("localhost:{}", port), endpoint_description: None, diff --git a/s2energy-connection/src/pairing/client.rs b/s2energy-connection/src/pairing/client.rs index a283cc4..d4e5baf 100644 --- a/s2energy-connection/src/pairing/client.rs +++ b/s2energy-connection/src/pairing/client.rs @@ -808,7 +808,7 @@ mod tests { handler: impl PrePairingHandler, overrides: Router<()>, ) -> (Handle, JoinHandle, Server) { - let mut server = Server::new_with_prepairing( + let server = Server::new_with_prepairing( ServerConfig { leaf_certificate: None, endpoint_description: EndpointDescription::default(), diff --git a/s2energy-connection/src/pairing/server.rs b/s2energy-connection/src/pairing/server.rs index 1eb4024..22e89b4 100644 --- a/s2energy-connection/src/pairing/server.rs +++ b/s2energy-connection/src/pairing/server.rs @@ -270,12 +270,12 @@ impl Server { } /// Enable longpolling - pub async fn enable_longpolling(&mut self) { + pub async fn enable_longpolling(&self) { self.longpolling_enabled.send_replace(true); } /// Disable longpolling - pub async fn disable_longpolling(&mut self) { + pub async fn disable_longpolling(&self) { self.longpolling_enabled.send_replace(false); let mut receiver = self.pending_longpolling_handles.lock().await; // Wait until all existing sessions are stopped. We need to drain any new From 70f7183f15b10f9e9a7fe45da7d68fae56f0de63 Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Mon, 13 Apr 2026 09:19:40 +0200 Subject: [PATCH 12/15] Provide a general error type that all errors in the library can be converted to. --- .../src/communication/client.rs | 2 +- .../src/communication/error.rs | 12 +- s2energy-connection/src/communication/mod.rs | 3 +- .../src/communication/server.rs | 2 +- s2energy-connection/src/discovery/error.rs | 8 +- s2energy-connection/src/discovery/mod.rs | 7 +- s2energy-connection/src/error.rs | 344 ++++++++++++++++++ s2energy-connection/src/lib.rs | 1 + s2energy-connection/src/pairing/error.rs | 6 +- s2energy-connection/src/pairing/mod.rs | 15 +- s2energy-connection/src/pairing/wire.rs | 12 +- 11 files changed, 380 insertions(+), 32 deletions(-) create mode 100644 s2energy-connection/src/error.rs diff --git a/s2energy-connection/src/communication/client.rs b/s2energy-connection/src/communication/client.rs index 7941754..e6532a4 100644 --- a/s2energy-connection/src/communication/client.rs +++ b/s2energy-connection/src/communication/client.rs @@ -43,7 +43,7 @@ pub struct Client { /// pairings is handled. pub trait ClientPairing: Send { /// Type of the errors generated by the storage. - type Error: std::error::Error + 'static; + type Error: std::error::Error + Send + 'static; /// The Node ID of the S2 node initiating communication. fn client_id(&self) -> NodeId; diff --git a/s2energy-connection/src/communication/error.rs b/s2energy-connection/src/communication/error.rs index 97a9144..8ae9342 100644 --- a/s2energy-connection/src/communication/error.rs +++ b/s2energy-connection/src/communication/error.rs @@ -8,8 +8,8 @@ use crate::{ /// Error that occurred during the communication subprotocol. #[derive(Debug)] pub struct Error { - kind: ErrorKind, - wrapped_error: WrappedError, + pub(crate) kind: ErrorKind, + pub(crate) wrapped_error: WrappedError, } impl Error { @@ -65,7 +65,7 @@ impl From for Error { } #[derive(Debug)] -enum WrappedError { +pub(crate) enum WrappedError { None, Reqwest(reqwest::Error), Rustls(rustls::Error), @@ -73,7 +73,7 @@ enum WrappedError { UrlParse(url::ParseError), UriParse(http::uri::InvalidUri), Remote(CommunicationDetailsErrorMessage), - Store(Box), + Store(Box), } impl WrappedError { @@ -136,8 +136,8 @@ impl From for WrappedError { } } -impl From> for WrappedError { - fn from(value: Box) -> Self { +impl From> for WrappedError { + fn from(value: Box) -> Self { WrappedError::Store(value) } } diff --git a/s2energy-connection/src/communication/mod.rs b/s2energy-connection/src/communication/mod.rs index 6336e44..43bcb7e 100644 --- a/s2energy-connection/src/communication/mod.rs +++ b/s2energy-connection/src/communication/mod.rs @@ -218,9 +218,10 @@ mod error; mod server; mod transport; mod websocket; -mod wire; +pub(crate) mod wire; pub use client::{Client, ClientConfig, ClientPairing}; +pub(crate) use error::WrappedError; pub use error::{Error, ErrorKind}; pub use server::{PairingLookup, PairingLookupResult, Server, ServerConfig, ServerPairing, ServerPairingStore}; pub use websocket::{WebSocketError, WebSocketTransport}; diff --git a/s2energy-connection/src/communication/server.rs b/s2energy-connection/src/communication/server.rs index 4a5dcc5..0d9d65e 100644 --- a/s2energy-connection/src/communication/server.rs +++ b/s2energy-connection/src/communication/server.rs @@ -54,7 +54,7 @@ pub enum PairingLookupResult { /// Storage provider for the pairings a communication server handles pub trait ServerPairingStore: Sync + Send + 'static { /// Type of the errors generated by this storage provider. - type Error: std::error::Error; + type Error: std::error::Error + Send + 'static; /// Type of the pairings provided by this storage provider. type Pairing<'a>: ServerPairing + 'a where diff --git a/s2energy-connection/src/discovery/error.rs b/s2energy-connection/src/discovery/error.rs index c9f5bc1..a016a70 100644 --- a/s2energy-connection/src/discovery/error.rs +++ b/s2energy-connection/src/discovery/error.rs @@ -1,7 +1,8 @@ +/// Error that occurs during discovery. #[derive(Debug)] pub struct Error { - kind: ErrorKind, - wrapped_error: WrappedError, + pub(crate) kind: ErrorKind, + pub(crate) wrapped_error: WrappedError, } impl Error { @@ -16,6 +17,7 @@ impl Error { } } + /// Indiciation of what kind of error occurred. pub fn kind(&self) -> ErrorKind { self.kind } @@ -47,7 +49,7 @@ impl From for Error { } #[derive(Debug)] -enum WrappedError { +pub(crate) enum WrappedError { None, Zeroconf(zeroconf_tokio::error::Error), } diff --git a/s2energy-connection/src/discovery/mod.rs b/s2energy-connection/src/discovery/mod.rs index ffa5dbe..07e238e 100644 --- a/s2energy-connection/src/discovery/mod.rs +++ b/s2energy-connection/src/discovery/mod.rs @@ -23,10 +23,9 @@ use zeroconf_tokio::{ prelude::{TMdnsBrowser, TMdnsService, TTxtRecord}, }; -use crate::{ - Deployment, Role, - discovery::error::{Error, ErrorKind}, -}; +use crate::{Deployment, Role}; +pub(crate) use error::WrappedError; +pub use error::{Error, ErrorKind}; /// Error that occurred during the process of creating a [`DiscoverableS2Endpoint`]. #[derive(Debug, Clone, Error, Eq, PartialEq)] diff --git a/s2energy-connection/src/error.rs b/s2energy-connection/src/error.rs new file mode 100644 index 0000000..eb4e0fc --- /dev/null +++ b/s2energy-connection/src/error.rs @@ -0,0 +1,344 @@ +//! Combined error type for the crate. +//! +//! Pairing, communication, and discovery are mostly disjoint, and each have +//! fairly different error types. Therefore, we have individual error types for +//! each. However, when creating a device that combines multiple of these +//! functions, it can be useful to have a single error type that combines all +//! these errors. This module provides that error type, which is also used by +//! the combined pairing and communication server. +use crate::{ + common::{BaseError, BaseErrorKind, BaseWrappedError}, + communication::{self, wire::CommunicationDetailsErrorMessage}, + discovery, + pairing::{ + self, ConfigError, + wire::{PairingResponseErrorMessage, WaitForPairingErrorMessage}, + }, +}; + +/// An error that occured during the pairing process. +#[derive(Debug)] +pub struct Error { + kind: ErrorKind, + wrapped_error: WrappedError, +} + +impl Error { + /// What kind of error occurred? + pub fn kind(&self) -> ErrorKind { + self.kind + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(inner) = self.wrapped_error.contents() { + write!(f, "{}: {inner}", self.kind) + } else { + self.kind.fmt(f) + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.wrapped_error.contents() + } +} + +impl From for Error { + fn from(kind: ErrorKind) -> Self { + Self { + kind, + wrapped_error: WrappedError::None, + } + } +} + +impl From for Error { + fn from(value: BaseError) -> Self { + Self { + kind: value.kind.into(), + wrapped_error: value.wrapped_error.into(), + } + } +} + +impl From for Error { + fn from(value: pairing::Error) -> Self { + Self { + kind: value.kind.into(), + wrapped_error: value.wrapped_error.into(), + } + } +} + +impl From for Error { + fn from(value: discovery::Error) -> Self { + Self { + kind: value.kind.into(), + wrapped_error: value.wrapped_error.into(), + } + } +} + +impl From for Error { + fn from(value: communication::Error) -> Self { + Self { + kind: value.kind.into(), + wrapped_error: value.wrapped_error.into(), + } + } +} + +#[derive(Debug)] +enum WrappedError { + None, + Reqwest(reqwest::Error), + UrlParse(url::ParseError), + UriParse(http::uri::InvalidUri), + Rustls(rustls::Error), + Tungstenite(tokio_tungstenite::tungstenite::Error), + RemotePairing(PairingResponseErrorMessage), + RemoteCommunication(CommunicationDetailsErrorMessage), + Longpolling(WaitForPairingErrorMessage), + Zeroconf(zeroconf_tokio::error::Error), + Boxed(Box), +} + +impl WrappedError { + fn contents(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::None => None, + Self::Reqwest(error) => Some(error), + Self::UrlParse(error) => Some(error), + Self::UriParse(error) => Some(error), + Self::Rustls(error) => Some(error), + Self::Tungstenite(error) => Some(error), + Self::RemotePairing(error) => Some(error), + Self::RemoteCommunication(error) => Some(error), + Self::Longpolling(error) => Some(error), + Self::Zeroconf(error) => Some(error), + Self::Boxed(error) => Some(error.as_ref()), + } + } +} + +impl From for WrappedError { + fn from(value: BaseWrappedError) -> Self { + match value { + BaseWrappedError::None => Self::None, + BaseWrappedError::Reqwest(error) => Self::Reqwest(error), + } + } +} + +impl From for WrappedError { + fn from(value: pairing::WrappedError) -> Self { + match value { + pairing::WrappedError::None => Self::None, + pairing::WrappedError::Reqwest(error) => Self::Reqwest(error), + pairing::WrappedError::UrlParse(parse_error) => Self::UrlParse(parse_error), + pairing::WrappedError::Rustls(error) => Self::Rustls(error), + pairing::WrappedError::Remote(pairing_response_error_message) => Self::RemotePairing(pairing_response_error_message), + pairing::WrappedError::Longpolling(wait_for_pairing_error_message) => Self::Longpolling(wait_for_pairing_error_message), + pairing::WrappedError::Boxed(error) => Self::Boxed(error), + } + } +} + +impl From for WrappedError { + fn from(value: discovery::WrappedError) -> Self { + match value { + discovery::WrappedError::None => Self::None, + discovery::WrappedError::Zeroconf(error) => Self::Zeroconf(error), + } + } +} + +impl From for WrappedError { + fn from(value: communication::WrappedError) -> Self { + match value { + communication::WrappedError::None => Self::None, + communication::WrappedError::Reqwest(error) => Self::Reqwest(error), + communication::WrappedError::Rustls(error) => Self::Rustls(error), + communication::WrappedError::Tungstenite(error) => Self::Tungstenite(error), + communication::WrappedError::UrlParse(parse_error) => Self::UrlParse(parse_error), + communication::WrappedError::UriParse(invalid_uri) => Self::UriParse(invalid_uri), + communication::WrappedError::Remote(communication_details_error_message) => { + Self::RemoteCommunication(communication_details_error_message) + } + communication::WrappedError::Store(error) => Self::Boxed(error), + } + } +} + +impl From for WrappedError { + fn from(value: reqwest::Error) -> Self { + Self::Reqwest(value) + } +} + +impl From for WrappedError { + fn from(value: url::ParseError) -> Self { + Self::UrlParse(value) + } +} + +impl From for WrappedError { + fn from(value: rustls::Error) -> Self { + Self::Rustls(value) + } +} + +impl From for WrappedError { + fn from(value: PairingResponseErrorMessage) -> Self { + Self::RemotePairing(value) + } +} + +impl From for WrappedError { + fn from(value: WaitForPairingErrorMessage) -> Self { + Self::Longpolling(value) + } +} + +impl From for WrappedError { + fn from(value: zeroconf_tokio::error::Error) -> Self { + WrappedError::Zeroconf(value) + } +} + +impl From> for WrappedError { + fn from(value: Box) -> Self { + Self::Boxed(value) + } +} + +/// Kind of error that occured during the pairing process. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ErrorKind { + /// Invalid URL for remote. + InvalidUrl, + /// Something went wrong in the transport layers. + TransportFailed, + /// The remote reacted outside our expectations. + ProtocolError, + /// No shared version with the remote. + NoSupportedVersion, + /// Unknown S2 Node + UnknownNode, + /// Session timed out. + Timeout, + /// Already have a pending pairing or longpolling session with that node id. + AlreadyPending, + /// Provided token was invalid. + InvalidToken, + /// Provided node alias was invalid. + InvalidNodeAlias, + /// Remote permanently rejects longpolling or querying of node information. + Rejected, + /// The pairing or longpolling session was cancelled. + Cancelled, + /// The remote is of the same type. + RemoteOfSameType, + /// The provided callback returned an error + CallbackFailed, + /// The configuration was invalid. + InvalidConfig(ConfigError), + /// The server configuration was invalid. + InvalidServerConfig, + /// Somehting went wrong with the mDNS protocol handling. + MdnsError, + /// The nodes are no longer paired. + Unpaired, + /// The nodes were not paired. + NotPaired, + /// Storage failed to persist token. + Storage, +} + +impl std::fmt::Display for ErrorKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidUrl => f.write_str("Invalid URL for remote"), + Self::TransportFailed => f.write_str("Could not send or receive protocol message"), + Self::ProtocolError => f.write_str("Unexpected response from remote"), + Self::NoSupportedVersion => f.write_str("No overlap in versions"), + Self::UnknownNode => f.write_str("Requested S2 Node not known to remote"), + Self::Timeout => f.write_str("Timed out"), + Self::AlreadyPending => f.write_str("A pairing or longpolling session for this node is already pending"), + Self::InvalidToken => f.write_str("The token used does not match with that of the remote"), + Self::InvalidNodeAlias => f.write_str("The node alias provided is not valid"), + Self::Rejected => f.write_str("Longpolling was permanently rejected by remote"), + Self::Cancelled => f.write_str("Pairing or longpolling was cancelled by remote"), + Self::RemoteOfSameType => f.write_str("Remote is of same type of us"), + Self::CallbackFailed => f.write_str("Pairing could not be handled by callback"), + Self::InvalidConfig(config_error) => config_error.fmt(f), + Self::InvalidServerConfig => f.write_str(""), + Self::MdnsError => f.write_str("mDNS failed"), + Self::Unpaired => f.write_str("Remote became unpaired from us"), + Self::NotPaired => f.write_str("Remote has no knowledge of previous pairing with us"), + Self::Storage => f.write_str("Storage failed to persist access token"), + } + } +} + +impl From for ErrorKind { + fn from(value: pairing::ErrorKind) -> Self { + match value { + pairing::ErrorKind::InvalidUrl => Self::InvalidUrl, + pairing::ErrorKind::TransportFailed => Self::TransportFailed, + pairing::ErrorKind::ProtocolError => Self::ProtocolError, + pairing::ErrorKind::NoSupportedVersion => Self::NoSupportedVersion, + pairing::ErrorKind::UnknownNode => Self::UnknownNode, + pairing::ErrorKind::Timeout => Self::Timeout, + pairing::ErrorKind::AlreadyPending => Self::AlreadyPending, + pairing::ErrorKind::InvalidToken => Self::InvalidToken, + pairing::ErrorKind::InvalidNodeAlias => Self::InvalidNodeAlias, + pairing::ErrorKind::Rejected => Self::Rejected, + pairing::ErrorKind::Cancelled => Self::Cancelled, + pairing::ErrorKind::RemoteOfSameType => Self::RemoteOfSameType, + pairing::ErrorKind::CallbackFailed => Self::CallbackFailed, + pairing::ErrorKind::InvalidConfig(config_error) => Self::InvalidConfig(config_error), + } + } +} + +impl From for ErrorKind { + fn from(value: discovery::ErrorKind) -> Self { + match value { + discovery::ErrorKind::MdnsError => Self::MdnsError, + } + } +} + +impl From for ErrorKind { + fn from(value: communication::ErrorKind) -> Self { + match value { + communication::ErrorKind::InvalidUrl => Self::InvalidUrl, + communication::ErrorKind::TransportFailed => Self::TransportFailed, + communication::ErrorKind::ProtocolError => Self::ProtocolError, + communication::ErrorKind::NoSupportedVersion => Self::NoSupportedVersion, + communication::ErrorKind::Unpaired => Self::Unpaired, + communication::ErrorKind::NotPaired => Self::NotPaired, + communication::ErrorKind::Storage => Self::Storage, + } + } +} + +impl From for ErrorKind { + fn from(value: BaseErrorKind) -> Self { + match value { + BaseErrorKind::TransportFailed => Self::TransportFailed, + BaseErrorKind::ProtocolError => Self::ProtocolError, + BaseErrorKind::NoSupportedVersion => Self::NoSupportedVersion, + } + } +} + +impl From for ErrorKind { + fn from(value: ConfigError) -> Self { + Self::InvalidConfig(value) + } +} diff --git a/s2energy-connection/src/lib.rs b/s2energy-connection/src/lib.rs index 3b4d71c..498d53c 100644 --- a/s2energy-connection/src/lib.rs +++ b/s2energy-connection/src/lib.rs @@ -13,6 +13,7 @@ pub(crate) mod common; pub mod communication; pub mod discovery; +pub mod error; pub mod pairing; pub use common::wire::{ diff --git a/s2energy-connection/src/pairing/error.rs b/s2energy-connection/src/pairing/error.rs index e016c48..4b7338d 100644 --- a/s2energy-connection/src/pairing/error.rs +++ b/s2energy-connection/src/pairing/error.rs @@ -8,8 +8,8 @@ use crate::{ /// An error that occured during the pairing process. #[derive(Debug)] pub struct Error { - kind: ErrorKind, - wrapped_error: WrappedError, + pub(crate) kind: ErrorKind, + pub(crate) wrapped_error: WrappedError, } impl Error { @@ -65,7 +65,7 @@ impl From for Error { } #[derive(Debug)] -enum WrappedError { +pub(crate) enum WrappedError { None, Reqwest(reqwest::Error), UrlParse(url::ParseError), diff --git a/s2energy-connection/src/pairing/mod.rs b/s2energy-connection/src/pairing/mod.rs index 255e767..fce624c 100644 --- a/s2energy-connection/src/pairing/mod.rs +++ b/s2energy-connection/src/pairing/mod.rs @@ -206,7 +206,7 @@ mod client; mod error; mod server; mod transport; -mod wire; +pub(crate) mod wire; use rand::CryptoRng; @@ -214,6 +214,7 @@ use rustls::pki_types::CertificateDer; use wire::{HmacChallenge, HmacChallengeResponse}; pub use client::{Client, ClientConfig, LongpollHandler, Longpoller, PairingRemote, PrePairing, RemoteNodeIdentifier}; +pub(crate) use error::WrappedError; pub use error::{ConfigError, Error, ErrorKind}; pub use server::{ LongpollingHandle, NoopPrePairingHandler, PairingToken, PairingTokenError, PrePairingHandler, PrePairingResponse, Server, ServerConfig, @@ -228,11 +229,11 @@ use crate::{ /// Full description of an S2 node. #[derive(Debug, Clone)] pub struct NodeConfig { - node_description: NodeDescription, - supported_message_versions: Vec, - supported_communication_protocols: Vec, - connection_initiate_url: Option, - root_certificate: Option>, + pub(crate) node_description: NodeDescription, + pub(crate) supported_message_versions: Vec, + pub(crate) supported_communication_protocols: Vec, + pub(crate) connection_initiate_url: Option, + pub(crate) root_certificate: Option>, } impl NodeConfig { @@ -393,7 +394,7 @@ impl HmacChallenge { Self(bytes) } - pub fn sha256(&self, network: &Network, pairing_token: &[u8]) -> HmacChallengeResponse { + fn sha256(&self, network: &Network, pairing_token: &[u8]) -> HmacChallengeResponse { use hmac::{Hmac, Mac}; use sha2::Sha256; diff --git a/s2energy-connection/src/pairing/wire.rs b/s2energy-connection/src/pairing/wire.rs index f261e9c..843afb1 100644 --- a/s2energy-connection/src/pairing/wire.rs +++ b/s2energy-connection/src/pairing/wire.rs @@ -200,12 +200,12 @@ impl FromRequestParts for PairingAttemptId { #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct RequestPairingResponse { - pub pairing_attempt_id: PairingAttemptId, - pub server_node_description: NodeDescription, - pub server_endpoint_description: EndpointDescription, - pub selected_hmac_hashing_algorithm: HmacHashingAlgorithm, - pub client_hmac_challenge_response: HmacChallengeResponse, - pub server_hmac_challenge: HmacChallenge, + pub(super) pairing_attempt_id: PairingAttemptId, + pub(super) server_node_description: NodeDescription, + pub(super) server_endpoint_description: EndpointDescription, + pub(super) selected_hmac_hashing_algorithm: HmacHashingAlgorithm, + pub(super) client_hmac_challenge_response: HmacChallengeResponse, + pub(super) server_hmac_challenge: HmacChallenge, } #[derive(Serialize, Deserialize)] From 35726fee7c0c65d59eeffab1e5313321e4c50d82 Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Mon, 13 Apr 2026 09:22:13 +0200 Subject: [PATCH 13/15] Minor cleanup of API surface. --- s2energy-connection/src/pairing/client.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/s2energy-connection/src/pairing/client.rs b/s2energy-connection/src/pairing/client.rs index d4e5baf..8f93953 100644 --- a/s2energy-connection/src/pairing/client.rs +++ b/s2energy-connection/src/pairing/client.rs @@ -44,6 +44,7 @@ pub struct PrePairingRemote { } /// Configuration for pairing clients. +#[derive(Debug, Clone)] pub struct ClientConfig { /// Additional roots of trust for TLS connections. Useful when testing during the development of WAN endpoints. /// From 610badab29a937f25cb24401cbed68adb3ad8d56 Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Mon, 13 Apr 2026 09:23:45 +0200 Subject: [PATCH 14/15] Implement combined server for pairing and communication. --- s2energy-connection/src/combined_server.rs | 234 ++++++++++++++++++ .../src/communication/server.rs | 21 ++ s2energy-connection/src/lib.rs | 1 + s2energy-connection/src/pairing/server.rs | 4 + 4 files changed, 260 insertions(+) create mode 100644 s2energy-connection/src/combined_server.rs diff --git a/s2energy-connection/src/combined_server.rs b/s2energy-connection/src/combined_server.rs new file mode 100644 index 0000000..f03fb38 --- /dev/null +++ b/s2energy-connection/src/combined_server.rs @@ -0,0 +1,234 @@ +//! A combined communication and pairing server. +//! +//! This provides a convenient combined server for pairing and communication. +//! An complete example on how to use this can be found in the examples folder. + +use std::sync::Arc; + +use axum::{Router, routing::get}; +use rustls::pki_types::CertificateDer; + +use crate::{ + CommunicationProtocol, EndpointDescription, MessageVersion, NodeDescription, NodeId, + common::root, + communication::{self, ConnectionInfo, PairingLookup, ServerPairingStore}, + error::{Error, ErrorKind}, + pairing::{self, LongpollingHandle, NodeIdAlias, NoopPrePairingHandler, Pairing, PairingToken, PrePairingHandler}, +}; + +/// Extensions to the pairing store for combined servers +pub trait CombinedServerPairingStore: ServerPairingStore { + /// Store the result of a newly negotiated pairing, replacing an existing pairing if present. + fn store(&self, local_node: NodeId, pairing: Pairing) -> impl Future> + Send; +} + +/// Certificate +pub struct ServerCertificates { + /// Leaf certificate + pub leaf_certificate: CertificateDer<'static>, + /// Root certificate + pub root_certificate: CertificateDer<'static>, +} + +/// Configuration for a combined pairing and communication S2 server. +pub struct ServerConfig { + /// URL at which the server is reachable. + pub base_url: String, + /// The leaf and root certificates of the server, if we are using a self-signed root. + /// Must be present if this is a LAN-deployed endpoint. + pub certificates: Option, + /// Endpoint description of the server + pub endpoint_description: EndpointDescription, + /// Initial set of nodes to advertise. This is only used if the server + /// is deployed on LAN. + pub advertised_nodes: Vec, +} + +/// Combined pairing and communication server +pub struct Server { + pairing: pairing::Server, + communication: communication::Server, + base_url: String, + root_certificate: Option>, +} + +impl Clone for Server { + fn clone(&self) -> Self { + Self { + pairing: self.pairing.clone(), + communication: self.communication.clone(), + base_url: self.base_url.clone(), + root_certificate: self.root_certificate.clone(), + } + } +} + +impl Server { + /// Create a new combined pairing/communication server. + pub fn new(server_config: ServerConfig, store: Store) -> Result { + Self::new_with_prepairing(server_config, NoopPrePairingHandler, store) + } +} + +/// Either a longpolling session or a connection to the server. +pub enum Session { + /// A longpolling connection + Longpolling(LongpollingHandle), + /// A new S2 transport connection + Connection(PairingLookup, ConnectionInfo), +} + +impl Server { + /// Create a new combined pairing/communication server with custom pre-pairing handling. + pub fn new_with_prepairing(server_config: ServerConfig, handler: H, store: Store) -> Result { + if server_config.endpoint_description.deployment == Some(crate::Deployment::Lan) && server_config.certificates.is_none() { + return Err(ErrorKind::InvalidServerConfig.into()); + } + let (leaf_certificate, root_certificate) = match server_config.certificates { + Some(certificates) => (Some(certificates.leaf_certificate), Some(certificates.root_certificate)), + None => (None, None), + }; + Ok(Self { + pairing: pairing::Server::new_with_prepairing( + pairing::ServerConfig { + leaf_certificate, + endpoint_description: server_config.endpoint_description.clone(), + advertised_nodes: server_config.advertised_nodes, + }, + handler, + ), + communication: communication::Server::new( + communication::ServerConfig { + base_url: server_config.base_url.clone(), + endpoint_description: Some(server_config.endpoint_description), + }, + store, + ), + base_url: format!("https://{}", server_config.base_url), + root_certificate, + }) + } + + /// Get an [`axum::Router`] handling the endpoints for the s2 connect protocol. + /// + /// Incomming http requests can be handled by this router through the [axum-server](https://docs.rs/axum-server/0.8.0/axum_server/) crate. + pub fn get_router(&self) -> axum::Router<()> { + Router::new().route("/", get(root)).nest( + "/v1", + self.communication.get_internal_router().merge(self.pairing.get_internal_router()), + ) + } + + /// Update the nodes advertised by this server. + /// + /// These are only used when the server is on a LAN. + pub fn update_advertised_nodes(&self, advertised_nodes: Vec) { + self.pairing.update_advertised_nodes(advertised_nodes); + } + + /// Enable longpolling + pub async fn enable_longpolling(&self) { + self.pairing.enable_longpolling().await; + } + + /// Disable longpolling + pub async fn disable_longpolling(&self) { + self.pairing.disable_longpolling().await; + } + + /// Get a pending longpolling handle. + pub async fn get_longpolling(&self) -> LongpollingHandle { + self.pairing.get_longpolling().await + } + + /// Start a one-time pairing session for the given node using the given token. + /// + /// The callback will receive the result of the pairing attempt. If the server + /// S2 node also becomes server for the communication, it must ensure it is + /// ready to handle the communication requests before returning Ok(()) from + /// the callback. + pub fn allow_pair_once + Send>( + &self, + node_description: NodeDescription, + message_versions: Vec, + pairing_node_id: Option, + pairing_token: PairingToken, + completion_handler: impl (FnOnce(Result<(), Error>) -> F) + Send + 'static, + ) -> Result<(), Error> { + let local_node = node_description.id; + + let config = pairing::NodeConfig { + node_description, + supported_message_versions: message_versions, + supported_communication_protocols: vec![CommunicationProtocol("WebSocket".into())], + connection_initiate_url: Some(self.base_url.clone()), + root_certificate: self.root_certificate.clone(), + }; + + let store = self.communication.store(); + Ok(self.pairing.allow_pair_once( + Arc::new(config), + pairing_node_id, + pairing_token, + async move |pairing_result| match pairing_result { + Ok(pairing) => match store.store(local_node, pairing).await { + Ok(_) => { + completion_handler(Ok(())).await; + Ok(()) + } + Err(error) => { + completion_handler(Err(ErrorKind::Storage.into())).await; + Err(error) + } + }, + Err(error) => { + completion_handler(Err(error.into())).await; + Ok(()) + } + }, + )?) + } + + /// Allow repeated pairing sessions for the given endpoing using the given token. + /// + /// The callback will receive the result of the pairing attempt. If the server + /// S2 node also becomes server for the communication, it must ensure it is + /// ready to handle the communication requests before returning Ok(()) from + /// the callback. + pub fn allow_pair_repeated( + &self, + node_description: NodeDescription, + message_versions: Vec, + pairing_node_id: Option, + pairing_token: PairingToken, + ) -> Result<(), Error> { + let local_node = node_description.id; + + let config = pairing::NodeConfig { + node_description, + supported_message_versions: message_versions, + supported_communication_protocols: vec![CommunicationProtocol("WebSocket".into())], + connection_initiate_url: Some(self.base_url.clone()), + root_certificate: self.root_certificate.clone(), + }; + + let store = self.communication.store(); + Ok(self + .pairing + .allow_pair_repeated(Arc::new(config), pairing_node_id, pairing_token, move |pairing_result| { + let store = store.clone(); + async move { + if let Ok(pairing) = pairing_result { + store.store(local_node, pairing).await + } else { + Ok(()) + } + } + })?) + } + + /// Get the next connection which has been established with the server. + pub async fn next_connection(&self) -> (PairingLookup, ConnectionInfo) { + self.communication.next_connection().await + } +} diff --git a/s2energy-connection/src/communication/server.rs b/s2energy-connection/src/communication/server.rs index 0d9d65e..d4f03f5 100644 --- a/s2energy-connection/src/communication/server.rs +++ b/s2energy-connection/src/communication/server.rs @@ -189,6 +189,27 @@ impl Server { .with_state(self.app_state.clone()) } + pub(crate) fn get_internal_router(&self) -> axum::Router<()> { + v1_router().with_state(self.app_state.clone()) + } + + pub(crate) fn store(&self) -> impl std::ops::Deref + Clone + Send + 'static { + struct StateWrapper(AppState); + impl std::ops::Deref for StateWrapper { + type Target = S; + + fn deref(&self) -> &Self::Target { + &self.0.store + } + } + impl Clone for StateWrapper { + fn clone(&self) -> Self { + Self(self.0.clone()) + } + } + StateWrapper(self.app_state.clone()) + } + /// Get the next connection which has been established with the server. pub async fn next_connection(&self) -> (PairingLookup, ConnectionInfo) { // The other end will always exist. diff --git a/s2energy-connection/src/lib.rs b/s2energy-connection/src/lib.rs index 498d53c..3e4603b 100644 --- a/s2energy-connection/src/lib.rs +++ b/s2energy-connection/src/lib.rs @@ -10,6 +10,7 @@ //! in the examples folder of this crate. #![warn(missing_docs)] +pub mod combined_server; pub(crate) mod common; pub mod communication; pub mod discovery; diff --git a/s2energy-connection/src/pairing/server.rs b/s2energy-connection/src/pairing/server.rs index 22e89b4..51b861a 100644 --- a/s2energy-connection/src/pairing/server.rs +++ b/s2energy-connection/src/pairing/server.rs @@ -262,6 +262,10 @@ impl Server { .with_state(self.state.clone()) } + pub(crate) fn get_internal_router(&self) -> axum::Router<()> { + v1_router().with_state(self.state.clone()) + } + /// Update the nodes advertised by this server. /// /// These are only used when the server is on a LAN. From c4f208c2384d2865d5366012b33e0c56164903b7 Mon Sep 17 00:00:00 2001 From: David Venhoek Date: Mon, 13 Apr 2026 09:26:59 +0200 Subject: [PATCH 15/15] Implemented full client and server examples. --- Cargo.lock | 499 +++++++++++++++-- Cargo.toml | 4 +- README.md | 17 + s2energy-connection/Cargo.toml | 2 + s2energy-connection/examples/full-client.rs | 578 ++++++++++++++++++++ s2energy-connection/examples/full-server.rs | 577 +++++++++++++++++++ 6 files changed, 1640 insertions(+), 37 deletions(-) create mode 100644 s2energy-connection/examples/full-client.rs create mode 100644 s2energy-connection/examples/full-server.rs diff --git a/Cargo.lock b/Cargo.lock index a834e4e..06e7f85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "arc-swap" version = "1.8.1" @@ -35,6 +85,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -200,7 +256,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.114", + "syn 2.0.117", "which", ] @@ -223,7 +279,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.114", + "syn 2.0.117", "which", ] @@ -264,7 +320,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -353,6 +409,46 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.1", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.57" @@ -362,6 +458,12 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -372,6 +474,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -407,6 +518,34 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "futures-core", + "mio", + "parking_lot", + "rustix 1.1.3", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -461,7 +600,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -483,7 +622,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -539,6 +678,28 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + [[package]] name = "digest" version = "0.10.7" @@ -558,7 +719,16 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", ] [[package]] @@ -657,6 +827,21 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -664,6 +849,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -672,6 +858,23 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + [[package]] name = "futures-macro" version = "0.3.31" @@ -680,7 +883,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -701,15 +904,27 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", ] +[[package]] +name = "generational-box" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "557cf2cbacd0504c6bf8c29f52f8071e0de1d9783346713dc6121d7fa1e5d0e0" +dependencies = [ + "parking_lot", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -754,6 +969,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "grid" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be136d9dacc2a13cc70bb6c8f902b414fb2641f8db1314637c6b7933411a8f82" + [[package]] name = "h2" version = "0.4.13" @@ -1089,6 +1310,33 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "iocraft" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cc99ec2f29c9c5f0ae7862dd6f12b93c9122282c9fe151e8648a9cc7301aa3f" +dependencies = [ + "bitflags", + "crossterm", + "futures", + "generational-box", + "iocraft-macros", + "taffy", + "unicode-width", +] + +[[package]] +name = "iocraft-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2a8b12929295d5fea225570fd69a41084e2949d56ce0c20ff22494ef13e0d56" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "uuid", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1105,6 +1353,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.12.1" @@ -1196,12 +1450,33 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -1254,6 +1529,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1292,12 +1568,41 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "peeking_take_while" version = "0.1.2" @@ -1347,14 +1652,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -1417,9 +1722,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.43" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1459,6 +1764,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -1564,6 +1878,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -1573,10 +1896,23 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.36" @@ -1680,12 +2016,14 @@ dependencies = [ "axum-extra", "axum-server", "base64", + "clap", "futures-util", "generic-array", "hmac", "http", "http-body-util", "hyper", + "iocraft", "pin-project-lite", "rand", "reqwest", @@ -1723,7 +2061,7 @@ dependencies = [ "semver", "serde", "serde_json", - "syn 2.0.114", + "syn 2.0.117", "thiserror 2.0.18", "tracing", "typify", @@ -1769,9 +2107,15 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.114", + "syn 2.0.117", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "security-framework" version = "3.6.0" @@ -1832,7 +2176,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1843,7 +2187,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1879,7 +2223,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1931,6 +2275,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1947,6 +2312,15 @@ version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -2000,9 +2374,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -2026,7 +2400,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2050,6 +2424,19 @@ dependencies = [ "libc", ] +[[package]] +name = "taffy" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb893bff0f80ae17d3a57e030622a967b8dbc90e38284d9b4b1442e23873c94" +dependencies = [ + "arrayvec", + "grid", + "num-traits", + "serde", + "slotmap", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2076,7 +2463,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2087,7 +2474,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2148,7 +2535,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2256,7 +2643,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2354,7 +2741,7 @@ dependencies = [ "semver", "serde", "serde_json", - "syn 2.0.114", + "syn 2.0.117", "thiserror 2.0.18", "unicode-ident", ] @@ -2372,7 +2759,7 @@ dependencies = [ "serde", "serde_json", "serde_tokenstream", - "syn 2.0.114", + "syn 2.0.117", "typify-impl", ] @@ -2382,6 +2769,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "untrusted" version = "0.9.0" @@ -2412,6 +2811,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.19.0" @@ -2516,7 +2921,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2585,9 +2990,25 @@ dependencies = [ "either", "home", "once_cell", - "rustix", + "rustix 0.38.44", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2597,6 +3018,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -2618,7 +3045,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2629,7 +3056,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2920,7 +3347,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -2947,7 +3374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b52318880bad5d5a081f7ac4251b5144e73dfa859cbcd10562d9433eeed6b72" dependencies = [ "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2979,7 +3406,7 @@ checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2999,7 +3426,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -3039,7 +3466,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8c686bc..ffb3589 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,10 +40,12 @@ schemars = "0.8.22" syn = { version = "2.0.106", features = ["fold"] } typify = "0.5.0" -# Development +# Development and examples axum-server = { version = "0.8.0", features = ["tls-rustls"] } +clap = { version = "4.6.0", features = ["derive"] } eyre = "0.6.12" http-body-util = "0.1.3" +iocraft = "0.8.0" tower = "0.5.3" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } diff --git a/README.md b/README.md index f933f2f..1ffac48 100644 --- a/README.md +++ b/README.md @@ -27,5 +27,22 @@ These crates require the avahi client libraries on linux. On debian or debian-li sudo apt install libavahi-client-dev ``` +## Running full client and server examples + +The full-client and full-server examples provide complete examples of an s2-connect client and server. Both examples assume local running, which means that the server example needs certificates for the hostname of the machine it is being run on. To generate this, run +```sh +cd s2energy-connection/testdata +./gen_cert .local +``` + +After generating the certificates, the server can be run with +```sh +cargo run --example full-server -- +``` +and the client with +```sh +cargo run --example full-client +``` + ## Documentation You can find the crate documentation at [docs.rs](https://docs.rs/s2energy). The crate documentation assumes that you are familiar with S2; if this is not the case, it may be useful to refer to [the S2 documentation website](https://docs.s2standard.org/docs/welcome/). That documentation explains S2 concepts in more detail, and contains a reference of all messages and types in the S2 specification. diff --git a/s2energy-connection/Cargo.toml b/s2energy-connection/Cargo.toml index ea1de77..b94a23a 100644 --- a/s2energy-connection/Cargo.toml +++ b/s2energy-connection/Cargo.toml @@ -33,7 +33,9 @@ zeroconf-tokio.workspace = true [dev-dependencies] axum-server.workspace = true +clap.workspace = true http-body-util.workspace = true +iocraft.workspace = true tokio = { workspace = true, features = ["signal", "macros", "rt", "test-util"] } tower.workspace = true tracing-subscriber.workspace = true diff --git a/s2energy-connection/examples/full-client.rs b/s2energy-connection/examples/full-client.rs new file mode 100644 index 0000000..99aa2b3 --- /dev/null +++ b/s2energy-connection/examples/full-client.rs @@ -0,0 +1,578 @@ +use std::{collections::HashSet, sync::Arc, time::Duration}; + +use iocraft::prelude::*; +use s2energy_common::S2Transport; +use s2energy_connection::{ + AccessToken, CertificateHash, Deployment, EndpointDescription, MessageVersion, NodeDescription, NodeId, Role, + communication::{self, ClientPairing, ConnectionInfo}, + discovery::{DiscoverableS2Endpoint, DiscoveryEvent, S2Discoverer}, + pairing::{self, LongpollHandler, PairingRemote, PairingToken, RemoteNodeIdentifier}, +}; +use tokio::sync::mpsc::UnboundedSender; +use tracing_subscriber::{ + EnvFilter, + fmt::{self, MakeWriter}, +}; + +#[derive(Default, Props)] +struct DiscoveryViewProps<'a> { + width: u16, + height: u16, + focus: bool, + discovered_nodes: Vec<(&'a str, Option<&'a str>, &'a NodeDescription)>, + want_pairing: Handler, +} + +#[component] +fn DiscoveryView<'a>(mut hooks: Hooks, props: &mut DiscoveryViewProps<'a>) -> impl Into> { + let mut selected_discovery = hooks.use_state(|| 0usize); + let focus = props.focus; + + if selected_discovery.get() != 0 && selected_discovery.get() > props.discovered_nodes.len() { + selected_discovery.set(props.discovered_nodes.len()); + } + + hooks.use_terminal_events({ + move |event| match event { + TerminalEvent::Key(KeyEvent { code, kind, .. }) if kind != KeyEventKind::Release => match code { + KeyCode::Up if focus => selected_discovery.set(selected_discovery.get().saturating_sub(1)), + KeyCode::Down if focus => selected_discovery.set(selected_discovery.get().saturating_add(1)), + _ => {} + }, + _ => {} + } + }); + + element! { + View( + width: props.width, + height: props.height, + border_style: if props.focus { BorderStyle::Double } else { BorderStyle::Single }, + flex_direction: FlexDirection::Column, + ) { + Text(content: "Discovered CEMs:", weight: Weight::Bold) + ScrollView() { + View( + width: props.width, + flex_direction: FlexDirection::Column, + ) { + #(props.discovered_nodes.iter().enumerate().map(|(i, (hostname, pairing_url, node))| { + let handler = if let Some(remote) = pairing_url.map(|url| PairingRemote{url: url.to_owned(), id: RemoteNodeIdentifier::Id(node.id)}) { + props.want_pairing.bind(remote) + } else { + Handler::default() + }; + let selected = props.focus && i == selected_discovery.get(); + element! { + Button(has_focus: selected, handler) { + View(width: props.width, background_color: if selected { Color::White } else { Color::Black }) { + Text(content: format!("{} ({} from {}) at {}", node.id, node.model_name, node.brand, *hostname), color: if selected { Color::Black } else { Color::White }) + } + } + } + })) + } + } + } + } +} + +#[derive(Default, Props)] +struct ClientUIProps { + discovered_clients: Vec<(String, (DiscoverableS2Endpoint, EndpointDescription, Vec))>, + initiate_pairing: HandlerMut<'static, PairingTrigger>, + reconnect: HandlerMut<'static, ()>, + send_message: HandlerMut<'static, ()>, + unpair: HandlerMut<'static, ()>, + log: String, + token: String, + have_pairing: bool, +} + +#[component] +fn ClientUI<'a>(mut hooks: Hooks, props: &'a mut ClientUIProps) -> impl Into> { + let (width, height) = hooks.use_terminal_size(); + let mut focus_top = hooks.use_state(|| true); + let mut want_pair_with = hooks.use_state(|| None); + let mut token = hooks.use_state(|| String::default()); + let mut last_error = hooks.use_state(|| ""); + + let mut initiate_pairing = props.initiate_pairing.take(); + let mut reconnect = props.reconnect.take(); + let mut send_message = props.send_message.take(); + let mut unpair = props.unpair.take(); + hooks.use_terminal_events({ + move |event| match event { + TerminalEvent::Key(KeyEvent { code, kind, .. }) if kind != KeyEventKind::Release => match code { + KeyCode::Tab => focus_top.set(!focus_top.get()), + KeyCode::Enter => { + if token.read().len() != 0 + && let Ok(token) = token.read().parse() + { + match want_pair_with.write().take() { + Some(remote) => initiate_pairing(PairingTrigger { remote, token }), + None => {} + } + } else if token.read().len() != 0 && want_pair_with.read().is_some() { + last_error.set("Invalid token"); + } + } + KeyCode::Esc => { + want_pair_with.write().take(); + } + KeyCode::Char('r') if want_pair_with.read().is_none() => reconnect(()), + KeyCode::Char('s') if want_pair_with.read().is_none() => send_message(()), + KeyCode::Char('u') if want_pair_with.read().is_none() => unpair(()), + _ => {} + }, + _ => {} + } + }); + + let discovered_nodes: Vec<_> = props + .discovered_clients + .iter() + .flat_map(|(hostname, (endpoint, _, nodes))| nodes.iter().map(|node| (hostname.as_str(), endpoint.pairing_url(), node))) + .collect(); + + element! { + View( + width, + height, + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Stretch, + ) { + DiscoveryView( + width, + height: height - (height/2 + 1), + focus: want_pair_with.read().is_none() && focus_top.get(), + discovered_nodes, + want_pairing: move |remote| { want_pair_with.clone().set(Some(remote)); token.clone().set(String::default()); }, + ) + View( + width, + height: height/2, + border_style: if want_pair_with.read().is_none() && !focus_top.get() { BorderStyle::Double } else { BorderStyle::Single }, + ) { + ScrollView ( + auto_scroll: true, + keyboard_scroll: want_pair_with.read().is_none() && !focus_top.get(), + ) { + Text(content: &props.log) + } + } + #(if props.have_pairing { + element! { + View( + width, + height: 1, + flex_direction: FlexDirection::Row, + justify_content: JustifyContent::SpaceBetween, + ) { + Text(content: "(R)econnect") + Text(content: "(S)end message") + Text(content: "(U)npair") + Text(content: format!("Our token: {}", props.token)) + } + } + } else { + element! { + View( + width, + height: 1, + flex_direction: FlexDirection::Row, + justify_content: JustifyContent::SpaceBetween, + ) { + Text(content: "Start pairing by selecting a CEM with the arrow keys and pressing enter.") + Text(content: format!("Our token: {}", props.token)) + } + } + }) + #(if want_pair_with.read().is_some() { + element! { + View( + position: Position::Absolute, + flex_direction: FlexDirection::Column, + top: 5, + left: 5, + bottom: 5, + right: 5, + border_style: BorderStyle::Double, + background_color: Color::Black, + ) { + Text(content: "Token:") + View( width: 30, height: 1, background_color: Color::DarkGrey, ) { + TextInput( + has_focus: true, + value: token.to_string(), + on_change: move |new_token| token.set(new_token), + ) + } + Text(content: last_error.get()) + } + } + } else { + element! {View (display: Display::None) {}} + }) + } + } +} + +struct PairingData { + client_id: NodeId, + server_id: NodeId, + communication_url: String, + access_tokens: std::sync::Mutex>, + certificate_hash: Option, +} + +#[derive(Clone)] +struct Pairing(Arc); + +impl ClientPairing for Pairing { + type Error = std::convert::Infallible; + + fn client_id(&self) -> NodeId { + self.0.client_id + } + + fn server_id(&self) -> NodeId { + self.0.server_id + } + + fn access_tokens(&self) -> impl AsRef<[AccessToken]> { + self.0.access_tokens.lock().unwrap().clone() + } + + fn communication_url(&self) -> impl AsRef { + &self.0.communication_url + } + + fn certificate_hash(&self) -> Option { + self.0.certificate_hash.clone() + } + + async fn set_access_tokens(&mut self, tokens: Vec) -> Result<(), Self::Error> { + *self.0.access_tokens.lock().unwrap() = tokens; + Ok(()) + } +} + +struct PairingTrigger { + remote: PairingRemote, + token: PairingToken, +} + +struct LogWriter(State); +impl<'a> MakeWriter<'a> for LogWriter { + type Writer = &'a Self; + + fn make_writer(&'a self) -> Self::Writer { + self + } +} + +impl std::io::Write for &LogWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.clone().write().push_str(&String::from_utf8_lossy(buf)); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[component] +fn Root(mut hooks: Hooks) -> impl Into> { + let token = hooks.use_state(|| PairingToken::new_static()); + let client_config = hooks.use_state(|| pairing::ClientConfig { + additional_certificates: vec![], + endpoint_description: EndpointDescription::default(), + pairing_deployment: Deployment::Lan, + }); + + let node_description = hooks.use_state(|| NodeDescription { + id: NodeId::new(), + brand: String::from("test"), + logo_url: None, + type_: String::from("fancy"), + model_name: String::from("test client"), + user_defined_name: None, + role: Role::Rm, + }); + + let pairing_node_config = hooks.use_state(|| { + pairing::NodeConfig::builder(node_description.read().clone(), vec![MessageVersion("v1".into())]) + .build() + .unwrap() + }); + + let mut discovered_clients = hooks.use_state(|| vec![]); + + let mut log = hooks.use_state(|| String::default()); + + hooks.use_state(|| { + fmt::fmt() + .with_writer(LogWriter(log)) + .with_env_filter(EnvFilter::from_default_env()) + .init() + }); + + let mut pairing_channel = hooks.use_state(|| tokio::sync::watch::channel::>(None)); + let mut pairing_trigger_channel = hooks.use_state(|| { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::(); + (tx, Some(rx)) + }); + let mut active_longpollers = hooks.use_state(|| HashSet::new()); + + // Handle longpolling + let handle_longpolling_url = hooks.use_async_handler(move |url: String| async move { + if !active_longpollers.write().insert(url.clone()) { + return; + } + + let client = pairing::Client::new(client_config.read().clone()).expect("Unable to create client"); + match client.longpoller(url.clone()).await { + Ok(longpoller) => { + let pairing_node_config = pairing_node_config.read().clone(); + longpoller.add_node(pairing_node_config).unwrap(); + struct LPHandler(UnboundedSender, String, PairingToken); + impl LongpollHandler for LPHandler { + async fn request_pairing(&mut self, _node: NodeId) -> bool { + self.0 + .send(PairingTrigger { + remote: PairingRemote { + url: self.1.clone(), + id: RemoteNodeIdentifier::None, + }, + token: self.2.clone(), + }) + .unwrap(); + true + } + async fn prepare_pairing(&mut self, _node: NodeId) {} + async fn cancel_prepare_pairing(&mut self, _node: NodeId) {} + } + log.write().push_str(&format!("Starting longpolling with {}.\n", url)); + let channel_sender = pairing_trigger_channel.read().0.clone(); + let token = token.read().clone(); + if let Err(error) = longpoller.run(&mut LPHandler(channel_sender, url.clone(), token)).await { + log.write().push_str(&format!("Error during longpolling with {}: {}\n", url, error)); + } else { + log.write().push_str(&format!("Finished longpolling with {}.\n", url)); + } + } + Err(error) => { + log.write().push_str(&format!("Could not longpoll with {}: {}\n", url, error)); + } + } + + active_longpollers.write().remove(&url); + }); + + // Handle discovery events + hooks.use_future(async move { + let mut discoverer = S2Discoverer::new(Role::Cem).await.unwrap(); + let client = pairing::Client::new(client_config.read().clone()).expect("Could not setup pairing client"); + loop { + let event = discoverer.next_event().await; + match event { + Ok(DiscoveryEvent::NewEndpoint { hostname, endpoint }) => { + log.write().push_str(&format!( + "New endpoint lp: {:?}, pair: {:?}\n", + endpoint.longpolling_url(), + endpoint.pairing_url() + )); + if let Some(longpolling_url) = endpoint.longpolling_url() { + handle_longpolling_url(longpolling_url.to_owned()); + } + if let Some(remote) = endpoint.pairing_url().or(endpoint.longpolling_url()) + && let Ok((endpoint_description, node_descriptions)) = client.get_endpoint_descriptors(remote.into()).await + { + let mut clients = discovered_clients.write(); + if let Some(i) = clients + .iter() + .enumerate() + .filter_map(|(i, (h, _))| if *h == *hostname { Some(i) } else { None }) + .next() + { + clients[i] = (hostname, (endpoint, endpoint_description, node_descriptions)); + } else { + clients.push((hostname, (endpoint, endpoint_description, node_descriptions))); + } + } else { + let mut clients = discovered_clients.write(); + clients.retain(|(h, _)| *h != hostname); + } + } + Ok(DiscoveryEvent::RemovedEndpoint { hostname }) => { + let mut clients = discovered_clients.write(); + clients.retain(|(h, _)| *h != hostname); + } + _ => {} + } + } + }); + + // Handle the actual pairing + hooks.use_future(async move { + let mut pair_receiver = pairing_trigger_channel.write().1.take().unwrap(); + let client = pairing::Client::new(client_config.read().clone()).expect("Unable to create client"); + while let Some(trigger) = pair_receiver.recv().await { + let pairing_node_config = pairing_node_config.read().clone(); + let client_id = pairing_node_config.node_description().id; + if let Err(error) = client + .pair( + &pairing_node_config, + trigger.remote, + trigger.token.as_slice(), + async move |pairing| { + match pairing.role { + pairing::PairingRole::CommunicationClient { initiate_url, root_hash } => { + log.write() + .push_str(&format!("Succesfully paired with {}\n", pairing.remote_node_description.id)); + pairing_channel + .write() + .0 + .send(Some(Pairing(Arc::new(PairingData { + client_id, + server_id: pairing.remote_node_description.id, + communication_url: initiate_url, + access_tokens: std::sync::Mutex::new(vec![pairing.token]), + certificate_hash: root_hash, + })))) + .unwrap(); + } + pairing::PairingRole::CommunicationServer => unreachable!("Got a server connection as LAN RM"), + }; + Ok::<_, std::convert::Infallible>(()) + }, + ) + .await + { + log.write().push_str(&format!("Error during pairing: {}\n", error)); + } + } + }); + + // Handle connecting to the remote. + let mut message_trigger_channel = hooks.use_state(|| { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<()>(); + (tx, Some(rx)) + }); + hooks.use_future(async move { + let mut pairing_receiver = pairing_channel.read().1.clone(); + let mut send_message_trigger = message_trigger_channel.write().1.take().unwrap(); + let node_config = communication::NodeConfig::builder(vec![MessageVersion("v1".into())]).build(); + let client = communication::Client::new( + communication::ClientConfig { + additional_certificates: vec![], + endpoint_description: None, + }, + Arc::new(node_config), + ); + loop { + let current_pairing = pairing_receiver.borrow_and_update().clone(); + while send_message_trigger.try_recv().is_ok() {} + if let Some(pairing) = current_pairing { + log.write().push_str("Starting to connect\n"); + match client.connect(pairing).await { + Ok(ConnectionInfo { + mut transport, + .. + }) => { + log.write().push_str("Established new connection\n"); + loop { + tokio::select! { + message = transport.receive::() => { + match message { + Ok(message) => log.write().push_str(&format!("Received message: {}\n", serde_json::to_string_pretty(&message).unwrap())), + Err(error) => { + log.write().push_str(&format!("Error receiving from remote: {}\n", error)); + // Reconnect + transport.disconnect().await; + break; + } + }; + } + _ = send_message_trigger.recv() => { + if let Err(error) = transport.send("hello from client").await { + log.write().push_str(&format!("Error sending to remote: {}\n", error)); + // Reconnect + transport.disconnect().await; + break; + } else { + log.write().push_str("Sent message to remote.\n"); + } + } + _ = pairing_receiver.changed() => { + transport.disconnect().await; + log.write().push_str("Disconnected from remote\n"); + break; + } + } + } + }, + Err(error) => match error.kind() { + communication::ErrorKind::InvalidUrl | communication::ErrorKind::Unpaired | communication::ErrorKind::NotPaired => { + log.write().push_str(&format!("Failed to connect: {}\n", error)); + // Forget the pairing + pairing_channel.write().0.send(None).unwrap(); + } + communication::ErrorKind::TransportFailed + | communication::ErrorKind::ProtocolError + | communication::ErrorKind::NoSupportedVersion + | communication::ErrorKind::Storage => { + // Retry after waiting a bit + log.write().push_str(&format!("Failed to connect: {}\n", error)); + tokio::time::sleep(Duration::from_secs(1)).await; + } + }, + } + } else { + pairing_receiver.wait_for(|v| { + v.is_some() + }).await.unwrap(); + } + } + }); + + let unpair = hooks.use_async_handler(move |_| async move { + let pairing = pairing_channel.write().0.send_replace(None); + if let Some(pairing) = pairing { + log.write().push_str("Unpairing...\n"); + let node_config = communication::NodeConfig::builder(vec![MessageVersion("v1".into())]).build(); + let client = communication::Client::new( + communication::ClientConfig { + additional_certificates: vec![], + endpoint_description: None, + }, + Arc::new(node_config), + ); + if let Err(error) = client.unpair(pairing).await { + log.write().push_str(&format!("Error during unpairing: {}\n", error)); + } else { + log.write().push_str("Succesfully unpaired.\n"); + } + } + }); + + element! { + ClientUI ( + discovered_clients: discovered_clients.read().clone(), + initiate_pairing: move |trigger| {pairing_trigger_channel.read().0.send(trigger).unwrap();}, + reconnect: move |_| { pairing_channel.read().0.send_modify(|_| {}); }, + unpair, + send_message: move |_| { message_trigger_channel.read().0.send(()).unwrap(); }, + log: log.read().to_string(), + have_pairing: pairing_channel.read().0.borrow().is_some(), + token: token.to_string(), + ) + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + element!(Root).render_loop().await.expect("Unexpected failure of renderer"); +} diff --git a/s2energy-connection/examples/full-server.rs b/s2energy-connection/examples/full-server.rs new file mode 100644 index 0000000..df71e0a --- /dev/null +++ b/s2energy-connection/examples/full-server.rs @@ -0,0 +1,577 @@ +use axum_server::tls_rustls::RustlsConfig; +use clap::Parser; +use iocraft::prelude::*; +use rustls::pki_types::{CertificateDer, pem::PemObject}; +use s2energy_common::S2Transport; +use s2energy_connection::{ + EndpointDescription, MessageVersion, NodeDescription, NodeId, Role, + combined_server::{self, CombinedServerPairingStore, ServerCertificates, ServerConfig}, + communication::{self, ConnectionInfo, PairingLookupResult, ServerPairing, ServerPairingStore}, + discovery::{DiscoverableS2Endpoint, advertise}, + pairing::{self, LongpollingHandle, PairingToken}, +}; +use std::{collections::HashMap, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; +use tracing_subscriber::{ + EnvFilter, + fmt::{self, MakeWriter}, +}; + +#[derive(Default, Props)] +struct LongpollingListProps<'a> { + width: u16, + height: u16, + focus: bool, + longpolling_clients: &'a [NodeId], + want_pair: Handler, +} + +#[component] +fn LongpollingList<'a>(mut hooks: Hooks, props: &LongpollingListProps<'a>) -> impl Into> { + let mut selected = hooks.use_state(|| 0usize); + let focus = props.focus; + + if selected.get() != 0 && selected.get() > props.longpolling_clients.len() { + selected.set(props.longpolling_clients.len()); + } + + hooks.use_terminal_events({ + move |event| match event { + TerminalEvent::Key(KeyEvent { code, kind, .. }) if kind != KeyEventKind::Release => match code { + KeyCode::Up if focus => selected.set(selected.get().saturating_sub(1)), + KeyCode::Down if focus => selected.set(selected.get().saturating_add(1)), + _ => {} + }, + _ => {} + } + }); + + element! { + View ( + width: props.width, + height: props.height, + border_style: if props.focus { BorderStyle::Double } else { BorderStyle::Single }, + flex_direction: FlexDirection::Column, + ) { + Text(content: "Longpolling clients:", weight: Weight::Bold) + ScrollView() { + View( + width: props.width, + flex_direction: FlexDirection::Column, + ) { + #(props.longpolling_clients.iter().enumerate().map(|(i, &id)| { + let handler = props.want_pair.bind(id); + let selected = props.focus && i == selected.get(); + element! { + Button(has_focus: selected, handler) { + View(width: props.width, background_color: if selected { Color::White } else { Color::Black }) { + Text(content: format!("{}", id), color: if selected { Color::Black } else { Color::White }) + } + } + } + })) + } + } + } + } +} + +#[derive(Default, Props)] +struct ClientUIProps { + paired_clients: Vec, + longpolling_clients: Vec, + log: String, + token: Option, + longpolling_pair: HandlerMut<'static, (NodeId, PairingToken)>, + pair: HandlerMut<'static, ()>, +} + +#[component] +fn ClientUI<'a>(mut hooks: Hooks, props: &'a mut ClientUIProps) -> impl Into> { + let (width, height) = hooks.use_terminal_size(); + let mut focus = hooks.use_state(|| 0); + let mut token = hooks.use_state(|| String::default()); + let mut want_pair_with = hooks.use_state(|| None); + let mut last_error = hooks.use_state(|| ""); + let mut available_for_pairing = hooks.use_state(|| false); + + if available_for_pairing.get() != props.token.is_some() { + available_for_pairing.set(props.token.is_some()) + } + + let mut longpolling_pair = props.longpolling_pair.take(); + let mut pair = props.pair.take(); + hooks.use_terminal_events({ + move |event| match event { + TerminalEvent::Key(KeyEvent { code, kind, .. }) if kind != KeyEventKind::Release => match code { + KeyCode::Tab if want_pair_with.read().is_none() => focus.set((focus.get() + 1) % 3), + KeyCode::BackTab if want_pair_with.read().is_none() => focus.set((focus.get() + 2) % 3), + KeyCode::End => want_pair_with.set(None), + KeyCode::Enter => { + if token.read().len() != 0 + && let Ok(token) = token.read().parse() + { + match want_pair_with.write().take() { + Some(remote) => longpolling_pair((remote, token)), + None => {} + } + } else if token.read().len() != 0 && want_pair_with.read().is_some() { + last_error.set("Invalid token"); + } + } + KeyCode::Char('p') if want_pair_with.read().is_none() && !available_for_pairing.get() => { + pair(()); + } + _ => {} + }, + _ => {} + } + }); + let height_list = (height - 1) / 3; + let height_log = height - 1 - 2 * height_list; + element! { + View( + width, + height, + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Stretch, + ) { + LongpollingList ( + width, + height: height_list, + focus: !available_for_pairing.get() && want_pair_with.read().is_none() && focus == 0, + longpolling_clients: props.longpolling_clients.as_slice(), + want_pair: move |id| {token.clone().set(String::default()); want_pair_with.clone().set(Some(id)); last_error.clone().set("")} + ) + View( + width, + height: height_list, + border_style: if !available_for_pairing.get() && want_pair_with.read().is_none() && focus == 1 { BorderStyle::Double } else { BorderStyle::Single }, + flex_direction: FlexDirection::Column, + ) { + Text(content: "Paired clients:", weight: Weight::Bold) + #(props.paired_clients.iter().map(|id| element! { Text(content: id.to_string())} )) + } + View( + width, + height: height_log, + border_style: if !available_for_pairing.get() && want_pair_with.read().is_none() && focus == 2 { BorderStyle::Double } else { BorderStyle::Single }, + flex_direction: FlexDirection::Column, + ) { + ScrollView ( + auto_scroll: true, + keyboard_scroll: want_pair_with.read().is_none() && focus == 2, + ) { + Text(content: &props.log) + } + } + View( + width, + height: 1, + flex_direction: FlexDirection::Row, + justify_content: JustifyContent::SpaceBetween, + ) { + Text(content: "Become available for (p)airing") + } + #(if want_pair_with.read().is_some() { + element! { + View( + position: Position::Absolute, + flex_direction: FlexDirection::Column, + top: 5, + left: 5, + bottom: 5, + right: 5, + border_style: BorderStyle::Double, + background_color: Color::Black, + ) { + Text(content: "Token:") + View( width: 30, height: 1, background_color: Color::DarkGrey, ) { + TextInput( + has_focus: true, + value: token.to_string(), + on_change: move |new_token| token.set(new_token), + ) + } + Text(content: last_error.get()) + } + } + } else if let Some(token) = props.token.as_ref() { + element! { + View( + position: Position::Absolute, + flex_direction: FlexDirection::Column, + top: 5, + left: 5, + bottom: 5, + right: 5, + border_style: BorderStyle::Double, + background_color: Color::Black, + ) { + Text(content: format!("Available for pairing with token: {}", token)) + } + } + } else { + element! {View (display: Display::None) {}} + }) + } + } +} + +struct LogWriter(State); +impl<'a> MakeWriter<'a> for LogWriter { + type Writer = &'a Self; + + fn make_writer(&'a self) -> Self::Writer { + self + } +} + +impl std::io::Write for &LogWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.clone().write().push_str(&String::from_utf8_lossy(buf)); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +struct PairingStoreData { + local_config: Arc, + pairings: HashMap, + log: State, +} + +#[derive(Clone)] +struct PairingStore(State); + +impl PairingStore { + fn hook_new(hooks: &mut Hooks, log: State, node_description: NodeDescription, message_versions: Vec) -> Self { + Self(hooks.use_state(|| { + let local_config = Arc::new( + communication::NodeConfig::builder(message_versions) + .with_node_description(node_description) + .build(), + ); + PairingStoreData { + local_config, + pairings: HashMap::new(), + log, + } + })) + } +} + +struct Pairing { + store: PairingStore, + id: NodeId, +} + +impl ServerPairingStore for PairingStore { + type Error = std::convert::Infallible; + + type Pairing<'a> + = Pairing + where + Self: 'a; + + async fn lookup( + &self, + request: s2energy_connection::communication::PairingLookup, + ) -> Result>, Self::Error> { + let this = self.0.read(); + if + /*this.local_config.node_description().map_or(true, |v| v.id == request.server) &&*/ + this.pairings.contains_key(&request.client) { + Ok(PairingLookupResult::Pairing(Pairing { + store: self.clone(), + id: request.client, + })) + } else { + Ok(PairingLookupResult::NeverPaired) + } + } +} + +impl CombinedServerPairingStore for PairingStore { + async fn store(&self, _local_node: NodeId, pairing: pairing::Pairing) -> Result<(), Self::Error> { + let mut this = self.0.clone(); + let mut this = this.write(); + this.log.write().push_str(&format!( + "New pairing with {} ({} from {})\n", + pairing.remote_node_description.id, pairing.remote_node_description.model_name, pairing.remote_node_description.brand + )); + this.pairings.insert(pairing.remote_node_description.id, pairing); + Ok(()) + } +} + +impl ServerPairing for Pairing { + type Error = std::convert::Infallible; + + fn access_token(&self) -> impl AsRef { + self.store.0.read().pairings.get(&self.id).unwrap().token.clone() + } + + fn config(&self) -> impl AsRef { + self.store.0.read().local_config.clone() + } + + async fn set_access_token(&mut self, token: s2energy_connection::AccessToken) -> Result<(), Self::Error> { + self.store.0.write().pairings.get_mut(&self.id).unwrap().token = token; + Ok(()) + } + + async fn unpair(mut self) -> Result<(), Self::Error> { + let mut this = self.store.0.write(); + if let Some(pairing) = this.pairings.remove(&self.id) { + this.log.write().push_str(&format!( + "Unpaired with {} ({} from {})\n", + pairing.remote_node_description.id, pairing.remote_node_description.model_name, pairing.remote_node_description.brand + )); + } + Ok(()) + } +} + +#[derive(Default, Props)] +struct RootProps { + hostname: String, +} + +#[derive(Clone)] +struct LongpollerActions { + pair: tokio::sync::mpsc::UnboundedSender<()>, +} + +#[component] +fn Root(mut hooks: Hooks, props: &RootProps) -> impl Into> { + let mut log = hooks.use_state(|| String::default()); + + hooks.use_state(|| { + fmt::fmt() + .with_writer(LogWriter(log)) + .with_env_filter(EnvFilter::from_default_env()) + .init() + }); + + let mut token = hooks.use_state(|| None); + let node_description = hooks.use_state(|| NodeDescription { + id: NodeId::new(), + brand: String::from("test"), + logo_url: None, + type_: String::from("fancy"), + model_name: String::from("test server"), + user_defined_name: None, + role: Role::Cem, + }); + + let store = PairingStore::hook_new(&mut hooks, log, node_description.read().clone(), vec![MessageVersion("v1".into())]); + + let store_copy = store.clone(); + let server = hooks.use_state(|| { + let hostname = props.hostname.clone(); + let cert_chain = CertificateDer::pem_file_iter( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("testdata") + .join(format!("{hostname}.local.chain.pem")), + ) + .expect(&format!("Unable to load certificates. Did you generate certificates for your hostname by running ./gen_cert {hostname}.local in the s2energy-connection/testdata folder?")) + .collect::, _>>() + .unwrap(); + + let server = combined_server::Server::new( + ServerConfig { + base_url: format!("{hostname}.local:8000"), + certificates: Some(ServerCertificates { + leaf_certificate: cert_chain.first().unwrap().clone().into_owned(), + root_certificate: cert_chain.last().unwrap().clone().into_owned(), + }), + endpoint_description: EndpointDescription::default(), + advertised_nodes: vec![node_description.read().clone()], + }, + store_copy, + ).unwrap(); + + server + }); + + let mut active_longpollers = hooks.use_state(|| HashMap::new()); + + let handle_longpoller = hooks.use_async_handler(move |mut handle: LongpollingHandle| async move { + log.write() + .push_str(&format!("New longpolling session from {}\n", handle.client_id())); + let (pair, mut pair_rx) = tokio::sync::mpsc::unbounded_channel(); + active_longpollers.write().insert(handle.client_id(), LongpollerActions { pair }); + loop { + tokio::select! { + _ = pair_rx.recv() => { + if let Err(error) = handle.request_pairing().await { + log.write().push_str(&format!("Longpolling remote indicated inability to pair: {}", error)); + } + } + _ = handle.wait_dropped() => { + log.write().push_str(&format!("Remote stopped longpolling")); + break; + } + } + } + log.write().push_str("Ended longpolling session.\n"); + active_longpollers.write().remove(&handle.client_id()); + }); + + let server_copy = server.read().clone(); + hooks.use_future(async move { + let server = server_copy; + server.enable_longpolling().await; + loop { + let handle = server.get_longpolling().await; + handle_longpoller(handle); + } + }); + + let handle_connection = hooks.use_async_handler( + move |(pairing, mut connection): (communication::PairingLookup, ConnectionInfo)| async move { + log.write() + .push_str(&format!("New communication session from {}\n", pairing.client)); + connection.transport.send("Hello from server").await.ok(); + while let Ok(value) = connection.transport.receive::().await { + log.write().push_str(&format!( + "Received message from {}: {}\n", + pairing.client, + serde_json::to_string_pretty(&value).unwrap() + )); + } + }, + ); + + let server_copy = server.read().clone(); + hooks.use_future(async move { + let server = server_copy; + loop { + let connection = server.next_connection().await; + handle_connection(connection); + } + }); + + let hostname = props.hostname.clone(); + hooks.use_future(async move { + let rustls_config = RustlsConfig::from_pem_file( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("testdata") + .join(format!("{hostname}.local.chain.pem")), + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("testdata") + .join(format!("{hostname}.local.key")), + ) + .await + .unwrap(); + + let router = server.read().get_router(); + let addr = SocketAddr::from(([0, 0, 0, 0], 8000)); + axum_server::bind_rustls(addr, rustls_config) + .serve(router.into_make_service()) + .await + .unwrap(); + }); + + let hostname = props.hostname.clone(); + hooks.use_future(async move { + let endpoint = DiscoverableS2Endpoint::build_with_pairing(vec![Role::Cem], format!("https://{hostname}.local:8000")) + .unwrap() + .with_endpoint_name("Example full server".into()) + .with_longpolling_url(format!("https://{hostname}.local:8000")) + .unwrap() + .build(); + log.write().push_str(&format!( + "Advertising with lp: {:?}, pair: {:?}\n", + endpoint.longpolling_url(), + endpoint.pairing_url() + )); + let _advertisement = advertise(8000, endpoint).await.unwrap(); + std::future::pending().await + }); + + let mut paired_clients: Vec<_> = store.0.read().pairings.keys().copied().collect(); + paired_clients.sort(); + + let mut longpolling_clients: Vec<_> = active_longpollers.read().keys().copied().collect(); + longpolling_clients.sort(); + + let longpolling_pair = hooks.use_async_handler(move |(id, token): (NodeId, PairingToken)| async move { + let Some(v) = active_longpollers.read().get(&id).cloned() else { + log.write() + .push_str("Unable to pair with requested node, it stopped longpolling.\n"); + return; + }; + + if let Err(error) = server.read().allow_pair_once( + node_description.read().clone(), + vec![MessageVersion("v1".into())], + None, + token, + async |_| {}, + ) { + log.write().push_str(&format!("Unable to pair: {}\n", error)); + return; + } + if v.pair.send(()).is_err() { + log.write() + .push_str("Unable to pair with requested node, it stopped longpolling.\n"); + } else { + log.write().push_str("Requested remote to start pairing.\n"); + } + }); + + let pair = hooks.use_async_handler(move |_: ()| async move { + let cur_token = PairingToken::new(); + token.set(Some(cur_token.clone())); + + let (finished_tx, finished_rx) = tokio::sync::oneshot::channel(); + + if let Err(error) = server.read().allow_pair_once( + node_description.read().clone(), + vec![MessageVersion("v1".into())], + None, + cur_token, + async move |_| { + finished_tx.send(()).ok(); + }, + ) { + log.write().push_str(&format!("Could not start pairing session: {}.", error)); + } else { + tokio::time::timeout(Duration::from_mins(10), finished_rx).await.ok(); + } + + token.set(None); + }); + + let token = token.read().as_ref().map(|v| v.to_string()); + element! { + ClientUI ( + log: log.to_string(), + token, + paired_clients, + longpolling_clients, + longpolling_pair, + pair + ) + } +} + +/// Demonstration server for S2-connect +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + hostname: String, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let hostname = Args::parse().hostname; + element!(Root(hostname)) + .render_loop() + .await + .expect("Unexpected failure of renderer"); +}