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}