diff --git a/src/client.rs b/src/client.rs index 613282f..f7b3948 100644 --- a/src/client.rs +++ b/src/client.rs @@ -37,7 +37,7 @@ use crate::xml; /// # async fn main() { /// // Create an instance of EppClient /// let timeout = Duration::from_secs(5); -/// let (mut client, mut connection) = match connect("registry_name".into(), ("example.com".into(), 7000), None, timeout).await { +/// let (mut client, mut connection) = match connect("registry_name".into(), ("example.com".into(), 7000), None, timeout, None).await { /// Ok(client) => client, /// Err(e) => panic!("Failed to create EppClient: {}", e) /// }; diff --git a/src/connect.rs b/src/connect.rs index 4dd503e..290c3d5 100644 --- a/src/connect.rs +++ b/src/connect.rs @@ -26,10 +26,12 @@ use crate::error::Error; /// Connect to the specified `server` and `hostname` over TLS /// -/// The `registry` is used as a name in internal logging; `addr` provides the address to -/// connect to, `hostname` is sent as the TLS server name indication and `identity` provides -/// optional TLS client authentication (using) rustls as the TLS implementation. -/// The `timeout` limits the time spent on any underlying network operations. +/// The `registry` is used as a name in internal logging; `server` provides the hostname and port +/// to connect to, and `identity` provides optional TLS client authentication (using) rustls as +/// the TLS implementation. +/// The `request_timeout` limits the time spent on any underlying network operation. +/// The `idle_timeout` prevents the connection to be closed server-side due to being idle. (See +/// [`EppConnection`] Keepalive) /// /// This returns two halves, a cloneable client and the underlying connection. /// @@ -40,22 +42,25 @@ pub async fn connect( server: (Cow<'static, str>, u16), identity: Option<(Vec, PrivateKey)>, request_timeout: Duration, + idle_timeout: Option, ) -> Result<(EppClient, EppConnection), Error> { let connector = RustlsConnector::new(server, identity).await?; let (sender, receiver) = mpsc::unbounded_channel(); let client = EppClient::new(sender, registry.clone()); - let connection = EppConnection::new(connector, registry, receiver, request_timeout).await?; + let connection = + EppConnection::new(connector, registry, receiver, request_timeout, idle_timeout).await?; Ok((client, connection)) } /// Connect to the specified `server` and `hostname` via the passed connector. /// -/// The `registry` is used as a name in internal logging; `addr` provides the address to -/// connect to, `hostname` is sent as the TLS server name indication and `identity` provides -/// optional TLS client authentication (using) rustls as the TLS implementation. -/// The `timeout` limits the time spent on any underlying network operations. +/// The `registry` is used as a name in internal logging; `connector` provides a way to +/// plug in various network connections. +/// The `request_timeout` limits the time spent on any underlying network operations. +/// The `idle_timeout` prevents the connection to be closed server-side due to being idle. (See +/// [`EppConnection`] Keepalive) /// /// This returns two halves, a cloneable client and the underlying connection. /// @@ -64,13 +69,15 @@ pub async fn connect_with_connector( connector: C, registry: Cow<'static, str>, request_timeout: Duration, + idle_timeout: Option, ) -> Result<(EppClient, EppConnection), Error> where C: Connector, { let (sender, receiver) = mpsc::unbounded_channel(); let client = EppClient::new(sender, registry.clone()); - let connection = EppConnection::new(connector, registry, receiver, request_timeout).await?; + let connection = + EppConnection::new(connector, registry, receiver, request_timeout, idle_timeout).await?; Ok((client, connection)) } @@ -160,3 +167,7 @@ pub trait Connector { async fn connect(&self, timeout: Duration) -> Result; } + +/// Per default try to send a keep alive every 8 minutes. +/// Verisign has an idle timeout of 10 minutes. +pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(60 * 8); diff --git a/src/connection.rs b/src/connection.rs index 7cdf7d1..b507327 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -12,6 +12,8 @@ use tracing::{debug, error, info, trace, warn}; use crate::connect::Connector; use crate::error::Error; +use crate::hello::HelloDocument; +use crate::xml; /// EPP Connection /// @@ -21,12 +23,27 @@ use crate::error::Error; /// [`EppConnection`] provides a [`EppConnection::run`](EppConnection::run) method, which only resolves when the connection is closed, /// either because a fatal error has occurred, or because its associated [`EppClient`](super::EppClient) has been dropped /// and all outstanding work has been completed. +/// +/// # Keepalive (Idle Timeout) +/// +/// EppConnection supports a keepalive mechanism. +/// When `idle_timeout` is set, every time the timeout reaches zero while waiting for a new request from the +/// [`EppClient`](super::EppClient), a `` request is sent to the epp server. +/// This is in line with VeriSign's guidelines. VeriSign uses an idle timeout of 10 minutes and an absolute timeout of 24h. +/// Choosing an `idle_timeout` of 8 minutes should be sufficient to not run into VeriSign's idle timeout. +/// Other registry operators might need other values. +/// +/// # Reconnect (Absolute Timeout) +/// +/// Reconnecting, to gracefully allow a [`EppConnection`] to be "active", is currently not implemented. But a reconnect +/// command is present to initiate the reconnect from the outside pub struct EppConnection { registry: Cow<'static, str>, connector: C, stream: C::Connection, greeting: String, timeout: Duration, + idle_timeout: Option, /// A receiver for receiving requests from [`EppClients`](super::client::EppClient) for the underlying connection. receiver: mpsc::UnboundedReceiver, state: ConnectionState, @@ -38,6 +55,7 @@ impl EppConnection { registry: Cow<'static, str>, receiver: mpsc::UnboundedReceiver, request_timeout: Duration, + idle_timeout: Option, ) -> Result { let mut this = Self { registry, @@ -46,6 +64,7 @@ impl EppConnection { receiver, greeting: String::new(), timeout: request_timeout, + idle_timeout, state: Default::default(), }; @@ -176,6 +195,35 @@ impl EppConnection { } } + async fn request_or_keepalive(&mut self) -> Result, Error> { + loop { + let Some(idle_timeout) = self.idle_timeout else { + // We do not have any keep alive set, just forward to waiting for a request. + return Ok(self.receiver.recv().await); + }; + trace!(registry = %self.registry, "Waiting for {idle_timeout:?} for new request until keepalive"); + match tokio::time::timeout(idle_timeout, self.receiver.recv()).await { + Ok(request) => return Ok(request), + Err(_) => { + self.keepalive().await?; + // We sent the keepalive. Go back to wait for requests. + continue; + } + } + } + } + + async fn keepalive(&mut self) -> Result<(), Error> { + trace!(registry = %self.registry, "Sending keepalive hello"); + // Send hello + let request = xml::serialize(&HelloDocument::default())?; + self.write_epp_request(&request).await?; + + // Await new greeting + self.greeting = self.read_epp_response().await?; + Ok(()) + } + /// This is the main method of the I/O tasks /// /// It will try to get a request, write it to the wire and waits for the response. @@ -191,8 +239,11 @@ impl EppConnection { return None; } - // Wait for new request - let request = self.receiver.recv().await; + // Wait for new request or send a keepalive + let request = match self.request_or_keepalive().await { + Ok(request) => request, + Err(err) => return Some(Err(err)), + }; let Some(request) = request else { // The client got dropped. We can close the connection. match self.wait_for_shutdown().await { diff --git a/src/lib.rs b/src/lib.rs index 9b220e3..1d4c6a9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -59,7 +59,7 @@ //! use std::net::ToSocketAddrs; //! use std::time::Duration; //! -//! use epp_client::connect::connect; +//! use epp_client::connect::{connect, DEFAULT_IDLE_TIMEOUT}; //! use epp_client::domain::DomainCheck; //! use epp_client::login::Login; //! @@ -67,7 +67,7 @@ //! async fn main() { //! // Create an instance of EppClient //! let timeout = Duration::from_secs(5); -//! let (mut client, mut connection) = match connect("registry_name".into(), ("example.com".into(), 7000), None, timeout).await { +//! let (mut client, mut connection) = match connect("registry_name".into(), ("example.com".into(), 7000), None, timeout, Some(DEFAULT_IDLE_TIMEOUT)).await { //! Ok(c) => c, //! Err(e) => panic!("Failed to create EppClient: {}", e) //! }; diff --git a/tests/basic.rs b/tests/basic.rs index 4fb5873..fdabb2b 100644 --- a/tests/basic.rs +++ b/tests/basic.rs @@ -101,7 +101,7 @@ async fn client() { } let (mut client, mut connection) = - connect_with_connector(FakeConnector, "test".into(), Duration::from_secs(5)) + connect_with_connector(FakeConnector, "test".into(), Duration::from_secs(5), None) .await .unwrap(); @@ -186,7 +186,7 @@ async fn dropped() { } let (mut client, mut connection) = - connect_with_connector(FakeConnector, "test".into(), Duration::from_secs(5)) + connect_with_connector(FakeConnector, "test".into(), Duration::from_secs(5), None) .await .unwrap(); tokio::spawn(async move { @@ -279,7 +279,7 @@ async fn drop_client() { } let (client, mut connection) = - connect_with_connector(FakeConnector, "test".into(), Duration::from_secs(5)) + connect_with_connector(FakeConnector, "test".into(), Duration::from_secs(5), None) .await .unwrap(); @@ -296,3 +296,93 @@ async fn drop_client() { drop(client); assert!(handle.await.is_ok()); } + +#[tokio::test] +async fn keepalive() { + let _guard = log_to_stdout(); + + struct FakeConnector; + + #[async_trait] + impl epp_client::client::Connector for FakeConnector { + type Connection = tokio_test::io::Mock; + + async fn connect(&self, _: Duration) -> Result { + Ok(build_stream(&[ + "response/greeting.xml", + "request/login.xml", + "response/login.xml", + // The keepalive should kick in. + // We set the keepalive to 100ms and wait 250ms which should yield two hello requests + "request/hello.xml", + "response/greeting.xml", + "request/hello.xml", + "response/greeting.xml", + "request/domain/create.xml", + "response/domain/create.xml", + ]) + .build()) + } + } + + let (mut client, mut connection) = connect_with_connector( + FakeConnector, + "test".into(), + Duration::from_secs(5), + Some(Duration::from_millis(100)), + ) + .await + .unwrap(); + tokio::spawn(async move { + connection.run().await.unwrap(); + trace!("connection future resolved successfully") + }); + + trace!("Trying to get greeting"); + assert_eq!( + client.xml_greeting().await.unwrap(), + xml("response/greeting.xml") + ); + + let rsp = client + .transact( + &Login::new( + "username", + "password", + Some("new-password"), + Some(&["http://schema.ispapi.net/epp/xml/keyvalue-1.0"]), + ), + CLTRID, + ) + .await + .unwrap(); + assert_eq!(rsp.result.code, ResultCode::CommandCompletedSuccessfully); + trace!("Waiting"); + tokio::time::sleep(Duration::from_millis(250)).await; + + let contacts = &[ + DomainContact { + contact_type: "admin".into(), + id: "eppdev-contact-3".into(), + }, + DomainContact { + contact_type: "tech".into(), + id: "eppdev-contact-3".into(), + }, + DomainContact { + contact_type: "billing".into(), + id: "eppdev-contact-3".into(), + }, + ]; + let create = DomainCreate::new( + "eppdev-1.com", + Period::years(1).unwrap(), + None, + Some("eppdev-contact-3"), + "epP4uthd#v", + Some(contacts), + ); + trace!("Trying to create domains"); + let rsp = client.transact(&create, CLTRID).await.unwrap(); + assert_eq!(rsp.result.code, ResultCode::CommandCompletedSuccessfully); +}