Skip to main content

pharos_plugins/
hosts.rs

1//! Hosts Directive configuration.
2
3use std::{
4    path::{Path, PathBuf},
5    sync::{Arc, Mutex},
6    time::Duration,
7};
8
9use pharos_corefile::{Cursor, Error, Fallthrough, origins_from_args_or_server_block};
10use pharos_proto::{Class, MessageBuilder, Name, NameRef, Opcode, Rcode, Section, Type};
11use pharos_server::{Context, Next, Outcome, Protocol};
12
13mod data;
14
15#[derive(Debug, Default)]
16struct State {
17    entries: arc_swap::ArcSwap<data::Map>,
18    modified: Mutex<Option<(std::time::SystemTime, u64)>>,
19    task: Mutex<Option<tokio::task::JoinHandle<()>>>,
20    metrics: Mutex<Option<pharos_server::metrics::HostsMetrics>>,
21    inline_len: Mutex<usize>,
22}
23
24#[derive(Debug)]
25pub struct Hosts {
26    pub path: PathBuf,
27    pub domains: Vec<String>,
28    pub fallthrough: Fallthrough,
29    pub fallthrough_unsupported: bool,
30    pub ttl: u32,
31    pub reload: Duration,
32    pub auto_reverse: bool,
33    pub inline: Vec<String>,
34    domain_names: Vec<Name>,
35    fallthrough_names: Vec<Name>,
36    state: Arc<State>,
37    inline_entries: data::Map,
38}
39
40impl Hosts {
41    pub(crate) fn set_metrics(&self, registry: &pharos_server::metrics::Registry) {
42        *self.state.metrics.lock().unwrap() = Some(registry.hosts(&self.path.to_string_lossy()));
43        *self.state.inline_len.lock().unwrap() = self.inline_entries.len();
44    }
45    pub(crate) fn register_hooks(&self, lifecycle: &mut pharos_server::Lifecycle) {
46        let state = Arc::clone(&self.state);
47        let path = self.path.clone();
48        let domains = self.domain_names.clone();
49        let auto_reverse = self.auto_reverse;
50        let interval = self.reload;
51        let start = move || {
52            let state = Arc::clone(&state);
53            let path = path.clone();
54            let domains = domains.clone();
55            async move {
56                state.read(&path, &domains, auto_reverse);
57                if !interval.is_zero() {
58                    let task_state = Arc::clone(&state);
59                    let task = tokio::spawn(async move {
60                        loop {
61                            tokio::time::sleep(interval).await;
62                            task_state.read(&path, &domains, auto_reverse);
63                        }
64                    });
65                    if let Some(old) = state.task.lock().unwrap().replace(task) {
66                        old.abort();
67                    }
68                }
69                Ok(())
70            }
71        };
72        lifecycle.register(pharos_server::Phase::Startup, start.clone());
73        lifecycle.register(pharos_server::Phase::RestartFailed, start);
74        let state = Arc::clone(&self.state);
75        let stop = move || {
76            let task = state.task.lock().unwrap().take();
77            async move {
78                if let Some(task) = task {
79                    task.abort();
80                    let _ = task.await;
81                }
82                Ok(())
83            }
84        };
85        lifecycle.register(pharos_server::Phase::Restart, stop.clone());
86        lifecycle.register(pharos_server::Phase::Shutdown, stop);
87    }
88
89    pub fn parse(cursor: &mut Cursor<'_>, server_domains: &[&str]) -> Result<Self, Error> {
90        cursor.next();
91        let args = cursor.remaining_args();
92        // shortcut: relative paths use the working directory; join with `root` once that Plugin exists.
93        let path = PathBuf::from(args.first().map_or("/etc/hosts", String::as_str));
94        if !args.is_empty() {
95            match std::fs::metadata(&path) {
96                Ok(metadata) if metadata.is_dir() => pharos_server::logging::log(
97                    "WARN",
98                    "hosts",
99                    &format!("Hosts file {:?} is a directory", path),
100                ),
101                Ok(_) => {}
102                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
103                    pharos_server::logging::log(
104                        "WARN",
105                        "hosts",
106                        &format!("File does not exist: {}", path.display()),
107                    )
108                }
109                Err(error) => {
110                    return Err(cursor.error(format!(
111                        "unable to access hosts file '{}': {error}",
112                        path.display()
113                    )));
114                }
115            }
116        }
117        let zones = args
118            .get(1..)
119            .unwrap_or_default()
120            .iter()
121            .map(String::as_str)
122            .collect::<Vec<_>>();
123        let mut hosts = Self {
124            path,
125            domains: origins_from_args_or_server_block(&zones, server_domains),
126            fallthrough: Fallthrough::default(),
127            fallthrough_unsupported: false,
128            ttl: 3600,
129            reload: Duration::from_secs(5),
130            auto_reverse: true,
131            inline: Vec::new(),
132            domain_names: Vec::new(),
133            fallthrough_names: Vec::new(),
134            state: Arc::new(State::default()),
135            inline_entries: data::Map::default(),
136        };
137        while cursor.next_block() {
138            let option = cursor.val().to_owned();
139            let args = cursor.remaining_args();
140            match option.as_str() {
141                "fallthrough" => hosts
142                    .fallthrough
143                    .set_domains_from_args(&args.iter().map(String::as_str).collect::<Vec<_>>()),
144                "fallthrough_unsupported" if args.is_empty() => {
145                    hosts.fallthrough_unsupported = true
146                }
147                "fallthrough_unsupported" => return Err(cursor.arg_err()),
148                "no_reverse" => hosts.auto_reverse = false,
149                "ttl" => {
150                    let value = args
151                        .first()
152                        .ok_or_else(|| cursor.error("ttl needs a time in seconds"))?;
153                    let value = value
154                        .parse::<u32>()
155                        .map_err(|_| cursor.error("invalid ttl"))?;
156                    if !(1..=65535).contains(&value) {
157                        return Err(cursor.error("invalid ttl"));
158                    }
159                    hosts.ttl = value;
160                }
161                "reload" => {
162                    if args.len() != 1 {
163                        return Err(cursor.arg_err());
164                    }
165                    let value = &args[0];
166                    let magnitude = value.strip_prefix('-').unwrap_or(value);
167                    if value.starts_with('-') && magnitude.starts_with(['-', '+']) {
168                        return Err(cursor.error("invalid reload duration"));
169                    }
170                    let duration = crate::health::parse_duration(magnitude)
171                        .ok_or_else(|| cursor.error("invalid reload duration"))?;
172                    if value.starts_with('-') && !duration.is_zero() {
173                        return Err(cursor.error("negative reload duration"));
174                    }
175                    hosts.reload = duration;
176                }
177                _ if hosts.fallthrough.domains.is_empty() => hosts.inline.push(
178                    std::iter::once(option)
179                        .chain(args)
180                        .collect::<Vec<_>>()
181                        .join(" "),
182                ),
183                _ => return Err(cursor.error(format!("unknown property '{option}'"))),
184            }
185        }
186        if hosts.fallthrough_unsupported && hosts.fallthrough.domains.is_empty() {
187            return Err(cursor.error("fallthrough_unsupported requires fallthrough"));
188        }
189        hosts.domain_names = hosts
190            .domains
191            .iter()
192            .filter_map(|s| s.parse().ok())
193            .collect();
194        hosts.fallthrough_names = hosts
195            .fallthrough
196            .domains
197            .iter()
198            .filter_map(|s| s.parse().ok())
199            .collect();
200        hosts.inline_entries = data::Map::parse(
201            hosts.inline.join("\n").as_bytes(),
202            &hosts.domain_names,
203            hosts.auto_reverse,
204            "Inline hosts entries",
205        );
206        Ok(hosts)
207    }
208}
209
210impl State {
211    fn read(&self, path: &Path, domains: &[Name], auto_reverse: bool) {
212        let Ok(file) = std::fs::File::open(path) else {
213            return;
214        };
215        let Ok(metadata) = file.metadata() else {
216            return;
217        };
218        let Ok(modified) = metadata.modified() else {
219            return;
220        };
221        let stamp = (modified, metadata.len());
222        let mut previous = self.modified.lock().unwrap();
223        if *previous == Some(stamp) {
224            return;
225        }
226        let map = data::Map::parse(file, domains, auto_reverse, &format!("Hosts file {path:?}"));
227        if let Some(metrics) = self.metrics.lock().unwrap().as_ref() {
228            let timestamp = modified
229                .duration_since(std::time::UNIX_EPOCH)
230                .map_or(0.0, |duration| duration.as_secs_f64());
231            metrics.update(map.len() + *self.inline_len.lock().unwrap(), timestamp);
232        }
233        self.entries.store(Arc::new(map));
234        *previous = Some(stamp);
235    }
236}
237
238impl pharos_server::Plugin for Hosts {
239    async fn serve<N: Next + Send>(&self, cx: &mut Context<'_>, next: N) -> Outcome {
240        let Some(question) = cx.query.questions().next() else {
241            return next.run(cx).await;
242        };
243        // RFC 1035 §3.1: wire names are at most 255 octets and end with a zero-length label.
244        let mut wire = [0u8; 255];
245        let mut len = 0;
246        // RFC 4343 §3: DNS name lookup compares ASCII letters case-insensitively.
247        for label in question.name.labels() {
248            wire[len] = label.len() as u8;
249            len += 1;
250            for byte in label {
251                wire[len] = byte.to_ascii_lowercase();
252                len += 1;
253            }
254        }
255        len += 1;
256        let key = &wire[..len];
257        if question.qtype != Type::PTR && !contains(key, &self.domain_names) {
258            return next.run(cx).await;
259        }
260        let fallthrough = contains(key, &self.fallthrough_names);
261        let entries = self.state.entries.load_full();
262        let ptr = if question.qtype == Type::PTR {
263            reverse_address(question.name)
264        } else {
265            None
266        };
267        let names1 = ptr.and_then(|ip| entries.reverse.get(&ip));
268        let names2 = ptr.and_then(|ip| self.inline_entries.reverse.get(&ip));
269        if question.qtype == Type::PTR && names1.is_none() && names2.is_none() {
270            return next.run(cx).await;
271        }
272        if question.qtype != Type::A
273            && question.qtype != Type::AAAA
274            && question.qtype != Type::PTR
275            && self.fallthrough_unsupported
276            && fallthrough
277        {
278            return next.run(cx).await;
279        }
280        let v4 = entries.lookup_key(key, true);
281        let v6 = entries.lookup_key(key, false);
282        let inline4 = self.inline_entries.lookup_key(key, true);
283        let inline6 = self.inline_entries.lookup_key(key, false);
284        let ips = if question.qtype == Type::A {
285            (v4, inline4)
286        } else if question.qtype == Type::AAAA {
287            (v6, inline6)
288        } else {
289            (&[][..], &[][..])
290        };
291        if ips.0.is_empty()
292            && ips.1.is_empty()
293            && names1.is_none()
294            && names2.is_none()
295            && v4.is_empty()
296            && v6.is_empty()
297            && inline4.is_empty()
298            && inline6.is_empty()
299        {
300            return if fallthrough {
301                next.run(cx).await
302            } else {
303                Outcome::Unanswered(Rcode::SERVFAIL)
304            };
305        }
306        let max = cx.max_response_size.max(512);
307        if cx.response.capacity() < max {
308            if cx.protocol == Protocol::Tcp {
309                cx.response.reserve(max.saturating_sub(cx.response.len()));
310            } else {
311                return Outcome::Error(pharos_server::Error::ResponseBufferTooSmall);
312            }
313        }
314        let mut flags = 0x8400 | (cx.query.flags() & 0x7800);
315        if cx.query.opcode() == Opcode::QUERY {
316            flags |= cx.query.flags() & 0x0110;
317        }
318        let result = (|| -> Result<(), pharos_server::Error> {
319            let mut builder = MessageBuilder::new(cx.response, max, cx.query.id(), flags)
320                .map_err(pharos_server::Error::Build)?;
321            builder
322                .question(question.name, question.qtype, question.qclass)
323                .map_err(pharos_server::Error::Build)?;
324            for ip in ips.0.iter().chain(ips.1) {
325                match ip {
326                    // RFC 1035 §3.4.1: A RDATA carries a 32-bit Internet address.
327                    std::net::IpAddr::V4(ip) => builder.record(
328                        Section::Answer,
329                        question.name,
330                        question.qtype,
331                        Class::IN,
332                        self.ttl,
333                        &ip.octets(),
334                    ),
335                    // RFC 3596 §2.2: AAAA RDATA carries a 128-bit IPv6 address.
336                    std::net::IpAddr::V6(ip) => builder.record(
337                        Section::Answer,
338                        question.name,
339                        question.qtype,
340                        Class::IN,
341                        self.ttl,
342                        &ip.octets(),
343                    ),
344                }
345                .map_err(pharos_server::Error::Build)?;
346            }
347            // RFC 1035 §3.3.12: PTR RDATA is a domain name.
348            for name in names1
349                .into_iter()
350                .flatten()
351                .chain(names2.into_iter().flatten())
352            {
353                builder
354                    .record(
355                        Section::Answer,
356                        question.name,
357                        Type::PTR,
358                        Class::IN,
359                        self.ttl,
360                        name.as_wire(),
361                    )
362                    .map_err(pharos_server::Error::Build)?;
363            }
364            let _ = builder.finish();
365            Ok(())
366        })();
367        result.map_or_else(Outcome::Error, |()| Outcome::Response)
368    }
369}
370
371// RFC 1034 §3.1: a subdomain name ends with its containing domain name.
372fn contains(mut key: &[u8], domains: &[Name]) -> bool {
373    loop {
374        if domains
375            .iter()
376            .any(|domain| key.eq_ignore_ascii_case(domain.as_wire()))
377        {
378            return true;
379        }
380        if key[0] == 0 {
381            return false;
382        }
383        key = &key[1 + usize::from(key[0])..];
384    }
385}
386
387fn reverse_address(name: NameRef<'_>) -> Option<std::net::IpAddr> {
388    let mut labels = [&[][..]; 34];
389    let mut count = 0;
390    for label in name.labels() {
391        if count == labels.len() {
392            return None;
393        }
394        labels[count] = label;
395        count += 1;
396    }
397    // RFC 1035 §3.5: IPv4 reverse names use reversed decimal octets without leading zeros.
398    if count == 6
399        && labels[4].eq_ignore_ascii_case(b"in-addr")
400        && labels[5].eq_ignore_ascii_case(b"arpa")
401    {
402        let mut bytes = [0; 4];
403        for (i, label) in labels[..4].iter().enumerate() {
404            if label.is_empty()
405                || (label.len() > 1 && label[0] == b'0')
406                || !label.iter().all(u8::is_ascii_digit)
407            {
408                return None;
409            }
410            bytes[3 - i] = std::str::from_utf8(label).ok()?.parse().ok()?;
411        }
412        return Some(std::net::Ipv4Addr::from(bytes).into());
413    }
414    // RFC 3596 §2.5: IP6.ARPA names encode IPv6 nibbles in reverse order.
415    if count == 34
416        && labels[32].eq_ignore_ascii_case(b"ip6")
417        && labels[33].eq_ignore_ascii_case(b"arpa")
418    {
419        let mut bytes = [0; 16];
420        for (i, label) in labels[..32].iter().enumerate() {
421            if label.len() != 1 {
422                return None;
423            }
424            let digit = (label[0] as char).to_digit(16)? as u8;
425            bytes[15 - i / 2] |= digit << ((i % 2) * 4);
426        }
427        let ip = std::net::Ipv6Addr::from(bytes);
428        return Some(
429            ip.to_ipv4_mapped()
430                .map_or(std::net::IpAddr::V6(ip), std::net::IpAddr::V4),
431        );
432    }
433    None
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    #[test]
441    fn reverse_ipv4_requires_canonical_octets() {
442        for (input, expected) in [
443            ("1.0.0.127.in-addr.arpa.", true),
444            ("01.0.0.127.in-addr.arpa.", false),
445            ("+1.0.0.127.in-addr.arpa.", false),
446        ] {
447            let name: Name = input.parse().unwrap();
448            assert_eq!(
449                reverse_address(NameRef::parse(name.as_wire(), 0).unwrap()).is_some(),
450                expected,
451                "{input}"
452            );
453        }
454    }
455}