Add Functionality for Tor Runner / Torrc Builder
This commit is contained in:
parent
dec3660f46
commit
178f426c7e
|
@ -1,3 +1,6 @@
|
||||||
/target
|
/target
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
.idea/*
|
.idea/*
|
||||||
|
examples/client_data_dir
|
||||||
|
examples/server_data_dir
|
||||||
|
*_rc
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
[package]
|
[package]
|
||||||
name = "tapir-cwtch"
|
name = "tapir-cwtch"
|
||||||
version = "0.1.7"
|
version = "0.1.8"
|
||||||
authors = ["Sarah Jamie Lewis <sarah@openprivacy.ca>"]
|
authors = ["Sarah Jamie Lewis <sarah@openprivacy.ca>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "Tapir is a small library for building p2p applications over anonymous communication systems"
|
description = "Tapir is a small library for building p2p applications over anonymous communication systems"
|
||||||
repository = "https://git.openprivacy.ca/sarah/tapir-rs"
|
repository = "https://git.openprivacy.ca/sarah/tapir-rs"
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|
||||||
|
# Compile with support for launching v3 onion services
|
||||||
onionv3 = []
|
onionv3 = []
|
||||||
|
|
||||||
[[test]]
|
[[test]]
|
||||||
|
@ -23,6 +25,7 @@ merlin = "2.0.0"
|
||||||
hex = "0.4.2"
|
hex = "0.4.2"
|
||||||
base32 = "0.4.0"
|
base32 = "0.4.0"
|
||||||
base64 = "0.13.0"
|
base64 = "0.13.0"
|
||||||
|
sha1 = "0.6.0"
|
||||||
sha3 = "0.9.1"
|
sha3 = "0.9.1"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0.61"
|
serde_json = "1.0.61"
|
||||||
|
@ -32,3 +35,4 @@ integer-encoding = "2.1.1"
|
||||||
secretbox = "0.1.2"
|
secretbox = "0.1.2"
|
||||||
subtle = "2.3.0"
|
subtle = "2.3.0"
|
||||||
hashbrown = "0.9.1"
|
hashbrown = "0.9.1"
|
||||||
|
which = "4.0.2"
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
|
use rand::{thread_rng, Rng};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tapir_cwtch::acns::tor::run::TorRunner;
|
||||||
|
use tapir_cwtch::acns::tor::torrc::TorrcGenerator;
|
||||||
use tapir_cwtch::applications::authentication_app::{AuthenicationApp, AUTHENTICATION_CAPABILITY};
|
use tapir_cwtch::applications::authentication_app::{AuthenicationApp, AUTHENTICATION_CAPABILITY};
|
||||||
use tapir_cwtch::connections::service::Service;
|
use tapir_cwtch::connections::service::Service;
|
||||||
use tapir_cwtch::connections::{Connection, ConnectionInterface, OutboundConnection};
|
use tapir_cwtch::connections::{Connection, ConnectionInterface, OutboundConnection};
|
||||||
|
@ -9,7 +12,19 @@ fn main() {
|
||||||
let identity = Arc::new(Identity::initialize_ephemeral_identity());
|
let identity = Arc::new(Identity::initialize_ephemeral_identity());
|
||||||
println!("Setup: {}", identity.hostname());
|
println!("Setup: {}", identity.hostname());
|
||||||
|
|
||||||
let mut service = Service::init(identity.clone());
|
let tor_path = which::which("tor");
|
||||||
|
let mut rng = thread_rng();
|
||||||
|
let socks_port = rng.gen_range(9052, 9100);
|
||||||
|
let mut tor_runner = TorRunner::run(
|
||||||
|
TorrcGenerator::new().with_socks_port(socks_port),
|
||||||
|
"./example_client_rc",
|
||||||
|
tor_path.unwrap().to_str().unwrap(),
|
||||||
|
"./examples/client_data_dir/",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
tor_runner.wait_until_bootstrapped();
|
||||||
|
|
||||||
|
let mut service = Service::init(identity.clone(), socks_port);
|
||||||
let identity = identity.clone();
|
let identity = identity.clone();
|
||||||
let outbound_identity = identity.clone();
|
let outbound_identity = identity.clone();
|
||||||
let outbound_service = |conn: Connection<OutboundConnection>| {
|
let outbound_service = |conn: Connection<OutboundConnection>| {
|
||||||
|
@ -24,7 +39,7 @@ fn main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match service.connect("qz4o3mqhzw6ye3jjadbi3l7g2rldbnkidf255iwsl4vfvrzsl26drfad", outbound_service.clone()) {
|
match service.connect("srg3w256xrpcs6o25i5qlo6iviwsybnfw66nwelrbah7c5e6knb32xyd", outbound_service.clone()) {
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
loop {}
|
loop {}
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
|
use rand::{thread_rng, Rng};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tapir_cwtch::acns::tor::authentication::HashedPassword;
|
use tapir_cwtch::acns::tor::authentication::HashedPassword;
|
||||||
|
use tapir_cwtch::acns::tor::run::TorRunner;
|
||||||
|
use tapir_cwtch::acns::tor::torrc::TorrcGenerator;
|
||||||
use tapir_cwtch::acns::tor::TorProcess;
|
use tapir_cwtch::acns::tor::TorProcess;
|
||||||
use tapir_cwtch::applications::authentication_app::AuthenicationApp;
|
use tapir_cwtch::applications::authentication_app::AuthenicationApp;
|
||||||
use tapir_cwtch::connections::service::Service;
|
use tapir_cwtch::connections::service::Service;
|
||||||
|
@ -8,7 +11,23 @@ use tapir_cwtch::primitives::identity::Identity;
|
||||||
use tapir_cwtch::primitives::transcript::Transcript;
|
use tapir_cwtch::primitives::transcript::Transcript;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let mut auth_control_port = TorProcess::connect(9051)
|
let tor_path = which::which("tor");
|
||||||
|
let mut rng = thread_rng();
|
||||||
|
let socks_port = rng.gen_range(10052, 11100);
|
||||||
|
let control_port = rng.gen_range(9052, 9100);
|
||||||
|
let mut tor_runner = TorRunner::run(
|
||||||
|
TorrcGenerator::new()
|
||||||
|
.with_socks_port(socks_port)
|
||||||
|
.with_control_port(control_port)
|
||||||
|
.with_hashed_control_password("examplehashedpassword"),
|
||||||
|
"./example_server_rc",
|
||||||
|
tor_path.unwrap().to_str().unwrap(),
|
||||||
|
"./examples/server_data_dir/",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
tor_runner.wait_until_bootstrapped();
|
||||||
|
|
||||||
|
let mut auth_control_port = TorProcess::connect(control_port)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.authenticate(Box::new(HashedPassword::new(String::from("examplehashedpassword"))))
|
.authenticate(Box::new(HashedPassword::new(String::from("examplehashedpassword"))))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
@ -20,7 +39,7 @@ fn main() {
|
||||||
println!("Service Id: {}", service_id);
|
println!("Service Id: {}", service_id);
|
||||||
println!("Setup: {}", identity.hostname());
|
println!("Setup: {}", identity.hostname());
|
||||||
|
|
||||||
let service = Service::init(identity.clone());
|
let service = Service::init(identity.clone(), 0);
|
||||||
|
|
||||||
let inbound_service = |conn: Connection<InboundConnection>| {
|
let inbound_service = |conn: Connection<InboundConnection>| {
|
||||||
let mut transcript = Transcript::new_transcript("tapir-transcript");
|
let mut transcript = Transcript::new_transcript("tapir-transcript");
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
|
#[cfg(any(feature = "onionv3"))]
|
||||||
pub mod tor;
|
pub mod tor;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum ACNError {
|
pub enum ACNError {
|
||||||
|
ConfigurationError(String),
|
||||||
AuthenticationError(String),
|
AuthenticationError(String),
|
||||||
ServiceSetupError(String),
|
ServiceSetupError(String),
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,8 @@ use std::io::{BufRead, BufReader, Error};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
|
|
||||||
pub mod authentication;
|
pub mod authentication;
|
||||||
|
pub mod run;
|
||||||
|
pub mod torrc;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct TorDisconnected(());
|
pub struct TorDisconnected(());
|
||||||
|
|
|
@ -0,0 +1,75 @@
|
||||||
|
use crate::acns::tor::torrc::TorrcGenerator;
|
||||||
|
use crate::acns::ACNError;
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct TorRunner {
|
||||||
|
process: Box<Child>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TorRunner {
|
||||||
|
/// run Tor at the given tor_path with the given torrc
|
||||||
|
pub fn run(torrc: TorrcGenerator, torrc_path: &str, tor_path: &str, tor_data_dir: &str) -> Result<TorRunner, ACNError> {
|
||||||
|
match torrc.build(torrc_path) {
|
||||||
|
Ok(()) => {
|
||||||
|
let child = Command::new(tor_path)
|
||||||
|
.arg("-f")
|
||||||
|
.arg(torrc_path)
|
||||||
|
.arg("--DataDirectory")
|
||||||
|
.arg(tor_data_dir)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
Ok(TorRunner { process: Box::new(child) })
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wait_until_bootstrapped(&mut self) {
|
||||||
|
let child_stdout = self.process.stdout.as_mut().unwrap();
|
||||||
|
let mut reader = BufReader::new(child_stdout);
|
||||||
|
loop {
|
||||||
|
let mut line = String::new();
|
||||||
|
reader.read_line(&mut line);
|
||||||
|
if line.is_empty() == false {
|
||||||
|
if line.contains("Bootstrapped 100% (done): Done") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kill Tor when the TorRunner drops out of scope
|
||||||
|
impl Drop for TorRunner {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
match self.process.kill() {
|
||||||
|
Ok(()) => eprintln!("Killing Tor...{:?}", self.process.wait()),
|
||||||
|
Err(_err) => eprintln!("Could not Kill Tor..."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::acns::tor::run::TorRunner;
|
||||||
|
use crate::acns::tor::torrc::TorrcGenerator;
|
||||||
|
use std::fs::remove_file;
|
||||||
|
use std::thread::sleep;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_run_tor() {
|
||||||
|
match TorRunner::run(TorrcGenerator::new().with_socks_port(9055), "./torrc", "/usr/sbin/tor") {
|
||||||
|
Ok(runner) => {
|
||||||
|
sleep(Duration::new(5, 0));
|
||||||
|
assert!(remove_file("./torrc").is_ok());
|
||||||
|
println!("Runner {:?}", runner)
|
||||||
|
}
|
||||||
|
_ => panic!("tor did not run"),
|
||||||
|
}
|
||||||
|
sleep(Duration::new(10, 0));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,130 @@
|
||||||
|
use crate::acns::ACNError;
|
||||||
|
use crate::acns::ACNError::ConfigurationError;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
/// Build a Torrc File with a set of configured options. Note: This is not a complete Torrc builder
|
||||||
|
/// although it might one day grow to be one. As it stands it only allows the configuration options
|
||||||
|
/// most necessary for Tor v3 Onion Service clients/servers as they related to Tapir/Cwtch.
|
||||||
|
/// We use the Builder patter to allow easy programmatic construction for different Tor configurations.
|
||||||
|
pub struct TorrcGenerator {
|
||||||
|
socks_port: u16,
|
||||||
|
control_port: u16,
|
||||||
|
hashed_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TorrcGenerator {
|
||||||
|
/// Generate a new, empty Torrc Generator.
|
||||||
|
pub fn new() -> TorrcGenerator {
|
||||||
|
TorrcGenerator {
|
||||||
|
socks_port: 0,
|
||||||
|
control_port: 0,
|
||||||
|
hashed_password: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure Tor to open a port to connect to external servers over Tor via SOCKS5
|
||||||
|
pub fn with_socks_port(mut self, socks_port: u16) -> Self {
|
||||||
|
self.socks_port = socks_port;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure Tor to have an open Control Port for configuration.
|
||||||
|
pub fn with_control_port(mut self, control_port: u16) -> Self {
|
||||||
|
self.control_port = control_port;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force the tor control port to accept a hashed password as authentication, the given password
|
||||||
|
/// is hashed with a random salt
|
||||||
|
/// From the Tor Docs: If the 'HashedControlPassword' option is set, it must contain the salted
|
||||||
|
/// hash of a secret password. The salted hash is computed according to the
|
||||||
|
/// S2K algorithm in RFC 2440 (OpenPGP), and prefixed with the s2k specifier.
|
||||||
|
/// This is then encoded in hexadecimal, prefixed by the indicator sequence
|
||||||
|
/// "16:".
|
||||||
|
pub fn with_hashed_control_password(mut self, password: &str) -> Self {
|
||||||
|
let salt: [u8; 8] = rand::random();
|
||||||
|
self.hashed_password = TorrcGenerator::generate_hashed_password(&salt, password);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// generate_hashed_password calculates a hash in the same way tha tor --hash-password does
|
||||||
|
/// this function takes a salt as input which is not great from an api-misuse perspective, but
|
||||||
|
/// we make it private.
|
||||||
|
fn generate_hashed_password(salt: &[u8; 8], password: &str) -> String {
|
||||||
|
let c = 96;
|
||||||
|
let mut count = (16 + (c & 15)) << ((c >> 4) + 6);
|
||||||
|
let mut tmp = vec![];
|
||||||
|
tmp.extend_from_slice(salt);
|
||||||
|
tmp.extend_from_slice(password.as_bytes());
|
||||||
|
let slen = tmp.len();
|
||||||
|
let mut hash = sha1::Sha1::new();
|
||||||
|
while count != 0 {
|
||||||
|
if count > slen {
|
||||||
|
hash.update(tmp.as_slice());
|
||||||
|
count -= slen
|
||||||
|
} else {
|
||||||
|
let tmp_slice = tmp.as_slice();
|
||||||
|
hash.update(tmp_slice.split_at(count).0);
|
||||||
|
count = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let hashed = hash.digest().bytes();
|
||||||
|
return format!("16:{}{:X}{}", hex::encode_upper(salt), c, hex::encode_upper(hashed));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compile the final torrc file and write it to the file system.
|
||||||
|
pub fn build(&self, file_name: &str) -> Result<(), ACNError> {
|
||||||
|
let mut file = match File::create(file_name) {
|
||||||
|
Err(why) => return Err(ConfigurationError(why.to_string())),
|
||||||
|
Ok(file) => file,
|
||||||
|
};
|
||||||
|
let mut lines = vec![];
|
||||||
|
|
||||||
|
if self.socks_port != 0 {
|
||||||
|
lines.push(format!("SocksPort {}", self.socks_port));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.control_port != 0 {
|
||||||
|
lines.push(format!("ControlPort {}", self.control_port));
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.hashed_password.is_empty() == false {
|
||||||
|
lines.push(format!("HashedControlPassword {}", self.hashed_password));
|
||||||
|
}
|
||||||
|
|
||||||
|
match file.write_all(lines.join("\n").as_bytes()) {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(why) => Err(ConfigurationError(why.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::acns::tor::torrc::TorrcGenerator;
|
||||||
|
use std::fs::{read_to_string, remove_file};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_hashed_password() {
|
||||||
|
let salt: [u8; 8] = [0xC1, 0x53, 0x05, 0xF9, 0x77, 0x89, 0x41, 0x4B];
|
||||||
|
assert_eq!(
|
||||||
|
String::from("16:C15305F97789414B601259E3EC5E76B8E55FC56A9F562B713F3D2BA257"),
|
||||||
|
TorrcGenerator::generate_hashed_password(&salt, "examplehashedpassword")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_torrc() {
|
||||||
|
let gen = TorrcGenerator::new().with_control_port(9051).with_hashed_control_password("examplehashedpassword");
|
||||||
|
match gen.build("./torrc") {
|
||||||
|
Ok(()) => {
|
||||||
|
assert!(Path::new("./torrc").exists());
|
||||||
|
println!("{}", read_to_string("./torrc").unwrap());
|
||||||
|
assert!(remove_file("./torrc").is_ok());
|
||||||
|
}
|
||||||
|
_ => assert!(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,6 +13,7 @@ pub struct ApplicationListenService(());
|
||||||
|
|
||||||
pub struct Service<ListenService> {
|
pub struct Service<ListenService> {
|
||||||
identity: Arc<Identity>,
|
identity: Arc<Identity>,
|
||||||
|
socks_port: u16,
|
||||||
listen_service: ListenService,
|
listen_service: ListenService,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,7 +22,7 @@ impl<ListenService> Service<ListenService> {
|
||||||
where
|
where
|
||||||
F: FnOnce(Connection<OutboundConnection>) + Send + Clone + 'static,
|
F: FnOnce(Connection<OutboundConnection>) + Send + Clone + 'static,
|
||||||
{
|
{
|
||||||
let conn = Socks5Stream::connect(format!("127.0.0.1:9050"), Domain(format!("{}.onion", hostname), 9878));
|
let conn = Socks5Stream::connect(format!("127.0.0.1:{}", self.socks_port), Domain(format!("{}.onion", hostname), 9878));
|
||||||
match conn {
|
match conn {
|
||||||
Ok(conn) => {
|
Ok(conn) => {
|
||||||
let application = application.clone();
|
let application = application.clone();
|
||||||
|
@ -34,9 +35,10 @@ impl<ListenService> Service<ListenService> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Service<NoListenService> {
|
impl Service<NoListenService> {
|
||||||
pub fn init(identity: Arc<Identity>) -> Service<NoListenService> {
|
pub fn init(identity: Arc<Identity>, socks_port: u16) -> Service<NoListenService> {
|
||||||
Service {
|
Service {
|
||||||
identity,
|
identity,
|
||||||
|
socks_port,
|
||||||
listen_service: NoListenService(()),
|
listen_service: NoListenService(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -66,6 +68,7 @@ impl Service<NoListenService> {
|
||||||
|
|
||||||
Ok(Service {
|
Ok(Service {
|
||||||
identity: self.identity,
|
identity: self.identity,
|
||||||
|
socks_port: self.socks_port,
|
||||||
listen_service: jh,
|
listen_service: jh,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -94,7 +97,7 @@ mod tests {
|
||||||
let keypair = ed25519_dalek::Keypair::generate(&mut csprng);
|
let keypair = ed25519_dalek::Keypair::generate(&mut csprng);
|
||||||
let _secret_key = SecretKey::from_bytes(&keypair.secret.to_bytes());
|
let _secret_key = SecretKey::from_bytes(&keypair.secret.to_bytes());
|
||||||
let identity = Identity::initialize(keypair);
|
let identity = Identity::initialize(keypair);
|
||||||
let _service = Service::init(identity);
|
let _service = Service::init(identity, 9051);
|
||||||
//let mut listen_service = service.listen(10000, TranscriptApp::new_instance()).unwrap_or_else(|_| panic!());
|
//let mut listen_service = service.listen(10000, TranscriptApp::new_instance()).unwrap_or_else(|_| panic!());
|
||||||
// this will not compile! wish we could test that service.connect(Hostname{},TranscriptApp::new_instance());
|
// this will not compile! wish we could test that service.connect(Hostname{},TranscriptApp::new_instance());
|
||||||
}
|
}
|
||||||
|
@ -105,7 +108,7 @@ mod tests {
|
||||||
let keypair = ed25519_dalek::Keypair::generate(&mut csprng);
|
let keypair = ed25519_dalek::Keypair::generate(&mut csprng);
|
||||||
let _secret_key = SecretKey::from_bytes(&keypair.secret.to_bytes());
|
let _secret_key = SecretKey::from_bytes(&keypair.secret.to_bytes());
|
||||||
let identity = Identity::initialize(keypair);
|
let identity = Identity::initialize(keypair);
|
||||||
let _service = Service::init(identity);
|
let _service = Service::init(identity, 9051);
|
||||||
//let listen_service = service.listen(1000, TranscriptApp::new_instance()).unwrap_or_else(|_| panic!());
|
//let listen_service = service.listen(1000, TranscriptApp::new_instance()).unwrap_or_else(|_| panic!());
|
||||||
// TODO use trybuild to test that this fails: service.connect(Hostname{},TranscriptApp::new_instance());
|
// TODO use trybuild to test that this fails: service.connect(Hostname{},TranscriptApp::new_instance());
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue