Skip to main content

ts_control/tokio/
set_dns.rs

1//! Control RPC to publish a DNS record for this node (`POST /machine/set-dns`).
2//!
3//! Mirrors Go's `POST /machine/set-dns` over the Noise (ts2021) transport: the node sends a
4//! [`SetDnsRequest`] (`{Version, NodeKey, Name, Type, Value}`) and control publishes the record
5//! into the tailnet's `ts.net` zone, returning an empty [`SetDnsResponse`] on success.
6//!
7//! The product use is the ACME **DNS-01** challenge: publish a
8//! `_acme-challenge.<host>.<tailnet>.ts.net` `TXT` record so an ACME CA can verify domain control.
9//! This RPC is a generic control primitive (not itself `acme`-gated), but in this fork it is only
10//! exercised by the (feature-gated) ACME engine.
11
12use core::time::Duration;
13use std::fmt;
14
15use bytes::Bytes;
16use ts_capabilityversion::CapabilityVersion;
17use ts_control_serde::{SetDnsRequest, SetDnsResponse};
18use ts_http_util::{BytesBody, ClientExt, Http2, ResponseExt, StatusCode};
19use url::Url;
20
21use crate::tokio::connect::ConnectionError;
22
23const LOAD_BALANCER_HEADER_KEY: &str = "Ts-Lb";
24
25/// Upper bound on a single set-dns RPC (fresh Noise connect + POST + response read).
26///
27/// A hung control plane must not leave a half-open connection pinned forever; on expiry the RPC
28/// is abandoned and reported as a transient [`SetDnsError::NetworkError`].
29const SET_DNS_TIMEOUT: Duration = Duration::from_secs(30);
30
31/// The internal failure kinds a set-dns request can surface.
32///
33/// Private to this module: `SetDnsError` owns its own internal vocabulary rather than borrowing a
34/// sibling module's. Only the generic kinds this RPC actually produces are represented.
35#[derive(Debug, Clone, Copy, Eq, PartialEq)]
36pub enum SetDnsInternalErrorKind {
37    /// Failed to build/parse a URL for the request.
38    Url,
39    /// Failed to serialize the request or deserialize the response body.
40    SerDe,
41    /// An unsuccessful (non-2xx) HTTP request, or an HTTP/transport error not classed as transient.
42    Http,
43    /// The response body was not valid UTF-8.
44    Utf8,
45}
46
47impl fmt::Display for SetDnsInternalErrorKind {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            SetDnsInternalErrorKind::Url => write!(f, "URL parsing error"),
51            SetDnsInternalErrorKind::SerDe => write!(f, "serialization/deserialization error"),
52            SetDnsInternalErrorKind::Http => write!(f, "unsuccessful HTTP request"),
53            SetDnsInternalErrorKind::Utf8 => write!(f, "invalid UTF8"),
54        }
55    }
56}
57
58/// Errors from a set-dns request.
59#[derive(Debug, thiserror::Error, Clone, Eq, PartialEq)]
60pub enum SetDnsError {
61    /// A transient network error; the request may succeed on retry.
62    #[error("network error publishing dns record")]
63    NetworkError,
64    /// An internal failure (URL/serde/HTTP/UTF-8). Detail kept coarse for the public surface.
65    #[error("error publishing dns record: {0}")]
66    Internal(SetDnsInternalErrorKind),
67}
68
69impl From<url::ParseError> for SetDnsError {
70    fn from(error: url::ParseError) -> Self {
71        tracing::error!(%error, "bad URL building set-dns request");
72        SetDnsError::Internal(SetDnsInternalErrorKind::Url)
73    }
74}
75
76impl From<serde_json::Error> for SetDnsError {
77    fn from(error: serde_json::Error) -> Self {
78        tracing::error!(%error, "serde error in set-dns request");
79        SetDnsError::Internal(SetDnsInternalErrorKind::SerDe)
80    }
81}
82
83impl From<core::str::Utf8Error> for SetDnsError {
84    fn from(error: core::str::Utf8Error) -> Self {
85        tracing::error!(%error, "invalid utf8 in set-dns response");
86        SetDnsError::Internal(SetDnsInternalErrorKind::Utf8)
87    }
88}
89
90impl From<ts_http_util::Error> for SetDnsError {
91    fn from(error: ts_http_util::Error) -> Self {
92        tracing::error!(%error, "http error in set-dns request");
93        if crate::http_error_is_recoverable(error) {
94            SetDnsError::NetworkError
95        } else {
96            SetDnsError::Internal(SetDnsInternalErrorKind::Http)
97        }
98    }
99}
100
101// The shared Noise `connect` surfaces a `ConnectionError`; fold it into our error. The connect
102// crate's richer `InternalErrorKind` is collapsed onto the coarser set-dns kinds.
103impl From<ConnectionError> for SetDnsError {
104    fn from(error: ConnectionError) -> Self {
105        use crate::tokio::connect::InternalErrorKind as Conn;
106        match error {
107            ConnectionError::NetworkError => SetDnsError::NetworkError,
108            ConnectionError::Internal(k) => SetDnsError::Internal(match k {
109                Conn::Url => SetDnsInternalErrorKind::Url,
110                Conn::SerDe => SetDnsInternalErrorKind::SerDe,
111                // Everything else is an unsuccessful request/handshake at the Noise layer.
112                Conn::Http
113                | Conn::MessageFormat
114                | Conn::Io
115                | Conn::ChallengeLength
116                | Conn::NoiseHandshake => SetDnsInternalErrorKind::Http,
117            }),
118        }
119    }
120}
121
122/// Publish a DNS record for this node via control (`POST /machine/set-dns`).
123///
124/// `name`/`record_type`/`value` are the record to publish — e.g.
125/// `("_acme-challenge.host.tailnet.ts.net", "TXT", "<base64url-digest>")` for an ACME DNS-01
126/// challenge. Opens a fresh Noise channel and POSTs the request. Returns `Ok(())` on a 2xx
127/// (the response body is an empty [`SetDnsResponse`]).
128///
129/// The whole connect + POST + response read is bounded by [`SET_DNS_TIMEOUT`]: a hung control
130/// plane is abandoned and reported as [`SetDnsError::NetworkError`] rather than pinning a
131/// half-open connection.
132pub async fn set_dns(
133    config: &crate::Config,
134    node_keystate: &ts_keys::NodeState,
135    name: &str,
136    record_type: &str,
137    value: &str,
138) -> Result<(), SetDnsError> {
139    let control_url = &config.server_url;
140    let rpc = async {
141        let http2_conn = crate::tokio::connect(
142            control_url,
143            &node_keystate.machine_keys,
144            config.allow_http_key_fetch,
145        )
146        .await?;
147        set_dns_with(
148            control_url,
149            node_keystate,
150            name,
151            record_type,
152            value,
153            &http2_conn,
154        )
155        .await
156    };
157
158    match tokio::time::timeout(SET_DNS_TIMEOUT, rpc).await {
159        Ok(result) => result,
160        Err(_elapsed) => {
161            tracing::error!(timeout = ?SET_DNS_TIMEOUT, "set-dns request timed out");
162            Err(SetDnsError::NetworkError)
163        }
164    }
165}
166
167/// Inner: send the `/machine/set-dns` POST over an already-established Noise channel.
168///
169/// Split out from [`set_dns`] so the response-checking logic ([`check_set_dns_status`]) is
170/// unit-testable independent of the Noise connect.
171pub(crate) async fn set_dns_with(
172    control_url: &Url,
173    node_keystate: &ts_keys::NodeState,
174    name: &str,
175    record_type: &str,
176    value: &str,
177    http2_conn: &Http2<BytesBody>,
178) -> Result<(), SetDnsError> {
179    let node_public_key = node_keystate.node_keys.public;
180
181    let req = SetDnsRequest {
182        version: CapabilityVersion::CURRENT,
183        node_key: node_public_key,
184        name: name.to_string(),
185        r#type: record_type.to_string(),
186        value: value.to_string(),
187    };
188
189    let body = serde_json::to_string(&req)?;
190    let url = control_url.join("machine/set-dns")?;
191
192    tracing::debug!(url = %url.as_str(), name, record_type, "publishing dns record via control");
193
194    let response = http2_conn
195        .post(
196            &url,
197            [(
198                LOAD_BALANCER_HEADER_KEY.parse().unwrap(),
199                node_public_key.to_string().parse().unwrap(),
200            )],
201            Bytes::from(body).into(),
202        )
203        .await?;
204
205    let status = response.status();
206    let body = response.collect_bytes().await?;
207    check_set_dns_status(status, &body)
208}
209
210/// Turn a `/machine/set-dns` HTTP response into a success/failure verdict.
211///
212/// Pure (no I/O): factored out of [`set_dns_with`] so the status/body branch logic is
213/// unit-testable without a live stream. A non-2xx status is [`SetDnsInternalErrorKind::Http`]
214/// (logging a truncated body). On 2xx the body is an empty [`SetDnsResponse`]; an empty body is
215/// tolerated as success, and a non-empty body is parsed to confirm the shape (Go's
216/// `SetDnsResponse{}`).
217fn check_set_dns_status(status: StatusCode, body: &[u8]) -> Result<(), SetDnsError> {
218    if !status.is_success() {
219        let mut truncated = body.to_vec();
220        truncated.truncate(512);
221        let preview = core::str::from_utf8(&truncated).unwrap_or("<invalid utf8>");
222        tracing::error!(body = %preview, %status, "set-dns request failed");
223        return Err(SetDnsError::Internal(SetDnsInternalErrorKind::Http));
224    }
225
226    let body = core::str::from_utf8(body)?;
227    // An empty body is a valid success signal (no `{}` payload required).
228    if body.trim().is_empty() {
229        return Ok(());
230    }
231    // Otherwise confirm the body deserializes to the empty `SetDnsResponse` shape.
232    let _resp: SetDnsResponse = serde_json::from_str(body)?;
233    Ok(())
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::tokio::connect::{ConnectionError, InternalErrorKind as ConnKind};
240
241    // --- Error `From` conversions ---
242
243    #[test]
244    fn connection_error_network_maps_to_network() {
245        assert_eq!(
246            SetDnsError::from(ConnectionError::NetworkError),
247            SetDnsError::NetworkError
248        );
249    }
250
251    #[test]
252    fn connection_error_internal_kinds_map_correctly() {
253        use SetDnsInternalErrorKind as Sd;
254        let cases = [
255            (ConnKind::Url, Sd::Url),
256            (ConnKind::SerDe, Sd::SerDe),
257            (ConnKind::Http, Sd::Http),
258            (ConnKind::MessageFormat, Sd::Http),
259            (ConnKind::Io, Sd::Http),
260            (ConnKind::ChallengeLength, Sd::Http),
261            (ConnKind::NoiseHandshake, Sd::Http),
262        ];
263        for (conn, expected) in cases {
264            assert_eq!(
265                SetDnsError::from(ConnectionError::Internal(conn)),
266                SetDnsError::Internal(expected),
267                "ConnectionError::Internal({conn:?}) should map to Internal({expected:?})"
268            );
269        }
270    }
271
272    #[test]
273    fn serde_error_maps_to_internal_serde() {
274        let err = serde_json::from_str::<SetDnsResponse>("not json").unwrap_err();
275        assert_eq!(
276            SetDnsError::from(err),
277            SetDnsError::Internal(SetDnsInternalErrorKind::SerDe)
278        );
279    }
280
281    #[test]
282    fn url_parse_error_maps_to_internal_url() {
283        let err = Url::parse("not a url").unwrap_err();
284        assert_eq!(
285            SetDnsError::from(err),
286            SetDnsError::Internal(SetDnsInternalErrorKind::Url)
287        );
288    }
289
290    #[test]
291    fn utf8_error_maps_to_internal_utf8() {
292        // Route the invalid bytes through a runtime Vec so the `invalid_from_utf8` lint (which only
293        // fires on compile-time-known literals) doesn't flag a genuinely intentional bad input.
294        let bytes = vec![0xffu8, 0xfe];
295        let err = core::str::from_utf8(&bytes).unwrap_err();
296        assert_eq!(
297            SetDnsError::from(err),
298            SetDnsError::Internal(SetDnsInternalErrorKind::Utf8)
299        );
300    }
301
302    #[test]
303    fn http_util_error_non_recoverable_maps_to_internal_http() {
304        let err = ts_http_util::Error::InvalidResponse;
305        assert_eq!(
306            SetDnsError::from(err),
307            SetDnsError::Internal(SetDnsInternalErrorKind::Http)
308        );
309    }
310
311    #[test]
312    fn http_util_error_recoverable_maps_to_network() {
313        let err = ts_http_util::Error::Io;
314        assert_eq!(SetDnsError::from(err), SetDnsError::NetworkError);
315    }
316
317    // --- Status check ---
318
319    #[test]
320    fn check_set_dns_status_ok_empty_body() {
321        // The success contract: HTTP 200 with an empty body.
322        check_set_dns_status(StatusCode::OK, b"").unwrap();
323    }
324
325    #[test]
326    fn check_set_dns_status_ok_empty_json() {
327        // A `{}` body deserializes to the empty `SetDnsResponse`.
328        check_set_dns_status(StatusCode::OK, b"{}").unwrap();
329    }
330
331    #[test]
332    fn check_set_dns_status_self_hosted_501_is_error() {
333        // a self-hosted control plane may not implement `/machine/set-dns` and returns 501 Not Implemented; document
334        // that DOA reality — it must surface as an error, never a silent success.
335        let err =
336            check_set_dns_status(StatusCode::NOT_IMPLEMENTED, b"not implemented").unwrap_err();
337        assert_eq!(err, SetDnsError::Internal(SetDnsInternalErrorKind::Http));
338    }
339
340    #[test]
341    fn check_set_dns_status_500_is_error() {
342        let err =
343            check_set_dns_status(StatusCode::INTERNAL_SERVER_ERROR, b"upstream boom").unwrap_err();
344        assert_eq!(err, SetDnsError::Internal(SetDnsInternalErrorKind::Http));
345    }
346}