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 host = crate::hostinfo::HostInfoData::detect();
197 let client_name = config.format_client_name();
198
199 let logout_req = RegisterRequest {
200 version: CapabilityVersion::CURRENT,
201 node_key: node_public_key,
202 nl_key: Some(node_keystate.network_lock_keys.public),
203 expiry: Some(past_expiry()),
204 hostinfo: HostInfo {
205 hostname: config.hostname.as_deref().map(std::borrow::Cow::Borrowed),
206 app: &client_name,
207 ipn_version: &host.ipn_version,
208 os: &host.os,
209 os_version: &host.os_version,
210 go_arch: &host.go_arch,
211 go_version: &host.go_version,
212 machine: &host.machine,
213 package: crate::hostinfo::PACKAGE_TSNET,
214 userspace: Some(true),
215 ..Default::default()
216 },
217 ..Default::default()
218 };
219
220 let body = serde_json::to_string(&logout_req)?;
221 let url = control_url.join("machine/register")?;
222
223 tracing::debug!(url = %url.as_str(), "logging out (expiring node key) via control");
224
225 let response = http2_conn
226 .post(
227 &url,
228 [(
229 LOAD_BALANCER_HEADER_KEY.parse().unwrap(),
230 node_public_key.to_string().parse().unwrap(),
231 )],
232 Bytes::from(body).into(),
233 )
234 .await?;
235
236 let status = response.status();
237 let body = response
238 .collect_bytes_limited(crate::MAX_CONTROL_RESPONSE)
239 .await
240 .unwrap_or_default();
241 classify_logout_response(status, &body)
242}
243
244fn classify_logout_response(status: StatusCode, body: &[u8]) -> Result<(), LogoutError> {
253 if !status.is_success() {
254 tracing::error!(%status, "logout request failed");
255 let mut truncated = body.to_vec();
259 truncated.truncate(512);
260 let preview = core::str::from_utf8(&truncated).unwrap_or("<invalid utf8>");
261 tracing::debug!(body = %preview, %status, "logout failure response body");
262 return Err(LogoutError::Internal(LogoutInternalErrorKind::Http));
263 }
264 Ok(())
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::tokio::connect::{ConnectionError, InternalErrorKind as ConnKind};
271
272 #[test]
275 fn connection_error_network_maps_to_network() {
276 assert_eq!(
277 LogoutError::from(ConnectionError::NetworkError),
278 LogoutError::NetworkError
279 );
280 }
281
282 #[test]
283 fn connection_error_internal_kinds_map_correctly() {
284 use LogoutInternalErrorKind as L;
285 let cases = [
286 (ConnKind::Url, L::Url),
287 (ConnKind::SerDe, L::SerDe),
288 (ConnKind::Http, L::Http),
289 (ConnKind::MessageFormat, L::Http),
290 (ConnKind::Io, L::Http),
291 (ConnKind::ChallengeLength, L::Http),
292 (ConnKind::NoiseHandshake, L::Http),
293 ];
294 for (conn, expected) in cases {
295 assert_eq!(
296 LogoutError::from(ConnectionError::Internal(conn)),
297 LogoutError::Internal(expected),
298 "ConnectionError::Internal({conn:?}) should map to Internal({expected:?})"
299 );
300 }
301 }
302
303 #[test]
304 fn http_util_error_recoverable_maps_to_network() {
305 assert_eq!(
306 LogoutError::from(ts_http_util::Error::Io),
307 LogoutError::NetworkError
308 );
309 }
310
311 #[test]
312 fn http_util_error_non_recoverable_maps_to_internal_http() {
313 assert_eq!(
314 LogoutError::from(ts_http_util::Error::InvalidResponse),
315 LogoutError::Internal(LogoutInternalErrorKind::Http)
316 );
317 }
318
319 #[test]
322 fn classify_logout_response_2xx_is_ok() {
323 assert!(classify_logout_response(StatusCode::OK, b"{}").is_ok());
324 assert!(classify_logout_response(StatusCode::NO_CONTENT, b"").is_ok());
326 }
327
328 #[test]
329 fn classify_logout_response_non_success_is_http() {
330 let err = classify_logout_response(StatusCode::INTERNAL_SERVER_ERROR, b"boom").unwrap_err();
331 assert_eq!(err, LogoutError::Internal(LogoutInternalErrorKind::Http));
332 }
333
334 #[test]
335 fn classify_logout_response_invalid_utf8_body_still_classifies() {
336 let err = classify_logout_response(StatusCode::BAD_GATEWAY, &[0xff, 0xfe]).unwrap_err();
338 assert_eq!(err, LogoutError::Internal(LogoutInternalErrorKind::Http));
339 }
340
341 #[test]
344 fn expiry_is_in_the_past() {
345 let now_secs = SystemTime::now()
346 .duration_since(UNIX_EPOCH)
347 .map(|d| d.as_secs() as i64)
348 .unwrap_or(0);
349 let expiry = past_expiry();
350 assert!(
351 expiry.timestamp() < now_secs,
352 "logout expiry ({}) must be before now ({now_secs})",
353 expiry.timestamp()
354 );
355 }
356}