diff --git a/.cargo/config b/.cargo/config index f55a0b7..de148a5 100644 --- a/.cargo/config +++ b/.cargo/config @@ -3,12 +3,27 @@ linker = "arm-linux-gnueabihf-gcc" objcopy = { path = "arm-linux-gnueabihf-objcopy" } strip = { path = "arm-linux-gnueabihf-strip" } +[target.armv7-unknown-linux-musleabihf] +linker = "arm-linux-gnueabihf-gcc" +objcopy = { path = "arm-linux-gnueabihf-objcopy" } +strip = { path = "arm-linux-gnueabihf-strip" } + [target.arm-unknown-linux-gnueabihf] linker = "arm-linux-gnueabihf-gcc" objcopy = { path = "arm-linux-gnueabihf-objcopy" } strip = { path = "arm-linux-gnueabihf-strip" } +[target.arm-unknown-linux-musleabihf] +linker = "arm-linux-gnueabihf-gcc" +objcopy = { path = "arm-linux-gnueabihf-objcopy" } +strip = { path = "arm-linux-gnueabihf-strip" } + [target.aarch64-unknown-linux-gnu] linker = "aarch64-linux-gnu-gcc" objcopy = { path = "aarch64-linux-gnu-objcopy" } strip = { path = "aarch64-linux-gnu-strip" } + +[target.aarch64-unknown-linux-musl] +linker = "aarch64-linux-gnu-gcc" +objcopy = { path = "aarch64-linux-gnu-objcopy" } +strip = { path = "aarch64-linux-gnu-strip" } \ No newline at end of file diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index f9be6a7..84a459f 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -11,7 +11,7 @@ jobs: - name: Run builder uses: ./.github/actions/build-deb with: - rust: '1.49.0' + rust: '1.50.0' - name: Archive artifacts uses: actions/upload-artifact@v1 with: @@ -31,7 +31,7 @@ jobs: - name: Run builder uses: ./.github/actions/build-rpm with: - rust: '1.49.0' + rust: '1.50.0' - name: Archive artifacts uses: actions/upload-artifact@v1 with: 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 c07fce1..d1e064e 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" @@ -913,9 +947,9 @@ dependencies = [ [[package]] name = "serde_yaml" -version = "0.8.16" +version = "0.8.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd2af560da3c1fdc02cb80965289254fc35dff869810061e2d8290ee48848ae" +checksum = "15654ed4ab61726bf918a39cb8d98a2e2995b002387807fa6ba58fdf7f59bb23" dependencies = [ "dtoa", "linked-hash-map", @@ -1077,6 +1111,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" @@ -1280,6 +1324,7 @@ dependencies = [ "byteorder", "criterion", "daemonize", + "dialoguer", "fnv", "iai", "igd", @@ -1443,3 +1488,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 d18f54d..b587ed2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,6 @@ 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" @@ -30,8 +29,10 @@ byteorder = "1.4" thiserror = "1.0" parking_lot = "*" 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" @@ -39,9 +40,11 @@ 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"] +installer = [] [[bench]] name = "criterion" diff --git a/builder/Dockerfile-deb b/builder/Dockerfile-deb index 38b22ef..7eb73d0 100644 --- a/builder/Dockerfile-deb +++ b/builder/Dockerfile-deb @@ -11,6 +11,7 @@ RUN apt-get update \ libc6-dev-i386 \ gcc-5-multilib \ asciidoctor \ + musl musl-dev musl-tools \ && rm -rf /var/cache/dpkg RUN ln -s asm-generic/ /usr/include/asm @@ -19,7 +20,7 @@ RUN useradd -ms /bin/bash user USER user WORKDIR /home/user -ENV RUST=1.49.0 +ENV RUST=1.50.0 RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain ${RUST} @@ -27,12 +28,18 @@ ENV PATH=/home/user/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin RUN rustup target add i686-unknown-linux-gnu \ && rustup target add armv7-unknown-linux-gnueabihf \ - && rustup target add aarch64-unknown-linux-gnu + && rustup target add aarch64-unknown-linux-gnu \ + && rustup target add x86_64-unknown-linux-musl \ + && rustup target add i686-unknown-linux-musl \ + && rustup target add armv7-unknown-linux-musleabihf \ + && rustup target add aarch64-unknown-linux-musl RUN cargo install cargo-deb \ && rm -rf /home/user/.cargo/{git,tmp,registry} +ENV UPX_VER=3.96 +RUN curl https://github.com/upx/upx/releases/download/v${UPX_VER}/upx-${UPX_VER}-amd64_linux.tar.xz -Lf | tar -xJ --strip-components=1 -C /home/user/.cargo/bin + VOLUME /home/user/.cargo/tmp VOLUME /home/user/.cargo/git -VOLUME /home/user/.cargo/registry - +VOLUME /home/user/.cargo/registry \ No newline at end of file diff --git a/builder/Dockerfile-rpm b/builder/Dockerfile-rpm index 9a90a28..5a0d255 100644 --- a/builder/Dockerfile-rpm +++ b/builder/Dockerfile-rpm @@ -7,7 +7,7 @@ RUN useradd -ms /bin/bash user USER user WORKDIR /home/user -ENV RUST=1.49.0 +ENV RUST=1.50.0 RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain ${RUST} diff --git a/builder/build.sh b/builder/build.sh index 43d7425..548c832 100755 --- a/builder/build.sh +++ b/builder/build.sh @@ -32,24 +32,44 @@ mkdir -p ../dist docker build --rm -f=Dockerfile-deb -t vpncloud-builder-deb . # x86_64 deb -docker_cmd deb 'cd code && cargo deb' -cp $CACHE/deb/target/debian/vpncloud_${DEB_VERSION}_amd64.deb ../dist/vpncloud_${DEB_VERSION}_amd64.deb +if ! [ -f ../dist/vpncloud_${DEB_VERSION}_amd64.deb ]; then + docker_cmd deb 'cd code && cargo deb' + cp $CACHE/deb/target/debian/vpncloud_${DEB_VERSION}_amd64.deb ../dist/vpncloud_${DEB_VERSION}_amd64.deb +fi -# i386 deb -docker_cmd deb 'cd code && cargo deb --target i686-unknown-linux-gnu' -cp $CACHE/deb/target/i686-unknown-linux-gnu/debian/vpncloud_${DEB_VERSION}_i386.deb ../dist/vpncloud_${DEB_VERSION}_i386.deb +build_deb() { + ARCH=$1 + TARGET=$2 + if ! [ -f ../dist/vpncloud_${DEB_VERSION}_${ARCH}.deb ]; then + docker_cmd deb "cd code && cargo deb --target ${TARGET}" + cp $CACHE/deb/target/${TARGET}/debian/vpncloud_${DEB_VERSION}_${ARCH}.deb ../dist/vpncloud_${DEB_VERSION}_${ARCH}.deb + fi +} -# arm7hf deb -docker_cmd deb 'cd code && cargo deb --target armv7-unknown-linux-gnueabihf' -cp $CACHE/deb/target/armv7-unknown-linux-gnueabihf/debian/vpncloud_${DEB_VERSION}_armhf.deb ../dist/vpncloud_${DEB_VERSION}_armhf.deb +build_deb i386 i686-unknown-linux-gnu +build_deb armhf armv7-unknown-linux-gnueabihf +build_deb arm64 aarch64-unknown-linux-gnu -# aarch64 deb -docker_cmd deb 'cd code && cargo deb --target aarch64-unknown-linux-gnu' -cp $CACHE/deb/target/aarch64-unknown-linux-gnu/debian/vpncloud_${DEB_VERSION}_arm64.deb ../dist/vpncloud_${DEB_VERSION}_arm64.deb + +build_static() { + ARCH=$1 + TARGET=$2 + if ! [ -f ../dist/vpncloud_${VERSION}_static_${ARCH} ]; then + docker_cmd deb "cd code && cargo build --release --features installer --target ${TARGET} && upx --lzma target/${TARGET}/release/vpncloud" + cp $CACHE/deb/target/${TARGET}/release/vpncloud ../dist/vpncloud_${VERSION}_static_${ARCH} + fi +} + +build_static amd64 x86_64-unknown-linux-musl +build_static i386 i686-unknown-linux-gnu +build_static armhf armv7-unknown-linux-musleabihf +#build_static arm64 aarch64-unknown-linux-musl # fails for unknown reason docker build --rm -f=Dockerfile-rpm -t vpncloud-builder-rpm . -# x86_64 rpm -docker_cmd rpm 'cd code && cargo rpm build' -cp $CACHE/rpm/target/release/rpmbuild/RPMS/x86_64/vpncloud-${RPM_VERSION}.x86_64.rpm ../dist/vpncloud_${RPM_VERSION}.x86_64.rpm +if ! [ -f ../dist/vpncloud_${RPM_VERSION}.x86_64.rpm ]; then + # x86_64 rpm + docker_cmd rpm 'cd code && cargo rpm build' + cp $CACHE/rpm/target/release/rpmbuild/RPMS/x86_64/vpncloud-${RPM_VERSION}.x86_64.rpm ../dist/vpncloud_${RPM_VERSION}.x86_64.rpm +fi \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 4614e38..5a9c983 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,22 @@ 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 + }, + + /// Install required utility files + #[cfg(feature = "installer")] + Install { + /// Remove installed files again + #[structopt(long)] + uninstall: bool } } diff --git a/src/crypto/common.rs b/src/crypto/common.rs index 785686f..c948ca2 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!( diff --git a/src/installer.rs b/src/installer.rs new file mode 100644 index 0000000..2882a3c --- /dev/null +++ b/src/installer.rs @@ -0,0 +1,49 @@ +use crate::error::Error; +use std::{ + env, + fs::{self, File}, + io::Write, + os::unix::fs::PermissionsExt +}; + +const MANPAGE: &[u8] = include_bytes!("../target/vpncloud.1.gz"); +const SERVICE_FILE: &[u8] = include_bytes!("../assets/vpncloud@.service"); +const WS_PROXY_SERVICE_FILE: &[u8] = include_bytes!("../assets/vpncloud-wsproxy.service"); +const EXAMPLE_CONFIG: &[u8] = include_bytes!("../assets/example.net.disabled"); + +pub fn install() -> Result<(), Error> { + env::current_exe() + .and_then(|p| fs::copy(p, "/usr/bin/vpncloud")) + .map_err(|e| Error::FileIo("Failed to copy binary", e))?; + fs::set_permissions("/usr/bin/vpncloud", fs::Permissions::from_mode(755)) + .map_err(|e| Error::FileIo("Failed to set permissions for binary", e))?; + fs::create_dir_all("/etc/vpncloud").map_err(|e| Error::FileIo("Failed to create config folder", e))?; + fs::set_permissions("/etc/vpncloud", fs::Permissions::from_mode(600)) + .map_err(|e| Error::FileIo("Failed to set permissions for config folder", e))?; + File::create("/etc/vpncloud/example.net.disabled") + .and_then(|mut f| f.write_all(EXAMPLE_CONFIG)) + .map_err(|e| Error::FileIo("Failed to create example config", e))?; + File::create("/usr/share/man/man1/vpncloud.1.gz") + .and_then(|mut f| f.write_all(MANPAGE)) + .map_err(|e| Error::FileIo("Failed to create manpage", e))?; + File::create("/lib/systemd/system/vpncloud@.service") + .and_then(|mut f| f.write_all(SERVICE_FILE)) + .map_err(|e| Error::FileIo("Failed to create service file", e))?; + File::create("/lib/systemd/system/vpncloud-wsproxy.service") + .and_then(|mut f| f.write_all(WS_PROXY_SERVICE_FILE)) + .map_err(|e| Error::FileIo("Failed to create wsporxy service file", e))?; + info!("Install successful"); + Ok(()) +} + +pub fn uninstall() -> Result<(), Error> { + fs::remove_file("/etc/vpncloud/example.net.disabled").map_err(|e| Error::FileIo("Failed to remove binary", e))?; + fs::remove_file("/usr/share/man/man1/vpncloud.1.gz").map_err(|e| Error::FileIo("Failed to remove manpage", e))?; + fs::remove_file("/lib/systemd/system/vpncloud@.service") + .map_err(|e| Error::FileIo("Failed to remove service file", e))?; + fs::remove_file("/lib/systemd/system/vpncloud-wsproxy.service") + .map_err(|e| Error::FileIo("Failed to remove wsproxy service file", e))?; + fs::remove_file("/usr/bin/vpncloud").map_err(|e| Error::FileIo("Failed to remove binary", e))?; + info!("Uninstall successful"); + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index b318a3d..9242a67 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,7 +27,9 @@ 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; +#[cfg(feature = "installer")] pub mod installer; use structopt::StructOpt; @@ -272,12 +274,23 @@ 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: {}"); + } + #[cfg(feature = "installer")] + Command::Install { uninstall } => { + if uninstall { + try_fail!(installer::uninstall(), "Uninstall failed: {}"); + } else { + try_fail!(installer::install(), "Install 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(()) +}