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().map(std::borrow::Cow::Borrowed),
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
225 .collect_bytes_limited(crate::MAX_CONTROL_RESPONSE)
226 .await
227 .unwrap_or_default();
228 classify_logout_response(status, &body)
229}
230
231fn classify_logout_response(status: StatusCode, body: &[u8]) -> Result<(), LogoutError> {
240 if !status.is_success() {
241 tracing::error!(%status, "logout request failed");
242 let mut truncated = body.to_vec();
246 truncated.truncate(512);
247 let preview = core::str::from_utf8(&truncated).unwrap_or("<invalid utf8>");
248 tracing::debug!(body = %preview, %status, "logout failure response body");
249 return Err(LogoutError::Internal(LogoutInternalErrorKind::Http));
250 }
251 Ok(())
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::tokio::connect::{ConnectionError, InternalErrorKind as ConnKind};
258
259 #[test]
262 fn connection_error_network_maps_to_network() {
263 assert_eq!(
264 LogoutError::from(ConnectionError::NetworkError),
265 LogoutError::NetworkError
266 );
267 }
268
269 #[test]
270 fn connection_error_internal_kinds_map_correctly() {
271 use LogoutInternalErrorKind as L;
272 let cases = [
273 (ConnKind::Url, L::Url),
274 (ConnKind::SerDe, L::SerDe),
275 (ConnKind::Http, L::Http),
276 (ConnKind::MessageFormat, L::Http),
277 (ConnKind::Io, L::Http),
278 (ConnKind::ChallengeLength, L::Http),
279 (ConnKind::NoiseHandshake, L::Http),
280 ];
281 for (conn, expected) in cases {
282 assert_eq!(
283 LogoutError::from(ConnectionError::Internal(conn)),
284 LogoutError::Internal(expected),
285 "ConnectionError::Internal({conn:?}) should map to Internal({expected:?})"
286 );
287 }
288 }
289
290 #[test]
291 fn http_util_error_recoverable_maps_to_network() {
292 assert_eq!(
293 LogoutError::from(ts_http_util::Error::Io),
294 LogoutError::NetworkError
295 );
296 }
297
298 #[test]
299 fn http_util_error_non_recoverable_maps_to_internal_http() {
300 assert_eq!(
301 LogoutError::from(ts_http_util::Error::InvalidResponse),
302 LogoutError::Internal(LogoutInternalErrorKind::Http)
303 );
304 }
305
306 #[test]
309 fn classify_logout_response_2xx_is_ok() {
310 assert!(classify_logout_response(StatusCode::OK, b"{}").is_ok());
311 assert!(classify_logout_response(StatusCode::NO_CONTENT, b"").is_ok());
313 }
314
315 #[test]
316 fn classify_logout_response_non_success_is_http() {
317 let err = classify_logout_response(StatusCode::INTERNAL_SERVER_ERROR, b"boom").unwrap_err();
318 assert_eq!(err, LogoutError::Internal(LogoutInternalErrorKind::Http));
319 }
320
321 #[test]
322 fn classify_logout_response_invalid_utf8_body_still_classifies() {
323 let err = classify_logout_response(StatusCode::BAD_GATEWAY, &[0xff, 0xfe]).unwrap_err();
325 assert_eq!(err, LogoutError::Internal(LogoutInternalErrorKind::Http));
326 }
327
328 #[test]
331 fn expiry_is_in_the_past() {
332 let now_secs = SystemTime::now()
333 .duration_since(UNIX_EPOCH)
334 .map(|d| d.as_secs() as i64)
335 .unwrap_or(0);
336 let expiry = past_expiry();
337 assert!(
338 expiry.timestamp() < now_secs,
339 "logout expiry ({}) must be before now ({now_secs})",
340 expiry.timestamp()
341 );
342 }
343}