1use std::default::Default;
3use std::net::SocketAddr;
4use std::option::Option;
5#[cfg(test)]
6use std::path::Path;
7use std::str::FromStr;
8use std::string::ToString;
9#[cfg(test)]
10use std::time::{Duration, Instant};
11
12use regex::Regex;
14#[cfg(test)]
15use serial_test::serial;
16
17use crate::legacy_tor_control_stream::*;
19#[cfg(test)]
20use crate::legacy_tor_process::*;
21use crate::legacy_tor_version::*;
22use crate::tor_crypto::*;
23
24#[derive(thiserror::Error, Debug)]
25pub enum Error {
26 #[error("response regex creation failed")]
27 ParsingRegexCreationFailed(#[source] regex::Error),
28
29 #[error("control stream read reply failed")]
30 ReadReplyFailed(#[source] crate::legacy_tor_control_stream::Error),
31
32 #[error("unexpected synchronous reply recieved")]
33 UnexpectedSynchonousReplyReceived(),
34
35 #[error("control stream write command failed")]
36 WriteCommandFailed(#[source] crate::legacy_tor_control_stream::Error),
37
38 #[error("invalid command arguments: {0}")]
39 InvalidCommandArguments(String),
40
41 #[error("command failed: {0} {}", .1.join("\n"))]
42 CommandFailed(u32, Vec<String>),
43
44 #[error("failed to parse command reply: {0}")]
45 CommandReplyParseFailed(String),
46
47 #[error("failed to parse received tor version")]
48 TorVersionParseFailed(#[source] crate::legacy_tor_version::Error),
49}
50
51#[derive(Default)]
53pub(crate) struct AddOnionFlags {
54 pub discard_pk: bool,
55 pub detach: bool,
56 pub v3_auth: bool,
57 pub non_anonymous: bool,
58 pub max_streams_close_circuit: bool,
59}
60
61#[derive(Default)]
62pub(crate) struct OnionClientAuthAddFlags {
63 pub permanent: bool,
64}
65
66pub(crate) enum AsyncEvent {
67 Unknown {
68 lines: Vec<String>,
69 },
70 StatusClient {
71 severity: String,
72 action: String,
73 arguments: Vec<(String, String)>,
74 },
75 HsDesc {
76 action: String,
77 hs_address: V3OnionServiceId,
78 },
79}
80
81pub(crate) struct LegacyTorController {
82 control_stream: LegacyControlStream,
84 async_replies: Vec<Reply>,
86 status_event_pattern: Regex,
88 status_event_argument_pattern: Regex,
89 hs_desc_pattern: Regex,
90}
91
92fn quoted_string(string: &str) -> String {
93 string.replace("\\", "\\\\").replace("\"", "\\\"")
96}
97
98fn reply_ok(reply: Reply) -> Result<Reply, Error> {
99 match reply.status_code {
100 250u32 => Ok(reply),
101 code => Err(Error::CommandFailed(code, reply.reply_lines)),
102 }
103}
104
105impl LegacyTorController {
106 pub fn new(control_stream: LegacyControlStream) -> Result<LegacyTorController, Error> {
107 let status_event_pattern =
108 Regex::new(r#"^STATUS_CLIENT (?P<severity>NOTICE|WARN|ERR) (?P<action>[A-Za-z]+)"#)
109 .map_err(Error::ParsingRegexCreationFailed)?;
110 let status_event_argument_pattern =
111 Regex::new(r#"(?P<key>[A-Z]+)=(?P<value>[A-Za-z0-9_]+|"[^"]+")"#)
112 .map_err(Error::ParsingRegexCreationFailed)?;
113 let hs_desc_pattern = Regex::new(
114 r#"HS_DESC (?P<action>REQUESTED|UPLOAD|RECEIVED|UPLOADED|IGNORE|FAILED|CREATED) (?P<hsaddress>[a-z2-7]{56})"#
115 ).map_err(Error::ParsingRegexCreationFailed)?;
116
117 Ok(LegacyTorController {
118 control_stream,
119 async_replies: Default::default(),
120 status_event_pattern,
122 status_event_argument_pattern,
123 hs_desc_pattern,
124 })
125 }
126
127 fn wait_async_replies(&mut self) -> Result<Vec<Reply>, Error> {
130 let mut replies: Vec<Reply> = Default::default();
131 std::mem::swap(&mut self.async_replies, &mut replies);
133
134 loop {
136 if let Some(reply) = self
137 .control_stream
138 .read_reply()
139 .map_err(Error::ReadReplyFailed)?
140 {
141 replies.push(reply);
142 } else {
143 return Ok(replies);
145 }
146 }
147 }
148
149 fn reply_to_event(&self, reply: &mut Reply) -> Result<AsyncEvent, Error> {
150 if reply.status_code != 650u32 {
151 return Err(Error::UnexpectedSynchonousReplyReceived());
152 }
153
154 let reply_text = reply.reply_lines.join(" ");
156 if let Some(caps) = self.status_event_pattern.captures(&reply_text) {
157 let severity = match caps.name("severity") {
158 Some(severity) => severity.as_str(),
159 None => unreachable!(),
160 };
161 let action = match caps.name("action") {
162 Some(action) => action.as_str(),
163 None => unreachable!(),
164 };
165
166 let mut arguments: Vec<(String, String)> = Default::default();
167 for caps in self
168 .status_event_argument_pattern
169 .captures_iter(&reply_text)
170 {
171 let key = match caps.name("key") {
172 Some(key) => key.as_str(),
173 None => unreachable!(),
174 };
175 let value = {
176 let value = match caps.name("value") {
177 Some(value) => value.as_str(),
178 None => unreachable!(),
179 };
180 if value.starts_with('\"') && value.ends_with('\"') {
181 &value[1..value.len() - 1]
182 } else {
183 value
184 }
185 };
186 arguments.push((key.to_string(), value.to_string()));
187 }
188
189 return Ok(AsyncEvent::StatusClient {
190 severity: severity.to_string(),
191 action: action.to_string(),
192 arguments,
193 });
194 }
195
196 if let Some(caps) = self.hs_desc_pattern.captures(&reply_text) {
197 let action = match caps.name("action") {
198 Some(action) => action.as_str(),
199 None => unreachable!(),
200 };
201 let hs_address = match caps.name("hsaddress") {
202 Some(hs_address) => hs_address.as_str(),
203 None => unreachable!(),
204 };
205
206 if let Ok(hs_address) = V3OnionServiceId::from_string(hs_address) {
207 return Ok(AsyncEvent::HsDesc {
208 action: action.to_string(),
209 hs_address,
210 });
211 }
212 }
213
214 let mut reply_lines: Vec<String> = Default::default();
216 std::mem::swap(&mut reply_lines, &mut reply.reply_lines);
217
218 Ok(AsyncEvent::Unknown { lines: reply_lines })
219 }
220
221 pub fn wait_async_events(&mut self) -> Result<Vec<AsyncEvent>, Error> {
222 let mut async_replies = self.wait_async_replies()?;
223 let mut async_events: Vec<AsyncEvent> = Default::default();
224
225 for reply in async_replies.iter_mut() {
226 async_events.push(self.reply_to_event(reply)?);
227 }
228
229 Ok(async_events)
230 }
231
232 fn wait_sync_reply(&mut self) -> Result<Reply, Error> {
234 loop {
235 if let Some(reply) = self
236 .control_stream
237 .read_reply()
238 .map_err(Error::ReadReplyFailed)?
239 {
240 match reply.status_code {
241 650u32 => self.async_replies.push(reply),
242 _ => return Ok(reply),
243 }
244 }
245 }
246 }
247
248 fn write_command(&mut self, text: &str) -> Result<Reply, Error> {
249 self.control_stream
250 .write(text)
251 .map_err(Error::WriteCommandFailed)?;
252 self.wait_sync_reply()
253 }
254
255 fn setconf_cmd(&mut self, key_values: &[(&str, String)]) -> Result<Reply, Error> {
266 if key_values.is_empty() {
267 return Err(Error::InvalidCommandArguments(
268 "SETCONF key-value pairs list must not be empty".to_string(),
269 ));
270 }
271 let mut command_buffer = vec!["SETCONF".to_string()];
272
273 for (key, value) in key_values.iter() {
274 command_buffer.push(format!("{}=\"{}\"", key, quoted_string(value.trim())));
275 }
276 let command = command_buffer.join(" ");
277
278 self.write_command(&command)
279 }
280
281 #[cfg(test)]
283 fn getconf_cmd(&mut self, keywords: &[&str]) -> Result<Reply, Error> {
284 if keywords.is_empty() {
285 return Err(Error::InvalidCommandArguments(
286 "GETCONF keywords list must not be empty".to_string(),
287 ));
288 }
289 let command = format!("GETCONF {}", keywords.join(" "));
290
291 self.write_command(&command)
292 }
293
294 fn setevents_cmd(&mut self, event_codes: &[&str]) -> Result<Reply, Error> {
296 if event_codes.is_empty() {
297 return Err(Error::InvalidCommandArguments(
298 "SETEVENTS event codes list mut not be empty".to_string(),
299 ));
300 }
301 let command = format!("SETEVENTS {}", event_codes.join(" "));
302
303 self.write_command(&command)
304 }
305
306 fn authenticate_cmd(&mut self, password: &str) -> Result<Reply, Error> {
308 let command = format!("AUTHENTICATE \"{}\"", quoted_string(password));
309
310 self.write_command(&command)
311 }
312
313 fn getinfo_cmd(&mut self, keywords: &[&str]) -> Result<Reply, Error> {
315 if keywords.is_empty() {
316 return Err(Error::InvalidCommandArguments(
317 "GETINFO keywords list must not be empty".to_string(),
318 ));
319 }
320 let command = format!("GETINFO {}", keywords.join(" "));
321
322 self.write_command(&command)
323 }
324
325 fn add_onion_cmd(
327 &mut self,
328 key: Option<&Ed25519PrivateKey>,
329 flags: &AddOnionFlags,
330 max_streams: Option<u16>,
331 virt_port: u16,
332 target: Option<SocketAddr>,
333 client_auth: Option<&[X25519PublicKey]>,
334 ) -> Result<Reply, Error> {
335 let mut command_buffer = vec!["ADD_ONION".to_string()];
336
337 if let Some(key) = key {
339 command_buffer.push(key.to_key_blob());
340 } else {
341 command_buffer.push("NEW:ED25519-V3".to_string());
342 }
343
344 let mut flag_buffer: Vec<&str> = Default::default();
346 if flags.discard_pk {
347 flag_buffer.push("DiscardPK");
348 }
349 if flags.detach {
350 flag_buffer.push("Detach");
351 }
352 if flags.v3_auth {
353 flag_buffer.push("V3Auth");
354 }
355 if flags.non_anonymous {
356 flag_buffer.push("NonAnonymous");
357 }
358 if flags.max_streams_close_circuit {
359 flag_buffer.push("MaxStreamsCloseCircuit");
360 }
361
362 if !flag_buffer.is_empty() {
363 command_buffer.push(format!("Flags={}", flag_buffer.join(",")));
364 }
365
366 if let Some(max_streams) = max_streams {
368 command_buffer.push(format!("MaxStreams={}", max_streams));
369 }
370
371 if let Some(target) = target {
373 command_buffer.push(format!("Port={},{}", virt_port, target));
374 } else {
375 command_buffer.push(format!("Port={}", virt_port));
376 }
377 if let Some(client_auth) = client_auth {
379 for key in client_auth.iter() {
380 command_buffer.push(format!("ClientAuthV3={}", key.to_base32()));
381 }
382 }
383
384 let command = command_buffer.join(" ");
386
387 self.write_command(&command)
388 }
389
390 fn del_onion_cmd(&mut self, service_id: &V3OnionServiceId) -> Result<Reply, Error> {
392 let command = format!("DEL_ONION {}", service_id);
393
394 self.write_command(&command)
395 }
396
397 fn onion_client_auth_add_cmd(
399 &mut self,
400 service_id: &V3OnionServiceId,
401 private_key: &X25519PrivateKey,
402 client_name: Option<String>,
403 flags: &OnionClientAuthAddFlags,
404 ) -> Result<Reply, Error> {
405 let mut command_buffer = vec!["ONION_CLIENT_AUTH_ADD".to_string()];
406
407 command_buffer.push(service_id.to_string());
409
410 command_buffer.push(format!("x25519:{}", private_key.to_base64()));
412
413 if let Some(client_name) = client_name {
414 command_buffer.push(format!("ClientName={}", client_name));
415 }
416
417 if flags.permanent {
418 command_buffer.push("Flags=Permanent".to_string());
419 }
420
421 let command = command_buffer.join(" ");
423
424 self.write_command(&command)
425 }
426
427 fn onion_client_auth_remove_cmd(
429 &mut self,
430 service_id: &V3OnionServiceId,
431 ) -> Result<Reply, Error> {
432 let command = format!("ONION_CLIENT_AUTH_REMOVE {}", service_id);
433
434 self.write_command(&command)
435 }
436
437 pub fn setconf(&mut self, key_values: &[(&str, String)]) -> Result<(), Error> {
442 self.setconf_cmd(key_values).and_then(reply_ok).map(|_| ())
443 }
444
445 #[cfg(test)]
446 pub fn getconf(&mut self, keywords: &[&str]) -> Result<Vec<(String, String)>, Error> {
447 let reply = self.getconf_cmd(keywords).and_then(reply_ok)?;
448
449 let mut key_values: Vec<(String, String)> = Default::default();
450 for line in reply.reply_lines {
451 match line.find('=') {
452 Some(index) => key_values
453 .push((line[0..index].to_string(), line[index + 1..].to_string())),
454 None => key_values.push((line, String::new())),
455 }
456 }
457 Ok(key_values)
458 }
459
460 pub fn setevents(&mut self, events: &[&str]) -> Result<(), Error> {
461 self.setevents_cmd(events).and_then(reply_ok).map(|_| ())
462 }
463
464 pub fn authenticate(&mut self, password: &str) -> Result<(), Error> {
465 self.authenticate_cmd(password).and_then(reply_ok).map(|_| ())
466 }
467
468 pub fn getinfo(&mut self, keywords: &[&str]) -> Result<Vec<(String, String)>, Error> {
469 let reply = self.getinfo_cmd(keywords).and_then(reply_ok)?;
470
471 let mut key_values: Vec<(String, String)> = Default::default();
472 for line in reply.reply_lines {
473 match line.find('=') {
474 Some(index) => key_values
475 .push((line[0..index].to_string(), line[index + 1..].to_string())),
476 None => {
477 if line != "OK" {
478 key_values.push((line, String::new()))
479 }
480 }
481 }
482 }
483 Ok(key_values)
484 }
485
486 pub fn add_onion(
487 &mut self,
488 key: Option<&Ed25519PrivateKey>,
489 flags: &AddOnionFlags,
490 max_streams: Option<u16>,
491 virt_port: u16,
492 target: Option<SocketAddr>,
493 client_auth: Option<&[X25519PublicKey]>,
494 ) -> Result<(Option<Ed25519PrivateKey>, V3OnionServiceId), Error> {
495 let reply = self.add_onion_cmd(key, flags, max_streams, virt_port, target, client_auth).and_then(reply_ok)?;
496
497 let mut private_key: Option<Ed25519PrivateKey> = None;
498 let mut service_id: Option<V3OnionServiceId> = None;
499
500 for line in reply.reply_lines {
501 if let Some(mut index) = line.find("ServiceID=") {
502 if service_id.is_some() {
503 return Err(Error::CommandReplyParseFailed(
504 "received duplicate ServiceID entries".to_string(),
505 ));
506 }
507 index += "ServiceId=".len();
508 let service_id_string = &line[index..];
509 service_id = match V3OnionServiceId::from_string(service_id_string) {
510 Ok(service_id) => Some(service_id),
511 Err(_) => {
512 return Err(Error::CommandReplyParseFailed(format!(
513 "could not parse '{}' as V3OnionServiceId",
514 service_id_string
515 )))
516 }
517 }
518 } else if let Some(mut index) = line.find("PrivateKey=") {
519 if private_key.is_some() {
520 return Err(Error::CommandReplyParseFailed(
521 "received duplicate PrivateKey entries".to_string(),
522 ));
523 }
524 index += "PrivateKey=".len();
525 let key_blob_string = &line[index..];
526 private_key = match Ed25519PrivateKey::from_key_blob_legacy(key_blob_string)
527 {
528 Ok(private_key) => Some(private_key),
529 Err(_) => {
530 return Err(Error::CommandReplyParseFailed(format!(
531 "could not parse {} as Ed25519PrivateKey",
532 key_blob_string
533 )))
534 }
535 };
536 } else if line.contains("ClientAuthV3=") {
537 if client_auth.unwrap_or_default().is_empty() {
538 return Err(Error::CommandReplyParseFailed(
539 "recieved unexpected ClientAuthV3 keys".to_string(),
540 ));
541 }
542 } else if !line.contains("OK") {
543 return Err(Error::CommandReplyParseFailed(format!(
544 "received unexpected reply line '{}'",
545 line
546 )));
547 }
548 }
549
550 if flags.discard_pk {
551 if private_key.is_some() {
552 return Err(Error::CommandReplyParseFailed(
553 "PrivateKey response should have been discard".to_string(),
554 ));
555 }
556 } else if private_key.is_none() {
557 return Err(Error::CommandReplyParseFailed(
558 "did not receive a PrivateKey".to_string(),
559 ));
560 }
561
562 match service_id {
563 Some(service_id) => Ok((private_key, service_id)),
564 None => Err(Error::CommandReplyParseFailed(
565 "did not receive a ServiceID".to_string(),
566 )),
567 }
568 }
569
570 pub fn del_onion(&mut self, service_id: &V3OnionServiceId) -> Result<(), Error> {
571 self.del_onion_cmd(service_id).and_then(reply_ok).map(|_| ())
572 }
573
574 pub fn getinfo_net_listeners_socks(&mut self) -> Result<Vec<SocketAddr>, Error> {
577 let response = self.getinfo(&["net/listeners/socks"])?;
578 for (key, value) in response.iter() {
579 if key.as_str() == "net/listeners/socks" {
580 if value.is_empty() {
581 return Ok(Default::default());
582 }
583 let listeners: Vec<&str> = value.split(' ').collect();
585 let mut result: Vec<SocketAddr> = Default::default();
586 for socket_addr in listeners.iter() {
587 if !socket_addr.starts_with('\"') || !socket_addr.ends_with('\"') {
588 return Err(Error::CommandReplyParseFailed(format!(
589 "could not parse '{}' as socket address",
590 socket_addr
591 )));
592 }
593
594 let stripped = &socket_addr[1..socket_addr.len() - 1];
596 result.push(match SocketAddr::from_str(stripped) {
597 Ok(result) => result,
598 Err(_) => {
599 return Err(Error::CommandReplyParseFailed(format!(
600 "could not parse '{}' as socket address",
601 socket_addr
602 )))
603 }
604 });
605 }
606 return Ok(result);
607 }
608 }
609 Err(Error::CommandReplyParseFailed(
610 "reply did not find a 'net/listeners/socks' key/value".to_string(),
611 ))
612 }
613
614 pub fn getinfo_version(&mut self) -> Result<LegacyTorVersion, Error> {
615 let response = self.getinfo(&["version"])?;
616 for (key, value) in response.iter() {
617 if key.as_str() == "version" {
618 return LegacyTorVersion::from_str(value).map_err(Error::TorVersionParseFailed);
619 }
620 }
621 Err(Error::CommandReplyParseFailed(
622 "did not find a 'version' key/value".to_string(),
623 ))
624 }
625
626 pub fn onion_client_auth_add(
627 &mut self,
628 service_id: &V3OnionServiceId,
629 private_key: &X25519PrivateKey,
630 client_name: Option<String>,
631 flags: &OnionClientAuthAddFlags,
632 ) -> Result<(), Error> {
633 let reply = self.onion_client_auth_add_cmd(service_id, private_key, client_name, flags)?;
634
635 match reply.status_code {
636 250u32..=252u32 => Ok(()),
637 code => Err(Error::CommandFailed(code, reply.reply_lines)),
638 }
639 }
640
641 #[allow(dead_code)]
642 pub fn onion_client_auth_remove(&mut self, service_id: &V3OnionServiceId) -> Result<(), Error> {
643 let reply = self.onion_client_auth_remove_cmd(service_id)?;
644
645 match reply.status_code {
646 250u32..=251u32 => Ok(()),
647 code => Err(Error::CommandFailed(code, reply.reply_lines)),
648 }
649 }
650}
651
652#[test]
653#[serial]
654fn test_tor_controller() -> anyhow::Result<()> {
655 let tor_path = which::which(format!("tor{}", std::env::consts::EXE_SUFFIX))?;
656 let mut data_path = std::env::temp_dir();
657 data_path.push("test_tor_controller");
658 let tor_process = LegacyTorProcess::new(&tor_path, &data_path)?;
659
660 {
662 let control_stream =
663 LegacyControlStream::new(tor_process.get_control_addr(), Duration::from_millis(16))?;
664
665 let mut tor_controller = LegacyTorController::new(control_stream)?;
667 tor_controller.authenticate_cmd(tor_process.get_password())?;
668 assert!(
669 tor_controller
670 .authenticate_cmd("invalid password")?
671 .status_code
672 == 515u32
673 );
674
675 assert!(
677 tor_controller
678 .authenticate_cmd(tor_process.get_password())
679 .is_err(),
680 "expected failure due to closed connection"
681 );
682 assert!(tor_controller.control_stream.closed_by_remote());
683 }
684 {
686 let control_stream =
687 LegacyControlStream::new(tor_process.get_control_addr(), Duration::from_millis(16))?;
688
689 let mut tor_controller = LegacyTorController::new(control_stream)?;
692 tor_controller.authenticate(tor_process.get_password())?;
693
694 let vals = tor_controller.getconf(&["SocksPort", "AvoidDiskWrites", "DisableNetwork"])?;
696 for (key, value) in vals.iter() {
697 let expected = match key.as_str() {
698 "SocksPort" => "auto",
699 "AvoidDiskWrites" => "1",
700 "DisableNetwork" => "1",
701 _ => panic!("unexpected returned key: {}", key),
702 };
703 assert!(value == expected);
704 }
705
706 let vals = tor_controller.getinfo(&["version", "config-file", "config-text"])?;
707 let mut expected_torrc_path = data_path.clone();
708 expected_torrc_path.push("torrc");
709 let mut expected_control_port_path = data_path.clone();
710 expected_control_port_path.push("control_port");
711 for (key, value) in vals.iter() {
712 match key.as_str() {
713 "version" => assert!(Regex::new(r"\d+\.\d+\.\d+\.\d+")?.is_match(&value)),
714 "config-file" => assert!(Path::new(&value) == expected_torrc_path),
715 "config-text" => assert!(
716 value.to_string()
717 == format!(
718 "\nControlPort auto\nControlPortWriteToFile {}\nDataDirectory {}",
719 expected_control_port_path.display(),
720 data_path.display()
721 )
722 ),
723 _ => panic!("unexpected returned key: {}", key),
724 }
725 }
726
727 tor_controller.setevents(&["STATUS_CLIENT"])?;
728 tor_controller.setconf(&[("DisableNetwork", "0".to_string())])?;
730
731 let (private_key, service_id) =
733 match tor_controller.add_onion(None, &Default::default(), None, 22, None, None)? {
734 (Some(private_key), service_id) => (private_key, service_id),
735 _ => panic!("add_onion did not return expected values"),
736 };
737 println!("private_key: {}", private_key.to_key_blob());
738 println!("service_id: {}", service_id.to_string());
739
740 assert!(
741 tor_controller
742 .del_onion(&V3OnionServiceId::from_string(
743 "6l62fw7tqctlu5fesdqukvpoxezkaxbzllrafa2ve6ewuhzphxczsjyd"
744 )?)
745 .is_err(),
746 "deleting unknown onion should have failed"
747 );
748
749 tor_controller.del_onion(&service_id)?;
751
752 println!("listeners: ");
753 for sock_addr in tor_controller.getinfo_net_listeners_socks()?.iter() {
754 println!(" {}", sock_addr);
755 }
756
757 for (key, value) in tor_controller.getinfo(&["events/names"])?.iter() {
759 println!("{} : {}", key, value);
760 }
761
762 let stop_time = Instant::now() + std::time::Duration::from_secs(5);
763 while stop_time > Instant::now() {
764 for async_event in tor_controller.wait_async_events()?.iter() {
765 match async_event {
766 AsyncEvent::Unknown { lines } => {
767 println!("Unknown: {}", lines.join("\n"));
768 }
769 AsyncEvent::StatusClient {
770 severity,
771 action,
772 arguments,
773 } => {
774 println!("STATUS_CLIENT severity={}, action={}", severity, action);
775 for (key, value) in arguments.iter() {
776 println!(" {}='{}'", key, value);
777 }
778 }
779 AsyncEvent::HsDesc { action, hs_address } => {
780 println!(
781 "HS_DESC action={}, hsaddress={}",
782 action,
783 hs_address.to_string()
784 );
785 }
786 }
787 }
788 }
789 }
790 Ok(())
791}