Skip to main content

ts_control/tokio/
logout.rs

1//! Control RPC to log this node out of the tailnet (deregister / expire the node key).
2//!
3//! Mirrors Go `tsnet`'s `LocalClient.Logout` at the control-protocol layer: re-`POST`s
4//! `/machine/register` over a fresh Noise (ts2021) channel with the node's **current** node key and
5//! an [`expiry`](ts_control_serde::RegisterRequest::expiry) set in the past. Per the Tailscale
6//! control protocol, when `expiry` is in the past *and* `node_key` is the current key for this node,
7//! control expires the node immediately — it drops out of every peer's netmap and must re-register
8//! (re-authenticate) to rejoin.
9//!
10//! This matters for **non-ephemeral** nodes: an ephemeral node is GC'd by control shortly after it
11//! disconnects, but a persistent node lingers in the tailnet (visible to peers, counting against the
12//! machine limit) for ~24h after the process exits unless it is explicitly logged out. Calling this
13//! before teardown deregisters it now. Ephemeral nodes may call it too — it just brings the GC
14//! forward.
15//!
16//! This is a control-plane state change only: it does not tear down the local datapath (the caller
17//! does that via the normal runtime shutdown). It also does not delete or rotate the on-disk node
18//! key — re-registering with the same key (e.g. a fresh `Device::new`) is the re-login path.
19
20use 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
37/// Upper bound on a single logout RPC (fresh Noise connect + POST + response read).
38///
39/// A hung control plane must not pin a half-open connection forever; on expiry the RPC is abandoned
40/// and reported as a transient [`LogoutError::NetworkError`]. Same budget as the id-token RPC.
41const LOGOUT_TIMEOUT: Duration = Duration::from_secs(30);
42
43/// How far in the past to backdate the node-key expiry, in seconds. Any past instant deregisters the
44/// node; a small fixed skew avoids a borderline "is now in the past yet?" race against control's
45/// clock (and tolerates minor client/server clock skew).
46const EXPIRY_BACKDATE_SECS: u64 = 10;
47
48/// A `DateTime<Utc>` a few seconds in the past, used as the logout expiry. Built from `SystemTime`
49/// (chrono's `clock` feature / `Utc::now()` is not enabled in this workspace, so the timestamp is
50/// derived from the std clock and converted). Falls back to the Unix epoch if the system clock is
51/// before 1970 (control still treats epoch as "in the past", so logout still works) or if the
52/// backdated value somehow overflows the representable range.
53fn 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/// The internal failure kinds a logout request can surface.
63///
64/// Private vocabulary for this RPC (mirrors `id_token`'s), covering only the kinds this path
65/// actually produces.
66#[derive(Debug, Clone, Copy, Eq, PartialEq)]
67pub enum LogoutInternalErrorKind {
68    /// Failed to build/parse a URL for the request.
69    Url,
70    /// Failed to serialize the request body.
71    SerDe,
72    /// An unsuccessful (non-2xx) HTTP request, or an HTTP/transport error not classed as transient.
73    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/// Errors from a logout request.
87#[derive(Debug, thiserror::Error, Clone, Eq, PartialEq)]
88pub enum LogoutError {
89    /// A transient network error; the request may succeed on retry. The node may or may not have
90    /// been expired — logout is idempotent, so retrying is safe.
91    #[error("network error logging out")]
92    NetworkError,
93    /// An internal failure (URL/serde/HTTP). Detail kept coarse for the public surface.
94    #[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
123// The shared Noise `connect` surfaces a `ConnectionError`; fold it into our error (same collapse as
124// `id_token`).
125impl 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
143/// Log this node out of the tailnet: deregister it by expiring its current node key.
144///
145/// Opens a fresh Noise channel and re-`POST`s `/machine/register` with the node's current node key
146/// and a past [`expiry`](RegisterRequest::expiry), which control honors by expiring the node now.
147/// The whole connect + POST + response read is bounded by [`LOGOUT_TIMEOUT`]; a hung control plane
148/// is abandoned and reported as [`LogoutError::NetworkError`].
149///
150/// Idempotent: logging out an already-expired/unknown node is a no-op as far as the caller is
151/// concerned (control accepts the request; the node is simply already gone).
152pub 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
176/// Inner: send the deregistering `/machine/register` POST over an already-established Noise channel.
177///
178/// Split out from [`logout`] so the response handling ([`classify_logout_response`]) is unit-testable
179/// independent of the Noise connect.
180pub(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    // A logout is a *registration* of the CURRENT node key with a past expiry. Control rejects a
189    // skeleton request (a near-empty RegisterRequest 500s on Tailscale SaaS), so this mirrors the
190    // normal `register()` request shape — same node key, NL key, and HostInfo identity — and only
191    // adds the backdated `expiry` that tells control to expire the node now. Auth/ephemeral are
192    // omitted: re-authentication is not part of a logout, and the node already exists.
193    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
228/// Turn a logout `/machine/register` HTTP response into a result.
229///
230/// Pure (no I/O): factored out of [`logout_with`] so the status branch is unit-testable without a
231/// live stream. Any 2xx is success (control accepted the expiry); a non-2xx is
232/// [`LogoutInternalErrorKind::Http`], logging a truncated body for diagnosis.
233///
234/// Note: unlike registration we do NOT inspect the `RegisterResponse` body for `MachineAuthorized` —
235/// expiring a node needs no authorization decision, and an already-gone node still answers 2xx.
236fn classify_logout_response(status: StatusCode, body: &[u8]) -> Result<(), LogoutError> {
237    if !status.is_success() {
238        tracing::error!(%status, "logout request failed");
239        // The response body is logged only at debug to keep any control-side diagnostic text out of
240        // default-level logs (defense-in-depth; control's /machine/register error bodies are
241        // status text, not credentials, but we don't surface them by default).
242        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    // --- Error `From` conversions ---
257
258    #[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    // --- Response classification ---
304
305    #[test]
306    fn classify_logout_response_2xx_is_ok() {
307        assert!(classify_logout_response(StatusCode::OK, b"{}").is_ok());
308        // An empty body on success is fine — we don't parse the body on logout.
309        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        // A non-2xx with a non-UTF8 body must not panic; it logs a placeholder and returns Http.
321        let err = classify_logout_response(StatusCode::BAD_GATEWAY, &[0xff, 0xfe]).unwrap_err();
322        assert_eq!(err, LogoutError::Internal(LogoutInternalErrorKind::Http));
323    }
324
325    /// The computed expiry must be strictly in the past so control expires the node (not schedule a
326    /// future expiry). Guards against an accidental sign flip in [`past_expiry`].
327    #[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}