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    // Same Hostinfo identity as a normal register (see `register()`), so the logout request looks
194    // like a genuine node rather than a skeleton. Bound before the request so the owned strings
195    // outlive the borrowing `HostInfo`.
196    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
244/// Turn a logout `/machine/register` HTTP response into a result.
245///
246/// Pure (no I/O): factored out of [`logout_with`] so the status branch is unit-testable without a
247/// live stream. Any 2xx is success (control accepted the expiry); a non-2xx is
248/// [`LogoutInternalErrorKind::Http`], logging a truncated body for diagnosis.
249///
250/// Note: unlike registration we do NOT inspect the `RegisterResponse` body for `MachineAuthorized` —
251/// expiring a node needs no authorization decision, and an already-gone node still answers 2xx.
252fn classify_logout_response(status: StatusCode, body: &[u8]) -> Result<(), LogoutError> {
253    if !status.is_success() {
254        tracing::error!(%status, "logout request failed");
255        // The response body is logged only at debug to keep any control-side diagnostic text out of
256        // default-level logs (defense-in-depth; control's /machine/register error bodies are
257        // status text, not credentials, but we don't surface them by default).
258        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    // --- Error `From` conversions ---
273
274    #[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    // --- Response classification ---
320
321    #[test]
322    fn classify_logout_response_2xx_is_ok() {
323        assert!(classify_logout_response(StatusCode::OK, b"{}").is_ok());
324        // An empty body on success is fine — we don't parse the body on logout.
325        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        // A non-2xx with a non-UTF8 body must not panic; it logs a placeholder and returns Http.
337        let err = classify_logout_response(StatusCode::BAD_GATEWAY, &[0xff, 0xfe]).unwrap_err();
338        assert_eq!(err, LogoutError::Internal(LogoutInternalErrorKind::Http));
339    }
340
341    /// The computed expiry must be strictly in the past so control expires the node (not schedule a
342    /// future expiry). Guards against an accidental sign flip in [`past_expiry`].
343    #[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}