diff --git a/Cargo.lock b/Cargo.lock index 5e323d6..8bf05db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -667,6 +667,41 @@ version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e186cfbae8084e513daff4240b4797e342f988cecda4fb6c939150f96315fd8" +[[package]] +name = "innernet-server" +version = "1.6.1" +dependencies = [ + "anyhow", + "bytes", + "clap", + "clap_complete", + "colored", + "dialoguer", + "hyper", + "indoc", + "ipnet", + "libc", + "libsqlite3-sys", + "log", + "once_cell", + "parking_lot", + "pretty_env_logger", + "publicip", + "regex", + "rusqlite", + "serde", + "serde_json", + "shared", + "socket2", + "subtle", + "tempfile", + "thiserror", + "tokio", + "toml", + "url", + "wireguard-control", +] + [[package]] name = "ipnet" version = "2.9.0" @@ -1252,41 +1287,6 @@ dependencies = [ "serde", ] -[[package]] -name = "server" -version = "1.6.1" -dependencies = [ - "anyhow", - "bytes", - "clap", - "clap_complete", - "colored", - "dialoguer", - "hyper", - "indoc", - "ipnet", - "libc", - "libsqlite3-sys", - "log", - "once_cell", - "parking_lot", - "pretty_env_logger", - "publicip", - "regex", - "rusqlite", - "serde", - "serde_json", - "shared", - "socket2", - "subtle", - "tempfile", - "thiserror", - "tokio", - "toml", - "url", - "wireguard-control", -] - [[package]] name = "shared" version = "1.6.1" diff --git a/README.md b/README.md index de3a16c..8f4d4b7 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ brew install tonarino/innernet/innernet cargo install --git https://github.com/tonarino/innernet --tag v1.6.1 client # to install innernet-server: -cargo install --git https://github.com/tonarino/innernet --tag v1.6.1 server +cargo install --git https://github.com/tonarino/innernet --tag v1.6.1 innernet-server ``` Note that you'll be responsible for updating manually. diff --git a/server/Cargo.toml b/server/Cargo.toml index cad18d9..dde8211 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -9,12 +9,15 @@ description = "A server to coordinate innernet networks." edition = "2021" homepage = "https://github.com/tonarino/innernet" license = "MIT" -name = "server" +name = "innernet-server" publish = false readme = "README.md" repository = "https://github.com/tonarino/innernet" version = "1.6.1" +[lib] +path = "src/lib.rs" + [[bin]] name = "innernet-server" path = "src/main.rs" diff --git a/server/src/initialize.rs b/server/src/initialize.rs index f51b140..e963e2b 100644 --- a/server/src/initialize.rs +++ b/server/src/initialize.rs @@ -1,7 +1,10 @@ -use crate::*; -use anyhow::anyhow; +use crate::{ + db::{self, DatabaseCidr, DatabasePeer}, + ConfigFile, Interface, Path, ServerConfig, +}; +use anyhow::{anyhow, Error}; use clap::Parser; -use db::DatabaseCidr; +use colored::Colorize; use dialoguer::{theme::ColorfulTheme, Input}; use indoc::printdoc; use ipnet::IpNet; @@ -10,6 +13,7 @@ use rusqlite::{params, Connection}; use shared::{ prompts, CidrContents, Endpoint, IpNetExt, PeerContents, PERSISTENT_KEEPALIVE_INTERVAL_SECS, }; +use std::net::{IpAddr, SocketAddr}; use wireguard_control::KeyPair; fn create_database>( diff --git a/server/src/lib.rs b/server/src/lib.rs new file mode 100644 index 0000000..5081f48 --- /dev/null +++ b/server/src/lib.rs @@ -0,0 +1,724 @@ +use anyhow::{anyhow, bail}; +use colored::*; +use dialoguer::Confirm; +use hyper::{http, server::conn::AddrStream, Body, Request, Response}; +use indoc::printdoc; +use ipnet::IpNet; +use parking_lot::{Mutex, RwLock}; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use shared::{ + get_local_addrs, AddCidrOpts, AddPeerOpts, DeleteCidrOpts, EnableDisablePeerOpts, Endpoint, + IoErrorContext, NetworkOpts, PeerContents, RenameCidrOpts, RenamePeerOpts, + INNERNET_PUBKEY_HEADER, +}; +use std::{ + collections::{HashMap, VecDeque}, + convert::TryInto, + env, + fs::File, + io::prelude::*, + net::{IpAddr, SocketAddr, TcpListener}, + ops::Deref, + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; +use subtle::ConstantTimeEq; +use wireguard_control::{Backend, Device, DeviceUpdate, InterfaceName, Key, PeerConfigBuilder}; + +mod api; +mod db; +mod error; +pub mod initialize; +#[cfg(test)] +mod test; +mod util; + +use db::{DatabaseCidr, DatabasePeer}; +pub use error::ServerError; +use shared::{prompts, wg, CidrTree, Error, Interface}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +type Db = Arc>; +type Endpoints = Arc>>; + +#[derive(Clone)] +pub struct Context { + pub db: Db, + pub endpoints: Endpoints, + pub interface: InterfaceName, + pub backend: Backend, + pub public_key: Key, +} + +pub struct Session { + pub context: Context, + pub peer: DatabasePeer, +} + +impl Session { + pub fn admin_capable(&self) -> bool { + self.peer.is_admin && self.user_capable() + } + + pub fn user_capable(&self) -> bool { + !self.peer.is_disabled && self.peer.is_redeemed + } + + pub fn redeemable(&self) -> bool { + !self.peer.is_disabled && !self.peer.is_redeemed + } +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct ConfigFile { + /// The server's WireGuard key + pub private_key: String, + + /// The listen port of the server + pub listen_port: u16, + + /// The internal WireGuard IP address assigned to the server + pub address: IpAddr, + + /// The CIDR prefix of the WireGuard network + pub network_cidr_prefix: u8, +} + +impl ConfigFile { + pub fn write_to_path>(&self, path: P) -> Result<(), Error> { + let mut invitation_file = File::create(&path).with_path(&path)?; + shared::chmod(&invitation_file, 0o600)?; + invitation_file + .write_all(toml::to_string(self).unwrap().as_bytes()) + .with_path(path)?; + Ok(()) + } + + pub fn from_file>(path: P) -> Result { + let path = path.as_ref(); + let file = File::open(path).with_path(path)?; + if shared::chmod(&file, 0o600)? { + println!( + "{} updated permissions for {} to 0600.", + "[!]".yellow(), + path.display() + ); + } + Ok(toml::from_str( + &std::fs::read_to_string(path).with_path(path)?, + )?) + } +} + +#[derive(Clone, Debug)] +pub struct ServerConfig { + pub config_dir: PathBuf, + pub data_dir: PathBuf, +} + +impl ServerConfig { + pub fn new(config_dir: PathBuf, data_dir: PathBuf) -> Self { + Self { + config_dir, + data_dir, + } + } + + pub fn database_dir(&self) -> &Path { + &self.data_dir + } + + pub fn database_path(&self, interface: &InterfaceName) -> PathBuf { + PathBuf::new() + .join(self.database_dir()) + .join(interface.to_string()) + .with_extension("db") + } + + pub fn config_dir(&self) -> &Path { + &self.config_dir + } + + pub fn config_path(&self, interface: &InterfaceName) -> PathBuf { + PathBuf::new() + .join(self.config_dir()) + .join(interface.to_string()) + .with_extension("conf") + } +} + +fn open_database_connection( + interface: &InterfaceName, + conf: &ServerConfig, +) -> Result { + let database_path = conf.database_path(interface); + if !Path::new(&database_path).exists() { + bail!( + "no database file found at {}", + database_path.to_string_lossy() + ); + } + + let conn = Connection::open(&database_path)?; + // Foreign key constraints aren't on in SQLite by default. Enable. + conn.pragma_update(None, "foreign_keys", 1)?; + db::auto_migrate(&conn)?; + Ok(conn) +} + +pub fn add_peer( + interface: &InterfaceName, + conf: &ServerConfig, + opts: AddPeerOpts, + network: NetworkOpts, +) -> Result<(), Error> { + let config = ConfigFile::from_file(conf.config_path(interface))?; + let conn = open_database_connection(interface, conf)?; + let peers = DatabasePeer::list(&conn)? + .into_iter() + .map(|dp| dp.inner) + .collect::>(); + let cidrs = DatabaseCidr::list(&conn)?; + let cidr_tree = CidrTree::new(&cidrs[..]); + + if let Some(result) = shared::prompts::add_peer(&peers, &cidr_tree, &opts)? { + let (peer_request, keypair, target_path, mut target_file) = result; + let peer = DatabasePeer::create(&conn, peer_request)?; + if cfg!(not(test)) && Device::get(interface, network.backend).is_ok() { + // Update the current WireGuard interface with the new peers. + DeviceUpdate::new() + .add_peer(PeerConfigBuilder::from(&*peer)) + .apply(interface, network.backend) + .map_err(|_| ServerError::WireGuard)?; + + println!("adding to WireGuard interface: {}", &*peer); + } + + let server_peer = DatabasePeer::get(&conn, 1)?; + prompts::write_peer_invitation( + (&mut target_file, &target_path), + interface, + &peer, + &server_peer, + &cidr_tree, + keypair, + &SocketAddr::new(config.address, config.listen_port), + )?; + } else { + println!("exited without creating peer."); + } + + Ok(()) +} + +pub fn rename_peer( + interface: &InterfaceName, + conf: &ServerConfig, + opts: RenamePeerOpts, +) -> Result<(), Error> { + let conn = open_database_connection(interface, conf)?; + let peers = DatabasePeer::list(&conn)? + .into_iter() + .map(|dp| dp.inner) + .collect::>(); + + if let Some((peer_request, old_name)) = shared::prompts::rename_peer(&peers, &opts)? { + let mut db_peer = DatabasePeer::list(&conn)? + .into_iter() + .find(|p| p.name == old_name) + .ok_or_else(|| anyhow!("Peer not found."))?; + db_peer.update(&conn, peer_request)?; + } else { + println!("exited without creating peer."); + } + + Ok(()) +} + +pub fn enable_or_disable_peer( + interface: &InterfaceName, + conf: &ServerConfig, + enable: bool, + network: NetworkOpts, + opts: EnableDisablePeerOpts, +) -> Result<(), Error> { + let conn = open_database_connection(interface, conf)?; + let peers = DatabasePeer::list(&conn)? + .into_iter() + .map(|dp| dp.inner) + .collect::>(); + + if let Some(peer) = prompts::enable_or_disable_peer(&peers[..], &opts, enable)? { + let mut db_peer = DatabasePeer::get(&conn, peer.id)?; + db_peer.update( + &conn, + PeerContents { + is_disabled: !enable, + ..peer.contents.clone() + }, + )?; + + if enable { + DeviceUpdate::new() + .add_peer(db_peer.deref().into()) + .apply(interface, network.backend) + .map_err(|_| ServerError::WireGuard)?; + } else { + let public_key = + Key::from_base64(&peer.public_key).map_err(|_| ServerError::WireGuard)?; + + DeviceUpdate::new() + .remove_peer_by_key(&public_key) + .apply(interface, network.backend) + .map_err(|_| ServerError::WireGuard)?; + } + } else { + log::info!("exiting without enabling or disabling peer."); + } + + Ok(()) +} + +pub fn add_cidr( + interface: &InterfaceName, + conf: &ServerConfig, + opts: AddCidrOpts, +) -> Result<(), Error> { + let conn = open_database_connection(interface, conf)?; + let cidrs = DatabaseCidr::list(&conn)?; + if let Some(cidr_request) = shared::prompts::add_cidr(&cidrs, &opts)? { + let cidr = DatabaseCidr::create(&conn, cidr_request)?; + printdoc!( + " + CIDR \"{cidr_name}\" added. + + Right now, peers within {cidr_name} can only see peers in the same CIDR, and in + the special \"innernet-server\" CIDR that includes the innernet server peer. + + You'll need to add more associations for peers in diffent CIDRs to communicate. + ", + cidr_name = cidr.name.bold() + ); + } else { + println!("exited without creating CIDR."); + } + + Ok(()) +} + +pub fn rename_cidr( + interface: &InterfaceName, + conf: &ServerConfig, + opts: RenameCidrOpts, +) -> Result<(), Error> { + let conn = open_database_connection(interface, conf)?; + let cidrs = DatabaseCidr::list(&conn)?; + + if let Some((cidr_request, old_name)) = shared::prompts::rename_cidr(&cidrs, &opts)? { + let db_cidr = DatabaseCidr::list(&conn)? + .into_iter() + .find(|c| c.name == old_name) + .ok_or_else(|| anyhow!("CIDR not found."))?; + db::DatabaseCidr::from(db_cidr).update(&conn, cidr_request)?; + } else { + println!("exited without renaming CIDR."); + } + + Ok(()) +} + +pub fn delete_cidr( + interface: &InterfaceName, + conf: &ServerConfig, + args: DeleteCidrOpts, +) -> Result<(), Error> { + println!("Fetching eligible CIDRs"); + let conn = open_database_connection(interface, conf)?; + let cidrs = DatabaseCidr::list(&conn)?; + let peers = DatabasePeer::list(&conn)? + .into_iter() + .map(|dp| dp.inner) + .collect::>(); + + let cidr_id = prompts::delete_cidr(&cidrs, &peers, &args)?; + + println!("Deleting CIDR..."); + DatabaseCidr::delete(&conn, cidr_id)?; + + println!("CIDR deleted."); + + Ok(()) +} + +pub fn uninstall( + interface: &InterfaceName, + conf: &ServerConfig, + network: NetworkOpts, + yes: bool, +) -> Result<(), Error> { + if yes + || Confirm::with_theme(&*prompts::THEME) + .with_prompt(format!( + "Permanently delete network \"{}\"?", + interface.as_str_lossy().yellow() + )) + .default(false) + .interact()? + { + println!("{} bringing down interface (if up).", "[*]".dimmed()); + wg::down(interface, network.backend).ok(); + let config = conf.config_path(interface); + let data = conf.database_path(interface); + std::fs::remove_file(&config) + .with_path(&config) + .map_err(|e| println!("[!] {}", e.to_string().yellow())) + .ok(); + std::fs::remove_file(&data) + .with_path(&data) + .map_err(|e| println!("[!] {}", e.to_string().yellow())) + .ok(); + println!( + "{} network {} is uninstalled.", + "[*]".dimmed(), + interface.as_str_lossy().yellow() + ); + } + Ok(()) +} + +fn spawn_endpoint_refresher(interface: InterfaceName, network: NetworkOpts) -> Endpoints { + let endpoints = Arc::new(RwLock::new(HashMap::new())); + tokio::task::spawn({ + let endpoints = endpoints.clone(); + async move { + let mut interval = tokio::time::interval(Duration::from_secs(10)); + loop { + interval.tick().await; + if let Ok(info) = Device::get(&interface, network.backend) { + for peer in info.peers { + if let Some(endpoint) = peer.config.endpoint { + endpoints + .write() + .insert(peer.config.public_key.to_base64(), endpoint); + } + } + } + } + } + }); + endpoints +} + +fn spawn_expired_invite_sweeper(db: Db) { + tokio::task::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(10)); + loop { + interval.tick().await; + match DatabasePeer::delete_expired_invites(&db.lock()) { + Ok(deleted) if deleted > 0 => { + log::info!("Deleted {} expired peer invitations.", deleted) + }, + Err(e) => log::error!("Failed to delete expired peer invitations: {}", e), + _ => {}, + } + } + }); +} + +pub async fn serve( + interface: InterfaceName, + conf: &ServerConfig, + network: NetworkOpts, +) -> Result<(), Error> { + let config = ConfigFile::from_file(conf.config_path(&interface))?; + log::debug!("opening database connection..."); + let conn = open_database_connection(&interface, conf)?; + + let mut peers = DatabasePeer::list(&conn)?; + log::debug!("peers listed..."); + let peer_configs = peers + .iter() + .map(|peer| peer.deref().into()) + .collect::>(); + + log::info!("bringing up interface."); + wg::up( + &interface, + &config.private_key, + IpNet::new(config.address, config.network_cidr_prefix)?, + Some(config.listen_port), + None, + network, + )?; + + DeviceUpdate::new() + .add_peers(&peer_configs) + .apply(&interface, network.backend)?; + + log::info!("{} peers added to wireguard interface.", peers.len()); + + let candidates: Vec = get_local_addrs()? + .map(|addr| SocketAddr::from((addr, config.listen_port)).into()) + .collect(); + let num_candidates = candidates.len(); + let myself = peers + .iter_mut() + .find(|peer| peer.ip == config.address) + .expect("Couldn't find server peer in peer list."); + myself.update( + &conn, + PeerContents { + candidates, + ..myself.contents.clone() + }, + )?; + + log::info!( + "{} local candidates added to server peer config.", + num_candidates + ); + + let public_key = wireguard_control::Key::from_base64(&config.private_key)?.get_public(); + let db = Arc::new(Mutex::new(conn)); + let endpoints = spawn_endpoint_refresher(interface, network); + spawn_expired_invite_sweeper(db.clone()); + + let context = Context { + db, + endpoints, + interface, + public_key, + backend: network.backend, + }; + + log::info!("innernet-server {} starting.", VERSION); + + let listener = get_listener((config.address, config.listen_port).into(), &interface)?; + + let make_svc = hyper::service::make_service_fn(move |socket: &AddrStream| { + let remote_addr = socket.remote_addr(); + let context = context.clone(); + async move { + Ok::<_, http::Error>(hyper::service::service_fn(move |req: Request| { + log::debug!("{} - {} {}", &remote_addr, req.method(), req.uri()); + hyper_service(req, context.clone(), remote_addr) + })) + } + }); + + let server = hyper::Server::from_tcp(listener)?.serve(make_svc); + + server.await?; + + Ok(()) +} + +/// This function differs per OS, because different operating systems have +/// opposing characteristics when binding to a specific IP address. +/// On Linux, binding to a specific local IP address does *not* bind it to +/// that IP's interface, allowing for spoofing attacks. +/// +/// See https://github.com/tonarino/innernet/issues/26 for more details. +#[cfg(target_os = "linux")] +fn get_listener(addr: SocketAddr, interface: &InterfaceName) -> Result { + let listener = TcpListener::bind(addr)?; + listener.set_nonblocking(true)?; + let sock = socket2::Socket::from(listener); + sock.bind_device(Some(interface.as_str_lossy().as_bytes()))?; + Ok(sock.into()) +} + +/// BSD-likes do seem to bind to an interface when binding to an IP, +/// according to the internet, but we may want to explicitly use +/// IP_BOUND_IF in the future regardless. This isn't currently in +/// the socket2 crate however, so we aren't currently using it. +/// +/// See https://github.com/tonarino/innernet/issues/26 for more details. +#[cfg(not(target_os = "linux"))] +fn get_listener(addr: SocketAddr, _interface: &InterfaceName) -> Result { + let listener = TcpListener::bind(addr)?; + listener.set_nonblocking(true)?; + Ok(listener) +} + +pub(crate) async fn hyper_service( + req: Request, + context: Context, + remote_addr: SocketAddr, +) -> Result, http::Error> { + // Break the path into components. + let components: VecDeque<_> = req + .uri() + .path() + .trim_start_matches('/') + .split('/') + .map(String::from) + .collect(); + + routes(req, context, remote_addr, components) + .await + .or_else(TryInto::try_into) +} + +async fn routes( + req: Request, + context: Context, + remote_addr: SocketAddr, + mut components: VecDeque, +) -> Result, ServerError> { + // Must be "/v1/[something]" + if components.pop_front().as_deref() != Some("v1") { + Err(ServerError::NotFound) + } else { + let session = get_session(&req, context, remote_addr.ip())?; + let component = components.pop_front(); + match component.as_deref() { + Some("user") => api::user::routes(req, components, session).await, + Some("admin") => api::admin::routes(req, components, session).await, + _ => Err(ServerError::NotFound), + } + } +} + +fn get_session( + req: &Request, + context: Context, + addr: IpAddr, +) -> Result { + let pubkey = req + .headers() + .get(INNERNET_PUBKEY_HEADER) + .ok_or(ServerError::Unauthorized)?; + let pubkey = pubkey.to_str().map_err(|_| ServerError::Unauthorized)?; + let pubkey = Key::from_base64(pubkey).map_err(|_| ServerError::Unauthorized)?; + if pubkey + .as_bytes() + .ct_eq(context.public_key.as_bytes()) + .into() + { + let peer = DatabasePeer::get_from_ip(&context.db.lock(), addr).map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => ServerError::Unauthorized, + e => ServerError::Database(e), + })?; + + if !peer.is_disabled { + return Ok(Session { context, peer }); + } + } + + Err(ServerError::Unauthorized) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test; + use anyhow::Result; + use hyper::StatusCode; + use std::path::Path; + + #[test] + fn test_init_wizard() -> Result<(), Error> { + // This runs init_wizard(). + let server = test::Server::new()?; + + assert!(Path::new(&server.wg_conf_path()).exists()); + + Ok(()) + } + + #[tokio::test] + async fn test_with_session_disguised_with_headers() -> Result<(), Error> { + let server = test::Server::new()?; + + let path = if cfg!(feature = "v6-test") { + format!("http://[{}]/v1/admin/peers", test::WG_MANAGE_PEER_IP) + } else { + format!("http://{}/v1/admin/peers", test::WG_MANAGE_PEER_IP) + }; + let req = Request::builder() + .uri(path) + .header("Forwarded", format!("for={}", test::ADMIN_PEER_IP)) + .header("X-Forwarded-For", test::ADMIN_PEER_IP) + .header("X-Real-IP", test::ADMIN_PEER_IP) + .body(Body::empty()) + .unwrap(); + + // Request from an unknown IP, trying to disguise as an admin using HTTP headers. + let res = if cfg!(feature = "v6-test") { + server.raw_request("fd00:1337::1337", req).await + } else { + server.raw_request("10.80.80.80", req).await + }; + + // addr::remote() filter only look at remote_addr from TCP socket. + // HTTP headers are not considered. This also means that innernet + // server would not function behind an HTTP proxy. + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + + Ok(()) + } + + #[tokio::test] + async fn test_incorrect_public_key() -> Result<(), Error> { + let server = test::Server::new()?; + + let key = Key::generate_private().get_public(); + + let path = if cfg!(feature = "v6-test") { + format!("http://[{}]/v1/admin/peers", test::WG_MANAGE_PEER_IP) + } else { + format!("http://{}/v1/admin/peers", test::WG_MANAGE_PEER_IP) + }; + // Request from an unknown IP, trying to disguise as an admin using HTTP headers. + let req = Request::builder() + .uri(path) + .header(shared::INNERNET_PUBKEY_HEADER, key.to_base64()) + .body(Body::empty()) + .unwrap(); + let res = if cfg!(feature = "v6-test") { + server.raw_request("fd00:1337::1337", req).await + } else { + server.raw_request("10.80.80.80", req).await + }; + + // addr::remote() filter only look at remote_addr from TCP socket. + // HTTP headers are not considered. This also means that innernet + // server would not function behind an HTTP proxy. + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + + Ok(()) + } + + #[tokio::test] + async fn test_unparseable_public_key() -> Result<(), Error> { + let server = test::Server::new()?; + + let path = if cfg!(feature = "v6-test") { + format!("http://[{}]/v1/admin/peers", test::WG_MANAGE_PEER_IP) + } else { + format!("http://{}/v1/admin/peers", test::WG_MANAGE_PEER_IP) + }; + let req = Request::builder() + .uri(path) + .header(shared::INNERNET_PUBKEY_HEADER, "!!!") + .body(Body::empty()) + .unwrap(); + let res = if cfg!(feature = "v6-test") { + server.raw_request("fd00:1337::1337", req).await + } else { + server.raw_request("10.80.80.80", req).await + }; + + // addr::remote() filter only look at remote_addr from TCP socket. + // HTTP headers are not considered. This also means that innernet + // server would not function behind an HTTP proxy. + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + + Ok(()) + } +} diff --git a/server/src/main.rs b/server/src/main.rs index e1cae2e..4181470 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,49 +1,17 @@ -use anyhow::{anyhow, bail}; use clap::{Parser, Subcommand}; use colored::*; -use dialoguer::Confirm; -use hyper::{http, server::conn::AddrStream, Body, Request, Response}; -use indoc::printdoc; -use ipnet::IpNet; -use parking_lot::{Mutex, RwLock}; -use rusqlite::Connection; -use serde::{Deserialize, Serialize}; use shared::{ - get_local_addrs, AddCidrOpts, AddPeerOpts, DeleteCidrOpts, EnableDisablePeerOpts, Endpoint, - IoErrorContext, NetworkOpts, PeerContents, RenameCidrOpts, RenamePeerOpts, - INNERNET_PUBKEY_HEADER, + AddCidrOpts, AddPeerOpts, DeleteCidrOpts, EnableDisablePeerOpts, NetworkOpts, RenameCidrOpts, + RenamePeerOpts, }; -use std::{ - collections::{HashMap, VecDeque}, - convert::TryInto, - env, - fs::File, - io::prelude::*, - net::{IpAddr, SocketAddr, TcpListener}, - ops::Deref, - path::{Path, PathBuf}, - sync::Arc, - time::Duration, -}; -use subtle::ConstantTimeEq; -use wireguard_control::{Backend, Device, DeviceUpdate, InterfaceName, Key, PeerConfigBuilder}; - -pub mod api; -pub mod db; -pub mod error; -#[cfg(test)] -mod test; -pub mod util; - -mod initialize; +use std::{env, path::PathBuf}; -use db::{DatabaseCidr, DatabasePeer}; -pub use error::ServerError; -use initialize::InitializeOpts; -use shared::{prompts, wg, CidrTree, Error, Interface}; -pub use shared::{Association, AssociationContents}; - -pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +use innernet_server::{ + add_cidr, add_peer, delete_cidr, enable_or_disable_peer, + initialize::{self, InitializeOpts}, + rename_cidr, rename_peer, serve, uninstall, ServerConfig, +}; +use shared::Interface; #[derive(Debug, Parser)] #[command(name = "innernet-server", author, version, about)] @@ -150,116 +118,6 @@ enum Command { }, } -pub type Db = Arc>; -pub type Endpoints = Arc>>; - -#[derive(Clone)] -pub struct Context { - pub db: Db, - pub endpoints: Endpoints, - pub interface: InterfaceName, - pub backend: Backend, - pub public_key: Key, -} - -pub struct Session { - pub context: Context, - pub peer: DatabasePeer, -} - -impl Session { - pub fn admin_capable(&self) -> bool { - self.peer.is_admin && self.user_capable() - } - - pub fn user_capable(&self) -> bool { - !self.peer.is_disabled && self.peer.is_redeemed - } - - pub fn redeemable(&self) -> bool { - !self.peer.is_disabled && !self.peer.is_redeemed - } -} - -#[derive(Deserialize, Serialize, Debug)] -#[serde(rename_all = "kebab-case")] -pub struct ConfigFile { - /// The server's WireGuard key - pub private_key: String, - - /// The listen port of the server - pub listen_port: u16, - - /// The internal WireGuard IP address assigned to the server - pub address: IpAddr, - - /// The CIDR prefix of the WireGuard network - pub network_cidr_prefix: u8, -} - -impl ConfigFile { - pub fn write_to_path>(&self, path: P) -> Result<(), Error> { - let mut invitation_file = File::create(&path).with_path(&path)?; - shared::chmod(&invitation_file, 0o600)?; - invitation_file - .write_all(toml::to_string(self).unwrap().as_bytes()) - .with_path(path)?; - Ok(()) - } - - pub fn from_file>(path: P) -> Result { - let path = path.as_ref(); - let file = File::open(path).with_path(path)?; - if shared::chmod(&file, 0o600)? { - println!( - "{} updated permissions for {} to 0600.", - "[!]".yellow(), - path.display() - ); - } - Ok(toml::from_str( - &std::fs::read_to_string(path).with_path(path)?, - )?) - } -} - -#[derive(Clone, Debug)] -pub struct ServerConfig { - pub config_dir: PathBuf, - pub data_dir: PathBuf, -} - -impl ServerConfig { - pub fn new(config_dir: PathBuf, data_dir: PathBuf) -> Self { - Self { - config_dir, - data_dir, - } - } - - fn database_dir(&self) -> &Path { - &self.data_dir - } - - fn database_path(&self, interface: &InterfaceName) -> PathBuf { - PathBuf::new() - .join(self.database_dir()) - .join(interface.to_string()) - .with_extension("db") - } - - fn config_dir(&self) -> &Path { - &self.config_dir - } - - fn config_path(&self, interface: &InterfaceName) -> PathBuf { - PathBuf::new() - .join(self.config_dir()) - .join(interface.to_string()) - .with_extension("conf") - } -} - #[tokio::main] async fn main() -> Result<(), Box> { if env::var_os("RUST_LOG").is_none() { @@ -310,575 +168,3 @@ async fn main() -> Result<(), Box> { Ok(()) } - -fn open_database_connection( - interface: &InterfaceName, - conf: &ServerConfig, -) -> Result { - let database_path = conf.database_path(interface); - if !Path::new(&database_path).exists() { - bail!( - "no database file found at {}", - database_path.to_string_lossy() - ); - } - - let conn = Connection::open(&database_path)?; - // Foreign key constraints aren't on in SQLite by default. Enable. - conn.pragma_update(None, "foreign_keys", 1)?; - db::auto_migrate(&conn)?; - Ok(conn) -} - -fn add_peer( - interface: &InterfaceName, - conf: &ServerConfig, - opts: AddPeerOpts, - network: NetworkOpts, -) -> Result<(), Error> { - let config = ConfigFile::from_file(conf.config_path(interface))?; - let conn = open_database_connection(interface, conf)?; - let peers = DatabasePeer::list(&conn)? - .into_iter() - .map(|dp| dp.inner) - .collect::>(); - let cidrs = DatabaseCidr::list(&conn)?; - let cidr_tree = CidrTree::new(&cidrs[..]); - - if let Some(result) = shared::prompts::add_peer(&peers, &cidr_tree, &opts)? { - let (peer_request, keypair, target_path, mut target_file) = result; - let peer = DatabasePeer::create(&conn, peer_request)?; - if cfg!(not(test)) && Device::get(interface, network.backend).is_ok() { - // Update the current WireGuard interface with the new peers. - DeviceUpdate::new() - .add_peer(PeerConfigBuilder::from(&*peer)) - .apply(interface, network.backend) - .map_err(|_| ServerError::WireGuard)?; - - println!("adding to WireGuard interface: {}", &*peer); - } - - let server_peer = DatabasePeer::get(&conn, 1)?; - prompts::write_peer_invitation( - (&mut target_file, &target_path), - interface, - &peer, - &server_peer, - &cidr_tree, - keypair, - &SocketAddr::new(config.address, config.listen_port), - )?; - } else { - println!("exited without creating peer."); - } - - Ok(()) -} - -fn rename_peer( - interface: &InterfaceName, - conf: &ServerConfig, - opts: RenamePeerOpts, -) -> Result<(), Error> { - let conn = open_database_connection(interface, conf)?; - let peers = DatabasePeer::list(&conn)? - .into_iter() - .map(|dp| dp.inner) - .collect::>(); - - if let Some((peer_request, old_name)) = shared::prompts::rename_peer(&peers, &opts)? { - let mut db_peer = DatabasePeer::list(&conn)? - .into_iter() - .find(|p| p.name == old_name) - .ok_or_else(|| anyhow!("Peer not found."))?; - db_peer.update(&conn, peer_request)?; - } else { - println!("exited without creating peer."); - } - - Ok(()) -} - -fn enable_or_disable_peer( - interface: &InterfaceName, - conf: &ServerConfig, - enable: bool, - network: NetworkOpts, - opts: EnableDisablePeerOpts, -) -> Result<(), Error> { - let conn = open_database_connection(interface, conf)?; - let peers = DatabasePeer::list(&conn)? - .into_iter() - .map(|dp| dp.inner) - .collect::>(); - - if let Some(peer) = prompts::enable_or_disable_peer(&peers[..], &opts, enable)? { - let mut db_peer = DatabasePeer::get(&conn, peer.id)?; - db_peer.update( - &conn, - PeerContents { - is_disabled: !enable, - ..peer.contents.clone() - }, - )?; - - if enable { - DeviceUpdate::new() - .add_peer(db_peer.deref().into()) - .apply(interface, network.backend) - .map_err(|_| ServerError::WireGuard)?; - } else { - let public_key = - Key::from_base64(&peer.public_key).map_err(|_| ServerError::WireGuard)?; - - DeviceUpdate::new() - .remove_peer_by_key(&public_key) - .apply(interface, network.backend) - .map_err(|_| ServerError::WireGuard)?; - } - } else { - log::info!("exiting without enabling or disabling peer."); - } - - Ok(()) -} - -fn add_cidr( - interface: &InterfaceName, - conf: &ServerConfig, - opts: AddCidrOpts, -) -> Result<(), Error> { - let conn = open_database_connection(interface, conf)?; - let cidrs = DatabaseCidr::list(&conn)?; - if let Some(cidr_request) = shared::prompts::add_cidr(&cidrs, &opts)? { - let cidr = DatabaseCidr::create(&conn, cidr_request)?; - printdoc!( - " - CIDR \"{cidr_name}\" added. - - Right now, peers within {cidr_name} can only see peers in the same CIDR, and in - the special \"innernet-server\" CIDR that includes the innernet server peer. - - You'll need to add more associations for peers in diffent CIDRs to communicate. - ", - cidr_name = cidr.name.bold() - ); - } else { - println!("exited without creating CIDR."); - } - - Ok(()) -} - -fn rename_cidr( - interface: &InterfaceName, - conf: &ServerConfig, - opts: RenameCidrOpts, -) -> Result<(), Error> { - let conn = open_database_connection(interface, conf)?; - let cidrs = DatabaseCidr::list(&conn)?; - - if let Some((cidr_request, old_name)) = shared::prompts::rename_cidr(&cidrs, &opts)? { - let db_cidr = DatabaseCidr::list(&conn)? - .into_iter() - .find(|c| c.name == old_name) - .ok_or_else(|| anyhow!("CIDR not found."))?; - db::DatabaseCidr::from(db_cidr).update(&conn, cidr_request)?; - } else { - println!("exited without renaming CIDR."); - } - - Ok(()) -} - -fn delete_cidr( - interface: &InterfaceName, - conf: &ServerConfig, - args: DeleteCidrOpts, -) -> Result<(), Error> { - println!("Fetching eligible CIDRs"); - let conn = open_database_connection(interface, conf)?; - let cidrs = DatabaseCidr::list(&conn)?; - let peers = DatabasePeer::list(&conn)? - .into_iter() - .map(|dp| dp.inner) - .collect::>(); - - let cidr_id = prompts::delete_cidr(&cidrs, &peers, &args)?; - - println!("Deleting CIDR..."); - DatabaseCidr::delete(&conn, cidr_id)?; - - println!("CIDR deleted."); - - Ok(()) -} - -fn uninstall( - interface: &InterfaceName, - conf: &ServerConfig, - network: NetworkOpts, - yes: bool, -) -> Result<(), Error> { - if yes - || Confirm::with_theme(&*prompts::THEME) - .with_prompt(format!( - "Permanently delete network \"{}\"?", - interface.as_str_lossy().yellow() - )) - .default(false) - .interact()? - { - println!("{} bringing down interface (if up).", "[*]".dimmed()); - wg::down(interface, network.backend).ok(); - let config = conf.config_path(interface); - let data = conf.database_path(interface); - std::fs::remove_file(&config) - .with_path(&config) - .map_err(|e| println!("[!] {}", e.to_string().yellow())) - .ok(); - std::fs::remove_file(&data) - .with_path(&data) - .map_err(|e| println!("[!] {}", e.to_string().yellow())) - .ok(); - println!( - "{} network {} is uninstalled.", - "[*]".dimmed(), - interface.as_str_lossy().yellow() - ); - } - Ok(()) -} - -fn spawn_endpoint_refresher(interface: InterfaceName, network: NetworkOpts) -> Endpoints { - let endpoints = Arc::new(RwLock::new(HashMap::new())); - tokio::task::spawn({ - let endpoints = endpoints.clone(); - async move { - let mut interval = tokio::time::interval(Duration::from_secs(10)); - loop { - interval.tick().await; - if let Ok(info) = Device::get(&interface, network.backend) { - for peer in info.peers { - if let Some(endpoint) = peer.config.endpoint { - endpoints - .write() - .insert(peer.config.public_key.to_base64(), endpoint); - } - } - } - } - } - }); - endpoints -} - -fn spawn_expired_invite_sweeper(db: Db) { - tokio::task::spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(10)); - loop { - interval.tick().await; - match DatabasePeer::delete_expired_invites(&db.lock()) { - Ok(deleted) if deleted > 0 => { - log::info!("Deleted {} expired peer invitations.", deleted) - }, - Err(e) => log::error!("Failed to delete expired peer invitations: {}", e), - _ => {}, - } - } - }); -} - -async fn serve( - interface: InterfaceName, - conf: &ServerConfig, - network: NetworkOpts, -) -> Result<(), Error> { - let config = ConfigFile::from_file(conf.config_path(&interface))?; - log::debug!("opening database connection..."); - let conn = open_database_connection(&interface, conf)?; - - let mut peers = DatabasePeer::list(&conn)?; - log::debug!("peers listed..."); - let peer_configs = peers - .iter() - .map(|peer| peer.deref().into()) - .collect::>(); - - log::info!("bringing up interface."); - wg::up( - &interface, - &config.private_key, - IpNet::new(config.address, config.network_cidr_prefix)?, - Some(config.listen_port), - None, - network, - )?; - - DeviceUpdate::new() - .add_peers(&peer_configs) - .apply(&interface, network.backend)?; - - log::info!("{} peers added to wireguard interface.", peers.len()); - - let candidates: Vec = get_local_addrs()? - .map(|addr| SocketAddr::from((addr, config.listen_port)).into()) - .collect(); - let num_candidates = candidates.len(); - let myself = peers - .iter_mut() - .find(|peer| peer.ip == config.address) - .expect("Couldn't find server peer in peer list."); - myself.update( - &conn, - PeerContents { - candidates, - ..myself.contents.clone() - }, - )?; - - log::info!( - "{} local candidates added to server peer config.", - num_candidates - ); - - let public_key = wireguard_control::Key::from_base64(&config.private_key)?.get_public(); - let db = Arc::new(Mutex::new(conn)); - let endpoints = spawn_endpoint_refresher(interface, network); - spawn_expired_invite_sweeper(db.clone()); - - let context = Context { - db, - endpoints, - interface, - public_key, - backend: network.backend, - }; - - log::info!("innernet-server {} starting.", VERSION); - - let listener = get_listener((config.address, config.listen_port).into(), &interface)?; - - let make_svc = hyper::service::make_service_fn(move |socket: &AddrStream| { - let remote_addr = socket.remote_addr(); - let context = context.clone(); - async move { - Ok::<_, http::Error>(hyper::service::service_fn(move |req: Request| { - log::debug!("{} - {} {}", &remote_addr, req.method(), req.uri()); - hyper_service(req, context.clone(), remote_addr) - })) - } - }); - - let server = hyper::Server::from_tcp(listener)?.serve(make_svc); - - server.await?; - - Ok(()) -} - -/// This function differs per OS, because different operating systems have -/// opposing characteristics when binding to a specific IP address. -/// On Linux, binding to a specific local IP address does *not* bind it to -/// that IP's interface, allowing for spoofing attacks. -/// -/// See https://github.com/tonarino/innernet/issues/26 for more details. -#[cfg(target_os = "linux")] -fn get_listener(addr: SocketAddr, interface: &InterfaceName) -> Result { - let listener = TcpListener::bind(addr)?; - listener.set_nonblocking(true)?; - let sock = socket2::Socket::from(listener); - sock.bind_device(Some(interface.as_str_lossy().as_bytes()))?; - Ok(sock.into()) -} - -/// BSD-likes do seem to bind to an interface when binding to an IP, -/// according to the internet, but we may want to explicitly use -/// IP_BOUND_IF in the future regardless. This isn't currently in -/// the socket2 crate however, so we aren't currently using it. -/// -/// See https://github.com/tonarino/innernet/issues/26 for more details. -#[cfg(not(target_os = "linux"))] -fn get_listener(addr: SocketAddr, _interface: &InterfaceName) -> Result { - let listener = TcpListener::bind(addr)?; - listener.set_nonblocking(true)?; - Ok(listener) -} - -pub(crate) async fn hyper_service( - req: Request, - context: Context, - remote_addr: SocketAddr, -) -> Result, http::Error> { - // Break the path into components. - let components: VecDeque<_> = req - .uri() - .path() - .trim_start_matches('/') - .split('/') - .map(String::from) - .collect(); - - routes(req, context, remote_addr, components) - .await - .or_else(TryInto::try_into) -} - -async fn routes( - req: Request, - context: Context, - remote_addr: SocketAddr, - mut components: VecDeque, -) -> Result, ServerError> { - // Must be "/v1/[something]" - if components.pop_front().as_deref() != Some("v1") { - Err(ServerError::NotFound) - } else { - let session = get_session(&req, context, remote_addr.ip())?; - let component = components.pop_front(); - match component.as_deref() { - Some("user") => api::user::routes(req, components, session).await, - Some("admin") => api::admin::routes(req, components, session).await, - _ => Err(ServerError::NotFound), - } - } -} - -fn get_session( - req: &Request, - context: Context, - addr: IpAddr, -) -> Result { - let pubkey = req - .headers() - .get(INNERNET_PUBKEY_HEADER) - .ok_or(ServerError::Unauthorized)?; - let pubkey = pubkey.to_str().map_err(|_| ServerError::Unauthorized)?; - let pubkey = Key::from_base64(pubkey).map_err(|_| ServerError::Unauthorized)?; - if pubkey - .as_bytes() - .ct_eq(context.public_key.as_bytes()) - .into() - { - let peer = DatabasePeer::get_from_ip(&context.db.lock(), addr).map_err(|e| match e { - rusqlite::Error::QueryReturnedNoRows => ServerError::Unauthorized, - e => ServerError::Database(e), - })?; - - if !peer.is_disabled { - return Ok(Session { context, peer }); - } - } - - Err(ServerError::Unauthorized) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test; - use anyhow::Result; - use hyper::StatusCode; - use std::path::Path; - - #[test] - fn test_init_wizard() -> Result<(), Error> { - // This runs init_wizard(). - let server = test::Server::new()?; - - assert!(Path::new(&server.wg_conf_path()).exists()); - - Ok(()) - } - - #[tokio::test] - async fn test_with_session_disguised_with_headers() -> Result<(), Error> { - let server = test::Server::new()?; - - let path = if cfg!(feature = "v6-test") { - format!("http://[{}]/v1/admin/peers", test::WG_MANAGE_PEER_IP) - } else { - format!("http://{}/v1/admin/peers", test::WG_MANAGE_PEER_IP) - }; - let req = Request::builder() - .uri(path) - .header("Forwarded", format!("for={}", test::ADMIN_PEER_IP)) - .header("X-Forwarded-For", test::ADMIN_PEER_IP) - .header("X-Real-IP", test::ADMIN_PEER_IP) - .body(Body::empty()) - .unwrap(); - - // Request from an unknown IP, trying to disguise as an admin using HTTP headers. - let res = if cfg!(feature = "v6-test") { - server.raw_request("fd00:1337::1337", req).await - } else { - server.raw_request("10.80.80.80", req).await - }; - - // addr::remote() filter only look at remote_addr from TCP socket. - // HTTP headers are not considered. This also means that innernet - // server would not function behind an HTTP proxy. - assert_eq!(res.status(), StatusCode::UNAUTHORIZED); - - Ok(()) - } - - #[tokio::test] - async fn test_incorrect_public_key() -> Result<(), Error> { - let server = test::Server::new()?; - - let key = Key::generate_private().get_public(); - - let path = if cfg!(feature = "v6-test") { - format!("http://[{}]/v1/admin/peers", test::WG_MANAGE_PEER_IP) - } else { - format!("http://{}/v1/admin/peers", test::WG_MANAGE_PEER_IP) - }; - // Request from an unknown IP, trying to disguise as an admin using HTTP headers. - let req = Request::builder() - .uri(path) - .header(shared::INNERNET_PUBKEY_HEADER, key.to_base64()) - .body(Body::empty()) - .unwrap(); - let res = if cfg!(feature = "v6-test") { - server.raw_request("fd00:1337::1337", req).await - } else { - server.raw_request("10.80.80.80", req).await - }; - - // addr::remote() filter only look at remote_addr from TCP socket. - // HTTP headers are not considered. This also means that innernet - // server would not function behind an HTTP proxy. - assert_eq!(res.status(), StatusCode::UNAUTHORIZED); - - Ok(()) - } - - #[tokio::test] - async fn test_unparseable_public_key() -> Result<(), Error> { - let server = test::Server::new()?; - - let path = if cfg!(feature = "v6-test") { - format!("http://[{}]/v1/admin/peers", test::WG_MANAGE_PEER_IP) - } else { - format!("http://{}/v1/admin/peers", test::WG_MANAGE_PEER_IP) - }; - let req = Request::builder() - .uri(path) - .header(shared::INNERNET_PUBKEY_HEADER, "!!!") - .body(Body::empty()) - .unwrap(); - let res = if cfg!(feature = "v6-test") { - server.raw_request("fd00:1337::1337", req).await - } else { - server.raw_request("10.80.80.80", req).await - }; - - // addr::remote() filter only look at remote_addr from TCP socket. - // HTTP headers are not considered. This also means that innernet - // server would not function behind an HTTP proxy. - assert_eq!(res.status(), StatusCode::UNAUTHORIZED); - - Ok(()) - } -}