From 449b4b8278496634cae9cb87fc3dc20e71222f7b Mon Sep 17 00:00:00 2001 From: Jake McGinty Date: Thu, 10 Jun 2021 22:57:47 +0900 Subject: [PATCH] client: support running as non-root (#94) shared(wg): use netlink instead of execve calls to "ip" hostsfile: write to hostsfile in-place --- Cargo.lock | 67 +++++++++++++++-- client/Cargo.toml | 2 +- client/src/data_store.rs | 14 +--- client/src/main.rs | 53 +++++++------- client/src/util.rs | 55 ++++++++++++-- hostsfile/Cargo.toml | 3 +- hostsfile/src/lib.rs | 77 ++++++++++---------- server/Cargo.toml | 1 + server/src/api/admin/peer.rs | 2 +- server/src/initialize.rs | 17 ++--- server/src/main.rs | 10 +-- server/src/test.rs | 2 +- shared/Cargo.toml | 8 +++ shared/src/interface_config.rs | 9 +-- shared/src/lib.rs | 31 +++++--- shared/src/netlink.rs | 128 +++++++++++++++++++++++++++++++++ shared/src/prompts.rs | 13 ++-- shared/src/types.rs | 17 ++--- shared/src/wg.rs | 106 ++++++++++++--------------- wgctrl-rs/src/device.rs | 2 +- 20 files changed, 417 insertions(+), 200 deletions(-) create mode 100644 shared/src/netlink.rs diff --git a/Cargo.lock b/Cargo.lock index 636906b..f85431a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,13 +153,13 @@ dependencies = [ name = "client" version = "1.3.1" dependencies = [ + "anyhow", "colored", "dialoguer", "hostsfile", "indoc", "ipnetwork", "lazy_static", - "libc", "log", "regex", "serde", @@ -382,10 +382,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hostsfile" -version = "1.0.1" -dependencies = [ - "tempfile", -] +version = "1.1.0" [[package]] name = "http" @@ -576,6 +573,54 @@ dependencies = [ "winapi", ] +[[package]] +name = "netlink-packet-core" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac48279d5062bdf175bdbcb6b58ff1d6b0ecd54b951f7a0ff4bc0550fe903ccb" +dependencies = [ + "anyhow", + "byteorder", + "libc", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-route" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da1bf86b4324996fb58f8e17752b2a06176f4c5efc013928060ac94a3a329b71" +dependencies = [ + "anyhow", + "bitflags", + "byteorder", + "libc", + "netlink-packet-core", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-utils" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2afb159d0e3ac700e85f0df25b8438b99d43ed0c0b685242fcdf1b5673e54d" +dependencies = [ + "anyhow", + "byteorder", + "paste", + "thiserror", +] + +[[package]] +name = "netlink-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d61c5374735aa0cd07cb7fd820b656062b187b5588d79517f72956b57c6de9ef" +dependencies = [ + "libc", + "log", +] + [[package]] name = "nom" version = "5.1.2" @@ -636,6 +681,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "paste" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf547ad0c65e31259204bd90935776d1c693cec2f4ff7abb7a1bbbd40dfe58" + [[package]] name = "peeking_take_while" version = "0.1.2" @@ -930,12 +981,17 @@ dependencies = [ name = "shared" version = "1.3.1" dependencies = [ + "anyhow", "colored", "dialoguer", "indoc", "ipnetwork", "lazy_static", + "libc", "log", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", "publicip", "regex", "serde", @@ -943,6 +999,7 @@ dependencies = [ "toml", "url", "wgctrl", + "wgctrl-sys", ] [[package]] diff --git a/client/Cargo.toml b/client/Cargo.toml index 5ed75ed..be344eb 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -14,13 +14,13 @@ name = "innernet" path = "src/main.rs" [dependencies] +anyhow = "1" colored = "2" dialoguer = "0.8" hostsfile = { path = "../hostsfile" } indoc = "1" ipnetwork = { git = "https://github.com/mcginty/ipnetwork" } # pending https://github.com/achanda/ipnetwork/pull/129 lazy_static = "1" -libc = "0.2" log = "0.4" regex = { version = "1", default-features = false, features = ["std"] } serde = { version = "1.0", features = ["derive"] } diff --git a/client/src/data_store.rs b/client/src/data_store.rs index 1f07829..5ffa8a0 100644 --- a/client/src/data_store.rs +++ b/client/src/data_store.rs @@ -1,5 +1,5 @@ use crate::Error; -use colored::*; +use anyhow::bail; use serde::{Deserialize, Serialize}; use shared::{ensure_dirs_exist, Cidr, IoErrorContext, Peer, WrappedIoError, CLIENT_DATA_DIR}; use std::{ @@ -35,13 +35,7 @@ impl DataStore { .open(path) .with_path(path)?; - if shared::chmod(&file, 0o600).with_path(path)? { - println!( - "{} updated permissions for {} to 0600.", - "[!]".yellow(), - path.display() - ); - } + shared::warn_on_dangerous_mode(path).with_path(path)?; let mut json = String::new(); file.read_to_string(&mut json).with_path(path)?; @@ -94,9 +88,7 @@ impl DataStore { for new_peer in current_peers.iter() { if let Some(existing_peer) = peers.iter_mut().find(|p| p.ip == new_peer.ip) { if existing_peer.public_key != new_peer.public_key { - return Err( - "PINNING ERROR: New peer has same IP but different public key.".into(), - ); + bail!("PINNING ERROR: New peer has same IP but different public key."); } else { *existing_peer = new_peer.clone(); } diff --git a/client/src/main.rs b/client/src/main.rs index b31fbf6..ba2ed3d 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -1,3 +1,4 @@ +use anyhow::{anyhow, bail}; use colored::*; use dialoguer::{Confirm, Input}; use hostsfile::HostsBuilder; @@ -6,10 +7,10 @@ use shared::{ interface_config::InterfaceConfig, prompts, AddAssociationOpts, AddCidrOpts, AddPeerOpts, Association, AssociationContents, Cidr, CidrTree, DeleteCidrOpts, EndpointContents, InstallOpts, Interface, IoErrorContext, NetworkOpt, Peer, RedeemContents, RenamePeerOpts, - State, CLIENT_CONFIG_DIR, REDEEM_TRANSITION_WAIT, + State, WrappedIoError, CLIENT_CONFIG_DIR, REDEEM_TRANSITION_WAIT, }; use std::{ - fmt, + fmt, io, path::{Path, PathBuf}, thread, time::{Duration, SystemTime}, @@ -245,7 +246,9 @@ fn update_hosts_file( &format!("{}.{}.wg", peer.contents.name, interface), ); } - hosts_builder.write_to(hosts_path)?; + if let Err(e) = hosts_builder.write_to(&hosts_path).with_path(hosts_path) { + log::warn!("failed to update hosts ({})", e); + } Ok(()) } @@ -272,7 +275,7 @@ fn install( let target_conf = CLIENT_CONFIG_DIR.join(&iface).with_extension("conf"); if target_conf.exists() { - return Err("An interface with this name already exists in innernet.".into()); + bail!("An interface with this name already exists in innernet."); } let iface = iface.parse()?; @@ -423,11 +426,10 @@ fn fetch( if !interface_up { if !bring_up_interface { - return Err(format!( + bail!( "Interface is not up. Use 'innernet up {}' instead", interface - ) - .into()); + ); } log::info!("bringing up the interface."); @@ -647,7 +649,7 @@ fn rename_peer(interface: &InterfaceName, opts: RenamePeerOpts) -> Result<(), Er .filter(|p| p.name == old_name) .map(|p| p.id) .next() - .ok_or("Peer not found.")?; + .ok_or(anyhow!("Peer not found."))?; let _ = api.http_form("PUT", &format!("/admin/peers/{}", id), peer_request)?; log::info!("Peer renamed."); @@ -687,11 +689,11 @@ fn add_association(interface: &InterfaceName, opts: AddAssociationOpts) -> Resul let cidr1 = cidrs .iter() .find(|c| &c.name == cidr1) - .ok_or(format!("can't find cidr '{}'", cidr1))?; + .ok_or(anyhow!("can't find cidr '{}'", cidr1))?; let cidr2 = cidrs .iter() .find(|c| &c.name == cidr2) - .ok_or(format!("can't find cidr '{}'", cidr2))?; + .ok_or(anyhow!("can't find cidr '{}'", cidr2))?; (cidr1, cidr2) } else if let Some((cidr1, cidr2)) = prompts::add_association(&cidrs[..])? { (cidr1, cidr2) @@ -824,16 +826,18 @@ fn show( let devices = interfaces .into_iter() .filter_map(|name| { - DataStore::open(&name) - .and_then(|store| { - Ok(( - Device::get(&name, network.backend).with_str(name.as_str_lossy())?, - store, - )) - }) - .ok() + match DataStore::open(&name) { + Ok(store) => { + let device = Device::get(&name, network.backend).with_str(name.as_str_lossy()); + Some(device.map(|device| (device, store))) + }, + // Skip WireGuard interfaces that aren't managed by innernet. + Err(e) if e.kind() == io::ErrorKind::NotFound => None, + // Error on interfaces that *are* managed by innernet but are not readable. + Err(e) => Some(Err(e)), + } }) - .collect::>(); + .collect::, _>>()?; if devices.is_empty() { log::info!("No innernet networks currently running."); @@ -846,7 +850,7 @@ fn show( let me = peers .iter() .find(|p| p.public_key == device_info.public_key.as_ref().unwrap().to_base64()) - .ok_or("missing peer info")?; + .ok_or(anyhow!("missing peer info"))?; let mut peer_states = device_info .peers @@ -858,7 +862,7 @@ fn show( peer, info: Some(info), }), - None => Err(format!("peer {} isn't an innernet peer.", public_key)), + None => Err(anyhow!("peer {} isn't an innernet peer.", public_key)), } }) .collect::, _>>()?; @@ -987,6 +991,9 @@ fn main() { if let Err(e) = run(opt) { println!(); log::error!("{}\n", e); + if let Some(e) = e.downcast_ref::() { + util::permissions_helptext(e); + } std::process::exit(1); } } @@ -998,10 +1005,6 @@ fn run(opt: Opts) -> Result<(), Error> { interface: None, }); - if unsafe { libc::getuid() } != 0 && !matches!(command, Command::Completions { .. }) { - return Err("innernet must run as root.".into()); - } - match command { Command::Install { invite, diff --git a/client/src/util.rs b/client/src/util.rs index 55a421b..beb8b0a 100644 --- a/client/src/util.rs +++ b/client/src/util.rs @@ -1,19 +1,27 @@ use crate::{ClientError, Error}; use colored::*; +use indoc::eprintdoc; use log::{Level, LevelFilter}; use serde::{de::DeserializeOwned, Serialize}; -use shared::{interface_config::ServerInfo, INNERNET_PUBKEY_HEADER}; -use std::time::Duration; +use shared::{interface_config::ServerInfo, WrappedIoError, INNERNET_PUBKEY_HEADER}; +use std::{io, time::Duration}; use ureq::{Agent, AgentBuilder}; static LOGGER: Logger = Logger; struct Logger; + +const BASE_MODULES: &[&str] = &["innernet", "shared"]; + +fn target_is_base(target: &str) -> bool { + BASE_MODULES + .iter() + .any(|module| module == &target || target.starts_with(&format!("{}::", module))) +} + impl log::Log for Logger { fn enabled(&self, metadata: &log::Metadata) -> bool { metadata.level() <= log::max_level() - && (log::max_level() == LevelFilter::Trace - || metadata.target().starts_with("shared::") - || metadata.target() == "innernet") + && (log::max_level() == LevelFilter::Trace || target_is_base(metadata.target())) } fn log(&self, record: &log::Record) { @@ -25,7 +33,7 @@ impl log::Log for Logger { Level::Debug => "[D]".blue(), Level::Trace => "[T]".purple(), }; - if record.level() <= LevelFilter::Debug && record.target() != "innernet" { + if record.level() <= LevelFilter::Debug && !target_is_base(record.target()) { println!( "{} {} {}", level_str, @@ -94,6 +102,41 @@ pub fn human_size(bytes: u64) -> String { } } +pub fn permissions_helptext(e: &WrappedIoError) { + if e.raw_os_error() == Some(1) { + let current_exe = std::env::current_exe() + .ok() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "".into()); + eprintdoc!( + "{}: innernet can't access the device info. + + You either need to run innernet as root, or give innernet CAP_NET_ADMIN capabilities: + + sudo setcap cap_net_admin+eip {} + ", + "ERROR".bold().red(), + current_exe + ); + } else if e.kind() == io::ErrorKind::PermissionDenied { + eprintdoc!( + "{}: innernet can't access its config/data folders. + + You either need to run innernet as root, or give the user/group running innernet permissions + to access {config} and {data}. + + For non-root permissions, it's recommended to create an \"innernet\" group, and run for example: + + sudo chgrp -R innernet {config} {data} + sudo chmod -R g+rwX {config} {data} + ", + "ERROR".bold().red(), + config = shared::CLIENT_CONFIG_DIR.to_string_lossy(), + data = shared::CLIENT_DATA_DIR.to_string_lossy(), + ); + } +} + pub struct Api<'a> { agent: Agent, server: &'a ServerInfo, diff --git a/hostsfile/Cargo.toml b/hostsfile/Cargo.toml index dd9e971..a059644 100644 --- a/hostsfile/Cargo.toml +++ b/hostsfile/Cargo.toml @@ -5,7 +5,6 @@ edition = "2018" license = "UNLICENSED" name = "hostsfile" publish = false -version = "1.0.1" +version = "1.1.0" [dependencies] -tempfile = "3" diff --git a/hostsfile/src/lib.rs b/hostsfile/src/lib.rs index da1bc94..978098c 100644 --- a/hostsfile/src/lib.rs +++ b/hostsfile/src/lib.rs @@ -1,12 +1,4 @@ -use std::{ - collections::HashMap, - fmt, - fs::{self, File, OpenOptions}, - io::{BufRead, BufReader, Write}, - net::IpAddr, - path::{Path, PathBuf}, - result, -}; +use std::{collections::HashMap, fmt, fs::OpenOptions, io::{self, BufRead, BufReader, ErrorKind, Write}, net::IpAddr, path::{Path, PathBuf}, result}; pub type Result = result::Result>; @@ -115,7 +107,12 @@ impl HostsBuilder { /// Inserts a new section to the system's default hosts file. If there is a section with the /// same tag name already, it will be replaced with the new list instead. - pub fn write(&self) -> Result<()> { + pub fn write(&self) -> io::Result<()> { + self.write_to(&Self::default_path()?) + } + + /// Returns the default hosts path based on the current OS. + pub fn default_path() -> io::Result { let hosts_file = if cfg!(unix) { PathBuf::from("/etc/hosts") } else if cfg!(windows) { @@ -124,21 +121,24 @@ impl HostsBuilder { // the location depends on the environment variable %WinDir%. format!( "{}\\System32\\Drivers\\Etc\\hosts", - std::env::var("WinDir")? + std::env::var("WinDir").map_err(|_| io::Error::new( + ErrorKind::Other, + "WinDir environment variable missing".to_owned() + ))? ), ) } else { - return Err(Box::new(Error("unsupported operating system.".to_owned()))); + return Err(io::Error::new( + ErrorKind::Other, + "unsupported operating system.".to_owned(), + )); }; if !hosts_file.exists() { - return Err(Box::new(Error(format!( - "hosts file {:?} missing", - &hosts_file - )))); + return Err(ErrorKind::NotFound.into()); } - self.write_to(&hosts_file) + Ok(hosts_file) } /// Inserts a new section to the specified hosts file. If there is a section with the same tag @@ -146,7 +146,7 @@ impl HostsBuilder { /// /// On Windows, the format of one hostname per line will be used, all other systems will use /// the same format as Unix and Unix-like systems (i.e. allow multiple hostnames per line). - pub fn write_to>(&self, hosts_path: P) -> Result<()> { + pub fn write_to>(&self, hosts_path: P) -> io::Result<()> { let hosts_path = hosts_path.as_ref(); let begin_marker = format!("# DO NOT EDIT {} BEGIN", &self.tag); let end_marker = format!("# DO NOT EDIT {} END", &self.tag); @@ -179,49 +179,44 @@ impl HostsBuilder { lines.len() }, _ => { - return Err(Box::new(Error(format!( - "start or end marker missing in {:?}", - &hosts_path - )))); + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("start or end marker missing in {:?}", &hosts_path), + )); }, }; - // The tempfile should be in the same filesystem as the hosts file. - let hosts_dir = hosts_path - .parent() - .expect("hosts file must be an absolute file path"); - let temp_dir = tempfile::Builder::new().tempdir_in(hosts_dir)?; - let temp_path = temp_dir.path().join("hosts"); - - // Copy the existing hosts file to preserve permissions. - fs::copy(&hosts_path, &temp_path)?; - - let mut file = File::create(&temp_path)?; + let mut s = vec![]; for line in &lines[..insert] { - writeln!(&mut file, "{}", line)?; + writeln!(&mut s, "{}", line)?; } if !self.hostname_map.is_empty() { - writeln!(&mut file, "{}", begin_marker)?; + writeln!(&mut s, "{}", begin_marker)?; for (ip, hostnames) in &self.hostname_map { if cfg!(windows) { // windows only allows one hostname per line for hostname in hostnames { - writeln!(&mut file, "{} {}", ip, hostname)?; + writeln!(&mut s, "{} {}", ip, hostname)?; } } else { // assume the same format as Unix - writeln!(&mut file, "{} {}", ip, hostnames.join(" "))?; + writeln!(&mut s, "{} {}", ip, hostnames.join(" "))?; } } - writeln!(&mut file, "{}", end_marker)?; + writeln!(&mut s, "{}", end_marker)?; } for line in &lines[insert..] { - writeln!(&mut file, "{}", line)?; + writeln!(&mut s, "{}", line)?; } - // Move the file atomically to avoid a partial state. - fs::rename(&temp_path, &hosts_path)?; + OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(true) + .open(hosts_path)? + .write_all(&s)?; Ok(()) } diff --git a/server/Cargo.toml b/server/Cargo.toml index 0d492ad..f66bdfb 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -13,6 +13,7 @@ name = "innernet-server" path = "src/main.rs" [dependencies] +anyhow = "1" bytes = "1" colored = "2" dialoguer = "0.8" diff --git a/server/src/api/admin/peer.rs b/server/src/api/admin/peer.rs index 0eaefd1..b651bb6 100644 --- a/server/src/api/admin/peer.rs +++ b/server/src/api/admin/peer.rs @@ -229,7 +229,7 @@ mod tests { let old_peer = DatabasePeer::get(&server.db.lock(), test::DEVELOPER1_PEER_ID)?; let change = PeerContents { - name: "new-peer-name".parse()?, + name: "new-peer-name".parse().unwrap(), ..old_peer.contents.clone() }; diff --git a/server/src/initialize.rs b/server/src/initialize.rs index 4894052..23cafdc 100644 --- a/server/src/initialize.rs +++ b/server/src/initialize.rs @@ -1,4 +1,5 @@ use crate::*; +use anyhow::anyhow; use db::DatabaseCidr; use dialoguer::{theme::ColorfulTheme, Input}; use indoc::printdoc; @@ -63,7 +64,7 @@ fn populate_database(conn: &Connection, db_init_data: DbInitData) -> Result<(), parent: None, }, ) - .map_err(|_| "failed to create root CIDR".to_string())?; + .map_err(|_| anyhow!("failed to create root CIDR"))?; let server_cidr = DatabaseCidr::create( &conn, @@ -73,12 +74,12 @@ fn populate_database(conn: &Connection, db_init_data: DbInitData) -> Result<(), parent: Some(root_cidr.id), }, ) - .map_err(|_| "failed to create innernet-server CIDR".to_string())?; + .map_err(|_| anyhow!("failed to create innernet-server CIDR"))?; let _me = DatabasePeer::create( &conn, PeerContents { - name: SERVER_NAME.parse()?, + name: SERVER_NAME.parse().map_err(|e: &str| anyhow!(e))?, ip: db_init_data.our_ip, cidr_id: server_cidr.id, public_key: db_init_data.public_key_base64, @@ -90,7 +91,7 @@ fn populate_database(conn: &Connection, db_init_data: DbInitData) -> Result<(), invite_expires: None, }, ) - .map_err(|_| "failed to create innernet peer.".to_string())?; + .map_err(|_| anyhow!("failed to create innernet peer."))?; Ok(()) } @@ -99,7 +100,7 @@ pub fn init_wizard(conf: &ServerConfig, opts: InitializeOpts) -> Result<(), Erro let theme = ColorfulTheme::default(); shared::ensure_dirs_exist(&[conf.config_dir(), conf.database_dir()]).map_err(|_| { - format!( + anyhow!( "Failed to create config and database directories {}", "(are you not running as root?)".bold() ) @@ -139,7 +140,7 @@ pub fn init_wizard(conf: &ServerConfig, opts: InitializeOpts) -> Result<(), Erro let endpoint: Endpoint = if let Some(endpoint) = opts.external_endpoint { endpoint } else if opts.auto_external_endpoint { - let ip = publicip::get_any(Preference::Ipv4).ok_or("couldn't get external IP")?; + let ip = publicip::get_any(Preference::Ipv4).ok_or(anyhow!("couldn't get external IP"))?; SocketAddr::new(ip, 51820).into() } else { prompts::ask_endpoint()? @@ -152,7 +153,7 @@ pub fn init_wizard(conf: &ServerConfig, opts: InitializeOpts) -> Result<(), Erro .with_prompt("Listen port") .default(51820) .interact() - .map_err(|_| "failed to get listen port.")? + .map_err(|_| anyhow!("failed to get listen port."))? }; let our_ip = root_cidr @@ -185,7 +186,7 @@ pub fn init_wizard(conf: &ServerConfig, opts: InitializeOpts) -> Result<(), Erro let database_path = conf.database_path(&name); let conn = create_database(&database_path).map_err(|_| { - format!( + anyhow!( "failed to create database {}", "(are you not running as root?)".bold() ) diff --git a/server/src/main.rs b/server/src/main.rs index 13fa7c6..14a86a8 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,3 +1,4 @@ +use anyhow::{anyhow, bail}; use colored::*; use dialoguer::Confirm; use hyper::{http, server::conn::AddrStream, Body, Request, Response}; @@ -261,14 +262,13 @@ async fn main() -> Result<(), Box> { fn open_database_connection( interface: &InterfaceName, conf: &ServerConfig, -) -> Result> { +) -> Result { let database_path = conf.database_path(&interface); if !Path::new(&database_path).exists() { - return Err(format!( + bail!( "no database file found at {}", database_path.to_string_lossy() - ) - .into()); + ); } let conn = Connection::open(&database_path)?; @@ -337,7 +337,7 @@ fn rename_peer( let mut db_peer = DatabasePeer::list(&conn)? .into_iter() .find(|p| p.name == old_name) - .ok_or( "Peer not found.")?; + .ok_or(anyhow!("Peer not found."))?; let _peer = db_peer.update(&conn, peer_request)?; } else { println!("exited without creating peer."); diff --git a/server/src/test.rs b/server/src/test.rs index 921a21f..190a3ba 100644 --- a/server/src/test.rs +++ b/server/src/test.rs @@ -221,7 +221,7 @@ pub fn peer_contents( let public_key = KeyPair::generate().public; Ok(PeerContents { - name: name.parse()?, + name: name.parse().map_err(|e: &str| anyhow!(e))?, ip: ip_str.parse()?, cidr_id, public_key: public_key.to_base64(), diff --git a/shared/Cargo.toml b/shared/Cargo.toml index b870b3c..5c3748f 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -7,11 +7,13 @@ publish = false version = "1.3.1" [dependencies] +anyhow = "1" colored = "2.0" dialoguer = "0.8" indoc = "1" ipnetwork = { git = "https://github.com/mcginty/ipnetwork" } # pending https://github.com/achanda/ipnetwork/pull/129 lazy_static = "1" +libc = "0.2" log = "0.4" publicip = { path = "../publicip" } regex = "1" @@ -20,3 +22,9 @@ structopt = "0.3" toml = "0.5" url = "2" wgctrl = { path = "../wgctrl-rs" } + +[target.'cfg(target_os = "linux")'.dependencies] +netlink-sys = "0.6" +netlink-packet-core = "0.2" +netlink-packet-route = "0.7" +wgctrl-sys = { path = "../wgctrl-sys" } diff --git a/shared/src/interface_config.rs b/shared/src/interface_config.rs index 74510ee..a5004ba 100644 --- a/shared/src/interface_config.rs +++ b/shared/src/interface_config.rs @@ -116,14 +116,7 @@ impl InterfaceConfig { pub fn from_interface(interface: &InterfaceName) -> Result { let path = Self::build_config_file_path(interface)?; - let file = File::open(&path).with_path(&path)?; - if crate::chmod(&file, 0o600)? { - println!( - "{} updated permissions for {} to 0600.", - "[!]".yellow(), - path.display() - ); - } + crate::warn_on_dangerous_mode(&path).with_path(&path)?; Self::from_file(path) } diff --git a/shared/src/lib.rs b/shared/src/lib.rs index b2c395e..920114a 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -1,4 +1,4 @@ -use colored::*; +pub use anyhow::Error; use lazy_static::lazy_static; use std::{ fs::{self, File}, @@ -9,6 +9,8 @@ use std::{ }; pub mod interface_config; +#[cfg(target_os = "linux")] +mod netlink; pub mod prompts; pub mod types; pub mod wg; @@ -26,8 +28,6 @@ lazy_static! { pub const PERSISTENT_KEEPALIVE_INTERVAL_SECS: u16 = 25; pub const INNERNET_PUBKEY_HEADER: &str = "X-Innernet-Server-Key"; -pub type Error = Box; - pub fn ensure_dirs_exist(dirs: &[&Path]) -> Result<(), WrappedIoError> { for dir in dirs { match fs::create_dir(dir).with_path(dir) { @@ -35,20 +35,29 @@ pub fn ensure_dirs_exist(dirs: &[&Path]) -> Result<(), WrappedIoError> { return Err(e); }, _ => { - let target_file = File::open(dir).with_path(dir)?; - if chmod(&target_file, 0o700).with_path(dir)? { - println!( - "{} updated permissions for {} to 0700.", - "[!]".yellow(), - dir.display() - ); - } + warn_on_dangerous_mode(dir).with_path(dir)?; }, } } Ok(()) } +pub fn warn_on_dangerous_mode(path: &Path) -> Result<(), io::Error> { + let file = File::open(path)?; + let metadata = file.metadata()?; + let permissions = metadata.permissions(); + let mode = permissions.mode() & 0o777; + + if mode & 0o007 != 0 { + log::warn!( + "{} is world-accessible (mode is {:#05o}). This is probably not what you want.", + path.to_string_lossy(), + mode + ); + } + Ok(()) +} + /// Updates the permissions of a file or directory. Returns `Ok(true)` if /// permissions had to be changed, `Ok(false)` if permissions were already /// correct. diff --git a/shared/src/netlink.rs b/shared/src/netlink.rs new file mode 100644 index 0000000..6cb8f0b --- /dev/null +++ b/shared/src/netlink.rs @@ -0,0 +1,128 @@ +use crate::Error; +use anyhow::anyhow; +use ipnetwork::IpNetwork; +use netlink_packet_core::{ + NetlinkMessage, NetlinkPayload, NLM_F_ACK, NLM_F_CREATE, NLM_F_EXCL, NLM_F_REQUEST, +}; +use netlink_packet_route::{ + address, constants::*, link, route, AddressHeader, AddressMessage, LinkHeader, LinkMessage, + RouteHeader, RouteMessage, RtnlMessage, RTN_UNICAST, RT_SCOPE_LINK, RT_TABLE_MAIN, +}; +use netlink_sys::{protocols::NETLINK_ROUTE, Socket, SocketAddr}; +use std::io; +use wgctrl::InterfaceName; + +fn if_nametoindex(interface: &InterfaceName) -> Result { + match unsafe { libc::if_nametoindex(interface.as_ptr()) } { + 0 => Err(anyhow!("couldn't find interface '{}'.", interface)), + index => Ok(index), + } +} + +fn netlink_call( + message: RtnlMessage, + flags: Option, +) -> Result, io::Error> { + let mut req = NetlinkMessage::from(message); + req.header.flags = flags.unwrap_or(NLM_F_REQUEST | NLM_F_ACK | NLM_F_EXCL | NLM_F_CREATE); + req.finalize(); + let mut buf = [0; 4096]; + req.serialize(&mut buf); + let len = req.buffer_len(); + + log::debug!("netlink request: {:?}", req); + let socket = Socket::new(NETLINK_ROUTE).unwrap(); + let kernel_addr = SocketAddr::new(0, 0); + socket.connect(&kernel_addr)?; + let n_sent = socket.send(&buf[..len], 0).unwrap(); + if n_sent != len { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "failed to send netlink request", + )); + } + + let n_received = socket.recv(&mut buf[..], 0).unwrap(); + let response = NetlinkMessage::::deserialize(&buf[..n_received]) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + log::trace!("netlink response: {:?}", response); + if let NetlinkPayload::Error(e) = response.payload { + return Err(e.to_io()); + } + Ok(response) +} + +pub fn set_up(interface: &InterfaceName, mtu: u32) -> Result<(), Error> { + let index = if_nametoindex(interface)?; + let message = LinkMessage { + header: LinkHeader { + index, + flags: IFF_UP, + ..Default::default() + }, + nlas: vec![link::nlas::Nla::Mtu(mtu)], + }; + netlink_call(RtnlMessage::SetLink(message), None)?; + Ok(()) +} + +pub fn set_addr(interface: &InterfaceName, addr: IpNetwork) -> Result<(), Error> { + let index = if_nametoindex(interface)?; + let (family, nlas) = match addr { + IpNetwork::V4(network) => { + let addr_bytes = network.ip().octets().to_vec(); + ( + AF_INET as u8, + vec![ + address::Nla::Local(addr_bytes.clone()), + address::Nla::Address(addr_bytes), + ], + ) + }, + IpNetwork::V6(network) => ( + AF_INET6 as u8, + vec![address::Nla::Address(network.ip().octets().to_vec())], + ), + }; + let message = AddressMessage { + header: AddressHeader { + index, + family, + prefix_len: addr.prefix(), + scope: RT_SCOPE_UNIVERSE, + ..Default::default() + }, + nlas, + }; + netlink_call( + RtnlMessage::NewAddress(message), + Some(NLM_F_REQUEST | NLM_F_ACK | NLM_F_REPLACE | NLM_F_CREATE), + )?; + Ok(()) +} + +pub fn add_route(interface: &InterfaceName, cidr: IpNetwork) -> Result { + let if_index = if_nametoindex(interface)?; + let (address_family, dst) = match cidr { + IpNetwork::V4(network) => (AF_INET as u8, network.network().octets().to_vec()), + IpNetwork::V6(network) => (AF_INET6 as u8, network.network().octets().to_vec()), + }; + let message = RouteMessage { + header: RouteHeader { + table: RT_TABLE_MAIN, + protocol: RTPROT_BOOT, + scope: RT_SCOPE_LINK, + kind: RTN_UNICAST, + destination_prefix_length: cidr.prefix(), + address_family, + ..Default::default() + }, + nlas: vec![route::Nla::Destination(dst), route::Nla::Oif(if_index)], + }; + + match netlink_call(RtnlMessage::NewRoute(message), None) { + Ok(_) => Ok(true), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Ok(false), + Err(e) => Err(e.into()), + } +} diff --git a/shared/src/prompts.rs b/shared/src/prompts.rs index 7120d45..ceb488a 100644 --- a/shared/src/prompts.rs +++ b/shared/src/prompts.rs @@ -3,6 +3,7 @@ use crate::{ AddCidrOpts, AddPeerOpts, Association, Cidr, CidrContents, CidrTree, DeleteCidrOpts, Endpoint, Error, Hostname, Peer, PeerContents, RenamePeerOpts, PERSISTENT_KEEPALIVE_INTERVAL_SECS, }; +use anyhow::anyhow; use colored::*; use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; use ipnetwork::IpNetwork; @@ -21,7 +22,7 @@ pub fn add_cidr(cidrs: &[Cidr], request: &AddCidrOpts) -> Result cidrs .iter() .find(|cidr| &cidr.name == name) - .ok_or_else(|| format!("CIDR {} doesn't exist or isn't eligible for deletion", name))? + .ok_or_else(|| anyhow!("CIDR {} doesn't exist or isn't eligible for deletion", name))? } else { let cidr_index = Select::with_theme(&*THEME) .with_prompt("Delete CIDR") @@ -92,7 +93,7 @@ pub fn delete_cidr(cidrs: &[Cidr], peers: &[Peer], request: &DeleteCidrOpts) -> { Ok(cidr.id) } else { - Err("Canceled".into()) + Err(anyhow!("Canceled")) } } @@ -193,7 +194,7 @@ pub fn add_peer( leaves .iter() .find(|cidr| &cidr.name == parent_name) - .ok_or("No eligible CIDR with that name exists.")? + .ok_or(anyhow!("No eligible CIDR with that name exists."))? } else { choose_cidr(&leaves[..], "Eligible CIDRs for peer")? }; @@ -241,7 +242,7 @@ pub fn add_peer( } else { Input::with_theme(&*THEME) .with_prompt("Invite expires after") - .default("14d".parse()?) + .default("14d".parse().map_err(|s: &str| anyhow!(s))?) .interact()? }; @@ -287,7 +288,7 @@ pub fn rename_peer( eligible_peers .into_iter() .find(|p| &p.name == name) - .ok_or_else(|| format!("Peer '{}' does not exist", name))? + .ok_or_else(|| anyhow!("Peer '{}' does not exist", name))? .clone() } else { let peer_index = Select::with_theme(&*THEME) diff --git a/shared/src/types.rs b/shared/src/types.rs index 4c19909..a1e80b6 100644 --- a/shared/src/types.rs +++ b/shared/src/types.rs @@ -4,6 +4,7 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use std::{ fmt::{self, Display, Formatter}, + io, net::{IpAddr, SocketAddr, ToSocketAddrs}, ops::Deref, path::Path, @@ -121,14 +122,14 @@ impl fmt::Display for Endpoint { } impl Endpoint { - pub fn resolve(&self) -> Result { - let mut addrs = self - .to_string() - .to_socket_addrs() - .map_err(|e| e.to_string())?; - addrs - .next() - .ok_or_else(|| "failed to resolve address".to_string()) + pub fn resolve(&self) -> Result { + let mut addrs = self.to_string().to_socket_addrs()?; + addrs.next().ok_or_else(|| { + io::Error::new( + io::ErrorKind::AddrNotAvailable, + "failed to resolve address".to_string(), + ) + }) } } diff --git a/shared/src/wg.rs b/shared/src/wg.rs index 34e4204..b888a8d 100644 --- a/shared/src/wg.rs +++ b/shared/src/wg.rs @@ -1,13 +1,11 @@ use crate::{Error, IoErrorContext, NetworkOpt}; use ipnetwork::IpNetwork; -use std::{ - net::{IpAddr, SocketAddr}, - process::{self, Command}, -}; +use std::net::{IpAddr, SocketAddr}; use wgctrl::{Backend, Device, DeviceUpdate, InterfaceName, PeerConfigBuilder}; -fn cmd(bin: &str, args: &[&str]) -> Result { - let output = Command::new(bin).args(args).output()?; +#[cfg(target_os = "macos")] +fn cmd(bin: &str, args: &[&str]) -> Result { + let output = std::process::Command::new(bin).args(args).output()?; log::debug!("cmd: {} {}", bin, args.join(" ")); log::debug!("status: {:?}", output.status.code()); log::trace!("stdout: {}", String::from_utf8_lossy(&output.stdout)); @@ -15,13 +13,12 @@ fn cmd(bin: &str, args: &[&str]) -> Result { if output.status.success() { Ok(output) } else { - Err(format!( + Err(anyhow::anyhow!( "failed to run {} {} command: {}", bin, args.join(" "), String::from_utf8_lossy(&output.stderr) - ) - .into()) + )) } } @@ -40,30 +37,30 @@ pub fn set_addr(interface: &InterfaceName, addr: IpNetwork) -> Result<(), Error> &addr.ip().to_string(), "alias", ], - )?; + ) + .map(|_output| ()) } else { cmd( "ifconfig", &[&real_interface, "inet6", &addr.to_string(), "alias"], - )?; + ) + .map(|_output| ()) } - cmd("ifconfig", &[&real_interface, "mtu", "1420"])?; +} + +#[cfg(target_os = "macos")] +pub fn set_up(interface: &InterfaceName, mtu: u32) -> Result<(), Error> { + let real_interface = + wgctrl::backends::userspace::resolve_tun(interface).with_str(interface.to_string())?; + cmd("ifconfig", &[&real_interface, "mtu", &mtu.to_string()])?; Ok(()) } #[cfg(target_os = "linux")] -pub fn set_addr(interface: &InterfaceName, addr: IpNetwork) -> Result<(), Error> { - let interface = interface.to_string(); - cmd( - "ip", - &["address", "replace", &addr.to_string(), "dev", &interface], - )?; - cmd( - "ip", - &["link", "set", "mtu", "1420", "up", "dev", &interface], - )?; - Ok(()) -} +pub use super::netlink::set_addr; + +#[cfg(target_os = "linux")] +pub use super::netlink::set_up; pub fn up( interface: &InterfaceName, @@ -88,6 +85,7 @@ pub fn up( .set_private_key(wgctrl::Key::from_base64(&private_key).unwrap()) .apply(interface, network.backend)?; set_addr(interface, address)?; + set_up(interface, 1420)?; if !network.no_routing { add_route(interface, address)?; } @@ -120,43 +118,31 @@ pub fn down(interface: &InterfaceName, backend: Backend) -> Result<(), Error> { /// Add a route in the OS's routing table to get traffic flowing through this interface. /// Returns an error if the process doesn't exit successfully, otherwise returns /// true if the route was changed, false if the route already exists. +#[cfg(target_os = "macos")] pub fn add_route(interface: &InterfaceName, cidr: IpNetwork) -> Result { - if cfg!(target_os = "macos") { - let real_interface = - wgctrl::backends::userspace::resolve_tun(interface).with_str(interface.to_string())?; - let output = cmd( - "route", - &[ - "-n", - "add", - if cidr.is_ipv4() { "-inet" } else { "-inet6" }, - &cidr.to_string(), - "-interface", - &real_interface, - ], - )?; - let stderr = String::from_utf8_lossy(&output.stderr); - if !output.status.success() { - Err(format!( - "failed to add route for device {} ({}): {}", - &interface, real_interface, stderr - ) - .into()) - } else { - Ok(!stderr.contains("File exists")) - } + let real_interface = + wgctrl::backends::userspace::resolve_tun(interface).with_str(interface.to_string())?; + let output = cmd( + "route", + &[ + "-n", + "add", + if cidr.is_ipv4() { "-inet" } else { "-inet6" }, + &cidr.to_string(), + "-interface", + &real_interface, + ], + )?; + let stderr = String::from_utf8_lossy(&output.stderr); + if !output.status.success() { + Err(anyhow::anyhow!( + "failed to add route for device {} ({}): {}", + &interface, real_interface, stderr + )) } else { - // TODO(mcginty): use the netlink interface on linux to modify routing table. - let _ = cmd( - "ip", - &[ - "route", - "add", - &IpNetwork::new(cidr.network(), cidr.prefix())?.to_string(), - "dev", - &interface.to_string(), - ], - ); - Ok(false) + Ok(!stderr.contains("File exists")) } } + +#[cfg(target_os = "linux")] +pub use super::netlink::add_route; diff --git a/wgctrl-rs/src/device.rs b/wgctrl-rs/src/device.rs index b470396..af96966 100644 --- a/wgctrl-rs/src/device.rs +++ b/wgctrl-rs/src/device.rs @@ -165,7 +165,7 @@ impl InterfaceName { #[cfg(target_os = "linux")] /// Returns a pointer to the inner byte buffer for FFI calls. - pub(crate) fn as_ptr(&self) -> *const c_char { + pub fn as_ptr(&self) -> *const c_char { self.0.as_ptr() }