1use core::time::Duration;
21use std::{
22 fmt,
23 time::{SystemTime, UNIX_EPOCH},
24};
25
26use bytes::Bytes;
27use chrono::{DateTime, Utc};
28use ts_capabilityversion::CapabilityVersion;
29use ts_control_serde::{HostInfo, RegisterRequest};
30use ts_http_util::{BytesBody, ClientExt, Http2, ResponseExt, StatusCode};
31use url::Url;
32
33use crate::tokio::connect::ConnectionError;
34
35const LOAD_BALANCER_HEADER_KEY: &str = "Ts-Lb";
36
37const LOGOUT_TIMEOUT: Duration = Duration::from_secs(30);
42
43const EXPIRY_BACKDATE_SECS: u64 = 10;
47
48fn past_expiry() -> DateTime<Utc> {
54 let secs = SystemTime::now()
55 .duration_since(UNIX_EPOCH)
56 .map(|d| d.as_secs())
57 .unwrap_or(0)
58 .saturating_sub(EXPIRY_BACKDATE_SECS);
59 DateTime::<Utc>::from_timestamp(secs as i64, 0).unwrap_or(DateTime::<Utc>::UNIX_EPOCH)
60}
61
62#[derive(Debug, Clone, Copy, Eq, PartialEq)]
67pub enum LogoutInternalErrorKind {
68 Url,
70 SerDe,
72 Http,
74}
75
76impl fmt::Display for LogoutInternalErrorKind {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 match self {
79 LogoutInternalErrorKind::Url => write!(f, "URL parsing error"),
80 LogoutInternalErrorKind::SerDe => write!(f, "serialization error"),
81 LogoutInternalErrorKind::Http => write!(f, "unsuccessful HTTP request"),
82 }
83 }
84}
85
86#[derive(Debug, thiserror::Error, Clone, Eq, PartialEq)]
88pub enum LogoutError {
89 #[error("network error logging out")]
92 NetworkError,
93 #[error("error logging out: {0}")]
95 Internal(LogoutInternalErrorKind),
96}
97
98impl From<url::ParseError> for LogoutError {
99 fn from(error: url::ParseError) -> Self {
100 tracing::error!(%error, "bad URL building logout request");
101 LogoutError::Internal(LogoutInternalErrorKind::Url)
102 }
103}
104
105impl From<serde_json::Error> for LogoutError {
106 fn from(error: serde_json::Error) -> Self {
107 tracing::error!(%error, "serde error in logout request");
108 LogoutError::Internal(LogoutInternalErrorKind::SerDe)
109 }
110}
111
112impl From<ts_http_util::Error> for LogoutError {
113 fn from(error: ts_http_util::Error) -> Self {
114 tracing::error!(%error, "http error in logout request");
115 if crate::http_error_is_recoverable(error) {
116 LogoutError::NetworkError
117 } else {
118 LogoutError::Internal(LogoutInternalErrorKind::Http)
119 }
120 }
121}
122
123impl From<ConnectionError> for LogoutError {
126 fn from(error: ConnectionError) -> Self {
127 use crate::tokio::connect::InternalErrorKind as Conn;
128 match error {
129 ConnectionError::NetworkError => LogoutError::NetworkError,
130 ConnectionError::Internal(k) => LogoutError::Internal(match k {
131 Conn::Url => LogoutInternalErrorKind::Url,
132 Conn::SerDe => LogoutInternalErrorKind::SerDe,
133 Conn::Http
134 | Conn::MessageFormat
135 | Conn::Io
136 | Conn::ChallengeLength
137 | Conn::NoiseHandshake => LogoutInternalErrorKind::Http,
138 }),
139 }
140 }
141}
142
143pub async fn logout(
153 config: &crate::Config,
154 node_keystate: &ts_keys::NodeState,
155) -> Result<(), LogoutError> {
156 let control_url = &config.server_url;
157 let rpc = async {
158 let http2_conn = crate::tokio::connect(
159 control_url,
160 &node_keystate.machine_keys,
161 config.allow_http_key_fetch,
162 )
163 .await?;
164 logout_with(config, control_url, node_keystate, &http2_conn).await
165 };
166
167 match tokio::time::timeout(LOGOUT_TIMEOUT, rpc).await {
168 Ok(result) => result,
169 Err(_elapsed) => {
170 tracing::error!(timeout = ?LOGOUT_TIMEOUT, "logout request timed out");
171 Err(LogoutError::NetworkError)
172 }
173 }
174}
175
176pub(crate) async fn logout_with(
181 config: &crate::Config,
182 control_url: &Url,
183 node_keystate: &ts_keys::NodeState,
184 http2_conn: &Http2<BytesBody>,
185) -> Result<(), LogoutError> {
186 let node_public_key = node_keystate.node_keys.public;
187
188 let logout_req = RegisterRequest {
194 version: CapabilityVersion::CURRENT,
195 node_key: node_public_key,
196 nl_key: Some(node_keystate.network_lock_keys.public),
197 expiry: Some(past_expiry()),
198 hostinfo: HostInfo {
199 hostname: config.hostname.as_deref(),
200 app: &config.format_client_name(),
201 ipn_version: crate::PKG_VERSION,
202 ..Default::default()
203 },
204 ..Default::default()
205 };
206
207 let body = serde_json::to_string(&logout_req)?;
208 let url = control_url.join("machine/register")?;
209
210 tracing::debug!(url = %url.as_str(), "logging out (expiring node key) via control");
211
212 let response = http2_conn
213 .post(
214 &url,
215 [(
216 LOAD_BALANCER_HEADER_KEY.parse().unwrap(),
217 node_public_key.to_string().parse().unwrap(),
218 )],
219 Bytes::from(body).into(),
220 )
221 .await?;
222
223 let status = response.status();
224 let body = response.collect_bytes().await.unwrap_or_default();
225 classify_logout_response(status, &body)
226}
227
228fn classify_logout_response(status: StatusCode, body: &[u8]) -> Result<(), LogoutError> {
237 if !status.is_success() {
238 tracing::error!(%status, "logout request failed");
239 let mut truncated = body.to_vec();
243 truncated.truncate(512);
244 let preview = core::str::from_utf8(&truncated).unwrap_or("<invalid utf8>");
245 tracing::debug!(body = %preview, %status, "logout failure response body");
246 return Err(LogoutError::Internal(LogoutInternalErrorKind::Http));
247 }
248 Ok(())
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use crate::tokio::connect::{ConnectionError, InternalErrorKind as ConnKind};
255
256 #[test]
259 fn connection_error_network_maps_to_network() {
260 assert_eq!(
261 LogoutError::from(ConnectionError::NetworkError),
262 LogoutError::NetworkError
263 );
264 }
265
266 #[test]
267 fn connection_error_internal_kinds_map_correctly() {
268 use LogoutInternalErrorKind as L;
269 let cases = [
270 (ConnKind::Url, L::Url),
271 (ConnKind::SerDe, L::SerDe),
272 (ConnKind::Http, L::Http),
273 (ConnKind::MessageFormat, L::Http),
274 (ConnKind::Io, L::Http),
275 (ConnKind::ChallengeLength, L::Http),
276 (ConnKind::NoiseHandshake, L::Http),
277 ];
278 for (conn, expected) in cases {
279 assert_eq!(
280 LogoutError::from(ConnectionError::Internal(conn)),
281 LogoutError::Internal(expected),
282 "ConnectionError::Internal({conn:?}) should map to Internal({expected:?})"
283 );
284 }
285 }
286
287 #[test]
288 fn http_util_error_recoverable_maps_to_network() {
289 assert_eq!(
290 LogoutError::from(ts_http_util::Error::Io),
291 LogoutError::NetworkError
292 );
293 }
294
295 #[test]
296 fn http_util_error_non_recoverable_maps_to_internal_http() {
297 assert_eq!(
298 LogoutError::from(ts_http_util::Error::InvalidResponse),
299 LogoutError::Internal(LogoutInternalErrorKind::Http)
300 );
301 }
302
303 #[test]
306 fn classify_logout_response_2xx_is_ok() {
307 assert!(classify_logout_response(StatusCode::OK, b"{}").is_ok());
308 assert!(classify_logout_response(StatusCode::NO_CONTENT, b"").is_ok());
310 }
311
312 #[test]
313 fn classify_logout_response_non_success_is_http() {
314 let err = classify_logout_response(StatusCode::INTERNAL_SERVER_ERROR, b"boom").unwrap_err();
315 assert_eq!(err, LogoutError::Internal(LogoutInternalErrorKind::Http));
316 }
317
318 #[test]
319 fn classify_logout_response_invalid_utf8_body_still_classifies() {
320 let err = classify_logout_response(StatusCode::BAD_GATEWAY, &[0xff, 0xfe]).unwrap_err();
322 assert_eq!(err, LogoutError::Internal(LogoutInternalErrorKind::Http));
323 }
324
325 #[test]
328 fn expiry_is_in_the_past() {
329 let now_secs = SystemTime::now()
330 .duration_since(UNIX_EPOCH)
331 .map(|d| d.as_secs() as i64)
332 .unwrap_or(0);
333 let expiry = past_expiry();
334 assert!(
335 expiry.timestamp() < now_secs,
336 "logout expiry ({}) must be before now ({now_secs})",
337 expiry.timestamp()
338 );
339 }
340}