diff --git a/CHANGELOG.md b/CHANGELOG.md index 39b4939..1e85c4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ This project follows [semantic versioning](http://semver.org). +### UNRELEASED + +- [added] Added interactive configuration wizard +- [changed] Restructured example config + ### v2.1.0 (2021-02-06) - [added] Support for websocket proxy mode diff --git a/Cargo.lock b/Cargo.lock index e38393b..44fb646 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,6 +143,22 @@ dependencies = [ "vec_map", ] +[[package]] +name = "console" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50aab2529019abfabfa93f1e6c41ef392f91fbf179b347a7e96abb524884a08" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "regex", + "terminal_size", + "unicode-width", + "winapi", + "winapi-util", +] + [[package]] name = "const_fn" version = "0.4.5" @@ -269,6 +285,18 @@ dependencies = [ "libc", ] +[[package]] +name = "dialoguer" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70f807b2943dc90f9747497d9d65d7e92472149be0b88bf4ce1201b4ac979c26" +dependencies = [ + "console", + "lazy_static", + "tempfile", + "zeroize", +] + [[package]] name = "digest" version = "0.9.0" @@ -296,6 +324,12 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "fnv" version = "1.0.7" @@ -1028,6 +1062,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "terminal_size" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86ca8ced750734db02076f44132d802af0b33b09942331f4459dde8636fd2406" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "textwrap" version = "0.11.0" @@ -1231,6 +1275,7 @@ dependencies = [ "byteorder", "criterion", "daemonize", + "dialoguer", "fnv", "iai", "igd", @@ -1393,3 +1438,9 @@ checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" dependencies = [ "linked-hash-map", ] + +[[package]] +name = "zeroize" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45af6a010d13e4cf5b54c94ba5a2b2eba5596b9e46bf5875612d332a1f2b3f86" diff --git a/Cargo.toml b/Cargo.toml index 0141b99..25267e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,15 +22,16 @@ libc = "0.2" rand = "0.8" fnv = "1" yaml-rust = "0.4" -igd = { version = "0.12", optional = true } daemonize = "0.4" ring = "0.16" privdrop = "0.5" byteorder = "1.4" thiserror = "1.0" smallvec = "1.6" +dialoguer = { version = "0.7", optional = true } tungstenite = { version = "0.13", optional = true, default-features = false } url = { version = "2.2", optional = true } +igd = { version = "0.12", optional = true } [dev-dependencies] tempfile = "3" @@ -38,9 +39,10 @@ criterion = { version = "0.3", features = ["html_reports"] } iai = "0.1" [features] -default = ["nat", "websocket"] +default = ["nat", "websocket", "wizard"] nat = ["igd"] websocket = ["tungstenite", "url"] +wizard = ["dialoguer"] [[bench]] name = "criterion" diff --git a/src/config.rs b/src/config.rs index 4614e38..63f3b03 100644 --- a/src/config.rs +++ b/src/config.rs @@ -303,6 +303,46 @@ impl Config { } } + pub fn into_config_file(self) -> ConfigFile { + ConfigFile { + auto_claim: Some(self.auto_claim), + claims: Some(self.claims), + beacon: Some(ConfigFileBeacon { + store: self.beacon_store, + load: self.beacon_load, + interval: Some(self.beacon_interval), + password: self.beacon_password + }), + device: Some(ConfigFileDevice { + name: Some(self.device_name), + path: self.device_path, + type_: Some(self.device_type), + fix_rp_filter: Some(self.fix_rp_filter) + }), + crypto: self.crypto, + group: self.group, + user: self.user, + ifup: self.ifup, + ifdown: self.ifdown, + ip: self.ip, + keepalive: self.keepalive, + listen: Some(self.listen), + mode: Some(self.mode), + peer_timeout: Some(self.peer_timeout), + peers: Some(self.peers), + pid_file: self.pid_file, + port_forwarding: Some(self.port_forwarding), + stats_file: self.stats_file, + statsd: Some(ConfigFileStatsd { + server: self.statsd_server, + prefix: self.statsd_prefix + }), + switch_timeout: Some(self.switch_timeout), + hook: self.hook, + hooks: self.hooks + } + } + pub fn get_keepalive(&self) -> Duration { match self.keepalive { Some(dur) => dur, @@ -525,6 +565,14 @@ pub enum Command { /// Shell to create completions for #[structopt(long, default_value="bash")] shell: Shell + }, + + /// Edit the config of a network + #[cfg(feature = "wizard")] + Config { + /// Name of the network + #[structopt(short, long)] + name: Option } } diff --git a/src/crypto/common.rs b/src/crypto/common.rs index db31179..874b1bf 100644 --- a/src/crypto/common.rs +++ b/src/crypto/common.rs @@ -69,6 +69,27 @@ pub struct Crypto { } impl Crypto { + pub fn parse_algorithms(algos: &[String]) -> Result<(bool, Vec<&'static aead::Algorithm>), Error> { + let algorithms = algos.iter().map(|a| a as &str).collect::>(); + let allowed = if algorithms.is_empty() { &DEFAULT_ALGORITHMS } else { &algorithms as &[&str] }; + let mut algos = vec![]; + let mut unencrypted = false; + for name in allowed { + let algo = match &name.to_uppercase() as &str { + "UNENCRYPTED" | "NONE" | "PLAIN" => { + unencrypted = true; + continue + } + "AES128" | "AES128_GCM" | "AES_128" | "AES_128_GCM" => &aead::AES_128_GCM, + "AES256" | "AES256_GCM" | "AES_256" | "AES_256_GCM" => &aead::AES_256_GCM, + "CHACHA" | "CHACHA20" | "CHACHA20_POLY1305" => &aead::CHACHA20_POLY1305, + _ => return Err(Error::InvalidConfig("Unknown crypto method")) + }; + algos.push(algo) + } + Ok((unencrypted, algos)) + } + pub fn new(node_id: NodeId, config: &Config) -> Result { let key_pair = if let Some(priv_key) = &config.private_key { if let Some(pub_key) = &config.public_key { @@ -91,26 +112,17 @@ impl Crypto { key.clone_from_slice(key_pair.public_key().as_ref()); trusted_keys.push(key); } - let mut algos = Algorithms { algorithm_speeds: smallvec![], allow_unencrypted: false }; - let algorithms = config.algorithms.iter().map(|a| a as &str).collect::>(); - let allowed = if algorithms.is_empty() { &DEFAULT_ALGORITHMS } else { &algorithms as &[&str] }; + let (unencrypted, allowed_algos) = Self::parse_algorithms(&config.algorithms)?; + if unencrypted { + warn!("Crypto settings allow unencrypted connections") + } + let mut algos = Algorithms { algorithm_speeds: smallvec![], allow_unencrypted: unencrypted }; let duration = Duration::from_secs_f32(SPEED_TEST_TIME); let mut speeds = Vec::new(); - for name in allowed { - let algo = match &name.to_uppercase() as &str { - "UNENCRYPTED" | "NONE" | "PLAIN" => { - algos.allow_unencrypted = true; - warn!("Crypto settings allow unencrypted connections"); - continue - } - "AES128" | "AES128_GCM" | "AES_128" | "AES_128_GCM" => &aead::AES_128_GCM, - "AES256" | "AES256_GCM" | "AES_256" | "AES_256_GCM" => &aead::AES_256_GCM, - "CHACHA" | "CHACHA20" | "CHACHA20_POLY1305" => &aead::CHACHA20_POLY1305, - _ => return Err(Error::InvalidConfig("Unknown crypto method")) - }; + for algo in allowed_algos { let speed = test_speed(algo, &duration); algos.algorithm_speeds.push((algo, speed as f32)); - speeds.push((name, speed as f32)); + speeds.push((format!("{:?}", algo), speed as f32)); } if !speeds.is_empty() { info!( @@ -180,6 +192,11 @@ impl Crypto { Ok(result) } + pub fn public_key_from_private_key(privkey: &str) -> Result { + let keypair = Self::parse_private_key(privkey)?; + Ok(to_base62(keypair.public_key().as_ref())) + } + pub fn peer_instance(&self, payload: P) -> PeerCrypto

{ PeerCrypto::new( self.node_id, @@ -216,8 +233,7 @@ impl PeerCrypto

{ pub fn new( node_id: NodeId, init_payload: P, key_pair: Arc, trusted_keys: Arc<[Ed25519PublicKey]>, algorithms: Algorithms - ) -> Self - { + ) -> Self { Self { node_id, init: Some(InitState::new(node_id, init_payload, key_pair, trusted_keys, algorithms)), diff --git a/src/main.rs b/src/main.rs index ef91b89..f0c93b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,7 @@ pub mod port_forwarding; pub mod table; pub mod traffic; pub mod types; +#[cfg(feature = "wizard")] pub mod wizard; #[cfg(feature = "websocket")] pub mod wsproxy; use structopt::StructOpt; @@ -272,12 +273,15 @@ fn main() { } Command::Completion { shell } => { Args::clap().gen_completions_to(env!("CARGO_PKG_NAME"), shell, &mut io::stdout()); - return } #[cfg(feature = "websocket")] Command::WsProxy { listen } => { try_fail!(wsproxy::run_proxy(&listen), "Failed to run websocket proxy: {:?}"); } + #[cfg(feature = "wizard")] + Command::Config { name } => { + try_fail!(wizard::configure(name), "Wizard failed: {}"); + } } return } diff --git a/src/wizard.rs b/src/wizard.rs new file mode 100644 index 0000000..c34c3d6 --- /dev/null +++ b/src/wizard.rs @@ -0,0 +1,512 @@ +use crate::{config::Config, crypto::Crypto, device, types::Mode}; +use dialoguer::{theme::ColorfulTheme, Confirm, Input, MultiSelect, Password, Select}; +use ring::aead; +use std::{collections::HashMap, fs, io, os::unix::fs::PermissionsExt, path::Path}; + +const MODE_SIMPLE: usize = 0; +const MODE_ADVANCED: usize = 1; +const MODE_EXPERT: usize = 2; + +fn str_list(s: String) -> Vec { + if s.is_empty() { + vec![] + } else { + s.split(',').map(|k| k.trim().to_string()).collect() + } +} + +fn str_opt(s: String) -> Option { + if s.is_empty() { + None + } else { + Some(s) + } +} + +fn configure_connectivity(config: &mut Config, mode: usize, theme: &ColorfulTheme) -> Result<(), io::Error> { + if mode >= MODE_ADVANCED { + config.listen = + Input::with_theme(theme).with_prompt("Listen address").default(config.listen.clone()).interact_text()?; + } + config.peers = str_list( + Input::with_theme(theme) + .with_prompt("Peer addresses (comma separated)") + .default(config.peers.join(",")) + .interact_text()? + ); + if mode >= MODE_ADVANCED { + config.port_forwarding = Confirm::with_theme(theme) + .with_prompt("Enable automatic port forwarding?") + .default(config.port_forwarding) + .interact()?; + } + if mode == MODE_EXPERT { + config.peer_timeout = Input::with_theme(theme) + .with_prompt("Peer timeout (in seconds)") + .default(config.peer_timeout) + .interact_text()?; + let val = Input::with_theme(theme) + .with_prompt("Keepalive interval (in seconds, 0 for default)") + .default(config.keepalive.unwrap_or_default()) + .interact_text()?; + config.keepalive = if val == 0 { None } else { Some(val) }; + } + Ok(()) +} + + +fn configure_crypto(config: &mut Config, mode: usize, theme: &ColorfulTheme) -> Result<(), io::Error> { + if (config.crypto.password.is_some() || config.crypto.private_key.is_some()) + && !Confirm::with_theme(theme).with_prompt("Create new crypto config?").default(false).interact()? + { + return Ok(()) + } + let mut use_password = true; + if mode >= MODE_ADVANCED { + use_password = Select::with_theme(theme) + .with_prompt("Crypto configuration method") + .items(&["Simple (Password)", "Complex (Key pair)"]) + .default(if config.crypto.private_key.is_some() { 1 } else { 0 }) + .interact()? + == 0 + } + if use_password { + config.crypto.password = Some( + Password::with_theme(theme) + .with_prompt("Password") + .with_confirmation("Confirm password", "Passwords do not match") + .interact()? + ); + config.crypto.private_key = None; + config.crypto.public_key = None; + config.crypto.trusted_keys = vec![]; + } else { + config.crypto.password = None; + let (priv_key, pub_key) = match Select::with_theme(theme) + .with_prompt("Specify key pair") + .items(&["Generate new key pair", "Enter private key", "Generate from password"]) + .default(0) + .interact()? + { + 0 => { + let (priv_key, pub_key) = Crypto::generate_keypair(None); + info!("Private key: {}", priv_key); + info!("Public key: {}", pub_key); + (priv_key, pub_key) + } + 1 => { + let priv_key = Password::with_theme(theme) + .with_prompt("Private key") + .with_confirmation("Confirm private key", "Keys do not match") + .interact()?; + let pub_key = try_fail!(Crypto::public_key_from_private_key(&priv_key), "Invalid private key: {:?}"); + info!("Public key: {}", pub_key); + (priv_key, pub_key) + } + 2 => { + let password = Password::with_theme(theme) + .with_prompt("Password") + .with_confirmation("Confirm password", "Passwords do not match") + .interact()?; + let (priv_key, pub_key) = Crypto::generate_keypair(Some(&password)); + info!("Private key: {}", priv_key); + info!("Public key: {}", pub_key); + (priv_key, pub_key) + } + _ => unreachable!() + }; + config.crypto.trusted_keys = str_list( + Input::with_theme(theme) + .with_prompt("Trusted keys (public keys, comma separated)") + .default(pub_key.clone()) + .interact_text()? + ); + config.crypto.private_key = Some(priv_key); + config.crypto.public_key = Some(pub_key); + } + if mode == MODE_EXPERT { + let (unencrypted, allowed_algos) = Crypto::parse_algorithms(&config.crypto.algorithms) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid crypto algorithms"))?; + let algos = MultiSelect::with_theme(theme) + .with_prompt("Allowed encryption algorithms (select multiple)") + .items_checked(&[ + ("Unencrypted (dangerous)", unencrypted), + ("AES-128 in GCM mode", allowed_algos.contains(&&aead::AES_128_GCM)), + ("AES-256 in GCM mode", allowed_algos.contains(&&aead::AES_256_GCM)), + ("ChaCha20-Poly1305 (RFC 7539)", allowed_algos.contains(&&aead::CHACHA20_POLY1305)) + ]) + .interact()?; + config.crypto.algorithms = vec![]; + for (id, name) in &[(0, "PLAIN"), (1, "AES128"), (2, "AES256"), (3, "CHACHA20")] { + if algos.contains(id) { + config.crypto.algorithms.push(name.to_string()); + } + } + } + Ok(()) +} + +fn configure_device(config: &mut Config, mode: usize, theme: &ColorfulTheme) -> Result<(), io::Error> { + if mode >= MODE_ADVANCED { + config.device_type = match Select::with_theme(theme) + .with_prompt("Device type") + .items(&["Tun (IP based)", "Tap (Ethernet based)"]) + .default(if config.device_type == device::Type::Tun { 0 } else { 1 }) + .interact()? + { + 0 => device::Type::Tun, + 1 => device::Type::Tap, + _ => unreachable!() + } + } + if mode == MODE_EXPERT { + config.device_name = + Input::with_theme(theme).with_prompt("Device name").default(config.device_name.clone()).interact_text()?; + config.device_path = str_opt( + Input::with_theme(theme) + .with_prompt("Device path (empty for default)") + .default(config.device_path.as_ref().cloned().unwrap_or_default()) + .interact_text()? + ); + config.fix_rp_filter = Confirm::with_theme(theme) + .with_prompt("Automatically fix insecure rp_filter settings") + .default(config.fix_rp_filter) + .interact()?; + config.mode = match Select::with_theme(theme) + .with_prompt("Operation mode") + .items(&["Normal", "Router", "Switch", "Hub"]) + .default(match config.mode { + Mode::Normal => 0, + Mode::Router => 1, + Mode::Switch => 2, + Mode::Hub => 3 + }) + .interact()? + { + 0 => Mode::Normal, + 1 => Mode::Router, + 2 => Mode::Switch, + 3 => Mode::Hub, + _ => unreachable!() + }; + if config.mode == Mode::Switch { + config.switch_timeout = Input::with_theme(theme) + .with_prompt("Switch timeout (in seconds") + .default(config.switch_timeout) + .interact_text()?; + } + } + Ok(()) +} + +fn configure_addresses(config: &mut Config, mode: usize, theme: &ColorfulTheme) -> Result<(), io::Error> { + config.ip = str_opt( + Input::with_theme(theme) + .with_prompt("Virtual IP address (e.g. 10.0.0.1, leave empty for none)") + .allow_empty(true) + .default(config.ip.as_ref().cloned().unwrap_or_default()) + .interact_text()? + ); + if config.device_type == device::Type::Tun { + if mode >= MODE_ADVANCED { + config.auto_claim = Confirm::with_theme(theme) + .with_prompt("Automatically claim IP set on virtual interface?") + .default(config.auto_claim) + .interact()?; + } + if mode == MODE_EXPERT { + config.claims = str_list( + Input::with_theme(theme) + .with_prompt("Claim additional addresses (e.g. 10.0.0.0/24, comma separated, leave empty for none)") + .allow_empty(true) + .default(config.claims.join(",")) + .interact_text()? + ); + } + } else { + config.claims = vec![]; + } + if mode == MODE_EXPERT { + config.ifup = str_opt( + Input::with_theme(theme) + .with_prompt("Interface setup command (leave empty for none)") + .allow_empty(true) + .default(config.ifup.as_ref().cloned().unwrap_or_default()) + .interact_text()? + ); + config.ifdown = str_opt( + Input::with_theme(theme) + .with_prompt("Interface tear down command (leave empty for none)") + .allow_empty(true) + .default(config.ifdown.as_ref().cloned().unwrap_or_default()) + .interact_text()? + ); + } + Ok(()) +} + +fn configure_beacon(config: &mut Config, mode: usize, theme: &ColorfulTheme) -> Result<(), io::Error> { + if mode == MODE_EXPERT + && Confirm::with_theme(theme) + .with_prompt("Configure beacons?") + .default(config.beacon_load.is_some() || config.beacon_store.is_some()) + .interact()? + { + config.beacon_store = match Select::with_theme(theme) + .with_prompt("How to store beacons") + .items(&["Do not store beacons", "Store to file", "Execute command"]) + .default(if let Some(v) = &config.beacon_store { + if v.starts_with('|') { + 2 + } else { + 1 + } + } else { + 0 + }) + .interact()? + { + 0 => None, + 1 => { + Some( + Input::with_theme(theme) + .with_prompt("File path") + .default(config.beacon_store.clone().unwrap_or_default()) + .interact_text()? + ) + } + 2 => { + Some(format!( + "|{}", + Input::::with_theme(theme) + .with_prompt("Command") + .default(config.beacon_store.clone().unwrap_or_default().trim_start_matches('|').to_string()) + .interact_text()? + )) + } + _ => unreachable!() + }; + config.beacon_load = match Select::with_theme(theme) + .with_prompt("How to load beacons") + .items(&["Do not load beacons", "Load from file", "Execute command"]) + .default(if let Some(v) = &config.beacon_load { + if v.starts_with('|') { + 2 + } else { + 1 + } + } else { + 0 + }) + .interact()? + { + 0 => None, + 1 => { + Some( + Input::with_theme(theme) + .with_prompt("File path") + .default(config.beacon_load.clone().unwrap_or_default()) + .interact_text()? + ) + } + 2 => { + Some(format!( + "|{}", + Input::::with_theme(theme) + .with_prompt("Command") + .default(config.beacon_load.clone().unwrap_or_default().trim_start_matches('|').to_string()) + .interact_text()? + )) + } + _ => unreachable!() + }; + config.beacon_interval = Input::with_theme(theme) + .with_prompt("Beacon interval (in seconds)") + .default(config.beacon_interval) + .interact_text()?; + config.beacon_password = str_opt( + Password::with_theme(theme) + .with_prompt("Beacon password (leave empty for none)") + .with_confirmation("Confirm password", "Passwords do not match") + .allow_empty_password(true) + .interact()? + ); + } + Ok(()) +} + +fn configure_stats(config: &mut Config, mode: usize, theme: &ColorfulTheme) -> Result<(), io::Error> { + if mode >= MODE_ADVANCED { + config.stats_file = str_opt( + Input::with_theme(theme) + .with_prompt("Write stats to file (empty to disable)") + .default(config.stats_file.clone().unwrap_or_default()) + .allow_empty(true) + .interact_text()? + ); + } + if mode == MODE_EXPERT { + if Confirm::with_theme(theme) + .with_prompt("Send statistics to statsd server?") + .default(config.statsd_server.is_some()) + .interact()? + { + config.statsd_server = str_opt( + Input::with_theme(theme) + .with_prompt("Statsd server URL") + .default(config.statsd_server.clone().unwrap_or_default()) + .allow_empty(true) + .interact_text()? + ); + config.statsd_prefix = str_opt( + Input::with_theme(theme) + .with_prompt("Statsd prefix") + .default(config.statsd_prefix.clone().unwrap_or_default()) + .allow_empty(true) + .interact_text()? + ); + } else { + config.statsd_server = None; + } + } + Ok(()) +} + +fn configure_process(config: &mut Config, mode: usize, theme: &ColorfulTheme) -> Result<(), io::Error> { + if mode == MODE_EXPERT { + config.user = str_opt( + Input::with_theme(theme) + .with_prompt("Run as different user (empty to disable)") + .default(config.user.clone().unwrap_or_default()) + .allow_empty(true) + .interact_text()? + ); + config.group = str_opt( + Input::with_theme(theme) + .with_prompt("Run as different group (empty to disable)") + .default(config.group.clone().unwrap_or_default()) + .allow_empty(true) + .interact_text()? + ); + config.pid_file = str_opt( + Input::with_theme(theme) + .with_prompt("Write process id to file (empty to disable)") + .default(config.pid_file.clone().unwrap_or_default()) + .allow_empty(true) + .interact_text()? + ); + } + Ok(()) +} + +fn configure_hooks(config: &mut Config, mode: usize, theme: &ColorfulTheme) -> Result<(), io::Error> { + if mode == MODE_EXPERT { + if Confirm::with_theme(theme) + .with_prompt("Set hooks to react on events?") + .default(config.hook.is_some() || !config.hooks.is_empty()) + .interact()? + { + config.hook = str_opt( + Input::with_theme(theme) + .with_prompt("Command to execute for all events (empty to disable)") + .default(config.hook.clone().unwrap_or_default()) + .allow_empty(true) + .interact_text()? + ); + let mut hooks: HashMap = Default::default(); + for event in &[ + "peer_connecting", + "peer_connected", + "peer_disconnected", + "device_setup", + "device_configured", + "vpn_started", + "vpn_shutdown" + ] { + if let Some(cmd) = str_opt( + Input::with_theme(theme) + .with_prompt(format!("Command to execute for event '{}' (empty to disable)", event)) + .default(config.hooks.get(*event).cloned().unwrap_or_default()) + .allow_empty(true) + .interact_text()? + ) { + hooks.insert(event.to_string(), cmd); + } + } + config.hooks = hooks; + } else { + config.hook = None; + config.hooks = Default::default(); + } + } + Ok(()) +} + +pub fn configure(name: Option) -> Result<(), io::Error> { + let theme = ColorfulTheme::default(); + + let name = if let Some(name) = name { + name + } else { + let mut names = vec![]; + for file in fs::read_dir("/etc/vpncloud")? { + names.push(file?.path().file_stem().unwrap().to_str().unwrap().to_string()); + } + let selection = + Select::with_theme(&theme).with_prompt("Which network?").item("New network").items(&names).interact()?; + if selection > 0 { + names[selection - 1].clone() + } else { + Input::with_theme(&theme).with_prompt("Network name").interact_text()? + } + }; + + let mut config = Config::default(); + let file = Path::new("/etc/vpncloud").join(format!("{}.net", name)); + if file.exists() { + let f = fs::File::open(&file)?; + let config_file = serde_yaml::from_reader(f) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Failed to parse config file"))?; + config.merge_file(config_file); + } + if file.parent().unwrap().metadata()?.permissions().readonly() { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, "Config file not writable")) + } + + loop { + let mode = Select::with_theme(&theme) + .with_prompt("Configuration mode") + .items(&["Simple (minimal options)", "Advanced (some more options)", "Expert (all options)"]) + .default(MODE_SIMPLE) + .interact()?; + + configure_connectivity(&mut config, mode, &theme)?; + configure_crypto(&mut config, mode, &theme)?; + configure_device(&mut config, mode, &theme)?; + configure_addresses(&mut config, mode, &theme)?; + configure_beacon(&mut config, mode, &theme)?; + configure_stats(&mut config, mode, &theme)?; + configure_process(&mut config, mode, &theme)?; + configure_hooks(&mut config, mode, &theme)?; + if Confirm::with_theme(&theme).with_prompt("Finish configuration?").default(true).interact()? { + break + } + } + + if Confirm::with_theme(&theme).with_prompt("Save config?").default(true).interact()? { + let config_file = config.into_config_file(); + let f = fs::File::create(&file)?; + serde_yaml::to_writer(f, &config_file) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Failed to parse config file"))?; + fs::set_permissions(file, fs::Permissions::from_mode(600))?; + println!(); + println!("Use the following commands to control your VPN:"); + println!(" start the VPN: sudo service vpncloud@{0} start", name); + println!(" stop the VPN: sudo service vpncloud@{0} stop", name); + println!(" get the status: sudo service vpncloud@{0} status", name); + println!(" add VPN to autostart: sudo sysctl enable vpncloud@{0}", name); + println!(" remove VPN from autostart: sudo sysctl disable vpncloud@{0}", name); + } + + Ok(()) +}