client: support running as non-root (#94)

shared(wg): use netlink instead of execve calls to "ip"
hostsfile: write to hostsfile in-place
pull/121/head
Jake McGinty 2021-06-10 22:57:47 +09:00 committed by GitHub
parent 6a60643d7d
commit 449b4b8278
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 417 additions and 200 deletions

67
Cargo.lock generated
View File

@ -153,13 +153,13 @@ dependencies = [
name = "client" name = "client"
version = "1.3.1" version = "1.3.1"
dependencies = [ dependencies = [
"anyhow",
"colored", "colored",
"dialoguer", "dialoguer",
"hostsfile", "hostsfile",
"indoc", "indoc",
"ipnetwork", "ipnetwork",
"lazy_static", "lazy_static",
"libc",
"log", "log",
"regex", "regex",
"serde", "serde",
@ -382,10 +382,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]] [[package]]
name = "hostsfile" name = "hostsfile"
version = "1.0.1" version = "1.1.0"
dependencies = [
"tempfile",
]
[[package]] [[package]]
name = "http" name = "http"
@ -576,6 +573,54 @@ dependencies = [
"winapi", "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]] [[package]]
name = "nom" name = "nom"
version = "5.1.2" version = "5.1.2"
@ -636,6 +681,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "paste"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acbf547ad0c65e31259204bd90935776d1c693cec2f4ff7abb7a1bbbd40dfe58"
[[package]] [[package]]
name = "peeking_take_while" name = "peeking_take_while"
version = "0.1.2" version = "0.1.2"
@ -930,12 +981,17 @@ dependencies = [
name = "shared" name = "shared"
version = "1.3.1" version = "1.3.1"
dependencies = [ dependencies = [
"anyhow",
"colored", "colored",
"dialoguer", "dialoguer",
"indoc", "indoc",
"ipnetwork", "ipnetwork",
"lazy_static", "lazy_static",
"libc",
"log", "log",
"netlink-packet-core",
"netlink-packet-route",
"netlink-sys",
"publicip", "publicip",
"regex", "regex",
"serde", "serde",
@ -943,6 +999,7 @@ dependencies = [
"toml", "toml",
"url", "url",
"wgctrl", "wgctrl",
"wgctrl-sys",
] ]
[[package]] [[package]]

View File

@ -14,13 +14,13 @@ name = "innernet"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
anyhow = "1"
colored = "2" colored = "2"
dialoguer = "0.8" dialoguer = "0.8"
hostsfile = { path = "../hostsfile" } hostsfile = { path = "../hostsfile" }
indoc = "1" indoc = "1"
ipnetwork = { git = "https://github.com/mcginty/ipnetwork" } # pending https://github.com/achanda/ipnetwork/pull/129 ipnetwork = { git = "https://github.com/mcginty/ipnetwork" } # pending https://github.com/achanda/ipnetwork/pull/129
lazy_static = "1" lazy_static = "1"
libc = "0.2"
log = "0.4" log = "0.4"
regex = { version = "1", default-features = false, features = ["std"] } regex = { version = "1", default-features = false, features = ["std"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }

View File

@ -1,5 +1,5 @@
use crate::Error; use crate::Error;
use colored::*; use anyhow::bail;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use shared::{ensure_dirs_exist, Cidr, IoErrorContext, Peer, WrappedIoError, CLIENT_DATA_DIR}; use shared::{ensure_dirs_exist, Cidr, IoErrorContext, Peer, WrappedIoError, CLIENT_DATA_DIR};
use std::{ use std::{
@ -35,13 +35,7 @@ impl DataStore {
.open(path) .open(path)
.with_path(path)?; .with_path(path)?;
if shared::chmod(&file, 0o600).with_path(path)? { shared::warn_on_dangerous_mode(path).with_path(path)?;
println!(
"{} updated permissions for {} to 0600.",
"[!]".yellow(),
path.display()
);
}
let mut json = String::new(); let mut json = String::new();
file.read_to_string(&mut json).with_path(path)?; file.read_to_string(&mut json).with_path(path)?;
@ -94,9 +88,7 @@ impl DataStore {
for new_peer in current_peers.iter() { for new_peer in current_peers.iter() {
if let Some(existing_peer) = peers.iter_mut().find(|p| p.ip == new_peer.ip) { if let Some(existing_peer) = peers.iter_mut().find(|p| p.ip == new_peer.ip) {
if existing_peer.public_key != new_peer.public_key { if existing_peer.public_key != new_peer.public_key {
return Err( bail!("PINNING ERROR: New peer has same IP but different public key.");
"PINNING ERROR: New peer has same IP but different public key.".into(),
);
} else { } else {
*existing_peer = new_peer.clone(); *existing_peer = new_peer.clone();
} }

View File

@ -1,3 +1,4 @@
use anyhow::{anyhow, bail};
use colored::*; use colored::*;
use dialoguer::{Confirm, Input}; use dialoguer::{Confirm, Input};
use hostsfile::HostsBuilder; use hostsfile::HostsBuilder;
@ -6,10 +7,10 @@ use shared::{
interface_config::InterfaceConfig, prompts, AddAssociationOpts, AddCidrOpts, AddPeerOpts, interface_config::InterfaceConfig, prompts, AddAssociationOpts, AddCidrOpts, AddPeerOpts,
Association, AssociationContents, Cidr, CidrTree, DeleteCidrOpts, EndpointContents, Association, AssociationContents, Cidr, CidrTree, DeleteCidrOpts, EndpointContents,
InstallOpts, Interface, IoErrorContext, NetworkOpt, Peer, RedeemContents, RenamePeerOpts, InstallOpts, Interface, IoErrorContext, NetworkOpt, Peer, RedeemContents, RenamePeerOpts,
State, CLIENT_CONFIG_DIR, REDEEM_TRANSITION_WAIT, State, WrappedIoError, CLIENT_CONFIG_DIR, REDEEM_TRANSITION_WAIT,
}; };
use std::{ use std::{
fmt, fmt, io,
path::{Path, PathBuf}, path::{Path, PathBuf},
thread, thread,
time::{Duration, SystemTime}, time::{Duration, SystemTime},
@ -245,7 +246,9 @@ fn update_hosts_file(
&format!("{}.{}.wg", peer.contents.name, interface), &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(()) Ok(())
} }
@ -272,7 +275,7 @@ fn install(
let target_conf = CLIENT_CONFIG_DIR.join(&iface).with_extension("conf"); let target_conf = CLIENT_CONFIG_DIR.join(&iface).with_extension("conf");
if target_conf.exists() { 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()?; let iface = iface.parse()?;
@ -423,11 +426,10 @@ fn fetch(
if !interface_up { if !interface_up {
if !bring_up_interface { if !bring_up_interface {
return Err(format!( bail!(
"Interface is not up. Use 'innernet up {}' instead", "Interface is not up. Use 'innernet up {}' instead",
interface interface
) );
.into());
} }
log::info!("bringing up the interface."); 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) .filter(|p| p.name == old_name)
.map(|p| p.id) .map(|p| p.id)
.next() .next()
.ok_or("Peer not found.")?; .ok_or(anyhow!("Peer not found."))?;
let _ = api.http_form("PUT", &format!("/admin/peers/{}", id), peer_request)?; let _ = api.http_form("PUT", &format!("/admin/peers/{}", id), peer_request)?;
log::info!("Peer renamed."); log::info!("Peer renamed.");
@ -687,11 +689,11 @@ fn add_association(interface: &InterfaceName, opts: AddAssociationOpts) -> Resul
let cidr1 = cidrs let cidr1 = cidrs
.iter() .iter()
.find(|c| &c.name == cidr1) .find(|c| &c.name == cidr1)
.ok_or(format!("can't find cidr '{}'", cidr1))?; .ok_or(anyhow!("can't find cidr '{}'", cidr1))?;
let cidr2 = cidrs let cidr2 = cidrs
.iter() .iter()
.find(|c| &c.name == cidr2) .find(|c| &c.name == cidr2)
.ok_or(format!("can't find cidr '{}'", cidr2))?; .ok_or(anyhow!("can't find cidr '{}'", cidr2))?;
(cidr1, cidr2) (cidr1, cidr2)
} else if let Some((cidr1, cidr2)) = prompts::add_association(&cidrs[..])? { } else if let Some((cidr1, cidr2)) = prompts::add_association(&cidrs[..])? {
(cidr1, cidr2) (cidr1, cidr2)
@ -824,16 +826,18 @@ fn show(
let devices = interfaces let devices = interfaces
.into_iter() .into_iter()
.filter_map(|name| { .filter_map(|name| {
DataStore::open(&name) match DataStore::open(&name) {
.and_then(|store| { Ok(store) => {
Ok(( let device = Device::get(&name, network.backend).with_str(name.as_str_lossy());
Device::get(&name, network.backend).with_str(name.as_str_lossy())?, Some(device.map(|device| (device, store)))
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)),
}
}) })
.ok() .collect::<Result<Vec<_>, _>>()?;
})
.collect::<Vec<_>>();
if devices.is_empty() { if devices.is_empty() {
log::info!("No innernet networks currently running."); log::info!("No innernet networks currently running.");
@ -846,7 +850,7 @@ fn show(
let me = peers let me = peers
.iter() .iter()
.find(|p| p.public_key == device_info.public_key.as_ref().unwrap().to_base64()) .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 let mut peer_states = device_info
.peers .peers
@ -858,7 +862,7 @@ fn show(
peer, peer,
info: Some(info), 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::<Result<Vec<PeerState>, _>>()?; .collect::<Result<Vec<PeerState>, _>>()?;
@ -987,6 +991,9 @@ fn main() {
if let Err(e) = run(opt) { if let Err(e) = run(opt) {
println!(); println!();
log::error!("{}\n", e); log::error!("{}\n", e);
if let Some(e) = e.downcast_ref::<WrappedIoError>() {
util::permissions_helptext(e);
}
std::process::exit(1); std::process::exit(1);
} }
} }
@ -998,10 +1005,6 @@ fn run(opt: Opts) -> Result<(), Error> {
interface: None, interface: None,
}); });
if unsafe { libc::getuid() } != 0 && !matches!(command, Command::Completions { .. }) {
return Err("innernet must run as root.".into());
}
match command { match command {
Command::Install { Command::Install {
invite, invite,

View File

@ -1,19 +1,27 @@
use crate::{ClientError, Error}; use crate::{ClientError, Error};
use colored::*; use colored::*;
use indoc::eprintdoc;
use log::{Level, LevelFilter}; use log::{Level, LevelFilter};
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
use shared::{interface_config::ServerInfo, INNERNET_PUBKEY_HEADER}; use shared::{interface_config::ServerInfo, WrappedIoError, INNERNET_PUBKEY_HEADER};
use std::time::Duration; use std::{io, time::Duration};
use ureq::{Agent, AgentBuilder}; use ureq::{Agent, AgentBuilder};
static LOGGER: Logger = Logger; static LOGGER: Logger = Logger;
struct 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 { impl log::Log for Logger {
fn enabled(&self, metadata: &log::Metadata) -> bool { fn enabled(&self, metadata: &log::Metadata) -> bool {
metadata.level() <= log::max_level() metadata.level() <= log::max_level()
&& (log::max_level() == LevelFilter::Trace && (log::max_level() == LevelFilter::Trace || target_is_base(metadata.target()))
|| metadata.target().starts_with("shared::")
|| metadata.target() == "innernet")
} }
fn log(&self, record: &log::Record) { fn log(&self, record: &log::Record) {
@ -25,7 +33,7 @@ impl log::Log for Logger {
Level::Debug => "[D]".blue(), Level::Debug => "[D]".blue(),
Level::Trace => "[T]".purple(), Level::Trace => "[T]".purple(),
}; };
if record.level() <= LevelFilter::Debug && record.target() != "innernet" { if record.level() <= LevelFilter::Debug && !target_is_base(record.target()) {
println!( println!(
"{} {} {}", "{} {} {}",
level_str, 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(|| "<innernet path>".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> { pub struct Api<'a> {
agent: Agent, agent: Agent,
server: &'a ServerInfo, server: &'a ServerInfo,

View File

@ -5,7 +5,6 @@ edition = "2018"
license = "UNLICENSED" license = "UNLICENSED"
name = "hostsfile" name = "hostsfile"
publish = false publish = false
version = "1.0.1" version = "1.1.0"
[dependencies] [dependencies]
tempfile = "3"

View File

@ -1,12 +1,4 @@
use std::{ use std::{collections::HashMap, fmt, fs::OpenOptions, io::{self, BufRead, BufReader, ErrorKind, Write}, net::IpAddr, path::{Path, PathBuf}, result};
collections::HashMap,
fmt,
fs::{self, File, OpenOptions},
io::{BufRead, BufReader, Write},
net::IpAddr,
path::{Path, PathBuf},
result,
};
pub type Result<T> = result::Result<T, Box<dyn std::error::Error>>; pub type Result<T> = result::Result<T, Box<dyn std::error::Error>>;
@ -115,7 +107,12 @@ impl HostsBuilder {
/// Inserts a new section to the system's default hosts file. If there is a section with the /// 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. /// 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<PathBuf> {
let hosts_file = if cfg!(unix) { let hosts_file = if cfg!(unix) {
PathBuf::from("/etc/hosts") PathBuf::from("/etc/hosts")
} else if cfg!(windows) { } else if cfg!(windows) {
@ -124,21 +121,24 @@ impl HostsBuilder {
// the location depends on the environment variable %WinDir%. // the location depends on the environment variable %WinDir%.
format!( format!(
"{}\\System32\\Drivers\\Etc\\hosts", "{}\\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 { } 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() { if !hosts_file.exists() {
return Err(Box::new(Error(format!( return Err(ErrorKind::NotFound.into());
"hosts file {:?} missing",
&hosts_file
))));
} }
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 /// 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 /// 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). /// the same format as Unix and Unix-like systems (i.e. allow multiple hostnames per line).
pub fn write_to<P: AsRef<Path>>(&self, hosts_path: P) -> Result<()> { pub fn write_to<P: AsRef<Path>>(&self, hosts_path: P) -> io::Result<()> {
let hosts_path = hosts_path.as_ref(); let hosts_path = hosts_path.as_ref();
let begin_marker = format!("# DO NOT EDIT {} BEGIN", &self.tag); let begin_marker = format!("# DO NOT EDIT {} BEGIN", &self.tag);
let end_marker = format!("# DO NOT EDIT {} END", &self.tag); let end_marker = format!("# DO NOT EDIT {} END", &self.tag);
@ -179,49 +179,44 @@ impl HostsBuilder {
lines.len() lines.len()
}, },
_ => { _ => {
return Err(Box::new(Error(format!( return Err(io::Error::new(
"start or end marker missing in {:?}", io::ErrorKind::InvalidData,
&hosts_path format!("start or end marker missing in {:?}", &hosts_path),
)))); ));
}, },
}; };
// The tempfile should be in the same filesystem as the hosts file. let mut s = vec![];
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)?;
for line in &lines[..insert] { for line in &lines[..insert] {
writeln!(&mut file, "{}", line)?; writeln!(&mut s, "{}", line)?;
} }
if !self.hostname_map.is_empty() { if !self.hostname_map.is_empty() {
writeln!(&mut file, "{}", begin_marker)?; writeln!(&mut s, "{}", begin_marker)?;
for (ip, hostnames) in &self.hostname_map { for (ip, hostnames) in &self.hostname_map {
if cfg!(windows) { if cfg!(windows) {
// windows only allows one hostname per line // windows only allows one hostname per line
for hostname in hostnames { for hostname in hostnames {
writeln!(&mut file, "{} {}", ip, hostname)?; writeln!(&mut s, "{} {}", ip, hostname)?;
} }
} else { } else {
// assume the same format as Unix // 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..] { for line in &lines[insert..] {
writeln!(&mut file, "{}", line)?; writeln!(&mut s, "{}", line)?;
} }
// Move the file atomically to avoid a partial state. OpenOptions::new()
fs::rename(&temp_path, &hosts_path)?; .create(true)
.read(true)
.write(true)
.truncate(true)
.open(hosts_path)?
.write_all(&s)?;
Ok(()) Ok(())
} }

View File

@ -13,6 +13,7 @@ name = "innernet-server"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
anyhow = "1"
bytes = "1" bytes = "1"
colored = "2" colored = "2"
dialoguer = "0.8" dialoguer = "0.8"

View File

@ -229,7 +229,7 @@ mod tests {
let old_peer = DatabasePeer::get(&server.db.lock(), test::DEVELOPER1_PEER_ID)?; let old_peer = DatabasePeer::get(&server.db.lock(), test::DEVELOPER1_PEER_ID)?;
let change = PeerContents { let change = PeerContents {
name: "new-peer-name".parse()?, name: "new-peer-name".parse().unwrap(),
..old_peer.contents.clone() ..old_peer.contents.clone()
}; };

View File

@ -1,4 +1,5 @@
use crate::*; use crate::*;
use anyhow::anyhow;
use db::DatabaseCidr; use db::DatabaseCidr;
use dialoguer::{theme::ColorfulTheme, Input}; use dialoguer::{theme::ColorfulTheme, Input};
use indoc::printdoc; use indoc::printdoc;
@ -63,7 +64,7 @@ fn populate_database(conn: &Connection, db_init_data: DbInitData) -> Result<(),
parent: None, parent: None,
}, },
) )
.map_err(|_| "failed to create root CIDR".to_string())?; .map_err(|_| anyhow!("failed to create root CIDR"))?;
let server_cidr = DatabaseCidr::create( let server_cidr = DatabaseCidr::create(
&conn, &conn,
@ -73,12 +74,12 @@ fn populate_database(conn: &Connection, db_init_data: DbInitData) -> Result<(),
parent: Some(root_cidr.id), 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( let _me = DatabasePeer::create(
&conn, &conn,
PeerContents { PeerContents {
name: SERVER_NAME.parse()?, name: SERVER_NAME.parse().map_err(|e: &str| anyhow!(e))?,
ip: db_init_data.our_ip, ip: db_init_data.our_ip,
cidr_id: server_cidr.id, cidr_id: server_cidr.id,
public_key: db_init_data.public_key_base64, 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, invite_expires: None,
}, },
) )
.map_err(|_| "failed to create innernet peer.".to_string())?; .map_err(|_| anyhow!("failed to create innernet peer."))?;
Ok(()) Ok(())
} }
@ -99,7 +100,7 @@ pub fn init_wizard(conf: &ServerConfig, opts: InitializeOpts) -> Result<(), Erro
let theme = ColorfulTheme::default(); let theme = ColorfulTheme::default();
shared::ensure_dirs_exist(&[conf.config_dir(), conf.database_dir()]).map_err(|_| { shared::ensure_dirs_exist(&[conf.config_dir(), conf.database_dir()]).map_err(|_| {
format!( anyhow!(
"Failed to create config and database directories {}", "Failed to create config and database directories {}",
"(are you not running as root?)".bold() "(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 { let endpoint: Endpoint = if let Some(endpoint) = opts.external_endpoint {
endpoint endpoint
} else if opts.auto_external_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() SocketAddr::new(ip, 51820).into()
} else { } else {
prompts::ask_endpoint()? prompts::ask_endpoint()?
@ -152,7 +153,7 @@ pub fn init_wizard(conf: &ServerConfig, opts: InitializeOpts) -> Result<(), Erro
.with_prompt("Listen port") .with_prompt("Listen port")
.default(51820) .default(51820)
.interact() .interact()
.map_err(|_| "failed to get listen port.")? .map_err(|_| anyhow!("failed to get listen port."))?
}; };
let our_ip = root_cidr 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 database_path = conf.database_path(&name);
let conn = create_database(&database_path).map_err(|_| { let conn = create_database(&database_path).map_err(|_| {
format!( anyhow!(
"failed to create database {}", "failed to create database {}",
"(are you not running as root?)".bold() "(are you not running as root?)".bold()
) )

View File

@ -1,3 +1,4 @@
use anyhow::{anyhow, bail};
use colored::*; use colored::*;
use dialoguer::Confirm; use dialoguer::Confirm;
use hyper::{http, server::conn::AddrStream, Body, Request, Response}; use hyper::{http, server::conn::AddrStream, Body, Request, Response};
@ -261,14 +262,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
fn open_database_connection( fn open_database_connection(
interface: &InterfaceName, interface: &InterfaceName,
conf: &ServerConfig, conf: &ServerConfig,
) -> Result<rusqlite::Connection, Box<dyn std::error::Error>> { ) -> Result<rusqlite::Connection, Error> {
let database_path = conf.database_path(&interface); let database_path = conf.database_path(&interface);
if !Path::new(&database_path).exists() { if !Path::new(&database_path).exists() {
return Err(format!( bail!(
"no database file found at {}", "no database file found at {}",
database_path.to_string_lossy() database_path.to_string_lossy()
) );
.into());
} }
let conn = Connection::open(&database_path)?; let conn = Connection::open(&database_path)?;
@ -337,7 +337,7 @@ fn rename_peer(
let mut db_peer = DatabasePeer::list(&conn)? let mut db_peer = DatabasePeer::list(&conn)?
.into_iter() .into_iter()
.find(|p| p.name == old_name) .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)?; let _peer = db_peer.update(&conn, peer_request)?;
} else { } else {
println!("exited without creating peer."); println!("exited without creating peer.");

View File

@ -221,7 +221,7 @@ pub fn peer_contents(
let public_key = KeyPair::generate().public; let public_key = KeyPair::generate().public;
Ok(PeerContents { Ok(PeerContents {
name: name.parse()?, name: name.parse().map_err(|e: &str| anyhow!(e))?,
ip: ip_str.parse()?, ip: ip_str.parse()?,
cidr_id, cidr_id,
public_key: public_key.to_base64(), public_key: public_key.to_base64(),

View File

@ -7,11 +7,13 @@ publish = false
version = "1.3.1" version = "1.3.1"
[dependencies] [dependencies]
anyhow = "1"
colored = "2.0" colored = "2.0"
dialoguer = "0.8" dialoguer = "0.8"
indoc = "1" indoc = "1"
ipnetwork = { git = "https://github.com/mcginty/ipnetwork" } # pending https://github.com/achanda/ipnetwork/pull/129 ipnetwork = { git = "https://github.com/mcginty/ipnetwork" } # pending https://github.com/achanda/ipnetwork/pull/129
lazy_static = "1" lazy_static = "1"
libc = "0.2"
log = "0.4" log = "0.4"
publicip = { path = "../publicip" } publicip = { path = "../publicip" }
regex = "1" regex = "1"
@ -20,3 +22,9 @@ structopt = "0.3"
toml = "0.5" toml = "0.5"
url = "2" url = "2"
wgctrl = { path = "../wgctrl-rs" } 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" }

View File

@ -116,14 +116,7 @@ impl InterfaceConfig {
pub fn from_interface(interface: &InterfaceName) -> Result<Self, Error> { pub fn from_interface(interface: &InterfaceName) -> Result<Self, Error> {
let path = Self::build_config_file_path(interface)?; let path = Self::build_config_file_path(interface)?;
let file = File::open(&path).with_path(&path)?; crate::warn_on_dangerous_mode(&path).with_path(&path)?;
if crate::chmod(&file, 0o600)? {
println!(
"{} updated permissions for {} to 0600.",
"[!]".yellow(),
path.display()
);
}
Self::from_file(path) Self::from_file(path)
} }

View File

@ -1,4 +1,4 @@
use colored::*; pub use anyhow::Error;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use std::{ use std::{
fs::{self, File}, fs::{self, File},
@ -9,6 +9,8 @@ use std::{
}; };
pub mod interface_config; pub mod interface_config;
#[cfg(target_os = "linux")]
mod netlink;
pub mod prompts; pub mod prompts;
pub mod types; pub mod types;
pub mod wg; pub mod wg;
@ -26,8 +28,6 @@ lazy_static! {
pub const PERSISTENT_KEEPALIVE_INTERVAL_SECS: u16 = 25; pub const PERSISTENT_KEEPALIVE_INTERVAL_SECS: u16 = 25;
pub const INNERNET_PUBKEY_HEADER: &str = "X-Innernet-Server-Key"; pub const INNERNET_PUBKEY_HEADER: &str = "X-Innernet-Server-Key";
pub type Error = Box<dyn std::error::Error>;
pub fn ensure_dirs_exist(dirs: &[&Path]) -> Result<(), WrappedIoError> { pub fn ensure_dirs_exist(dirs: &[&Path]) -> Result<(), WrappedIoError> {
for dir in dirs { for dir in dirs {
match fs::create_dir(dir).with_path(dir) { match fs::create_dir(dir).with_path(dir) {
@ -35,20 +35,29 @@ pub fn ensure_dirs_exist(dirs: &[&Path]) -> Result<(), WrappedIoError> {
return Err(e); return Err(e);
}, },
_ => { _ => {
let target_file = File::open(dir).with_path(dir)?; warn_on_dangerous_mode(dir).with_path(dir)?;
if chmod(&target_file, 0o700).with_path(dir)? {
println!(
"{} updated permissions for {} to 0700.",
"[!]".yellow(),
dir.display()
);
}
}, },
} }
} }
Ok(()) 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 /// Updates the permissions of a file or directory. Returns `Ok(true)` if
/// permissions had to be changed, `Ok(false)` if permissions were already /// permissions had to be changed, `Ok(false)` if permissions were already
/// correct. /// correct.

128
shared/src/netlink.rs Normal file
View File

@ -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<u32, Error> {
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<u16>,
) -> Result<NetlinkMessage<RtnlMessage>, 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::<RtnlMessage>::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<bool, Error> {
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()),
}
}

View File

@ -3,6 +3,7 @@ use crate::{
AddCidrOpts, AddPeerOpts, Association, Cidr, CidrContents, CidrTree, DeleteCidrOpts, Endpoint, AddCidrOpts, AddPeerOpts, Association, Cidr, CidrContents, CidrTree, DeleteCidrOpts, Endpoint,
Error, Hostname, Peer, PeerContents, RenamePeerOpts, PERSISTENT_KEEPALIVE_INTERVAL_SECS, Error, Hostname, Peer, PeerContents, RenamePeerOpts, PERSISTENT_KEEPALIVE_INTERVAL_SECS,
}; };
use anyhow::anyhow;
use colored::*; use colored::*;
use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select};
use ipnetwork::IpNetwork; use ipnetwork::IpNetwork;
@ -21,7 +22,7 @@ pub fn add_cidr(cidrs: &[Cidr], request: &AddCidrOpts) -> Result<Option<CidrCont
cidrs cidrs
.iter() .iter()
.find(|cidr| &cidr.name == parent_name) .find(|cidr| &cidr.name == parent_name)
.ok_or("No parent CIDR with that name exists.")? .ok_or(anyhow!("No parent CIDR with that name exists."))?
} else { } else {
choose_cidr(cidrs, "Parent CIDR")? choose_cidr(cidrs, "Parent CIDR")?
}; };
@ -74,7 +75,7 @@ pub fn delete_cidr(cidrs: &[Cidr], peers: &[Peer], request: &DeleteCidrOpts) ->
cidrs cidrs
.iter() .iter()
.find(|cidr| &cidr.name == name) .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 { } else {
let cidr_index = Select::with_theme(&*THEME) let cidr_index = Select::with_theme(&*THEME)
.with_prompt("Delete CIDR") .with_prompt("Delete CIDR")
@ -92,7 +93,7 @@ pub fn delete_cidr(cidrs: &[Cidr], peers: &[Peer], request: &DeleteCidrOpts) ->
{ {
Ok(cidr.id) Ok(cidr.id)
} else { } else {
Err("Canceled".into()) Err(anyhow!("Canceled"))
} }
} }
@ -193,7 +194,7 @@ pub fn add_peer(
leaves leaves
.iter() .iter()
.find(|cidr| &cidr.name == parent_name) .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 { } else {
choose_cidr(&leaves[..], "Eligible CIDRs for peer")? choose_cidr(&leaves[..], "Eligible CIDRs for peer")?
}; };
@ -241,7 +242,7 @@ pub fn add_peer(
} else { } else {
Input::with_theme(&*THEME) Input::with_theme(&*THEME)
.with_prompt("Invite expires after") .with_prompt("Invite expires after")
.default("14d".parse()?) .default("14d".parse().map_err(|s: &str| anyhow!(s))?)
.interact()? .interact()?
}; };
@ -287,7 +288,7 @@ pub fn rename_peer(
eligible_peers eligible_peers
.into_iter() .into_iter()
.find(|p| &p.name == name) .find(|p| &p.name == name)
.ok_or_else(|| format!("Peer '{}' does not exist", name))? .ok_or_else(|| anyhow!("Peer '{}' does not exist", name))?
.clone() .clone()
} else { } else {
let peer_index = Select::with_theme(&*THEME) let peer_index = Select::with_theme(&*THEME)

View File

@ -4,6 +4,7 @@ use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{ use std::{
fmt::{self, Display, Formatter}, fmt::{self, Display, Formatter},
io,
net::{IpAddr, SocketAddr, ToSocketAddrs}, net::{IpAddr, SocketAddr, ToSocketAddrs},
ops::Deref, ops::Deref,
path::Path, path::Path,
@ -121,14 +122,14 @@ impl fmt::Display for Endpoint {
} }
impl Endpoint { impl Endpoint {
pub fn resolve(&self) -> Result<SocketAddr, String> { pub fn resolve(&self) -> Result<SocketAddr, io::Error> {
let mut addrs = self let mut addrs = self.to_string().to_socket_addrs()?;
.to_string() addrs.next().ok_or_else(|| {
.to_socket_addrs() io::Error::new(
.map_err(|e| e.to_string())?; io::ErrorKind::AddrNotAvailable,
addrs "failed to resolve address".to_string(),
.next() )
.ok_or_else(|| "failed to resolve address".to_string()) })
} }
} }

View File

@ -1,13 +1,11 @@
use crate::{Error, IoErrorContext, NetworkOpt}; use crate::{Error, IoErrorContext, NetworkOpt};
use ipnetwork::IpNetwork; use ipnetwork::IpNetwork;
use std::{ use std::net::{IpAddr, SocketAddr};
net::{IpAddr, SocketAddr},
process::{self, Command},
};
use wgctrl::{Backend, Device, DeviceUpdate, InterfaceName, PeerConfigBuilder}; use wgctrl::{Backend, Device, DeviceUpdate, InterfaceName, PeerConfigBuilder};
fn cmd(bin: &str, args: &[&str]) -> Result<process::Output, Error> { #[cfg(target_os = "macos")]
let output = Command::new(bin).args(args).output()?; fn cmd(bin: &str, args: &[&str]) -> Result<std::process::Output, Error> {
let output = std::process::Command::new(bin).args(args).output()?;
log::debug!("cmd: {} {}", bin, args.join(" ")); log::debug!("cmd: {} {}", bin, args.join(" "));
log::debug!("status: {:?}", output.status.code()); log::debug!("status: {:?}", output.status.code());
log::trace!("stdout: {}", String::from_utf8_lossy(&output.stdout)); log::trace!("stdout: {}", String::from_utf8_lossy(&output.stdout));
@ -15,13 +13,12 @@ fn cmd(bin: &str, args: &[&str]) -> Result<process::Output, Error> {
if output.status.success() { if output.status.success() {
Ok(output) Ok(output)
} else { } else {
Err(format!( Err(anyhow::anyhow!(
"failed to run {} {} command: {}", "failed to run {} {} command: {}",
bin, bin,
args.join(" "), args.join(" "),
String::from_utf8_lossy(&output.stderr) 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(), &addr.ip().to_string(),
"alias", "alias",
], ],
)?; )
.map(|_output| ())
} else { } else {
cmd( cmd(
"ifconfig", "ifconfig",
&[&real_interface, "inet6", &addr.to_string(), "alias"], &[&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(()) Ok(())
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn set_addr(interface: &InterfaceName, addr: IpNetwork) -> Result<(), Error> { pub use super::netlink::set_addr;
let interface = interface.to_string();
cmd( #[cfg(target_os = "linux")]
"ip", pub use super::netlink::set_up;
&["address", "replace", &addr.to_string(), "dev", &interface],
)?;
cmd(
"ip",
&["link", "set", "mtu", "1420", "up", "dev", &interface],
)?;
Ok(())
}
pub fn up( pub fn up(
interface: &InterfaceName, interface: &InterfaceName,
@ -88,6 +85,7 @@ pub fn up(
.set_private_key(wgctrl::Key::from_base64(&private_key).unwrap()) .set_private_key(wgctrl::Key::from_base64(&private_key).unwrap())
.apply(interface, network.backend)?; .apply(interface, network.backend)?;
set_addr(interface, address)?; set_addr(interface, address)?;
set_up(interface, 1420)?;
if !network.no_routing { if !network.no_routing {
add_route(interface, address)?; add_route(interface, address)?;
} }
@ -120,8 +118,8 @@ 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. /// 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 /// Returns an error if the process doesn't exit successfully, otherwise returns
/// true if the route was changed, false if the route already exists. /// 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<bool, Error> { pub fn add_route(interface: &InterfaceName, cidr: IpNetwork) -> Result<bool, Error> {
if cfg!(target_os = "macos") {
let real_interface = let real_interface =
wgctrl::backends::userspace::resolve_tun(interface).with_str(interface.to_string())?; wgctrl::backends::userspace::resolve_tun(interface).with_str(interface.to_string())?;
let output = cmd( let output = cmd(
@ -137,26 +135,14 @@ pub fn add_route(interface: &InterfaceName, cidr: IpNetwork) -> Result<bool, Err
)?; )?;
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() { if !output.status.success() {
Err(format!( Err(anyhow::anyhow!(
"failed to add route for device {} ({}): {}", "failed to add route for device {} ({}): {}",
&interface, real_interface, stderr &interface, real_interface, stderr
) ))
.into())
} else { } else {
Ok(!stderr.contains("File exists")) Ok(!stderr.contains("File exists"))
} }
} 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)
}
} }
#[cfg(target_os = "linux")]
pub use super::netlink::add_route;

View File

@ -165,7 +165,7 @@ impl InterfaceName {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
/// Returns a pointer to the inner byte buffer for FFI calls. /// 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() self.0.as_ptr()
} }