Skip to main content

dnsy_core/
lib.rs

1//! Shared vocabulary types for DNSY. No logic beyond small, total helpers.
2
3mod clock;
4
5#[cfg(feature = "test-util")]
6pub use clock::ManualClock;
7pub use clock::{Clock, SystemClock};
8
9use std::net::{Ipv4Addr, Ipv6Addr};
10
11use serde::{Deserialize, Serialize};
12
13mod key;
14
15pub use key::{KeyRole, KeyState};
16
17/// Transport a DNS request arrived on.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum Transport {
21    Udp,
22    Tcp,
23    Dot,
24    Doh,
25}
26
27/// Database id of a user.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(transparent)]
30pub struct UserId(pub i64);
31
32/// Database id of an API token.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(transparent)]
35pub struct TokenId(pub i64);
36
37/// Who made a request.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum Principal {
40    Token { token: TokenId, user: UserId },
41    ClientCert { sha256: [u8; 32], subject: String },
42}
43
44/// User role.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum Role {
48    Admin,
49    Operator,
50    Viewer,
51}
52
53/// Permission scope carried by a token or granted by a role.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
55pub enum Scope {
56    #[serde(rename = "dns:query")]
57    DnsQuery,
58    #[serde(rename = "api:read")]
59    ApiRead,
60    #[serde(rename = "api:write")]
61    ApiWrite,
62    #[serde(rename = "admin")]
63    Admin,
64}
65
66impl Role {
67    /// Whether this role is allowed to exercise `scope`.
68    #[must_use]
69    pub fn allows(self, scope: Scope) -> bool {
70        match self {
71            Role::Admin => true,
72            Role::Operator => matches!(scope, Scope::ApiRead | Scope::ApiWrite | Scope::DnsQuery),
73            Role::Viewer => matches!(scope, Scope::ApiRead),
74        }
75    }
76}
77
78/// DNSSEC validation outcome (RFC 4035 section 4.3) plus `Unchecked`
79/// (validation off, CD=1, or locally generated answer).
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
81#[serde(rename_all = "lowercase")]
82pub enum DnssecStatus {
83    Unchecked,
84    Secure,
85    Insecure,
86    Bogus,
87    Indeterminate,
88}
89
90/// Hexadecimal encoding shared by the CLI, the API and the engine.
91pub mod hex {
92    use std::fmt::Write as _;
93
94    /// Upper-case hexadecimal.
95    #[must_use]
96    pub fn upper(bytes: &[u8]) -> String {
97        bytes.iter().fold(String::new(), |mut s, b| {
98            let _ = write!(s, "{b:02X}");
99            s
100        })
101    }
102
103    /// Lower-case hexadecimal.
104    #[must_use]
105    pub fn lower(bytes: &[u8]) -> String {
106        bytes.iter().fold(String::new(), |mut s, b| {
107            let _ = write!(s, "{b:02x}");
108            s
109        })
110    }
111
112    #[cfg(test)]
113    mod tests {
114        #[test]
115        fn encodes_both_cases() {
116            assert_eq!(super::upper(&[0x00, 0xab, 0xFF]), "00ABFF");
117            assert_eq!(super::lower(&[0x00, 0xab, 0xFF]), "00abff");
118            assert_eq!(super::upper(&[]), "");
119        }
120    }
121}
122
123/// Network vocabulary shared by every per-client limit.
124pub mod net {
125    use std::net::{IpAddr, Ipv6Addr};
126
127    pub use ipnet::IpNet;
128
129    /// Key under which per-client state is held: an IPv4 address as `/32`, an IPv6 address as its
130    /// `/64` (one subscriber owns a whole `/64`, so per-address keys would be trivially bypassed).
131    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
132    pub struct ClientKey(IpAddr);
133
134    impl ClientKey {
135        /// The canonical address (IPv6 with the low 64 bits zeroed).
136        #[must_use]
137        pub fn ip(self) -> IpAddr {
138            self.0
139        }
140    }
141
142    impl std::fmt::Display for ClientKey {
143        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144            self.0.fmt(f)
145        }
146    }
147
148    /// Client key of `ip`. IPv4-mapped IPv6 addresses count as IPv4.
149    #[must_use]
150    pub fn client_key(ip: IpAddr) -> ClientKey {
151        ClientKey(match ip.to_canonical() {
152            IpAddr::V4(v4) => IpAddr::V4(v4),
153            IpAddr::V6(v6) => IpAddr::V6(Ipv6Addr::from(u128::from(v6) & (!0u128 << 64))),
154        })
155    }
156
157    /// Most trusted hops [`forwarded_client`] skips before giving up.
158    pub const MAX_TRUSTED_HOPS: usize = 16;
159
160    /// The key of a request from a trusted proxy that names no usable client: no
161    /// `X-Forwarded-For`, every entry a trusted proxy, more than [`MAX_TRUSTED_HOPS`] trusted
162    /// entries, or a malformed entry reached on the walk. The unspecified address `0.0.0.0` is
163    /// never a real peer, so it carries no ACL `allow`, no private-network trust and no
164    /// per-client allowance of the proxy itself; all such requests share one bucket.
165    pub const UNKNOWN_FORWARDED: IpAddr = IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED);
166
167    /// Whether `ip` (IPv4-mapped IPv6 counted as IPv4) lies inside one of `trusted`.
168    #[must_use]
169    pub fn is_trusted(ip: IpAddr, trusted: &[IpNet]) -> bool {
170        let ip = ip.to_canonical();
171        trusted.iter().any(|net| net.contains(&ip))
172    }
173
174    /// The client address of a request that arrived from `peer` carrying the `X-Forwarded-For`
175    /// header instances `xff` (in order), given the proxies listed in `trusted`.
176    ///
177    /// - `peer` not trusted: `peer`; the header is ignored entirely.
178    /// - Otherwise the instances are walked right to left (last instance first, each split on
179    ///   `,`); entries inside `trusted` are skipped and the first entry outside it is the client.
180    ///   Entries left of the client are never looked at, so a client-supplied prefix cannot
181    ///   affect the result.
182    /// - A reached entry that is not a bare IPv4/IPv6 address or a `[v6]` literal (a port suffix,
183    ///   a hostname, an empty entry), more than [`MAX_TRUSTED_HOPS`] trusted entries, no header,
184    ///   or every entry trusted: [`UNKNOWN_FORWARDED`]. A trusted peer never gets its own address
185    ///   back, so nothing a request does can borrow the proxy's identity.
186    #[must_use]
187    pub fn forwarded_client<'a>(
188        peer: IpAddr,
189        xff: impl DoubleEndedIterator<Item = &'a str>,
190        trusted: &[IpNet],
191    ) -> IpAddr {
192        if !is_trusted(peer, trusted) {
193            return peer;
194        }
195        let mut hops = 0usize;
196        for entry in xff.rev().flat_map(|value| value.rsplit(',')) {
197            let Some(ip) = parse_forwarded_entry(entry.trim()) else {
198                return UNKNOWN_FORWARDED;
199            };
200            if !is_trusted(ip, trusted) {
201                return ip.to_canonical();
202            }
203            hops += 1;
204            if hops >= MAX_TRUSTED_HOPS {
205                return UNKNOWN_FORWARDED;
206            }
207        }
208        UNKNOWN_FORWARDED
209    }
210
211    fn parse_forwarded_entry(entry: &str) -> Option<IpAddr> {
212        if let Some(inner) = entry.strip_prefix('[').and_then(|e| e.strip_suffix(']')) {
213            return inner.parse::<std::net::Ipv6Addr>().ok().map(IpAddr::V6);
214        }
215        entry.parse().ok()
216    }
217
218    #[cfg(test)]
219    mod tests {
220        use super::*;
221
222        fn ip(s: &str) -> IpAddr {
223            s.parse().unwrap()
224        }
225
226        #[test]
227        fn forwarded_client_rules() {
228            let trusted: Vec<IpNet> =
229                vec!["10.0.0.0/8".parse().unwrap(), "::1/128".parse().unwrap()];
230            let proxy = ip("10.0.0.1");
231            let unknown = "0.0.0.0";
232            let cases: &[(&str, IpAddr, &[&str], &str)] = &[
233                (
234                    "untrusted peer ignores header",
235                    ip("198.51.100.7"),
236                    &["203.0.113.9"],
237                    "198.51.100.7",
238                ),
239                (
240                    "rightmost untrusted",
241                    proxy,
242                    &["1.1.1.1, 203.0.113.9"],
243                    "203.0.113.9",
244                ),
245                (
246                    "two trusted hops skipped",
247                    proxy,
248                    &["203.0.113.9, 10.1.1.1, 10.2.2.2"],
249                    "203.0.113.9",
250                ),
251                ("all trusted", proxy, &["10.1.1.1, 10.2.2.2"], unknown),
252                ("no header", proxy, &[], unknown),
253                (
254                    "garbage reached",
255                    proxy,
256                    &["203.0.113.9, evil.example"],
257                    unknown,
258                ),
259                (
260                    "garbage left of the client ignored",
261                    proxy,
262                    &["evil.example, 203.0.113.9"],
263                    "203.0.113.9",
264                ),
265                ("empty entry reached", proxy, &["203.0.113.9, "], unknown),
266                ("bracketed v6", proxy, &["[2001:db8::5]"], "2001:db8::5"),
267                ("bare v6", proxy, &["2001:db8::5"], "2001:db8::5"),
268                ("port suffix", proxy, &["1.2.3.4:80"], unknown),
269                ("v6 with port", proxy, &["[2001:db8::5]:443"], unknown),
270                (
271                    "two instances joined",
272                    proxy,
273                    &["203.0.113.1", "203.0.113.9"],
274                    "203.0.113.9",
275                ),
276                (
277                    "second instance trusted",
278                    proxy,
279                    &["203.0.113.1", "10.3.3.3"],
280                    "203.0.113.1",
281                ),
282                (
283                    "mapped peer is trusted",
284                    ip("::ffff:10.0.0.1"),
285                    &["203.0.113.9"],
286                    "203.0.113.9",
287                ),
288                (
289                    "mapped entry unmapped",
290                    proxy,
291                    &["::ffff:203.0.113.9"],
292                    "203.0.113.9",
293                ),
294                ("v6 proxy", ip("::1"), &["203.0.113.9"], "203.0.113.9"),
295                ("proxy names itself", proxy, &["10.0.0.1"], unknown),
296            ];
297            for (name, peer, xff, want) in cases {
298                let got = forwarded_client(*peer, xff.iter().copied(), &trusted);
299                assert_eq!(got, ip(want), "{name}");
300            }
301            // A long client-supplied prefix does not matter: only the reached entries count.
302            let padded = format!("{}, 203.0.113.9", vec!["evil"; 5000].join(","));
303            assert_eq!(
304                forwarded_client(proxy, [padded.as_str()].into_iter(), &trusted),
305                ip("203.0.113.9")
306            );
307            // Trusted hops are capped.
308            let hops = |n: usize| format!("203.0.113.9, {}", vec!["10.9.9.9"; n].join(", "));
309            let fifteen = hops(15);
310            assert_eq!(
311                forwarded_client(proxy, [fifteen.as_str()].into_iter(), &trusted),
312                ip("203.0.113.9")
313            );
314            let sixteen = hops(16);
315            assert_eq!(
316                forwarded_client(proxy, [sixteen.as_str()].into_iter(), &trusted),
317                UNKNOWN_FORWARDED
318            );
319            assert_eq!(
320                forwarded_client(proxy, ["203.0.113.9"].into_iter(), &[]),
321                proxy
322            );
323        }
324
325        #[test]
326        fn client_key_groups_ipv6_by_64() {
327            assert_eq!(
328                client_key(ip("2001:db8:0:1:ffff::3")).ip(),
329                ip("2001:db8:0:1::")
330            );
331            assert_eq!(
332                client_key(ip("2001:db8:0:1::1")),
333                client_key(ip("2001:db8:0:1:aaaa::2"))
334            );
335            assert_ne!(
336                client_key(ip("2001:db8:0:1::1")),
337                client_key(ip("2001:db8:0:2::1"))
338            );
339        }
340
341        #[test]
342        fn client_key_keeps_ipv4_and_unmaps_mapped() {
343            assert_eq!(client_key(ip("192.0.2.9")).ip(), ip("192.0.2.9"));
344            assert_eq!(client_key(ip("::ffff:192.0.2.9")).ip(), ip("192.0.2.9"));
345            assert_eq!(client_key(ip("192.0.2.9")).to_string(), "192.0.2.9");
346        }
347    }
348}
349
350/// DNSSEC validation mode. The serde strings are the `[dnssec] validation` config values.
351#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
352#[serde(rename_all = "lowercase")]
353pub enum Validation {
354    /// No validation.
355    Off,
356    /// Validate; Bogus answers are returned without AD.
357    Permissive,
358    /// Validate; Bogus answers become SERVFAIL.
359    #[default]
360    Strict,
361}
362
363/// How a blocked query is answered. The serde strings are the `[filter] block_mode` config values.
364#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
365#[serde(rename_all = "snake_case")]
366pub enum BlockMode {
367    /// NXDOMAIN.
368    #[serde(rename = "nxdomain")]
369    NxDomain,
370    /// `0.0.0.0` for A and `::` for AAAA.
371    #[default]
372    NullIp,
373    /// REFUSED.
374    Refused,
375    /// Operator chosen sinkhole addresses; a family without an address answers NODATA. Set by
376    /// code, not by configuration.
377    #[serde(skip_deserializing)]
378    CustomIp {
379        v4: Option<Ipv4Addr>,
380        v6: Option<Ipv6Addr>,
381    },
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    const SCOPES: [(Scope, &str); 4] = [
389        (Scope::DnsQuery, "\"dns:query\""),
390        (Scope::ApiRead, "\"api:read\""),
391        (Scope::ApiWrite, "\"api:write\""),
392        (Scope::Admin, "\"admin\""),
393    ];
394
395    #[test]
396    fn scope_serde_roundtrip() {
397        for (scope, json) in SCOPES {
398            assert_eq!(serde_json::to_string(&scope).unwrap(), json);
399            assert_eq!(serde_json::from_str::<Scope>(json).unwrap(), scope);
400        }
401        assert!(serde_json::from_str::<Scope>("\"root\"").is_err());
402    }
403
404    #[test]
405    fn role_allows_scope() {
406        for (scope, _) in SCOPES {
407            assert!(Role::Admin.allows(scope));
408            assert_eq!(Role::Viewer.allows(scope), scope == Scope::ApiRead);
409            assert_eq!(Role::Operator.allows(scope), scope != Scope::Admin);
410        }
411    }
412
413    #[test]
414    fn dnssec_status_serde() {
415        let all = [
416            (DnssecStatus::Secure, "\"secure\""),
417            (DnssecStatus::Insecure, "\"insecure\""),
418            (DnssecStatus::Bogus, "\"bogus\""),
419            (DnssecStatus::Indeterminate, "\"indeterminate\""),
420            (DnssecStatus::Unchecked, "\"unchecked\""),
421        ];
422        for (status, json) in all {
423            assert_eq!(serde_json::to_string(&status).unwrap(), json);
424            assert_eq!(serde_json::from_str::<DnssecStatus>(json).unwrap(), status);
425        }
426    }
427
428    #[test]
429    fn ids_and_enums_serde() {
430        assert_eq!(serde_json::to_string(&UserId(7)).unwrap(), "7");
431        assert_eq!(serde_json::to_string(&TokenId(9)).unwrap(), "9");
432        assert_eq!(serde_json::to_string(&Transport::Doh).unwrap(), "\"doh\"");
433        assert_eq!(serde_json::to_string(&Role::Viewer).unwrap(), "\"viewer\"");
434        let p = Principal::ClientCert {
435            sha256: [0; 32],
436            subject: "CN=a".into(),
437        };
438        assert_eq!(p.clone(), p);
439    }
440
441    #[test]
442    fn validation_and_block_mode_serde_strings_are_pinned() {
443        for (v, text) in [
444            (Validation::Off, "\"off\""),
445            (Validation::Permissive, "\"permissive\""),
446            (Validation::Strict, "\"strict\""),
447        ] {
448            assert_eq!(serde_json::to_string(&v).unwrap(), text);
449            assert_eq!(serde_json::from_str::<Validation>(text).unwrap(), v);
450        }
451        for (m, text) in [
452            (BlockMode::NullIp, "\"null_ip\""),
453            (BlockMode::NxDomain, "\"nxdomain\""),
454            (BlockMode::Refused, "\"refused\""),
455        ] {
456            assert_eq!(serde_json::to_string(&m).unwrap(), text);
457            assert_eq!(serde_json::from_str::<BlockMode>(text).unwrap(), m);
458        }
459        assert_eq!(Validation::default(), Validation::Strict);
460        assert_eq!(BlockMode::default(), BlockMode::NullIp);
461    }
462}