Skip to main content

onionlink_core/
directory.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::io::Write;
3use std::sync::atomic::{AtomicUsize, Ordering};
4use std::sync::{Arc, Mutex};
5use std::thread;
6
7use chrono::NaiveDateTime;
8use log::{info, warn};
9
10use crate::crypto::{ed25519_point_is_valid, ed25519_scalarmult_noclamp, sha256, sha3_256};
11use crate::error::{ensure, err, Error, Result};
12use crate::tor::{connect_tcp, read_all_fd, write_all_fd};
13use crate::util::{
14    base32_decode_onion, base64_decode, base64_encode_unpadded, from_string, hex,
15    ipv4_to_link_bytes, lower, parse_hostport, put_u64, split_ws, to_string_lossy, Bytes, HostPort,
16};
17
18const K_BLIND_STRING: &str = "Derive temporary signing key";
19const K_BLIND_BASE_POINT: &str =
20    "(1511222134953540077250115140958853151145401269304185720604611328394984776\
212202, \
2246316835694926478169428394003475163141307993866256225615783033603165251855\
23960)";
24
25#[derive(Clone, Debug, Default, Eq, PartialEq)]
26pub struct Relay {
27    pub nickname: String,
28    pub ip: String,
29    pub or_port: u16,
30    pub dir_port: u16,
31    pub rsa_id: Bytes,
32    pub ed_id: Bytes,
33    pub flags: BTreeSet<String>,
34    pub proto: String,
35    pub md_digest: String,
36    pub ntor_key: Bytes,
37}
38
39impl Relay {
40    pub fn has_flag(&self, flag: &str) -> bool {
41        self.flags.contains(flag)
42    }
43}
44
45#[derive(Clone, Debug, Default)]
46pub struct Consensus {
47    pub valid_after: i64,
48    pub fresh_until: i64,
49    pub params: BTreeMap<String, i32>,
50    pub shared_rand_current: Bytes,
51    pub shared_rand_previous: Bytes,
52    pub relays: Vec<Relay>,
53}
54
55impl Consensus {
56    pub fn param(&self, name: &str, default: i32) -> i32 {
57        self.params.get(name).copied().unwrap_or(default)
58    }
59}
60
61#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct OnionAddress {
63    pub pubkey: Bytes,
64}
65
66pub fn parse_onion_address(addr: &str) -> Result<OnionAddress> {
67    let raw = base32_decode_onion(addr)?;
68    let pubkey = raw[..32].to_vec();
69    let checksum = &raw[32..34];
70    let version = raw[34];
71    ensure(version == 3, "only v3 onion addresses are supported")?;
72    let mut check_input = from_string(".onion checksum");
73    check_input.extend_from_slice(&pubkey);
74    check_input.push(version);
75    let expected = sha3_256(&check_input);
76    ensure(
77        checksum[0] == expected[0] && checksum[1] == expected[1],
78        "bad onion checksum",
79    )?;
80    ensure(
81        ed25519_point_is_valid(&pubkey),
82        "onion ed25519 key is invalid",
83    )?;
84    Ok(OnionAddress { pubkey })
85}
86
87pub fn parse_time_utc(date: &str, time: &str) -> Result<i64> {
88    let both = format!("{date} {time}");
89    let dt = NaiveDateTime::parse_from_str(&both, "%Y-%m-%d %H:%M:%S")?;
90    Ok(dt.and_utc().timestamp())
91}
92
93pub fn parse_consensus(doc: &str) -> Result<Consensus> {
94    let mut c = Consensus::default();
95    let mut cur: Option<usize> = None;
96    for raw_line in doc.lines() {
97        let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
98        if line.is_empty() {
99            continue;
100        }
101        let parts = split_ws(line);
102        if parts.is_empty() {
103            continue;
104        }
105        match parts[0] {
106            "valid-after" if parts.len() >= 3 => {
107                c.valid_after = parse_time_utc(parts[1], parts[2])?;
108            }
109            "fresh-until" if parts.len() >= 3 => {
110                c.fresh_until = parse_time_utc(parts[1], parts[2])?;
111            }
112            "params" => {
113                for part in &parts[1..] {
114                    if let Some(eq) = part.find('=') {
115                        c.params
116                            .insert(part[..eq].to_string(), part[eq + 1..].parse()?);
117                    }
118                }
119            }
120            "shared-rand-current-value" if parts.len() >= 3 => {
121                c.shared_rand_current = base64_decode(parts[2])?;
122            }
123            "shared-rand-previous-value" if parts.len() >= 3 => {
124                c.shared_rand_previous = base64_decode(parts[2])?;
125            }
126            "r" if parts.len() >= 8 => {
127                let mut r = Relay {
128                    nickname: parts[1].to_string(),
129                    rsa_id: base64_decode(parts[2])?,
130                    ..Relay::default()
131                };
132                if parts.len() >= 9 {
133                    r.ip = parts[6].to_string();
134                    r.or_port = parts[7].parse()?;
135                    r.dir_port = parts[8].parse()?;
136                } else {
137                    r.ip = parts[5].to_string();
138                    r.or_port = parts[6].parse()?;
139                    r.dir_port = parts[7].parse()?;
140                }
141                c.relays.push(r);
142                cur = Some(c.relays.len() - 1);
143            }
144            "s" => {
145                if let Some(i) = cur {
146                    for part in &parts[1..] {
147                        c.relays[i].flags.insert((*part).to_string());
148                    }
149                }
150            }
151            "pr" => {
152                if let Some(i) = cur {
153                    c.relays[i].proto = if line.len() > 3 {
154                        line[3..].to_string()
155                    } else {
156                        String::new()
157                    };
158                }
159            }
160            "id" if parts.len() >= 3 && parts[1] == "ed25519" && parts[2] != "none" => {
161                if let Some(i) = cur {
162                    c.relays[i].ed_id = base64_decode(parts[2])?;
163                }
164            }
165            "m" if parts.len() >= 2 => {
166                if let Some(i) = cur {
167                    let mut digest = parts[1].to_string();
168                    for part in &parts[1..] {
169                        if let Some(eq) = part.find("sha256=") {
170                            digest = part[eq + 7..].to_string();
171                        }
172                    }
173                    c.relays[i].md_digest = digest;
174                }
175            }
176            _ => {}
177        }
178    }
179    ensure(c.valid_after != 0, "consensus missing valid-after")?;
180    ensure(!c.relays.is_empty(), "consensus has no relays")?;
181    Ok(c)
182}
183
184pub fn read_file_bytes(path: &str) -> Result<Bytes> {
185    std::fs::read(path).map_err(|_| Error::new(format!("failed to open {path}")))
186}
187
188pub fn read_file_string(path: &str) -> Result<String> {
189    Ok(to_string_lossy(&read_file_bytes(path)?))
190}
191
192pub fn parse_microdescriptor_into(mut relay: Relay, doc: &str) -> Result<Relay> {
193    for raw_line in doc.lines() {
194        let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
195        let parts = split_ws(line);
196        if parts.is_empty() {
197            continue;
198        }
199        if parts[0] == "ntor-onion-key" && parts.len() >= 2 {
200            relay.ntor_key = base64_decode(parts[1])?;
201        } else if parts[0] == "id"
202            && parts.len() >= 3
203            && parts[1] == "ed25519"
204            && relay.ed_id.is_empty()
205        {
206            relay.ed_id = base64_decode(parts[2])?;
207        }
208    }
209    ensure(
210        relay.ntor_key.len() == 32,
211        format!(
212            "microdescriptor missing ntor-onion-key for {}",
213            relay.nickname
214        ),
215    )?;
216    Ok(relay)
217}
218
219pub fn split_microdescriptors(raw: &str) -> Vec<String> {
220    let Some(mut start) = raw.find("onion-key\n") else {
221        return Vec::new();
222    };
223    let mut out = Vec::new();
224    while start < raw.len() {
225        if let Some(next_rel) = raw[start + 1..].find("\nonion-key\n") {
226            let next = start + 1 + next_rel;
227            out.push(raw[start..next + 1].to_string());
228            start = next + 1;
229        } else {
230            out.push(raw[start..].to_string());
231            break;
232        }
233    }
234    out
235}
236
237#[derive(Clone, Debug, Default)]
238pub struct MicrodescriptorFields {
239    pub ed_id: Bytes,
240    pub ntor_key: Bytes,
241}
242
243pub fn parse_microdescriptor_fields(doc: &str) -> Result<MicrodescriptorFields> {
244    let mut fields = MicrodescriptorFields::default();
245    for raw_line in doc.lines() {
246        let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
247        let parts = split_ws(line);
248        if parts.is_empty() {
249            continue;
250        }
251        if parts[0] == "ntor-onion-key" && parts.len() >= 2 {
252            fields.ntor_key = base64_decode(parts[1])?;
253        } else if parts[0] == "id" && parts.len() >= 3 && parts[1] == "ed25519" {
254            fields.ed_id = base64_decode(parts[2])?;
255        }
256    }
257    Ok(fields)
258}
259
260pub fn index_microdescriptors(raw: &str) -> Result<BTreeMap<String, MicrodescriptorFields>> {
261    let mut out = BTreeMap::new();
262    for doc in split_microdescriptors(raw) {
263        out.insert(
264            base64_encode_unpadded(&sha256(doc.as_bytes())),
265            parse_microdescriptor_fields(&doc)?,
266        );
267    }
268    Ok(out)
269}
270
271pub fn relay_usable_dir(r: &Relay) -> bool {
272    r.or_port != 0 && r.has_flag("Running") && r.has_flag("Valid")
273}
274
275pub fn relay_usable_hsdir(r: &Relay) -> bool {
276    relay_usable_dir(r)
277        && r.has_flag("HSDir")
278        && !r.has_flag("NoEdConsensus")
279        && r.ed_id.len() == 32
280}
281
282pub fn relay_usable_rendezvous(r: &Relay) -> bool {
283    relay_usable_dir(r)
284        && !r.has_flag("MiddleOnly")
285        && !r.has_flag("NoEdConsensus")
286        && r.rsa_id.len() == 20
287        && r.ed_id.len() == 32
288        && r.ntor_key.len() == 32
289}
290
291pub fn candidate_rendezvous_relays(c: &Consensus) -> Result<Vec<Relay>> {
292    let mut out: Vec<Relay> = c
293        .relays
294        .iter()
295        .filter(|r| relay_usable_rendezvous(r))
296        .cloned()
297        .collect();
298    ensure(!out.is_empty(), "no usable rendezvous relays in consensus")?;
299    use rand::seq::SliceRandom;
300    out.shuffle(&mut rand::thread_rng());
301    Ok(out)
302}
303
304pub fn current_period_num(c: &Consensus, period_len_min: i32) -> u64 {
305    let mut voting_interval = 60;
306    if c.fresh_until > c.valid_after {
307        voting_interval = ((c.fresh_until - c.valid_after) / 60) as i32;
308        if voting_interval <= 0 {
309            voting_interval = 60;
310        }
311    }
312    let minutes = (c.valid_after / 60).max(0) as u64;
313    let offset = (12 * voting_interval) as u64;
314    if minutes < offset {
315        return 0;
316    }
317    (minutes - offset) / period_len_min as u64
318}
319
320pub fn blind_onion_key(pubkey: &[u8], period_num: u64, period_len: u64) -> Result<Bytes> {
321    ensure(pubkey.len() == 32, "bad onion pubkey")?;
322    let mut nonce = from_string("key-blind");
323    put_u64(&mut nonce, period_num);
324    put_u64(&mut nonce, period_len);
325    let mut input = from_string(K_BLIND_STRING);
326    input.push(0);
327    input.extend_from_slice(pubkey);
328    input.extend_from_slice(K_BLIND_BASE_POINT.as_bytes());
329    input.extend_from_slice(&nonce);
330    let mut h = sha3_256(&input);
331    h[0] &= 248;
332    h[31] &= 63;
333    h[31] |= 64;
334    ed25519_scalarmult_noclamp(&h, pubkey)
335}
336
337pub fn onion_subcredential(pubkey: &[u8], blinded: &[u8]) -> Bytes {
338    let mut cred_in = from_string("credential");
339    cred_in.extend_from_slice(pubkey);
340    let cred = sha3_256(&cred_in);
341    let mut sub_in = from_string("subcredential");
342    sub_in.extend_from_slice(&cred);
343    sub_in.extend_from_slice(blinded);
344    sha3_256(&sub_in)
345}
346
347#[derive(Clone, Debug, Default)]
348pub struct HsPeriodKeys {
349    pub period_num: u64,
350    pub period_len: i32,
351    pub blinded: Bytes,
352    pub subcredential: Bytes,
353}
354
355pub fn derive_hs_period_keys(c: &Consensus, addr: &OnionAddress) -> Result<HsPeriodKeys> {
356    let period_len = c.param("hsdir-interval", 1440);
357    let period_num = current_period_num(c, period_len);
358    let blinded = blind_onion_key(&addr.pubkey, period_num, period_len as u64)?;
359    let subcredential = onion_subcredential(&addr.pubkey, &blinded);
360    Ok(HsPeriodKeys {
361        period_num,
362        period_len,
363        blinded,
364        subcredential,
365    })
366}
367
368pub fn select_hsdirs(
369    c: &Consensus,
370    blinded: &[u8],
371    srv: &[u8],
372    period_num: u64,
373    period_len: i32,
374) -> Result<Vec<Relay>> {
375    ensure(
376        srv.len() == 32,
377        "shared random value missing from consensus",
378    )?;
379    #[derive(Clone)]
380    struct Indexed {
381        idx: Bytes,
382        relay: Relay,
383    }
384    let mut ring = Vec::<Indexed>::new();
385    for r in &c.relays {
386        if !relay_usable_hsdir(r) {
387            continue;
388        }
389        let mut input = from_string("node-idx");
390        input.extend_from_slice(&r.ed_id);
391        input.extend_from_slice(srv);
392        put_u64(&mut input, period_num);
393        put_u64(&mut input, period_len as u64);
394        ring.push(Indexed {
395            idx: sha3_256(&input),
396            relay: r.clone(),
397        });
398    }
399    ensure(!ring.is_empty(), "no usable HSDir relays in consensus")?;
400    ring.sort_by(|a, b| a.idx.cmp(&b.idx));
401
402    let replicas = c.param("hsdir_n_replicas", 2).clamp(1, 16);
403    let spread = c.param("hsdir_spread_fetch", 3).clamp(1, 128);
404    let mut out = Vec::new();
405    let mut used = BTreeSet::new();
406    for rep in 1..=replicas {
407        let mut sin = from_string("store-at-idx");
408        sin.extend_from_slice(blinded);
409        put_u64(&mut sin, rep as u64);
410        put_u64(&mut sin, period_len as u64);
411        put_u64(&mut sin, period_num);
412        let service_idx = sha3_256(&sin);
413        let start = ring
414            .binary_search_by(|indexed| indexed.idx.cmp(&service_idx))
415            .unwrap_or_else(|pos| pos);
416        let mut n = 0usize;
417        let mut seen = 0usize;
418        while seen < ring.len() && n < spread as usize {
419            let relay = &ring[(start + seen) % ring.len()].relay;
420            let key = hex(&relay.ed_id);
421            if used.insert(key) {
422                out.push(relay.clone());
423                n += 1;
424            }
425            seen += 1;
426        }
427    }
428    Ok(out)
429}
430
431#[derive(Clone, Debug, Eq, PartialEq)]
432pub struct LinkSpecifier {
433    pub spec_type: u8,
434    pub data: Bytes,
435}
436
437pub fn parse_link_specifiers(encoded: &[u8]) -> Result<Vec<LinkSpecifier>> {
438    ensure(!encoded.is_empty(), "empty link specifier block")?;
439    let mut pos = 0usize;
440    let n = encoded[pos];
441    pos += 1;
442    let mut specs = Vec::new();
443    for _ in 0..n {
444        ensure(pos + 2 <= encoded.len(), "truncated link specifier")?;
445        let spec_type = encoded[pos];
446        let len = encoded[pos + 1] as usize;
447        pos += 2;
448        ensure(pos + len <= encoded.len(), "truncated link specifier body")?;
449        specs.push(LinkSpecifier {
450            spec_type,
451            data: encoded[pos..pos + len].to_vec(),
452        });
453        pos += len;
454    }
455    Ok(specs)
456}
457
458pub fn serialize_link_specifiers(specs: &[LinkSpecifier]) -> Result<Bytes> {
459    ensure(specs.len() <= 255, "too many link specifiers")?;
460    let mut out = Bytes::new();
461    out.push(specs.len() as u8);
462    for spec in specs {
463        ensure(spec.data.len() <= 255, "link specifier too large")?;
464        out.push(spec.spec_type);
465        out.push(spec.data.len() as u8);
466        out.extend_from_slice(&spec.data);
467    }
468    Ok(out)
469}
470
471pub fn link_spec_ipv4(specs: &[LinkSpecifier]) -> Option<HostPort> {
472    for spec in specs {
473        if spec.spec_type == 0 && spec.data.len() == 6 {
474            let host = format!(
475                "{}.{}.{}.{}",
476                spec.data[0], spec.data[1], spec.data[2], spec.data[3]
477            );
478            let port = ((spec.data[4] as u16) << 8) | spec.data[5] as u16;
479            return Some(HostPort { host, port });
480        }
481    }
482    None
483}
484
485pub fn relay_link_specifiers(r: &Relay) -> Result<Vec<LinkSpecifier>> {
486    Ok(vec![
487        LinkSpecifier {
488            spec_type: 0,
489            data: ipv4_to_link_bytes(&r.ip, r.or_port)?,
490        },
491        LinkSpecifier {
492            spec_type: 2,
493            data: r.rsa_id.clone(),
494        },
495        LinkSpecifier {
496            spec_type: 3,
497            data: r.ed_id.clone(),
498        },
499    ])
500}
501
502pub fn same_relay(a: &Relay, b: &Relay) -> bool {
503    if !a.ed_id.is_empty() && !b.ed_id.is_empty() && a.ed_id == b.ed_id {
504        return true;
505    }
506    if !a.rsa_id.is_empty() && !b.rsa_id.is_empty() && a.rsa_id == b.rsa_id {
507        return true;
508    }
509    !a.ip.is_empty() && a.or_port != 0 && a.ip == b.ip && a.or_port == b.or_port
510}
511
512pub fn decode_http_body(response: &[u8]) -> Result<Bytes> {
513    let s = to_string_lossy(response);
514    let Some(pos) = s.find("\r\n\r\n") else {
515        return err("malformed HTTP response");
516    };
517    let head = &s[..pos];
518    let body = &response[pos + 4..];
519    ensure(
520        head.contains(" 200 "),
521        format!(
522            "HTTP request failed: {}",
523            head.split("\r\n").next().unwrap_or(head)
524        ),
525    )?;
526    if lower(head).contains("transfer-encoding: chunked") {
527        let mut decoded = Bytes::new();
528        let mut p = 0usize;
529        while p < body.len() {
530            let mut line_end = p;
531            while line_end + 1 < body.len()
532                && !(body[line_end] == b'\r' && body[line_end + 1] == b'\n')
533            {
534                line_end += 1;
535            }
536            ensure(line_end + 1 < body.len(), "bad chunked response")?;
537            let len_s = std::str::from_utf8(&body[p..line_end])?
538                .split(';')
539                .next()
540                .unwrap_or("")
541                .trim();
542            let chunk_len = usize::from_str_radix(len_s, 16)?;
543            p = line_end + 2;
544            if chunk_len == 0 {
545                break;
546            }
547            ensure(p + chunk_len <= body.len(), "truncated chunk")?;
548            decoded.extend_from_slice(&body[p..p + chunk_len]);
549            p += chunk_len + 2;
550        }
551        Ok(decoded)
552    } else {
553        Ok(body.to_vec())
554    }
555}
556
557pub fn http_get_direct(hp: &HostPort, path: &str, timeout_ms: i32) -> Result<Bytes> {
558    let mut stream = connect_tcp(&hp.host, hp.port, timeout_ms)?;
559    let req = format!(
560        "GET {path} HTTP/1.0\r\nHost: {}\r\nUser-Agent: onionlink/0\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n",
561        hp.host
562    );
563    write_all_fd(&mut stream, req.as_bytes())?;
564    stream.flush()?;
565    let resp = read_all_fd(&mut stream, 8 * 1024 * 1024)?;
566    decode_http_body(&resp)
567}
568
569pub fn fetch_microdescriptor_doc(
570    bootstrap: &HostPort,
571    r: &Relay,
572    timeout_ms: i32,
573) -> Result<String> {
574    ensure(
575        !r.md_digest.is_empty(),
576        "relay missing microdescriptor digest",
577    )?;
578    let body = http_get_direct(
579        bootstrap,
580        &format!("/tor/micro/d/{}", r.md_digest),
581        timeout_ms,
582    )?;
583    Ok(to_string_lossy(&body))
584}
585
586pub fn hydrate_microdescriptors(
587    consensus: &mut Consensus,
588    bootstrap: &HostPort,
589    timeout_ms: i32,
590    _verbose: bool,
591) -> Result<()> {
592    let mut digests = Vec::new();
593    let mut seen = BTreeSet::new();
594    for r in &consensus.relays {
595        if !relay_usable_dir(r) || r.md_digest.is_empty() || r.has_flag("NoEdConsensus") {
596            continue;
597        }
598        if r.has_flag("HSDir") && seen.insert(r.md_digest.clone()) {
599            digests.push(r.md_digest.clone());
600        }
601    }
602    ensure(
603        !digests.is_empty(),
604        "consensus has no microdescriptor digests to fetch",
605    )?;
606
607    let mut sources = vec![bootstrap.clone()];
608    for r in &consensus.relays {
609        if relay_usable_dir(r) && r.dir_port != 0 && r.has_flag("V2Dir") {
610            sources.push(HostPort {
611                host: r.ip.clone(),
612                port: r.dir_port,
613            });
614        }
615    }
616    {
617        use rand::seq::SliceRandom;
618        sources[1..].shuffle(&mut rand::thread_rng());
619    }
620
621    #[derive(Clone)]
622    struct Batch {
623        first: usize,
624        last: usize,
625        path: String,
626    }
627
628    let mut batches = Vec::new();
629    for (i, chunk) in digests.chunks(90).enumerate() {
630        let first = i * 90 + 1;
631        let last = first + chunk.len() - 1;
632        batches.push(Batch {
633            first,
634            last,
635            path: format!("/tor/micro/d/{}", chunk.join("-")),
636        });
637    }
638
639    let fields = Arc::new(Mutex::new(BTreeMap::<String, MicrodescriptorFields>::new()));
640    let next_batch = Arc::new(AtomicUsize::new(0));
641    let batches = Arc::new(batches);
642    let sources = Arc::new(sources);
643    let worker_count = 8usize.min(batches.len().max(1));
644    let microdesc_timeout_ms = timeout_ms.min(3000);
645    info!(
646        "fetching {} HSDir microdescriptor batches from {} directory sources",
647        batches.len(),
648        sources.len()
649    );
650
651    let mut workers = Vec::new();
652    for worker_id in 0..worker_count {
653        let fields = fields.clone();
654        let next_batch = next_batch.clone();
655        let batches = batches.clone();
656        let sources = sources.clone();
657        let digests_len = digests.len();
658        workers.push(thread::spawn(move || loop {
659            let idx = next_batch.fetch_add(1, Ordering::SeqCst);
660            if idx >= batches.len() {
661                return;
662            }
663            let batch = &batches[idx];
664            info!(
665                "fetching HSDir microdescriptors {}-{} of {}",
666                batch.first, batch.last, digests_len
667            );
668            let attempts = 5usize.min(sources.len());
669            let mut ok = false;
670            let mut last_error = String::new();
671            for attempt in 0..attempts {
672                let src_idx = (idx * 7 + worker_id * 13 + attempt) % sources.len();
673                let src = &sources[src_idx];
674                match http_get_direct(src, &batch.path, microdesc_timeout_ms)
675                    .and_then(|body| index_microdescriptors(&to_string_lossy(&body)))
676                {
677                    Ok(parsed) => {
678                        fields.lock().expect("fields lock").extend(parsed);
679                        ok = true;
680                        break;
681                    }
682                    Err(e) => last_error = e.to_string(),
683                }
684            }
685            if !ok {
686                warn!(
687                    "microdescriptor batch {}-{} failed after {} sources: {}",
688                    batch.first, batch.last, attempts, last_error
689                );
690            }
691        }));
692    }
693    for worker in workers {
694        worker
695            .join()
696            .map_err(|_| Error::new("microdescriptor worker panicked"))?;
697    }
698
699    let fields = fields.lock().expect("fields lock");
700    let mut hydrated = 0usize;
701    for r in &mut consensus.relays {
702        let Some(found) = fields.get(&r.md_digest) else {
703            continue;
704        };
705        if r.ed_id.is_empty() {
706            r.ed_id = found.ed_id.clone();
707        }
708        if r.ntor_key.is_empty() {
709            r.ntor_key = found.ntor_key.clone();
710        }
711        if r.ed_id.len() == 32 || r.ntor_key.len() == 32 {
712            hydrated += 1;
713        }
714    }
715    info!("hydrated {hydrated} relays from microdescriptors");
716    Ok(())
717}
718
719pub fn default_bootstrap() -> HostPort {
720    parse_hostport("128.31.0.39:9131", 0).expect("valid default bootstrap")
721}