geiserx_ts_control 0.23.0

tailscale control client
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
//! TLS certificate acquisition for a node's MagicDNS name (`host.tailnet.ts.net`).
//!
//! # What tsnet does (the real protocol — there is NO control `cert/<domain>` RPC)
//!
//! In upstream Tailscale, `tsnet`'s `GetCertificate` mints a *real* publicly
//! trusted certificate for the node's MagicDNS name. Contrary to a common
//! misreading, control does **not** run the ACME order on the node's behalf and
//! there is **no** `POST /machine/<machineKey>/cert/<domain>` endpoint. Instead
//! **the node itself is the ACME client** and talks **directly to Let's
//! Encrypt**; control's *only* role is to publish the DNS-01 challenge TXT record
//! into the `ts.net` zone it controls (the node has no authority over that zone).
//! The real flow upstream is:
//!
//! 1. node generates/loads an ACME account key (ECDSA P-256) and a fresh cert
//!    key, and opens an ACME order for `<name>` directly against Let's Encrypt,
//! 2. for the **DNS-01** challenge, the node computes the challenge digest and
//!    asks control to publish it by sending, over the **Noise (ts2021)** channel,
//!    `POST /machine/set-dns` with body
//!    `tailcfg.SetDNSRequest{ Version: <current cap>, NodeKey: <node pub>,
//!    Name: "_acme-challenge.<name>", Type: "TXT", Value: <digest> }`
//!    (note: `NodeKey` travels in the BODY, not the URL; the response is an empty
//!    `SetDNSResponse{}` with HTTP 200 on success),
//! 3. node tells Let's Encrypt the challenge is ready; LE validates the TXT,
//! 4. node finalizes the order and downloads the signed leaf + chain *from LE*,
//! 5. node assembles a [`rustls::sign::CertifiedKey`] and serves it, renewing at
//!    ~2/3 of lifetime (with ARI).
//!
//! (DNS-01 is used for `*.ts.net`; TLS-ALPN-01 is used for Funnel/BYO domains;
//! HTTP-01 is not used.)
//!
//! ## Gap verdict for THIS fork (fail-closed seam, no fake cert)
//!
//! The control client in this crate (`ts_control::tokio`) implements exactly
//! these control RPCs and **no others**:
//!
//! - `GET /key`            — control/Noise public key fetch ([`crate::tokio::connect`])
//! - `POST /ts2021`        — Noise (ts2021) handshake upgrade
//! - `POST /machine/register` — node registration ([`crate::tokio::register`])
//! - `POST /machine/map`   — netmap stream + endpoint/derp updates
//! - ping-response callback (`/machine/.../ping`)
//!
//! There is **no** `POST /machine/set-dns` client and **no** ACME engine. Neither
//! the DNS-01 TXT publish RPC nor the LE-facing order/challenge/finalize state
//! machine exists, so a node cannot obtain a publicly trusted cert for its
//! `*.ts.net` name here.
//!
//! Because issuing a real cert is impossible and self-signing for production is
//! forbidden (it would not be publicly trusted and would teach callers to expect
//! a working `ListenTLS`), [`get_certificate`] returns
//! [`CertError::Unimplemented`] naming exactly what is missing. This is
//! **fail-closed**: no self-signed fallback, no plaintext downgrade.
//!
//! ## What a future implementation needs (so this seam can be filled in place)
//!
//! - A **client-side ACME engine** (talks to Let's Encrypt directly, not to
//!   control): ACME account key + cert key generation (ECDSA P-256 via `rcgen`,
//!   ring-only), JWS-signed order/authz/challenge/finalize, and leaf+chain
//!   download. Renew at ~2/3 lifetime.
//! - A `POST /machine/set-dns` Noise RPC client to publish the
//!   `_acme-challenge.<name>` TXT record (body carries `NodeKey`; see step 2
//!   above). Add it alongside the existing RPCs in [`crate::tokio`]
//!   (`register.rs` is the template; the Noise transport is `connect.rs`).
//! - Local ACME account-key persistence keyed to the node identity.
//!
//! **Deployment caveat (why this is currently stubbed, not built):** a
//! self-hosted control plane target may return **HTTP 501
//! NotImplemented** for `/machine/set-dns`. A client-side ACME engine therefore
//! cannot complete a DNS-01 challenge against such a control plane — the issuance path
//! is non-functional until the control plane grows `set-dns` + a real backing DNS zone
//! (separate, out-of-repo work). Building the ACME engine here without that would
//! be dead code against the actual control plane.
//!
//! Once both pieces land (and control answers `set-dns`), replace the
//! [`CertError::Unimplemented`] branch in [`get_certificate`] with: open order ->
//! publish TXT via `set-dns` -> finalize -> assemble [`CertifiedKey`] from the
//! LE-returned chain + locally held key via [`certified_key_from_pem`].

use tokio_rustls::rustls::{
    pki_types::{CertificateDer, PrivateKeyDer},
    sign::CertifiedKey,
};

/// The control-plane seam the ACME DNS-01 engine depends on: publish (and later clear) the
/// `_acme-challenge.<name>` TXT record in the `ts.net` zone control owns, by sending the node's
/// `POST /machine/set-dns` Noise RPC.
///
/// Implemented by the runtime's control-RPC layer (which holds the Noise transport + node keys);
/// the ACME engine ([`crate::acme`], `acme` feature) calls it without depending on the actor types.
/// `name` is the FULL record name (`_acme-challenge.<host>.<tailnet>.ts.net`), `value` the
/// base64url-unpadded DNS-01 digest. Returning `Err` fails the issuance closed (no cert).
#[cfg(feature = "acme")]
pub trait PublishTxt {
    /// Publish the DNS-01 challenge TXT record via `POST /machine/set-dns`. Resolves once control
    /// has accepted the record (HTTP 200 / empty `SetDnsResponse`).
    fn publish_txt(
        &self,
        name: &str,
        value: &str,
    ) -> std::pin::Pin<Box<dyn core::future::Future<Output = Result<(), CertError>> + Send + '_>>;
}

/// Map a [`crate::tokio::SetDnsError`] into [`CertError::Acme`].
///
/// The DNS-01 publish is the one I/O step of issuance the ACME engine reaches through the
/// [`PublishTxt`] seam; fold the set-dns RPC's own error vocabulary into the cert error surface
/// (its `Display` carries the coarse cause, e.g. the self-hosted control plane 501 `Internal(Http)`).
#[cfg(feature = "acme")]
impl From<crate::tokio::SetDnsError> for CertError {
    fn from(error: crate::tokio::SetDnsError) -> Self {
        CertError::Acme(format!("set-dns publish failed: {error}"))
    }
}

/// A [`PublishTxt`] backed by the node's `POST /machine/set-dns` Noise RPC.
///
/// Borrows the node's [`crate::Config`] (control URL + transport) and [`ts_keys::NodeState`] (node
/// keys for the Noise channel) and publishes the `_acme-challenge.<name>` `TXT` record through
/// [`crate::tokio::set_dns`]. SaaS-only: a self-hosted control plane typically 501s on `set-dns`, surfaced as
/// [`CertError::Acme`].
#[cfg(feature = "acme")]
pub struct SetDnsPublisher<'a> {
    /// Control config (server URL + transport) the set-dns RPC dials.
    config: &'a crate::Config,
    /// The node's key state, providing the node/machine keys for the Noise channel.
    node_keystate: &'a ts_keys::NodeState,
}

#[cfg(feature = "acme")]
impl<'a> SetDnsPublisher<'a> {
    /// Build a publisher borrowing the node's control `config` and `node_keystate`.
    pub fn new(config: &'a crate::Config, node_keystate: &'a ts_keys::NodeState) -> Self {
        Self {
            config,
            node_keystate,
        }
    }
}

#[cfg(feature = "acme")]
impl PublishTxt for SetDnsPublisher<'_> {
    fn publish_txt(
        &self,
        name: &str,
        value: &str,
    ) -> std::pin::Pin<Box<dyn core::future::Future<Output = Result<(), CertError>> + Send + '_>>
    {
        let name = name.to_string();
        let value = value.to_string();
        Box::pin(async move {
            crate::tokio::set_dns(self.config, self.node_keystate, &name, "TXT", &value)
                .await
                .map_err(CertError::from)
        })
    }
}

/// Issue a real certificate for `name` via the client-side ACME DNS-01 engine, publishing the
/// challenge TXT through the node's `POST /machine/set-dns` RPC.
///
/// `account_key` is the ACME account identity (persist its PKCS#8 DER across renewals — see the
/// runtime caller); `directory_url` selects the ACME CA (production is
/// [`crate::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY`]). Rejects non-tailnet names up front (anti-leak)
/// before any network I/O. SaaS-only: against a self-hosted control plane the set-dns publish typically 501s, surfaced as
/// [`CertError::Acme`]. Fail-closed: returns a [`CertifiedKey`] only when the LE order reached
/// `valid` and the chain assembled.
#[cfg(feature = "acme")]
pub async fn issue_certificate_via_setdns(
    config: &crate::Config,
    node_keystate: &ts_keys::NodeState,
    name: &str,
    account_key: &crate::acme::AcmeAccountKey,
    directory_url: &url::Url,
) -> Result<CertifiedKey, CertError> {
    if !is_tailnet_name(name) {
        return Err(CertError::NotTailnetName(name.to_string()));
    }
    let publisher = SetDnsPublisher::new(config, node_keystate);
    crate::acme::issue_certificate(name, directory_url, account_key, &publisher).await
}

/// Names exactly what this fork is missing to issue a real cert, surfaced
/// verbatim in [`CertError::Unimplemented`] so the gap is self-documenting at
/// runtime. There is no control `cert/<domain>` RPC in real Tailscale — the node
/// is the ACME client and only needs control to publish the DNS-01 TXT via
/// `POST /machine/set-dns` (which a self-hosted control plane typically 501s). See the module docs.
pub const MISSING_CERT_RPC: &str = "client-side ACME engine (direct to Let's Encrypt) + a POST /machine/set-dns \
     Noise RPC to publish the _acme-challenge TXT (a self-hosted control plane returns 501 for set-dns)";

/// Errors from certificate acquisition / TLS material assembly.
///
/// Fail-closed by construction: there is no variant that yields a usable cert
/// without a genuine issuance path, and there is deliberately no self-signed
/// production fallback.
#[derive(Debug)]
pub enum CertError {
    /// The control plane in this fork does not expose the RPC(s) needed to mint
    /// a real certificate. `detail` names exactly what is missing.
    Unimplemented {
        /// Names exactly which control RPC is missing (e.g. [`MISSING_CERT_RPC`]).
        detail: String,
    },
    /// An ACME-protocol-level failure (order/challenge/finalize).
    Acme(String),
    /// I/O failure (network, file, etc.).
    Io(std::io::Error),
    /// A rustls / crypto-material failure (bad key, mismatched cert, provider).
    Rustls(tokio_rustls::rustls::Error),
    /// The requested name is not a tailnet (`*.ts.net`-style) name. Anti-leak:
    /// we never mint or serve certs for off-tailnet names.
    NotTailnetName(String),
}

impl core::fmt::Display for CertError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            CertError::Unimplemented { detail } => {
                write!(
                    f,
                    "certificate acquisition is unimplemented in this fork: {detail}"
                )
            }
            CertError::Acme(e) => write!(f, "ACME error: {e}"),
            CertError::Io(e) => write!(f, "I/O error: {e}"),
            CertError::Rustls(e) => write!(f, "rustls error: {e}"),
            CertError::NotTailnetName(name) => {
                write!(
                    f,
                    "refusing to obtain a certificate for non-tailnet name {name:?}"
                )
            }
        }
    }
}

impl std::error::Error for CertError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            CertError::Io(e) => Some(e),
            CertError::Rustls(e) => Some(e),
            CertError::Unimplemented { .. } | CertError::Acme(_) | CertError::NotTailnetName(_) => {
                None
            }
        }
    }
}

impl From<std::io::Error> for CertError {
    fn from(e: std::io::Error) -> Self {
        CertError::Io(e)
    }
}

impl From<tokio_rustls::rustls::Error> for CertError {
    fn from(e: tokio_rustls::rustls::Error) -> Self {
        CertError::Rustls(e)
    }
}

/// Returns `true` if `name` looks like a tailnet MagicDNS name we may serve a
/// cert for. We only ever mint/serve certs for tailnet names — never arbitrary
/// public hostnames — to avoid being turned into a cert oracle for off-tailnet
/// origins.
pub fn is_tailnet_name(name: &str) -> bool {
    // `host.tailnet.ts.net` (public) or `*.ts.net`. Keep this conservative.
    let name = name.trim_end_matches('.');
    !name.is_empty() && name.ends_with(".ts.net") && !name.contains('/')
}

/// Obtain a [`CertifiedKey`] for a node's MagicDNS `name`.
///
/// **Fail-closed.** In this fork the control plane exposes no ACME / DNS-01 cert
/// RPC (see module docs), so this always returns [`CertError::Unimplemented`]
/// once the name passes the tailnet-name check. It NEVER self-signs and NEVER
/// returns a placeholder cert — a caller cannot accidentally serve an untrusted
/// certificate.
///
/// When the control RPC ([`MISSING_CERT_RPC`]) is added, fill in the issuance
/// branch here.
pub async fn get_certificate(name: &str) -> Result<CertifiedKey, CertError> {
    if !is_tailnet_name(name) {
        return Err(CertError::NotTailnetName(name.to_string()));
    }

    // No client-side ACME engine and no set-dns RPC exist in this fork, and a
    // self-hosted control target typically 501s on set-dns. Do NOT self-sign.
    Err(CertError::Unimplemented {
        detail: format!(
            "cannot issue a real certificate for {name:?}; requires: {MISSING_CERT_RPC}"
        ),
    })
}

/// Assemble a [`CertifiedKey`] from a PEM chain + PEM private key, using the
/// **ring** crypto provider's signing-key loader (matching the rest of the TLS
/// stack — `ts_tls_util` is `tokio-rustls`/`ring`). This is the assembly helper
/// a future real issuance path (or a test) feeds the control-returned chain into.
///
/// This does NOT fetch or issue anything; it only turns already-trusted material
/// into the rustls representation. Production callers reach it only via a genuine
/// issuance path; tests reach it with a clearly-marked self-signed cert.
pub fn certified_key_from_pem(
    cert_chain_pem: &[u8],
    key_pem: &[u8],
) -> Result<CertifiedKey, CertError> {
    let certs: Vec<CertificateDer<'static>> =
        rustls_pemfile::certs(&mut &cert_chain_pem[..]).collect::<Result<_, _>>()?;
    if certs.is_empty() {
        return Err(CertError::Acme(
            "certificate chain PEM contained no certificates".into(),
        ));
    }

    let key: PrivateKeyDer<'static> = rustls_pemfile::private_key(&mut &key_pem[..])?
        .ok_or_else(|| CertError::Acme("private key PEM contained no key".into()))?;

    certified_key_from_der(certs, key)
}

/// Assemble a [`CertifiedKey`] from DER cert chain + DER private key using the
/// ring signing-key loader. Verifies the key matches the leaf (fail-closed).
pub fn certified_key_from_der(
    cert_chain: Vec<CertificateDer<'static>>,
    key: PrivateKeyDer<'static>,
) -> Result<CertifiedKey, CertError> {
    // Match the rest of the stack: ring provider's signing-key loader, never
    // auto-detect (which panics under ring+aws-lc feature unification).
    // `any_supported_type` already yields an `Arc<dyn SigningKey>`; don't re-wrap.
    let signing_key = tokio_rustls::rustls::crypto::ring::sign::any_supported_type(&key)
        .map_err(CertError::Rustls)?;
    let ck = CertifiedKey::new(cert_chain, signing_key);
    ck.keys_match().map_err(CertError::Rustls)?;
    Ok(ck)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn tailnet_name_accepts_magicdns() {
        assert!(is_tailnet_name("host.tail1234.ts.net"));
        assert!(is_tailnet_name("host.tail1234.ts.net."));
    }

    #[test]
    fn tailnet_name_rejects_offtailnet() {
        assert!(!is_tailnet_name("example.com"));
        assert!(!is_tailnet_name("evil.ts.net.attacker.com"));
        assert!(!is_tailnet_name(""));
        assert!(!is_tailnet_name("host.ts.net/path"));
    }

    #[tokio::test]
    async fn get_certificate_is_fail_closed_unimplemented() {
        let err = get_certificate("host.tail1234.ts.net")
            .await
            .expect_err("must not mint a cert without an ACME RPC");
        match err {
            CertError::Unimplemented { detail } => {
                assert!(
                    detail.contains("cert"),
                    "detail should name the missing RPC: {detail}"
                );
            }
            other => panic!("expected Unimplemented, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn get_certificate_rejects_offtailnet_name() {
        let err = get_certificate("example.com").await.unwrap_err();
        assert!(matches!(err, CertError::NotTailnetName(_)));
    }

    #[test]
    fn cert_error_is_std_error_and_displays() {
        let e = CertError::Unimplemented { detail: "x".into() };
        let _: &dyn std::error::Error = &e;
        assert!(format!("{e}").contains("unimplemented"));
    }

    /// `issue_certificate_via_setdns` rejects a non-tailnet name with [`CertError::NotTailnetName`]
    /// BEFORE any network I/O (the `is_tailnet_name` guard fires first). This is the only path
    /// reachable without a live control plane / ACME CA, and it proves the anti-leak guard.
    #[cfg(feature = "acme")]
    #[tokio::test]
    async fn issue_via_setdns_rejects_offtailnet_before_network() {
        let config = crate::Config::default();
        let keystate = ts_keys::NodeState::generate();
        let (account_key, _der) = crate::acme::AcmeAccountKey::generate().expect("generate");
        let directory = url::Url::parse(crate::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY).unwrap();

        let err = issue_certificate_via_setdns(
            &config,
            &keystate,
            "example.com",
            &account_key,
            &directory,
        )
        .await
        .expect_err("must refuse a non-tailnet name without touching the network");
        assert!(matches!(err, CertError::NotTailnetName(_)));
    }

    /// `SetDnsPublisher` implements [`PublishTxt`] (compile-level assertion).
    #[cfg(feature = "acme")]
    #[test]
    fn set_dns_publisher_is_publish_txt() {
        fn assert_publish_txt<T: PublishTxt>() {}
        assert_publish_txt::<SetDnsPublisher<'_>>();
    }
}