Skip to main content

ts_control/
cert.rs

1//! TLS certificate acquisition for a node's MagicDNS name (`host.tailnet.ts.net`).
2//!
3//! # What tsnet does (the real protocol — there is NO control `cert/<domain>` RPC)
4//!
5//! In upstream Tailscale, `tsnet`'s `GetCertificate` mints a *real* publicly
6//! trusted certificate for the node's MagicDNS name. Contrary to a common
7//! misreading, control does **not** run the ACME order on the node's behalf and
8//! there is **no** `POST /machine/<machineKey>/cert/<domain>` endpoint. Instead
9//! **the node itself is the ACME client** and talks **directly to Let's
10//! Encrypt**; control's *only* role is to publish the DNS-01 challenge TXT record
11//! into the `ts.net` zone it controls (the node has no authority over that zone).
12//! The real flow upstream is:
13//!
14//! 1. node generates/loads an ACME account key (ECDSA P-256) and a fresh cert
15//!    key, and opens an ACME order for `<name>` directly against Let's Encrypt,
16//! 2. for the **DNS-01** challenge, the node computes the challenge digest and
17//!    asks control to publish it by sending, over the **Noise (ts2021)** channel,
18//!    `POST /machine/set-dns` with body
19//!    `tailcfg.SetDNSRequest{ Version: <current cap>, NodeKey: <node pub>,
20//!    Name: "_acme-challenge.<name>", Type: "TXT", Value: <digest> }`
21//!    (note: `NodeKey` travels in the BODY, not the URL; the response is an empty
22//!    `SetDNSResponse{}` with HTTP 200 on success),
23//! 3. node tells Let's Encrypt the challenge is ready; LE validates the TXT,
24//! 4. node finalizes the order and downloads the signed leaf + chain *from LE*,
25//! 5. node assembles a [`rustls::sign::CertifiedKey`] and serves it, renewing at
26//!    ~2/3 of lifetime (with ARI).
27//!
28//! (DNS-01 is used for `*.ts.net`; TLS-ALPN-01 is used for Funnel/BYO domains;
29//! HTTP-01 is not used.)
30//!
31//! ## Gap verdict for THIS fork (fail-closed seam, no fake cert)
32//!
33//! The control client in this crate (`ts_control::tokio`) implements exactly
34//! these control RPCs and **no others**:
35//!
36//! - `GET /key`            — control/Noise public key fetch ([`crate::tokio::connect`])
37//! - `POST /ts2021`        — Noise (ts2021) handshake upgrade
38//! - `POST /machine/register` — node registration ([`crate::tokio::register`])
39//! - `POST /machine/map`   — netmap stream + endpoint/derp updates
40//! - ping-response callback (`/machine/.../ping`)
41//!
42//! There is **no** `POST /machine/set-dns` client and **no** ACME engine. Neither
43//! the DNS-01 TXT publish RPC nor the LE-facing order/challenge/finalize state
44//! machine exists, so a node cannot obtain a publicly trusted cert for its
45//! `*.ts.net` name here.
46//!
47//! Because issuing a real cert is impossible and self-signing for production is
48//! forbidden (it would not be publicly trusted and would teach callers to expect
49//! a working `ListenTLS`), [`get_certificate`] returns
50//! [`CertError::Unimplemented`] naming exactly what is missing. This is
51//! **fail-closed**: no self-signed fallback, no plaintext downgrade.
52//!
53//! ## What a future implementation needs (so this seam can be filled in place)
54//!
55//! - A **client-side ACME engine** (talks to Let's Encrypt directly, not to
56//!   control): ACME account key + cert key generation (ECDSA P-256 via `rcgen`,
57//!   ring-only), JWS-signed order/authz/challenge/finalize, and leaf+chain
58//!   download. Renew at ~2/3 lifetime.
59//! - A `POST /machine/set-dns` Noise RPC client to publish the
60//!   `_acme-challenge.<name>` TXT record (body carries `NodeKey`; see step 2
61//!   above). Add it alongside the existing RPCs in [`crate::tokio`]
62//!   (`register.rs` is the template; the Noise transport is `connect.rs`).
63//! - Local ACME account-key persistence keyed to the node identity.
64//!
65//! **Deployment caveat (why this is currently stubbed, not built):** a
66//! self-hosted control plane target may return **HTTP 501
67//! NotImplemented** for `/machine/set-dns`. A client-side ACME engine therefore
68//! cannot complete a DNS-01 challenge against such a control plane — the issuance path
69//! is non-functional until the control plane grows `set-dns` + a real backing DNS zone
70//! (separate, out-of-repo work). Building the ACME engine here without that would
71//! be dead code against the actual control plane.
72//!
73//! Once both pieces land (and control answers `set-dns`), replace the
74//! [`CertError::Unimplemented`] branch in [`get_certificate`] with: open order ->
75//! publish TXT via `set-dns` -> finalize -> assemble [`CertifiedKey`] from the
76//! LE-returned chain + locally held key via [`certified_key_from_pem`].
77
78use tokio_rustls::rustls::{
79    pki_types::{CertificateDer, PrivateKeyDer},
80    sign::CertifiedKey,
81};
82
83/// The control-plane seam the ACME DNS-01 engine depends on: publish (and later clear) the
84/// `_acme-challenge.<name>` TXT record in the `ts.net` zone control owns, by sending the node's
85/// `POST /machine/set-dns` Noise RPC.
86///
87/// Implemented by the runtime's control-RPC layer (which holds the Noise transport + node keys);
88/// the ACME engine ([`crate::acme`], `acme` feature) calls it without depending on the actor types.
89/// `name` is the FULL record name (`_acme-challenge.<host>.<tailnet>.ts.net`), `value` the
90/// base64url-unpadded DNS-01 digest. Returning `Err` fails the issuance closed (no cert).
91#[cfg(feature = "acme")]
92pub trait PublishTxt {
93    /// Publish the DNS-01 challenge TXT record via `POST /machine/set-dns`. Resolves once control
94    /// has accepted the record (HTTP 200 / empty `SetDnsResponse`).
95    fn publish_txt(
96        &self,
97        name: &str,
98        value: &str,
99    ) -> std::pin::Pin<Box<dyn core::future::Future<Output = Result<(), CertError>> + Send + '_>>;
100}
101
102/// Map a [`crate::tokio::SetDnsError`] into [`CertError::Acme`].
103///
104/// The DNS-01 publish is the one I/O step of issuance the ACME engine reaches through the
105/// [`PublishTxt`] seam; fold the set-dns RPC's own error vocabulary into the cert error surface
106/// (its `Display` carries the coarse cause, e.g. the self-hosted control plane 501 `Internal(Http)`).
107#[cfg(feature = "acme")]
108impl From<crate::tokio::SetDnsError> for CertError {
109    fn from(error: crate::tokio::SetDnsError) -> Self {
110        CertError::Acme(format!("set-dns publish failed: {error}"))
111    }
112}
113
114/// A [`PublishTxt`] backed by the node's `POST /machine/set-dns` Noise RPC.
115///
116/// Borrows the node's [`crate::Config`] (control URL + transport) and [`ts_keys::NodeState`] (node
117/// keys for the Noise channel) and publishes the `_acme-challenge.<name>` `TXT` record through
118/// [`crate::tokio::set_dns`]. SaaS-only: a self-hosted control plane typically 501s on `set-dns`, surfaced as
119/// [`CertError::Acme`].
120#[cfg(feature = "acme")]
121pub struct SetDnsPublisher<'a> {
122    /// Control config (server URL + transport) the set-dns RPC dials.
123    config: &'a crate::Config,
124    /// The node's key state, providing the node/machine keys for the Noise channel.
125    node_keystate: &'a ts_keys::NodeState,
126}
127
128#[cfg(feature = "acme")]
129impl<'a> SetDnsPublisher<'a> {
130    /// Build a publisher borrowing the node's control `config` and `node_keystate`.
131    pub fn new(config: &'a crate::Config, node_keystate: &'a ts_keys::NodeState) -> Self {
132        Self {
133            config,
134            node_keystate,
135        }
136    }
137}
138
139#[cfg(feature = "acme")]
140impl PublishTxt for SetDnsPublisher<'_> {
141    fn publish_txt(
142        &self,
143        name: &str,
144        value: &str,
145    ) -> std::pin::Pin<Box<dyn core::future::Future<Output = Result<(), CertError>> + Send + '_>>
146    {
147        let name = name.to_string();
148        let value = value.to_string();
149        Box::pin(async move {
150            crate::tokio::set_dns(self.config, self.node_keystate, &name, "TXT", &value)
151                .await
152                .map_err(CertError::from)
153        })
154    }
155}
156
157/// Issue a real certificate for `name` via the client-side ACME DNS-01 engine, publishing the
158/// challenge TXT through the node's `POST /machine/set-dns` RPC.
159///
160/// `account_key` is the ACME account identity (persist its PKCS#8 DER across renewals — see the
161/// runtime caller); `directory_url` selects the ACME CA (production is
162/// [`crate::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY`]). Rejects non-tailnet names up front (anti-leak)
163/// before any network I/O. SaaS-only: against a self-hosted control plane the set-dns publish typically 501s, surfaced as
164/// [`CertError::Acme`]. Fail-closed: returns a [`CertifiedKey`] only when the LE order reached
165/// `valid` and the chain assembled.
166#[cfg(feature = "acme")]
167pub async fn issue_certificate_via_setdns(
168    config: &crate::Config,
169    node_keystate: &ts_keys::NodeState,
170    name: &str,
171    account_key: &crate::acme::AcmeAccountKey,
172    directory_url: &url::Url,
173) -> Result<CertifiedKey, CertError> {
174    if !is_tailnet_name(name) {
175        return Err(CertError::NotTailnetName(name.to_string()));
176    }
177    let publisher = SetDnsPublisher::new(config, node_keystate);
178    crate::acme::issue_certificate(name, directory_url, account_key, &publisher).await
179}
180
181/// Names exactly what this fork is missing to issue a real cert, surfaced
182/// verbatim in [`CertError::Unimplemented`] so the gap is self-documenting at
183/// runtime. There is no control `cert/<domain>` RPC in real Tailscale — the node
184/// is the ACME client and only needs control to publish the DNS-01 TXT via
185/// `POST /machine/set-dns` (which a self-hosted control plane typically 501s). See the module docs.
186pub const MISSING_CERT_RPC: &str = "client-side ACME engine (direct to Let's Encrypt) + a POST /machine/set-dns \
187     Noise RPC to publish the _acme-challenge TXT (a self-hosted control plane returns 501 for set-dns)";
188
189/// Errors from certificate acquisition / TLS material assembly.
190///
191/// Fail-closed by construction: there is no variant that yields a usable cert
192/// without a genuine issuance path, and there is deliberately no self-signed
193/// production fallback.
194#[derive(Debug)]
195pub enum CertError {
196    /// The control plane in this fork does not expose the RPC(s) needed to mint
197    /// a real certificate. `detail` names exactly what is missing.
198    Unimplemented {
199        /// Names exactly which control RPC is missing (e.g. [`MISSING_CERT_RPC`]).
200        detail: String,
201    },
202    /// An ACME-protocol-level failure (order/challenge/finalize).
203    Acme(String),
204    /// I/O failure (network, file, etc.).
205    Io(std::io::Error),
206    /// A rustls / crypto-material failure (bad key, mismatched cert, provider).
207    Rustls(tokio_rustls::rustls::Error),
208    /// The requested name is not a tailnet (`*.ts.net`-style) name. Anti-leak:
209    /// we never mint or serve certs for off-tailnet names.
210    NotTailnetName(String),
211}
212
213impl core::fmt::Display for CertError {
214    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
215        match self {
216            CertError::Unimplemented { detail } => {
217                write!(
218                    f,
219                    "certificate acquisition is unimplemented in this fork: {detail}"
220                )
221            }
222            CertError::Acme(e) => write!(f, "ACME error: {e}"),
223            CertError::Io(e) => write!(f, "I/O error: {e}"),
224            CertError::Rustls(e) => write!(f, "rustls error: {e}"),
225            CertError::NotTailnetName(name) => {
226                write!(
227                    f,
228                    "refusing to obtain a certificate for non-tailnet name {name:?}"
229                )
230            }
231        }
232    }
233}
234
235impl std::error::Error for CertError {
236    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
237        match self {
238            CertError::Io(e) => Some(e),
239            CertError::Rustls(e) => Some(e),
240            CertError::Unimplemented { .. } | CertError::Acme(_) | CertError::NotTailnetName(_) => {
241                None
242            }
243        }
244    }
245}
246
247impl From<std::io::Error> for CertError {
248    fn from(e: std::io::Error) -> Self {
249        CertError::Io(e)
250    }
251}
252
253impl From<tokio_rustls::rustls::Error> for CertError {
254    fn from(e: tokio_rustls::rustls::Error) -> Self {
255        CertError::Rustls(e)
256    }
257}
258
259/// Returns `true` if `name` looks like a tailnet MagicDNS name we may serve a
260/// cert for. We only ever mint/serve certs for tailnet names — never arbitrary
261/// public hostnames — to avoid being turned into a cert oracle for off-tailnet
262/// origins.
263pub fn is_tailnet_name(name: &str) -> bool {
264    // `host.tailnet.ts.net` (public) or `*.ts.net`. Keep this conservative.
265    let name = name.trim_end_matches('.');
266    !name.is_empty() && name.ends_with(".ts.net") && !name.contains('/')
267}
268
269/// Obtain a [`CertifiedKey`] for a node's MagicDNS `name`.
270///
271/// **Fail-closed.** In this fork the control plane exposes no ACME / DNS-01 cert
272/// RPC (see module docs), so this always returns [`CertError::Unimplemented`]
273/// once the name passes the tailnet-name check. It NEVER self-signs and NEVER
274/// returns a placeholder cert — a caller cannot accidentally serve an untrusted
275/// certificate.
276///
277/// When the control RPC ([`MISSING_CERT_RPC`]) is added, fill in the issuance
278/// branch here.
279pub async fn get_certificate(name: &str) -> Result<CertifiedKey, CertError> {
280    if !is_tailnet_name(name) {
281        return Err(CertError::NotTailnetName(name.to_string()));
282    }
283
284    // No client-side ACME engine and no set-dns RPC exist in this fork, and a
285    // self-hosted control target typically 501s on set-dns. Do NOT self-sign.
286    Err(CertError::Unimplemented {
287        detail: format!(
288            "cannot issue a real certificate for {name:?}; requires: {MISSING_CERT_RPC}"
289        ),
290    })
291}
292
293/// Assemble a [`CertifiedKey`] from a PEM chain + PEM private key, using the
294/// **ring** crypto provider's signing-key loader (matching the rest of the TLS
295/// stack — `ts_tls_util` is `tokio-rustls`/`ring`). This is the assembly helper
296/// a future real issuance path (or a test) feeds the control-returned chain into.
297///
298/// This does NOT fetch or issue anything; it only turns already-trusted material
299/// into the rustls representation. Production callers reach it only via a genuine
300/// issuance path; tests reach it with a clearly-marked self-signed cert.
301pub fn certified_key_from_pem(
302    cert_chain_pem: &[u8],
303    key_pem: &[u8],
304) -> Result<CertifiedKey, CertError> {
305    let certs: Vec<CertificateDer<'static>> =
306        rustls_pemfile::certs(&mut &cert_chain_pem[..]).collect::<Result<_, _>>()?;
307    if certs.is_empty() {
308        return Err(CertError::Acme(
309            "certificate chain PEM contained no certificates".into(),
310        ));
311    }
312
313    let key: PrivateKeyDer<'static> = rustls_pemfile::private_key(&mut &key_pem[..])?
314        .ok_or_else(|| CertError::Acme("private key PEM contained no key".into()))?;
315
316    certified_key_from_der(certs, key)
317}
318
319/// Assemble a [`CertifiedKey`] from DER cert chain + DER private key using the
320/// ring signing-key loader. Verifies the key matches the leaf (fail-closed).
321pub fn certified_key_from_der(
322    cert_chain: Vec<CertificateDer<'static>>,
323    key: PrivateKeyDer<'static>,
324) -> Result<CertifiedKey, CertError> {
325    // Match the rest of the stack: ring provider's signing-key loader, never
326    // auto-detect (which panics under ring+aws-lc feature unification).
327    // `any_supported_type` already yields an `Arc<dyn SigningKey>`; don't re-wrap.
328    let signing_key = tokio_rustls::rustls::crypto::ring::sign::any_supported_type(&key)
329        .map_err(CertError::Rustls)?;
330    let ck = CertifiedKey::new(cert_chain, signing_key);
331    ck.keys_match().map_err(CertError::Rustls)?;
332    Ok(ck)
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn tailnet_name_accepts_magicdns() {
341        assert!(is_tailnet_name("host.tail1234.ts.net"));
342        assert!(is_tailnet_name("host.tail1234.ts.net."));
343    }
344
345    #[test]
346    fn tailnet_name_rejects_offtailnet() {
347        assert!(!is_tailnet_name("example.com"));
348        assert!(!is_tailnet_name("evil.ts.net.attacker.com"));
349        assert!(!is_tailnet_name(""));
350        assert!(!is_tailnet_name("host.ts.net/path"));
351    }
352
353    #[tokio::test]
354    async fn get_certificate_is_fail_closed_unimplemented() {
355        let err = get_certificate("host.tail1234.ts.net")
356            .await
357            .expect_err("must not mint a cert without an ACME RPC");
358        match err {
359            CertError::Unimplemented { detail } => {
360                assert!(
361                    detail.contains("cert"),
362                    "detail should name the missing RPC: {detail}"
363                );
364            }
365            other => panic!("expected Unimplemented, got {other:?}"),
366        }
367    }
368
369    #[tokio::test]
370    async fn get_certificate_rejects_offtailnet_name() {
371        let err = get_certificate("example.com").await.unwrap_err();
372        assert!(matches!(err, CertError::NotTailnetName(_)));
373    }
374
375    #[test]
376    fn cert_error_is_std_error_and_displays() {
377        let e = CertError::Unimplemented { detail: "x".into() };
378        let _: &dyn std::error::Error = &e;
379        assert!(format!("{e}").contains("unimplemented"));
380    }
381
382    /// `issue_certificate_via_setdns` rejects a non-tailnet name with [`CertError::NotTailnetName`]
383    /// BEFORE any network I/O (the `is_tailnet_name` guard fires first). This is the only path
384    /// reachable without a live control plane / ACME CA, and it proves the anti-leak guard.
385    #[cfg(feature = "acme")]
386    #[tokio::test]
387    async fn issue_via_setdns_rejects_offtailnet_before_network() {
388        let config = crate::Config::default();
389        let keystate = ts_keys::NodeState::generate();
390        let (account_key, _der) = crate::acme::AcmeAccountKey::generate().expect("generate");
391        let directory = url::Url::parse(crate::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY).unwrap();
392
393        let err = issue_certificate_via_setdns(
394            &config,
395            &keystate,
396            "example.com",
397            &account_key,
398            &directory,
399        )
400        .await
401        .expect_err("must refuse a non-tailnet name without touching the network");
402        assert!(matches!(err, CertError::NotTailnetName(_)));
403    }
404
405    /// `SetDnsPublisher` implements [`PublishTxt`] (compile-level assertion).
406    #[cfg(feature = "acme")]
407    #[test]
408    fn set_dns_publisher_is_publish_txt() {
409        fn assert_publish_txt<T: PublishTxt>() {}
410        assert_publish_txt::<SetDnsPublisher<'_>>();
411    }
412}